Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 56 additions & 1 deletion apps/app/src/app/lib/den.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,15 @@ export type DenWorkerTokens = {
workspaceId: string | null;
};

export type DenStaticWorkerAttachInput = {
name: string;
description?: string | null;
url: string;
clientToken: string;
hostToken: string;
activityToken?: string | null;
};

export type DenOrgLlmProviderModel = {
id: string;
name: string;
Expand All @@ -124,14 +133,18 @@ export type DenOrgLlmProvider = {
providerId: string;
name: string;
providerConfig: Record<string, unknown>;
credentialKind: "api_key" | "opencode_oauth";
hasApiKey: boolean;
hasOpencodeAuth: boolean;
hasCredential: boolean;
models: DenOrgLlmProviderModel[];
createdAt: string | null;
updatedAt: string | null;
};

export type DenOrgLlmProviderConnection = DenOrgLlmProvider & {
apiKey: string | null;
opencodeAuth: string | null;
};

export type DenPluginConfigObjectType = "skill" | "agent" | "command" | "tool" | "mcp" | "hook" | "context" | "custom";
Expand Down Expand Up @@ -452,8 +465,20 @@ function syncBootstrapSettingsToLocalStorage(config: DenBootstrapConfig) {
return;
}

const previousBaseUrl = window.localStorage.getItem(STORAGE_BASE_URL);
const previousOrigin = normalizeDenBaseUrl(previousBaseUrl) ?? "";
const nextOrigin = normalizeDenBaseUrl(config.baseUrl) ?? "";
const denOriginChanged = Boolean(previousOrigin && nextOrigin && previousOrigin !== nextOrigin);

window.localStorage.setItem(STORAGE_BASE_URL, config.baseUrl);
window.localStorage.setItem(STORAGE_API_BASE_URL, config.apiBaseUrl);

if (denOriginChanged) {
window.localStorage.removeItem(STORAGE_AUTH_TOKEN);
window.localStorage.removeItem(STORAGE_ACTIVE_ORG_ID);
window.localStorage.removeItem(STORAGE_ACTIVE_ORG_SLUG);
window.localStorage.removeItem(STORAGE_ACTIVE_ORG_NAME);
}
}

function getPendingBootstrapConfig(next: DenSettings): DenBootstrapConfig | null {
Expand Down Expand Up @@ -910,7 +935,10 @@ function parseDenOrgLlmProvider(value: unknown): DenOrgLlmProvider | null {
providerId: value.providerId,
name: value.name,
providerConfig: isRecord(value.providerConfig) ? value.providerConfig : {},
credentialKind: value.credentialKind === "opencode_oauth" ? "opencode_oauth" : "api_key",
hasApiKey: value.hasApiKey === true,
hasOpencodeAuth: value.hasOpencodeAuth === true,
hasCredential: value.hasCredential === true || value.hasApiKey === true || value.hasOpencodeAuth === true,
models: Array.isArray(value.models)
? value.models.flatMap((model) => {
const parsed = parseDenOrgLlmProviderModel(model);
Expand Down Expand Up @@ -946,6 +974,7 @@ function getDenOrgLlmProviderConnection(payload: unknown): DenOrgLlmProviderConn
return {
...provider,
apiKey: typeof payload.llmProvider.apiKey === "string" ? payload.llmProvider.apiKey : null,
opencodeAuth: typeof payload.llmProvider.opencodeAuth === "string" ? payload.llmProvider.opencodeAuth : null,
};
}

Expand Down Expand Up @@ -1684,6 +1713,32 @@ export function createDenClient(options: { baseUrl: string; apiBaseUrl?: string
return tokens;
},

async attachStaticWorker(orgId: string, input: DenStaticWorkerAttachInput): Promise<DenWorkerSummary> {
const payload = await requestJson<unknown>(baseUrls, "/v1/workers/static-attach", {
method: "POST",
token,
organizationId: orgId,
body: {
name: input.name,
description: input.description ?? undefined,
url: input.url,
clientToken: input.clientToken,
hostToken: input.hostToken,
activityToken: input.activityToken ?? undefined,
},
});
const workers = getWorkers({
workers: isRecord(payload) && isRecord(payload.worker)
? [{ ...payload.worker, instance: isRecord(payload.instance) ? payload.instance : null }]
: [],
});
const worker = workers[0];
if (!worker) {
throw new DenApiError(500, "invalid_worker_attach_payload", "Static worker attach response was missing worker details.");
}
return worker;
},

async listOrgSkills(orgId: string): Promise<DenOrgSkillCard[]> {
const payload = await requestJson<unknown>(baseUrls, "/v1/skills", {
method: "GET",
Expand Down Expand Up @@ -1757,7 +1812,7 @@ export function createDenClient(options: { baseUrl: string; apiBaseUrl?: string
async getOrgLlmProviderConnection(orgId: string, llmProviderId: string): Promise<DenOrgLlmProviderConnection> {
const payload = await requestJson<unknown>(
baseUrls,
`/v1/llm-providers/${encodeURIComponent(llmProviderId)}/connect`,
`/v1/llm-providers/${encodeURIComponent(llmProviderId)}/import-credential`,
{
method: "GET",
token,
Expand Down
3 changes: 3 additions & 0 deletions apps/app/src/app/lib/desktop-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,9 @@ export type WorkspaceInfo = {
openworkHostToken?: string | null;
openworkWorkspaceId?: string | null;
openworkWorkspaceName?: string | null;
openworkDenBaseUrl?: string | null;
openworkDenOrgId?: string | null;
openworkDenWorkerId?: string | null;
sandboxBackend?: "docker" | "microsandbox" | null;
sandboxRunId?: string | null;
sandboxContainerName?: string | null;
Expand Down
26 changes: 24 additions & 2 deletions apps/app/src/app/lib/openwork-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,28 @@ export function parseOpenworkWorkspaceIdFromUrl(input: string) {
}
}

export function stripOpenworkWorkspaceMount(input: string) {
const normalized = normalizeOpenworkServerUrl(input) ?? "";
if (!normalized) return "";

try {
const url = new URL(normalized);
const segments = url.pathname.split("/").filter(Boolean);
const workspaceIndex = segments.indexOf("workspace");
const legacyIndex = segments.indexOf("w");
const mountIndex = workspaceIndex >= 0 ? workspaceIndex : legacyIndex;
if (mountIndex >= 0 && segments[mountIndex + 1]) {
const prefix = segments.slice(0, mountIndex).join("/");
url.pathname = prefix ? `/${prefix}` : "/";
return url.toString().replace(/\/+$/, "");
}
} catch {
// Fall through to the normalized value below.
}

return normalized.replace(/\/+$/, "");
}

export function buildOpenworkWorkspaceBaseUrl(hostUrl: string, workspaceId?: string | null) {
const normalized = normalizeOpenworkServerUrl(hostUrl) ?? "";
if (!normalized) return null;
Expand Down Expand Up @@ -564,7 +586,7 @@ export function stripOpenworkConnectInviteFromUrl(input: string) {
export function readOpenworkServerSettings(): OpenworkServerSettings {
if (typeof window === "undefined") return {};
try {
const urlOverride = normalizeOpenworkServerUrl(
const urlOverride = stripOpenworkWorkspaceMount(
window.localStorage.getItem(STORAGE_URL_OVERRIDE) ?? "",
);
const portRaw = window.localStorage.getItem(STORAGE_PORT_OVERRIDE) ?? "";
Expand All @@ -573,7 +595,7 @@ export function readOpenworkServerSettings(): OpenworkServerSettings {
const hostToken = window.localStorage.getItem(STORAGE_HOST_AUTH_KEY) ?? undefined;
const remoteAccessRaw = window.localStorage.getItem(STORAGE_REMOTE_ACCESS) ?? "";
return {
urlOverride: urlOverride ?? undefined,
urlOverride: urlOverride || undefined,
portOverride: Number.isNaN(portOverride) ? undefined : portOverride,
token: token?.trim() || undefined,
hostToken: hostToken?.trim() || undefined,
Expand Down
13 changes: 12 additions & 1 deletion apps/app/src/app/lib/workspace-endpoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ export type ResolvedWorkspaceEndpoint = {
baseUrl: string;
/** Auth token for that server. May be empty for unauthenticated local servers. */
token: string;
/** Host/admin token for routes that require worker mutation privileges. */
hostToken: string;
/** Workspace id as the owning server expects it in URL paths. No `rem_` prefix. */
workspaceId: string;
/** True when the workspace lives on a remote OpenWork worker, not the user's local server. */
Expand Down Expand Up @@ -93,13 +95,18 @@ function pickRemoteBaseUrl(workspace: WorkspaceEndpointInput): string {
function pickRemoteToken(workspace: WorkspaceEndpointInput): string {
if (!workspace) return "";
return (
workspace.openworkToken ??
workspace.openworkClientToken ??
workspace.openworkToken ??
workspace.openworkHostToken ??
""
).trim();
}

function pickRemoteHostToken(workspace: WorkspaceEndpointInput): string {
if (!workspace) return "";
return (workspace.openworkHostToken ?? "").trim();
}

/**
* Resolve the right server endpoint for a workspace. Returns null when the
* workspace can't be reached (remote with no baseUrl, or local with no local
Expand All @@ -116,17 +123,20 @@ export function resolveWorkspaceEndpoint(
const baseUrl = pickRemoteBaseUrl(workspace);
if (!baseUrl) return null;
const token = pickRemoteToken(workspace);
const hostToken = pickRemoteHostToken(workspace);
const workspaceId = workspaceServerId(workspace);
const client = createOpenworkServerClient({
baseUrl,
token: token || undefined,
hostToken: hostToken || undefined,
});
const mountedBaseUrl = (
buildOpenworkWorkspaceBaseUrl(baseUrl, workspaceId) ?? baseUrl
).replace(/\/+$/, "");
return {
baseUrl,
token,
hostToken,
workspaceId,
isRemote: true,
client,
Expand All @@ -149,6 +159,7 @@ export function resolveWorkspaceEndpoint(
return {
baseUrl: localBaseUrl,
token: localToken,
hostToken: "",
workspaceId,
isRemote: false,
client,
Expand Down
104 changes: 101 additions & 3 deletions apps/app/src/react-app/domains/settings/pages/cloud-workers-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import * as React from "react";

import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import { t } from "@/i18n";
import { useStatusToasts } from "../../shell-feedback/status-toasts";
Expand All @@ -13,6 +14,11 @@ export type CloudWorkersViewProps = {
connectRemoteWorkspace: (input: {
openworkHostUrl?: string | null;
openworkToken?: string | null;
openworkClientToken?: string | null;
openworkHostToken?: string | null;
openworkDenBaseUrl?: string | null;
openworkDenOrgId?: string | null;
openworkDenWorkerId?: string | null;
directory?: string | null;
displayName?: string | null;
}) => Promise<boolean>;
Expand All @@ -23,12 +29,19 @@ export function CloudWorkersView({
connectRemoteWorkspace,
onOpenAccount,
}: CloudWorkersViewProps) {
const { activeOrganization: activeOrg, authToken, client, isSignedIn, user } = useCloudSession();
const { activeOrganization: activeOrg, authToken, baseUrl, client, isSignedIn, user } = useCloudSession();
const { showToast } = useStatusToasts();
const [workersBusy, setWorkersBusy] = React.useState(false);
const [openingWorkerId, setOpeningWorkerId] = React.useState<string | null>(null);
const [attachBusy, setAttachBusy] = React.useState(false);
const [workers, setWorkers] = React.useState<CloudWorker[]>([]);
const [workersError, setWorkersError] = React.useState<string | null>(null);
const [staticWorkerForm, setStaticWorkerForm] = React.useState({
name: "LAN static worker",
url: "",
clientToken: "",
hostToken: "",
});
const activeOrgId = activeOrg?.id ?? "";

const refreshWorkers = React.useCallback(
Expand Down Expand Up @@ -84,14 +97,19 @@ export function CloudWorkersView({
try {
const tokens = await client.getWorkerTokens(workerId, activeOrgId);
const openworkUrl = tokens.openworkUrl?.trim() ?? "";
const accessToken = tokens.ownerToken?.trim() || tokens.clientToken?.trim() || "";
const accessToken = tokens.clientToken?.trim() || tokens.ownerToken?.trim() || "";
if (!openworkUrl || !accessToken) {
throw new Error(t("den.error_worker_not_ready"));
}

const ok = await connectRemoteWorkspace({
openworkHostUrl: openworkUrl,
openworkToken: accessToken,
openworkClientToken: tokens.clientToken?.trim() || null,
openworkHostToken: tokens.hostToken?.trim() || null,
openworkDenBaseUrl: baseUrl,
openworkDenOrgId: activeOrgId,
openworkDenWorkerId: workerId,
directory: null,
displayName: workerName,
});
Expand All @@ -113,9 +131,50 @@ export function CloudWorkersView({
setOpeningWorkerId(null);
}
},
[activeOrgId, client, connectRemoteWorkspace, showToast],
[activeOrgId, baseUrl, client, connectRemoteWorkspace, showToast],
);

const attachStaticWorker = React.useCallback(async () => {
if (!activeOrgId) {
setWorkersError(t("den.error_choose_org"));
return;
}

const name = staticWorkerForm.name.trim();
const url = staticWorkerForm.url.trim();
const clientToken = staticWorkerForm.clientToken.trim();
const hostToken = staticWorkerForm.hostToken.trim();
if (!name || !url || !clientToken || !hostToken) {
setWorkersError("Name, URL, client token, and host token are required to attach a static worker.");
return;
}

setAttachBusy(true);
setWorkersError(null);
try {
const worker = await client.attachStaticWorker(activeOrgId, {
name,
url,
clientToken,
hostToken,
});
setWorkers((current) => [worker, ...current.filter((entry) => entry.workerId !== worker.workerId)]);
setStaticWorkerForm((current) => ({ ...current, url: "", clientToken: "", hostToken: "" }));
showToast({
title: `Attached ${worker.workerName}`,
tone: "success",
});
void refreshWorkers(true);
} catch (error) {
const status = typeof error === "object" && error !== null && "status" in error ? Number((error as { status?: unknown }).status) : null;
setWorkersError(status === 403
? "Only organization owners and admins can attach static workers. Ask an operator to register this worker."
: error instanceof Error ? error.message : "Static worker attach failed.");
} finally {
setAttachBusy(false);
}
}, [activeOrgId, client, refreshWorkers, showToast, staticWorkerForm]);

if (!isSignedIn) {
return (
<SettingsStack>
Expand All @@ -135,6 +194,45 @@ export function CloudWorkersView({
return (
<SettingsStack>
<Separator />
<SettingsNotice>
<div className="flex flex-col gap-3">
<div>
<div className="text-sm font-medium">Admin/operator: attach LAN static worker</div>
<div className="text-xs text-muted-foreground">
Organization owners and admins can register a pre-running OpenWork worker without manual database changes. The URL and tokens must match the worker container environment.
</div>
</div>
<div className="grid gap-2 md:grid-cols-2">
<Input
value={staticWorkerForm.name}
onChange={(event) => setStaticWorkerForm((current) => ({ ...current, name: event.currentTarget.value }))}
placeholder="Worker name"
/>
<Input
value={staticWorkerForm.url}
onChange={(event) => setStaticWorkerForm((current) => ({ ...current, url: event.currentTarget.value }))}
placeholder="http://192.168.1.50:8787"
/>
<Input
value={staticWorkerForm.clientToken}
onChange={(event) => setStaticWorkerForm((current) => ({ ...current, clientToken: event.currentTarget.value }))}
placeholder="OPENWORK_TOKEN"
type="password"
/>
<Input
value={staticWorkerForm.hostToken}
onChange={(event) => setStaticWorkerForm((current) => ({ ...current, hostToken: event.currentTarget.value }))}
placeholder="OPENWORK_HOST_TOKEN"
type="password"
/>
</div>
<div>
<Button size="sm" onClick={() => void attachStaticWorker()} disabled={attachBusy || workersBusy || !activeOrgId}>
{attachBusy ? "Attaching..." : "Attach static worker"}
</Button>
</div>
</div>
</SettingsNotice>
<CloudWorkersSection
openingWorkerId={openingWorkerId}
workers={workers}
Expand Down
Loading