From 48e718cc13fef9b616b1df0a47f97c09a23dd724 Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Fri, 19 Jun 2026 06:30:53 -0400 Subject: [PATCH 01/25] feat(core): add remote profile storage --- packages/core/src/remote/credential-store.ts | 59 ++++ packages/core/src/remote/options.ts | 8 + packages/core/src/remote/profile-store.ts | 320 +++++++++++++++++++ packages/core/src/remote/profiles.ts | 95 ++++++ packages/core/test/remote-profiles.test.ts | 251 +++++++++++++++ 5 files changed, 733 insertions(+) create mode 100644 packages/core/src/remote/credential-store.ts create mode 100644 packages/core/src/remote/profile-store.ts create mode 100644 packages/core/src/remote/profiles.ts create mode 100644 packages/core/test/remote-profiles.test.ts diff --git a/packages/core/src/remote/credential-store.ts b/packages/core/src/remote/credential-store.ts new file mode 100644 index 00000000..90095449 --- /dev/null +++ b/packages/core/src/remote/credential-store.ts @@ -0,0 +1,59 @@ +import { + chmodSync, + existsSync, + mkdirSync, + readFileSync, + renameSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { join } from "node:path"; +import { DEFAULT_AUTH_DIR } from "../config/paths"; +import type { RemoteProfileCredential } from "./profiles"; + +export type RemoteCredentialStoreOptions = { + root?: string | undefined; +}; + +export class FileRemoteCredentialStore { + readonly root: string; + + constructor(options: RemoteCredentialStoreOptions = {}) { + this.root = options.root ?? join(DEFAULT_AUTH_DIR, "remote-credentials"); + } + + pathForKey(key: string): string { + return join(this.root, `${encodeURIComponent(key)}.json`); + } + + async load(key: string): Promise { + const path = this.pathForKey(key); + if (!existsSync(path)) return undefined; + return JSON.parse(readFileSync(path, "utf8")) as RemoteProfileCredential; + } + + async save(key: string, credential: RemoteProfileCredential): Promise { + mkdirSync(this.root, { recursive: true, mode: 0o700 }); + try { + chmodSync(this.root, 0o700); + } catch { + // Best effort on platforms without POSIX permissions. + } + const path = this.pathForKey(key); + const tempPath = `${path}.${process.pid}.${Date.now()}.tmp`; + writeFileSync(tempPath, `${JSON.stringify(credential, null, 2)}\n`, { mode: 0o600 }); + try { + chmodSync(tempPath, 0o600); + } catch { + // Best effort on platforms without POSIX permissions. + } + renameSync(tempPath, path); + } + + async delete(key: string): Promise { + const path = this.pathForKey(key); + if (!existsSync(path)) return false; + rmSync(path, { force: true }); + return true; + } +} diff --git a/packages/core/src/remote/options.ts b/packages/core/src/remote/options.ts index e1db9a79..adbd3e12 100644 --- a/packages/core/src/remote/options.ts +++ b/packages/core/src/remote/options.ts @@ -207,6 +207,14 @@ export function hostedCloudWorkspaceFromRemoteUrl(value: string): string | undef } } +export function normalizeRemoteProfileHostUrl(value: string): string { + const url = parseServerBaseUrl(value); + if (isCapletsCloudUrl(url.toString())) { + return `${url.origin}/`; + } + return url.toString(); +} + export function projectBindingWebSocketUrlForBase(baseUrl: URL): URL { return webSocketUrl(appendBasePath(baseUrl, "v1/attach/project-bindings/connect")); } diff --git a/packages/core/src/remote/profile-store.ts b/packages/core/src/remote/profile-store.ts new file mode 100644 index 00000000..ae5c8498 --- /dev/null +++ b/packages/core/src/remote/profile-store.ts @@ -0,0 +1,320 @@ +import { + chmodSync, + existsSync, + mkdirSync, + readFileSync, + readdirSync, + renameSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { dirname, join } from "node:path"; +import { CloudAuthStore } from "../cloud-auth/store"; +import type { CloudAuthCredentials } from "../cloud-auth/store"; +import { DEFAULT_AUTH_DIR } from "../config/paths"; +import { CapletsError } from "../errors"; +import { FileRemoteCredentialStore } from "./credential-store"; +import { + remoteProfileKey, + remoteProfileStatus, + selectedWorkspaceKey, + type RemoteProfileCredential, + type RemoteProfileStatus, +} from "./profiles"; +import { hostedCloudWorkspaceFromRemoteUrl, normalizeRemoteProfileHostUrl } from "./options"; + +type StoredRemoteProfile = { + version: 1; + kind: "cloud"; + key: string; + hostUrl: string; + workspaceId: string; + workspaceSlug?: string | undefined; + clientLabel?: string | undefined; + createdAt: string; + updatedAt: string; +}; + +type SelectedCloudWorkspace = { + version: 1; + hostUrl: string; + workspace: string; + profileKey: string; + selectedAt: string; +}; + +export type SaveCloudProfileInput = { + hostUrl: string; + workspaceId: string; + workspaceSlug?: string | undefined; + clientLabel?: string | undefined; + credentials: RemoteProfileCredential; + now?: Date | undefined; +}; + +export type CloudProfileLookup = { + hostUrl: string; + workspace?: string | undefined; +}; + +export type RemoteProfileStoreOptions = { + root?: string | undefined; + credentials?: FileRemoteCredentialStore | undefined; + legacyCloudAuthStore?: CloudAuthStore | undefined; +}; + +export class FileRemoteProfileStore { + readonly root: string; + readonly credentials: FileRemoteCredentialStore; + readonly legacyCloudAuthStore?: CloudAuthStore | undefined; + + constructor(options: RemoteProfileStoreOptions = {}) { + this.root = options.root ?? join(DEFAULT_AUTH_DIR, "remote-profiles"); + this.credentials = + options.credentials ?? + new FileRemoteCredentialStore({ root: join(this.root, "credentials") }); + this.legacyCloudAuthStore = options.legacyCloudAuthStore; + } + + async saveCloudProfile(input: SaveCloudProfileInput): Promise { + const hostUrl = normalizeRemoteProfileHostUrl(input.hostUrl); + const workspace = cloudWorkspace(input); + const key = remoteProfileKey({ kind: "cloud", hostUrl, workspace }); + const now = (input.now ?? new Date()).toISOString(); + const existing = this.readProfile(key); + const profile: StoredRemoteProfile = { + version: 1, + kind: "cloud", + key, + hostUrl, + workspaceId: input.workspaceId, + ...(input.workspaceSlug ? { workspaceSlug: input.workspaceSlug } : {}), + ...(input.clientLabel ? { clientLabel: input.clientLabel } : {}), + createdAt: existing?.createdAt ?? now, + updatedAt: now, + }; + + await this.credentials.save(key, input.credentials); + this.writeJson(this.profilePath(key), profile); + this.writeJson(this.selectedWorkspacePath(hostUrl), { + version: 1, + hostUrl, + workspace, + profileKey: key, + selectedAt: now, + } satisfies SelectedCloudWorkspace); + + return await this.statusFor(profile, input.credentials, true); + } + + async getCloudProfileStatus(input: CloudProfileLookup): Promise { + const hostUrl = normalizeRemoteProfileHostUrl(input.hostUrl); + const workspace = input.workspace ?? hostedCloudWorkspaceFromRemoteUrl(input.hostUrl); + if (workspace) { + const found = await this.findCloudStatus(hostUrl, workspace); + if (found) return found; + return this.migrateLegacyCloudProfile(hostUrl, workspace); + } + + const selected = this.readSelectedWorkspace(hostUrl); + if (selected) return this.statusByKey(selected.profileKey, true); + if (this.listProfilesForHost(hostUrl).length > 0) { + throw new CapletsError( + "REQUEST_INVALID", + "Cloud Remote Profile requires a selected or explicit workspace.", + ); + } + return this.migrateLegacyCloudProfile(hostUrl); + } + + async listCloudProfileStatuses(hostUrlInput: string): Promise { + const hostUrl = normalizeRemoteProfileHostUrl(hostUrlInput); + const selected = this.readSelectedWorkspace(hostUrl)?.profileKey; + const statuses = await Promise.all( + this.listProfilesForHost(hostUrl).map(async (profile) => + this.statusFor(profile, undefined, profile.key === selected), + ), + ); + return statuses.sort((left, right) => + (left.workspaceSlug ?? left.workspaceId ?? "").localeCompare( + right.workspaceSlug ?? right.workspaceId ?? "", + ), + ); + } + + async logoutCloudProfile(input: CloudProfileLookup): Promise { + const hostUrl = normalizeRemoteProfileHostUrl(input.hostUrl); + const workspace = input.workspace ?? hostedCloudWorkspaceFromRemoteUrl(input.hostUrl); + const selected = this.readSelectedWorkspace(hostUrl); + let key: string | undefined; + if (workspace) { + key = this.listProfilesForHost(hostUrl).find((profile) => + profileMatchesWorkspace(profile, workspace), + )?.key; + } else if (selected) { + key = selected.profileKey; + } else if (this.listProfilesForHost(hostUrl).length > 0) { + throw new CapletsError( + "REQUEST_INVALID", + "Cloud Remote Profile requires a selected or explicit workspace.", + ); + } + if (!key) return false; + rmSync(this.profilePath(key), { force: true }); + await this.credentials.delete(key); + if (selected?.profileKey === key) rmSync(this.selectedWorkspacePath(hostUrl), { force: true }); + return true; + } + + async clearSelectedCloudWorkspace(hostUrlInput: string): Promise { + const path = this.selectedWorkspacePath(normalizeRemoteProfileHostUrl(hostUrlInput)); + if (!existsSync(path)) return false; + rmSync(path, { force: true }); + return true; + } + + private async findCloudStatus( + hostUrl: string, + workspace: string, + ): Promise { + const selected = this.readSelectedWorkspace(hostUrl)?.profileKey; + const profile = this.listProfilesForHost(hostUrl).find((candidate) => + profileMatchesWorkspace(candidate, workspace), + ); + if (!profile) return undefined; + return this.statusFor(profile, undefined, profile.key === selected); + } + + private async migrateLegacyCloudProfile( + hostUrl: string, + workspace?: string, + ): Promise { + const legacy = await this.legacyCloudAuthStore?.load(); + if (!legacy) return undefined; + if (normalizeRemoteProfileHostUrl(legacy.cloudUrl) !== hostUrl) return undefined; + if (workspace && workspace !== legacy.workspaceSlug && workspace !== legacy.workspaceId) { + return undefined; + } + return this.saveCloudProfile({ + hostUrl, + workspaceId: legacy.workspaceId, + ...(legacy.workspaceSlug ? { workspaceSlug: legacy.workspaceSlug } : {}), + clientLabel: legacy.deviceName, + credentials: legacyCredential(legacy), + now: legacy.createdAt ? new Date(legacy.createdAt) : undefined, + }); + } + + private async statusByKey( + key: string, + selected: boolean, + ): Promise { + const profile = this.readProfile(key); + if (!profile) return undefined; + return this.statusFor(profile, undefined, selected); + } + + private async statusFor( + profile: StoredRemoteProfile, + credential: RemoteProfileCredential | undefined, + selected: boolean, + ): Promise { + const build = (loadedCredential: RemoteProfileCredential | undefined): RemoteProfileStatus => + remoteProfileStatus({ + kind: "cloud", + key: profile.key, + hostUrl: profile.hostUrl, + workspaceId: profile.workspaceId, + workspaceSlug: profile.workspaceSlug, + clientLabel: profile.clientLabel, + createdAt: profile.createdAt, + updatedAt: profile.updatedAt, + selected, + credential: loadedCredential, + }); + if (credential !== undefined) return build(credential); + return build(await this.credentials.load(profile.key)); + } + + private listProfilesForHost(hostUrl: string): StoredRemoteProfile[] { + const dir = this.profilesDir(); + if (!existsSync(dir)) return []; + return readdirSync(dir, { withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name.endsWith(".json")) + .map((entry) => this.readProfileFile(join(dir, entry.name))) + .filter((profile): profile is StoredRemoteProfile => Boolean(profile)) + .filter((profile) => profile.kind === "cloud" && profile.hostUrl === hostUrl); + } + + private readProfile(key: string): StoredRemoteProfile | undefined { + return this.readProfileFile(this.profilePath(key)); + } + + private readProfileFile(path: string): StoredRemoteProfile | undefined { + if (!existsSync(path)) return undefined; + return JSON.parse(readFileSync(path, "utf8")) as StoredRemoteProfile; + } + + private readSelectedWorkspace(hostUrl: string): SelectedCloudWorkspace | undefined { + const path = this.selectedWorkspacePath(hostUrl); + if (!existsSync(path)) return undefined; + return JSON.parse(readFileSync(path, "utf8")) as SelectedCloudWorkspace; + } + + private profilePath(key: string): string { + return join(this.profilesDir(), `${encodeURIComponent(key)}.json`); + } + + private selectedWorkspacePath(hostUrl: string): string { + return join(this.selectionsDir(), `${encodeURIComponent(selectedWorkspaceKey(hostUrl))}.json`); + } + + private profilesDir(): string { + return join(this.root, "profiles"); + } + + private selectionsDir(): string { + return join(this.root, "selections"); + } + + private writeJson(path: string, value: unknown): void { + const directory = dirname(path); + mkdirSync(directory, { recursive: true, mode: 0o700 }); + try { + chmodSync(directory, 0o700); + } catch { + // Best effort on platforms without POSIX permissions. + } + const tempPath = `${path}.${process.pid}.${Date.now()}.tmp`; + writeFileSync(tempPath, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 }); + try { + chmodSync(tempPath, 0o600); + } catch { + // Best effort on platforms without POSIX permissions. + } + renameSync(tempPath, path); + } +} + +function cloudWorkspace(input: SaveCloudProfileInput): string { + return ( + input.workspaceSlug ?? + input.workspaceId ?? + hostedCloudWorkspaceFromRemoteUrl(input.hostUrl) ?? + "" + ); +} + +function profileMatchesWorkspace(profile: StoredRemoteProfile, workspace: string): boolean { + return profile.workspaceSlug === workspace || profile.workspaceId === workspace; +} + +function legacyCredential(credentials: CloudAuthCredentials): RemoteProfileCredential { + return { + accessToken: credentials.accessToken, + refreshToken: credentials.refreshToken, + expiresAt: credentials.expiresAt, + ...(credentials.scope ? { scope: credentials.scope } : {}), + ...(credentials.tokenType ? { tokenType: credentials.tokenType } : {}), + }; +} diff --git a/packages/core/src/remote/profiles.ts b/packages/core/src/remote/profiles.ts new file mode 100644 index 00000000..bb4cd77e --- /dev/null +++ b/packages/core/src/remote/profiles.ts @@ -0,0 +1,95 @@ +import { CapletsError } from "../errors"; +import { hostedCloudWorkspaceFromRemoteUrl, normalizeRemoteProfileHostUrl } from "./options"; + +export type RemoteProfileKind = "cloud" | "self-hosted"; + +export type RemoteProfileKeyInput = { + kind: RemoteProfileKind; + hostUrl: string; + workspace?: string | undefined; +}; + +export type RemoteProfileCredential = { + accessToken?: string | undefined; + refreshToken?: string | undefined; + tokenType?: string | undefined; + expiresAt?: string | undefined; + scope?: string[] | undefined; + clientSecret?: string | undefined; + pairingCode?: string | undefined; +}; + +export type RemoteProfileStatusInput = { + kind: RemoteProfileKind; + hostUrl: string; + key?: string | undefined; + workspaceId?: string | undefined; + workspaceSlug?: string | undefined; + selected?: boolean | undefined; + clientLabel?: string | undefined; + createdAt?: string | undefined; + updatedAt?: string | undefined; + credential?: RemoteProfileCredential | undefined; +}; + +export type RemoteProfileStatus = { + authenticated: boolean; + kind: RemoteProfileKind; + key: string; + hostUrl: string; + workspaceId?: string | undefined; + workspaceSlug?: string | undefined; + selected: boolean; + clientLabel?: string | undefined; + createdAt?: string | undefined; + updatedAt?: string | undefined; + expiresAt?: string | undefined; + scope?: string[] | undefined; + tokenType?: string | undefined; +}; + +export function remoteProfileKey(input: RemoteProfileKeyInput): string { + const hostUrl = normalizeRemoteProfileHostUrl(input.hostUrl); + if (input.kind === "cloud") { + const workspace = input.workspace ?? hostedCloudWorkspaceFromRemoteUrl(input.hostUrl); + if (!workspace) { + throw new CapletsError("REQUEST_INVALID", "Cloud Remote Profile requires a workspace."); + } + return `cloud:${hostUrl}:${workspace}`; + } + return `self-hosted:${hostUrl}`; +} + +export function selectedWorkspaceKey(hostUrl: string): string { + return `cloud:${normalizeRemoteProfileHostUrl(hostUrl)}:selected-workspace`; +} + +export function remoteProfileStatus(input: RemoteProfileStatusInput): RemoteProfileStatus { + const hostUrl = normalizeRemoteProfileHostUrl(input.hostUrl); + const key = + input.key ?? + remoteProfileKey({ + kind: input.kind, + hostUrl, + workspace: input.workspaceSlug ?? input.workspaceId, + }); + const expiresAt = input.credential?.expiresAt; + const expired = Number.isFinite(Date.parse(expiresAt ?? "")) + ? Date.parse(expiresAt ?? "") <= Date.now() + : false; + return { + authenticated: Boolean(input.credential) && !expired, + kind: input.kind, + key, + hostUrl, + ...(input.workspaceId ? { workspaceId: input.workspaceId } : {}), + ...(input.workspaceSlug ? { workspaceSlug: input.workspaceSlug } : {}), + selected: Boolean(input.selected), + ...(input.clientLabel ? { clientLabel: input.clientLabel } : {}), + ...(input.createdAt ? { createdAt: input.createdAt } : {}), + ...(input.updatedAt ? { updatedAt: input.updatedAt } : {}), + ...(expiresAt ? { expiresAt } : {}), + ...(input.credential?.scope ? { scope: input.credential.scope } : {}), + ...(input.credential?.tokenType ? { tokenType: input.credential.tokenType } : {}), + }; +} diff --git a/packages/core/test/remote-profiles.test.ts b/packages/core/test/remote-profiles.test.ts new file mode 100644 index 00000000..def72312 --- /dev/null +++ b/packages/core/test/remote-profiles.test.ts @@ -0,0 +1,251 @@ +import { existsSync, mkdtempSync, readdirSync, rmSync, statSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { CloudAuthStore, type CloudAuthCredentials } from "../src/cloud-auth/store"; +import { CapletsError } from "../src/errors"; +import { FileRemoteCredentialStore } from "../src/remote/credential-store"; +import { FileRemoteProfileStore } from "../src/remote/profile-store"; +import { + remoteProfileKey, + remoteProfileStatus, + selectedWorkspaceKey, +} from "../src/remote/profiles"; + +const tempDirs: string[] = []; + +const cloudCredentials = { + accessToken: "access_secret", + refreshToken: "refresh_secret", + expiresAt: "2099-06-19T12:00:00.000Z", + scope: ["mcp:tools"], + tokenType: "Bearer", + clientSecret: "client_secret", + pairingCode: "pairing_code", +}; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +describe("Remote Profile storage", () => { + it("saves a Cloud profile with redacted status and selected workspace pointer", async () => { + const store = tempRemoteProfileStore(); + + const status = await store.saveCloudProfile({ + hostUrl: "https://cloud.caplets.dev/v1/ws/team/mcp", + workspaceId: "ws_123", + workspaceSlug: "team", + clientLabel: "Ian's MacBook", + credentials: cloudCredentials, + now: new Date("2026-06-19T10:00:00.000Z"), + }); + + expect(status).toEqual({ + authenticated: true, + kind: "cloud", + key: remoteProfileKey({ + kind: "cloud", + hostUrl: "https://cloud.caplets.dev/", + workspace: "team", + }), + hostUrl: "https://cloud.caplets.dev/", + workspaceId: "ws_123", + workspaceSlug: "team", + selected: true, + clientLabel: "Ian's MacBook", + createdAt: "2026-06-19T10:00:00.000Z", + updatedAt: "2026-06-19T10:00:00.000Z", + expiresAt: "2099-06-19T12:00:00.000Z", + scope: ["mcp:tools"], + tokenType: "Bearer", + }); + expect(JSON.stringify(status)).not.toContain("access_secret"); + expect(JSON.stringify(status)).not.toContain("refresh_secret"); + expect(JSON.stringify(status)).not.toContain("client_secret"); + expect(JSON.stringify(status)).not.toContain("pairing_code"); + await expect( + store.getCloudProfileStatus({ hostUrl: "https://cloud.caplets.dev" }), + ).resolves.toMatchObject({ workspaceSlug: "team", selected: true }); + }); + + it("keeps Cloud workspaces distinct under one host and only logs out the selected profile", async () => { + const store = tempRemoteProfileStore(); + await store.saveCloudProfile({ + hostUrl: "https://cloud.caplets.dev", + workspaceId: "ws_a", + workspaceSlug: "alpha", + credentials: { ...cloudCredentials, accessToken: "alpha_access" }, + }); + await store.saveCloudProfile({ + hostUrl: "https://cloud.caplets.dev", + workspaceId: "ws_b", + workspaceSlug: "beta", + credentials: { ...cloudCredentials, accessToken: "beta_access" }, + }); + + expect(await store.listCloudProfileStatuses("https://cloud.caplets.dev")).toEqual([ + expect.objectContaining({ workspaceSlug: "alpha", selected: false }), + expect.objectContaining({ workspaceSlug: "beta", selected: true }), + ]); + + await store.logoutCloudProfile({ hostUrl: "https://cloud.caplets.dev" }); + + expect(await store.listCloudProfileStatuses("https://cloud.caplets.dev")).toEqual([ + expect.objectContaining({ workspaceSlug: "alpha", selected: false }), + ]); + await expect( + store.credentials.load( + remoteProfileKey({ + kind: "cloud", + hostUrl: "https://cloud.caplets.dev/", + workspace: "alpha", + }), + ), + ).resolves.toMatchObject({ accessToken: "alpha_access" }); + }); + + it("requires an explicit workspace before mutating a bare Cloud host with no selected workspace", async () => { + const store = tempRemoteProfileStore(); + await store.saveCloudProfile({ + hostUrl: "https://cloud.caplets.dev", + workspaceId: "ws_a", + workspaceSlug: "alpha", + credentials: cloudCredentials, + }); + await store.clearSelectedCloudWorkspace("https://cloud.caplets.dev"); + + await expect( + store.getCloudProfileStatus({ hostUrl: "https://cloud.caplets.dev" }), + ).rejects.toThrow(expect.objectContaining({ code: "REQUEST_INVALID" }) as CapletsError); + await expect( + store.logoutCloudProfile({ hostUrl: "https://cloud.caplets.dev" }), + ).rejects.toThrow(expect.objectContaining({ code: "REQUEST_INVALID" }) as CapletsError); + }); + + it("rejects credential-bearing remote URLs before persisting profile data", async () => { + const root = tempDir("caplets-remote-profiles-"); + const store = new FileRemoteProfileStore({ root }); + + await expect( + store.saveCloudProfile({ + hostUrl: "https://user:pass@cloud.caplets.dev/v1/ws/team/mcp?token=query#refresh", + workspaceId: "ws_123", + workspaceSlug: "team", + credentials: cloudCredentials, + }), + ).rejects.toThrow(expect.objectContaining({ code: "REQUEST_INVALID" }) as CapletsError); + expect(readdirSync(root)).toEqual([]); + }); + + it("creates private directories and credential files with restrictive permissions", async () => { + const root = tempDir("caplets-remote-credentials-"); + const credentials = new FileRemoteCredentialStore({ root }); + + await credentials.save("profile-key", cloudCredentials); + + expect(await credentials.load("profile-key")).toEqual(cloudCredentials); + if (process.platform !== "win32") { + expect(statSync(root).mode & 0o777).toBe(0o700); + expect(statSync(credentials.pathForKey("profile-key")).mode & 0o777).toBe(0o600); + } + }); + + it("migrates legacy Cloud Auth through the remote profile store without deleting legacy state", async () => { + const legacyPath = join(tempDir("caplets-cloud-auth-"), "cloud-auth.json"); + const legacy = new CloudAuthStore({ path: legacyPath }); + await legacy.save({ + ...legacyCloudCredentials, + cloudUrl: "https://cloud.caplets.dev", + workspaceSlug: "team", + }); + const store = tempRemoteProfileStore({ legacyCloudAuthStore: legacy }); + + const status = await store.getCloudProfileStatus({ + hostUrl: "https://cloud.caplets.dev", + workspace: "team", + }); + + expect(status).toMatchObject({ + authenticated: true, + kind: "cloud", + hostUrl: "https://cloud.caplets.dev/", + workspaceId: "ws_123", + workspaceSlug: "team", + selected: true, + }); + expect(existsSync(legacyPath)).toBe(true); + expect(JSON.stringify(status)).not.toContain("legacy_access"); + await expect( + store.credentials.load( + remoteProfileKey({ + kind: "cloud", + hostUrl: "https://cloud.caplets.dev/", + workspace: "team", + }), + ), + ).resolves.toMatchObject({ accessToken: "legacy_access", refreshToken: "legacy_refresh" }); + }); +}); + +describe("Remote Profile helpers", () => { + it("derives normalized profile and selected workspace keys", () => { + expect( + remoteProfileKey({ + kind: "cloud", + hostUrl: "https://cloud.caplets.dev/v1/ws/team/mcp", + workspace: "team", + }), + ).toBe("cloud:https://cloud.caplets.dev/:team"); + expect(selectedWorkspaceKey("https://cloud.caplets.dev/v1/ws/team/mcp")).toBe( + "cloud:https://cloud.caplets.dev/:selected-workspace", + ); + }); + + it("redacts raw credential-shaped fields from profile status helpers", () => { + expect( + JSON.stringify( + remoteProfileStatus({ + kind: "cloud", + hostUrl: "https://cloud.caplets.dev", + workspaceId: "ws_123", + workspaceSlug: "team", + clientLabel: "Test", + credential: cloudCredentials, + }), + ), + ).not.toMatch(/access_secret|refresh_secret|client_secret|pairing_code/u); + }); +}); + +const legacyCloudCredentials: CloudAuthCredentials = { + version: 2, + cloudUrl: "https://cloud.caplets.dev", + workspaceId: "ws_123", + workspaceSlug: "team", + accessToken: "legacy_access", + refreshToken: "legacy_refresh", + expiresAt: "2099-06-19T12:00:00.000Z", + scope: ["mcp:tools"], + tokenType: "Bearer", + credentialFamilyId: "family_123", + deviceName: "Legacy CLI", + createdAt: "2026-06-19T09:00:00.000Z", + lastRefreshAt: "2026-06-19T09:00:00.000Z", +}; + +function tempRemoteProfileStore( + options: { legacyCloudAuthStore?: CloudAuthStore } = {}, +): FileRemoteProfileStore { + return new FileRemoteProfileStore({ + root: tempDir("caplets-remote-profiles-"), + credentials: new FileRemoteCredentialStore({ root: tempDir("caplets-remote-credentials-") }), + legacyCloudAuthStore: options.legacyCloudAuthStore, + }); +} + +function tempDir(prefix: string): string { + const dir = mkdtempSync(join(tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} From 29fc5293fba51a93bdd2a7f3d84dcb4c0889b39f Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Fri, 19 Jun 2026 06:44:16 -0400 Subject: [PATCH 02/25] feat(core): add remote credential pairing store --- packages/core/src/cli.ts | 5 + packages/core/src/remote/pairing.ts | 36 ++ .../src/remote/server-credential-store.ts | 331 ++++++++++++++++++ .../core/src/remote/server-credentials.ts | 23 ++ packages/core/src/serve/http.ts | 199 ++++++++++- packages/core/src/serve/options.ts | 18 +- packages/core/test/remote-pairing.test.ts | 188 ++++++++++ packages/core/test/serve-http.test.ts | 178 ++++++++++ packages/core/test/serve-options.test.ts | 22 ++ 9 files changed, 986 insertions(+), 14 deletions(-) create mode 100644 packages/core/src/remote/pairing.ts create mode 100644 packages/core/src/remote/server-credential-store.ts create mode 100644 packages/core/src/remote/server-credentials.ts create mode 100644 packages/core/test/remote-pairing.test.ts diff --git a/packages/core/src/cli.ts b/packages/core/src/cli.ts index f551b286..a747ef4a 100644 --- a/packages/core/src/cli.ts +++ b/packages/core/src/cli.ts @@ -155,6 +155,7 @@ type DaemonInstallCommandOptions = { path?: string; user?: string; password?: string; + remoteStatePath?: string; allowUnauthenticatedHttp?: boolean; trustProxy?: boolean; json?: boolean; @@ -190,6 +191,7 @@ function addDaemonInstallOptions(command: Command): Command { .option("--path ", "HTTP service base path") .option("--user ", "HTTP Basic Auth username") .option("--password ", "HTTP Basic Auth password") + .option("--remote-state-path ", "server-owned remote credential state directory") .option( "--allow-unauthenticated-http", "allow unauthenticated HTTP serving on non-loopback hosts", @@ -268,6 +270,7 @@ function daemonInstallOptions(options: DaemonInstallCommandOptions): DaemonInsta ...(options.path !== undefined ? { path: options.path } : {}), ...(options.user !== undefined ? { user: options.user } : {}), ...(options.password !== undefined ? { password: options.password } : {}), + ...(options.remoteStatePath !== undefined ? { remoteStatePath: options.remoteStatePath } : {}), ...(options.allowUnauthenticatedHttp !== undefined ? { allowUnauthenticatedHttp: options.allowUnauthenticatedHttp } : {}), @@ -531,6 +534,7 @@ export function createProgram(io: CliIO = {}): Command { .option("--path ", "HTTP service base path") .option("--user ", "HTTP Basic Auth username") .option("--password ", "HTTP Basic Auth password") + .option("--remote-state-path ", "server-owned remote credential state directory") .option( "--allow-unauthenticated-http", "allow unauthenticated HTTP serving on non-loopback hosts", @@ -544,6 +548,7 @@ export function createProgram(io: CliIO = {}): Command { path?: string; user?: string; password?: string; + remoteStatePath?: string; allowUnauthenticatedHttp?: boolean; trustProxy?: boolean; }) => { diff --git a/packages/core/src/remote/pairing.ts b/packages/core/src/remote/pairing.ts new file mode 100644 index 00000000..9bddd6b6 --- /dev/null +++ b/packages/core/src/remote/pairing.ts @@ -0,0 +1,36 @@ +import { randomBytes } from "node:crypto"; + +const PAIRING_CODE_PREFIX = "cap_pair"; + +export type ParsedPairingCode = { + codeId: string; + secret: string; +}; + +export function createPairingCode(): { codeId: string; code: string; secret: string } { + const codeId = randomPairingPart(8); + const secret = randomPairingPart(24); + return { codeId, secret, code: createPairingCodeVerifier(codeId, secret) }; +} + +export function createPairingCodeVerifier(codeId: string, secret: string): string { + return `${PAIRING_CODE_PREFIX}_${codeId}_${secret}`; +} + +export function parsePairingCode(value: string): ParsedPairingCode | undefined { + const match = value.match(/^cap_pair_([0-9A-Za-z_-]{8,})_([0-9A-Za-z_-]{24,})$/u); + if (!match?.[1] || !match[2]) return undefined; + return { codeId: match[1], secret: match[2] }; +} + +export function isPairingCodeFormat(value: string): boolean { + return parsePairingCode(value) !== undefined; +} + +export function randomToken(bytes = 32): string { + return randomBytes(bytes).toString("base64url"); +} + +function randomPairingPart(bytes: number): string { + return randomToken(bytes).replaceAll("_", "-"); +} diff --git a/packages/core/src/remote/server-credential-store.ts b/packages/core/src/remote/server-credential-store.ts new file mode 100644 index 00000000..9845a065 --- /dev/null +++ b/packages/core/src/remote/server-credential-store.ts @@ -0,0 +1,331 @@ +import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; +import { Buffer } from "node:buffer"; +import { createHash, timingSafeEqual, randomUUID } from "node:crypto"; +import { join } from "node:path"; +import { CapletsError } from "../errors"; +import { normalizeRemoteProfileHostUrl } from "./options"; +import { createPairingCode, parsePairingCode, randomToken } from "./pairing"; +import type { + IssuedRemoteClientCredentials, + RemoteClientStatus, + ValidatedRemoteClient, +} from "./server-credentials"; + +export type RemoteServerCredentialStoreOptions = { + dir: string; +}; + +export type CreatePairingCodeInput = { + hostUrl: string; + clientLabel?: string | undefined; + ttlMs?: number | undefined; + maxAttempts?: number | undefined; + now?: Date | undefined; +}; + +export type ExchangePairingCodeInput = { + hostUrl: string; + code: string; + clientLabel?: string | undefined; + now?: Date | undefined; +}; + +export type ValidateAccessTokenInput = { + hostUrl: string; + accessToken: string; + now?: Date | undefined; +}; + +export type RefreshClientCredentialsInput = { + hostUrl: string; + refreshToken: string; + now?: Date | undefined; +}; + +type StoredPairingCode = { + codeId: string; + hostUrl: string; + secretHash: string; + clientLabel?: string | undefined; + createdAt: string; + expiresAt: string; + attempts: number; + maxAttempts: number; + usedAt?: string | undefined; +}; + +type StoredRemoteClient = { + clientId: string; + clientLabel: string; + hostUrl: string; + accessTokenHash: string; + accessExpiresAt: string; + refreshTokenHash: string; + supersededRefreshTokenHashes: string[]; + refreshFamilyId: string; + createdAt: string; + lastUsedAt?: string | undefined; + revokedAt?: string | undefined; +}; + +type RemoteServerCredentialState = { + version: 1; + pairingCodes: StoredPairingCode[]; + clients: StoredRemoteClient[]; +}; + +const DEFAULT_PAIRING_CODE_TTL_MS = 10 * 60_000; +const DEFAULT_PAIRING_CODE_MAX_ATTEMPTS = 5; +const DEFAULT_ACCESS_TOKEN_TTL_MS = 15 * 60_000; +const STATE_FILE = "remote-server-credentials.json"; + +export class RemoteServerCredentialStore { + readonly dir: string; + + constructor(options: RemoteServerCredentialStoreOptions) { + this.dir = options.dir; + } + + createPairingCode(input: CreatePairingCodeInput): { + codeId: string; + code: string; + expiresAt: string; + } { + const now = input.now ?? new Date(); + const issued = createPairingCode(); + const state = this.loadState(); + state.pairingCodes.push({ + codeId: issued.codeId, + hostUrl: normalizeRemoteProfileHostUrl(input.hostUrl), + secretHash: hashSecret(issued.secret), + ...(input.clientLabel ? { clientLabel: input.clientLabel } : {}), + createdAt: now.toISOString(), + expiresAt: new Date( + now.getTime() + (input.ttlMs ?? DEFAULT_PAIRING_CODE_TTL_MS), + ).toISOString(), + attempts: 0, + maxAttempts: input.maxAttempts ?? DEFAULT_PAIRING_CODE_MAX_ATTEMPTS, + }); + this.saveState(state); + return { + codeId: issued.codeId, + code: issued.code, + expiresAt: state.pairingCodes.at(-1)!.expiresAt, + }; + } + + exchangePairingCode(input: ExchangePairingCodeInput): IssuedRemoteClientCredentials { + const now = input.now ?? new Date(); + const parsed = parsePairingCode(input.code); + if (!parsed) { + throw new CapletsError("AUTH_FAILED", "Pairing Code format is invalid."); + } + const state = this.loadState(); + const pairingCode = state.pairingCodes.find((candidate) => candidate.codeId === parsed.codeId); + if (!pairingCode) { + throw new CapletsError("AUTH_FAILED", "Pairing Code is unknown."); + } + const hostUrl = normalizeRemoteProfileHostUrl(input.hostUrl); + if (pairingCode.hostUrl !== hostUrl) { + throw new CapletsError("AUTH_FAILED", "Pairing Code belongs to a different host."); + } + if (pairingCode.usedAt) { + throw new CapletsError("AUTH_FAILED", "Pairing Code has already been used."); + } + if (Date.parse(pairingCode.expiresAt) <= now.getTime()) { + throw new CapletsError("AUTH_FAILED", "Pairing Code has expired."); + } + if (pairingCode.attempts >= pairingCode.maxAttempts) { + throw new CapletsError("AUTH_FAILED", "Pairing Code attempts exhausted."); + } + if (!safeHashEqual(hashSecret(parsed.secret), pairingCode.secretHash)) { + pairingCode.attempts += 1; + this.saveState(state); + throw new CapletsError( + "AUTH_FAILED", + pairingCode.attempts >= pairingCode.maxAttempts + ? "Pairing Code attempts exhausted." + : "Pairing Code is invalid.", + ); + } + + pairingCode.usedAt = now.toISOString(); + const clientId = `rcli_${randomToken(12)}`; + const accessToken = `cap_remote_access_${randomToken(32)}`; + const refreshToken = `cap_remote_refresh_${randomToken(32)}`; + const client: StoredRemoteClient = { + clientId, + clientLabel: input.clientLabel ?? pairingCode.clientLabel ?? "Caplets Remote Client", + hostUrl, + accessTokenHash: hashSecret(accessToken), + accessExpiresAt: new Date(now.getTime() + DEFAULT_ACCESS_TOKEN_TTL_MS).toISOString(), + refreshTokenHash: hashSecret(refreshToken), + supersededRefreshTokenHashes: [], + refreshFamilyId: randomUUID(), + createdAt: now.toISOString(), + }; + state.clients.push(client); + this.saveState(state); + return credentialsFromClient(client, accessToken, refreshToken); + } + + listClients(): RemoteClientStatus[] { + return this.loadState() + .clients.map(clientStatus) + .sort((left, right) => left.createdAt.localeCompare(right.createdAt)); + } + + revokeClient(clientId: string, now = new Date()): boolean { + const state = this.loadState(); + const client = state.clients.find((candidate) => candidate.clientId === clientId); + if (!client) return false; + client.revokedAt = client.revokedAt ?? now.toISOString(); + this.saveState(state); + return true; + } + + validateAccessToken(input: ValidateAccessTokenInput): ValidatedRemoteClient { + const now = input.now ?? new Date(); + const state = this.loadState(); + const client = state.clients.find((candidate) => + safeHashEqual(hashSecret(input.accessToken), candidate.accessTokenHash), + ); + if (!client) { + throw new CapletsError("AUTH_FAILED", "Remote client credential is invalid."); + } + validateClient(client, normalizeRemoteProfileHostUrl(input.hostUrl), now); + client.lastUsedAt = now.toISOString(); + this.saveState(state); + return { ...clientStatus(client), tokenType: "Bearer" }; + } + + refreshClientCredentials(input: RefreshClientCredentialsInput): IssuedRemoteClientCredentials { + const now = input.now ?? new Date(); + const state = this.loadState(); + const refreshTokenHash = hashSecret(input.refreshToken); + const client = state.clients.find((candidate) => + safeHashEqual(refreshTokenHash, candidate.refreshTokenHash), + ); + if (!client) { + const replayedClient = state.clients.find((candidate) => + candidate.supersededRefreshTokenHashes.some((supersededHash) => + safeHashEqual(refreshTokenHash, supersededHash), + ), + ); + if (replayedClient) { + replayedClient.revokedAt = now.toISOString(); + this.saveState(state); + throw new CapletsError("AUTH_FAILED", "Remote refresh credential is stale."); + } + throw new CapletsError("AUTH_FAILED", "Remote refresh credential is invalid."); + } + validateClient(client, normalizeRemoteProfileHostUrl(input.hostUrl), now, { + allowExpiredAccess: true, + }); + + const accessToken = `cap_remote_access_${randomToken(32)}`; + const refreshToken = `cap_remote_refresh_${randomToken(32)}`; + client.accessTokenHash = hashSecret(accessToken); + client.accessExpiresAt = new Date(now.getTime() + DEFAULT_ACCESS_TOKEN_TTL_MS).toISOString(); + client.supersededRefreshTokenHashes.push(client.refreshTokenHash); + client.refreshTokenHash = hashSecret(refreshToken); + client.lastUsedAt = now.toISOString(); + this.saveState(state); + return credentialsFromClient(client, accessToken, refreshToken); + } + + dumpForTest(): RemoteServerCredentialState { + return this.loadState(); + } + + private loadState(): RemoteServerCredentialState { + const path = this.statePath(); + if (!existsSync(path)) return { version: 1, pairingCodes: [], clients: [] }; + const parsed = JSON.parse(readFileSync(path, "utf8")) as Partial; + return { + version: 1, + pairingCodes: parsed.pairingCodes ?? [], + clients: (parsed.clients ?? []).map((client) => ({ + ...client, + supersededRefreshTokenHashes: client.supersededRefreshTokenHashes ?? [], + })), + }; + } + + private saveState(state: RemoteServerCredentialState): void { + mkdirSync(this.dir, { recursive: true, mode: 0o700 }); + try { + chmodSync(this.dir, 0o700); + } catch { + // Best effort on platforms without POSIX permissions. + } + const path = this.statePath(); + const tempPath = `${path}.${process.pid}.${Date.now()}.tmp`; + writeFileSync(tempPath, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 }); + try { + chmodSync(tempPath, 0o600); + } catch { + // Best effort on platforms without POSIX permissions. + } + renameSync(tempPath, path); + } + + private statePath(): string { + return join(this.dir, STATE_FILE); + } +} + +function validateClient( + client: StoredRemoteClient, + hostUrl: string, + now: Date, + options: { allowExpiredAccess?: boolean } = {}, +): void { + if (client.hostUrl !== hostUrl) { + throw new CapletsError("AUTH_FAILED", "Remote client credential is for a different host."); + } + if (client.revokedAt) { + throw new CapletsError("AUTH_FAILED", "Remote client credential has been revoked."); + } + if (!options.allowExpiredAccess && Date.parse(client.accessExpiresAt) <= now.getTime()) { + throw new CapletsError("AUTH_FAILED", "Remote client credential has expired."); + } +} + +function credentialsFromClient( + client: StoredRemoteClient, + accessToken: string, + refreshToken: string, +): IssuedRemoteClientCredentials { + return { + hostUrl: client.hostUrl, + clientId: client.clientId, + clientLabel: client.clientLabel, + accessToken, + refreshToken, + tokenType: "Bearer", + expiresAt: client.accessExpiresAt, + createdAt: client.createdAt, + }; +} + +function clientStatus(client: StoredRemoteClient): RemoteClientStatus { + return { + clientId: client.clientId, + clientLabel: client.clientLabel, + hostUrl: client.hostUrl, + createdAt: client.createdAt, + ...(client.lastUsedAt ? { lastUsedAt: client.lastUsedAt } : {}), + ...(client.revokedAt ? { revokedAt: client.revokedAt } : {}), + }; +} + +function hashSecret(secret: string): string { + return createHash("sha256").update(secret).digest("base64url"); +} + +function safeHashEqual(left: string, right: string): boolean { + const leftBuffer = Buffer.from(left); + const rightBuffer = Buffer.from(right); + return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer); +} diff --git a/packages/core/src/remote/server-credentials.ts b/packages/core/src/remote/server-credentials.ts new file mode 100644 index 00000000..06d1f69f --- /dev/null +++ b/packages/core/src/remote/server-credentials.ts @@ -0,0 +1,23 @@ +export type IssuedRemoteClientCredentials = { + hostUrl: string; + clientId: string; + clientLabel: string; + accessToken: string; + refreshToken: string; + tokenType: "Bearer"; + expiresAt: string; + createdAt: string; +}; + +export type RemoteClientStatus = { + clientId: string; + clientLabel: string; + hostUrl: string; + createdAt: string; + lastUsedAt?: string | undefined; + revokedAt?: string | undefined; +}; + +export type ValidatedRemoteClient = RemoteClientStatus & { + tokenType: "Bearer"; +}; diff --git a/packages/core/src/serve/http.ts b/packages/core/src/serve/http.ts index 7a9bbc0f..3fda84ed 100644 --- a/packages/core/src/serve/http.ts +++ b/packages/core/src/serve/http.ts @@ -19,6 +19,7 @@ import { } from "../remote-control/dispatch"; import { RemoteAuthFlowStore } from "../remote-control/auth-flow"; import type { RemoteCliRequest } from "../remote-control/types"; +import { RemoteServerCredentialStore } from "../remote/server-credential-store"; import type { HttpBasicAuthOptions, HttpServeOptions } from "./options"; import { CapletsMcpSession } from "./session"; @@ -28,6 +29,7 @@ type HttpServeIo = { authFlowStore?: RemoteAuthFlowStore; sessionFactory?: HttpMcpSessionFactory; exposeAttach?: boolean; + remoteCredentialStore?: RemoteServerCredentialStore; }; type HttpMcpSession = { @@ -62,6 +64,7 @@ export function createHttpServeApp( const paths = servicePaths(options.path); const authFlowStore = io.authFlowStore ?? new RemoteAuthFlowStore(); const exposeAttach = io.exposeAttach ?? true; + const protectedRouteAuth = routeAuth(options, io.remoteCredentialStore, paths.base); app.use( "*", logger((message, ...rest) => { @@ -75,7 +78,9 @@ export function createHttpServeApp( transport: "http", base: paths.base, versions: [versionDiscovery(paths, exposeAttach)], - auth: { type: "basic", enabled: options.auth.enabled }, + auth: io.remoteCredentialStore + ? { type: "remote_credentials", enabled: true } + : { type: "basic", enabled: options.auth.enabled }, }), ); @@ -87,7 +92,65 @@ export function createHttpServeApp( }), ); - app.all(paths.mcp, basicAuth(options.auth), async (c) => { + if (io.remoteCredentialStore) { + app.post(paths.pairingExchange, async (c) => { + try { + const parsed = await parseJsonObject(c.req.json(), "Pairing exchange request"); + const code = stringField(parsed, "code"); + const clientLabel = optionalStringField(parsed, "clientLabel"); + const credentials = io.remoteCredentialStore!.exchangePairingCode({ + hostUrl: publicHostUrl( + c.req.url, + paths.base, + options.publicOrigin, + options.trustProxy, + (name) => c.req.header(name), + ), + code, + ...(clientLabel ? { clientLabel } : {}), + }); + return c.json({ + clientId: credentials.clientId, + clientLabel: credentials.clientLabel, + accessToken: credentials.accessToken, + refreshToken: credentials.refreshToken, + tokenType: credentials.tokenType, + expiresAt: credentials.expiresAt, + }); + } catch (error) { + return remoteCredentialErrorResponse(error); + } + }); + + app.post(paths.remoteRefresh, async (c) => { + try { + const parsed = await parseJsonObject(c.req.json(), "Remote refresh request"); + const refreshToken = stringField(parsed, "refreshToken"); + const credentials = io.remoteCredentialStore!.refreshClientCredentials({ + hostUrl: publicHostUrl( + c.req.url, + paths.base, + options.publicOrigin, + options.trustProxy, + (name) => c.req.header(name), + ), + refreshToken, + }); + return c.json({ + clientId: credentials.clientId, + clientLabel: credentials.clientLabel, + accessToken: credentials.accessToken, + refreshToken: credentials.refreshToken, + tokenType: credentials.tokenType, + expiresAt: credentials.expiresAt, + }); + } catch (error) { + return remoteCredentialErrorResponse(error); + } + }); + } + + app.all(paths.mcp, protectedRouteAuth, async (c) => { const sessionId = c.req.header("mcp-session-id"); if (sessionId) { const existing = sessions.get(sessionId); @@ -135,16 +198,16 @@ export function createHttpServeApp( const attachHostProtection = dnsRebindingProtection(options); if (exposeAttach) { - app.get(paths.attachManifest, attachHostProtection, basicAuth(options.auth), async (c) => { + app.get(paths.attachManifest, attachHostProtection, protectedRouteAuth, async (c) => { const attachProjection = await buildAttachProjection(engine); return c.json(attachProjection.manifest); }); - app.get(paths.attachEvents, attachHostProtection, basicAuth(options.auth), () => + app.get(paths.attachEvents, attachHostProtection, protectedRouteAuth, () => attachEventsResponse(engine, attachEventStreams), ); - app.post(paths.attachInvoke, attachHostProtection, basicAuth(options.auth), async (c) => { + app.post(paths.attachInvoke, attachHostProtection, protectedRouteAuth, async (c) => { try { const request = await parseAttachInvokeRequest(c.req.json()); const attachProjection = await buildAttachProjection(engine); @@ -157,7 +220,7 @@ export function createHttpServeApp( }); } - app.post(paths.control, basicAuth(options.auth), async (c) => { + app.post(paths.control, protectedRouteAuth, async (c) => { let request: RemoteCliRequest; try { const parsed = await c.req.json(); @@ -190,18 +253,18 @@ export function createHttpServeApp( ); }); - app.get(routePath(paths.projectBindings, "connect"), basicAuth(options.auth), (c) => + app.get(routePath(paths.projectBindings, "connect"), protectedRouteAuth, (c) => c.json({ error: "websocket_upgrade_required" }, 426), ); - app.get(routePath(paths.projectBindings, ":bindingId/status"), basicAuth(options.auth), (c) => + app.get(routePath(paths.projectBindings, ":bindingId/status"), protectedRouteAuth, (c) => c.json({ bindingId: c.req.param("bindingId"), state: "not_attached", }), ); - app.post(routePath(paths.projectBindings, "sessions"), basicAuth(options.auth), (c) => { + app.post(routePath(paths.projectBindings, "sessions"), protectedRouteAuth, (c) => { const bindingId = randomUUID(); return c.json( { @@ -212,14 +275,14 @@ export function createHttpServeApp( ); }); - app.post(routePath(paths.projectBindings, ":bindingId/heartbeat"), basicAuth(options.auth), (c) => + app.post(routePath(paths.projectBindings, ":bindingId/heartbeat"), protectedRouteAuth, (c) => c.json({ ok: true, binding: { bindingId: c.req.param("bindingId"), state: "ready" }, }), ); - app.delete(routePath(paths.projectBindings, ":bindingId/session"), basicAuth(options.auth), (c) => + app.delete(routePath(paths.projectBindings, ":bindingId/session"), protectedRouteAuth, (c) => c.json({ ok: true, binding: { bindingId: c.req.param("bindingId"), state: "ended" }, @@ -288,7 +351,7 @@ function controlContext( authFlowStore, controlCallbackBaseUrl: new URL( controlPath, - publicOrigin ?? publicRequestOrigin(requestUrl, trustProxy, header), + publicOrigin ?? publicRequestOrigin(requestUrl, "/", trustProxy, header), ).toString(), writeErr, }; @@ -296,12 +359,13 @@ function controlContext( function publicRequestOrigin( requestUrl: string, + basePath: string, trustProxy: boolean, header: (name: string) => string | undefined, ): string { const url = new URL(requestUrl); if (!trustProxy) { - return `${url.protocol.slice(0, -1)}://${header("host") ?? url.host}`; + return `${url.protocol.slice(0, -1)}://${url.host}`; } const forwardedProto = firstForwardedValue(header("x-forwarded-proto")); const forwardedHost = firstForwardedValue(header("x-forwarded-host")); @@ -313,6 +377,19 @@ function publicRequestOrigin( return `${proto}://${host}`; } +function publicHostUrl( + requestUrl: string, + basePath: string, + publicOrigin: string | undefined, + trustProxy: boolean, + header: (name: string) => string | undefined, +): string { + return new URL( + basePath, + publicOrigin ?? publicRequestOrigin(requestUrl, basePath, trustProxy, header), + ).toString(); +} + function firstForwardedValue(value: string | undefined): string | undefined { return value?.split(",", 1)[0]?.trim() || undefined; } @@ -447,6 +524,13 @@ export async function serveHttp( const engine = new CapletsEngine(resolvedEngineOptions); const app = createHttpServeApp(options, engine, { writeErr, + ...(options.remoteCredentialStateDir + ? { + remoteCredentialStore: new RemoteServerCredentialStore({ + dir: options.remoteCredentialStateDir, + }), + } + : {}), control: { ...resolvedEngineOptions, projectCapletsRoot: projectCapletsRootForEngineOptions(resolvedEngineOptions), @@ -479,6 +563,13 @@ export async function serveHttpWithSessionFactory( const app = createHttpServeApp(options, engine, { writeErr, exposeAttach: false, + ...(options.remoteCredentialStateDir + ? { + remoteCredentialStore: new RemoteServerCredentialStore({ + dir: options.remoteCredentialStateDir, + }), + } + : {}), sessionFactory: createSession, control: { ...resolvedEngineOptions, @@ -524,10 +615,13 @@ export function servicePaths(base: string): { attachEvents: string; attachInvoke: string; projectBindings: string; + pairingExchange: string; + remoteRefresh: string; health: string; } { const version = routePath(base, "v1"); const attach = routePath(version, "attach"); + const remote = routePath(version, "remote"); return { base, version, @@ -537,6 +631,8 @@ export function servicePaths(base: string): { attachEvents: routePath(attach, "events"), attachInvoke: routePath(attach, "invoke"), projectBindings: routePath(attach, "project-bindings"), + pairingExchange: routePath(remote, "pairing/exchange"), + remoteRefresh: routePath(remote, "refresh"), health: routePath(version, "healthz"), }; } @@ -577,6 +673,83 @@ function basicAuth(auth: HttpBasicAuthOptions): MiddlewareHandler { }; } +function routeAuth( + options: HttpServeOptions, + remoteCredentialStore: RemoteServerCredentialStore | undefined, + basePath: string, +): MiddlewareHandler { + if (!remoteCredentialStore) return basicAuth(options.auth); + return async (c, next) => { + const header = c.req.header("authorization") ?? ""; + const token = bearerToken(header); + if (!token) { + return c.text("Unauthorized", 401); + } + try { + remoteCredentialStore.validateAccessToken({ + hostUrl: publicHostUrl( + c.req.url, + basePath, + options.publicOrigin, + options.trustProxy, + (name) => c.req.header(name), + ), + accessToken: token, + }); + } catch { + return c.text("Unauthorized", 401); + } + await next(); + }; +} + +function bearerToken(header: string): string | undefined { + const [scheme, token] = header.split(" "); + return scheme?.toLowerCase() === "bearer" && token ? token : undefined; +} + +async function parseJsonObject( + input: Promise, + label: string, +): Promise> { + let parsed: unknown; + try { + parsed = await input; + } catch (error) { + throw new CapletsError("REQUEST_INVALID", `${label} body must be valid JSON.`, error); + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new CapletsError("REQUEST_INVALID", `${label} JSON must be an object.`); + } + return parsed as Record; +} + +function stringField(input: Record, key: string): string { + const value = input[key]; + if (typeof value !== "string" || !value.trim()) { + throw new CapletsError("REQUEST_INVALID", `${key} must be a non-empty string.`); + } + return value.trim(); +} + +function optionalStringField(input: Record, key: string): string | undefined { + const value = input[key]; + if (value === undefined) return undefined; + if (typeof value !== "string" || !value.trim()) { + throw new CapletsError("REQUEST_INVALID", `${key} must be a non-empty string.`); + } + return value.trim(); +} + +function remoteCredentialErrorResponse(error: unknown): Response { + const safe = + error instanceof CapletsError + ? toSafeError(error, error.code) + : toSafeError(error, "AUTH_FAILED"); + const status = safe.code === "REQUEST_INVALID" ? 400 : 401; + return Response.json({ ok: false, error: safe }, { status }); +} + function dnsRebindingProtection(options: HttpServeOptions): MiddlewareHandler { if (!options.loopback) { return async (_c, next) => { diff --git a/packages/core/src/serve/options.ts b/packages/core/src/serve/options.ts index 765490e8..a10320d1 100644 --- a/packages/core/src/serve/options.ts +++ b/packages/core/src/serve/options.ts @@ -1,5 +1,7 @@ import { CapletsError } from "../errors"; import { parseServerBaseUrl } from "../server/options"; +import { DEFAULT_AUTH_DIR } from "../config/paths"; +import { join } from "node:path"; export type ServeTransport = "stdio" | "http"; @@ -10,6 +12,7 @@ export type RawServeOptions = { path?: string; user?: string; password?: string; + remoteStatePath?: string; allowUnauthenticatedHttp?: boolean; trustProxy?: boolean; }; @@ -25,6 +28,7 @@ export type HttpServeOptions = { path: string; publicOrigin?: string | undefined; auth: HttpBasicAuthOptions; + remoteCredentialStateDir?: string | undefined; allowUnauthenticatedHttp: boolean; warnUnauthenticatedNetwork: boolean; loopback: boolean; @@ -38,7 +42,13 @@ export type HttpBasicAuthOptions = export type ServeOptions = StdioServeOptions | HttpServeOptions; export type ServeEnv = Partial< - Record<"CAPLETS_SERVER_URL" | "CAPLETS_SERVER_USER" | "CAPLETS_SERVER_PASSWORD", string> + Record< + | "CAPLETS_SERVER_URL" + | "CAPLETS_SERVER_USER" + | "CAPLETS_SERVER_PASSWORD" + | "CAPLETS_REMOTE_SERVER_STATE_DIR", + string + > >; const HTTP_ONLY_OPTIONS = [ @@ -47,6 +57,7 @@ const HTTP_ONLY_OPTIONS = [ "path", "user", "password", + "remoteStatePath", "allowUnauthenticatedHttp", "trustProxy", ] as const; @@ -81,6 +92,10 @@ export function resolveServeOptions( const password = nonEmpty(raw.password, "--password") ?? nonEmpty(env.CAPLETS_SERVER_PASSWORD, "CAPLETS_SERVER_PASSWORD"); + const remoteCredentialStateDir = + nonEmpty(raw.remoteStatePath, "--remote-state-path") ?? + nonEmpty(env.CAPLETS_REMOTE_SERVER_STATE_DIR, "CAPLETS_REMOTE_SERVER_STATE_DIR") ?? + join(DEFAULT_AUTH_DIR, "remote-server"); if (userWasExplicit && password === undefined) { throw new CapletsError( @@ -107,6 +122,7 @@ export function resolveServeOptions( path, ...(serverUrl ? { publicOrigin: serverUrl.origin } : {}), auth, + remoteCredentialStateDir, allowUnauthenticatedHttp: raw.allowUnauthenticatedHttp === true, warnUnauthenticatedNetwork: !loopback && !auth.enabled, loopback, diff --git a/packages/core/test/remote-pairing.test.ts b/packages/core/test/remote-pairing.test.ts new file mode 100644 index 00000000..e7e57d7c --- /dev/null +++ b/packages/core/test/remote-pairing.test.ts @@ -0,0 +1,188 @@ +import { mkdtempSync, rmSync, statSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { CapletsError } from "../src/errors"; +import { createPairingCodeVerifier, isPairingCodeFormat } from "../src/remote/pairing"; +import { RemoteServerCredentialStore } from "../src/remote/server-credential-store"; + +const dirs: string[] = []; + +afterEach(() => { + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +describe("self-hosted remote pairing", () => { + it("issues one-time Pairing Codes that exchange for client credentials", () => { + const store = new RemoteServerCredentialStore({ dir: tempDir() }); + const issued = store.createPairingCode({ + hostUrl: "https://caplets.example.com/caplets", + clientLabel: "MacBook Pro", + now: new Date("2026-06-19T12:00:00.000Z"), + }); + + expect(isPairingCodeFormat(issued.code)).toBe(true); + expect(JSON.stringify(store.dumpForTest())).not.toContain(issued.code); + + const exchanged = store.exchangePairingCode({ + hostUrl: "https://caplets.example.com/caplets", + code: issued.code, + clientLabel: "MacBook Pro", + now: new Date("2026-06-19T12:01:00.000Z"), + }); + + expect(exchanged).toMatchObject({ + clientLabel: "MacBook Pro", + hostUrl: "https://caplets.example.com/caplets", + tokenType: "Bearer", + }); + expect(exchanged.accessToken).not.toBe(issued.code); + expect(exchanged.refreshToken).not.toBe(issued.code); + + expect(() => + store.exchangePairingCode({ + hostUrl: "https://caplets.example.com/caplets", + code: issued.code, + }), + ).toThrow(/used/u); + }); + + it("rejects expired, wrong-host, and attempt-exhausted Pairing Codes", () => { + const store = new RemoteServerCredentialStore({ dir: tempDir() }); + const expired = store.createPairingCode({ + hostUrl: "https://caplets.example.com", + ttlMs: 1_000, + now: new Date("2026-06-19T12:00:00.000Z"), + }); + expect(() => + store.exchangePairingCode({ + hostUrl: "https://caplets.example.com", + code: expired.code, + now: new Date("2026-06-19T12:00:02.000Z"), + }), + ).toThrow(/expired/u); + + const wrongHost = store.createPairingCode({ + hostUrl: "https://caplets.example.com", + now: new Date("2026-06-19T12:00:00.000Z"), + }); + expect(() => + store.exchangePairingCode({ + hostUrl: "https://other.example.com", + code: wrongHost.code, + }), + ).toThrow(/host/u); + + const exhausted = store.createPairingCode({ + hostUrl: "https://caplets.example.com", + maxAttempts: 2, + now: new Date("2026-06-19T12:00:00.000Z"), + }); + const badCode = createPairingCodeVerifier( + exhausted.codeId, + "wrong-secret-value-that-is-long-enough", + ); + for (let attempt = 0; attempt < 2; attempt += 1) { + expect(() => + store.exchangePairingCode({ hostUrl: "https://caplets.example.com", code: badCode }), + ).toThrow(CapletsError); + } + expect(() => + store.exchangePairingCode({ + hostUrl: "https://caplets.example.com", + code: exhausted.code, + }), + ).toThrow(/attempts/u); + }); + + it("lists, persists, and revokes paired clients without exposing secrets", () => { + const dir = tempDir(); + const store = new RemoteServerCredentialStore({ dir }); + const issued = store.createPairingCode({ + hostUrl: "https://caplets.example.com", + now: new Date("2026-06-19T12:00:00.000Z"), + }); + const credentials = store.exchangePairingCode({ + hostUrl: "https://caplets.example.com", + code: issued.code, + clientLabel: "CI Runner", + now: new Date("2026-06-19T12:00:00.000Z"), + }); + + const restarted = new RemoteServerCredentialStore({ dir }); + expect(restarted.listClients()).toEqual([ + expect.objectContaining({ + clientId: credentials.clientId, + clientLabel: "CI Runner", + }), + ]); + expect(restarted.listClients()[0]).not.toHaveProperty("revokedAt"); + expect(JSON.stringify(restarted.listClients())).not.toContain(credentials.accessToken); + expect(JSON.stringify(restarted.listClients())).not.toContain(credentials.refreshToken); + + restarted.validateAccessToken({ + hostUrl: "https://caplets.example.com", + accessToken: credentials.accessToken, + now: new Date("2026-06-19T12:01:00.000Z"), + }); + restarted.revokeClient(credentials.clientId, new Date("2026-06-19T12:02:00.000Z")); + expect(() => + restarted.validateAccessToken({ + hostUrl: "https://caplets.example.com", + accessToken: credentials.accessToken, + }), + ).toThrow(/revoked/u); + }); + + it("rotates refresh tokens once and invalidates the family on stale reuse", () => { + const store = new RemoteServerCredentialStore({ dir: tempDir() }); + const issued = store.createPairingCode({ + hostUrl: "https://caplets.example.com", + now: new Date("2026-06-19T12:00:00.000Z"), + }); + const credentials = store.exchangePairingCode({ + hostUrl: "https://caplets.example.com", + code: issued.code, + now: new Date("2026-06-19T12:01:00.000Z"), + }); + + const refreshed = store.refreshClientCredentials({ + hostUrl: "https://caplets.example.com", + refreshToken: credentials.refreshToken, + now: new Date("2026-06-19T12:02:00.000Z"), + }); + + expect(refreshed.refreshToken).not.toBe(credentials.refreshToken); + expect(() => + store.refreshClientCredentials({ + hostUrl: "https://caplets.example.com", + refreshToken: credentials.refreshToken, + now: new Date("2026-06-19T12:03:00.000Z"), + }), + ).toThrow(/stale/u); + expect(() => + store.validateAccessToken({ + hostUrl: "https://caplets.example.com", + accessToken: refreshed.accessToken, + now: new Date("2026-06-19T12:04:00.000Z"), + }), + ).toThrow(/revoked/u); + }); + + it("creates private server-owned state files where supported", () => { + const dir = tempDir(); + const store = new RemoteServerCredentialStore({ dir }); + store.createPairingCode({ hostUrl: "https://caplets.example.com" }); + + if (process.platform !== "win32") { + expect(statSync(dir).mode & 0o777).toBe(0o700); + expect(statSync(join(dir, "remote-server-credentials.json")).mode & 0o777).toBe(0o600); + } + }); +}); + +function tempDir(): string { + const dir = mkdtempSync(join(tmpdir(), "caplets-remote-pairing-")); + dirs.push(dir); + return dir; +} diff --git a/packages/core/test/serve-http.test.ts b/packages/core/test/serve-http.test.ts index 93f5995e..c8b6b65c 100644 --- a/packages/core/test/serve-http.test.ts +++ b/packages/core/test/serve-http.test.ts @@ -4,6 +4,7 @@ import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { CapletsEngine } from "../src/engine"; import { RemoteAuthFlowStore } from "../src/remote-control/auth-flow"; +import { RemoteServerCredentialStore } from "../src/remote/server-credential-store"; import { createHttpServeApp } from "../src/serve/http"; import type { HttpServeOptions } from "../src/serve/options"; @@ -108,6 +109,129 @@ describe("createHttpServeApp", () => { await engine.close(); }); + it("uses issued remote credentials for protected self-hosted route classes", async () => { + const { engine } = testEngine(); + const store = remoteCredentialStore(); + const credentials = pairedClient(store); + const app = createHttpServeApp(httpOptions(), engine, { + writeErr: () => {}, + remoteCredentialStore: store, + }); + + const root = await app.request("http://127.0.0.1:5387/"); + expect(root.status).toBe(200); + await expect(root.json()).resolves.toMatchObject({ + auth: { type: "remote_credentials", enabled: true }, + }); + + const basic = await app.request("http://127.0.0.1:5387/v1/attach/manifest", { + headers: { + authorization: `Basic ${Buffer.from("caplets:password").toString("base64")}`, + }, + }); + expect(basic.status).toBe(401); + expect(basic.headers.get("www-authenticate")).toBeNull(); + + const attach = await app.request("http://127.0.0.1:5387/v1/attach/manifest", { + headers: { authorization: `Bearer ${credentials.accessToken}` }, + }); + expect(attach.status).toBe(200); + + const project = await app.request( + "http://127.0.0.1:5387/v1/attach/project-bindings/bind_123/status", + { headers: { authorization: `Bearer ${credentials.accessToken}` } }, + ); + expect(project.status).toBe(200); + + const control = await app.request("http://127.0.0.1:5387/v1/admin", { + method: "POST", + headers: { + authorization: `Bearer ${credentials.accessToken}`, + "content-type": "application/json", + }, + body: JSON.stringify({ command: "list", arguments: {} }), + }); + expect(control.status).toBe(200); + await expect(control.json()).resolves.toMatchObject({ ok: true }); + + const mcp = await app.request("http://127.0.0.1:5387/v1/mcp", { + method: "POST", + headers: { + authorization: `Bearer ${credentials.accessToken}`, + host: "127.0.0.1:5387", + accept: "application/json, text/event-stream", + "content-type": "application/json", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2025-03-26", + capabilities: {}, + clientInfo: { name: "test", version: "1.0.0" }, + }, + }), + }); + expect(mcp.status).toBe(200); + + await engine.close(); + }); + + it("exchanges Pairing Codes and rotates refresh credentials without accepting the copied code", async () => { + const { engine } = testEngine(); + const store = remoteCredentialStore(); + const issued = store.createPairingCode({ hostUrl: "http://127.0.0.1:5387/" }); + const app = createHttpServeApp(httpOptions(), engine, { + writeErr: () => {}, + remoteCredentialStore: store, + }); + + const exchanged = await app.request("http://127.0.0.1:5387/v1/remote/pairing/exchange", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ code: issued.code, clientLabel: "Test Client" }), + }); + expect(exchanged.status).toBe(200); + const credentials = (await exchanged.json()) as { + accessToken: string; + refreshToken: string; + }; + expect(credentials.accessToken).not.toBe(issued.code); + expect(credentials.refreshToken).not.toBe(issued.code); + + const copiedCodeAttach = await app.request("http://127.0.0.1:5387/v1/attach/manifest", { + headers: { authorization: `Bearer ${issued.code}` }, + }); + expect(copiedCodeAttach.status).toBe(401); + + const refreshed = await app.request("http://127.0.0.1:5387/v1/remote/refresh", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ refreshToken: credentials.refreshToken }), + }); + expect(refreshed.status).toBe(200); + const nextCredentials = (await refreshed.json()) as { + accessToken: string; + refreshToken: string; + }; + expect(nextCredentials.refreshToken).not.toBe(credentials.refreshToken); + + const stale = await app.request("http://127.0.0.1:5387/v1/remote/refresh", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ refreshToken: credentials.refreshToken }), + }); + expect(stale.status).toBe(401); + + const revokedByReplay = await app.request("http://127.0.0.1:5387/v1/attach/manifest", { + headers: { authorization: `Bearer ${nextCredentials.accessToken}` }, + }); + expect(revokedByReplay.status).toBe(401); + + await engine.close(); + }); + it("requires Basic Auth on attach manifest when password is configured", async () => { const { engine } = testEngine(); const testPassword = ["test", "password"].join("-"); @@ -486,6 +610,39 @@ describe("createHttpServeApp", () => { await engine.close(); }); + it("ignores spoofed host headers for remote auth callback URLs by default", async () => { + const context = testContext({ oauth: true }); + const engine = new CapletsEngine({ + configPath: context.configPath, + projectConfigPath: context.projectConfigPath, + watch: false, + }); + const app = createHttpServeApp(httpOptions({ path: "/caplets" }), engine, { + writeErr: () => {}, + control: context, + }); + + const response = await app.request("http://10.0.0.5:5387/caplets/v1/admin", { + method: "POST", + headers: { + "content-type": "application/json", + host: "attacker.example.com", + }, + body: JSON.stringify({ command: "auth_login_start", arguments: { server: "remote" } }), + }); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body).toEqual(expect.objectContaining({ ok: true })); + const result = (body as { result: { authorizationUrl: string } }).result; + const authorizationUrl = new URL(result.authorizationUrl); + expect(authorizationUrl.searchParams.get("redirect_uri")).toMatch( + /^http:\/\/10\.0\.0\.5:5387\/caplets\/v1\/admin\/auth\/callback\//u, + ); + + await engine.close(); + }); + it("uses forwarded host and proto for remote auth callback URLs when proxy trust is enabled", async () => { const context = testContext({ oauth: true }); const engine = new CapletsEngine({ @@ -1008,6 +1165,27 @@ function httpOptions(overrides: Partial = {}): HttpServeOption }; } +function remoteCredentialStore(): RemoteServerCredentialStore { + return new RemoteServerCredentialStore({ dir: tempDir("caplets-http-remote-credentials-") }); +} + +function pairedClient(store: RemoteServerCredentialStore): { + accessToken: string; + refreshToken: string; +} { + const issued = store.createPairingCode({ hostUrl: "http://127.0.0.1:5387/" }); + return store.exchangePairingCode({ + hostUrl: "http://127.0.0.1:5387/", + code: issued.code, + }); +} + +function tempDir(prefix: string): string { + const dir = mkdtempSync(join(tmpdir(), prefix)); + dirs.push(dir); + return dir; +} + function testEngine(config: Record = {}): { engine: CapletsEngine } { const context = testContext(config); return { diff --git a/packages/core/test/serve-options.test.ts b/packages/core/test/serve-options.test.ts index 71d79922..9d3dc9d1 100644 --- a/packages/core/test/serve-options.test.ts +++ b/packages/core/test/serve-options.test.ts @@ -157,6 +157,28 @@ describe("resolveServeOptions", () => { }); }); + it("resolves the server-owned remote credential state directory", () => { + expect(resolveServeOptions({ transport: "http" }, {})).toMatchObject({ + remoteCredentialStateDir: expect.stringContaining("remote-server"), + }); + expect( + resolveServeOptions( + { transport: "http", remoteStatePath: "/var/lib/caplets/remote-auth" }, + { CAPLETS_REMOTE_SERVER_STATE_DIR: "/env/remote-auth" }, + ), + ).toMatchObject({ + remoteCredentialStateDir: "/var/lib/caplets/remote-auth", + }); + expect( + resolveServeOptions( + { transport: "http" }, + { CAPLETS_REMOTE_SERVER_STATE_DIR: "/env/remote-auth" }, + ), + ).toMatchObject({ + remoteCredentialStateDir: "/env/remote-auth", + }); + }); + it("allows unauthenticated non-loopback HTTP serving with explicit opt-in", () => { expect( resolveServeOptions( From aa8f7c5dd71dca2614e3308e9de7e68fbf231ee3 Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Fri, 19 Jun 2026 06:47:30 -0400 Subject: [PATCH 03/25] fix(core): complete self-hosted remote profiles --- packages/core/src/remote/credential-store.ts | 21 +++- packages/core/src/remote/profile-store.ts | 124 ++++++++++++++++++- packages/core/src/remote/profiles.ts | 3 + packages/core/test/remote-profiles.test.ts | 77 ++++++++++++ 4 files changed, 218 insertions(+), 7 deletions(-) diff --git a/packages/core/src/remote/credential-store.ts b/packages/core/src/remote/credential-store.ts index 90095449..735a0403 100644 --- a/packages/core/src/remote/credential-store.ts +++ b/packages/core/src/remote/credential-store.ts @@ -29,7 +29,7 @@ export class FileRemoteCredentialStore { async load(key: string): Promise { const path = this.pathForKey(key); if (!existsSync(path)) return undefined; - return JSON.parse(readFileSync(path, "utf8")) as RemoteProfileCredential; + return parseRemoteProfileCredential(JSON.parse(readFileSync(path, "utf8"))); } async save(key: string, credential: RemoteProfileCredential): Promise { @@ -57,3 +57,22 @@ export class FileRemoteCredentialStore { return true; } } + +function parseRemoteProfileCredential(value: unknown): RemoteProfileCredential | undefined { + if (!isRecord(value)) return undefined; + return { + ...(typeof value.accessToken === "string" ? { accessToken: value.accessToken } : {}), + ...(typeof value.refreshToken === "string" ? { refreshToken: value.refreshToken } : {}), + ...(typeof value.tokenType === "string" ? { tokenType: value.tokenType } : {}), + ...(typeof value.expiresAt === "string" ? { expiresAt: value.expiresAt } : {}), + ...(Array.isArray(value.scope) + ? { scope: value.scope.filter((entry): entry is string => typeof entry === "string") } + : {}), + ...(typeof value.clientSecret === "string" ? { clientSecret: value.clientSecret } : {}), + ...(typeof value.pairingCode === "string" ? { pairingCode: value.pairingCode } : {}), + }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/packages/core/src/remote/profile-store.ts b/packages/core/src/remote/profile-store.ts index ae5c8498..22dc69a4 100644 --- a/packages/core/src/remote/profile-store.ts +++ b/packages/core/src/remote/profile-store.ts @@ -25,11 +25,12 @@ import { hostedCloudWorkspaceFromRemoteUrl, normalizeRemoteProfileHostUrl } from type StoredRemoteProfile = { version: 1; - kind: "cloud"; + kind: "cloud" | "self-hosted"; key: string; hostUrl: string; - workspaceId: string; + workspaceId?: string | undefined; workspaceSlug?: string | undefined; + clientId?: string | undefined; clientLabel?: string | undefined; createdAt: string; updatedAt: string; @@ -57,6 +58,18 @@ export type CloudProfileLookup = { workspace?: string | undefined; }; +export type SaveSelfHostedProfileInput = { + hostUrl: string; + clientId: string; + clientLabel?: string | undefined; + credentials: RemoteProfileCredential; + now?: Date | undefined; +}; + +export type SelfHostedProfileLookup = { + hostUrl: string; +}; + export type RemoteProfileStoreOptions = { root?: string | undefined; credentials?: FileRemoteCredentialStore | undefined; @@ -76,6 +89,49 @@ export class FileRemoteProfileStore { this.legacyCloudAuthStore = options.legacyCloudAuthStore; } + async saveSelfHostedProfile(input: SaveSelfHostedProfileInput): Promise { + const hostUrl = normalizeRemoteProfileHostUrl(input.hostUrl); + const key = remoteProfileKey({ kind: "self-hosted", hostUrl }); + const now = (input.now ?? new Date()).toISOString(); + const existing = this.readProfile(key); + const profile: StoredRemoteProfile = { + version: 1, + kind: "self-hosted", + key, + hostUrl, + clientId: input.clientId, + ...(input.clientLabel ? { clientLabel: input.clientLabel } : {}), + createdAt: existing?.createdAt ?? now, + updatedAt: now, + }; + + await this.credentials.save(key, input.credentials); + this.writeJson(this.profilePath(key), profile); + return await this.statusFor(profile, input.credentials, false); + } + + async getSelfHostedProfileStatus( + input: SelfHostedProfileLookup, + ): Promise { + const key = remoteProfileKey({ + kind: "self-hosted", + hostUrl: normalizeRemoteProfileHostUrl(input.hostUrl), + }); + return this.statusByKey(key, false); + } + + async logoutSelfHostedProfile(input: SelfHostedProfileLookup): Promise { + const key = remoteProfileKey({ + kind: "self-hosted", + hostUrl: normalizeRemoteProfileHostUrl(input.hostUrl), + }); + const profile = this.readProfile(key); + if (!profile) return false; + await this.credentials.delete(key); + rmSync(this.profilePath(key), { force: true }); + return true; + } + async saveCloudProfile(input: SaveCloudProfileInput): Promise { const hostUrl = normalizeRemoteProfileHostUrl(input.hostUrl); const workspace = cloudWorkspace(input); @@ -160,8 +216,8 @@ export class FileRemoteProfileStore { ); } if (!key) return false; - rmSync(this.profilePath(key), { force: true }); await this.credentials.delete(key); + rmSync(this.profilePath(key), { force: true }); if (selected?.profileKey === key) rmSync(this.selectedWorkspacePath(hostUrl), { force: true }); return true; } @@ -221,11 +277,12 @@ export class FileRemoteProfileStore { ): Promise { const build = (loadedCredential: RemoteProfileCredential | undefined): RemoteProfileStatus => remoteProfileStatus({ - kind: "cloud", + kind: profile.kind, key: profile.key, hostUrl: profile.hostUrl, workspaceId: profile.workspaceId, workspaceSlug: profile.workspaceSlug, + clientId: profile.clientId, clientLabel: profile.clientLabel, createdAt: profile.createdAt, updatedAt: profile.updatedAt, @@ -252,13 +309,13 @@ export class FileRemoteProfileStore { private readProfileFile(path: string): StoredRemoteProfile | undefined { if (!existsSync(path)) return undefined; - return JSON.parse(readFileSync(path, "utf8")) as StoredRemoteProfile; + return parseStoredRemoteProfile(JSON.parse(readFileSync(path, "utf8"))); } private readSelectedWorkspace(hostUrl: string): SelectedCloudWorkspace | undefined { const path = this.selectedWorkspacePath(hostUrl); if (!existsSync(path)) return undefined; - return JSON.parse(readFileSync(path, "utf8")) as SelectedCloudWorkspace; + return parseSelectedCloudWorkspace(JSON.parse(readFileSync(path, "utf8")), hostUrl); } private profilePath(key: string): string { @@ -318,3 +375,58 @@ function legacyCredential(credentials: CloudAuthCredentials): RemoteProfileCrede ...(credentials.tokenType ? { tokenType: credentials.tokenType } : {}), }; } + +function parseStoredRemoteProfile(value: unknown): StoredRemoteProfile | undefined { + if (!isRecord(value)) return undefined; + if (value.version !== 1) return undefined; + if (value.kind !== "cloud" && value.kind !== "self-hosted") return undefined; + if ( + typeof value.key !== "string" || + typeof value.hostUrl !== "string" || + typeof value.createdAt !== "string" || + typeof value.updatedAt !== "string" + ) { + return undefined; + } + if (value.kind === "cloud" && typeof value.workspaceId !== "string") return undefined; + if (value.kind === "self-hosted" && typeof value.clientId !== "string") return undefined; + return { + version: 1, + kind: value.kind, + key: value.key, + hostUrl: value.hostUrl, + ...(typeof value.workspaceId === "string" ? { workspaceId: value.workspaceId } : {}), + ...(typeof value.workspaceSlug === "string" ? { workspaceSlug: value.workspaceSlug } : {}), + ...(typeof value.clientId === "string" ? { clientId: value.clientId } : {}), + ...(typeof value.clientLabel === "string" ? { clientLabel: value.clientLabel } : {}), + createdAt: value.createdAt, + updatedAt: value.updatedAt, + }; +} + +function parseSelectedCloudWorkspace( + value: unknown, + expectedHostUrl: string, +): SelectedCloudWorkspace | undefined { + if (!isRecord(value)) return undefined; + if ( + value.version !== 1 || + value.hostUrl !== expectedHostUrl || + typeof value.workspace !== "string" || + typeof value.profileKey !== "string" || + typeof value.selectedAt !== "string" + ) { + return undefined; + } + return { + version: 1, + hostUrl: value.hostUrl, + workspace: value.workspace, + profileKey: value.profileKey, + selectedAt: value.selectedAt, + }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/packages/core/src/remote/profiles.ts b/packages/core/src/remote/profiles.ts index bb4cd77e..3aca5651 100644 --- a/packages/core/src/remote/profiles.ts +++ b/packages/core/src/remote/profiles.ts @@ -25,6 +25,7 @@ export type RemoteProfileStatusInput = { key?: string | undefined; workspaceId?: string | undefined; workspaceSlug?: string | undefined; + clientId?: string | undefined; selected?: boolean | undefined; clientLabel?: string | undefined; createdAt?: string | undefined; @@ -39,6 +40,7 @@ export type RemoteProfileStatus = { hostUrl: string; workspaceId?: string | undefined; workspaceSlug?: string | undefined; + clientId?: string | undefined; selected: boolean; clientLabel?: string | undefined; createdAt?: string | undefined; @@ -84,6 +86,7 @@ export function remoteProfileStatus(input: RemoteProfileStatusInput): RemoteProf hostUrl, ...(input.workspaceId ? { workspaceId: input.workspaceId } : {}), ...(input.workspaceSlug ? { workspaceSlug: input.workspaceSlug } : {}), + ...(input.clientId ? { clientId: input.clientId } : {}), selected: Boolean(input.selected), ...(input.clientLabel ? { clientLabel: input.clientLabel } : {}), ...(input.createdAt ? { createdAt: input.createdAt } : {}), diff --git a/packages/core/test/remote-profiles.test.ts b/packages/core/test/remote-profiles.test.ts index def72312..b3e804c6 100644 --- a/packages/core/test/remote-profiles.test.ts +++ b/packages/core/test/remote-profiles.test.ts @@ -29,6 +29,83 @@ afterEach(() => { }); describe("Remote Profile storage", () => { + it("saves a self-hosted profile with redacted status and credential storage", async () => { + const store = tempRemoteProfileStore(); + + const status = await store.saveSelfHostedProfile({ + hostUrl: "https://caplets.example.com/caplets/", + clientId: "rcli_123", + clientLabel: "Ian's MacBook", + credentials: { + accessToken: "self_hosted_access_secret", + refreshToken: "self_hosted_refresh_secret", + tokenType: "Bearer", + expiresAt: "2099-06-19T12:00:00.000Z", + }, + now: new Date("2026-06-19T10:00:00.000Z"), + }); + + expect(status).toEqual({ + authenticated: true, + kind: "self-hosted", + key: remoteProfileKey({ + kind: "self-hosted", + hostUrl: "https://caplets.example.com/caplets", + }), + hostUrl: "https://caplets.example.com/caplets", + clientId: "rcli_123", + selected: false, + clientLabel: "Ian's MacBook", + createdAt: "2026-06-19T10:00:00.000Z", + updatedAt: "2026-06-19T10:00:00.000Z", + expiresAt: "2099-06-19T12:00:00.000Z", + tokenType: "Bearer", + }); + expect(JSON.stringify(status)).not.toContain("self_hosted_access_secret"); + expect(JSON.stringify(status)).not.toContain("self_hosted_refresh_secret"); + + await expect( + store.getSelfHostedProfileStatus({ hostUrl: "https://caplets.example.com/caplets" }), + ).resolves.toMatchObject({ clientId: "rcli_123", clientLabel: "Ian's MacBook" }); + await expect( + store.credentials.load( + remoteProfileKey({ + kind: "self-hosted", + hostUrl: "https://caplets.example.com/caplets", + }), + ), + ).resolves.toMatchObject({ accessToken: "self_hosted_access_secret" }); + }); + + it("logs out a self-hosted profile without touching Cloud profiles", async () => { + const store = tempRemoteProfileStore(); + await store.saveSelfHostedProfile({ + hostUrl: "https://caplets.example.com", + clientId: "rcli_123", + credentials: { + accessToken: "self_hosted_access_secret", + refreshToken: "self_hosted_refresh_secret", + }, + }); + await store.saveCloudProfile({ + hostUrl: "https://cloud.caplets.dev", + workspaceId: "ws_123", + workspaceSlug: "team", + credentials: cloudCredentials, + }); + + await expect( + store.logoutSelfHostedProfile({ hostUrl: "https://caplets.example.com" }), + ).resolves.toBe(true); + + await expect( + store.getSelfHostedProfileStatus({ hostUrl: "https://caplets.example.com" }), + ).resolves.toBeUndefined(); + await expect( + store.getCloudProfileStatus({ hostUrl: "https://cloud.caplets.dev" }), + ).resolves.toMatchObject({ workspaceSlug: "team" }); + }); + it("saves a Cloud profile with redacted status and selected workspace pointer", async () => { const store = tempRemoteProfileStore(); From ad24f16999c8927c4451d49a0565dec232a33abe Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Fri, 19 Jun 2026 06:53:43 -0400 Subject: [PATCH 04/25] feat(core): add unified remote login cli --- packages/core/src/cli.ts | 362 +++++++++++++++++++- packages/core/src/cli/commands.ts | 9 + packages/core/src/cli/completion.ts | 11 + packages/core/test/cli-completion.test.ts | 11 + packages/core/test/remote-login-cli.test.ts | 191 +++++++++++ 5 files changed, 583 insertions(+), 1 deletion(-) create mode 100644 packages/core/test/remote-login-cli.test.ts diff --git a/packages/core/src/cli.ts b/packages/core/src/cli.ts index a747ef4a..8ff2a956 100644 --- a/packages/core/src/cli.ts +++ b/packages/core/src/cli.ts @@ -1,4 +1,5 @@ import { Command, CommanderError } from "commander"; +import { Buffer } from "node:buffer"; import { dirname, join } from "node:path"; import { createInterface } from "node:readline/promises"; import { version as packageJsonVersion } from "../package.json"; @@ -76,7 +77,15 @@ import { ProjectBindingError } from "./project-binding/errors"; import type { ProjectBindingWebSocketFactory } from "./project-binding/transport"; import { RemoteControlClient } from "./remote-control/client"; import type { RemoteCliCommand } from "./remote-control/types"; -import { resolveCapletsRemote, resolveRemoteMode } from "./remote/options"; +import { FileRemoteProfileStore } from "./remote/profile-store"; +import { RemoteServerCredentialStore } from "./remote/server-credential-store"; +import { + isCapletsCloudUrl, + normalizeRemoteProfileHostUrl, + resolveCapletsRemote, + resolveRemoteMode, +} from "./remote/options"; +import type { RemoteProfileStatus } from "./remote/profiles"; import { daemonLogs, daemonStatus, @@ -93,6 +102,8 @@ import { type DaemonOperationOptions, } from "./daemon"; import { resolveServeOptions, serveResolvedCaplets, type ServeOptions } from "./serve"; +import { DEFAULT_AUTH_DIR } from "./config/paths"; +import { appendBasePath } from "./server/options"; export { initConfig, starterConfig } from "./cli/init"; export { installCaplets, normalizeGitRepo } from "./cli/install"; @@ -240,6 +251,26 @@ function cloudAuthStatus(credentials: CloudAuthCredentials | undefined): Record< return redactedCloudAuthStatus(credentials); } +function remoteProfileStore(authDir: string | undefined): FileRemoteProfileStore { + const root = join(authDir ?? DEFAULT_AUTH_DIR, "remote-profiles"); + return new FileRemoteProfileStore({ + root, + legacyCloudAuthStore: new CloudAuthStore({ + path: join(authDir ?? DEFAULT_AUTH_DIR, "cloud-auth.json"), + }), + }); +} + +function remoteServerCredentialStore( + statePath: string | undefined, + env: NodeJS.ProcessEnv | Record, +): RemoteServerCredentialStore { + return new RemoteServerCredentialStore({ + dir: + statePath ?? env.CAPLETS_REMOTE_SERVER_STATE_DIR ?? join(DEFAULT_AUTH_DIR, "remote-server"), + }); +} + function compactCloudCaplet(value: unknown): Record { if (!value || typeof value !== "object" || Array.isArray(value)) return {}; const record = value as Record; @@ -306,6 +337,145 @@ async function waitForCloudLogin( return { status: "expired" as const, message: "Cloud Auth login timed out." }; } +async function loginCloudRemoteProfile( + url: string, + options: { + workspace?: string; + deviceName?: string; + open?: boolean; + json?: boolean; + }, + store: FileRemoteProfileStore, + io: { + env: NodeJS.ProcessEnv | Record; + fetch?: typeof fetch; + writeOut: (value: string) => void; + }, +): Promise { + const client = new CloudAuthClient({ cloudUrl: url, ...(io.fetch ? { fetch: io.fetch } : {}) }); + const started = await client.startLogin({ + requestedWorkspace: options.workspace, + deviceName: options.deviceName ?? io.env.CAPLETS_DEVICE_NAME ?? "Caplets CLI", + }); + if (options.open !== false) await openBrowserUrl(started.loginUrl); + if (!options.json) { + io.writeOut(`Open ${started.loginUrl}\n`); + io.writeOut(`Enter code ${started.userCode} if prompted.\n`); + } + + const completed = await waitForCloudLogin(client, started.loginId, io.env); + if (completed.status !== "completed") { + throw new CapletsError("AUTH_FAILED", `Remote Login Cloud flow ${completed.status}.`); + } + const exchanged = await client.exchangeToken({ + loginId: started.loginId, + oneTimeCode: completed.oneTimeCode, + }); + return store.saveCloudProfile({ + hostUrl: exchanged.cloudUrl, + workspaceId: exchanged.workspaceId, + ...(exchanged.workspaceSlug ? { workspaceSlug: exchanged.workspaceSlug } : {}), + clientLabel: exchanged.deviceName ?? options.deviceName ?? "Caplets CLI", + credentials: { + accessToken: exchanged.accessToken, + refreshToken: exchanged.refreshToken ?? "", + expiresAt: exchanged.expiresAt, + scope: exchanged.scope, + tokenType: exchanged.tokenType, + }, + }); +} + +async function pairingCodeFromOptions( + options: { code?: string; codeStdin?: boolean }, + readStdin: (() => Promise) | undefined, +): Promise { + if (options.code?.trim()) return options.code.trim(); + if (options.codeStdin) { + const value = readStdin ? await readStdin() : await readAllStdin(); + const code = value.trim(); + if (code) return code; + } + const readline = createInterface({ input: process.stdin, output: process.stdout }); + try { + const code = (await readline.question("Pairing Code: ")).trim(); + if (code) return code; + } finally { + readline.close(); + } + throw new CapletsError( + "REQUEST_INVALID", + "Pairing Code is required for self-hosted Remote Login.", + ); +} + +async function readAllStdin(): Promise { + const chunks: Buffer[] = []; + for await (const chunk of process.stdin) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk))); + } + return Buffer.concat(chunks).toString("utf8"); +} + +type RemoteLoginCredentialsResponse = { + clientId: string; + clientLabel: string; + accessToken: string; + refreshToken: string; + tokenType?: string | undefined; + expiresAt?: string | undefined; +}; + +async function parseRemoteLoginCredentials( + response: Response, +): Promise { + const parsed = await response.json(); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new CapletsError("DOWNSTREAM_PROTOCOL_ERROR", "Remote Login response must be an object."); + } + const record = parsed as Record; + if ( + typeof record.clientId !== "string" || + typeof record.accessToken !== "string" || + typeof record.refreshToken !== "string" + ) { + throw new CapletsError( + "DOWNSTREAM_PROTOCOL_ERROR", + "Remote Login response is missing credentials.", + ); + } + return { + clientId: record.clientId, + clientLabel: typeof record.clientLabel === "string" ? record.clientLabel : "Caplets CLI", + accessToken: record.accessToken, + refreshToken: record.refreshToken, + ...(typeof record.tokenType === "string" ? { tokenType: record.tokenType } : {}), + ...(typeof record.expiresAt === "string" ? { expiresAt: record.expiresAt } : {}), + }; +} + +function writeRemoteStatus( + status: RemoteProfileStatus | Record, + json: boolean, + writeOut: (value: string) => void, +): void { + if (json) { + writeOut(`${JSON.stringify(status, null, 2)}\n`); + return; + } + if (status.authenticated) { + const workspace = + typeof status.workspaceSlug === "string" + ? ` workspace ${status.workspaceSlug}` + : typeof status.workspaceId === "string" + ? ` workspace ${status.workspaceId}` + : ""; + writeOut(`Authenticated to ${status.hostUrl}${workspace}.\n`); + return; + } + writeOut(`Not authenticated to ${status.hostUrl}.\n`); +} + function numberEnv(value: string | undefined, fallback: number): number { if (value === undefined) return fallback; const parsed = Number(value); @@ -835,6 +1005,196 @@ export function createProgram(io: CliIO = {}): Command { }, ); + const remote = program.command(cliCommands.remote).description("Manage Caplets Remote Login."); + remote + .command("login") + .description("Log this machine into a Caplets host.") + .argument("", "Caplets host URL") + .option("--workspace ", "Cloud workspace ID or slug to select") + .option("--client-label