From fea0ff802e31ffc4a559928124bb7e4c6edd3954 Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Mon, 22 Jun 2026 15:14:28 -0400 Subject: [PATCH 01/25] feat(core): add remote host identity metadata --- packages/core/src/remote/profile-store.ts | 19 ++++++++- packages/core/src/remote/profiles.ts | 3 ++ packages/core/src/serve/http.ts | 49 ++++++++++++++++++---- packages/core/test/remote-profiles.test.ts | 33 ++++++++++++++- packages/core/test/serve-http.test.ts | 40 ++++++++++++++++++ 5 files changed, 134 insertions(+), 10 deletions(-) diff --git a/packages/core/src/remote/profile-store.ts b/packages/core/src/remote/profile-store.ts index 3b02ab00..01fe9184 100644 --- a/packages/core/src/remote/profile-store.ts +++ b/packages/core/src/remote/profile-store.ts @@ -33,6 +33,7 @@ type StoredRemoteProfile = { kind: "cloud" | "self-hosted"; key: string; hostUrl: string; + hostIdentity?: string | undefined; workspaceId?: string | undefined; workspaceSlug?: string | undefined; clientId?: string | undefined; @@ -65,6 +66,7 @@ export type CloudProfileLookup = { export type SaveSelfHostedProfileInput = { hostUrl: string; + hostIdentity?: string | undefined; clientId: string; clientLabel?: string | undefined; credentials: RemoteProfileCredential; @@ -73,6 +75,7 @@ export type SaveSelfHostedProfileInput = { export type SelfHostedProfileLookup = { hostUrl: string; + hostIdentity?: string | undefined; }; export type RefreshSelfHostedProfileInput = SelfHostedProfileLookup & { @@ -164,7 +167,9 @@ export class FileRemoteProfileStore { kind: "self-hosted", hostUrl: normalizeRemoteProfileHostUrl(input.hostUrl), }); - return this.statusByKey(key, false); + const status = await this.statusByKey(key, false); + assertHostIdentityMatches(status, input.hostIdentity); + return status; } async logoutSelfHostedProfile(input: SelfHostedProfileLookup): Promise { @@ -452,6 +457,7 @@ export class FileRemoteProfileStore { kind: profile.kind, key: profile.key, hostUrl: profile.hostUrl, + hostIdentity: profile.hostIdentity, workspaceId: profile.workspaceId, workspaceSlug: profile.workspaceSlug, clientId: profile.clientId, @@ -477,6 +483,7 @@ export class FileRemoteProfileStore { kind: "self-hosted", key, hostUrl, + ...(input.hostIdentity ? { hostIdentity: input.hostIdentity } : {}), clientId: input.clientId, ...(input.clientLabel ? { clientLabel: input.clientLabel } : {}), createdAt: existing?.createdAt ?? now, @@ -682,6 +689,15 @@ function legacyCredential(credentials: CloudAuthCredentials): RemoteProfileCrede }; } +function assertHostIdentityMatches( + status: RemoteProfileStatus | undefined, + expectedHostIdentity: string | undefined, +): void { + if (!status || !expectedHostIdentity || !status.hostIdentity) return; + if (status.hostIdentity === expectedHostIdentity) return; + throw new CapletsError("AUTH_FAILED", "Remote Profile belongs to a different host identity."); +} + function parseStoredRemoteProfile(value: unknown): StoredRemoteProfile | undefined { if (!isRecord(value)) return undefined; if (value.version !== 1) return undefined; @@ -701,6 +717,7 @@ function parseStoredRemoteProfile(value: unknown): StoredRemoteProfile | undefin kind: value.kind, key: value.key, hostUrl: value.hostUrl, + ...(typeof value.hostIdentity === "string" ? { hostIdentity: value.hostIdentity } : {}), ...(typeof value.workspaceId === "string" ? { workspaceId: value.workspaceId } : {}), ...(typeof value.workspaceSlug === "string" ? { workspaceSlug: value.workspaceSlug } : {}), ...(typeof value.clientId === "string" ? { clientId: value.clientId } : {}), diff --git a/packages/core/src/remote/profiles.ts b/packages/core/src/remote/profiles.ts index f2065ce9..b286e2a6 100644 --- a/packages/core/src/remote/profiles.ts +++ b/packages/core/src/remote/profiles.ts @@ -22,6 +22,7 @@ export type RemoteProfileCredential = { export type RemoteProfileStatusInput = { kind: RemoteProfileKind; hostUrl: string; + hostIdentity?: string | undefined; key?: string | undefined; workspaceId?: string | undefined; workspaceSlug?: string | undefined; @@ -38,6 +39,7 @@ export type RemoteProfileStatus = { kind: RemoteProfileKind; key: string; hostUrl: string; + hostIdentity?: string | undefined; workspaceId?: string | undefined; workspaceSlug?: string | undefined; clientId?: string | undefined; @@ -85,6 +87,7 @@ export function remoteProfileStatus(input: RemoteProfileStatusInput): RemoteProf kind: input.kind, key, hostUrl, + ...(input.hostIdentity ? { hostIdentity: input.hostIdentity } : {}), ...(input.workspaceId ? { workspaceId: input.workspaceId } : {}), ...(input.workspaceSlug ? { workspaceSlug: input.workspaceSlug } : {}), ...(input.clientId ? { clientId: input.clientId } : {}), diff --git a/packages/core/src/serve/http.ts b/packages/core/src/serve/http.ts index 2e1bff54..bc0f887c 100644 --- a/packages/core/src/serve/http.ts +++ b/packages/core/src/serve/http.ts @@ -83,17 +83,26 @@ export function createHttpServeApp( }), ); - app.get(paths.base, (c) => - c.json({ + app.get(paths.base, (c) => { + const remote = remoteCredentialStore + ? remoteHostMetadata(c.req.url, paths.base, options, (name) => c.req.header(name)) + : undefined; + return c.json({ name: "caplets", transport: "http", base: paths.base, - versions: [versionDiscovery(paths, exposeAttach)], + versions: [versionDiscovery(paths, exposeAttach, remote)], auth: { type: options.auth.type }, - }), - ); + ...(remote ? { remote } : {}), + }); + }); - app.get(paths.version, (c) => c.json(versionDiscovery(paths, exposeAttach))); + app.get(paths.version, (c) => { + const remote = remoteCredentialStore + ? remoteHostMetadata(c.req.url, paths.base, options, (name) => c.req.header(name)) + : undefined; + return c.json(versionDiscovery(paths, exposeAttach, remote)); + }); app.get(paths.health, (c) => c.json({ @@ -437,10 +446,36 @@ function firstForwardedValue(value: string | undefined): string | undefined { return value?.split(",", 1)[0]?.trim() || undefined; } -function versionDiscovery(paths: ReturnType, exposeAttach = true) { +type RemoteHostMetadata = { + hostIdentity: string; + audience: string; +}; + +function remoteHostMetadata( + requestUrl: string, + basePath: string, + options: HttpServeOptions, + header: (name: string) => string | undefined, +): RemoteHostMetadata { + const audience = remoteCredentialHostUrl( + requestUrl, + basePath, + options.publicOrigin, + options.trustProxy, + header, + ); + return { hostIdentity: audience, audience }; +} + +function versionDiscovery( + paths: ReturnType, + exposeAttach = true, + remote?: RemoteHostMetadata | undefined, +) { return { version: 1, path: paths.version, + ...(remote ? { remote } : {}), links: { mcp: paths.mcp, admin: paths.control, diff --git a/packages/core/test/remote-profiles.test.ts b/packages/core/test/remote-profiles.test.ts index c4098307..86d4b8e1 100644 --- a/packages/core/test/remote-profiles.test.ts +++ b/packages/core/test/remote-profiles.test.ts @@ -42,6 +42,7 @@ describe("Remote Profile storage", () => { const status = await store.saveSelfHostedProfile({ hostUrl: "https://caplets.example.com/caplets/", + hostIdentity: "host_self_123", clientId: "rcli_123", clientLabel: "Ian's MacBook", credentials: { @@ -61,6 +62,7 @@ describe("Remote Profile storage", () => { hostUrl: "https://caplets.example.com/caplets", }), hostUrl: "https://caplets.example.com/caplets", + hostIdentity: "host_self_123", clientId: "rcli_123", selected: false, clientLabel: "Ian's MacBook", @@ -73,8 +75,15 @@ describe("Remote Profile storage", () => { 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" }); + store.getSelfHostedProfileStatus({ + hostUrl: "https://caplets.example.com/caplets", + hostIdentity: "host_self_123", + }), + ).resolves.toMatchObject({ + clientId: "rcli_123", + clientLabel: "Ian's MacBook", + hostIdentity: "host_self_123", + }); await expect( store.credentials.load( remoteProfileKey({ @@ -85,6 +94,26 @@ describe("Remote Profile storage", () => { ).resolves.toMatchObject({ accessToken: "self_hosted_access_secret" }); }); + it("fails closed when a self-hosted profile identity does not match the contacted host", async () => { + const store = tempRemoteProfileStore(); + await store.saveSelfHostedProfile({ + hostUrl: "https://caplets.example.com", + hostIdentity: "host_original", + clientId: "rcli_123", + credentials: { + accessToken: "self_hosted_access_secret", + refreshToken: "self_hosted_refresh_secret", + }, + }); + + await expect( + store.getSelfHostedProfileStatus({ + hostUrl: "https://caplets.example.com", + hostIdentity: "host_replaced", + }), + ).rejects.toThrow(expect.objectContaining({ code: "AUTH_FAILED" }) as CapletsError); + }); + it("logs out a self-hosted profile without touching Cloud profiles", async () => { const store = tempRemoteProfileStore(); await store.saveSelfHostedProfile({ diff --git a/packages/core/test/serve-http.test.ts b/packages/core/test/serve-http.test.ts index 3c0e9eaf..d83aa8c7 100644 --- a/packages/core/test/serve-http.test.ts +++ b/packages/core/test/serve-http.test.ts @@ -187,6 +187,46 @@ describe("createHttpServeApp", () => { await engine.close(); }); + it("reports remote credential host identity metadata from the public origin", async () => { + const { engine } = testEngine(); + const app = createHttpServeApp( + httpOptions({ + publicOrigin: "https://caplets.example.com", + auth: { type: "remote_credentials" }, + }), + engine, + { writeErr: () => {}, remoteCredentialStore: remoteCredentialStore() }, + ); + + const root = await app.request("http://127.0.0.1:5387/"); + expect(root.status).toBe(200); + await expect(root.json()).resolves.toMatchObject({ + remote: { + hostIdentity: "https://caplets.example.com/", + audience: "https://caplets.example.com/", + }, + versions: [ + expect.objectContaining({ + remote: { + hostIdentity: "https://caplets.example.com/", + audience: "https://caplets.example.com/", + }, + }), + ], + }); + + const version = await app.request("http://127.0.0.1:5387/v1"); + expect(version.status).toBe(200); + await expect(version.json()).resolves.toMatchObject({ + remote: { + hostIdentity: "https://caplets.example.com/", + audience: "https://caplets.example.com/", + }, + }); + + await engine.close(); + }); + it("exchanges Pairing Codes and rotates refresh credentials without accepting the copied code", async () => { const { engine } = testEngine(); const store = remoteCredentialStore(); From 369395b9d00cd4065ad78f0d9ac2785aa8a0d1a6 Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Mon, 22 Jun 2026 15:20:13 -0400 Subject: [PATCH 02/25] feat(core): add pending remote login store --- .../src/remote/server-credential-store.ts | 335 +++++++++++++++++- packages/core/test/remote-pairing.test.ts | 192 ++++++++++ 2 files changed, 526 insertions(+), 1 deletion(-) diff --git a/packages/core/src/remote/server-credential-store.ts b/packages/core/src/remote/server-credential-store.ts index 6c1f6d85..5f40a6bc 100644 --- a/packages/core/src/remote/server-credential-store.ts +++ b/packages/core/src/remote/server-credential-store.ts @@ -51,6 +51,34 @@ export type RefreshClientCredentialsInput = { now?: Date | undefined; }; +export type CreatePendingLoginInput = { + hostUrl: string; + hostIdentity?: string | undefined; + clientLabel?: string | undefined; + clientFingerprint?: string | undefined; + sourceHint?: string | undefined; + now?: Date | undefined; +}; + +export type PendingLoginPossessionInput = { + flowId: string; + pendingCompletionSecret: string; + now?: Date | undefined; +}; + +export type RefreshPendingLoginInput = PendingLoginPossessionInput & { + pendingRefreshSecret: string; +}; + +export type ApprovePendingLoginInput = { + operatorCode: string; + now?: Date | undefined; +}; + +export type CompletePendingLoginInput = PendingLoginPossessionInput & { + hostUrl: string; +}; + type StoredPairingCode = { codeId: string; hostUrl: string; @@ -77,6 +105,29 @@ type StoredRemoteClient = { revokedAt?: string | undefined; }; +type PendingLoginStatus = "pending" | "approved" | "denied" | "cancelled" | "expired" | "exchanged"; + +type StoredPendingLogin = { + flowId: string; + hostUrl: string; + hostIdentity?: string | undefined; + operatorCodeHash: string; + pendingRefreshHash: string; + supersededPendingRefreshHashes: SupersededRefreshToken[]; + pendingCompletionHash: string; + clientLabel: string; + clientFingerprint?: string | undefined; + sourceHint?: string | undefined; + createdAt: string; + codeExpiresAt: string; + flowExpiresAt: string; + status: PendingLoginStatus; + approvedAt?: string | undefined; + deniedAt?: string | undefined; + cancelledAt?: string | undefined; + exchangedAt?: string | undefined; +}; + type SupersededRefreshToken = { hash: string; supersededAt: string; @@ -85,12 +136,16 @@ type SupersededRefreshToken = { type RemoteServerCredentialState = { version: 1; pairingCodes: StoredPairingCode[]; + pendingLogins: StoredPendingLogin[]; 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 DEFAULT_PENDING_OPERATOR_CODE_TTL_MS = 10 * 60_000; +const DEFAULT_PENDING_FLOW_TTL_MS = 24 * 60 * 60_000; +const DEFAULT_PENDING_POLL_INTERVAL_SECONDS = 5; const STALE_REFRESH_REVOKE_GRACE_MS = 30_000; const SUPERSEDED_REFRESH_TOKEN_RETENTION_MS = 24 * 60 * 60_000; const STATE_FILE = "remote-server-credentials.json"; @@ -105,6 +160,245 @@ export class RemoteServerCredentialStore { this.dir = options.dir; } + createPendingLogin(input: CreatePendingLoginInput): { + flowId: string; + operatorCode: string; + pendingRefreshSecret: string; + pendingCompletionSecret: string; + codeExpiresAt: string; + flowExpiresAt: string; + intervalSeconds: number; + } { + return this.withStateLock(() => { + const now = input.now ?? new Date(); + const flowId = `rlogin_${randomToken(12)}`; + const operatorCode = `cap_login_${randomToken(5)}`; + const pendingRefreshSecret = `cap_pending_refresh_${randomToken(32)}`; + const pendingCompletionSecret = `cap_pending_complete_${randomToken(32)}`; + const codeExpiresAt = new Date( + now.getTime() + DEFAULT_PENDING_OPERATOR_CODE_TTL_MS, + ).toISOString(); + const flowExpiresAt = new Date(now.getTime() + DEFAULT_PENDING_FLOW_TTL_MS).toISOString(); + const state = this.loadState(); + state.pendingLogins.push({ + flowId, + hostUrl: normalizeRemoteProfileHostUrl(input.hostUrl), + ...(input.hostIdentity ? { hostIdentity: input.hostIdentity } : {}), + operatorCodeHash: hashSecret(operatorCode), + pendingRefreshHash: hashSecret(pendingRefreshSecret), + supersededPendingRefreshHashes: [], + pendingCompletionHash: hashSecret(pendingCompletionSecret), + clientLabel: input.clientLabel ?? "Caplets Remote Client", + ...(input.clientFingerprint ? { clientFingerprint: input.clientFingerprint } : {}), + ...(input.sourceHint ? { sourceHint: input.sourceHint } : {}), + createdAt: now.toISOString(), + codeExpiresAt, + flowExpiresAt, + status: "pending", + }); + this.saveState(state); + return { + flowId, + operatorCode, + pendingRefreshSecret, + pendingCompletionSecret, + codeExpiresAt, + flowExpiresAt, + intervalSeconds: DEFAULT_PENDING_POLL_INTERVAL_SECONDS, + }; + }); + } + + pollPendingLogin(input: PendingLoginPossessionInput): { + flowId: string; + status: PendingLoginStatus; + } { + const now = input.now ?? new Date(); + const flow = this.pendingLoginForCompletion(input.flowId, input.pendingCompletionSecret, now); + return { flowId: flow.flowId, status: flow.status }; + } + + refreshPendingLogin(input: RefreshPendingLoginInput): { + flowId: string; + operatorCode: string; + pendingRefreshSecret: string; + codeExpiresAt: string; + flowExpiresAt: string; + intervalSeconds: number; + } { + return this.withStateLock(() => { + const now = input.now ?? new Date(); + const state = this.loadState(); + const flow = this.pendingLoginForCompletion( + input.flowId, + input.pendingCompletionSecret, + now, + state, + ); + if (flow.status !== "pending") { + throw new CapletsError("AUTH_FAILED", `Pending login is already ${flow.status}.`); + } + const refreshHash = hashSecret(input.pendingRefreshSecret); + if (!safeHashEqual(refreshHash, flow.pendingRefreshHash)) { + if ( + flow.supersededPendingRefreshHashes.some((entry) => + safeHashEqual(refreshHash, entry.hash), + ) + ) { + throw new CapletsError("AUTH_FAILED", "Pending login refresh material is stale."); + } + throw new CapletsError("AUTH_FAILED", "Pending login refresh material is invalid."); + } + + const operatorCode = `cap_login_${randomToken(5)}`; + const pendingRefreshSecret = `cap_pending_refresh_${randomToken(32)}`; + const codeExpiresAt = new Date( + now.getTime() + DEFAULT_PENDING_OPERATOR_CODE_TTL_MS, + ).toISOString(); + flow.operatorCodeHash = hashSecret(operatorCode); + flow.supersededPendingRefreshHashes = pruneSupersededRefreshTokens( + flow.supersededPendingRefreshHashes, + now, + ); + flow.supersededPendingRefreshHashes.push({ + hash: flow.pendingRefreshHash, + supersededAt: now.toISOString(), + }); + flow.pendingRefreshHash = hashSecret(pendingRefreshSecret); + flow.codeExpiresAt = codeExpiresAt; + this.saveState(state); + return { + flowId: flow.flowId, + operatorCode, + pendingRefreshSecret, + codeExpiresAt, + flowExpiresAt: flow.flowExpiresAt, + intervalSeconds: DEFAULT_PENDING_POLL_INTERVAL_SECONDS, + }; + }); + } + + denyPendingLogin(input: ApprovePendingLoginInput): { + flowId: string; + status: "denied"; + } { + return this.withStateLock(() => { + const now = input.now ?? new Date(); + const state = this.loadState(); + const flow = state.pendingLogins.find((candidate) => + safeHashEqual(hashSecret(input.operatorCode), candidate.operatorCodeHash), + ); + if (!flow) throw new CapletsError("AUTH_FAILED", "Pending login code is unknown."); + if (flow.status !== "pending") { + throw new CapletsError("AUTH_FAILED", `Pending login is already ${flow.status}.`); + } + if (Date.parse(flow.flowExpiresAt) <= now.getTime()) { + flow.status = "expired"; + this.saveState(state); + throw new CapletsError("AUTH_FAILED", "Pending login has expired."); + } + flow.status = "denied"; + flow.deniedAt = now.toISOString(); + this.saveState(state); + return { flowId: flow.flowId, status: "denied" }; + }); + } + + cancelPendingLogin(input: PendingLoginPossessionInput): { + flowId: string; + status: "cancelled"; + } { + return this.withStateLock(() => { + const now = input.now ?? new Date(); + const state = this.loadState(); + const flow = this.pendingLoginForCompletion( + input.flowId, + input.pendingCompletionSecret, + now, + state, + ); + if (flow.status !== "pending") { + throw new CapletsError("AUTH_FAILED", `Pending login is already ${flow.status}.`); + } + flow.status = "cancelled"; + flow.cancelledAt = now.toISOString(); + this.saveState(state); + return { flowId: flow.flowId, status: "cancelled" }; + }); + } + + approvePendingLogin(input: ApprovePendingLoginInput): { + flowId: string; + status: "approved"; + clientLabel: string; + clientFingerprint?: string | undefined; + sourceHint?: string | undefined; + } { + return this.withStateLock(() => { + const now = input.now ?? new Date(); + const state = this.loadState(); + const flow = state.pendingLogins.find((candidate) => + safeHashEqual(hashSecret(input.operatorCode), candidate.operatorCodeHash), + ); + if (!flow) throw new CapletsError("AUTH_FAILED", "Pending login code is unknown."); + if (Date.parse(flow.flowExpiresAt) <= now.getTime()) { + flow.status = "expired"; + this.saveState(state); + throw new CapletsError("AUTH_FAILED", "Pending login has expired."); + } + if (flow.status !== "pending") { + throw new CapletsError("AUTH_FAILED", `Pending login is already ${flow.status}.`); + } + flow.status = "approved"; + flow.approvedAt = now.toISOString(); + this.saveState(state); + return pendingApprovalStatus(flow); + }); + } + + completePendingLogin(input: CompletePendingLoginInput): IssuedRemoteClientCredentials { + return this.withStateLock(() => { + const now = input.now ?? new Date(); + const state = this.loadState(); + const flow = this.pendingLoginForCompletion( + input.flowId, + input.pendingCompletionSecret, + now, + state, + ); + if (flow.hostUrl !== normalizeRemoteProfileHostUrl(input.hostUrl)) { + throw new CapletsError("AUTH_FAILED", "Pending login belongs to a different host."); + } + if (flow.status !== "approved") { + throw new CapletsError( + "AUTH_FAILED", + flow.status === "exchanged" + ? "Pending login has already been exchanged." + : `Pending login is ${flow.status}, not approved.`, + ); + } + + const accessToken = `cap_remote_access_${randomToken(32)}`; + const refreshToken = `cap_remote_refresh_${randomToken(32)}`; + const client: StoredRemoteClient = { + clientId: `rcli_${randomToken(12)}`, + clientLabel: flow.clientLabel, + hostUrl: flow.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); + flow.status = "exchanged"; + flow.exchangedAt = now.toISOString(); + this.saveState(state); + return credentialsFromClient(client, accessToken, refreshToken); + }); + } + createPairingCode(input: CreatePairingCodeInput): { codeId: string; code: string; @@ -284,11 +578,17 @@ export class RemoteServerCredentialStore { private loadState(): RemoteServerCredentialState { const path = this.statePath(); - if (!existsSync(path)) return { version: 1, pairingCodes: [], clients: [] }; + if (!existsSync(path)) return { version: 1, pairingCodes: [], pendingLogins: [], clients: [] }; const parsed = JSON.parse(readFileSync(path, "utf8")) as Partial; return { version: 1, pairingCodes: parsed.pairingCodes ?? [], + pendingLogins: (parsed.pendingLogins ?? []).map((pending) => ({ + ...pending, + supersededPendingRefreshHashes: parseSupersededRefreshTokens( + pending.supersededPendingRefreshHashes, + ), + })), clients: (parsed.clients ?? []).map((client) => ({ ...client, supersededRefreshTokenHashes: parseSupersededRefreshTokens( @@ -365,6 +665,23 @@ export class RemoteServerCredentialStore { return false; } } + + private pendingLoginForCompletion( + flowId: string, + pendingCompletionSecret: string, + now: Date, + state = this.loadState(), + ): StoredPendingLogin { + const flow = state.pendingLogins.find((candidate) => candidate.flowId === flowId); + if (!flow) throw new CapletsError("AUTH_FAILED", "Pending login is unknown."); + if (!safeHashEqual(hashSecret(pendingCompletionSecret), flow.pendingCompletionHash)) { + throw new CapletsError("AUTH_FAILED", "Pending login possession material is invalid."); + } + if (Date.parse(flow.flowExpiresAt) <= now.getTime() && flow.status === "pending") { + flow.status = "expired"; + } + return flow; + } } function sleepSync(ms: number): void { @@ -438,6 +755,22 @@ function clientStatus(client: StoredRemoteClient): RemoteClientStatus { }; } +function pendingApprovalStatus(flow: StoredPendingLogin): { + flowId: string; + status: "approved"; + clientLabel: string; + clientFingerprint?: string | undefined; + sourceHint?: string | undefined; +} { + return { + flowId: flow.flowId, + status: "approved", + clientLabel: flow.clientLabel, + ...(flow.clientFingerprint ? { clientFingerprint: flow.clientFingerprint } : {}), + ...(flow.sourceHint ? { sourceHint: flow.sourceHint } : {}), + }; +} + function hashSecret(secret: string): string { return createHash("sha256").update(secret).digest("base64url"); } diff --git a/packages/core/test/remote-pairing.test.ts b/packages/core/test/remote-pairing.test.ts index fa3372b7..12584691 100644 --- a/packages/core/test/remote-pairing.test.ts +++ b/packages/core/test/remote-pairing.test.ts @@ -13,6 +13,198 @@ afterEach(() => { }); describe("self-hosted remote pairing", () => { + it("creates pending login flows that approve and complete with separate possession material", () => { + const store = new RemoteServerCredentialStore({ dir: tempDir() }); + const pending = store.createPendingLogin({ + hostUrl: "https://caplets.example.com/caplets", + hostIdentity: "https://caplets.example.com/caplets", + clientLabel: "MacBook Pro", + clientFingerprint: "fp_123", + sourceHint: "127.0.0.1", + now: new Date("2026-06-19T12:00:00.000Z"), + }); + + expect(pending).toMatchObject({ + flowId: expect.stringMatching(/^rlogin_/u), + operatorCode: expect.stringMatching(/^cap_login_/u), + pendingRefreshSecret: expect.stringMatching(/^cap_pending_refresh_/u), + pendingCompletionSecret: expect.stringMatching(/^cap_pending_complete_/u), + codeExpiresAt: "2026-06-19T12:10:00.000Z", + flowExpiresAt: "2026-06-20T12:00:00.000Z", + intervalSeconds: 5, + }); + expect(pending.pendingRefreshSecret).not.toBe(pending.pendingCompletionSecret); + expect(JSON.stringify(store.dumpForTest())).not.toContain(pending.operatorCode); + expect(JSON.stringify(store.dumpForTest())).not.toContain(pending.pendingRefreshSecret); + expect(JSON.stringify(store.dumpForTest())).not.toContain(pending.pendingCompletionSecret); + + expect( + store.pollPendingLogin({ + flowId: pending.flowId, + pendingCompletionSecret: pending.pendingCompletionSecret, + now: new Date("2026-06-19T12:01:00.000Z"), + }), + ).toMatchObject({ status: "pending" }); + + expect(() => + store.completePendingLogin({ + hostUrl: "https://caplets.example.com/caplets", + flowId: pending.flowId, + pendingCompletionSecret: pending.pendingCompletionSecret, + now: new Date("2026-06-19T12:01:30.000Z"), + }), + ).toThrow(/approved/u); + + const approved = store.approvePendingLogin({ + operatorCode: pending.operatorCode, + now: new Date("2026-06-19T12:02:00.000Z"), + }); + expect(approved).toMatchObject({ + flowId: pending.flowId, + status: "approved", + clientLabel: "MacBook Pro", + clientFingerprint: "fp_123", + sourceHint: "127.0.0.1", + }); + + expect(() => + store.refreshPendingLogin({ + flowId: pending.flowId, + pendingRefreshSecret: pending.pendingRefreshSecret, + pendingCompletionSecret: pending.pendingCompletionSecret, + now: new Date("2026-06-19T12:02:30.000Z"), + }), + ).toThrow(/approved/u); + + const credentials = store.completePendingLogin({ + hostUrl: "https://caplets.example.com/caplets", + flowId: pending.flowId, + pendingCompletionSecret: pending.pendingCompletionSecret, + now: new Date("2026-06-19T12:03:00.000Z"), + }); + expect(credentials).toMatchObject({ + hostUrl: "https://caplets.example.com/caplets", + clientLabel: "MacBook Pro", + tokenType: "Bearer", + }); + expect(credentials.accessToken).not.toBe(pending.pendingRefreshSecret); + expect(credentials.refreshToken).not.toBe(pending.pendingCompletionSecret); + expect(store.listClients()).toEqual([ + expect.objectContaining({ clientId: credentials.clientId, clientLabel: "MacBook Pro" }), + ]); + expect(() => + store.completePendingLogin({ + hostUrl: "https://caplets.example.com/caplets", + flowId: pending.flowId, + pendingCompletionSecret: pending.pendingCompletionSecret, + }), + ).toThrow(/exchanged/u); + }); + + it("rotates pending login codes and rejects terminal pending flows", () => { + const store = new RemoteServerCredentialStore({ dir: tempDir() }); + const pending = store.createPendingLogin({ + hostUrl: "https://caplets.example.com", + clientLabel: "MacBook Pro", + now: new Date("2026-06-19T12:00:00.000Z"), + }); + + const refreshed = store.refreshPendingLogin({ + flowId: pending.flowId, + pendingRefreshSecret: pending.pendingRefreshSecret, + pendingCompletionSecret: pending.pendingCompletionSecret, + now: new Date("2026-06-19T12:09:00.000Z"), + }); + + expect(refreshed).toMatchObject({ + flowId: pending.flowId, + operatorCode: expect.stringMatching(/^cap_login_/u), + pendingRefreshSecret: expect.stringMatching(/^cap_pending_refresh_/u), + codeExpiresAt: "2026-06-19T12:19:00.000Z", + flowExpiresAt: pending.flowExpiresAt, + intervalSeconds: 5, + }); + expect(refreshed.operatorCode).not.toBe(pending.operatorCode); + expect(refreshed.pendingRefreshSecret).not.toBe(pending.pendingRefreshSecret); + expect(JSON.stringify(store.dumpForTest())).not.toContain(refreshed.operatorCode); + expect(JSON.stringify(store.dumpForTest())).not.toContain(refreshed.pendingRefreshSecret); + expect(() => + store.approvePendingLogin({ + operatorCode: pending.operatorCode, + now: new Date("2026-06-19T12:10:00.000Z"), + }), + ).toThrow(/unknown/u); + expect(() => + store.refreshPendingLogin({ + flowId: pending.flowId, + pendingRefreshSecret: pending.pendingRefreshSecret, + pendingCompletionSecret: pending.pendingCompletionSecret, + now: new Date("2026-06-19T12:10:00.000Z"), + }), + ).toThrow(/stale/u); + + store.denyPendingLogin({ + operatorCode: refreshed.operatorCode, + now: new Date("2026-06-19T12:11:00.000Z"), + }); + expect( + store.pollPendingLogin({ + flowId: pending.flowId, + pendingCompletionSecret: pending.pendingCompletionSecret, + now: new Date("2026-06-19T12:12:00.000Z"), + }), + ).toMatchObject({ status: "denied" }); + expect(() => + store.completePendingLogin({ + hostUrl: "https://caplets.example.com", + flowId: pending.flowId, + pendingCompletionSecret: pending.pendingCompletionSecret, + }), + ).toThrow(/denied/u); + }); + + it("cancels and expires pending login flows without issuing credentials", () => { + const store = new RemoteServerCredentialStore({ dir: tempDir() }); + const cancelled = store.createPendingLogin({ + hostUrl: "https://caplets.example.com", + now: new Date("2026-06-19T12:00:00.000Z"), + }); + + expect( + store.cancelPendingLogin({ + flowId: cancelled.flowId, + pendingCompletionSecret: cancelled.pendingCompletionSecret, + now: new Date("2026-06-19T12:01:00.000Z"), + }), + ).toMatchObject({ flowId: cancelled.flowId, status: "cancelled" }); + expect(() => + store.completePendingLogin({ + hostUrl: "https://caplets.example.com", + flowId: cancelled.flowId, + pendingCompletionSecret: cancelled.pendingCompletionSecret, + }), + ).toThrow(/cancelled/u); + + const expired = store.createPendingLogin({ + hostUrl: "https://caplets.example.com", + now: new Date("2026-06-19T12:00:00.000Z"), + }); + expect( + store.pollPendingLogin({ + flowId: expired.flowId, + pendingCompletionSecret: expired.pendingCompletionSecret, + now: new Date("2026-06-20T12:00:01.000Z"), + }), + ).toMatchObject({ flowId: expired.flowId, status: "expired" }); + expect(() => + store.approvePendingLogin({ + operatorCode: expired.operatorCode, + now: new Date("2026-06-20T12:00:01.000Z"), + }), + ).toThrow(/expired/u); + expect(store.listClients()).toHaveLength(0); + }); + it("issues one-time Pairing Codes that exchange for client credentials", () => { const store = new RemoteServerCredentialStore({ dir: tempDir() }); const issued = store.createPairingCode({ From bbc9c01a4a8afa92b3207d99cd5eb570638ddd41 Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Mon, 22 Jun 2026 15:22:34 -0400 Subject: [PATCH 03/25] feat(core): expose pending remote login routes --- packages/core/src/serve/http.ts | 97 +++++++++++++++++++++++++++ packages/core/test/serve-http.test.ts | 82 ++++++++++++++++++++++ 2 files changed, 179 insertions(+) diff --git a/packages/core/src/serve/http.ts b/packages/core/src/serve/http.ts index bc0f887c..c110b2b7 100644 --- a/packages/core/src/serve/http.ts +++ b/packages/core/src/serve/http.ts @@ -111,6 +111,93 @@ export function createHttpServeApp( ); if (remoteCredentialStore) { + app.post(paths.remoteLoginStart, async (c) => { + try { + const parsed = await parseJsonObject(c.req.json(), "Pending remote login start request"); + const clientLabel = optionalStringField(parsed, "clientLabel"); + const clientFingerprint = optionalStringField(parsed, "clientFingerprint"); + const hostUrl = remoteCredentialHostUrl( + c.req.url, + paths.base, + options.publicOrigin, + options.trustProxy, + (name) => c.req.header(name), + ); + const pending = remoteCredentialStore.createPendingLogin({ + hostUrl, + hostIdentity: hostUrl, + ...(clientLabel ? { clientLabel } : {}), + ...(clientFingerprint ? { clientFingerprint } : {}), + }); + return c.json(pending); + } catch (error) { + return remoteCredentialErrorResponse(error); + } + }); + + app.post(paths.remoteLoginPoll, async (c) => { + try { + const parsed = await parseJsonObject(c.req.json(), "Pending remote login poll request"); + return c.json( + remoteCredentialStore.pollPendingLogin({ + flowId: stringField(parsed, "flowId"), + pendingCompletionSecret: stringField(parsed, "pendingCompletionSecret"), + }), + ); + } catch (error) { + return remoteCredentialErrorResponse(error); + } + }); + + app.post(paths.remoteLoginRefresh, async (c) => { + try { + const parsed = await parseJsonObject(c.req.json(), "Pending remote login refresh request"); + return c.json( + remoteCredentialStore.refreshPendingLogin({ + flowId: stringField(parsed, "flowId"), + pendingRefreshSecret: stringField(parsed, "pendingRefreshSecret"), + pendingCompletionSecret: stringField(parsed, "pendingCompletionSecret"), + }), + ); + } catch (error) { + return remoteCredentialErrorResponse(error); + } + }); + + app.post(paths.remoteLoginComplete, async (c) => { + try { + const parsed = await parseJsonObject(c.req.json(), "Pending remote login complete request"); + const credentials = remoteCredentialStore.completePendingLogin({ + hostUrl: remoteCredentialHostUrl( + c.req.url, + paths.base, + options.publicOrigin, + options.trustProxy, + (name) => c.req.header(name), + ), + flowId: stringField(parsed, "flowId"), + pendingCompletionSecret: stringField(parsed, "pendingCompletionSecret"), + }); + return c.json(credentials); + } catch (error) { + return remoteCredentialErrorResponse(error); + } + }); + + app.post(paths.remoteLoginCancel, async (c) => { + try { + const parsed = await parseJsonObject(c.req.json(), "Pending remote login cancel request"); + return c.json( + remoteCredentialStore.cancelPendingLogin({ + flowId: stringField(parsed, "flowId"), + pendingCompletionSecret: stringField(parsed, "pendingCompletionSecret"), + }), + ); + } catch (error) { + return remoteCredentialErrorResponse(error); + } + }); + app.post(paths.pairingExchange, async (c) => { try { const parsed = await parseJsonObject(c.req.json(), "Pairing exchange request"); @@ -677,6 +764,11 @@ export function servicePaths(base: string): { attachInvoke: string; projectBindings: string; pairingExchange: string; + remoteLoginStart: string; + remoteLoginPoll: string; + remoteLoginRefresh: string; + remoteLoginComplete: string; + remoteLoginCancel: string; remoteRefresh: string; remoteClient: string; health: string; @@ -694,6 +786,11 @@ export function servicePaths(base: string): { attachInvoke: routePath(attach, "invoke"), projectBindings: routePath(attach, "project-bindings"), pairingExchange: routePath(remote, "pairing/exchange"), + remoteLoginStart: routePath(remote, "login/start"), + remoteLoginPoll: routePath(remote, "login/poll"), + remoteLoginRefresh: routePath(remote, "login/refresh"), + remoteLoginComplete: routePath(remote, "login/complete"), + remoteLoginCancel: routePath(remote, "login/cancel"), remoteRefresh: routePath(remote, "refresh"), remoteClient: routePath(remote, "client"), health: routePath(version, "healthz"), diff --git a/packages/core/test/serve-http.test.ts b/packages/core/test/serve-http.test.ts index d83aa8c7..cf1395f7 100644 --- a/packages/core/test/serve-http.test.ts +++ b/packages/core/test/serve-http.test.ts @@ -281,6 +281,88 @@ describe("createHttpServeApp", () => { await engine.close(); }); + it("starts, polls, and completes pending remote login over HTTP without remote approval routes", async () => { + const { engine } = testEngine(); + const store = remoteCredentialStore(); + const app = createHttpServeApp(httpOptions({ auth: { type: "remote_credentials" } }), engine, { + writeErr: () => {}, + remoteCredentialStore: store, + }); + + const started = await app.request("http://127.0.0.1:5387/v1/remote/login/start", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + clientLabel: "Test Client", + clientFingerprint: "fp_test", + }), + }); + expect(started.status).toBe(200); + const pending = (await started.json()) as { + flowId: string; + operatorCode: string; + pendingRefreshSecret: string; + pendingCompletionSecret: string; + codeExpiresAt: string; + flowExpiresAt: string; + intervalSeconds: number; + }; + expect(pending.operatorCode).toMatch(/^cap_login_/u); + expect(pending.pendingRefreshSecret).toMatch(/^cap_pending_refresh_/u); + expect(pending.pendingCompletionSecret).toMatch(/^cap_pending_complete_/u); + + const waiting = await app.request("http://127.0.0.1:5387/v1/remote/login/poll", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + flowId: pending.flowId, + pendingCompletionSecret: pending.pendingCompletionSecret, + }), + }); + expect(waiting.status).toBe(200); + await expect(waiting.json()).resolves.toMatchObject({ status: "pending" }); + + expect(await app.request("http://127.0.0.1:5387/v1/remote/login/approve")).toMatchObject({ + status: 404, + }); + store.approvePendingLogin({ operatorCode: pending.operatorCode }); + + const approved = await app.request("http://127.0.0.1:5387/v1/remote/login/poll", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + flowId: pending.flowId, + pendingCompletionSecret: pending.pendingCompletionSecret, + }), + }); + expect(approved.status).toBe(200); + await expect(approved.json()).resolves.toMatchObject({ status: "approved" }); + + const completed = await app.request("http://127.0.0.1:5387/v1/remote/login/complete", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + flowId: pending.flowId, + pendingCompletionSecret: pending.pendingCompletionSecret, + }), + }); + expect(completed.status).toBe(200); + const credentials = (await completed.json()) as { + accessToken: string; + refreshToken: string; + clientId: string; + }; + expect(credentials.accessToken).not.toBe(pending.pendingRefreshSecret); + expect(credentials.refreshToken).not.toBe(pending.pendingCompletionSecret); + + const attach = await app.request("http://127.0.0.1:5387/v1/attach/manifest", { + headers: { authorization: `Bearer ${credentials.accessToken}` }, + }); + expect(attach.status).toBe(200); + + await engine.close(); + }); + it("returns a retryable status when remote credential refresh state is unavailable", async () => { const { engine } = testEngine(); const app = createHttpServeApp(httpOptions({ auth: { type: "remote_credentials" } }), engine, { From 8f9a88a71eb347fc1b97d3ed8c2724d9fe2ca276 Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Mon, 22 Jun 2026 15:27:14 -0400 Subject: [PATCH 04/25] feat(core): add pending login operator commands --- packages/core/src/cli.ts | 57 ++++++++++++++++++ .../src/remote/server-credential-store.ts | 26 +++++++++ .../core/src/remote/server-credentials.ts | 17 ++++++ packages/core/test/remote-login-cli.test.ts | 58 +++++++++++++++++++ 4 files changed, 158 insertions(+) diff --git a/packages/core/src/cli.ts b/packages/core/src/cli.ts index bee635e6..fc4bb4ec 100644 --- a/packages/core/src/cli.ts +++ b/packages/core/src/cli.ts @@ -1299,6 +1299,63 @@ export function createProgram(io: CliIO = {}): Command { ); } }); + remoteHost + .command("logins") + .description("List pending self-hosted Remote Login approvals from server state.") + .option("--state-path ", "server-owned remote credential state directory") + .option("--json", "print JSON output") + .action((options: { statePath?: string; json?: boolean }) => { + const pendingLogins = remoteServerCredentialStore(options.statePath, env).listPendingLogins(); + if (options.json) { + writeOut(`${JSON.stringify({ pendingLogins }, null, 2)}\n`); + return; + } + if (pendingLogins.length === 0) { + writeOut("No pending Remote Login approvals.\n"); + return; + } + for (const pending of pendingLogins) { + writeOut( + `${pending.flowId}\t${terminalSafeText(pending.clientLabel)}\t${pending.hostUrl}\t${pending.status}\n`, + ); + } + }); + remoteHost + .command("approve") + .description("Approve one pending self-hosted Remote Login code from server state.") + .argument("", "operator-visible Remote Login code") + .option("--state-path ", "server-owned remote credential state directory") + .option("--yes", "approve without an interactive confirmation prompt") + .option("--json", "print JSON output") + .action((code: string, options: { statePath?: string; yes?: boolean; json?: boolean }) => { + if (!options.yes && !options.json) { + throw new CapletsError("REQUEST_INVALID", "Use --yes to approve this pending login."); + } + const approved = remoteServerCredentialStore(options.statePath, env).approvePendingLogin({ + operatorCode: code, + }); + if (options.json) { + writeOut(`${JSON.stringify(approved, null, 2)}\n`); + return; + } + writeOut(`Approved pending Remote Login ${approved.flowId}.\n`); + }); + remoteHost + .command("deny") + .description("Deny one pending self-hosted Remote Login code from server state.") + .argument("", "operator-visible Remote Login code") + .option("--state-path ", "server-owned remote credential state directory") + .option("--json", "print JSON output") + .action((code: string, options: { statePath?: string; json?: boolean }) => { + const denied = remoteServerCredentialStore(options.statePath, env).denyPendingLogin({ + operatorCode: code, + }); + if (options.json) { + writeOut(`${JSON.stringify(denied, null, 2)}\n`); + return; + } + writeOut(`Denied pending Remote Login ${denied.flowId}.\n`); + }); remoteHost .command("revoke") .description("Revoke one paired self-hosted remote client from server state.") diff --git a/packages/core/src/remote/server-credential-store.ts b/packages/core/src/remote/server-credential-store.ts index 5f40a6bc..481f1388 100644 --- a/packages/core/src/remote/server-credential-store.ts +++ b/packages/core/src/remote/server-credential-store.ts @@ -17,6 +17,7 @@ import { createPairingCode, parsePairingCode, randomToken } from "./pairing"; import type { IssuedRemoteClientCredentials, RemoteClientStatus, + RemotePendingLoginStatus, ValidatedRemoteClient, } from "./server-credentials"; @@ -494,6 +495,12 @@ export class RemoteServerCredentialStore { .sort((left, right) => left.createdAt.localeCompare(right.createdAt)); } + listPendingLogins(): RemotePendingLoginStatus[] { + return this.loadState() + .pendingLogins.map(pendingLoginStatus) + .sort((left, right) => left.createdAt.localeCompare(right.createdAt)); + } + revokeClient(clientId: string, now = new Date()): boolean { return this.withStateLock(() => { const state = this.loadState(); @@ -755,6 +762,25 @@ function clientStatus(client: StoredRemoteClient): RemoteClientStatus { }; } +function pendingLoginStatus(flow: StoredPendingLogin): RemotePendingLoginStatus { + return { + flowId: flow.flowId, + hostUrl: flow.hostUrl, + ...(flow.hostIdentity ? { hostIdentity: flow.hostIdentity } : {}), + status: flow.status, + clientLabel: flow.clientLabel, + ...(flow.clientFingerprint ? { clientFingerprint: flow.clientFingerprint } : {}), + ...(flow.sourceHint ? { sourceHint: flow.sourceHint } : {}), + createdAt: flow.createdAt, + codeExpiresAt: flow.codeExpiresAt, + flowExpiresAt: flow.flowExpiresAt, + ...(flow.approvedAt ? { approvedAt: flow.approvedAt } : {}), + ...(flow.deniedAt ? { deniedAt: flow.deniedAt } : {}), + ...(flow.cancelledAt ? { cancelledAt: flow.cancelledAt } : {}), + ...(flow.exchangedAt ? { exchangedAt: flow.exchangedAt } : {}), + }; +} + function pendingApprovalStatus(flow: StoredPendingLogin): { flowId: string; status: "approved"; diff --git a/packages/core/src/remote/server-credentials.ts b/packages/core/src/remote/server-credentials.ts index 06d1f69f..a9b00a81 100644 --- a/packages/core/src/remote/server-credentials.ts +++ b/packages/core/src/remote/server-credentials.ts @@ -18,6 +18,23 @@ export type RemoteClientStatus = { revokedAt?: string | undefined; }; +export type RemotePendingLoginStatus = { + flowId: string; + hostUrl: string; + hostIdentity?: string | undefined; + status: string; + clientLabel: string; + clientFingerprint?: string | undefined; + sourceHint?: string | undefined; + createdAt: string; + codeExpiresAt: string; + flowExpiresAt: string; + approvedAt?: string | undefined; + deniedAt?: string | undefined; + cancelledAt?: string | undefined; + exchangedAt?: string | undefined; +}; + export type ValidatedRemoteClient = RemoteClientStatus & { tokenType: "Bearer"; }; diff --git a/packages/core/test/remote-login-cli.test.ts b/packages/core/test/remote-login-cli.test.ts index 7ff46b5f..6f8ab25a 100644 --- a/packages/core/test/remote-login-cli.test.ts +++ b/packages/core/test/remote-login-cli.test.ts @@ -253,6 +253,64 @@ describe("caplets remote CLI", () => { expect(out.join("")).toContain("Bad?[31mName?"); }); + it("lists and approves pending self-hosted logins from server state", async () => { + const serverStateDir = tempDir("caplets-remote-cli-server-"); + const server = new RemoteServerCredentialStore({ dir: serverStateDir }); + const pending = server.createPendingLogin({ + hostUrl: "https://caplets.example.com", + clientLabel: `Bad${String.fromCharCode(0x1b)}[31mDevice`, + clientFingerprint: "fp_test", + sourceHint: "127.0.0.1", + }); + const listOut: string[] = []; + + await runCli(["remote", "host", "logins", "--state-path", serverStateDir, "--json"], { + writeOut: (value) => listOut.push(value), + }); + + expect(JSON.parse(listOut.join(""))).toMatchObject({ + pendingLogins: [ + { + flowId: pending.flowId, + status: "pending", + clientLabel: `Bad${String.fromCharCode(0x1b)}[31mDevice`, + clientFingerprint: "fp_test", + sourceHint: "127.0.0.1", + }, + ], + }); + expect(listOut.join("")).not.toContain(pending.pendingRefreshSecret); + expect(listOut.join("")).not.toContain(pending.pendingCompletionSecret); + + const approveOut: string[] = []; + await runCli( + [ + "remote", + "host", + "approve", + pending.operatorCode, + "--state-path", + serverStateDir, + "--yes", + "--json", + ], + { writeOut: (value) => approveOut.push(value) }, + ); + + expect(JSON.parse(approveOut.join(""))).toMatchObject({ + flowId: pending.flowId, + status: "approved", + clientLabel: `Bad${String.fromCharCode(0x1b)}[31mDevice`, + }); + expect( + server.completePendingLogin({ + hostUrl: "https://caplets.example.com", + flowId: pending.flowId, + pendingCompletionSecret: pending.pendingCompletionSecret, + }), + ).toMatchObject({ clientLabel: `Bad${String.fromCharCode(0x1b)}[31mDevice` }); + }); + it("routes Cloud login through Remote Profiles instead of legacy Cloud Auth", async () => { const authDir = tempDir("caplets-remote-cli-auth-"); const responses = [ From 10406ec4183151b4ebd38b56e40a75b16b3a94ab Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Mon, 22 Jun 2026 15:35:31 -0400 Subject: [PATCH 05/25] feat(core): add pending remote login client flow --- packages/core/src/cli.ts | 170 ++++++++++++++++++-- packages/core/test/remote-login-cli.test.ts | 76 +++++++++ 2 files changed, 229 insertions(+), 17 deletions(-) diff --git a/packages/core/src/cli.ts b/packages/core/src/cli.ts index fc4bb4ec..50fdad02 100644 --- a/packages/core/src/cli.ts +++ b/packages/core/src/cli.ts @@ -453,6 +453,16 @@ type RemoteLoginCredentialsResponse = { expiresAt?: string | undefined; }; +type PendingRemoteLoginStartResponse = { + flowId: string; + operatorCode: string; + pendingRefreshSecret: string; + pendingCompletionSecret: string; + codeExpiresAt: string; + flowExpiresAt: string; + intervalSeconds: number; +}; + async function parseRemoteLoginCredentials( response: Response, ): Promise { @@ -481,6 +491,120 @@ async function parseRemoteLoginCredentials( }; } +async function parsePendingRemoteLoginStart( + response: Response, +): Promise { + const parsed = await response.json(); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new CapletsError( + "DOWNSTREAM_PROTOCOL_ERROR", + "Pending Remote Login response must be an object.", + ); + } + const record = parsed as Record; + if ( + typeof record.flowId !== "string" || + typeof record.operatorCode !== "string" || + typeof record.pendingRefreshSecret !== "string" || + typeof record.pendingCompletionSecret !== "string" || + typeof record.codeExpiresAt !== "string" || + typeof record.flowExpiresAt !== "string" + ) { + throw new CapletsError( + "DOWNSTREAM_PROTOCOL_ERROR", + "Pending Remote Login response is missing pending material.", + ); + } + return { + flowId: record.flowId, + operatorCode: record.operatorCode, + pendingRefreshSecret: record.pendingRefreshSecret, + pendingCompletionSecret: record.pendingCompletionSecret, + codeExpiresAt: record.codeExpiresAt, + flowExpiresAt: record.flowExpiresAt, + intervalSeconds: typeof record.intervalSeconds === "number" ? record.intervalSeconds : 5, + }; +} + +async function parsePendingRemoteLoginStatus(response: Response): Promise { + const parsed = await response.json(); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new CapletsError( + "DOWNSTREAM_PROTOCOL_ERROR", + "Pending Remote Login status response must be an object.", + ); + } + const status = (parsed as Record).status; + if (typeof status !== "string") { + throw new CapletsError( + "DOWNSTREAM_PROTOCOL_ERROR", + "Pending Remote Login status response is missing status.", + ); + } + return status; +} + +async function selfHostedPendingRemoteLogin( + url: string, + input: { + clientLabel?: string | undefined; + json?: boolean | undefined; + fetch?: typeof fetch | undefined; + writeOut: (value: string) => void; + env: NodeJS.ProcessEnv | Record; + }, +): Promise { + const fetchImpl = input.fetch ?? fetch; + const baseUrl = new URL(normalizeRemoteProfileHostUrl(url)); + const startBody = input.clientLabel ? { clientLabel: input.clientLabel } : {}; + const start = await fetchImpl(appendBasePath(baseUrl, "v1/remote/login/start"), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(startBody), + }); + if (!start.ok) throw new CapletsError("AUTH_FAILED", "Remote Login pending start failed."); + const pending = await parsePendingRemoteLoginStart(start); + if (!input.json) { + input.writeOut(`Remote Login Code: ${pending.operatorCode}\n`); + input.writeOut( + `Approve from the host with caplets remote host approve ${pending.operatorCode}\n`, + ); + } + + const intervalMs = numberEnv( + input.env.CAPLETS_REMOTE_LOGIN_POLL_INTERVAL_MS, + pending.intervalSeconds * 1_000, + ); + while (true) { + const poll = await fetchImpl(appendBasePath(baseUrl, "v1/remote/login/poll"), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + flowId: pending.flowId, + pendingCompletionSecret: pending.pendingCompletionSecret, + }), + }); + if (!poll.ok) throw new CapletsError("AUTH_FAILED", "Remote Login pending poll failed."); + const status = await parsePendingRemoteLoginStatus(poll); + if (status === "approved") break; + if (status !== "pending") { + throw new CapletsError("AUTH_FAILED", `Remote Login pending flow ${status}.`); + } + await sleep(intervalMs); + } + + const complete = await fetchImpl(appendBasePath(baseUrl, "v1/remote/login/complete"), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + flowId: pending.flowId, + pendingCompletionSecret: pending.pendingCompletionSecret, + }), + }); + if (!complete.ok) throw new CapletsError("AUTH_FAILED", "Remote Login pending complete failed."); + return parseRemoteLoginCredentials(complete); +} + async function revokeSelfHostedRemoteClient( remoteUrl: string, accessToken: string, @@ -1128,25 +1252,37 @@ export function createProgram(io: CliIO = {}): Command { return; } - const code = await pairingCodeFromOptions(options, io.readStdin, writeErr); - const exchangeUrl = appendBasePath( - new URL(normalizeRemoteProfileHostUrl(url)), - "v1/remote/pairing/exchange", - ); - const response = await (io.fetch ?? fetch)(exchangeUrl, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - code, - ...(options.clientLabel ? { clientLabel: options.clientLabel } : {}), - }), - }); - if (!response.ok) { - throw new CapletsError("AUTH_FAILED", "Remote Login pairing exchange failed."); - } - const credentials = await parseRemoteLoginCredentials(response); + const credentials = + options.code?.trim() || options.codeStdin + ? await (async () => { + const code = await pairingCodeFromOptions(options, io.readStdin, writeErr); + const exchangeUrl = appendBasePath( + new URL(normalizeRemoteProfileHostUrl(url)), + "v1/remote/pairing/exchange", + ); + const response = await (io.fetch ?? fetch)(exchangeUrl, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + code, + ...(options.clientLabel ? { clientLabel: options.clientLabel } : {}), + }), + }); + if (!response.ok) { + throw new CapletsError("AUTH_FAILED", "Remote Login pairing exchange failed."); + } + return parseRemoteLoginCredentials(response); + })() + : await selfHostedPendingRemoteLogin(url, { + ...(options.clientLabel ? { clientLabel: options.clientLabel } : {}), + json: options.json, + ...(io.fetch ? { fetch: io.fetch } : {}), + writeOut, + env, + }); const status = await store.saveSelfHostedProfile({ hostUrl: url, + hostIdentity: normalizeRemoteProfileHostUrl(url), clientId: credentials.clientId, clientLabel: credentials.clientLabel, credentials: { diff --git a/packages/core/test/remote-login-cli.test.ts b/packages/core/test/remote-login-cli.test.ts index 6f8ab25a..ce1bd3c9 100644 --- a/packages/core/test/remote-login-cli.test.ts +++ b/packages/core/test/remote-login-cli.test.ts @@ -80,6 +80,82 @@ describe("caplets remote CLI", () => { expect(statusOut.join("")).not.toContain(issued.code); }); + it("logs into a self-hosted remote through pending login approval", async () => { + const authDir = tempDir("caplets-remote-cli-auth-"); + const server = new RemoteServerCredentialStore({ dir: tempDir("caplets-remote-cli-server-") }); + const requests: string[] = []; + const out: string[] = []; + let pending: ReturnType | undefined; + + await runCli( + [ + "remote", + "login", + "https://caplets.example.com/caplets", + "--client-label", + "Test Device", + "--json", + ], + { + authDir, + fetch: async (input, init) => { + const url = new URL(String(input)); + requests.push(url.pathname); + const body = init?.body ? (JSON.parse(String(init.body)) as Record) : {}; + if (url.pathname.endsWith("/v1/remote/login/start")) { + pending = server.createPendingLogin({ + hostUrl: "https://caplets.example.com/caplets", + clientLabel: body.clientLabel, + }); + return Response.json(pending); + } + if (url.pathname.endsWith("/v1/remote/login/poll")) { + if (!pending) throw new Error("missing pending flow"); + const flowId = body.flowId; + const pendingCompletionSecret = body.pendingCompletionSecret; + if (!flowId || !pendingCompletionSecret) throw new Error("missing pending poll body"); + server.approvePendingLogin({ operatorCode: pending.operatorCode }); + return Response.json( + server.pollPendingLogin({ + flowId, + pendingCompletionSecret, + }), + ); + } + if (url.pathname.endsWith("/v1/remote/login/complete")) { + const flowId = body.flowId; + const pendingCompletionSecret = body.pendingCompletionSecret; + if (!flowId || !pendingCompletionSecret) + throw new Error("missing pending complete body"); + return Response.json( + server.completePendingLogin({ + hostUrl: "https://caplets.example.com/caplets", + flowId, + pendingCompletionSecret, + }), + ); + } + throw new Error(`unexpected request ${url.pathname}`); + }, + writeOut: (value) => out.push(value), + }, + ); + + expect(requests).toEqual([ + "/caplets/v1/remote/login/start", + "/caplets/v1/remote/login/poll", + "/caplets/v1/remote/login/complete", + ]); + expect(JSON.parse(out.at(-1) ?? "{}")).toMatchObject({ + authenticated: true, + kind: "self-hosted", + hostUrl: "https://caplets.example.com/caplets", + clientLabel: "Test Device", + }); + expect(out.join("")).not.toContain(pending?.pendingRefreshSecret ?? "missing"); + expect(out.join("")).not.toContain(pending?.pendingCompletionSecret ?? "missing"); + }); + it("logs out a stored self-hosted remote profile", async () => { const authDir = tempDir("caplets-remote-cli-auth-"); const server = new RemoteServerCredentialStore({ dir: tempDir("caplets-remote-cli-server-") }); From 8e8a2a6bdd94cc2a947e66e45466a17746a5c40e Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Mon, 22 Jun 2026 15:49:58 -0400 Subject: [PATCH 06/25] feat(core): refine pending remote login client states --- packages/core/src/cli.ts | 148 ++++++------ packages/core/test/cli.test.ts | 2 +- packages/core/test/remote-login-cli.test.ts | 238 ++++++++++++++------ 3 files changed, 245 insertions(+), 143 deletions(-) diff --git a/packages/core/src/cli.ts b/packages/core/src/cli.ts index 50fdad02..3d92953b 100644 --- a/packages/core/src/cli.ts +++ b/packages/core/src/cli.ts @@ -381,41 +381,6 @@ async function loginCloudRemoteProfile( }); } -async function pairingCodeFromOptions( - options: { code?: string; codeStdin?: boolean }, - readStdin: (() => Promise) | undefined, - writeErr: (value: string) => void, -): Promise { - if (options.code?.trim()) { - writeErr( - "Warning: --code may store the Pairing Code in shell history; prefer the hidden prompt or --code-stdin for automation.\n", - ); - return options.code.trim(); - } - if (options.codeStdin) { - const value = readStdin ? await readStdin() : await readAllStdin(); - const code = value.trim(); - if (code) return code; - throw new CapletsError( - "REQUEST_INVALID", - "Pairing Code is required when --code-stdin is used.", - ); - } - const output = new HiddenPromptOutput(process.stdout); - const readline = createInterface({ input: process.stdin, output, terminal: true }); - try { - const code = (await readline.question("Pairing Code: ")).trim(); - if (code) return code; - } finally { - readline.close(); - process.stdout.write("\n"); - } - throw new CapletsError( - "REQUEST_INVALID", - "Pairing Code is required for self-hosted Remote Login.", - ); -} - class HiddenPromptOutput extends Writable { private wrotePrompt = false; @@ -493,6 +458,7 @@ async function parseRemoteLoginCredentials( async function parsePendingRemoteLoginStart( response: Response, + options: { pendingCompletionSecret?: string | undefined } = {}, ): Promise { const parsed = await response.json(); if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { @@ -506,7 +472,6 @@ async function parsePendingRemoteLoginStart( typeof record.flowId !== "string" || typeof record.operatorCode !== "string" || typeof record.pendingRefreshSecret !== "string" || - typeof record.pendingCompletionSecret !== "string" || typeof record.codeExpiresAt !== "string" || typeof record.flowExpiresAt !== "string" ) { @@ -515,11 +480,21 @@ async function parsePendingRemoteLoginStart( "Pending Remote Login response is missing pending material.", ); } + const pendingCompletionSecret = + typeof record.pendingCompletionSecret === "string" + ? record.pendingCompletionSecret + : options.pendingCompletionSecret; + if (!pendingCompletionSecret) { + throw new CapletsError( + "DOWNSTREAM_PROTOCOL_ERROR", + "Pending Remote Login response is missing completion material.", + ); + } return { flowId: record.flowId, operatorCode: record.operatorCode, pendingRefreshSecret: record.pendingRefreshSecret, - pendingCompletionSecret: record.pendingCompletionSecret, + pendingCompletionSecret, codeExpiresAt: record.codeExpiresAt, flowExpiresAt: record.flowExpiresAt, intervalSeconds: typeof record.intervalSeconds === "number" ? record.intervalSeconds : 5, @@ -563,8 +538,18 @@ async function selfHostedPendingRemoteLogin( body: JSON.stringify(startBody), }); if (!start.ok) throw new CapletsError("AUTH_FAILED", "Remote Login pending start failed."); - const pending = await parsePendingRemoteLoginStart(start); - if (!input.json) { + let pending = await parsePendingRemoteLoginStart(start); + if (input.json) { + input.writeOut( + `${JSON.stringify({ + code: "pending_login_started", + flowId: pending.flowId, + operatorCode: pending.operatorCode, + codeExpiresAt: pending.codeExpiresAt, + flowExpiresAt: pending.flowExpiresAt, + })}\n`, + ); + } else { input.writeOut(`Remote Login Code: ${pending.operatorCode}\n`); input.writeOut( `Approve from the host with caplets remote host approve ${pending.operatorCode}\n`, @@ -576,6 +561,35 @@ async function selfHostedPendingRemoteLogin( pending.intervalSeconds * 1_000, ); while (true) { + if (Date.parse(pending.codeExpiresAt) <= Date.now()) { + const refresh = await fetchImpl(appendBasePath(baseUrl, "v1/remote/login/refresh"), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + flowId: pending.flowId, + pendingRefreshSecret: pending.pendingRefreshSecret, + pendingCompletionSecret: pending.pendingCompletionSecret, + }), + }); + if (!refresh.ok) + throw new CapletsError("AUTH_FAILED", "Remote Login pending refresh failed."); + pending = await parsePendingRemoteLoginStart(refresh, { + pendingCompletionSecret: pending.pendingCompletionSecret, + }); + if (input.json) { + input.writeOut( + `${JSON.stringify({ + code: "pending_login_code_refreshed", + flowId: pending.flowId, + operatorCode: pending.operatorCode, + codeExpiresAt: pending.codeExpiresAt, + flowExpiresAt: pending.flowExpiresAt, + })}\n`, + ); + } else { + input.writeOut(`Remote Login Code refreshed: ${pending.operatorCode}\n`); + } + } const poll = await fetchImpl(appendBasePath(baseUrl, "v1/remote/login/poll"), { method: "POST", headers: { "content-type": "application/json" }, @@ -586,7 +600,14 @@ async function selfHostedPendingRemoteLogin( }); if (!poll.ok) throw new CapletsError("AUTH_FAILED", "Remote Login pending poll failed."); const status = await parsePendingRemoteLoginStatus(poll); - if (status === "approved") break; + if (status === "approved") { + if (input.json) { + input.writeOut( + `${JSON.stringify({ code: "pending_login_approved", flowId: pending.flowId })}\n`, + ); + } + break; + } if (status !== "pending") { throw new CapletsError("AUTH_FAILED", `Remote Login pending flow ${status}.`); } @@ -1252,34 +1273,19 @@ export function createProgram(io: CliIO = {}): Command { return; } - const credentials = - options.code?.trim() || options.codeStdin - ? await (async () => { - const code = await pairingCodeFromOptions(options, io.readStdin, writeErr); - const exchangeUrl = appendBasePath( - new URL(normalizeRemoteProfileHostUrl(url)), - "v1/remote/pairing/exchange", - ); - const response = await (io.fetch ?? fetch)(exchangeUrl, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - code, - ...(options.clientLabel ? { clientLabel: options.clientLabel } : {}), - }), - }); - if (!response.ok) { - throw new CapletsError("AUTH_FAILED", "Remote Login pairing exchange failed."); - } - return parseRemoteLoginCredentials(response); - })() - : await selfHostedPendingRemoteLogin(url, { - ...(options.clientLabel ? { clientLabel: options.clientLabel } : {}), - json: options.json, - ...(io.fetch ? { fetch: io.fetch } : {}), - writeOut, - env, - }); + if (options.code?.trim() || options.codeStdin) { + throw new CapletsError( + "REQUEST_INVALID", + `Self-hosted Remote Login no longer accepts Pairing Codes. Run caplets remote login ${normalizeRemoteProfileHostUrl(url)} without --code and approve the pending login from the host.`, + ); + } + const credentials = await selfHostedPendingRemoteLogin(url, { + ...(options.clientLabel ? { clientLabel: options.clientLabel } : {}), + json: options.json, + ...(io.fetch ? { fetch: io.fetch } : {}), + writeOut, + env, + }); const status = await store.saveSelfHostedProfile({ hostUrl: url, hostIdentity: normalizeRemoteProfileHostUrl(url), @@ -1292,7 +1298,11 @@ export function createProgram(io: CliIO = {}): Command { expiresAt: credentials.expiresAt, }, }); - writeRemoteStatus(status, options.json === true, writeOut); + if (options.json === true) { + writeOut(`${JSON.stringify({ code: "remote_profile_saved", ...status })}\n`); + } else { + writeRemoteStatus(status, false, writeOut); + } }, ); diff --git a/packages/core/test/cli.test.ts b/packages/core/test/cli.test.ts index 7fc634d3..70c34e8b 100644 --- a/packages/core/test/cli.test.ts +++ b/packages/core/test/cli.test.ts @@ -419,7 +419,7 @@ describe("cli init", () => { }), ).rejects.toMatchObject({ code: "REQUEST_INVALID", - message: "Pairing Code is required when --code-stdin is used.", + message: expect.stringContaining("Self-hosted Remote Login no longer accepts Pairing Codes"), } satisfies Partial); expect(fetchStub).not.toHaveBeenCalled(); }); diff --git a/packages/core/test/remote-login-cli.test.ts b/packages/core/test/remote-login-cli.test.ts index ce1bd3c9..b025a758 100644 --- a/packages/core/test/remote-login-cli.test.ts +++ b/packages/core/test/remote-login-cli.test.ts @@ -13,71 +13,30 @@ afterEach(() => { }); describe("caplets remote CLI", () => { - it("logs into a self-hosted remote with a Pairing Code without storing the copied code", async () => { - const authDir = tempDir("caplets-remote-cli-auth-"); - const serverStateDir = tempDir("caplets-remote-cli-server-"); - const server = new RemoteServerCredentialStore({ dir: serverStateDir }); - const issued = server.createPairingCode({ hostUrl: "https://caplets.example.com/caplets" }); - const requests: Array<{ url: string; body: unknown }> = []; - const out: string[] = []; - const err: string[] = []; + it("rejects legacy Pairing Code argv login with migration guidance", async () => { + const issued = new RemoteServerCredentialStore({ + dir: tempDir("caplets-remote-cli-server-"), + }).createPairingCode({ hostUrl: "https://caplets.example.com/caplets" }); + const requests: string[] = []; - await runCli( - [ - "remote", - "login", - "https://caplets.example.com/caplets", - "--code", - issued.code, - "--client-label", - "Test Device", - "--json", - ], - { - authDir, - fetch: async (input, init) => { - const body = JSON.parse(String(init?.body)) as { code: string }; - requests.push({ url: String(input), body }); - const credentials = server.exchangePairingCode({ - hostUrl: "https://caplets.example.com/caplets", - code: body.code, - clientLabel: "Test Device", - }); - return Response.json(credentials); + await expect( + runCli( + ["remote", "login", "https://caplets.example.com/caplets", "--code", issued.code, "--json"], + { + fetch: async (input) => { + requests.push(String(input)); + return Response.json({}); + }, + writeErr: () => undefined, }, - writeOut: (value) => out.push(value), - writeErr: (value) => err.push(value), - }, - ); - - expect(requests).toEqual([ - { - url: "https://caplets.example.com/caplets/v1/remote/pairing/exchange", - body: { code: issued.code, clientLabel: "Test Device" }, - }, - ]); - expect(JSON.parse(out.join(""))).toMatchObject({ - authenticated: true, - kind: "self-hosted", - hostUrl: "https://caplets.example.com/caplets", - clientId: expect.any(String), - clientLabel: "Test Device", - }); - expect(out.join("")).not.toContain(issued.code); - expect(err.join("")).toContain("--code may store the Pairing Code in shell history"); - expect(err.join("")).not.toContain(issued.code); - - const statusOut: string[] = []; - await runCli(["remote", "status", "https://caplets.example.com/caplets", "--json"], { - authDir, - writeOut: (value) => statusOut.push(value), - }); - expect(JSON.parse(statusOut.join(""))).toMatchObject({ - authenticated: true, - kind: "self-hosted", - clientLabel: "Test Device", + ), + ).rejects.toMatchObject({ + code: "REQUEST_INVALID", + message: expect.stringContaining( + "Run caplets remote login https://caplets.example.com/caplets without --code", + ), }); - expect(statusOut.join("")).not.toContain(issued.code); + expect(requests).toEqual([]); }); it("logs into a self-hosted remote through pending login approval", async () => { @@ -156,23 +115,156 @@ describe("caplets remote CLI", () => { expect(out.join("")).not.toContain(pending?.pendingCompletionSecret ?? "missing"); }); + it("emits stable JSON events for pending remote login", async () => { + const authDir = tempDir("caplets-remote-cli-auth-"); + const server = new RemoteServerCredentialStore({ dir: tempDir("caplets-remote-cli-server-") }); + const out: string[] = []; + let pending: ReturnType | undefined; + + await runCli(["remote", "login", "https://caplets.example.com/caplets", "--json"], { + authDir, + fetch: async (input, init) => { + const url = new URL(String(input)); + const body = init?.body ? (JSON.parse(String(init.body)) as Record) : {}; + if (url.pathname.endsWith("/v1/remote/login/start")) { + pending = server.createPendingLogin({ hostUrl: "https://caplets.example.com/caplets" }); + return Response.json(pending); + } + if (url.pathname.endsWith("/v1/remote/login/poll")) { + if (!pending) throw new Error("missing pending flow"); + const flowId = body.flowId; + const pendingCompletionSecret = body.pendingCompletionSecret; + if (!flowId || !pendingCompletionSecret) throw new Error("missing pending poll body"); + server.approvePendingLogin({ operatorCode: pending.operatorCode }); + return Response.json(server.pollPendingLogin({ flowId, pendingCompletionSecret })); + } + if (url.pathname.endsWith("/v1/remote/login/complete")) { + const flowId = body.flowId; + const pendingCompletionSecret = body.pendingCompletionSecret; + if (!flowId || !pendingCompletionSecret) throw new Error("missing pending complete body"); + return Response.json( + server.completePendingLogin({ + hostUrl: "https://caplets.example.com/caplets", + flowId, + pendingCompletionSecret, + }), + ); + } + throw new Error(`unexpected request ${url.pathname}`); + }, + writeOut: (value) => out.push(value), + }); + + const events = out + .join("") + .trim() + .split("\n") + .map((line) => JSON.parse(line) as { code: string }); + expect(events.map((event) => event.code)).toEqual([ + "pending_login_started", + "pending_login_approved", + "remote_profile_saved", + ]); + expect(out.join("")).not.toContain(pending?.pendingRefreshSecret ?? "missing"); + expect(out.join("")).not.toContain(pending?.pendingCompletionSecret ?? "missing"); + }); + + it("refreshes the visible pending login code during delayed approval", async () => { + const authDir = tempDir("caplets-remote-cli-auth-"); + const server = new RemoteServerCredentialStore({ dir: tempDir("caplets-remote-cli-server-") }); + const requests: string[] = []; + const out: string[] = []; + let pending: ReturnType | undefined; + let pollCount = 0; + + await runCli(["remote", "login", "https://caplets.example.com/caplets", "--json"], { + authDir, + env: { CAPLETS_REMOTE_LOGIN_POLL_INTERVAL_MS: "0" }, + fetch: async (input, init) => { + const url = new URL(String(input)); + requests.push(url.pathname); + const body = init?.body ? (JSON.parse(String(init.body)) as Record) : {}; + if (url.pathname.endsWith("/v1/remote/login/start")) { + pending = server.createPendingLogin({ hostUrl: "https://caplets.example.com/caplets" }); + return Response.json({ + ...pending, + codeExpiresAt: new Date(Date.now() - 1).toISOString(), + }); + } + if (url.pathname.endsWith("/v1/remote/login/refresh")) { + if (!pending) throw new Error("missing pending flow"); + const flowId = body.flowId; + const pendingRefreshSecret = body.pendingRefreshSecret; + const pendingCompletionSecret = body.pendingCompletionSecret; + if (!flowId || !pendingRefreshSecret || !pendingCompletionSecret) + throw new Error("missing pending refresh body"); + const refreshed = server.refreshPendingLogin({ + flowId, + pendingRefreshSecret, + pendingCompletionSecret, + }); + pending = { ...refreshed, pendingCompletionSecret }; + return Response.json(pending); + } + if (url.pathname.endsWith("/v1/remote/login/poll")) { + if (!pending) throw new Error("missing pending flow"); + const flowId = body.flowId; + const pendingCompletionSecret = body.pendingCompletionSecret; + if (!flowId || !pendingCompletionSecret) throw new Error("missing pending poll body"); + pollCount += 1; + if (pollCount > 1) server.approvePendingLogin({ operatorCode: pending.operatorCode }); + return Response.json(server.pollPendingLogin({ flowId, pendingCompletionSecret })); + } + if (url.pathname.endsWith("/v1/remote/login/complete")) { + if (!pending) throw new Error("missing pending flow"); + const flowId = body.flowId; + const pendingCompletionSecret = body.pendingCompletionSecret; + if (!flowId || !pendingCompletionSecret) throw new Error("missing pending complete body"); + return Response.json( + server.completePendingLogin({ + hostUrl: "https://caplets.example.com/caplets", + flowId, + pendingCompletionSecret, + }), + ); + } + throw new Error(`unexpected request ${url.pathname}`); + }, + writeOut: (value) => out.push(value), + }); + + expect(requests).toContain("/caplets/v1/remote/login/refresh"); + const events = out + .join("") + .trim() + .split("\n") + .map((line) => JSON.parse(line) as { code: string }); + expect(events.map((event) => event.code)).toContain("pending_login_code_refreshed"); + expect(out.join("")).not.toContain(pending?.pendingRefreshSecret ?? "missing"); + expect(out.join("")).not.toContain(pending?.pendingCompletionSecret ?? "missing"); + }); + it("logs out a stored self-hosted remote profile", async () => { const authDir = tempDir("caplets-remote-cli-auth-"); const server = new RemoteServerCredentialStore({ dir: tempDir("caplets-remote-cli-server-") }); const issued = server.createPairingCode({ hostUrl: "https://caplets.example.com" }); + const credentials = server.exchangePairingCode({ + hostUrl: "https://caplets.example.com/", + code: issued.code, + }); let accessToken = ""; - - await runCli(["remote", "login", "https://caplets.example.com", "--code", issued.code], { - authDir, - fetch: async (_input, init) => { - const credentials = server.exchangePairingCode({ - hostUrl: "https://caplets.example.com/", - code: String(JSON.parse(String(init?.body)).code), - }); - accessToken = credentials.accessToken; - return Response.json(credentials); + accessToken = credentials.accessToken; + await new FileRemoteProfileStore({ + root: join(authDir, "remote-profiles"), + }).saveSelfHostedProfile({ + hostUrl: "https://caplets.example.com", + clientId: credentials.clientId, + clientLabel: credentials.clientLabel, + credentials: { + accessToken: credentials.accessToken, + refreshToken: credentials.refreshToken, + expiresAt: credentials.expiresAt, }, - writeOut: () => undefined, }); const out: string[] = []; From afe1cf799ce07ce82d197a1f088d1ae0fca8cfe0 Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Mon, 22 Jun 2026 15:52:24 -0400 Subject: [PATCH 07/25] feat(core): emit pending login terminal events --- packages/core/src/cli.ts | 5 +++ packages/core/test/remote-login-cli.test.ts | 43 +++++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/packages/core/src/cli.ts b/packages/core/src/cli.ts index 3d92953b..e7d67ec1 100644 --- a/packages/core/src/cli.ts +++ b/packages/core/src/cli.ts @@ -609,6 +609,11 @@ async function selfHostedPendingRemoteLogin( break; } if (status !== "pending") { + if (input.json) { + input.writeOut( + `${JSON.stringify({ code: `pending_login_${status}`, flowId: pending.flowId })}\n`, + ); + } throw new CapletsError("AUTH_FAILED", `Remote Login pending flow ${status}.`); } await sleep(intervalMs); diff --git a/packages/core/test/remote-login-cli.test.ts b/packages/core/test/remote-login-cli.test.ts index b025a758..d773c583 100644 --- a/packages/core/test/remote-login-cli.test.ts +++ b/packages/core/test/remote-login-cli.test.ts @@ -244,6 +244,49 @@ describe("caplets remote CLI", () => { expect(out.join("")).not.toContain(pending?.pendingCompletionSecret ?? "missing"); }); + it("emits a stable JSON event when pending remote login is denied", async () => { + const authDir = tempDir("caplets-remote-cli-auth-"); + const server = new RemoteServerCredentialStore({ dir: tempDir("caplets-remote-cli-server-") }); + const out: string[] = []; + let pending: ReturnType | undefined; + + await expect( + runCli(["remote", "login", "https://caplets.example.com/caplets", "--json"], { + authDir, + fetch: async (input, init) => { + const url = new URL(String(input)); + const body = init?.body ? (JSON.parse(String(init.body)) as Record) : {}; + if (url.pathname.endsWith("/v1/remote/login/start")) { + pending = server.createPendingLogin({ hostUrl: "https://caplets.example.com/caplets" }); + server.denyPendingLogin({ operatorCode: pending.operatorCode }); + return Response.json(pending); + } + if (url.pathname.endsWith("/v1/remote/login/poll")) { + if (!pending) throw new Error("missing pending flow"); + const flowId = body.flowId; + const pendingCompletionSecret = body.pendingCompletionSecret; + if (!flowId || !pendingCompletionSecret) throw new Error("missing pending poll body"); + return Response.json(server.pollPendingLogin({ flowId, pendingCompletionSecret })); + } + throw new Error(`unexpected request ${url.pathname}`); + }, + writeOut: (value) => out.push(value), + }), + ).rejects.toMatchObject({ code: "AUTH_FAILED" }); + + const events = out + .join("") + .trim() + .split("\n") + .map((line) => JSON.parse(line) as { code: string }); + expect(events.map((event) => event.code)).toEqual([ + "pending_login_started", + "pending_login_denied", + ]); + expect(out.join("")).not.toContain(pending?.pendingRefreshSecret ?? "missing"); + expect(out.join("")).not.toContain(pending?.pendingCompletionSecret ?? "missing"); + }); + it("logs out a stored self-hosted remote profile", async () => { const authDir = tempDir("caplets-remote-cli-auth-"); const server = new RemoteServerCredentialStore({ dir: tempDir("caplets-remote-cli-server-") }); From 30b3b750c4a9c4481bcd1731244e90822198eb51 Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Mon, 22 Jun 2026 15:59:01 -0400 Subject: [PATCH 08/25] feat(core): cancel pending remote login on abort --- packages/core/src/cli.ts | 18 ++++++++ packages/core/test/remote-login-cli.test.ts | 51 +++++++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/packages/core/src/cli.ts b/packages/core/src/cli.ts index e7d67ec1..d27bfaa7 100644 --- a/packages/core/src/cli.ts +++ b/packages/core/src/cli.ts @@ -525,6 +525,7 @@ async function selfHostedPendingRemoteLogin( clientLabel?: string | undefined; json?: boolean | undefined; fetch?: typeof fetch | undefined; + signal?: AbortSignal | undefined; writeOut: (value: string) => void; env: NodeJS.ProcessEnv | Record; }, @@ -561,6 +562,22 @@ async function selfHostedPendingRemoteLogin( pending.intervalSeconds * 1_000, ); while (true) { + if (input.signal?.aborted) { + await fetchImpl(appendBasePath(baseUrl, "v1/remote/login/cancel"), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + flowId: pending.flowId, + pendingCompletionSecret: pending.pendingCompletionSecret, + }), + }).catch(() => undefined); + if (input.json) { + input.writeOut( + `${JSON.stringify({ code: "pending_login_cancelled", flowId: pending.flowId })}\n`, + ); + } + throw new CapletsError("REQUEST_INVALID", "Remote Login pending flow cancelled."); + } if (Date.parse(pending.codeExpiresAt) <= Date.now()) { const refresh = await fetchImpl(appendBasePath(baseUrl, "v1/remote/login/refresh"), { method: "POST", @@ -1288,6 +1305,7 @@ export function createProgram(io: CliIO = {}): Command { ...(options.clientLabel ? { clientLabel: options.clientLabel } : {}), json: options.json, ...(io.fetch ? { fetch: io.fetch } : {}), + ...(io.signal ? { signal: io.signal } : {}), writeOut, env, }); diff --git a/packages/core/test/remote-login-cli.test.ts b/packages/core/test/remote-login-cli.test.ts index d773c583..6bbfe121 100644 --- a/packages/core/test/remote-login-cli.test.ts +++ b/packages/core/test/remote-login-cli.test.ts @@ -287,6 +287,57 @@ describe("caplets remote CLI", () => { expect(out.join("")).not.toContain(pending?.pendingCompletionSecret ?? "missing"); }); + it("cancels pending remote login when the CLI signal is aborted", async () => { + const authDir = tempDir("caplets-remote-cli-auth-"); + const server = new RemoteServerCredentialStore({ dir: tempDir("caplets-remote-cli-server-") }); + const controller = new AbortController(); + const requests: string[] = []; + const out: string[] = []; + let pending: ReturnType | undefined; + + await expect( + runCli(["remote", "login", "https://caplets.example.com/caplets", "--json"], { + authDir, + signal: controller.signal, + fetch: async (input, init) => { + const url = new URL(String(input)); + requests.push(url.pathname); + const body = init?.body ? (JSON.parse(String(init.body)) as Record) : {}; + if (url.pathname.endsWith("/v1/remote/login/start")) { + pending = server.createPendingLogin({ hostUrl: "https://caplets.example.com/caplets" }); + controller.abort(); + return Response.json(pending); + } + if (url.pathname.endsWith("/v1/remote/login/cancel")) { + if (!pending) throw new Error("missing pending flow"); + const flowId = body.flowId; + const pendingCompletionSecret = body.pendingCompletionSecret; + if (!flowId || !pendingCompletionSecret) throw new Error("missing pending cancel body"); + return Response.json(server.cancelPendingLogin({ flowId, pendingCompletionSecret })); + } + throw new Error(`unexpected request ${url.pathname}`); + }, + writeOut: (value) => out.push(value), + }), + ).rejects.toMatchObject({ + code: "REQUEST_INVALID", + message: "Remote Login pending flow cancelled.", + }); + + expect(requests).toEqual(["/caplets/v1/remote/login/start", "/caplets/v1/remote/login/cancel"]); + const events = out + .join("") + .trim() + .split("\n") + .map((line) => JSON.parse(line) as { code: string }); + expect(events.map((event) => event.code)).toEqual([ + "pending_login_started", + "pending_login_cancelled", + ]); + expect(out.join("")).not.toContain(pending?.pendingRefreshSecret ?? "missing"); + expect(out.join("")).not.toContain(pending?.pendingCompletionSecret ?? "missing"); + }); + it("logs out a stored self-hosted remote profile", async () => { const authDir = tempDir("caplets-remote-cli-auth-"); const server = new RemoteServerCredentialStore({ dir: tempDir("caplets-remote-cli-server-") }); From 5f008ccfc59d7e9a08bdad08e033d400b10dccf5 Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Mon, 22 Jun 2026 16:57:47 -0400 Subject: [PATCH 09/25] feat(core): distinguish revoked remote credentials --- packages/core/src/project-binding/errors.ts | 2 + packages/core/src/remote/selection.ts | 19 ++++++++- packages/core/test/attach-cli.test.ts | 43 +++++++++++++++++++++ packages/core/test/remote-selection.test.ts | 38 ++++++++++++++++++ 4 files changed, 101 insertions(+), 1 deletion(-) diff --git a/packages/core/src/project-binding/errors.ts b/packages/core/src/project-binding/errors.ts index 09d01147..d4e59763 100644 --- a/packages/core/src/project-binding/errors.ts +++ b/packages/core/src/project-binding/errors.ts @@ -21,6 +21,7 @@ export const PROJECT_BINDING_ERROR_CODES = [ "subscription_past_due", "email_verification_required", "remote_credentials_required", + "remote_credentials_revoked", "remote_auth_failed", ] as const; @@ -77,6 +78,7 @@ function recoveryCommandFor(code: ProjectBindingErrorCode): string | undefined { case "sync_size_limit_exceeded": return "Add exclusions to .capletsignore or upgrade the workspace plan."; case "remote_credentials_required": + case "remote_credentials_revoked": case "remote_auth_failed": return "caplets remote login "; case "endpoint_unavailable": diff --git a/packages/core/src/remote/selection.ts b/packages/core/src/remote/selection.ts index 52e44edc..32aee620 100644 --- a/packages/core/src/remote/selection.ts +++ b/packages/core/src/remote/selection.ts @@ -285,7 +285,9 @@ async function refreshSelfHostedCredentials( async function selfHostedRefreshError(remoteUrl: string, response: Response): Promise { const summary = await parseSelfHostedRefreshError(response); if (response.status === 401 || summary?.code === "AUTH_FAILED") { - return remoteLoginRequired(remoteUrl); + return selfHostedRefreshLooksRevoked(summary) + ? remoteLoginRevoked(remoteUrl) + : remoteLoginRequired(remoteUrl); } if (response.status === 503 || summary?.code === "SERVER_UNAVAILABLE") { return new CapletsError( @@ -353,6 +355,21 @@ function remoteLoginRequired(remoteUrl: string): ProjectBindingError { }); } +function remoteLoginRevoked(remoteUrl: string): ProjectBindingError { + const normalizedUrl = normalizeRemoteProfileHostUrl(remoteUrl); + return new ProjectBindingError({ + code: "remote_credentials_revoked", + message: `Remote credentials for ${normalizedUrl} were revoked or rejected. Run Remote Login again and ask the server operator to approve the pending login.`, + recoveryCommand: `caplets remote login ${normalizedUrl}`, + }); +} + +function selfHostedRefreshLooksRevoked( + summary: { code?: string | undefined; message?: string | undefined } | undefined, +): boolean { + return /revoked|rejected/iu.test(summary?.message ?? ""); +} + async function parseSelfHostedRefreshCredentials( response: Response, ): Promise { diff --git a/packages/core/test/attach-cli.test.ts b/packages/core/test/attach-cli.test.ts index 4a665dda..b35fe667 100644 --- a/packages/core/test/attach-cli.test.ts +++ b/packages/core/test/attach-cli.test.ts @@ -412,6 +412,49 @@ describe("caplets attach CLI", () => { }); }); + it("prints JSON recovery for revoked self-hosted credentials", async () => { + const authDir = tempAuthDir(); + const out: string[] = []; + let exitCode = 0; + await new FileRemoteProfileStore({ + root: join(authDir, "remote-profiles"), + }).saveSelfHostedProfile({ + hostUrl: "https://caplets.example.com/caplets", + clientId: "rcli_123", + clientLabel: "Test Device", + credentials: { + accessToken: "old-access", + refreshToken: "old-refresh", + expiresAt: "2026-06-19T00:00:00.000Z", + }, + }); + + await runCli(["attach", "--once", "--json"], { + authDir, + env: { + CAPLETS_MODE: "remote", + CAPLETS_REMOTE_URL: "https://caplets.example.com/caplets", + }, + fetch: async () => + Response.json( + { error: { code: "AUTH_FAILED", message: "Remote client credential has been revoked." } }, + { status: 401 }, + ), + writeOut: (value) => out.push(value), + setExitCode: (code) => { + exitCode = code; + }, + }); + + expect(exitCode).toBe(1); + expect(JSON.parse(out.join(""))).toMatchObject({ + error: { + code: "remote_credentials_revoked", + recoveryCommand: "caplets remote login https://caplets.example.com/caplets", + }, + }); + }); + it("rejects attach --workspace when it differs from the saved Selected Workspace", async () => { const path = tempCloudAuthPath(); const out: string[] = []; diff --git a/packages/core/test/remote-selection.test.ts b/packages/core/test/remote-selection.test.ts index 052847e1..09a570b8 100644 --- a/packages/core/test/remote-selection.test.ts +++ b/packages/core/test/remote-selection.test.ts @@ -157,6 +157,44 @@ describe("resolveRemoteSelection", () => { }); }); + it("reports revoked self-hosted credentials with relogin and operator approval guidance", async () => { + const authDir = tempDir("caplets-remote-selection-auth-"); + await new FileRemoteProfileStore({ + root: join(authDir, "remote-profiles"), + }).saveSelfHostedProfile({ + hostUrl: "https://caplets.example.com/caplets", + clientId: "rcli_123", + clientLabel: "Test Device", + credentials: { + accessToken: "old-access", + refreshToken: "old-refresh", + tokenType: "Bearer", + expiresAt: "2026-06-19T00:00:00.000Z", + }, + }); + + await expect( + resolveRemoteSelection( + { + authDir, + fetch: async () => + Response.json( + { error: { code: "AUTH_FAILED", message: "Remote client was revoked." } }, + { status: 401 }, + ), + }, + { + CAPLETS_MODE: "remote", + CAPLETS_REMOTE_URL: "https://caplets.example.com/caplets", + }, + ), + ).rejects.toMatchObject({ + projectBindingCode: "remote_credentials_revoked", + recoveryCommand: "caplets remote login https://caplets.example.com/caplets", + message: expect.stringContaining("server operator"), + }); + }); + it("preserves CAPLETS_REMOTE_WORKSPACE for self-hosted remotes", async () => { const authDir = tempDir("caplets-remote-selection-auth-"); await new FileRemoteProfileStore({ From 52c8bd812444d2c9708a9eaf05a6e5cf40788ccb Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Mon, 22 Jun 2026 17:03:03 -0400 Subject: [PATCH 10/25] feat(core): align cloud remote profile lifecycle --- packages/core/src/remote/selection.ts | 31 ++++++++++++- packages/core/test/remote-login-cli.test.ts | 50 +++++++++++++++++++++ packages/core/test/remote-selection.test.ts | 30 +++++++++++++ 3 files changed, 109 insertions(+), 2 deletions(-) diff --git a/packages/core/src/remote/selection.ts b/packages/core/src/remote/selection.ts index 32aee620..24fea390 100644 --- a/packages/core/src/remote/selection.ts +++ b/packages/core/src/remote/selection.ts @@ -132,12 +132,12 @@ export async function resolveRemoteSelection( input.workspace ?? (workspaceFromRemoteUrl ? undefined : env.CAPLETS_REMOTE_WORKSPACE); const profileWorkspace = workspaceFromRemoteUrl ?? explicitWorkspace; const normalizedRemoteUrl = normalizeRemoteProfileHostUrl(remoteUrl); - let status = await store.getCloudProfileStatus({ + let status = await getCloudProfileStatusForSelection(store, { hostUrl: normalizedRemoteUrl, workspace: profileWorkspace, }); if (!status && profileWorkspace) { - status = await store.getCloudProfileStatus({ + status = await getCloudProfileStatusForSelection(store, { hostUrl: normalizedRemoteUrl, }); } @@ -370,6 +370,33 @@ function selfHostedRefreshLooksRevoked( return /revoked|rejected/iu.test(summary?.message ?? ""); } +async function getCloudProfileStatusForSelection( + store: ReturnType, + input: { hostUrl: string; workspace?: string | undefined }, +): Promise< + Awaited["getCloudProfileStatus"]>> +> { + try { + return await store.getCloudProfileStatus(input); + } catch (error) { + if (isCloudWorkspaceAmbiguity(error)) { + throw projectBindingError( + "workspace_switch_required", + "Cloud Remote Profile requires a selected or explicit workspace.", + ); + } + throw error; + } +} + +function isCloudWorkspaceAmbiguity(error: unknown): boolean { + return ( + error instanceof CapletsError && + error.code === "REQUEST_INVALID" && + /Cloud Remote Profile requires a selected or explicit workspace/u.test(error.message) + ); +} + async function parseSelfHostedRefreshCredentials( response: Response, ): Promise { diff --git a/packages/core/test/remote-login-cli.test.ts b/packages/core/test/remote-login-cli.test.ts index 6bbfe121..6fa7d1ac 100644 --- a/packages/core/test/remote-login-cli.test.ts +++ b/packages/core/test/remote-login-cli.test.ts @@ -678,6 +678,56 @@ describe("caplets remote CLI", () => { expect(out.join("")).not.toContain("self-hosted-access"); expect(out.join("")).not.toContain("self-hosted-refresh"); }); + + it("lists multiple Cloud workspace profiles through remote status", async () => { + const authDir = tempDir("caplets-remote-cli-auth-"); + const store = new FileRemoteProfileStore({ root: join(authDir, "remote-profiles") }); + await store.saveCloudProfile({ + hostUrl: "https://cloud.caplets.dev", + workspaceId: "workspace_alpha", + workspaceSlug: "alpha", + credentials: { + accessToken: "alpha-access", + refreshToken: "alpha-refresh", + expiresAt: "2999-01-01T00:00:00.000Z", + }, + }); + await store.saveCloudProfile({ + hostUrl: "https://cloud.caplets.dev", + workspaceId: "workspace_beta", + workspaceSlug: "beta", + credentials: { + accessToken: "beta-access", + refreshToken: "beta-refresh", + expiresAt: "2999-01-01T00:00:00.000Z", + }, + }); + const out: string[] = []; + + await runCli(["remote", "status", "--json"], { + authDir, + writeOut: (value) => out.push(value), + }); + + expect(JSON.parse(out.join(""))).toMatchObject({ + profiles: [ + { + kind: "cloud", + hostUrl: "https://cloud.caplets.dev/", + workspaceSlug: "alpha", + selected: false, + }, + { + kind: "cloud", + hostUrl: "https://cloud.caplets.dev/", + workspaceSlug: "beta", + selected: true, + }, + ], + }); + expect(out.join("")).not.toContain("alpha-access"); + expect(out.join("")).not.toContain("beta-refresh"); + }); }); function tempDir(prefix: string): string { diff --git a/packages/core/test/remote-selection.test.ts b/packages/core/test/remote-selection.test.ts index 09a570b8..29b6d967 100644 --- a/packages/core/test/remote-selection.test.ts +++ b/packages/core/test/remote-selection.test.ts @@ -379,6 +379,36 @@ describe("resolveRemoteSelection", () => { }); }); + it("fails with workspace-specific recovery when a Cloud host has profiles but no selected workspace", async () => { + const authDir = tempDir("caplets-remote-selection-auth-"); + const store = new FileRemoteProfileStore({ root: join(authDir, "remote-profiles") }); + await store.saveCloudProfile({ + hostUrl: "https://cloud.caplets.dev", + workspaceId: "workspace_team", + workspaceSlug: "team", + credentials: { + accessToken: "cloud-access", + refreshToken: "cloud-refresh", + expiresAt: "2999-01-01T00:00:00.000Z", + scope: ["project_binding:read", "project_binding:write", "mcp:tools"], + }, + }); + await store.clearSelectedCloudWorkspace("https://cloud.caplets.dev"); + + await expect( + resolveRemoteSelection( + { authDir }, + { + CAPLETS_MODE: "cloud", + CAPLETS_REMOTE_URL: "https://cloud.caplets.dev", + }, + ), + ).rejects.toMatchObject({ + projectBindingCode: "workspace_switch_required", + recoveryCommand: "caplets remote login --workspace ", + }); + }); + it("derives Cloud MCP and Project Binding URLs from the selected workspace", async () => { const path = tempCloudAuthPath(); await new CloudAuthStore({ path }).save( From 6f17f80b0fc5e74cbef40c627c77705ed432c51d Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Mon, 22 Jun 2026 17:06:03 -0400 Subject: [PATCH 11/25] docs: update remote attach pending login flow --- apps/docs/src/content/docs/remote-attach.mdx | 25 +++++++++++------- apps/landing/src/data/landing.ts | 4 +-- packages/core/test/cli.test.ts | 27 ++++++++++++++++++++ 3 files changed, 45 insertions(+), 11 deletions(-) diff --git a/apps/docs/src/content/docs/remote-attach.mdx b/apps/docs/src/content/docs/remote-attach.mdx index c4658ed4..c13f4f71 100644 --- a/apps/docs/src/content/docs/remote-attach.mdx +++ b/apps/docs/src/content/docs/remote-attach.mdx @@ -42,25 +42,32 @@ Generic MCP JSON: ## Remote Login -For a self-hosted remote, first create a short-lived Pairing Code on the server: +For a self-hosted remote, start Remote Login from the client: ```sh -caplets remote host pair --host-url https://caplets.example.com/caplets +caplets remote login https://caplets.example.com/caplets ``` -If the server uses a non-default credential state directory, pass the same directory with -`--state-path` that the server uses for `caplets serve --remote-state-path`. +The command prints a short operator code and waits. On the host, a server-local +operator reviews the pending login metadata and approves the code from the same +server environment that owns the remote credential state: + +```sh +caplets remote host logins +caplets remote host approve +``` -Then log in once on the client and configure the agent to launch attach: +If the server uses a non-default credential state directory, pass the same directory +with `--state-path` that the server uses for `caplets serve --remote-state-path`. +The visible approval code is not an attach credential, but do not put Caplets access +or refresh credentials in shell history, agent configs, or environment variables. + +After approval, configure the agent to launch attach: ```sh -caplets remote login https://caplets.example.com/caplets caplets attach --remote-url https://caplets.example.com/caplets ``` -Enter the Pairing Code at the hidden prompt from `caplets remote login`. Use -`--code-stdin` for automation instead of putting Pairing Codes in shell history. - For Caplets Cloud native integrations, log in once and then set the Cloud URL selector: ```sh diff --git a/apps/landing/src/data/landing.ts b/apps/landing/src/data/landing.ts index e6d2553d..d919b9e4 100644 --- a/apps/landing/src/data/landing.ts +++ b/apps/landing/src/data/landing.ts @@ -92,8 +92,8 @@ export const whyCapletsProblems = [ export const remoteCommands = { server: `caplets daemon install --start -caplets remote host pair --host-url `, - client: `caplets remote login --code +caplets remote host approve `, + client: `caplets remote login caplets attach --remote-url `, } as const; diff --git a/packages/core/test/cli.test.ts b/packages/core/test/cli.test.ts index 70c34e8b..c2f5c14c 100644 --- a/packages/core/test/cli.test.ts +++ b/packages/core/test/cli.test.ts @@ -3303,6 +3303,33 @@ describe("cli setup", () => { ); }); + it("keeps supported remote setup docs on pending login and secret-free attach", () => { + const repoRoot = join(import.meta.dirname, "../../.."); + const supportedDocs = [ + "README.md", + "apps/docs/src/content/docs/remote-attach.mdx", + "apps/docs/src/content/docs/troubleshooting.mdx", + "apps/docs/src/content/docs/install.mdx", + "apps/docs/src/content/docs/agent-integrations.mdx", + "apps/docs/src/content/docs/vault.mdx", + "apps/landing/src/data/landing.ts", + "docs/project-binding.md", + "docs/native-integrations.md", + "packages/opencode/README.md", + "packages/pi/README.md", + ]; + + for (const relativePath of supportedDocs) { + const text = readFileSync(join(repoRoot, relativePath), "utf8"); + expect(text, relativePath).not.toMatch(/remote host pair/u); + expect(text, relativePath).not.toMatch(/remote login[^\n]*--code/u); + expect(text, relativePath).not.toMatch(/Pairing Code/u); + expect(text, relativePath).not.toMatch(/CAPLETS_REMOTE_(TOKEN|USER|PASSWORD)/u); + expect(text, relativePath).not.toMatch(/Basic Auth/u); + expect(text, relativePath).not.toMatch(/add-mcp --env/u); + } + }); + it("keeps --server-url as a remote setup alias", async () => { const out: string[] = []; const commands: Array<{ command: string; args: string[] }> = []; From fe4e57a7aa312c03f278083ca16f4335b2cac450 Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Mon, 22 Jun 2026 17:07:27 -0400 Subject: [PATCH 12/25] chore: add pending remote login changeset --- .changeset/self-hosted-pending-remote-login.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/self-hosted-pending-remote-login.md diff --git a/.changeset/self-hosted-pending-remote-login.md b/.changeset/self-hosted-pending-remote-login.md new file mode 100644 index 00000000..c95f33ba --- /dev/null +++ b/.changeset/self-hosted-pending-remote-login.md @@ -0,0 +1,6 @@ +--- +"@caplets/core": patch +"caplets": patch +--- + +Replace self-hosted Remote Login's operator-minted Pairing Code bootstrap with a client-started pending login flow. The client now starts `caplets remote login `, displays a short operator code, waits for server-local approval, rotates pre-login material while pending, and stores final Remote Profile credentials only after approval. Remote attach recovery now reports revoked self-hosted credentials and Cloud workspace ambiguity with stable recovery guidance, and public docs/examples show the pending-login approval sequence without remote secrets in agent configuration. From 1d3f07dae5446ef611284c30aaaf54fecb96b878 Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Mon, 22 Jun 2026 17:15:25 -0400 Subject: [PATCH 13/25] fix(core): bound pending remote login state --- .../src/remote/server-credential-store.ts | 82 +++++++++++++++++-- packages/core/test/remote-pairing.test.ts | 50 +++++++++++ 2 files changed, 126 insertions(+), 6 deletions(-) diff --git a/packages/core/src/remote/server-credential-store.ts b/packages/core/src/remote/server-credential-store.ts index 481f1388..da9ca97d 100644 --- a/packages/core/src/remote/server-credential-store.ts +++ b/packages/core/src/remote/server-credential-store.ts @@ -147,6 +147,9 @@ const DEFAULT_ACCESS_TOKEN_TTL_MS = 15 * 60_000; const DEFAULT_PENDING_OPERATOR_CODE_TTL_MS = 10 * 60_000; const DEFAULT_PENDING_FLOW_TTL_MS = 24 * 60 * 60_000; const DEFAULT_PENDING_POLL_INTERVAL_SECONDS = 5; +const DEFAULT_PENDING_MAX_ACTIVE_FLOWS = 64; +const DEFAULT_PENDING_MAX_ACTIVE_FLOWS_PER_SOURCE = 8; +const PENDING_TERMINAL_RETENTION_MS = 24 * 60 * 60_000; const STALE_REFRESH_REVOKE_GRACE_MS = 30_000; const SUPERSEDED_REFRESH_TOKEN_RETENTION_MS = 24 * 60 * 60_000; const STATE_FILE = "remote-server-credentials.json"; @@ -181,6 +184,8 @@ export class RemoteServerCredentialStore { ).toISOString(); const flowExpiresAt = new Date(now.getTime() + DEFAULT_PENDING_FLOW_TTL_MS).toISOString(); const state = this.loadState(); + cleanupPendingLogins(state, now); + enforcePendingLoginQuota(state, input.sourceHint); state.pendingLogins.push({ flowId, hostUrl: normalizeRemoteProfileHostUrl(input.hostUrl), @@ -230,6 +235,7 @@ export class RemoteServerCredentialStore { return this.withStateLock(() => { const now = input.now ?? new Date(); const state = this.loadState(); + cleanupPendingLogins(state, now); const flow = this.pendingLoginForCompletion( input.flowId, input.pendingCompletionSecret, @@ -286,8 +292,10 @@ export class RemoteServerCredentialStore { return this.withStateLock(() => { const now = input.now ?? new Date(); const state = this.loadState(); + cleanupPendingLogins(state, now); + const operatorCodeHash = hashSecret(input.operatorCode); const flow = state.pendingLogins.find((candidate) => - safeHashEqual(hashSecret(input.operatorCode), candidate.operatorCodeHash), + safeHashEqual(operatorCodeHash, candidate.operatorCodeHash), ); if (!flow) throw new CapletsError("AUTH_FAILED", "Pending login code is unknown."); if (flow.status !== "pending") { @@ -312,6 +320,7 @@ export class RemoteServerCredentialStore { return this.withStateLock(() => { const now = input.now ?? new Date(); const state = this.loadState(); + cleanupPendingLogins(state, now); const flow = this.pendingLoginForCompletion( input.flowId, input.pendingCompletionSecret, @@ -338,8 +347,10 @@ export class RemoteServerCredentialStore { return this.withStateLock(() => { const now = input.now ?? new Date(); const state = this.loadState(); + cleanupPendingLogins(state, now); + const operatorCodeHash = hashSecret(input.operatorCode); const flow = state.pendingLogins.find((candidate) => - safeHashEqual(hashSecret(input.operatorCode), candidate.operatorCodeHash), + safeHashEqual(operatorCodeHash, candidate.operatorCodeHash), ); if (!flow) throw new CapletsError("AUTH_FAILED", "Pending login code is unknown."); if (Date.parse(flow.flowExpiresAt) <= now.getTime()) { @@ -361,6 +372,7 @@ export class RemoteServerCredentialStore { return this.withStateLock(() => { const now = input.now ?? new Date(); const state = this.loadState(); + cleanupPendingLogins(state, now); const flow = this.pendingLoginForCompletion( input.flowId, input.pendingCompletionSecret, @@ -495,10 +507,14 @@ export class RemoteServerCredentialStore { .sort((left, right) => left.createdAt.localeCompare(right.createdAt)); } - listPendingLogins(): RemotePendingLoginStatus[] { - return this.loadState() - .pendingLogins.map(pendingLoginStatus) - .sort((left, right) => left.createdAt.localeCompare(right.createdAt)); + listPendingLogins(now = new Date()): RemotePendingLoginStatus[] { + return this.withStateLock(() => { + const state = this.loadState(); + if (cleanupPendingLogins(state, now)) this.saveState(state); + return state.pendingLogins + .map(pendingLoginStatus) + .sort((left, right) => left.createdAt.localeCompare(right.createdAt)); + }); } revokeClient(clientId: string, now = new Date()): boolean { @@ -717,6 +733,60 @@ function pruneSupersededRefreshTokens( }); } +function cleanupPendingLogins(state: RemoteServerCredentialState, now: Date): boolean { + let changed = false; + for (const flow of state.pendingLogins) { + if (isActivePendingLogin(flow) && Date.parse(flow.flowExpiresAt) <= now.getTime()) { + flow.status = "expired"; + changed = true; + } + } + const retained = state.pendingLogins.filter((flow) => shouldRetainPendingLogin(flow, now)); + if (retained.length !== state.pendingLogins.length) changed = true; + state.pendingLogins = retained; + return changed; +} + +function enforcePendingLoginQuota( + state: RemoteServerCredentialState, + sourceHint: string | undefined, +): void { + const active = state.pendingLogins.filter(isActivePendingLogin); + if (active.length >= DEFAULT_PENDING_MAX_ACTIVE_FLOWS) { + throw new CapletsError("AUTH_FAILED", "Too many active pending logins."); + } + const sourceKey = sourceHint ?? ""; + const activeForSource = active.filter((flow) => (flow.sourceHint ?? "") === sourceKey); + if (activeForSource.length >= DEFAULT_PENDING_MAX_ACTIVE_FLOWS_PER_SOURCE) { + throw new CapletsError("AUTH_FAILED", "Too many active pending logins for this source."); + } +} + +function isActivePendingLogin(flow: StoredPendingLogin): boolean { + return flow.status === "pending" || flow.status === "approved"; +} + +function shouldRetainPendingLogin(flow: StoredPendingLogin, now: Date): boolean { + if (isActivePendingLogin(flow)) return true; + const terminalAt = pendingLoginTerminalTime(flow); + return Number.isFinite(terminalAt) && now.getTime() - terminalAt < PENDING_TERMINAL_RETENTION_MS; +} + +function pendingLoginTerminalTime(flow: StoredPendingLogin): number { + switch (flow.status) { + case "denied": + return Date.parse(flow.deniedAt ?? flow.flowExpiresAt); + case "cancelled": + return Date.parse(flow.cancelledAt ?? flow.flowExpiresAt); + case "exchanged": + return Date.parse(flow.exchangedAt ?? flow.flowExpiresAt); + case "expired": + return Date.parse(flow.flowExpiresAt); + default: + return Number.NaN; + } +} + function validateClient( client: StoredRemoteClient, hostUrl: string, diff --git a/packages/core/test/remote-pairing.test.ts b/packages/core/test/remote-pairing.test.ts index 12584691..f946dab0 100644 --- a/packages/core/test/remote-pairing.test.ts +++ b/packages/core/test/remote-pairing.test.ts @@ -97,6 +97,7 @@ describe("self-hosted remote pairing", () => { hostUrl: "https://caplets.example.com/caplets", flowId: pending.flowId, pendingCompletionSecret: pending.pendingCompletionSecret, + now: new Date("2026-06-19T12:03:30.000Z"), }), ).toThrow(/exchanged/u); }); @@ -159,6 +160,7 @@ describe("self-hosted remote pairing", () => { hostUrl: "https://caplets.example.com", flowId: pending.flowId, pendingCompletionSecret: pending.pendingCompletionSecret, + now: new Date("2026-06-19T12:12:30.000Z"), }), ).toThrow(/denied/u); }); @@ -182,6 +184,7 @@ describe("self-hosted remote pairing", () => { hostUrl: "https://caplets.example.com", flowId: cancelled.flowId, pendingCompletionSecret: cancelled.pendingCompletionSecret, + now: new Date("2026-06-19T12:01:30.000Z"), }), ).toThrow(/cancelled/u); @@ -205,6 +208,53 @@ describe("self-hosted remote pairing", () => { expect(store.listClients()).toHaveLength(0); }); + it("cleans up expired pending login records before listing and creating new flows", () => { + const store = new RemoteServerCredentialStore({ dir: tempDir() }); + store.createPendingLogin({ + hostUrl: "https://caplets.example.com", + now: new Date("2026-06-19T12:00:00.000Z"), + }); + + expect(store.listPendingLogins(new Date("2026-06-22T12:00:00.000Z"))).toEqual([]); + + const fresh = store.createPendingLogin({ + hostUrl: "https://caplets.example.com", + now: new Date("2026-06-22T12:00:00.000Z"), + }); + + expect(store.listPendingLogins(new Date("2026-06-22T12:01:00.000Z"))).toEqual([ + expect.objectContaining({ flowId: fresh.flowId, status: "pending" }), + ]); + }); + + it("bounds active pending login flows per observed source", () => { + const store = new RemoteServerCredentialStore({ dir: tempDir() }); + + for (let index = 0; index < 8; index += 1) { + store.createPendingLogin({ + hostUrl: "https://caplets.example.com", + sourceHint: "203.0.113.7", + now: new Date("2026-06-19T12:00:00.000Z"), + }); + } + + expect(() => + store.createPendingLogin({ + hostUrl: "https://caplets.example.com", + sourceHint: "203.0.113.7", + now: new Date("2026-06-19T12:00:00.000Z"), + }), + ).toThrow(/Too many active pending logins for this source/u); + + expect( + store.createPendingLogin({ + hostUrl: "https://caplets.example.com", + sourceHint: "203.0.113.8", + now: new Date("2026-06-19T12:00:00.000Z"), + }), + ).toMatchObject({ operatorCode: expect.stringMatching(/^cap_login_/u) }); + }); + it("issues one-time Pairing Codes that exchange for client credentials", () => { const store = new RemoteServerCredentialStore({ dir: tempDir() }); const issued = store.createPairingCode({ From 1af992101eccd193cbbf1e03c694a520b9e6f0b8 Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Mon, 22 Jun 2026 17:32:21 -0400 Subject: [PATCH 14/25] fix(core): close legacy remote login bootstrap --- apps/docs/src/content/docs/remote-attach.mdx | 2 +- apps/landing/src/data/landing.ts | 2 +- packages/core/src/cli.ts | 52 +++++++------- .../src/remote/server-credential-store.ts | 14 +++- packages/core/src/serve/http.ts | 36 +++------- packages/core/test/remote-login-cli.test.ts | 41 +++++++++++- packages/core/test/remote-pairing.test.ts | 35 ++++++++++ packages/core/test/serve-http.test.ts | 67 +++++++------------ 8 files changed, 148 insertions(+), 101 deletions(-) diff --git a/apps/docs/src/content/docs/remote-attach.mdx b/apps/docs/src/content/docs/remote-attach.mdx index c13f4f71..d0109397 100644 --- a/apps/docs/src/content/docs/remote-attach.mdx +++ b/apps/docs/src/content/docs/remote-attach.mdx @@ -54,7 +54,7 @@ server environment that owns the remote credential state: ```sh caplets remote host logins -caplets remote host approve +caplets remote host approve --yes ``` If the server uses a non-default credential state directory, pass the same directory diff --git a/apps/landing/src/data/landing.ts b/apps/landing/src/data/landing.ts index d919b9e4..cc68568d 100644 --- a/apps/landing/src/data/landing.ts +++ b/apps/landing/src/data/landing.ts @@ -92,7 +92,7 @@ export const whyCapletsProblems = [ export const remoteCommands = { server: `caplets daemon install --start -caplets remote host approve `, +caplets remote host approve --yes`, client: `caplets remote login caplets attach --remote-url `, } as const; diff --git a/packages/core/src/cli.ts b/packages/core/src/cli.ts index d27bfaa7..803a4ab3 100644 --- a/packages/core/src/cli.ts +++ b/packages/core/src/cli.ts @@ -1,4 +1,4 @@ -import { Command, CommanderError } from "commander"; +import { Command, CommanderError, Option } from "commander"; import { Buffer } from "node:buffer"; import { dirname, join } from "node:path"; import { createInterface } from "node:readline/promises"; @@ -1267,8 +1267,8 @@ export function createProgram(io: CliIO = {}): Command { .option("--workspace ", "Cloud workspace ID or slug to select") .option("--client-label