diff --git a/apps/app/src/app/app.tsx b/apps/app/src/app/app.tsx index 36105c7f0..3a311bc48 100644 --- a/apps/app/src/app/app.tsx +++ b/apps/app/src/app/app.tsx @@ -119,16 +119,19 @@ import { readStoredFontZoom, } from "./lib/font-zoom"; import { + createOpenworkServerClient, parseOpenworkWorkspaceIdFromUrl, readOpenworkConnectInviteFromSearch, stripOpenworkConnectInviteFromUrl, hydrateOpenworkServerSettingsFromEnv, + normalizeOpenworkServerHostUrl, normalizeOpenworkServerUrl, readOpenworkServerSettings, writeOpenworkServerSettings, type OpenworkServerDiagnostics, type OpenworkServerSettings, } from "./lib/openwork-server"; +import { createDenClient } from "./lib/den"; import { parseBundleDeepLink, stripBundleQuery, @@ -251,13 +254,54 @@ export default function App() { const invite = readOpenworkConnectInviteFromSearch(window.location.search); const bundleInvite = parseBundleDeepLink(window.location.href); - if (!invite) { - setOpenworkServerSettings(stored); - } else { + if (bundleInvite?.bundleUrl) { + bundlesStore.queueBundleLink(window.location.href); + } + + const cleanedConnect = stripOpenworkConnectInviteFromUrl(window.location.href); + const cleaned = stripBundleQuery(cleanedConnect) ?? cleanedConnect; + if (cleaned !== window.location.href) { + window.history.replaceState(window.history.state ?? null, "", cleaned); + } + + void (async () => { + if (!invite) { + setOpenworkServerSettings(stored); + return; + } + + let inviteUrl = invite.url; + let inviteToken = stored.token; + + if (!invite.grant && /(?:^|[?&])ow_token=/.test(window.location.search)) { + setError("Legacy OpenWork invite links with embedded tokens are no longer supported. Create a new one-time connect link."); + setOpenworkServerSettings(stored); + return; + } + + if (invite.grant) { + try { + const exchange = invite.denBaseUrl + ? await createDenClient({ baseUrl: invite.denBaseUrl }).exchangeWorkerConnectGrant(invite.grant) + : await createOpenworkServerClient({ + baseUrl: normalizeOpenworkServerHostUrl(invite.url) ?? invite.url, + }).exchangeConnectGrant(invite.grant); + inviteUrl = exchange.openworkUrl?.trim() || inviteUrl; + inviteToken = exchange.token?.trim() || inviteToken; + if (!inviteToken) { + throw new Error("This OpenWork connect link did not return a usable token."); + } + } catch (error) { + setError(error instanceof Error ? error.message : "Failed to redeem the OpenWork connect link."); + setOpenworkServerSettings(stored); + return; + } + } + const merged: OpenworkServerSettings = { ...stored, - urlOverride: invite.url, - token: invite.token ?? stored.token, + urlOverride: inviteUrl, + token: inviteToken, }; const next = writeOpenworkServerSettings(merged); @@ -267,27 +311,17 @@ export default function App() { setStartupPreference("server"); setOnboardingStep("server"); } - } - - if (bundleInvite?.bundleUrl) { - bundlesStore.queueBundleLink(window.location.href); - } - - if (invite?.autoConnect) { - deepLinks.queueRemoteConnectDefaults({ - openworkHostUrl: invite.url, - openworkToken: invite.token ?? null, - directory: null, - displayName: null, - autoConnect: true, - }); - } - const cleanedConnect = stripOpenworkConnectInviteFromUrl(window.location.href); - const cleaned = stripBundleQuery(cleanedConnect) ?? cleanedConnect; - if (cleaned !== window.location.href) { - window.history.replaceState(window.history.state ?? null, "", cleaned); - } + if (invite.autoConnect) { + deepLinks.queueRemoteConnectDefaults({ + openworkHostUrl: inviteUrl, + openworkToken: inviteToken ?? null, + directory: null, + displayName: null, + autoConnect: true, + }); + } + })(); }); createEffect(() => { diff --git a/apps/app/src/app/connections/openwork-server-store.ts b/apps/app/src/app/connections/openwork-server-store.ts index b761e5b09..cf4201a6d 100644 --- a/apps/app/src/app/connections/openwork-server-store.ts +++ b/apps/app/src/app/connections/openwork-server-store.ts @@ -30,6 +30,8 @@ export type OpenworkServerStore = ReturnType; type RemoteWorkspaceInput = { openworkHostUrl: string; openworkToken?: string | null; + openworkConnectGrant?: string | null; + denBaseUrl?: string | null; directory?: string | null; displayName?: string | null; }; diff --git a/apps/app/src/app/context/workspace.ts b/apps/app/src/app/context/workspace.ts index ad98f6dc3..c4c245cec 100644 --- a/apps/app/src/app/context/workspace.ts +++ b/apps/app/src/app/context/workspace.ts @@ -27,12 +27,14 @@ import { blueprintMaterializedSessions, blueprintSessions, defaultBlueprintSessi import { buildOpenworkWorkspaceBaseUrl, createOpenworkServerClient, + normalizeOpenworkServerHostUrl, normalizeOpenworkServerUrl, parseOpenworkWorkspaceIdFromUrl, OpenworkServerError, type OpenworkServerClient, type OpenworkWorkspaceInfo, } from "../lib/openwork-server"; +import { createDenClient } from "../lib/den"; import { downloadDir, homeDir } from "@tauri-apps/api/path"; import { engineDoctor, @@ -2468,6 +2470,8 @@ export function createWorkspaceStore(options: { async function createRemoteWorkspaceFlow(input: { openworkHostUrl?: string | null; openworkToken?: string | null; + openworkConnectGrant?: string | null; + denBaseUrl?: string | null; openworkClientToken?: string | null; openworkHostToken?: string | null; directory?: string | null; @@ -2489,8 +2493,10 @@ export function createWorkspaceStore(options: { } const run = (async () => { - const hostUrl = normalizeOpenworkServerUrl(input.openworkHostUrl ?? "") ?? ""; - const token = input.openworkToken?.trim() ?? ""; + let hostUrl = normalizeOpenworkServerUrl(input.openworkHostUrl ?? "") ?? ""; + let token = input.openworkToken?.trim() ?? ""; + const connectGrant = input.openworkConnectGrant?.trim() ?? ""; + const denBaseUrl = input.denBaseUrl?.trim() ?? ""; const directory = input.directory?.trim() ?? ""; const displayName = input.displayName?.trim() || null; @@ -2499,6 +2505,27 @@ export function createWorkspaceStore(options: { return false; } + if (connectGrant) { + try { + const exchange = denBaseUrl + ? await createDenClient({ baseUrl: denBaseUrl }).exchangeWorkerConnectGrant(connectGrant) + : await createOpenworkServerClient({ + baseUrl: normalizeOpenworkServerHostUrl(hostUrl) ?? hostUrl, + }).exchangeConnectGrant(connectGrant); + hostUrl = normalizeOpenworkServerUrl(exchange.openworkUrl ?? hostUrl) ?? hostUrl; + token = exchange.token?.trim() ?? ""; + } catch (error) { + const message = error instanceof Error ? error.message : safeStringify(error); + options.setError(addOpencodeCacheHint(message)); + return false; + } + + if (!token) { + options.setError("Remote connect link did not return a usable token."); + return false; + } + } + options.setError(null); console.log("[workspace] create remote request", { hostUrl: hostUrl || null, diff --git a/apps/app/src/app/lib/den.ts b/apps/app/src/app/lib/den.ts index d4f88038e..bbc0a6224 100644 --- a/apps/app/src/app/lib/den.ts +++ b/apps/app/src/app/lib/den.ts @@ -59,6 +59,9 @@ export type DenWorkerTokens = { hostToken: string | null; openworkUrl: string | null; workspaceId: string | null; + connectGrant: string | null; + connectGrantExpiresAt: string | null; + connectGrantDenBaseUrl: string | null; }; export type DenTemplateCreator = { @@ -134,6 +137,14 @@ export type DenDesktopHandoffExchange = { token: string | null; }; +export type DenWorkerConnectGrantExchange = { + openworkUrl: string | null; + workspaceId: string | null; + token: string | null; + workerId: string | null; + workerName: string | null; +}; + type RawJsonResponse = { ok: boolean; status: number; @@ -442,6 +453,9 @@ function getWorkerTokens(payload: unknown): DenWorkerTokens | null { hostToken: typeof tokens.host === "string" ? tokens.host : null, openworkUrl: connect && typeof connect.openworkUrl === "string" ? connect.openworkUrl : null, workspaceId: connect && typeof connect.workspaceId === "string" ? connect.workspaceId : null, + connectGrant: connect && typeof connect.grant === "string" ? connect.grant : null, + connectGrantExpiresAt: connect && typeof connect.expiresAt === "string" ? connect.expiresAt : null, + connectGrantDenBaseUrl: connect && typeof connect.denBaseUrl === "string" ? connect.denBaseUrl : null, }; } @@ -723,6 +737,21 @@ export function createDenClient(options: { baseUrl: string; token?: string | nul return { user: getUser(payload), token: getToken(payload) }; }, + async exchangeWorkerConnectGrant(grant: string): Promise { + const payload = await requestJson(baseUrls, "/v1/workers/connect-grant/exchange", { + method: "POST", + body: { grant }, + }); + + return { + openworkUrl: isRecord(payload) && typeof payload.openworkUrl === "string" ? payload.openworkUrl : null, + workspaceId: isRecord(payload) && typeof payload.workspaceId === "string" ? payload.workspaceId : null, + token: getToken(payload), + workerId: isRecord(payload) && typeof payload.workerId === "string" ? payload.workerId : null, + workerName: isRecord(payload) && typeof payload.workerName === "string" ? payload.workerName : null, + }; + }, + async listOrgs(): Promise<{ orgs: DenOrgSummary[]; defaultOrgId: string | null }> { const payload = await requestJson(baseUrls, "/v1/me/orgs", { method: "GET", diff --git a/apps/app/src/app/lib/openwork-links.ts b/apps/app/src/app/lib/openwork-links.ts index b13ad8fca..b09b46350 100644 --- a/apps/app/src/app/lib/openwork-links.ts +++ b/apps/app/src/app/lib/openwork-links.ts @@ -6,6 +6,8 @@ import type { BundleRequest } from "../bundles/types"; export type RemoteWorkspaceDefaults = { openworkHostUrl?: string | null; openworkToken?: string | null; + openworkConnectGrant?: string | null; + denBaseUrl?: string | null; directory?: string | null; displayName?: string | null; autoConnect?: boolean; @@ -43,10 +45,11 @@ export function parseRemoteConnectDeepLink(rawUrl: string): RemoteWorkspaceDefau } const hostUrlRaw = url.searchParams.get("openworkHostUrl") ?? url.searchParams.get("openworkUrl") ?? ""; - const tokenRaw = url.searchParams.get("openworkToken") ?? url.searchParams.get("accessToken") ?? ""; + const grantRaw = url.searchParams.get("grant") ?? ""; + const denBaseUrl = normalizeDenBaseUrl(url.searchParams.get("denBaseUrl")?.trim() ?? ""); const normalizedHostUrl = normalizeOpenworkServerUrl(hostUrlRaw); - const token = tokenRaw.trim(); - if (!normalizedHostUrl || !token) { + const grant = grantRaw.trim(); + if (!normalizedHostUrl || !grant) { return null; } @@ -62,13 +65,48 @@ export function parseRemoteConnectDeepLink(rawUrl: string): RemoteWorkspaceDefau return { openworkHostUrl: normalizedHostUrl, - openworkToken: token, + openworkToken: null, + openworkConnectGrant: grant, + denBaseUrl: denBaseUrl ?? null, directory: null, displayName: displayName || null, autoConnect, }; } +export function buildRemoteConnectDeepLink(input: { + openworkHostUrl: string; + grant: string; + denBaseUrl?: string | null; + displayName?: string | null; + workspaceId?: string | null; + autoConnect?: boolean; +}) { + const hostUrl = normalizeOpenworkServerUrl(input.openworkHostUrl ?? ""); + const grant = input.grant.trim(); + if (!hostUrl || !grant) return null; + + const url = new URL("openwork://connect-remote"); + url.searchParams.set("openworkHostUrl", hostUrl); + url.searchParams.set("grant", grant); + const denBaseUrl = normalizeDenBaseUrl(input.denBaseUrl ?? ""); + if (denBaseUrl) { + url.searchParams.set("denBaseUrl", denBaseUrl); + } + if (input.autoConnect) { + url.searchParams.set("autoConnect", "1"); + } + const workspaceId = input.workspaceId?.trim() ?? ""; + if (workspaceId) { + url.searchParams.set("workerId", workspaceId); + } + const displayName = input.displayName?.trim() ?? ""; + if (displayName) { + url.searchParams.set("workerName", displayName); + } + return url.toString(); +} + export function stripRemoteConnectQuery(rawUrl: string): string | null { let url: URL; try { @@ -81,6 +119,8 @@ export function stripRemoteConnectQuery(rawUrl: string): string | null { for (const key of [ "openworkHostUrl", "openworkUrl", + "grant", + "denBaseUrl", "openworkToken", "accessToken", "workerId", diff --git a/apps/app/src/app/lib/openwork-server.ts b/apps/app/src/app/lib/openwork-server.ts index 7ad57bf43..d127442b7 100644 --- a/apps/app/src/app/lib/openwork-server.ts +++ b/apps/app/src/app/lib/openwork-server.ts @@ -1,6 +1,7 @@ import { fetch as tauriFetch } from "@tauri-apps/plugin-http"; import { isTauriRuntime } from "../utils"; import type { ExecResult, OpencodeConfigFile, ScheduledJob, WorkspaceInfo, WorkspaceList } from "./tauri"; +import { normalizeDenBaseUrl } from "./den"; export type OpenworkServerCapabilities = { skills: { read: boolean; write: boolean; source: "openwork" | "opencode" }; @@ -90,6 +91,21 @@ export type OpenworkServerSettings = { remoteAccessEnabled?: boolean; }; +export type OpenworkConnectGrantIssueResult = { + grant: string; + expiresAt: string; + openworkUrl: string; + workspaceId?: string | null; + workspaceName?: string | null; +}; + +export type OpenworkConnectGrantExchangeResult = { + openworkUrl: string | null; + workspaceId: string | null; + token: string | null; + workspaceName?: string | null; +}; + export type OpenworkWorkspaceInfo = WorkspaceInfo & { opencode?: { baseUrl?: string; @@ -476,6 +492,25 @@ export function normalizeOpenworkServerUrl(input: string) { return withProtocol.replace(/\/+$/, ""); } +export function normalizeOpenworkServerHostUrl(input: string) { + const normalized = normalizeOpenworkServerUrl(input); + if (!normalized) return null; + + try { + const url = new URL(normalized); + const segments = url.pathname.split("/").filter(Boolean); + const last = segments[segments.length - 1] ?? ""; + const prev = segments[segments.length - 2] ?? ""; + if (prev === "w" && last) { + const baseSegments = segments.slice(0, -2); + url.pathname = baseSegments.length > 0 ? `/${baseSegments.join("/")}` : "/"; + } + return url.toString().replace(/\/+$/, ""); + } catch { + return normalized.replace(/\/w\/[^/?#]+$/, ""); + } +} + export function parseOpenworkWorkspaceIdFromUrl(input: string) { const normalized = normalizeOpenworkServerUrl(input) ?? ""; if (!normalized) return null; @@ -527,12 +562,15 @@ export function buildOpenworkWorkspaceBaseUrl(hostUrl: string, workspaceId?: str const OPENWORK_INVITE_PARAM_URL = "ow_url"; const OPENWORK_INVITE_PARAM_TOKEN = "ow_token"; +const OPENWORK_INVITE_PARAM_GRANT = "ow_grant"; +const OPENWORK_INVITE_PARAM_DEN_BASE_URL = "ow_den_url"; const OPENWORK_INVITE_PARAM_STARTUP = "ow_startup"; const OPENWORK_INVITE_PARAM_AUTO_CONNECT = "ow_auto_connect"; export type OpenworkConnectInvite = { url: string; - token?: string; + grant?: string; + denBaseUrl?: string; startup?: "server"; autoConnect?: boolean; }; @@ -547,14 +585,16 @@ export function readOpenworkConnectInviteFromSearch(input: string | URLSearchPar const url = normalizeOpenworkServerUrl(rawUrl); if (!url) return null; - const token = search.get(OPENWORK_INVITE_PARAM_TOKEN)?.trim() ?? ""; + const grant = search.get(OPENWORK_INVITE_PARAM_GRANT)?.trim() ?? ""; + const denBaseUrl = normalizeDenBaseUrl(search.get(OPENWORK_INVITE_PARAM_DEN_BASE_URL)?.trim() ?? "") ?? undefined; const startupRaw = search.get(OPENWORK_INVITE_PARAM_STARTUP)?.trim() ?? ""; const startup = startupRaw === "server" ? "server" : undefined; const autoConnect = search.get(OPENWORK_INVITE_PARAM_AUTO_CONNECT)?.trim() === "1"; return { url, - token: token || undefined, + grant: grant || undefined, + denBaseUrl, startup, autoConnect: autoConnect || undefined, } satisfies OpenworkConnectInvite; @@ -565,6 +605,8 @@ export function stripOpenworkConnectInviteFromUrl(input: string) { const url = new URL(input); url.searchParams.delete(OPENWORK_INVITE_PARAM_URL); url.searchParams.delete(OPENWORK_INVITE_PARAM_TOKEN); + url.searchParams.delete(OPENWORK_INVITE_PARAM_GRANT); + url.searchParams.delete(OPENWORK_INVITE_PARAM_DEN_BASE_URL); url.searchParams.delete(OPENWORK_INVITE_PARAM_STARTUP); url.searchParams.delete(OPENWORK_INVITE_PARAM_AUTO_CONNECT); return url.toString(); @@ -1003,6 +1045,24 @@ export function createOpenworkServerClient(options: { baseUrl: string; token?: s `/workspaces/${encodeURIComponent(workspaceId)}`, { token, hostToken, method: "DELETE", timeoutMs: timeouts.deleteWorkspace }, ), + createConnectGrant: (workspaceId: string, payload: { hostUrl: string; label?: string | null }) => + requestJson( + baseUrl, + `/workspace/${encodeURIComponent(workspaceId)}/connect-grant`, + { + token, + hostToken, + method: "POST", + body: payload, + timeoutMs: timeouts.activateWorkspace, + }, + ), + exchangeConnectGrant: (grant: string) => + requestJson(baseUrl, "/connect-grant/exchange", { + method: "POST", + body: { grant }, + timeoutMs: timeouts.activateWorkspace, + }), deleteSession: (workspaceId: string, sessionId: string) => requestJson<{ ok: boolean }>( baseUrl, diff --git a/apps/app/src/app/pages/session.tsx b/apps/app/src/app/pages/session.tsx index 0d38acc60..0e9dcc7ad 100644 --- a/apps/app/src/app/pages/session.tsx +++ b/apps/app/src/app/pages/session.tsx @@ -3562,6 +3562,11 @@ export default function SessionView(props: SessionViewProps) { onSave: props.saveShareRemoteAccess, } : undefined} + inviteLink={shareWorkspaceState.shareAccessInviteUrl()} + inviteLinkBusy={shareWorkspaceState.shareAccessInviteBusy()} + inviteLinkError={shareWorkspaceState.shareAccessInviteError()} + inviteLinkDisabledReason={shareWorkspaceState.shareAccessInviteDisabledReason()} + onCreateInviteLink={shareWorkspaceState.createShareAccessInviteLink} note={shareWorkspaceState.shareNote()} onShareWorkspaceProfile={shareWorkspaceState.publishWorkspaceProfileLink} shareWorkspaceProfileBusy={shareWorkspaceState.shareWorkspaceProfileBusy()} diff --git a/apps/app/src/app/session/share-workspace.ts b/apps/app/src/app/session/share-workspace.ts index dc0b53983..3c8cf491b 100644 --- a/apps/app/src/app/session/share-workspace.ts +++ b/apps/app/src/app/session/share-workspace.ts @@ -17,10 +17,13 @@ import { buildOpenworkWorkspaceBaseUrl, createOpenworkServerClient, OpenworkServerError, + normalizeOpenworkServerHostUrl, parseOpenworkWorkspaceIdFromUrl, type OpenworkWorkspaceExportSensitiveMode, type OpenworkWorkspaceExportWarning, } from "../lib/openwork-server"; +import { buildRemoteConnectDeepLink } from "../lib/openwork-links"; +import { DEFAULT_OPENWORK_PUBLISHER_BASE_URL } from "../lib/publisher"; import type { EngineInfo, OpenworkServerInfo, @@ -63,6 +66,9 @@ export function createShareWorkspaceState(options: ShareWorkspaceStateOptions) { createSignal(null); const [shareWorkspaceProfileTeamSuccess, setShareWorkspaceProfileTeamSuccess] = createSignal(null); + const [shareAccessInviteBusy, setShareAccessInviteBusy] = createSignal(false); + const [shareAccessInviteUrl, setShareAccessInviteUrl] = createSignal(null); + const [shareAccessInviteError, setShareAccessInviteError] = createSignal(null); const [shareCloudSettingsVersion, setShareCloudSettingsVersion] = createSignal(0); const [shareSkillsSetBusy, setShareSkillsSetBusy] = createSignal(false); @@ -112,6 +118,9 @@ export function createShareWorkspaceState(options: ShareWorkspaceStateOptions) { setShareWorkspaceProfileTeamBusy(false); setShareWorkspaceProfileTeamError(null); setShareWorkspaceProfileTeamSuccess(null); + setShareAccessInviteBusy(false); + setShareAccessInviteUrl(null); + setShareAccessInviteError(null); setShareSkillsSetBusy(false); setShareSkillsSetUrl(null); setShareSkillsSetError(null); @@ -190,8 +199,6 @@ export function createShareWorkspaceState(options: ShareWorkspaceStateOptions) { : null; const url = mountedUrl || hostUrl; const ownerToken = options.openworkServerHostInfo()?.ownerToken?.trim() || ""; - const collaboratorToken = - options.openworkServerHostInfo()?.clientToken?.trim() || ""; return [ { label: "Worker URL", @@ -214,15 +221,6 @@ export function createShareWorkspaceState(options: ShareWorkspaceStateOptions) { ? "Use on phones or laptops connecting to this worker." : "Use when the remote client must answer permission prompts.", }, - { - label: "Collaborator token", - value: collaboratorToken, - secret: true, - placeholder: isTauriRuntime() ? "-" : "Desktop app required", - hint: mountedUrl - ? "Routine remote access when you do not need owner-only actions." - : "Routine remote access to this host without owner-only actions.", - }, ]; } @@ -304,6 +302,194 @@ export function createShareWorkspaceState(options: ShareWorkspaceStateOptions) { return null; }); + const shareAccessInviteDisabledReason = createMemo(() => { + const workspace = shareWorkspace(); + if (!workspace) return "Select a workspace first."; + + if (workspace.workspaceType !== "remote") { + if (options.openworkServerHostInfo()?.remoteAccessEnabled !== true) { + return "Enable remote access and save before creating a connect link."; + } + const baseUrl = options.openworkServerHostInfo()?.baseUrl?.trim() ?? ""; + const hostUrl = + options.openworkServerHostInfo()?.connectUrl?.trim() || + options.openworkServerHostInfo()?.lanUrl?.trim() || + options.openworkServerHostInfo()?.mdnsUrl?.trim() || + ""; + const hostToken = options.openworkServerHostInfo()?.hostToken?.trim() ?? ""; + if (!baseUrl || !hostToken) return "Local OpenWork host is not ready yet."; + if (!hostUrl) return "Network share URL is not ready yet."; + if (!shareLocalOpenworkWorkspaceId()?.trim()) return "Workspace URL is still resolving."; + return null; + } + + if (workspace.remoteType !== "openwork") { + return "One-time connect links are available for OpenWork workers."; + } + + const hostUrl = workspace.openworkHostUrl?.trim() || workspace.baseUrl?.trim() || ""; + const hostAuth = workspace.openworkHostToken?.trim() || workspace.openworkToken?.trim() || options.openworkServerSettings().token?.trim() || ""; + if (!hostUrl) return "Missing OpenWork host URL."; + if (!hostAuth) return "Missing OpenWork owner access."; + return null; + }); + + const resolveShareAccessInviteContext = async (): Promise<{ + client: ReturnType; + workspaceId: string; + workspace: WorkspaceInfo; + hostUrl: string; + }> => { + const workspace = shareWorkspace(); + if (!workspace) { + throw new Error("Select a workspace first."); + } + + if (workspace.workspaceType !== "remote") { + const baseUrl = options.openworkServerHostInfo()?.baseUrl?.trim() ?? ""; + const hostUrl = + options.openworkServerHostInfo()?.connectUrl?.trim() || + options.openworkServerHostInfo()?.lanUrl?.trim() || + options.openworkServerHostInfo()?.mdnsUrl?.trim() || + ""; + const hostToken = options.openworkServerHostInfo()?.hostToken?.trim() ?? ""; + const readToken = + options.openworkServerHostInfo()?.ownerToken?.trim() || + options.openworkServerHostInfo()?.clientToken?.trim() || + ""; + + if (!baseUrl || !hostToken) { + throw new Error("Local OpenWork host is not ready yet."); + } + if (!hostUrl) { + throw new Error("Network share URL is not ready yet."); + } + + let workspaceId = shareLocalOpenworkWorkspaceId()?.trim() ?? ""; + if (!workspaceId) { + if (!readToken) { + throw new Error("Workspace URL is still resolving."); + } + const readClient = createOpenworkServerClient({ baseUrl, token: readToken }); + const response = await readClient.listWorkspaces(); + const items = Array.isArray(response.items) ? response.items : []; + const targetPath = normalizeDirectoryPath(workspace.path?.trim() ?? ""); + const match = items.find( + (entry) => normalizeDirectoryPath(entry.path) === targetPath, + ); + workspaceId = (match?.id ?? "").trim(); + setShareLocalOpenworkWorkspaceId(workspaceId || null); + } + + if (!workspaceId) { + throw new Error("Could not resolve this workspace on the local OpenWork host."); + } + + return { + client: createOpenworkServerClient({ baseUrl, hostToken }), + workspaceId, + workspace, + hostUrl, + }; + } + + if (workspace.remoteType !== "openwork") { + throw new Error("One-time connect links are available for OpenWork workers."); + } + + const hostUrl = workspace.openworkHostUrl?.trim() || workspace.baseUrl?.trim() || ""; + if (!hostUrl) { + throw new Error("Missing OpenWork host URL."); + } + + let workspaceId = + workspace.openworkWorkspaceId?.trim() || + parseOpenworkWorkspaceIdFromUrl(workspace.openworkHostUrl ?? "") || + parseOpenworkWorkspaceIdFromUrl(workspace.baseUrl ?? "") || + ""; + + const baseUrl = normalizeOpenworkServerHostUrl(hostUrl) ?? hostUrl; + const hostToken = workspace.openworkHostToken?.trim() ?? ""; + const token = workspace.openworkToken?.trim() || options.openworkServerSettings().token?.trim() || ""; + const client = createOpenworkServerClient({ + baseUrl, + token: hostToken ? undefined : token || undefined, + hostToken: hostToken || undefined, + }); + + if (!workspaceId) { + if (!token && !hostToken) { + throw new Error("Missing OpenWork owner access."); + } + const readClient = createOpenworkServerClient({ baseUrl, token: token || undefined }); + const response = await readClient.listWorkspaces(); + const items = Array.isArray(response.items) ? response.items : []; + const directoryHint = normalizeDirectoryPath( + workspace.directory?.trim() ?? workspace.path?.trim() ?? "", + ); + const match = directoryHint + ? items.find((entry) => { + const entryPath = normalizeDirectoryPath( + ( + entry.opencode?.directory ?? + entry.directory ?? + entry.path ?? + "" + ).trim(), + ); + return Boolean(entryPath && entryPath === directoryHint); + }) + : ((response.activeId + ? items.find((entry) => entry.id === response.activeId) + : null) ?? items[0]); + workspaceId = (match?.id ?? "").trim(); + } + + if (!workspaceId) { + throw new Error("Could not resolve this workspace on the OpenWork host."); + } + + return { client, workspaceId, workspace, hostUrl }; + }; + + const createShareAccessInviteLink = async () => { + if (shareAccessInviteBusy()) return; + setShareAccessInviteBusy(true); + setShareAccessInviteError(null); + setShareAccessInviteUrl(null); + + try { + const { client, workspaceId, workspace, hostUrl } = await resolveShareAccessInviteContext(); + const issued = await client.createConnectGrant(workspaceId, { + hostUrl, + label: options.workspaceLabel(workspace), + }); + const inviteUrl = buildRemoteConnectDeepLink({ + openworkHostUrl: issued.openworkUrl || hostUrl, + grant: issued.grant, + displayName: options.workspaceLabel(workspace), + workspaceId: issued.workspaceId ?? workspaceId, + autoConnect: true, + }); + if (!inviteUrl) { + throw new Error("Failed to build the OpenWork connect link."); + } + + setShareAccessInviteUrl(inviteUrl); + try { + await navigator.clipboard.writeText(inviteUrl); + } catch { + // ignore + } + } catch (error) { + setShareAccessInviteError( + error instanceof Error ? error.message : "Failed to create connect link", + ); + } finally { + setShareAccessInviteBusy(false); + } + }; + const shareCloudSettings = createMemo(() => { shareWorkspaceId(); shareCloudSettingsVersion(); @@ -568,6 +754,11 @@ export function createShareWorkspaceState(options: ShareWorkspaceStateOptions) { shareWorkspaceDetail, shareFields, shareNote, + shareAccessInviteBusy, + shareAccessInviteUrl, + shareAccessInviteError, + shareAccessInviteDisabledReason, + createShareAccessInviteLink, shareServiceDisabledReason, shareWorkspaceProfileBusy, shareWorkspaceProfileUrl, diff --git a/apps/app/src/app/shell/deep-links.ts b/apps/app/src/app/shell/deep-links.ts index f5d6f42cd..97b1d239e 100644 --- a/apps/app/src/app/shell/deep-links.ts +++ b/apps/app/src/app/shell/deep-links.ts @@ -55,6 +55,8 @@ export function createDeepLinksController(options: { const input = { openworkHostUrl: pending.openworkHostUrl, openworkToken: pending.openworkToken, + openworkConnectGrant: pending.openworkConnectGrant, + denBaseUrl: pending.denBaseUrl, directory: pending.directory, displayName: pending.displayName, }; diff --git a/apps/app/src/app/shell/settings-shell.tsx b/apps/app/src/app/shell/settings-shell.tsx index 8234047d5..40b33d460 100644 --- a/apps/app/src/app/shell/settings-shell.tsx +++ b/apps/app/src/app/shell/settings-shell.tsx @@ -31,8 +31,10 @@ import { buildOpenworkWorkspaceBaseUrl, createOpenworkServerClient, OpenworkServerError, + normalizeOpenworkServerHostUrl, parseOpenworkWorkspaceIdFromUrl, } from "../lib/openwork-server"; +import { buildRemoteConnectDeepLink } from "../lib/openwork-links"; import type { OpenworkAuditEntry, OpenworkServerClient, @@ -509,6 +511,9 @@ export default function SettingsShell(props: SettingsShellProps) { const [shareWorkspaceProfileTeamBusy, setShareWorkspaceProfileTeamBusy] = createSignal(false); const [shareWorkspaceProfileTeamError, setShareWorkspaceProfileTeamError] = createSignal(null); const [shareWorkspaceProfileTeamSuccess, setShareWorkspaceProfileTeamSuccess] = createSignal(null); + const [shareAccessInviteBusy, setShareAccessInviteBusy] = createSignal(false); + const [shareAccessInviteUrl, setShareAccessInviteUrl] = createSignal(null); + const [shareAccessInviteError, setShareAccessInviteError] = createSignal(null); const [shareCloudSettingsVersion, setShareCloudSettingsVersion] = createSignal(0); const [shareSkillsSetBusy, setShareSkillsSetBusy] = createSignal(false); const [shareSkillsSetUrl, setShareSkillsSetUrl] = createSignal(null); @@ -524,6 +529,9 @@ export default function SettingsShell(props: SettingsShellProps) { setShareWorkspaceProfileTeamBusy(false); setShareWorkspaceProfileTeamError(null); setShareWorkspaceProfileTeamSuccess(null); + setShareAccessInviteBusy(false); + setShareAccessInviteUrl(null); + setShareAccessInviteError(null); setShareSkillsSetBusy(false); setShareSkillsSetUrl(null); setShareSkillsSetError(null); @@ -593,7 +601,6 @@ export default function SettingsShell(props: SettingsShellProps) { : null; const url = mountedUrl || hostUrl; const ownerToken = props.openworkServerHostInfo?.ownerToken?.trim() || ""; - const collaboratorToken = props.openworkServerHostInfo?.clientToken?.trim() || ""; return [ { label: "Worker URL", @@ -614,15 +621,6 @@ export default function SettingsShell(props: SettingsShellProps) { ? "Use on phones or laptops connecting to this worker." : "Use when the remote client must answer permission prompts.", }, - { - label: "Collaborator token", - value: collaboratorToken, - secret: true, - placeholder: isTauriRuntime() ? "-" : "Desktop app required", - hint: mountedUrl - ? "Routine remote access when you do not need owner-only actions." - : "Routine remote access to this host without owner-only actions.", - }, ]; } @@ -696,6 +694,179 @@ export default function SettingsShell(props: SettingsShellProps) { return null; }); + const shareAccessInviteDisabledReason = createMemo(() => { + const ws = shareWorkspace(); + if (!ws) return "Select a workspace first."; + + if (ws.workspaceType !== "remote") { + if (props.openworkServerHostInfo?.remoteAccessEnabled !== true) { + return "Enable remote access and save before creating a connect link."; + } + const baseUrl = props.openworkServerHostInfo?.baseUrl?.trim() ?? ""; + const hostUrl = + props.openworkServerHostInfo?.connectUrl?.trim() || + props.openworkServerHostInfo?.lanUrl?.trim() || + props.openworkServerHostInfo?.mdnsUrl?.trim() || + ""; + const hostToken = props.openworkServerHostInfo?.hostToken?.trim() ?? ""; + if (!baseUrl || !hostToken) return "Local OpenWork host is not ready yet."; + if (!hostUrl) return "Network share URL is not ready yet."; + if (!shareLocalOpenworkWorkspaceId()?.trim()) return "Workspace URL is still resolving."; + return null; + } + + if (ws.remoteType !== "openwork") { + return "One-time connect links are available for OpenWork workers."; + } + + const hostUrl = ws.openworkHostUrl?.trim() || ws.baseUrl?.trim() || ""; + const hostAuth = ws.openworkHostToken?.trim() || ws.openworkToken?.trim() || props.openworkServerSettings.token?.trim() || ""; + if (!hostUrl) return "Missing OpenWork host URL."; + if (!hostAuth) return "Missing OpenWork owner access."; + return null; + }); + + const resolveShareAccessInviteContext = async (): Promise<{ + client: OpenworkServerClient; + workspaceId: string; + workspace: WorkspaceInfo; + hostUrl: string; + }> => { + const ws = shareWorkspace(); + if (!ws) { + throw new Error("Select a workspace first."); + } + + if (ws.workspaceType !== "remote") { + const baseUrl = props.openworkServerHostInfo?.baseUrl?.trim() ?? ""; + const hostUrl = + props.openworkServerHostInfo?.connectUrl?.trim() || + props.openworkServerHostInfo?.lanUrl?.trim() || + props.openworkServerHostInfo?.mdnsUrl?.trim() || + ""; + const hostToken = props.openworkServerHostInfo?.hostToken?.trim() ?? ""; + const readToken = + props.openworkServerHostInfo?.ownerToken?.trim() || + props.openworkServerHostInfo?.clientToken?.trim() || + ""; + + if (!baseUrl || !hostToken) { + throw new Error("Local OpenWork host is not ready yet."); + } + if (!hostUrl) { + throw new Error("Network share URL is not ready yet."); + } + + let workspaceId = shareLocalOpenworkWorkspaceId()?.trim() ?? ""; + if (!workspaceId) { + if (!readToken) { + throw new Error("Workspace URL is still resolving."); + } + const readClient = createOpenworkServerClient({ baseUrl, token: readToken }); + const response = await readClient.listWorkspaces(); + const items = Array.isArray(response.items) ? response.items : []; + const targetPath = normalizeDirectoryPath(ws.path?.trim() ?? ""); + const match = items.find((entry) => normalizeDirectoryPath(entry.path) === targetPath); + workspaceId = (match?.id ?? "").trim(); + setShareLocalOpenworkWorkspaceId(workspaceId || null); + } + + if (!workspaceId) { + throw new Error("Could not resolve this workspace on the local OpenWork host."); + } + + return { + client: createOpenworkServerClient({ baseUrl, hostToken }), + workspaceId, + workspace: ws, + hostUrl, + }; + } + + if (ws.remoteType !== "openwork") { + throw new Error("One-time connect links are available for OpenWork workers."); + } + + const hostUrl = ws.openworkHostUrl?.trim() || ws.baseUrl?.trim() || ""; + if (!hostUrl) { + throw new Error("Missing OpenWork host URL."); + } + + let workspaceId = + ws.openworkWorkspaceId?.trim() || + parseOpenworkWorkspaceIdFromUrl(ws.openworkHostUrl ?? "") || + parseOpenworkWorkspaceIdFromUrl(ws.baseUrl ?? "") || + ""; + + const baseUrl = normalizeOpenworkServerHostUrl(hostUrl) ?? hostUrl; + const hostToken = ws.openworkHostToken?.trim() ?? ""; + const token = ws.openworkToken?.trim() || props.openworkServerSettings.token?.trim() || ""; + const client = createOpenworkServerClient({ + baseUrl, + token: hostToken ? undefined : token || undefined, + hostToken: hostToken || undefined, + }); + + if (!workspaceId) { + if (!token && !hostToken) { + throw new Error("Missing OpenWork owner access."); + } + const readClient = createOpenworkServerClient({ baseUrl, token: token || undefined }); + const response = await readClient.listWorkspaces(); + const items = Array.isArray(response.items) ? response.items : []; + const directoryHint = normalizeDirectoryPath(ws.directory?.trim() ?? ws.path?.trim() ?? ""); + const match = directoryHint + ? items.find((entry) => { + const entryPath = normalizeDirectoryPath((entry.opencode?.directory ?? entry.directory ?? entry.path ?? "").trim()); + return Boolean(entryPath && entryPath === directoryHint); + }) + : (response.activeId ? items.find((entry) => entry.id === response.activeId) : null) ?? items[0]; + workspaceId = (match?.id ?? "").trim(); + } + + if (!workspaceId) { + throw new Error("Could not resolve this workspace on the OpenWork host."); + } + + return { client, workspaceId, workspace: ws, hostUrl }; + }; + + const createShareAccessInviteLink = async () => { + if (shareAccessInviteBusy()) return; + setShareAccessInviteBusy(true); + setShareAccessInviteError(null); + setShareAccessInviteUrl(null); + + try { + const { client, workspaceId, workspace, hostUrl } = await resolveShareAccessInviteContext(); + const issued = await client.createConnectGrant(workspaceId, { + hostUrl, + label: workspaceLabel(workspace), + }); + const inviteUrl = buildRemoteConnectDeepLink({ + openworkHostUrl: issued.openworkUrl || hostUrl, + grant: issued.grant, + displayName: workspaceLabel(workspace), + workspaceId: issued.workspaceId ?? workspaceId, + autoConnect: true, + }); + if (!inviteUrl) { + throw new Error("Failed to build the OpenWork connect link."); + } + + setShareAccessInviteUrl(inviteUrl); + try { + await navigator.clipboard.writeText(inviteUrl); + } catch { + // ignore + } + } catch (error) { + setShareAccessInviteError(error instanceof Error ? error.message : "Failed to create connect link"); + } finally { + setShareAccessInviteBusy(false); + } + }; + const shareCloudSettings = createMemo(() => { shareWorkspaceId(); shareCloudSettingsVersion(); @@ -1318,6 +1489,11 @@ export default function SettingsShell(props: SettingsShellProps) { onSave: props.saveShareRemoteAccess, } : undefined} + inviteLink={shareAccessInviteUrl()} + inviteLinkBusy={shareAccessInviteBusy()} + inviteLinkError={shareAccessInviteError()} + inviteLinkDisabledReason={shareAccessInviteDisabledReason()} + onCreateInviteLink={createShareAccessInviteLink} note={shareNote()} onShareWorkspaceProfile={publishWorkspaceProfileLink} shareWorkspaceProfileBusy={shareWorkspaceProfileBusy()} diff --git a/apps/app/src/app/workspace/create-remote-workspace-modal.tsx b/apps/app/src/app/workspace/create-remote-workspace-modal.tsx index 96d008182..2580c1ff5 100644 --- a/apps/app/src/app/workspace/create-remote-workspace-modal.tsx +++ b/apps/app/src/app/workspace/create-remote-workspace-modal.tsx @@ -24,6 +24,8 @@ export default function CreateRemoteWorkspaceModal(props: CreateRemoteWorkspaceM const [openworkHostUrl, setOpenworkHostUrl] = createSignal(""); const [openworkToken, setOpenworkToken] = createSignal(""); + const [openworkConnectGrant, setOpenworkConnectGrant] = createSignal(null); + const [denBaseUrl, setDenBaseUrl] = createSignal(null); const [openworkTokenVisible, setOpenworkTokenVisible] = createSignal(false); const [directory, setDirectory] = createSignal(""); const [displayName, setDisplayName] = createSignal(""); @@ -51,6 +53,8 @@ export default function CreateRemoteWorkspaceModal(props: CreateRemoteWorkspaceM const defaults = props.initialValues ?? {}; setOpenworkHostUrl(defaults.openworkHostUrl?.trim() ?? ""); setOpenworkToken(defaults.openworkToken?.trim() ?? ""); + setOpenworkConnectGrant(defaults.openworkConnectGrant?.trim() ?? null); + setDenBaseUrl(defaults.denBaseUrl?.trim() ?? null); setOpenworkTokenVisible(false); setDirectory(defaults.directory?.trim() ?? ""); setDisplayName(defaults.displayName?.trim() ?? ""); @@ -115,6 +119,8 @@ export default function CreateRemoteWorkspaceModal(props: CreateRemoteWorkspaceM props.onConfirm({ openworkHostUrl: openworkHostUrl().trim(), openworkToken: openworkToken().trim(), + openworkConnectGrant: openworkConnectGrant(), + denBaseUrl: denBaseUrl(), directory: directory().trim() ? directory().trim() : null, displayName: displayName().trim() ? displayName().trim() : null, }) diff --git a/apps/app/src/app/workspace/share-workspace-access-panel.tsx b/apps/app/src/app/workspace/share-workspace-access-panel.tsx index de615288a..d5cac9328 100644 --- a/apps/app/src/app/workspace/share-workspace-access-panel.tsx +++ b/apps/app/src/app/workspace/share-workspace-access-panel.tsx @@ -40,6 +40,11 @@ export default function ShareWorkspaceAccessPanel(props: { error?: string | null; onSave: (enabled: boolean) => void | Promise; }; + inviteLink?: string | null; + inviteLinkBusy?: boolean; + inviteLinkError?: string | null; + inviteLinkDisabledReason?: string | null; + onCreateInviteLink?: () => void | Promise; remoteAccessEnabled: boolean; onRemoteAccessEnabledChange: (value: boolean) => void; note?: string | null; @@ -158,6 +163,53 @@ export default function ShareWorkspaceAccessPanel(props: { }} +
+
+
+

One-time connect link

+

+ Create a single-use link that redeems into live workspace access without exposing the token in the URL. +

+
+ +
+ +
+ + +
+ + +
{props.inviteLinkError}
+
+ +
{props.inviteLinkDisabledReason}
+
+
+
diff --git a/apps/app/src/app/workspace/share-workspace-modal.tsx b/apps/app/src/app/workspace/share-workspace-modal.tsx index 98acd2c2c..11d3b1e8b 100644 --- a/apps/app/src/app/workspace/share-workspace-modal.tsx +++ b/apps/app/src/app/workspace/share-workspace-modal.tsx @@ -222,6 +222,11 @@ export default function ShareWorkspaceModal(props: ShareWorkspaceModalProps) { collaboratorExpanded={collaboratorExpanded()} onToggleCollaboratorExpanded={() => setCollaboratorExpanded((value) => !value)} remoteAccess={props.remoteAccess} + inviteLink={props.inviteLink} + inviteLinkBusy={props.inviteLinkBusy} + inviteLinkError={props.inviteLinkError} + inviteLinkDisabledReason={props.inviteLinkDisabledReason} + onCreateInviteLink={props.onCreateInviteLink} remoteAccessEnabled={remoteAccessEnabled()} onRemoteAccessEnabledChange={setRemoteAccessEnabled} note={props.note} diff --git a/apps/app/src/app/workspace/types.ts b/apps/app/src/app/workspace/types.ts index 5af8e3515..0898ad0dc 100644 --- a/apps/app/src/app/workspace/types.ts +++ b/apps/app/src/app/workspace/types.ts @@ -7,6 +7,8 @@ export type CreateWorkspaceScreen = "chooser" | "local" | "remote" | "shared"; export type RemoteWorkspaceInput = { openworkHostUrl?: string | null; openworkToken?: string | null; + openworkConnectGrant?: string | null; + denBaseUrl?: string | null; openworkClientToken?: string | null; openworkHostToken?: string | null; directory?: string | null; @@ -72,12 +74,16 @@ export type CreateRemoteWorkspaceModalProps = { onConfirm: (input: { openworkHostUrl?: string | null; openworkToken?: string | null; + openworkConnectGrant?: string | null; + denBaseUrl?: string | null; directory?: string | null; displayName?: string | null; }) => void; initialValues?: { openworkHostUrl?: string | null; openworkToken?: string | null; + openworkConnectGrant?: string | null; + denBaseUrl?: string | null; directory?: string | null; displayName?: string | null; }; @@ -118,6 +124,11 @@ export type ShareWorkspaceModalProps = { error?: string | null; onSave: (enabled: boolean) => void | Promise; }; + inviteLink?: string | null; + inviteLinkBusy?: boolean; + inviteLinkError?: string | null; + inviteLinkDisabledReason?: string | null; + onCreateInviteLink?: () => void | Promise; note?: string | null; onShareWorkspaceProfile?: () => void; shareWorkspaceProfileBusy?: boolean; diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index ff3224614..509369f85 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -1,6 +1,6 @@ import { existsSync } from "node:fs"; import { readFile, writeFile, rm, readdir, rename, stat } from "node:fs/promises"; -import { createHash, randomInt } from "node:crypto"; +import { createHash, randomBytes, randomInt } from "node:crypto"; import { homedir, hostname } from "node:os"; import { basename, dirname, join, relative, resolve, sep } from "node:path"; import type { ApprovalRequest, Capabilities, ServerConfig, WorkspaceInfo, Actor, ReloadReason, ReloadTrigger, TokenScope } from "./types.js"; @@ -469,6 +469,26 @@ function buildOpencodeProxyUrl(baseUrl: string, path: string, search: string) { return target.toString(); } +function buildOpenworkWorkspaceUrl(hostUrl: string, workspaceId: string) { + const normalized = hostUrl.trim().replace(/\/+$/, ""); + if (!normalized) return ""; + + try { + const url = new URL(normalized); + const segments = url.pathname.split("/").filter(Boolean); + const last = segments[segments.length - 1] ?? ""; + const prev = segments[segments.length - 2] ?? ""; + if (prev === "w" && last) { + return url.toString().replace(/\/+$/, ""); + } + const basePath = url.pathname.replace(/\/+$/, ""); + url.pathname = `${basePath}/w/${encodeURIComponent(workspaceId)}`; + return url.toString().replace(/\/+$/, ""); + } catch { + return `${normalized}/w/${encodeURIComponent(workspaceId)}`; + } +} + async function fetchOpencodeJson(config: ServerConfig, workspace: WorkspaceInfo, path: string, init: { method: string; body?: unknown }) { const connection = resolveWorkspaceOpencodeConnection(config, workspace); const baseUrl = connection.baseUrl?.trim() ?? ""; @@ -1113,6 +1133,22 @@ function createRoutes( ): Route[] { const routes: Route[] = []; const fileSessions = new FileSessionStore(); + const connectGrants = new Map(); + + const pruneConnectGrants = () => { + const now = Date.now(); + for (const [id, grant] of connectGrants) { + if (grant.consumedAt !== null || grant.expiresAt <= now) { + connectGrants.delete(id); + } + } + }; const serializeFileSession = (session: { id: string; @@ -1289,6 +1325,62 @@ function createRoutes( return jsonResponse({ items, workspaces: items, activeId: active?.id ?? null }); }); + addRoute(routes, "POST", "/workspace/:id/connect-grant", "host", async (ctx) => { + const workspace = await resolveWorkspace(config, ctx.params.id); + const body = await readJsonBody(ctx.request); + const hostUrl = typeof body.hostUrl === "string" ? body.hostUrl.trim() : ""; + if (!hostUrl) { + throw new ApiError(400, "invalid_payload", "hostUrl is required"); + } + + pruneConnectGrants(); + const grant = randomBytes(24).toString("base64url"); + const expiresAt = Date.now() + 5 * 60 * 1000; + connectGrants.set(grant, { + workspaceId: workspace.id, + hostUrl, + scope: "owner", + expiresAt, + consumedAt: null, + }); + + return jsonResponse({ + grant, + expiresAt: new Date(expiresAt).toISOString(), + openworkUrl: buildOpenworkWorkspaceUrl(hostUrl, workspace.id), + workspaceId: workspace.id, + workspaceName: workspace.name, + }, 201); + }); + + addRoute(routes, "POST", "/connect-grant/exchange", "none", async (ctx) => { + const body = await readJsonBody(ctx.request); + const grantId = typeof body.grant === "string" ? body.grant.trim() : ""; + if (!grantId) { + throw new ApiError(400, "invalid_payload", "grant is required"); + } + + pruneConnectGrants(); + const grant = connectGrants.get(grantId); + if (!grant || grant.consumedAt !== null || grant.expiresAt <= Date.now()) { + throw new ApiError(404, "grant_not_found", "This OpenWork connect link is missing, expired, or already used."); + } + + const workspace = await resolveWorkspace(config, grant.workspaceId); + const issued = await tokens.create(grant.scope, { + label: `OpenWork connect link: ${workspace.name}`, + }); + grant.consumedAt = Date.now(); + connectGrants.set(grantId, grant); + + return jsonResponse({ + openworkUrl: buildOpenworkWorkspaceUrl(grant.hostUrl, workspace.id), + workspaceId: workspace.id, + workspaceName: workspace.name, + token: issued.token, + }); + }); + addRoute(routes, "GET", "/tokens", "host", async () => { const items = await tokens.list(); return jsonResponse({ items }); diff --git a/ee/apps/den-controller/src/http/desktop-auth.ts b/ee/apps/den-controller/src/http/desktop-auth.ts index 4a8d42c42..d4872e803 100644 --- a/ee/apps/den-controller/src/http/desktop-auth.ts +++ b/ee/apps/den-controller/src/http/desktop-auth.ts @@ -44,7 +44,7 @@ function withDenProxyPath(origin: string): string { return url.toString().replace(/\/+$/, "") } -function resolveDesktopDenBaseUrl(req: express.Request): string { +export function resolveDesktopDenBaseUrl(req: express.Request): string { const originHeader = readSingleHeader(req.headers.origin) if (originHeader) { try { diff --git a/ee/apps/den-controller/src/http/workers.ts b/ee/apps/den-controller/src/http/workers.ts index 1c4f4100d..697493238 100644 --- a/ee/apps/den-controller/src/http/workers.ts +++ b/ee/apps/den-controller/src/http/workers.ts @@ -1,11 +1,12 @@ import { randomBytes } from "crypto" import express from "express" -import { and, asc, desc, eq, isNull } from "../db/drizzle.js" +import { and, asc, desc, eq, gt, isNull } from "../db/drizzle.js" import { z } from "zod" import { getCloudWorkerBillingStatus, requireCloudWorkerAccess, setCloudWorkerSubscriptionCancellation } from "../billing/polar.js" import { db } from "../db/index.js" -import { AuditEventTable, AuthUserTable, DaytonaSandboxTable, OrgMembershipTable, WorkerBundleTable, WorkerInstanceTable, WorkerTable, WorkerTokenTable } from "../db/schema.js" +import { AuditEventTable, AuthUserTable, DaytonaSandboxTable, OrgMembershipTable, WorkerBundleTable, WorkerConnectGrantTable, WorkerInstanceTable, WorkerTable, WorkerTokenTable } from "../db/schema.js" import { env } from "../env.js" +import { resolveDesktopDenBaseUrl } from "./desktop-auth.js" import { asyncRoute, isTransientDbConnectionError } from "./errors.js" import { getRequestSession } from "./session.js" import { ensureUserOrgAccess, listUserOrgs, setSessionActiveOrganization } from "../orgs.js" @@ -41,6 +42,10 @@ const activityHeartbeatSchema = z.object({ openSessionCount: z.number().int().min(0).optional(), }) +const exchangeConnectGrantSchema = z.object({ + grant: z.string().trim().min(12).max(128), +}) + const token = () => randomBytes(32).toString("hex") type WorkerRow = typeof WorkerTable.$inferSelect @@ -93,9 +98,9 @@ function parseWorkspaceSelection(payload: unknown): { workspaceId: string; openw } } -async function resolveConnectUrlFromWorker(instanceUrl: string, clientToken: string) { +async function resolveConnectUrlFromWorker(instanceUrl: string, accessToken: string) { const baseUrl = normalizeUrl(instanceUrl) - if (!baseUrl || !clientToken.trim()) { + if (!baseUrl || !accessToken.trim()) { return null } @@ -104,7 +109,7 @@ async function resolveConnectUrlFromWorker(instanceUrl: string, clientToken: str method: "GET", headers: { Accept: "application/json", - Authorization: `Bearer ${clientToken.trim()}`, + Authorization: `Bearer ${accessToken.trim()}`, }, }) @@ -183,10 +188,10 @@ function newerDate(current: Date | null | undefined, candidate: Date | null | un return candidate.getTime() > current.getTime() ? candidate : current } -async function resolveConnectUrlFromCandidates(workerId: WorkerId, instanceUrl: string | null, clientToken: string) { +async function resolveConnectUrlFromCandidates(workerId: WorkerId, instanceUrl: string | null, accessToken: string) { const candidates = getConnectUrlCandidates(workerId, instanceUrl) for (const candidate of candidates) { - const resolved = await resolveConnectUrlFromWorker(candidate, clientToken) + const resolved = await resolveConnectUrlFromWorker(candidate, accessToken) if (resolved) { return resolved } @@ -194,6 +199,10 @@ async function resolveConnectUrlFromCandidates(workerId: WorkerId, instanceUrl: return null } +function createWorkerConnectGrant() { + return randomBytes(24).toString("base64url") +} + async function getWorkerRuntimeAccess(workerId: WorkerId) { const instance = await getLatestWorkerInstance(workerId) const tokenRows = await db @@ -882,7 +891,17 @@ workersRouter.post("/:id/tokens", asyncRoute(async (req, res) => { } const instance = await getLatestWorkerInstance(rows[0].id) - const connect = await resolveConnectUrlFromCandidates(rows[0].id, instance?.url ?? null, clientToken) + const connect = await resolveConnectUrlFromCandidates(rows[0].id, instance?.url ?? null, hostToken) + const grant = createWorkerConnectGrant() + const expiresAt = new Date(Date.now() + 5 * 60 * 1000) + + await db.insert(WorkerConnectGrantTable).values({ + id: grant, + worker_id: rows[0].id, + token_scope: "host", + expires_at: expiresAt, + consumed_at: null, + }) res.json({ tokens: { @@ -890,7 +909,92 @@ workersRouter.post("/:id/tokens", asyncRoute(async (req, res) => { host: hostToken, client: clientToken, }, - connect: connect ?? (instance?.url ? { openworkUrl: instance.url, workspaceId: null } : null), + connect: { + ...(connect ?? (instance?.url ? { openworkUrl: instance.url, workspaceId: null } : {})), + grant, + expiresAt: expiresAt.toISOString(), + denBaseUrl: resolveDesktopDenBaseUrl(req), + }, + }) +})) + +workersRouter.post("/connect-grant/exchange", asyncRoute(async (req, res) => { + const parsed = exchangeConnectGrantSchema.safeParse(req.body ?? {}) + if (!parsed.success) { + res.status(400).json({ error: "invalid_request", details: parsed.error.flatten() }) + return + } + + const now = new Date() + const rows = await db + .select({ + grant: WorkerConnectGrantTable, + worker: WorkerTable, + }) + .from(WorkerConnectGrantTable) + .innerJoin(WorkerTable, eq(WorkerConnectGrantTable.worker_id, WorkerTable.id)) + .where( + and( + eq(WorkerConnectGrantTable.id, parsed.data.grant), + isNull(WorkerConnectGrantTable.consumed_at), + eq(WorkerConnectGrantTable.token_scope, "host"), + gt(WorkerConnectGrantTable.expires_at, now), + ), + ) + .limit(1) + + const row = rows[0] + if (!row) { + res.status(404).json({ + error: "grant_not_found", + message: "This worker connect link is missing, expired, or already used.", + }) + return + } + + await db + .update(WorkerConnectGrantTable) + .set({ consumed_at: now }) + .where( + and( + eq(WorkerConnectGrantTable.id, parsed.data.grant), + isNull(WorkerConnectGrantTable.consumed_at), + ), + ) + + const tokenRows = await db + .select() + .from(WorkerTokenTable) + .where(and(eq(WorkerTokenTable.worker_id, row.worker.id), isNull(WorkerTokenTable.revoked_at))) + .orderBy(asc(WorkerTokenTable.created_at)) + + const hostToken = tokenRows.find((entry) => entry.scope === "host")?.token ?? null + if (!hostToken) { + res.status(409).json({ + error: "worker_tokens_unavailable", + message: "Worker tokens are missing for this worker. Launch a new worker and try again.", + }) + return + } + + const instance = await getLatestWorkerInstance(row.worker.id) + const connect = await resolveConnectUrlFromCandidates(row.worker.id, instance?.url ?? null, hostToken) + const openworkUrl = connect?.openworkUrl ?? (instance?.url ? normalizeUrl(instance.url) : null) + + if (!openworkUrl) { + res.status(409).json({ + error: "worker_connect_unavailable", + message: "Worker is not ready to connect yet. Try again in a moment.", + }) + return + } + + res.json({ + openworkUrl, + workspaceId: connect?.workspaceId ?? null, + token: hostToken, + workerId: row.worker.id, + workerName: row.worker.name, }) })) diff --git a/ee/apps/den-web/README.md b/ee/apps/den-web/README.md index b32bd2a64..f032e766e 100644 --- a/ee/apps/den-web/README.md +++ b/ee/apps/den-web/README.md @@ -29,7 +29,7 @@ Frontend for `app.openworklabs.com`. - default: `https://den-control-plane-openwork.onrender.com` - `NEXT_PUBLIC_OPENWORK_APP_CONNECT_URL` (client): Base URL for "Open in App" links. - Example: `https://openworklabs.com/app` - - The web panel appends `/connect-remote` and injects worker URL/token params automatically. + - The web panel appends `/connect-remote` and injects a one-time connect grant plus the worker URL automatically. - `NEXT_PUBLIC_OPENWORK_AUTH_CALLBACK_URL` (client): Canonical URL used for GitHub auth callback redirects. - default: `https://app.openworklabs.com` - this host must serve `/api/auth/*`; the included proxy route does that diff --git a/ee/apps/den-web/app/(den)/_lib/den-flow.ts b/ee/apps/den-web/app/(den)/_lib/den-flow.ts index 8a98f2406..8265fcf41 100644 --- a/ee/apps/den-web/app/(den)/_lib/den-flow.ts +++ b/ee/apps/den-web/app/(den)/_lib/den-flow.ts @@ -68,6 +68,9 @@ export type WorkerLaunch = { clientToken: string | null; ownerToken: string | null; hostToken: string | null; + connectGrant: string | null; + connectGrantExpiresAt: string | null; + connectGrantDenBaseUrl: string | null; }; export type WorkerSummary = { @@ -85,6 +88,9 @@ export type WorkerTokens = { hostToken: string | null; openworkUrl: string | null; workspaceId: string | null; + connectGrant: string | null; + connectGrantExpiresAt: string | null; + connectGrantDenBaseUrl: string | null; }; export type WorkerListItem = { @@ -422,7 +428,10 @@ export function getWorker(payload: unknown): WorkerLaunch | null { : tokens && typeof tokens.host === "string" ? tokens.host : null, - hostToken: tokens && typeof tokens.host === "string" ? tokens.host : null + hostToken: tokens && typeof tokens.host === "string" ? tokens.host : null, + connectGrant: null, + connectGrantExpiresAt: null, + connectGrantDenBaseUrl: null, }; } @@ -464,12 +473,24 @@ export function getWorkerTokens(payload: unknown): WorkerTokens | null { const hostToken = typeof tokens.host === "string" ? tokens.host : null; const openworkUrl = connect && typeof connect.openworkUrl === "string" ? connect.openworkUrl : null; const workspaceId = connect && typeof connect.workspaceId === "string" ? connect.workspaceId : null; + const connectGrant = connect && typeof connect.grant === "string" ? connect.grant : null; + const connectGrantExpiresAt = connect && typeof connect.expiresAt === "string" ? connect.expiresAt : null; + const connectGrantDenBaseUrl = connect && typeof connect.denBaseUrl === "string" ? connect.denBaseUrl : null; if (!clientToken && !ownerToken && !hostToken) { return null; } - return { clientToken, ownerToken, hostToken, openworkUrl, workspaceId }; + return { + clientToken, + ownerToken, + hostToken, + openworkUrl, + workspaceId, + connectGrant, + connectGrantExpiresAt, + connectGrantDenBaseUrl, + }; } export function getWorkerRuntimeSnapshot(payload: unknown): WorkerRuntimeSnapshot | null { @@ -715,7 +736,10 @@ export function isWorkerLaunch(value: unknown): value is WorkerLaunch { (typeof value.workspaceId === "string" || value.workspaceId === null || typeof value.workspaceId === "undefined") && (typeof value.clientToken === "string" || value.clientToken === null) && (typeof value.ownerToken === "string" || value.ownerToken === null || typeof value.ownerToken === "undefined") && - (typeof value.hostToken === "string" || value.hostToken === null) + (typeof value.hostToken === "string" || value.hostToken === null) && + (typeof value.connectGrant === "string" || value.connectGrant === null || typeof value.connectGrant === "undefined") && + (typeof value.connectGrantExpiresAt === "string" || value.connectGrantExpiresAt === null || typeof value.connectGrantExpiresAt === "undefined") && + (typeof value.connectGrantDenBaseUrl === "string" || value.connectGrantDenBaseUrl === null || typeof value.connectGrantDenBaseUrl === "undefined") ); } @@ -730,7 +754,10 @@ export function listItemToWorker(item: WorkerListItem, current: WorkerLaunch | n workspaceId: current?.workerId === item.workerId ? current.workspaceId : null, clientToken: current?.workerId === item.workerId ? current.clientToken : null, ownerToken: current?.workerId === item.workerId ? current.ownerToken : null, - hostToken: current?.workerId === item.workerId ? current.hostToken : null + hostToken: current?.workerId === item.workerId ? current.hostToken : null, + connectGrant: current?.workerId === item.workerId ? current.connectGrant : null, + connectGrantExpiresAt: current?.workerId === item.workerId ? current.connectGrantExpiresAt : null, + connectGrantDenBaseUrl: current?.workerId === item.workerId ? current.connectGrantDenBaseUrl : null, }; } @@ -772,17 +799,19 @@ function buildWorkspaceUrl(instanceUrl: string, workspaceId: string): string { export function buildOpenworkDeepLink( openworkUrl: string | null, - accessToken: string | null, + connectGrant: string | null, + denBaseUrl: string | null, workerId: string | null, workerName: string | null ): string | null { - if (!openworkUrl || !accessToken) { + if (!openworkUrl || !connectGrant || !denBaseUrl) { return null; } const params = new URLSearchParams({ openworkHostUrl: openworkUrl, - openworkToken: accessToken, + grant: connectGrant, + denBaseUrl, source: "openwork-web" }); @@ -800,12 +829,13 @@ export function buildOpenworkDeepLink( export function buildOpenworkAppConnectUrl( appConnectBaseUrl: string, openworkUrl: string | null, - accessToken: string | null, + connectGrant: string | null, + denBaseUrl: string | null, workerId: string | null, workerName: string | null, options?: { autoConnect?: boolean } ): string | null { - if (!appConnectBaseUrl || !openworkUrl || !accessToken) { + if (!appConnectBaseUrl || !openworkUrl || !connectGrant || !denBaseUrl) { return null; } @@ -826,7 +856,8 @@ export function buildOpenworkAppConnectUrl( } connectUrl.searchParams.set("openworkHostUrl", openworkUrl); - connectUrl.searchParams.set("openworkToken", accessToken); + connectUrl.searchParams.set("grant", connectGrant); + connectUrl.searchParams.set("denBaseUrl", denBaseUrl); if (options?.autoConnect) { connectUrl.searchParams.set("autoConnect", "1"); } diff --git a/ee/apps/den-web/app/(den)/_providers/den-flow-provider.tsx b/ee/apps/den-web/app/(den)/_providers/den-flow-provider.tsx index 6f4440303..bf5e148d1 100644 --- a/ee/apps/den-web/app/(den)/_providers/den-flow-provider.tsx +++ b/ee/apps/den-web/app/(den)/_providers/den-flow-provider.tsx @@ -250,18 +250,21 @@ export function DenFlowProvider({ children }: { children: ReactNode }) { ? listItemToWorker(selectedWorker, worker) : worker; const openworkConnectUrl = activeWorker?.openworkUrl ?? activeWorker?.instanceUrl ?? null; - const preferredOpenworkToken = activeWorker?.clientToken ?? activeWorker?.ownerToken ?? null; + const preferredConnectGrant = activeWorker?.connectGrant ?? null; + const preferredConnectGrantDenBaseUrl = activeWorker?.connectGrantDenBaseUrl ?? null; const hasWorkspaceScopedUrl = Boolean(openworkConnectUrl && /\/w\/[^/?#]+/.test(openworkConnectUrl)); const openworkDeepLink = buildOpenworkDeepLink( openworkConnectUrl, - preferredOpenworkToken, + preferredConnectGrant, + preferredConnectGrantDenBaseUrl, activeWorker?.workerId ?? null, activeWorker?.workerName ?? null ); const openworkAppConnectUrl = buildOpenworkAppConnectUrl( OPENWORK_APP_CONNECT_BASE_URL, openworkConnectUrl, - preferredOpenworkToken, + preferredConnectGrant, + preferredConnectGrantDenBaseUrl, activeWorker?.workerId ?? null, activeWorker?.workerName ?? null, { autoConnect: true } @@ -553,7 +556,7 @@ export function DenFlowProvider({ children }: { children: ReactNode }) { }; } - const accessToken = candidate.clientToken?.trim() ?? candidate.ownerToken?.trim() ?? ""; + const accessToken = candidate.ownerToken?.trim() ?? candidate.clientToken?.trim() ?? ""; if (!accessToken) { const mountedWorkspaceId = parseWorkspaceIdFromUrl(instanceUrl); return { @@ -1467,7 +1470,10 @@ export function DenFlowProvider({ children }: { children: ReactNode }) { workspaceId: null, clientToken: null, ownerToken: null, - hostToken: null + hostToken: null, + connectGrant: null, + connectGrantExpiresAt: null, + connectGrantDenBaseUrl: null, }; const shouldUpdateActiveWorker = worker?.workerId === summary.workerId || (!background && workerLookupId === summary.workerId); @@ -1552,7 +1558,10 @@ export function DenFlowProvider({ children }: { children: ReactNode }) { workspaceId: tokens.workspaceId ?? worker.workspaceId, clientToken: tokens.clientToken, ownerToken: tokens.ownerToken, - hostToken: tokens.hostToken + hostToken: tokens.hostToken, + connectGrant: tokens.connectGrant, + connectGrantExpiresAt: tokens.connectGrantExpiresAt, + connectGrantDenBaseUrl: tokens.connectGrantDenBaseUrl, } : { workerId: id, @@ -1564,7 +1573,10 @@ export function DenFlowProvider({ children }: { children: ReactNode }) { workspaceId: tokens.workspaceId, clientToken: tokens.clientToken, ownerToken: tokens.ownerToken, - hostToken: tokens.hostToken + hostToken: tokens.hostToken, + connectGrant: tokens.connectGrant, + connectGrantExpiresAt: tokens.connectGrantExpiresAt, + connectGrantDenBaseUrl: tokens.connectGrantDenBaseUrl, }; const resolvedWorker = await withResolvedOpenworkCredentials(nextWorker, { quiet: true }); @@ -1887,7 +1899,10 @@ export function DenFlowProvider({ children }: { children: ReactNode }) { workspaceId: parsed.workspaceId ?? parseWorkspaceIdFromUrl(parsed.instanceUrl ?? ""), clientToken: null, ownerToken: null, - hostToken: null + hostToken: null, + connectGrant: null, + connectGrantExpiresAt: null, + connectGrantDenBaseUrl: null, }; setWorker(restored); @@ -1909,7 +1924,10 @@ export function DenFlowProvider({ children }: { children: ReactNode }) { ...worker, clientToken: null, ownerToken: null, - hostToken: null + hostToken: null, + connectGrant: null, + connectGrantExpiresAt: null, + connectGrantDenBaseUrl: null, }; window.localStorage.setItem(LAST_WORKER_STORAGE_KEY, JSON.stringify(serializable)); diff --git a/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/background-agents-screen.tsx b/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/background-agents-screen.tsx index 9d3f00dc5..23d4575cb 100644 --- a/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/background-agents-screen.tsx +++ b/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/background-agents-screen.tsx @@ -348,14 +348,16 @@ export function BackgroundAgentsScreen() { openworkAppConnectUrl: buildOpenworkAppConnectUrl( OPENWORK_APP_CONNECT_BASE_URL, tokens.openworkUrl, - tokens.clientToken, + tokens.connectGrant, + tokens.connectGrantDenBaseUrl, workerId, workerName, { autoConnect: true }, ), openworkDeepLink: buildOpenworkDeepLink( tokens.openworkUrl, - tokens.clientToken, + tokens.connectGrant, + tokens.connectGrantDenBaseUrl, workerId, workerName, ), diff --git a/ee/packages/den-db/src/schema.ts b/ee/packages/den-db/src/schema.ts index 5721269f8..aad23caf7 100644 --- a/ee/packages/den-db/src/schema.ts +++ b/ee/packages/den-db/src/schema.ts @@ -133,6 +133,22 @@ export const DesktopHandoffGrantTable = mysqlTable( ], ) +export const WorkerConnectGrantTable = mysqlTable( + "worker_connect_grant", + { + id: varchar("id", { length: 64 }).notNull().primaryKey(), + worker_id: denTypeIdColumn("worker", "worker_id").notNull(), + token_scope: mysqlEnum("token_scope", ["host", "client"]).notNull().default("host"), + expires_at: timestamp("expires_at", { fsp: 3 }).notNull(), + consumed_at: timestamp("consumed_at", { fsp: 3 }), + created_at: timestamp("created_at", { fsp: 3 }).notNull().defaultNow(), + }, + (table) => [ + index("worker_connect_grant_worker_id").on(table.worker_id), + index("worker_connect_grant_expires_at").on(table.expires_at), + ], +) + export const OrganizationTable = mysqlTable( "organization", {