diff --git a/.changeset/attach-positional-url.md b/.changeset/attach-positional-url.md new file mode 100644 index 00000000..bbd6895c --- /dev/null +++ b/.changeset/attach-positional-url.md @@ -0,0 +1,6 @@ +--- +"@caplets/core": patch +"caplets": patch +--- + +Make `caplets attach ` the primary remote attach command while keeping `--remote-url ` as a hidden compatibility alias. diff --git a/.changeset/remote-pairing-ergonomics.md b/.changeset/remote-pairing-ergonomics.md new file mode 100644 index 00000000..c6cce355 --- /dev/null +++ b/.changeset/remote-pairing-ergonomics.md @@ -0,0 +1,6 @@ +--- +"@caplets/core": patch +"caplets": patch +--- + +Label hidden CLI prompts without echoing entered secrets, and document the pending self-hosted Remote Login requirements and implementation plan. 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. diff --git a/CONCEPTS.md b/CONCEPTS.md index ba201c16..b0ae0238 100644 --- a/CONCEPTS.md +++ b/CONCEPTS.md @@ -84,13 +84,19 @@ Remote Attach uses Remote Profiles for trust and credentials. Long-lived attach The provider-neutral flow that trusts a local Caplets client to a Caplets host, whether the host is self-hosted or Caplets Cloud. -Remote Login stores host credentials in Caplets-owned credential storage so agent configs can launch `caplets attach --remote-url ...` without carrying remote secrets. +Remote Login stores host credentials in Caplets-owned credential storage so agent configs can launch Remote Attach using stable host selectors without carrying remote secrets. + +### Pending Remote Login + +The self-hosted Remote Login state between client initiation and server-local operator approval or rejection. + +A Pending Remote Login separates the operator-visible Pairing Code from client-held pending material. Approval alone does not create reusable attach credentials; the initiating client must still complete the flow with its possession proof before a Remote Profile can be stored. ### Pairing Code -A short-lived, one-time code minted by a self-hosted Caplets host and exchanged by a client during Remote Login. +A short-lived, operator-visible approval code for a pending self-hosted Remote Login flow. -Pairing Codes are bootstrap material only. They are not reusable client credentials. +Pairing Codes prove that a server-local operator approved a specific pending login. They are not reusable client credentials, attach bearer credentials, or the flow's longer-lived pre-login refresh material. ### Remote Profile diff --git a/README.md b/README.md index c33cd51d..ef78d33f 100644 --- a/README.md +++ b/README.md @@ -127,7 +127,7 @@ For a remote or Cloud-backed MCP server, point the client at `caplets attach` in ```toml [mcp_servers.caplets] command = "caplets" -args = ["attach"] +args = ["attach", "https://caplets.example.com/caplets"] ``` ```json @@ -135,7 +135,7 @@ args = ["attach"] "mcpServers": { "caplets": { "command": "caplets", - "args": ["attach"] + "args": ["attach", "https://caplets.example.com/caplets"] } } } @@ -150,7 +150,7 @@ host once, then launch attach or a native integration with only non-secret selec ```sh caplets remote login https://caplets.example.com/caplets -caplets attach --remote-url https://caplets.example.com/caplets +caplets attach https://caplets.example.com/caplets caplets remote login https://cloud.caplets.dev CAPLETS_MODE=cloud CAPLETS_REMOTE_URL=https://cloud.caplets.dev opencode diff --git a/apps/docs/src/content/docs/remote-attach.mdx b/apps/docs/src/content/docs/remote-attach.mdx index c4658ed4..85671c08 100644 --- a/apps/docs/src/content/docs/remote-attach.mdx +++ b/apps/docs/src/content/docs/remote-attach.mdx @@ -24,7 +24,7 @@ Codex: ```toml [mcp_servers.caplets] command = "caplets" -args = ["attach", "--remote-url", "https://caplets.example.com/caplets"] +args = ["attach", "https://caplets.example.com/caplets"] ``` Generic MCP JSON: @@ -34,7 +34,7 @@ Generic MCP JSON: "mcpServers": { "caplets": { "command": "caplets", - "args": ["attach", "--remote-url", "https://caplets.example.com/caplets"] + "args": ["attach", "https://caplets.example.com/caplets"] } } } @@ -42,24 +42,31 @@ 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`. - -Then log in once on the client and configure the agent to launch attach: +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 login https://caplets.example.com/caplets -caplets attach --remote-url https://caplets.example.com/caplets +caplets remote host logins +caplets remote host approve --yes ``` -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. +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 attach https://caplets.example.com/caplets +``` For Caplets Cloud native integrations, log in once and then set the Cloud URL selector: diff --git a/apps/docs/src/content/docs/troubleshooting.mdx b/apps/docs/src/content/docs/troubleshooting.mdx index eb1cce44..db9aea3d 100644 --- a/apps/docs/src/content/docs/troubleshooting.mdx +++ b/apps/docs/src/content/docs/troubleshooting.mdx @@ -152,7 +152,7 @@ Confirm the remote environment: ```sh caplets remote login https://caplets.example.com/caplets caplets doctor -caplets attach --remote-url https://caplets.example.com/caplets +caplets attach https://caplets.example.com/caplets ``` If the URL belongs to Caplets Cloud, log in through Remote Login and keep the Cloud URL diff --git a/apps/landing/src/data/landing.ts b/apps/landing/src/data/landing.ts index e6d2553d..a6b64e7e 100644 --- a/apps/landing/src/data/landing.ts +++ b/apps/landing/src/data/landing.ts @@ -92,9 +92,9 @@ export const whyCapletsProblems = [ export const remoteCommands = { server: `caplets daemon install --start -caplets remote host pair --host-url `, - client: `caplets remote login --code -caplets attach --remote-url `, +caplets remote host approve --yes`, + client: `caplets remote login +caplets attach `, } as const; export const remoteEndpoints = [ diff --git a/docs/brainstorms/2026-06-19-unified-remote-attach-auth-requirements.md b/docs/brainstorms/2026-06-19-unified-remote-attach-auth-requirements.md index fcbcf933..78d442e1 100644 --- a/docs/brainstorms/2026-06-19-unified-remote-attach-auth-requirements.md +++ b/docs/brainstorms/2026-06-19-unified-remote-attach-auth-requirements.md @@ -1,154 +1,193 @@ --- date: 2026-06-19 topic: unified-remote-attach-auth +last_updated: 2026-06-22 --- # Unified Remote Attach Auth Requirements ## Summary -Caplets should use one remote login model for both Caplets Cloud and self-hosted Caplets. Users trust a Caplets host once, then MCP clients and native integrations launch `caplets attach --remote-url ...` without Basic Auth, env-secret plumbing, or Cloud-specific login commands. +Caplets should use one remote login model for both Caplets Cloud and self-hosted Caplets. Users trust a Caplets host once, then MCP clients and native integrations launch `caplets attach ` without Basic Auth, env-secret plumbing, copied bearer tokens, or Cloud-specific login commands. + +The current codebase already contains much of the Remote Profile shape: `remote login/status/logout`, attach-time credential resolution, hosted Cloud login under Remote Profiles, and a self-hosted pairing exchange. The remaining product work is to replace the old operator-minted Pairing Code bootstrap with a client-started, server-approved pending login flow; harden that flow's refresh/security semantics; clarify migration/recovery behavior; and keep setup docs from reintroducing remote secrets. --- ## Problem Frame -Remote attach currently splits by provider. Hosted Cloud uses `caplets cloud auth login` and saved Cloud Auth credentials, while self-hosted remotes use `CAPLETS_REMOTE_TOKEN` or Basic Auth through flags and environment variables. +Remote attach historically split by provider. Hosted Cloud used `caplets cloud auth login` and saved Cloud Auth credentials, while self-hosted remotes used `CAPLETS_REMOTE_TOKEN`, Basic Auth flags/env, or an operator-minted Pairing Code flow. The current branch has already moved several of these surfaces toward unified Remote Profiles, but the self-hosted bootstrap shape still needs to become a durable product flow rather than a short-lived code handoff. -That split leaks into the first-run experience. MCP clients need launch commands, agent configs become a place where secrets can drift, and each provider adds its own setup wording. The problem is not only Codex env inheritance; any agent that starts a subprocess can become another place to debug PATH, env, token, and credential persistence. +That split leaks into the first-run experience. MCP clients need launch commands, agent configs can become a place where secrets drift, and each provider adds its own setup wording. The problem is not only Codex env inheritance; any agent that starts a subprocess can become another place to debug PATH, env, token, and credential persistence. -Caplets should own remote trust. Agent wiring should be reduced to installing the right attach command into the agent, while Caplets stores and refreshes the credentials needed to connect to the selected host. +Caplets should own remote trust. Agent wiring should install the right attach command into the agent, while Caplets stores, refreshes, and recovers the credentials needed to connect to the selected host. --- ## Key Decisions - **Remote login is the provider-neutral user model.** `caplets remote login ` replaces Cloud-specific login wording and self-hosted env-token setup in the primary docs. -- **Pairing codes are not reusable credentials.** Self-hosted pairing produces a short-lived code that is exchanged for stored client credentials. -- **Agent configs do not carry secrets.** `add-mcp` installs `caplets attach --remote-url ...`; Caplets resolves credentials from its own store at runtime. -- **Basic Auth leaves the product path.** Self-hosted attach, MCP, and control routes should use issued client credentials rather than username/password auth. +- **Self-hosted login is client-started and server-approved.** The client starts a pending device-style flow, displays a short operator code, waits for approval, and receives stored client credentials only after server-local approval. +- **Self-hosted v1 assumes server-local administrative authority.** Web/admin approval is deferred; the supported v1 self-hosted path requires shell access or equivalent local operator authority on the host, and user-facing errors/docs should say so. +- **Pending login refresh is flow state, not a reusable credential.** The client can refresh a pending login within a longer refresh window, but refresh material is bound to the initiating flow/client, rotates, and cannot become attach bearer material. +- **Remote trust requires host identity, not just URL strings.** URL-shaped commands remain the user interface, but credentials are audience-bound to the issuing host identity and must handle aliases, redirects, and workspace disambiguation deliberately. +- **Agent configs do not carry secrets.** `add-mcp` installs `caplets attach `; Caplets resolves credentials from its own store at runtime. +- **Basic Auth leaves the product path.** Self-hosted attach, MCP, and control routes should use issued client credentials or server-local operator authority rather than username/password auth. - **Backend OAuth stays separate.** `caplets auth login ` continues to mean authentication for a configured backend Caplet, not authentication to a Caplets host. -- **Cloud becomes one host kind under remote login.** Existing Cloud browser/device auth can remain internally, but the user-facing command moves under the unified remote namespace. +- **Cloud becomes one host kind under remote login.** Existing Cloud browser/device auth can remain internally, but the user-facing command and credential lifecycle move under the unified remote namespace. ```mermaid flowchart TB A["User chooses a Caplets host"] --> B{"Hosted Cloud?"} B -->|yes| C["caplets remote login cloud URL"] - B -->|no| D["Server mints pairing code"] - D --> E["Client runs caplets remote login host URL --code"] - C --> F["Caplets stores host credentials"] - E --> F - F --> G["add-mcp installs caplets attach --remote-url host URL"] - G --> H["Agent starts attach with no env secrets"] + B -->|no| D["Client starts pending self-hosted login"] + D --> E["Client displays operator code and waits"] + E --> F["Server-local operator approves code plus client context"] + C --> G["Caplets stores host credentials"] + F --> G + G --> H["add-mcp installs caplets attach host URL"] + H --> I["Agent starts attach with no env secrets"] ``` --- ## Actors -- A1. **Self-hosted server operator.** Runs the Caplets HTTP service, creates pairing codes, and revokes paired clients. +- A1. **Self-hosted server operator.** Runs the Caplets HTTP service, approves pending login codes from the server environment or equivalent local administrative authority, and revokes paired clients. - A2. **Remote client user.** Logs a local machine into a Caplets host and configures agents to launch attach. -- A3. **Agent or MCP client.** Starts `caplets attach --remote-url ...` and receives the remote-backed Caplets surface. -- A4. **Caplets host.** Issues pairing codes, exchanges them for client credentials, validates attach requests, and records client identity. +- A3. **Agent or MCP client.** Starts `caplets attach ` and receives the remote-backed Caplets surface. +- A4. **Caplets host.** Starts pending login flows, rotates pre-login refresh material, records server-local approvals, issues client credentials, validates attach requests, enforces administrative boundaries, and records client identity. - A5. **Caplets Cloud.** Implements the same remote login contract while preserving hosted workspace selection and refresh behavior. --- ## Requirements -**Unified remote login** +**Unified remote login and host identity** - R1. `caplets remote login ` authenticates this machine to a Caplets host, whether the host is self-hosted or Caplets Cloud. -- R2. `caplets remote status` shows saved remote credentials in redacted form, including host URL, host kind, selected workspace when applicable, client label, created time, and last-used time when available. -- R3. `caplets remote logout ` removes this machine's saved credentials for that host. -- R4. `caplets attach --remote-url ` resolves stored credentials for the normalized host URL without requiring `CAPLETS_REMOTE_TOKEN`, `CAPLETS_REMOTE_USER`, or `CAPLETS_REMOTE_PASSWORD`. -- R5. Attach mode inference remains URL-driven: Cloud URLs use the hosted Cloud path, and non-Cloud URLs use the self-hosted path. - -**Self-hosted pairing** - -- R6. A self-hosted server operator can mint a short-lived one-time pairing code from the server environment. -- R7. Pairing codes are scoped to one host, expire within minutes by default, are rate-limited, and are stored server-side only as non-reusable verification material. -- R8. A client can exchange a valid pairing code through `caplets remote login --code `. -- R9. Successful self-hosted login issues client credentials that are stored by Caplets on the client and can be revoked independently on the server. -- R10. Pairing code exchange never turns the copied code into the long-lived bearer credential. - -**Credential lifecycle** +- R2. `caplets remote status` shows saved remote credentials in redacted form, including host URL, canonical host identity when available, host kind, selected workspace when applicable, client label, created time, and last-used time when available. +- R3. `caplets remote logout ` removes this machine's saved credentials for that host, with explicit disambiguation or clearly documented all-profile behavior when multiple profiles match the URL. +- R4. `caplets attach ` resolves stored credentials for the normalized host URL without requiring `CAPLETS_REMOTE_TOKEN`, `CAPLETS_REMOTE_USER`, or `CAPLETS_REMOTE_PASSWORD`. +- R5. Attach mode inference remains URL-driven for user ergonomics: Cloud URLs use the hosted Cloud path, and non-Cloud URLs use the self-hosted path. +- R6. Remote credentials are audience-restricted to the issuing Caplets host identity, not merely to the locator string the user typed. +- R7. URL normalization, redirects, aliases, localhost-vs-LAN addresses, and host URL changes have defined behavior so Caplets does not silently duplicate profiles, attach with the wrong credentials, or lose logout/revocation semantics. +- R8. Remote login, pending refresh, final credential exchange, and attach transmit credential-bearing data only over authenticated encrypted transport by default; plaintext is limited to explicit, warning-gated local-development exceptions. + +**Self-hosted pending login** + +- R9. A self-hosted client starts Remote Login from the client side by running `caplets remote login ` against a reachable host. +- R10. Starting self-hosted login creates a pending login flow on the host and displays a short operator code for server-local approval. +- R11. The client-side login command defines its visible state model: initial instructions, waiting/polling behavior, code-refresh presentation, approval success, operator denial, expiry, retryable network failure, interrupt/cancel behavior, and non-interactive or JSON output when supported. +- R12. The visible operator code is scoped to one host, expires quickly by default, is rate-limited, and is stored server-side only as non-reusable verification material. +- R13. The client receives pre-login refresh material for the pending flow, with a longer default lifetime than the visible operator code. +- R14. Pre-login refresh rotates both the pending flow's refresh material and the visible operator code; old refresh material and old operator codes stop working after rotation. +- R15. Pre-login refresh material is bound to the initiating pending flow and client context, is invalidated on approval, cancellation, and timeout, and cannot retrieve final credentials unless the original client possession check succeeds. +- R16. The unauthenticated pending-login surface has abuse controls for creation and refresh attempts, including rate limits, per-source and global pending-flow quotas, maximum pending lifetime, expired-state cleanup, refresh backoff, and operator-facing behavior when limits are hit. +- R17. A self-hosted server operator can approve a pending login code from the server environment or equivalent local administrative authority. +- R18. Pending-login approval displays and records operator-relevant request metadata before issuing credentials, such as client label, requested host or workspace, created time, approximate source, and stable client identity or fingerprint where available. +- R19. Operator-facing approve, list, and revoke flows define required inputs, success output, confirmation behavior, client-label behavior, and failure states for unknown, expired, already-used, host-mismatched, denied, rate-limited, and revoked clients. +- R20. Approval of a pending login issues client credentials that are stored by Caplets on the client and can be revoked independently on the server. +- R21. Pending login codes and pre-login refresh material never become attach bearer credentials or reusable Remote Profile credentials. + +**Credential lifecycle and administration** + +- R22. Remote credentials are keyed by canonical host identity plus locator metadata and, for hosted Cloud, selected workspace. +- R23. Access credentials used for attach are audience-restricted to their issuing Caplets host. +- R24. Long-lived refresh material is rotated or otherwise constrained so stealing one stale token is not enough for indefinite access. +- R25. Credential storage is owned by Caplets and must use restrictive local permissions, with OS credential storage preferred when available. +- R26. CLI output, diagnostics, JSON errors, and logs redact remote access credentials, refresh credentials, pending-flow refresh material, and approval codes. +- R27. The server can list and revoke paired clients by stable client identity, label, created time, and last-used time when available. +- R28. Ordinary attach credentials are not sufficient to approve pending logins, list paired clients, revoke clients, or otherwise administer the host unless intentionally granted an explicit admin scope; v1 self-hosted approval and revocation may be server-local operator actions instead. + +**Cloud migration and workspace lifecycle** + +- R29. `caplets cloud auth login` is deprecated in favor of `caplets remote login `. +- R30. Valid existing Cloud credentials used by remote attach are migrated into the unified Remote Profile model before use or surfaced through the same `remote status` and `remote logout` lifecycle with a documented sunset. +- R31. Recovery messages point users to `caplets remote login` only when legacy Cloud state is missing, invalid, unreadable, or outside the supported migration window. +- R32. Cloud workspace selection is represented as part of the remote login profile, not as a separate auth model. +- R33. When multiple Cloud workspace profiles share one Cloud URL, `remote status`, `remote logout`, and `attach ` provide explicit workspace/profile disambiguation or clearly documented all-profile behavior; they do not silently choose or delete the wrong profile. -- R11. Remote credentials are keyed by normalized host URL and, for hosted Cloud, selected workspace. -- R12. Access credentials used for attach are audience-restricted to their issuing Caplets host. -- R13. Long-lived refresh material is rotated or otherwise constrained so stealing one stale token is not enough for indefinite access. -- R14. Credential storage is owned by Caplets and must use restrictive local permissions, with OS credential storage preferred when available. -- R15. CLI output, diagnostics, JSON errors, and logs redact remote access and refresh credentials. -- R16. The server can list and revoke paired clients by stable client identity, label, created time, and last-used time. - -**Cloud migration** +**Agent setup and docs** -- R17. `caplets cloud auth login` is deprecated in favor of `caplets remote login `. -- R18. Existing Cloud credentials continue to work during migration or are migrated automatically into the unified remote credential store. -- R19. Cloud workspace selection is represented as part of the remote login profile, not as a separate auth model. -- R20. Recovery messages that currently say `caplets cloud auth login` point users to the unified remote login command. +- R34. First-run docs use `add-mcp` for generic MCP wiring instead of making `caplets setup` the primary path. +- R35. Local MCP docs install `caplets serve` through `add-mcp`. +- R36. Remote MCP docs install `caplets attach ` through `add-mcp` after the user has completed `caplets remote login `. +- R37. Remote MCP docs do not recommend `add-mcp --env` for Caplets remote credentials. +- R38. Native OpenCode and Pi docs use their native extension setup paths and the same remote login model. +- R39. `caplets setup` is deprecated, removed, or reduced to a transitional router that points users at `add-mcp` and native extension docs. -**Agent setup and docs** +**Basic Auth and old self-hosted auth removal** -- R21. First-run docs use `add-mcp` for generic MCP wiring instead of making `caplets setup` the primary path. -- R22. Local MCP docs install `caplets serve` through `add-mcp`. -- R23. Remote MCP docs install `caplets attach --remote-url ` through `add-mcp` after the user has completed `caplets remote login `. -- R24. Remote MCP docs do not recommend `add-mcp --env` for Caplets remote credentials. -- R25. Native OpenCode and Pi docs use their native extension setup paths and the same remote login model. -- R26. `caplets setup` is deprecated, removed, or reduced to a transitional router that points users at `add-mcp` and native extension docs. +- R40. Self-hosted Basic Auth is removed from the primary self-hosted attach, MCP, and control model. +- R41. If Basic Auth compatibility remains for one release, it is hidden from first-run docs, warns on use, and has a documented removal path. +- R42. New tests and docs must not describe Basic Auth as a supported self-hosted setup path. +- R43. The old operator-minted `caplets remote host pair` bootstrap flow is removed instead of kept as a parallel supported login path. +- R44. Existing self-hosted env-token, Basic Auth, and old pairing usage receives clear detection or recovery guidance that points to `caplets remote login ` and explains any temporary compatibility window or sunset. +- R45. Existing third-party agent configs that already contain remote env secrets are not automatically rewritten in this scope, but attach/setup errors should guide users away from env-secret storage and toward Remote Login. -**Basic Auth removal** +**Attach recovery behavior** -- R27. Self-hosted Basic Auth is removed from the primary self-hosted attach, MCP, and control model. -- R28. If Basic Auth compatibility remains for one release, it is hidden from first-run docs, warns on use, and has a documented removal path. -- R29. New tests and docs must not describe Basic Auth as a supported self-hosted setup path. +- R46. `caplets attach ` has state-specific human-readable and JSON recovery behavior for no saved credential, revoked credential, expired or refresh-failed credential, host unreachable, host-kind mismatch, workspace/profile ambiguity, and invalid URL. +- R47. Attach recovery output identifies the next action when possible: run `caplets remote login`, ask a server operator to approve/reapprove, choose a workspace/profile, inspect server reachability, or fix the URL. --- ## Key Flows -- F1. Self-hosted host pairing - - **Trigger:** A server operator wants to let a local machine attach to a self-hosted Caplets service. +- F1. Self-hosted pending login approval + - **Trigger:** A remote client user wants to let a local machine attach to a self-hosted Caplets service. - **Actors:** A1, A2, A4 - - **Steps:** The operator starts the HTTP service, mints a pairing code on the server, copies the suggested login command to the client, and the client exchanges the code for stored credentials. - - **Covered by:** R6, R7, R8, R9, R10 + - **Steps:** The client starts Remote Login against the host, the host creates pending login state, the client displays a short operator code and waits, the operator approves that code together with displayed client context from the server environment or equivalent local authority, and the client receives stored credentials. + - **Covered by:** R9, R10, R11, R12, R17, R18, R19, R20, R21 -- F2. Cloud host login +- F2. Pending login refresh while approval is delayed + - **Trigger:** The operator has not approved before the visible operator code expires, but the pending login remains inside its refresh lifetime. + - **Actors:** A2, A4 + - **Steps:** The client refreshes the pending flow, receives a new visible code and rotated pre-login refresh material, old pending materials stop working, and the client continues waiting with updated instructions. + - **Covered by:** R11, R13, R14, R15, R16 + +- F3. Cloud host login - **Trigger:** A user wants to attach local agents to Caplets Cloud. - **Actors:** A2, A5 - - **Steps:** The user runs remote login against the Cloud URL, completes the existing hosted auth flow, selects or confirms the workspace, and Caplets stores the remote profile. - - **Covered by:** R1, R11, R17, R18, R19 + - **Steps:** The user runs remote login against the Cloud URL, completes the existing hosted auth flow, selects or confirms the workspace, and Caplets stores the remote profile under the unified lifecycle. + - **Covered by:** R1, R22, R29, R30, R32, R33 -- F3. Agent wiring after remote login +- F4. Agent wiring after remote login - **Trigger:** The user wants an MCP client to use a remote-backed Caplets surface. - **Actors:** A2, A3 - - **Steps:** The user runs `add-mcp` with the `caplets attach --remote-url ...` command, the agent starts that command later, and Caplets resolves stored credentials before connecting to the host. - - **Covered by:** R4, R21, R23, R24 + - **Steps:** The user runs `add-mcp` with the `caplets attach ` command, the agent starts that command later, and Caplets resolves stored credentials before connecting to the host. + - **Covered by:** R4, R34, R36, R37, R46, R47 -- F4. Client revocation +- F5. Client revocation - **Trigger:** A device is lost, replaced, or no longer trusted. - **Actors:** A1, A2, A4 - **Steps:** The operator lists paired clients, identifies the client by label and metadata, revokes it, and future attach attempts from that client fail until it logs in again. - - **Covered by:** R3, R9, R16 + - **Covered by:** R20, R27, R28, R46, R47 -- F5. Legacy Cloud migration - - **Trigger:** A user has existing saved Cloud Auth credentials. - - **Actors:** A2, A5 - - **Steps:** The user runs a command that needs remote credentials, Caplets reads or migrates the existing credential, and recovery messages teach the new `remote login` command. - - **Covered by:** R17, R18, R20 +- F6. Legacy credential recovery + - **Trigger:** A user has existing Cloud Auth state, self-hosted env-token config, Basic Auth config, or old pairing assumptions. + - **Actors:** A1, A2, A3, A4, A5 + - **Steps:** Caplets either migrates or surfaces valid legacy Cloud credentials through the unified lifecycle, and self-hosted legacy paths produce warnings or recovery guidance pointing to Remote Login without automatically rewriting third-party agent configs. + - **Covered by:** R29, R30, R31, R40, R41, R42, R43, R44, R45 --- ## Acceptance Examples -- AE1. **Covers R1, R17, R19.** Given a user runs `caplets remote login https://cloud.caplets.dev`, when the hosted auth flow completes, then Caplets stores a Cloud remote profile with the selected workspace. -- AE2. **Covers R6, R7, R8, R10.** Given a server operator creates a pairing code and a client exchanges it, when the client later attaches, then it uses issued client credentials rather than the original copied code. -- AE3. **Covers R4, R23, R24.** Given a user has completed remote login, when they install `caplets attach --remote-url ` through `add-mcp`, then the agent config contains no remote token, password, or Caplets credential env vars. -- AE4. **Covers R12, R13, R15.** Given a remote credential is stored locally, when attach refreshes or reports diagnostics, then credentials remain host-scoped and redacted from output. -- AE5. **Covers R16.** Given a self-hosted operator lists paired clients, when they revoke one client, then that client's next attach attempt fails until it logs in again. -- AE6. **Covers R18, R20.** Given a user still has legacy Cloud Auth state, when attach requires credentials, then Caplets either migrates the state or reports a recovery command using `caplets remote login`. -- AE7. **Covers R21, R22, R23, R25.** Given a first-time user reads install docs, when they choose MCP or native setup, then the docs send them through `add-mcp` for MCP and native extension setup for OpenCode or Pi. -- AE8. **Covers R27, R28, R29.** Given a user follows current docs, when they self-host and attach, then they never configure Basic Auth. +- AE1. **Covers R1, R29, R32.** Given a user runs `caplets remote login https://cloud.caplets.dev`, when the hosted auth flow completes, then Caplets stores a Cloud remote profile with the selected workspace. +- AE2. **Covers R9, R10, R17, R18, R20, R21.** Given a client starts self-hosted Remote Login and the operator approves the displayed code and client context from the host environment, when the client later attaches, then it uses issued client credentials rather than the operator-visible code. +- AE3. **Covers R11, R13, R14, R15.** Given a client starts self-hosted Remote Login and approval takes longer than the visible code lifetime, when the client refreshes within the pre-login refresh lifetime, then it receives a new visible code and rotated client-bound pre-login refresh material while the old code and old refresh material stop working. +- AE4. **Covers R8, R23, R26.** Given a user attempts remote login or attach with credential-bearing data over a non-local plaintext URL, when no explicit warning-gated local-development exception is active, then the command refuses or reports secure-transport guidance without printing credentials. +- AE5. **Covers R16.** Given many unauthenticated clients create or refresh pending logins, when limits or quotas are hit, then the host throttles or rejects the requests, cleans up expired state, and reports limit behavior without leaking valid codes. +- AE6. **Covers R4, R36, R37.** Given a user has completed remote login, when they install `caplets attach ` through `add-mcp`, then the agent config contains no remote token, password, or Caplets credential env vars. +- AE7. **Covers R23, R24, R26.** Given a remote credential is stored locally, when attach refreshes or reports diagnostics, then credentials remain host-scoped and redacted from output. +- AE8. **Covers R27, R28.** Given a self-hosted operator lists paired clients, when they revoke one client, then that client's next attach attempt fails until it logs in again, and ordinary attach credentials cannot perform that revocation. +- AE9. **Covers R30, R31.** Given a user still has valid legacy Cloud Auth state, when attach requires credentials, then Caplets migrates or surfaces the state through the unified Remote Profile lifecycle; re-login guidance appears only when the legacy state is missing, invalid, unreadable, or out of the compatibility window. +- AE10. **Covers R33.** Given a user has multiple Cloud workspace profiles for the same Cloud URL, when they run status, attach, or logout, then Caplets lists or disambiguates the matching profiles rather than silently attaching to or deleting the wrong workspace. +- AE11. **Covers R34, R35, R36, R38.** Given a first-time user reads install docs, when they choose MCP or native setup, then the docs send them through `add-mcp` for MCP and native extension setup for OpenCode or Pi. +- AE12. **Covers R40, R41, R42, R43, R44, R45.** Given a user follows current docs or hits legacy self-hosted auth recovery, when they self-host and attach, then they never configure Basic Auth or the old operator-minted Pairing Code flow as the supported path and receive Remote Login guidance instead. +- AE13. **Covers R46, R47.** Given an agent-launched attach fails because credentials are missing, revoked, expired, ambiguous, or the host is unreachable, when output is human-readable or JSON, then it reports redacted state-specific recovery guidance and a stable next action. --- @@ -156,8 +195,12 @@ flowchart TB - A first-time MCP user can install Caplets into an agent config without writing a remote secret into that config. - Cloud and self-hosted docs use the same remote-login vocabulary. -- `caplets attach --once --remote-url --json` gives credential recovery guidance that points to `caplets remote login`, not provider-specific or env-secret instructions. -- Self-hosted revocation can identify and remove one paired client without rotating every client credential. +- `caplets attach --once --json` gives state-specific credential recovery guidance that points to `caplets remote login` or the correct remediation, not provider-specific or env-secret instructions. +- Self-hosted login can tolerate delayed approval with rotating pre-login refresh material without requiring the old operator-minted Pairing Code flow. +- Self-hosted v1 docs and errors make clear that approval requires shell access or equivalent local administrative authority on the host. +- Credential-bearing remote flows use authenticated encrypted transport by default, with only explicit warning-gated local-development exceptions. +- Self-hosted revocation can identify and remove one paired client without rotating every client credential, and ordinary attach credentials cannot administer other clients. +- Cloud workspace profiles sharing a URL can be inspected, attached, and logged out without silent wrong-workspace selection or deletion. - The install docs no longer require users to understand `CAPLETS_REMOTE_TOKEN`, `CAPLETS_REMOTE_USER`, or `CAPLETS_REMOTE_PASSWORD` for the normal path. --- @@ -167,14 +210,18 @@ flowchart TB **Deferred for later** - Hardware-backed or sender-constrained credentials beyond the best available software credential model. +- Web-based admin approval for self-hosted pending logins. - Multi-user role and permission administration for self-hosted teams beyond client listing and revocation. - Automatic migration of every possible third-party MCP config that may already contain Caplets env secrets. +- Exact command names, endpoint shapes, storage schemas, and error-code names for the pending-login protocol; requirements here define the product contract that planning must realize. **Outside this product's identity** - Using agent MCP configs as the source of truth for Caplets secrets. - Treating Basic Auth as the long-term self-hosted authentication model. +- Treating the old operator-minted Pairing Code flow as a parallel long-term login model. - Replacing backend Caplet OAuth with remote host login. +- Allowing credential-bearing remote attach/login traffic over plaintext by default. --- @@ -182,8 +229,10 @@ flowchart TB - `add-mcp` remains the recommended third-party MCP config writer for broad provider coverage. - Caplets can persist remote credentials in a local store with restrictive permissions and can later improve the backing store without changing the command model. -- Self-hosted pairing commands are run by someone with shell access to the server or equivalent administrative authority. +- Self-hosted v1 approval commands are run by someone with shell access to the server or equivalent local administrative authority; deployments without that authority need a later admin approval surface. +- The default self-hosted pending-login policy starts with a 10-minute visible operator code and a 24-hour rotating pre-login refresh token. - Caplets Cloud can expose the existing hosted login behavior through the unified remote command namespace. +- Host identity can be represented separately from the URL locator users type, even if the first implementation derives part of that identity from existing host metadata. --- @@ -192,8 +241,18 @@ flowchart TB - `README.md` for the current quick-start, `caplets setup`, `caplets serve`, and `caplets attach` public contract. - `packages/core/src/cli.ts` for current `attach`, `cloud auth`, and setup command surfaces. - `packages/core/src/remote/options.ts` and `packages/core/src/remote/selection.ts` for current self-hosted and hosted remote credential resolution. +- `packages/core/src/remote/server-credential-store.ts` and `packages/core/src/serve/http.ts` for current self-hosted Pairing Code exchange and post-login credential refresh behavior. +- `docs/solutions/integration-issues/stale-remote-profile-credentials-refresh.md` for the existing post-login Remote Profile refresh model. - `apps/docs/src/content/docs/remote-attach.mdx`, `docs/project-binding.md`, and `docs/product/caplets-code-mode-prd.md` for current remote attach documentation. - `add-mcp@1.10.4` CLI help and package source for supported agents and `--env` handling. - [RFC 8628 OAuth 2.0 Device Authorization Grant](https://www.rfc-editor.org/rfc/rfc8628.html) for the short-code device authorization pattern. - [RFC 9700 OAuth 2.0 Security Best Current Practice](https://www.rfc-editor.org/rfc/rfc9700.html) for token lifecycle and refresh-token risk framing. - [OWASP OAuth2 Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/OAuth2_Cheat_Sheet.html) for OAuth security guidance. + +--- + +## Deferred / Open Questions + +### From 2026-06-22 document review + +- **Setup/docs scope:** Should this requirements document include broader `caplets setup` deprecation and local MCP setup modernization, or should it stay focused on remote attach auth and move those setup decisions to a separate onboarding/setup requirements document? diff --git a/docs/plans/2026-06-19-001-feat-unified-remote-attach-auth-plan.md b/docs/plans/2026-06-19-001-feat-unified-remote-attach-auth-plan.md index 5a027023..50a0db86 100644 --- a/docs/plans/2026-06-19-001-feat-unified-remote-attach-auth-plan.md +++ b/docs/plans/2026-06-19-001-feat-unified-remote-attach-auth-plan.md @@ -8,6 +8,15 @@ deepened: 2026-06-19 # feat: Unify remote attach authentication +## Supersession Note + +This plan is retained as historical planning context, but two implementation details were superseded during PR #144 review and follow-up work: + +- Remote Attach now uses `caplets attach ` as the primary public command shape. `--remote-url ` remains only as a hidden compatibility alias for existing configs. +- Self-hosted Remote Login now uses a client-started Pending Remote Login flow with server-local approval (`caplets remote host logins` and `caplets remote host approve --yes`) instead of operator-minted Pairing Code bootstrap/exchange. + +Use `docs/brainstorms/2026-06-19-unified-remote-attach-auth-requirements.md` and `docs/solutions/developer-experience/self-hosted-pending-remote-login-and-attach-positional-url.md` for the current command shape, credential-boundary guidance, and pending-login implementation pattern. The body below describes the earlier plan state and should not be used as current execution guidance where it mentions `caplets attach --remote-url ` as the primary path or operator-minted Pairing Code exchange as the self-hosted login flow. + ## Summary Implement provider-neutral Remote Login for Caplets Cloud and self-hosted Caplets. After this work, users trust a host once with `caplets remote login `, then `caplets attach --remote-url ` and native integrations resolve Caplets-owned credentials without agent env secrets, env-token setup, or Basic Auth. diff --git a/docs/plans/2026-06-22-001-feat-self-hosted-pending-remote-login-plan.md b/docs/plans/2026-06-22-001-feat-self-hosted-pending-remote-login-plan.md new file mode 100644 index 00000000..c735b484 --- /dev/null +++ b/docs/plans/2026-06-22-001-feat-self-hosted-pending-remote-login-plan.md @@ -0,0 +1,349 @@ +--- +title: Self-Hosted Pending Remote Login Plan +type: feat +date: 2026-06-22 +origin: docs/brainstorms/2026-06-19-unified-remote-attach-auth-requirements.md +--- + +# Self-Hosted Pending Remote Login Plan + +## Summary + +Replace the self-hosted Remote Login bootstrap with a client-started, server-approved pending-login flow. The new flow keeps the existing Remote Profile and post-login refresh model, but replaces operator-minted Pairing Codes with short operator-visible approval codes, rotating pre-login refresh material, server-local approval commands, host identity checks, state-specific attach recovery, and docs that keep remote secrets out of agent configuration. + +This plan supersedes the self-hosted Pairing Code portions of `docs/plans/2026-06-19-001-feat-unified-remote-attach-auth-plan.md`. Cloud Remote Profile behavior and existing attach/native request-time credential resolution remain the baseline rather than a greenfield rebuild. + +--- + +## Problem Frame + +The current self-hosted login path still depends on an operator running `caplets remote host pair` to mint a copied Pairing Code, then the client entering that code into `caplets remote login `. That path is backwards for a device-style login: the user initiating trust should start the flow, the host should hold pending state, the operator should approve from the server environment, and the client should refresh or finish the flow without copying credential-like material through argv, environment variables, or agent config. + +The codebase already has the right durable destination for successful login: Remote Profiles store host credentials, attach resolves profiles at request time, and native clients already avoid capturing stale auth headers. The work is to replace the self-hosted bootstrap and harden the surrounding lifecycle, not to re-platform all remote auth. + +--- + +## Plan Requirements + +**Pending login lifecycle** + +- PR1. Self-hosted `caplets remote login ` starts a pending login on the host instead of prompting for an operator-minted Pairing Code. +- PR2. The pending login has distinct material classes: a short visible operator code, high-entropy pre-login refresh material, final access credentials, and final refresh credentials. +- PR3. Visible operator codes are not attach bearer credentials and are only useful to a server-local operator approval command. +- PR4. Pre-login refresh material is client-held, rotating, flow-bound, invalidated on terminal states, and never stored as a Remote Profile credential. +- PR5. The client login command has explicit waiting, refresh, denial, expiry, cancellation, retry, and success states in human output and stable JSON events. + +**Server-local administration** + +- PR6. Server-local operators can list, approve, and deny pending logins by local state access; ordinary attach credentials cannot approve pending logins or administer other clients. +- PR7. Operator approval shows request metadata before issuing credentials, including host/audience, client label, created time, approximate source, and stable fingerprint or generated client identity where available. +- PR8. Existing paired clients remain valid across the migration; unexchanged legacy Pairing Codes stop being a supported login path. + +**Identity, security, and abuse controls** + +- PR9. Pending login, refresh, completion, attach, and post-login refresh use authenticated encrypted transport by default, with only warning-gated local-development plaintext exceptions. +- PR10. Self-hosted profiles and credentials carry server-reported host identity/audience metadata in addition to locator URLs, and mismatches fail closed rather than silently merging aliases. +- PR11. The unauthenticated pending-login surface has quotas, cleanup, refresh backoff, and redacted error responses. +- PR12. Pending refresh rotation has a bounded lost-response retry strategy so transient network failures do not strand legitimate clients. + +**Attach, Cloud, and agent parity** + +- PR13. Attach recovery distinguishes missing profile, revoked credential, refresh failure, host unreachable, host identity mismatch, invalid URL, insecure transport, and Cloud workspace/profile ambiguity. +- PR14. Cloud Remote Profile behavior remains under the unified `remote` namespace, including legacy Cloud migration and workspace-aware status/logout semantics. +- PR15. MCP, OpenCode, Pi, and supported client/agent docs use Remote Profiles and non-secret selectors only; supported agent setup paths do not put remote tokens, passwords, Pairing Codes, or pre-login refresh material in config, argv examples, or environment variables. + +--- + +## Key Technical Decisions + +- KTD1. **Use a new pending-login endpoint family instead of overloading `/v1/remote/pairing/exchange`.** The target lifecycle has start, poll, refresh, complete, cancel, and terminal states. Encoding that as a one-shot Pairing Code exchange would preserve the old mental model and make refresh/denial/cancellation awkward. + +- KTD2. **Keep approval server-local for v1.** Approval, pending-list, deny, and administrative revoke operate through local CLI access to the same server credential state path as `caplets serve`. No web admin surface and no attach-authenticated admin API ships in this plan. + +- KTD3. **Delay paired-client creation until the original pending client completes the approved flow.** Operator approval marks the pending flow approved, but final client credentials are persisted only when the original flow holder completes with current possession material. This avoids orphaned clients when a terminal closes after approval. + +- KTD4. **Use rotating pre-login refresh with stale-retry grace.** The store rotates pre-login refresh verifiers the same way post-login refresh rotates today, while retaining a short superseded verifier window bound to the same flow/client proof for lost-response retries. Replays outside that window fail and mark the event for redacted diagnostics. + +- KTD5. **Keep pre-login refresh non-resumable in v1.** The client keeps pending refresh material in memory during `remote login`; it is not written to the Remote Profile credential store. Ctrl-C sends best-effort cancellation, and abandoned flows expire server-side. + +- KTD6. **Introduce conservative host identity metadata before seamless alias merging.** A self-hosted server reports a stable issuer/host identity and public audience derived from the same `CAPLETS_SERVER_URL` / trusted-proxy logic used for remote credentials. Profiles record both identity and locator; mismatches produce recovery guidance instead of silently merging localhost, LAN, and reverse-proxy aliases. + +- KTD7. **Treat operator-visible metadata as display-only unless server-derived.** Client labels help humans, but they do not authorize approval. Source hints are server-observed, and forwarded metadata is shown only when proxy trust is explicitly configured. + +- KTD8. **Classify client self-revocation separately from administration.** `DELETE /v1/remote/client` can remain as best-effort logout for the authenticated client only. Listing pending logins, approving/denying, and revoking other clients stay server-local. + +- KTD9. **Make `remote login --json` newline-delimited events for self-hosted pending login.** A long-running waiting command needs machine-readable progress. Human output can be friendly, but tests and automation assert event codes. + +- KTD10. **Resolve `caplets setup` only as a remote-secret boundary.** `caplets setup` remains a transitional surface for this plan: it must not generate secret-bearing remote config, and remote-auth guidance points to `remote login`, `add-mcp`, and native docs. Broader `caplets setup` deprecation or local MCP onboarding modernization remains a separate product decision. + +--- + +## High-Level Technical Design + +```mermaid +sequenceDiagram + participant Client as caplets remote login + participant Host as Self-hosted HTTP service + participant Store as RemoteServerCredentialStore + participant Operator as Server-local CLI + participant Profile as Remote Profile store + + Client->>Host: POST /v1/remote/login/start + Host->>Store: create pending flow + Store-->>Host: flow id, operator code, pending refresh, completion proof, expiries + Host-->>Client: pending state + visible code + Client->>Client: display approval instructions and wait + Operator->>Store: caplets remote host approve + Store-->>Operator: show metadata and confirm approval + Client->>Host: POST /v1/remote/login/poll + Host-->>Client: approved + Client->>Host: POST /v1/remote/login/complete with current possession material + Host->>Store: create paired client credentials + Host-->>Client: final access/refresh credentials + Client->>Profile: save self-hosted Remote Profile +``` + +### Pending HTTP contract + +The implementation should add explicit self-hosted pending-login service paths in `packages/core/src/serve/http.ts` and `servicePaths()`: + +| Route | Auth | Purpose | +| -------------------------------- | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | +| `POST /v1/remote/login/start` | unauthenticated, quota-limited | Create pending flow and return visible operator code plus client-held `pendingRefreshSecret` and `pendingCompletionSecret` / client proof. | +| `POST /v1/remote/login/poll` | pending flow possession | Return `pending`, `approved`, `denied`, `expired`, `cancelled`, `rate_limited`, or retryable status without issuing final credentials. | +| `POST /v1/remote/login/refresh` | pending flow possession | Rotate visible operator code and pre-login refresh material before code expiry or after `slow_down`/backoff guidance. | +| `POST /v1/remote/login/complete` | pending flow possession after approval | Issue and persist final paired-client credentials for the original flow holder. | +| `POST /v1/remote/login/cancel` | pending flow possession | Best-effort invalidation when the client aborts. | + +These routes use the existing remote credential error envelope shape and redaction rules. They are not control/admin routes. + +### Pending material classes and terminal transitions + +| Material | Secret? | Used by | Invalidated when | Persistence | +| ---------------------------------------- | ----------------------------------------- | -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ | +| `flowId` | No | Correlates start, poll, refresh, complete, cancel, and operator metadata. | Flow cleanup removes it from active state. | Server state only; may appear in JSON as a non-secret identifier. | +| Visible operator code | No attach authority, but redacted in logs | Server-local `remote host approve ` / `deny `. | Refresh rotates it; denial, cancellation, expiry, completion, or cleanup terminally invalidates it. | Hashed server-side; shown intentionally to the client and operator. | +| `pendingRefreshSecret` | Yes | `/v1/remote/login/refresh` only. | Refresh rotation, approval, denial, cancellation, expiry, completion, or cleanup. | In client memory and hashed server-side; never saved to Remote Profiles. | +| `pendingCompletionSecret` / client proof | Yes | `/v1/remote/login/poll`, `/v1/remote/login/complete`, and best-effort `/cancel`. | Completion consumes it; denial, cancellation, expiry, or cleanup invalidates it. Approval does not invalidate it until completion or expiry. | In client memory and hashed server-side; never saved to Remote Profiles. | +| Final access token | Yes | Attach/MCP/control requests after login. | Expiry or client revocation. | Remote Profile credential store only. | +| Final refresh token | Yes | `/v1/remote/refresh` after login. | Post-login refresh rotation, replay failure, expiry, or client revocation. | Remote Profile credential store and hashed server-side verifier. | + +Approval invalidates refresh authority but preserves completion authority for the original flow holder. Completion transitions `approved` to `exchanged`, creates the paired client, returns final credentials once, and invalidates all pending material. Polling after approval reports `approved` but does not return final credentials; the client must call `complete` with the current completion proof. + +### Pending login policy defaults + +| Policy | Default | Configurability for v1 | +| ------------------------------- | --------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | +| Visible operator code TTL | 10 minutes | Internal constant unless existing serve policy config is already being edited. | +| Pending flow max lifetime | 24 hours | Internal constant for v1; expose later only if deployment feedback needs it. | +| Poll interval | 5 seconds | Server-provided response field, defaulting to RFC 8628's 5-second interval. | +| Slow-down behavior | Add 5 seconds to the current interval after each `slow_down` / rate-limit response, capped at 60 seconds. | Internal constant. | +| Refresh cooldown | Minimum 30 seconds between successful refreshes unless code expiry is imminent. | Internal constant. | +| Stale refresh retry grace | 30 seconds for the immediately superseded verifier, bound to the same flow and completion proof. | Internal constant, mirroring the current post-login stale retry posture. | +| Global active pending flows | 256 active flows per server state file. | Internal constant for the file-backed v1 store. | +| Per-source active pending flows | 8 active flows per server-observed source hint. | Internal constant; source is advisory and never authority. | +| Cleanup cadence | Run cleanup before start, list, approve, refresh, and complete operations. | Store behavior, not a separate scheduler. | + +If implementation turns any policy into user configuration, update `packages/core/src/config.ts`, run `pnpm schema:generate`, and keep the defaults above in tests. + +### State model + +`RemoteServerCredentialStore` should move from state version 1 to a version that preserves existing `clients` and adds `pendingLogins`. Legacy `pairingCodes` are not migrated into active pending logins. + +A pending login record should include at least: + +- server-generated `flowId` and stable `hostIdentity` / public audience; +- hashed visible operator code verifier; +- hashed current pre-login refresh verifier and short-lived superseded verifier records; +- client label, client fingerprint or generated client identity, requested host/workspace context, created time, and server-observed source hint; +- `codeExpiresAt`, `flowExpiresAt`, `lastRefreshAt`, refresh counters, and rate-limit/backoff metadata; +- status: `pending`, `approved`, `denied`, `cancelled`, `expired`, `exchanged`; +- approval/denial metadata and bounded redacted audit data. + +Secret material follows existing file-backed patterns: `0700` directories, `0600` temp files, atomic rename, lock directory, and best-effort chmod. + +--- + +## Implementation Units + +### U1. Host identity and Remote Profile metadata baseline + +- **Goal:** Add the minimum self-hosted host identity contract needed before pending login issues credentials. +- **Files:** `packages/core/src/remote/profiles.ts`, `packages/core/src/remote/profile-store.ts`, `packages/core/src/remote/options.ts`, `packages/core/src/serve/http.ts`, `packages/core/src/serve/options.ts`, `packages/core/test/remote-profiles.test.ts`, `packages/core/test/serve-http.test.ts`. +- **Approach:** Add server-reported issuer/host identity metadata and locator metadata without attempting automatic alias merging. Continue accepting existing URL-keyed profiles, but enrich new self-hosted profiles with host identity and fail closed when a saved identity does not match the contacted host. Reuse `parseServerBaseUrl()` and `remoteCredentialHostUrl()` so HTTPS, loopback HTTP, `CAPLETS_SERVER_URL`, and `--trust-proxy` behavior remain centralized. +- **Test scenarios:** New self-hosted profile records host identity and locator; existing URL-keyed profile can still be read; issuer mismatch fails with redacted recovery; trusted proxy/public-origin tests continue to pin public audience; non-loopback HTTP credential-bearing login/attach is refused unless the chosen local-development override is present. +- **Dependencies:** None. +- **Verification:** `pnpm --filter @caplets/core test -- test/remote-profiles.test.ts test/serve-http.test.ts`. + +### U2. Pending-login server state and token lifecycle + +- **Goal:** Replace server-minted Pairing Code state with first-class pending-login state while preserving existing paired clients. +- **Files:** `packages/core/src/remote/server-credential-store.ts`, `packages/core/src/remote/server-credentials.ts`, `packages/core/src/remote/pairing.ts`, `packages/core/test/remote-pairing.test.ts` or a new `packages/core/test/remote-pending-login.test.ts`. +- **Approach:** Add pending-flow create, refresh, poll/status, approve, deny, cancel, complete, cleanup, and migration operations under the existing file-backed store and lock discipline. Use a short grouped alphanumeric visible code with a recognizable syntax for redaction, plus separate high-entropy pending refresh material. Hash all verifiers at rest. Rotate pending refresh and visible code together. Retain a short superseded verifier grace window for same-flow lost-response retries. Keep final access/refresh credential generation and post-login rotation as the existing durable client path. +- **Test scenarios:** State v1 with clients migrates without revoking clients; legacy unexchanged pairing codes are ignored or rejected with guidance; pending start stores no raw code/token; refresh rotates code and token; old code fails after refresh; old pending refresh fails after grace; same-flow lost-response retry succeeds only inside grace; approve invalidates future refresh but does not create a client until complete; deny/cancel/expiry prevent completion; cleanup removes expired secrets; private file and locking tests still pass. +- **Dependencies:** U1 for host identity fields. +- **Verification:** `pnpm --filter @caplets/core test -- test/remote-pairing.test.ts` or the renamed focused test file. + +### U3. Pending-login HTTP routes and abuse controls + +- **Goal:** Expose the pending-login lifecycle over HTTP without creating an admin surface. +- **Files:** `packages/core/src/serve/http.ts`, `packages/core/src/serve/options.ts`, `packages/core/src/remote/options.ts`, `packages/core/test/serve-http.test.ts`, `packages/core/test/serve-options.test.ts`. +- **Approach:** Add `start`, `poll`, `refresh`, `complete`, and `cancel` routes only when a `RemoteServerCredentialStore` is present. Keep them unauthenticated but possession-bound where applicable: refresh requires `pendingRefreshSecret`, while poll/complete/cancel require the separate completion proof. Enforce the policy defaults above with in-store quotas, refresh cooldown/backoff, max pending lifetime, and cleanup before create/list/approve. Use RFC 8628-style statuses where useful: `authorization_pending`, `slow_down`, and `expired_token` equivalents, but keep Caplets-specific error codes stable. Do not add remote approval/list/deny endpoints. +- **Test scenarios:** Start returns operator code, flow id, poll interval, expiry metadata, `pendingRefreshSecret`, and `pendingCompletionSecret` / client proof without leaking either secret in logs/errors; poll respects interval/backoff; refresh rate limits and rotates; complete before approval fails; complete after approval issues credentials only with current possession material; cancel invalidates; forged remote approval attempt has no route; pending routes use remote credential host audience and transport checks; resource limits return redacted errors. +- **Dependencies:** U1 and U2. +- **Verification:** `pnpm --filter @caplets/core test -- test/serve-http.test.ts test/serve-options.test.ts`. + +### U4. Server-local operator CLI + +- **Goal:** Give the self-hosted operator a concrete local approval, denial, pending-list, client-list, and revoke workflow. +- **Files:** `packages/core/src/cli.ts`, `packages/core/src/remote/server-credential-store.ts`, `packages/core/test/remote-login-cli.test.ts`. +- **Approach:** Replace the supported `remote host pair` workflow with pending-login commands under `caplets remote host`: `logins`, `approve `, and `deny `. The visible code is acceptable in the server-local operator command because it is not attach authority; docs should still warn that it identifies an approval request and should not be pasted into agent/client config. Keep `clients` and `revoke ` for paired clients. Use `--state-path` and `CAPLETS_REMOTE_SERVER_STATE_DIR` consistently. Approval shows metadata and requires confirmation unless `--yes` or JSON mode is used. Unknown-code and no-pending-flow output includes the resolved state path and guidance to use the same state directory as `caplets serve`. +- **Test scenarios:** `logins` shows pending metadata without secrets; `approve` with a current code marks approved after confirmation; `deny` invalidates flow; old/stale/expired codes fail without mutation; wrong state path prints resolved path guidance; JSON outputs stable status codes; `remote host pair` is removed or converted to deprecation guidance that does not mint supported login material; already-approved, already-exchanged, denied-code reapproval, host/audience mismatch, rate-limited approval, revoked-client, and missing-client states return stable JSON codes; ordinary bearer credentials cannot list/approve/deny or revoke another client. +- **Dependencies:** U2. +- **Verification:** `pnpm --filter @caplets/core test -- test/remote-login-cli.test.ts`. + +### U5. Self-hosted `remote login` client CLI + +- **Goal:** Convert self-hosted `caplets remote login ` into a wait/poll/refresh command that stores final credentials only after server-local approval. +- **Files:** `packages/core/src/cli.ts`, `packages/core/src/remote/profile-store.ts`, `packages/core/src/remote/credential-store.ts`, `packages/core/test/remote-login-cli.test.ts`, `packages/core/test/cli.test.ts`. +- **Approach:** Remove the hidden Pairing Code prompt, `--code`, and `--code-stdin` from the supported self-hosted login path. Start pending login, keep returned pending refresh and completion proof in memory, display the code and operator command instructions, poll at server-provided intervals, refresh visible code before expiry, and complete after approval. Emit newline-delimited JSON events in `--json` mode with codes such as `pending_login_started`, `pending_login_waiting`, `pending_login_code_refreshed`, `pending_login_approved`, `pending_login_denied`, `pending_login_expired`, `pending_login_cancelled`, and `remote_profile_saved`. Keep pending refresh material in memory only. Handle Ctrl-C with best-effort cancel and clear local terminal messaging. +- **Test scenarios:** Human output shows a code and approval instructions; JSON mode emits stable events; delayed approval refreshes visible code and invalidates old material; denial, expiry, cancel, rate-limit, unsupported-host, insecure-URL, and network retry states have stable exits; final profile contains only final credentials and redacted metadata; stdout/stderr never include pending refresh, pending completion proof, access token, refresh token, or client secret; old code flags produce migration guidance rather than accepting secret-bearing argv. +- **Dependencies:** U3 and U4. +- **Verification:** `pnpm --filter @caplets/core test -- test/remote-login-cli.test.ts test/cli.test.ts`. + +### U6. Attach, logout, native, and state-specific recovery + +- **Goal:** Preserve current post-login attach behavior while making recovery precise and agent-compatible. +- **Files:** `packages/core/src/remote/selection.ts`, `packages/core/src/remote/profile-store.ts`, `packages/core/src/project-binding/errors.ts`, `packages/core/src/cli.ts`, `packages/core/src/native/remote.ts`, `packages/core/src/native/service.ts`, `packages/core/test/remote-selection.test.ts`, `packages/core/test/attach-cli.test.ts`, `packages/core/test/native-remote.test.ts`. +- **Approach:** Keep Remote Profiles authoritative and resolve auth-bearing runtime options at request time for CLI, MCP-launched attach, OpenCode, and Pi. Extend self-hosted refresh and attach errors so missing profiles, revoked credentials, refresh failures, host unreachable, insecure transport, identity mismatch, invalid URL, and workspace ambiguity produce distinct human and JSON recovery. Keep client self-revocation only as “revoke this credential” best-effort logout, not an admin revoke surface. +- **Test scenarios:** No profile points to `caplets remote login `; revoked profile points to relogin plus server operator approval; refresh failure distinguishes auth from network; host identity mismatch fails before tool execution; native event polling and reconnects resolve fresh credentials after rotation; `remote logout` revokes only the authenticated client and removes local profile even when host is unreachable; bearer token cannot administer pending flows or other clients. +- **Dependencies:** U1 and U2. +- **Verification:** `pnpm --filter @caplets/core test -- test/remote-selection.test.ts test/attach-cli.test.ts test/native-remote.test.ts`. + +### U7. Cloud workspace and legacy lifecycle alignment + +- **Goal:** Keep Cloud under the unified Remote Profile lifecycle without widening the self-hosted pending-login work into a Cloud rewrite. +- **Files:** `packages/core/src/remote/profiles.ts`, `packages/core/src/remote/profile-store.ts`, `packages/core/src/cloud-auth/*`, `packages/core/src/cli.ts`, `packages/core/test/remote-profiles.test.ts`, `packages/core/test/remote-login-cli.test.ts`, `packages/core/test/remote-selection.test.ts`. +- **Approach:** Constrain legacy Cloud compatibility so valid legacy credentials are migrated into, or surfaced through, the unified Remote Profile lifecycle before attach/status/logout treats the host as authenticated. Define workspace-aware status/logout behavior: `remote status` lists all profiles grouped by host, and URL-only Cloud logout either removes all profiles for that host or requires an explicit workspace selector before deleting one profile. Prefer deterministic disambiguation through the existing selected-workspace pointer when exactly one selected profile is available; fail with redacted choices when ambiguous. +- **Test scenarios:** Existing Cloud credentials migrate or appear in remote status; `cloud auth login/status/logout` compatibility still routes through Remote Profiles; multiple Cloud workspaces under one host list redacted workspace choices; ambiguous attach fails with stable recovery; logout semantics are covered for all-workspace or workspace-specific behavior chosen in implementation. +- **Dependencies:** U1. +- **Verification:** `pnpm --filter @caplets/core test -- test/remote-profiles.test.ts test/remote-login-cli.test.ts test/remote-selection.test.ts`. + +### U8. Documentation, setup output, and public examples + +- **Goal:** Make docs and generated setup output teach the pending-login trust sequence and prevent old secret-bearing setup from reappearing. +- **Files:** `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`, `README.md`, `apps/landing/src/data/landing.ts`, `docs/project-binding.md`, `docs/native-integrations.md`, `packages/opencode/README.md`, `packages/pi/README.md`, `packages/core/test/cli.test.ts`. +- **Approach:** Update supported setup docs to show: client runs `caplets remote login `, server-local operator approves the pending code, then MCP/native clients run `caplets attach ` with non-secret selectors only. Remove first-run examples that teach `remote host pair`, client-side `--code`, Basic Auth, `CAPLETS_REMOTE_TOKEN`, `CAPLETS_REMOTE_USER`, `CAPLETS_REMOTE_PASSWORD`, or credential-bearing `add-mcp --env`. Server-local operator docs may show `caplets remote host approve ` with shell-history and redaction guidance. Preserve non-secret selectors such as `CAPLETS_MODE` and `CAPLETS_REMOTE_URL` where they remain part of native launch contracts. +- **Test scenarios:** Docs/setup scans fail on old Pairing Code bootstrap in supported-path docs; generated setup output contains no remote token/password env vars; native docs mention Remote Login plus extension setup; troubleshooting recovery distinguishes approval-needed, revoked, unreachable, and workspace-ambiguous states; landing examples match the new flow. +- **Dependencies:** U5 and U6. +- **Verification:** `pnpm docs:check` and focused CLI/docs tests. + +### U9. Compatibility, rollout, and full verification + +- **Goal:** Land the change without breaking existing paired clients or hiding stale behavior behind docs drift. +- **Files:** `.changeset/*`, `packages/core/test/*`, affected docs from U8. +- **Approach:** Add a user-facing changeset. Preserve existing paired clients through state migration. Remove or deprecate old unexchanged Pairing Codes with clear recovery. Run focused tests before broad package checks, then repo gates. If config schema changes for pending policy knobs, run `pnpm schema:generate` and `pnpm schema:check`; otherwise do not touch generated schema. +- **Test scenarios:** End-to-end self-hosted login succeeds from start through attach; existing paired client can still refresh and attach; unexchanged legacy code does not work as a login path; old docs examples are absent; no secret material appears in JSON, logs, docs, setup output, or redacted status. +- **Dependencies:** U1-U8. +- **Verification:** Focused package tests, `pnpm --filter @caplets/core test`, `pnpm typecheck`, `pnpm lint`, `pnpm docs:check`, and `pnpm verify` before merge. + +--- + +## Acceptance Examples + +- AE1. **Covers PR1-PR5.** Given a self-hosted host with remote credentials enabled, when a user runs `caplets remote login `, then the CLI starts a pending flow, prints a visible operator code and approval instructions, waits, refreshes the code when needed, and stores a Remote Profile only after approval and completion. +- AE2. **Covers PR2-PR4, PR11-PR12.** Given a pending login refresh succeeds, when the old visible code or old pre-login refresh material is reused after the allowed retry grace, then the server rejects it and does not issue final credentials. +- AE3. **Covers PR6-PR8.** Given an operator approves a current code through `caplets remote host approve `, then the command shows metadata, records approval, and final credentials are created only when the original pending client completes the flow. +- AE4. **Covers PR6-PR7.** Given an attach-authenticated client calls any pending approval/list/deny operation remotely, then the request fails because v1 administration is server-local only. +- AE5. **Covers PR9-PR10.** Given a non-loopback HTTP URL without an explicit local-development override, when login or attach would send credential-bearing material, then Caplets refuses with redacted HTTPS/local-dev guidance. +- AE6. **Covers PR13.** Given an agent launches `caplets attach ` with a revoked self-hosted profile, then JSON output returns a stable revoked/relogin recovery code rather than a generic auth failure. +- AE7. **Covers PR14.** Given a user has two Cloud workspace profiles for the same Cloud host, when status/logout/attach are run, then the command either uses the selected-workspace pointer or fails with workspace-specific recovery rather than deleting or attaching ambiguously. +- AE8. **Covers PR15.** Given docs, generated setup output, and native integration examples are scanned, then supported paths contain no remote token/password env vars, Basic Auth instructions, old `remote host pair` bootstrap, or approval codes in agent/client argv/config. + +--- + +## Scope Boundaries + +**In scope now** + +- Self-hosted client-started pending login. +- Server-local approval/list/deny and paired-client list/revoke. +- Rotating pre-login refresh and final post-login credential lifecycle. +- Host identity metadata with conservative mismatch handling. +- State-specific attach recovery for CLI, MCP-launched attach, OpenCode, and Pi. +- Cloud Remote Profile lifecycle alignment needed for status/logout/attach consistency. +- Docs and setup changes required to remove remote secrets from supported paths. + +**Deferred for later** + +- Web-based self-hosted admin approval UI. +- Hardware-backed or sender-constrained final credentials. +- Seamless alias merging across multiple locators for the same self-hosted host. +- Distributed or multi-instance rate limiting beyond the current file-backed single-node store. +- Automatic rewriting of third-party agent configs that already contain old Caplets remote secrets. +- Fine-grained self-hosted roles beyond server-local approve/list/deny/revoke. +- Broad `caplets setup` deprecation or local MCP onboarding redesign unrelated to remote auth secrets; this plan only makes setup transitional for remote-secret handling. + +**Out of scope for this product direction** + +- Treating pre-login refresh material as attach bearer credentials. +- Using MCP/native agent config as the source of truth for Caplets remote secrets. +- Letting ordinary attach credentials approve pending logins or administer other clients. +- Replacing backend Caplet OAuth (`caplets auth login `) with Remote Login, or vice versa. +- Keeping Basic Auth as the long-term self-hosted remote auth model. + +--- + +## System-Wide Impact + +- **Auth state:** `RemoteServerCredentialStore` gains a new pending state machine and migration path while preserving paired-client state. +- **HTTP service:** `packages/core/src/serve/http.ts` gains unauthenticated but possession-bound pending-login routes that must not weaken protected MCP, attach, control, Project Binding, or OAuth callback behavior. +- **Remote Profiles:** New self-hosted profiles carry host identity and locator metadata, and Cloud workspace behavior must stay coherent with status/logout/attach. +- **CLI UX:** `remote login` becomes long-running for self-hosted hosts; `remote host pair` stops being the supported bootstrap; new server-local commands become the operator path. +- **Native/agent parity:** OpenCode, Pi, and MCP-launched attach continue to resolve profiles at request time. Any stale env-token or Basic Auth fallback would violate the plan. +- **Docs as security boundary:** Public docs, landing snippets, generated setup, and troubleshooting must not teach secret-bearing agent configs or old copied-code bootstrap. + +--- + +## Risks and Mitigations + +| Risk | Mitigation | +| -------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | +| Pending login becomes a remote admin surface. | Keep approval/list/deny/revoke server-local in v1 and add negative tests for attach credentials. | +| Pre-login refresh behaves like a long-lived bearer credential. | Keep it flow-bound, rotating, in-memory on the client, hashed at rest, and unusable for attach or profile storage. | +| Transient refresh response loss strands users. | Add short same-flow stale retry grace modeled on post-login refresh replay handling. | +| URL aliases create duplicate or wrong-host profiles. | Record host identity plus locator, fail closed on mismatch, and defer seamless alias merging. | +| Docs continue to teach old Pairing Code or env-secret setup. | Add docs/setup scans and update public examples in the same implementation unit as CLI changes. | +| Cloud cleanup widens scope. | Limit Cloud work to lifecycle consistency: migration/read-through, workspace disambiguation, status/logout/attach behavior. | +| Long-running CLI output is hard to automate. | Use stable NDJSON events for self-hosted `remote login --json` and test event codes. | + +--- + +## Sources and Research + +- `docs/brainstorms/2026-06-19-unified-remote-attach-auth-requirements.md` defines the reviewed product requirements and acceptance examples for unified Remote Login. +- `docs/plans/2026-06-19-001-feat-unified-remote-attach-auth-plan.md` is stale background for Cloud/profile/attach context; its self-hosted Pairing Code units are superseded by this plan. +- `packages/core/src/cli.ts`, `packages/core/src/remote/server-credential-store.ts`, `packages/core/src/serve/http.ts`, and `packages/core/src/remote/selection.ts` are the primary implementation seams. +- `docs/solutions/integration-issues/stale-remote-profile-credentials-refresh.md` requires request-time credential resolution for long-lived native remote clients. +- `docs/solutions/integration-issues/vault-cli-runtime-integration-fixes.md` supports treating secret-bearing values as runtime-owned state and enforcing privileged actions at the correct boundary. +- RFC 8628 informs polling defaults and pending statuses: default 5-second polling interval, `authorization_pending`, `slow_down`, and `expired_token`-style states. +- RFC 9700 informs refresh token rotation and replay detection for both post-login and pre-login rotating material. +- OAuth cross-device security guidance and current device-code phishing research reinforce approval-time client context, anti-relay caution, and short-lived visible codes. + +--- + +## Origin Traceability + +The R-numbers in this table refer to `docs/brainstorms/2026-06-19-unified-remote-attach-auth-requirements.md`, not the PR-numbers in this plan. + +| Plan unit | Origin requirements covered | +| --------- | ----------------------------------------------------------------------- | +| U1 | R5-R8, R22-R23, R26, R46-R47 | +| U2 | R9-R16, R22-R23, R40-R43 | +| U3 | R8-R16, R18, R20, R46-R47 | +| U4 | R17-R19, R27-R28, R43 | +| U5 | R1-R4, R9-R16, R20-R21 | +| U6 | R4, R22-R28, R40-R47 | +| U7 | R29-R33 | +| U8 | R34-R38, R40-R45; R39 only as transitional remote-secret setup behavior | +| U9 | R18, R21-R23, R40-R47 | diff --git a/docs/solutions/developer-experience/self-hosted-pending-remote-login-and-attach-positional-url.md b/docs/solutions/developer-experience/self-hosted-pending-remote-login-and-attach-positional-url.md new file mode 100644 index 00000000..5852ecec --- /dev/null +++ b/docs/solutions/developer-experience/self-hosted-pending-remote-login-and-attach-positional-url.md @@ -0,0 +1,352 @@ +--- +title: "Self-Hosted Remote Login Uses Pending Approval and Positional Attach URLs" +date: 2026-06-23 +category: developer-experience +module: "remote attach cli" +problem_type: developer_experience +component: tooling +severity: medium +applies_when: + - "Designing remote login or attach flows that should keep credentials out of agent configuration" + - "Migrating from operator-minted self-hosted Pairing Codes to client-started host approval" + - "Changing a CLI flag-based primary path to a positional argument while preserving compatibility" +symptoms: + - "Self-hosted Remote Login required an operator to mint and copy a Pairing Code before the client could start login" + - "`caplets attach --remote-url ` made the common remote attach path look like an advanced option-heavy mode" + - "Remote agent configuration examples risked teaching users to handle credential-like bootstrap material in argv, environment variables, or config" +root_cause: missing_workflow_step +resolution_type: workflow_improvement +related_components: + - authentication + - documentation + - testing_framework +tags: + - remote-login + - remote-attach + - pairing + - cli-ergonomics + - remote-profiles + - self-hosted + - agent-config +--- + +# Self-Hosted Remote Login Uses Pending Approval and Positional Attach URLs + +## Context + +Self-hosted Remote Login and Remote Attach both had the right long-term product direction — Caplets-owned Remote Profiles instead of credentials in agent configs — but the operator experience still taught the wrong shape. + +The old self-hosted login path started from the host. An operator minted a Pairing Code, copied it to a client, and the client exchanged it for final credentials: + +```sh +caplets remote host pair --host-url https://caplets.example.com/caplets --json +caplets remote login https://caplets.example.com/caplets --code +``` + +That made a human carry credential-like bootstrap material and made the supported flow depend on a hidden prompt. During implementation, a RED test timed out at that hidden Pairing Code prompt, which pushed the workflow toward client-started pending login instead of trying to polish the old prompt (session history). + +Remote Attach had a smaller but related ergonomics problem. The common command looked like an advanced option mode: + +```sh +caplets attach --remote-url https://caplets.example.com/caplets +``` + +The old shape was especially awkward for generated agent configuration. A prior Codex setup path had baked a resolved remote URL into an MCP command as `caplets attach --remote-url `, which captured a point-in-time value and made setup more fragile than using runtime-owned Caplets resolution (session history). + +The resulting branch coupled two decisions: + +- self-hosted Remote Login is client-started and server-approved; +- `caplets attach ` is the primary Remote Attach command, while `--remote-url` remains a hidden compatibility alias for old configs. + +## Guidance + +When a remote CLI flow requires both trust establishment and later agent subprocess execution, make Caplets own the credential lifecycle. The client should initiate trust, the server-local operator should approve pending metadata, final credentials should land in Remote Profiles only after approval and possession proof, and agent configs should contain stable selectors such as `caplets attach ` rather than copied secrets or bootstrap codes. + +### Make the common attach shape positional + +Users attach to a URL, so the URL should be the primary argument: + +```sh +caplets attach https://caplets.example.com/caplets +``` + +Keep the old option only as hidden compatibility while new help, docs, tests, and generated examples converge on the simpler form: + +```ts +program + .command(cliCommands.attach) + .description("Start a remote-backed Caplets MCP server.") + .argument("[url]", "remote Caplets service base URL") + .addOption( + new Option( + "--remote-url ", + "legacy alias for the remote Caplets service base URL", + ).hideHelp(), + ); +``` + +Centralize the dual-input merge rule and reject ambiguous invocations: + +```ts +function attachRemoteUrlFromArgs( + positionalUrl: string | undefined, + legacyRemoteUrl: string | undefined, +): string | undefined { + if (positionalUrl && legacyRemoteUrl && positionalUrl !== legacyRemoteUrl) { + throw new CapletsError( + "REQUEST_INVALID", + "Pass either attach URL or --remote-url, not both. Use caplets attach for new configs.", + ); + } + return positionalUrl ?? legacyRemoteUrl; +} +``` + +This gives old scripts a narrow path to keep working without leaving users to debug which URL won. + +### Start self-hosted login from the client + +The replacement self-hosted flow starts where the credential will be stored: + +```sh +caplets remote login https://caplets.example.com/caplets +``` + +The client creates pending server state, prints only operator-facing material, polls for approval, and completes only after both approval and original client possession proof are present. The host operator approves from server-owned state: + +```sh +caplets remote host logins +caplets remote host approve --yes +``` + +The client output should be useful to the operator but not reveal possession material: + +```text +Remote Login Code: cap_login_... +Code fingerprint: ... +Approve from the host with caplets remote host approve cap_login_... --yes +``` + +Approval stays host-local. The HTTP surface supports start, poll, refresh, complete, and cancel; it does not expose a remote approval route. + +### Separate visible approval codes from possession material + +Pending login state should distinguish the values used by different actors: + +```ts +const operatorCode = `cap_login_${randomToken(5)}`; +const pendingRefreshSecret = `cap_pending_refresh_${randomToken(32)}`; +const pendingCompletionSecret = `cap_pending_complete_${randomToken(32)}`; +``` + +The operator code is visible and approval-only. The pending refresh and completion secrets are client-held possession material. The server stores hashes, not plaintext: + +```ts +state.pendingLogins.push({ + flowId, + operatorCodeHash: hashSecret(operatorCode), + pendingRefreshHash: hashSecret(pendingRefreshSecret), + pendingCompletionHash: hashSecret(pendingCompletionSecret), + operatorCodeFingerprint: pendingOperatorCodeFingerprint(operatorCode), + status: "pending", +}); +``` + +Tests should assert that this separation holds: + +```ts +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); +``` + +The host can use a non-secret fingerprint to correlate what the client displayed with what `remote host logins` shows, without exposing refresh or completion material. + +### Convert old Pairing Code entry points into migration guidance + +Do not silently keep the old Pairing Code bootstrap as a supported self-hosted workflow. Old entry points should return explicit guidance and avoid minting new login material: + +```ts +const guidance = + "Self-hosted Pairing Code bootstrap is no longer supported. Run caplets remote login from the client, then approve the pending login with caplets remote host logins and caplets remote host approve from the host."; +``` + +Likewise, `remote login --code` should reject before network I/O so users get deterministic migration guidance rather than an inconsistent partial legacy path: + +```ts +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.`, + ); +} +``` + +### Treat expiry, replay, cancellation, and races as protocol behavior + +Pending login is not complete when the happy path works once. It needs deterministic behavior around the failure windows that matter for auth flows: + +- visible operator codes expire independently from the longer pending flow; +- refresh rotates the visible code and refresh secret while preserving the completion proof; +- refresh and completion responses can be replayed briefly to tolerate lost responses; +- cancellation is possession-based and can cancel approved-but-unexchanged flows; +- the client polls before refreshing so it does not rotate away from an approval it could observe; +- if refresh races with approval, the client does one final poll before surfacing failure. + +Those were not theoretical edges. Review found refresh-before-poll could race with approval, interrupt handling could leave sleeps or in-flight requests uncancelled, source hints and host metadata had abuse-control gaps, and the initial profile save derived host identity from CLI input rather than server-reported credentials (session history). + +## Why This Matters + +The old workflow asked the server operator to mint and transfer a bearer-like bootstrap artifact before the client could even start login. That blurred who owned each part of trust establishment: + +- the client is the party that wants credentials; +- the host owns the authorization decision; +- a visible operator code should prove approval intent, not become attach credential material; +- the client should prove possession before final credentials are issued; +- final credentials should be stored in Caplets-owned Remote Profiles, not in shell history, agent configs, or long-lived process arguments. + +The new workflow maps each responsibility to the right surface. A user or automation can configure an agent with a stable command such as: + +```toml +args = ["attach", "https://caplets.example.com/caplets"] +``` + +That command remains local mediation. It can resolve Remote Profiles, refresh credentials, apply project binding or overlays, and keep secret-bearing state inside Caplets-owned runtime paths rather than pushing credentials into the agent harness. Earlier Codex setup exploration compared direct remote MCP with local `caplets attach`; the durable decision was to keep local attach as the mediation point when Caplets-specific credential and config behavior is needed (session history). + +The attach ergonomics change is what makes that safer shape easy to teach. Hiding `--remote-url` from help lets existing scripts survive while making new docs and generated configs converge on `caplets attach `. + +## When to Apply + +Use this pattern when an established CLI or auth workflow has these traits: + +- there is a clearer command shape users should adopt going forward; +- existing scripts or generated configs may still use the old shape; +- the old shape can remain harmless if hidden from help and guarded by an explicit conflict rule; +- the workflow crosses a trust boundary, especially where operator approval and client possession are different concepts; +- bootstrap material previously moved through humans, docs, terminals, or agent configuration; +- the replacement can separate visible approval codes, client-held pending material, server-owned pending state, and final credentials; +- expiry, cancellation, replay, and race behavior can be covered with deterministic tests. + +Do not use hidden compatibility as an excuse to keep old UX alive indefinitely. The alias is for old automation; help, docs, examples, tests, and generated configs should teach the new path. + +## Examples + +### Attach before and after + +Before: + +```sh +caplets attach --remote-url https://caplets.example.com/caplets +``` + +After: + +```sh +caplets attach https://caplets.example.com/caplets +``` + +Compatibility still works, but is hidden from help: + +```sh +caplets attach --remote-url https://caplets.example.com/caplets +``` + +The help test anchors the public shape: + +```ts +expect(out.join("")).toContain("Usage: caplets attach [options] [url]"); +expect(out.join("")).not.toContain("--remote-url "); +``` + +Conflicting inputs are rejected: + +```sh +caplets attach https://caplets.example.com/caplets \ + --remote-url https://other.example.com/caplets +``` + +Expected error: + +```text +Pass either attach URL or --remote-url, not both. Use caplets attach for new configs. +``` + +### Self-hosted Remote Login before and after + +Before: + +```sh +caplets remote host pair --host-url https://caplets.example.com/caplets --json +caplets remote login https://caplets.example.com/caplets --code +``` + +After: + +```sh +caplets remote login https://caplets.example.com/caplets +# client prints cap_login_... and waits + +caplets remote host logins +caplets remote host approve --yes +``` + +The CLI tests assert the client prints an approvable host command and a code fingerprint while avoiding possession-secret leaks: + +```ts +expect(out.join("")).toContain( + `Approve from the host with caplets remote host approve ${pending?.operatorCode} --yes`, +); +expect(out.join("")).toContain(`Code fingerprint: ${pending?.operatorCodeFingerprint}`); +expect(out.join("")).not.toContain(pending?.pendingRefreshSecret ?? "missing"); +expect(out.join("")).not.toContain(pending?.pendingCompletionSecret ?? "missing"); +``` + +### Pending refresh and replay + +Visible operator code expiry is independent from flow expiry. After the visible code expires, approval fails, but the original client can refresh to continue the same pending flow: + +```ts +expect(() => + store.approvePendingLogin({ + operatorCode: pending.operatorCode, + now: new Date("2026-06-19T12:10:01.000Z"), + }), +).toThrow(/code has expired/u); + +const refreshed = store.refreshPendingLogin({ + flowId: pending.flowId, + pendingRefreshSecret: pending.pendingRefreshSecret, + pendingCompletionSecret: pending.pendingCompletionSecret, + now: new Date("2026-06-19T12:10:02.000Z"), +}); + +expect(refreshed.operatorCode).not.toBe(pending.operatorCode); +expect(refreshed.pendingRefreshSecret).not.toBe(pending.pendingRefreshSecret); +``` + +The implementation also stores short replay material for refresh and completion responses so a lost response does not wedge the flow after the server has already rotated or issued material. + +### Verification focus + +The targeted verification for this cluster covered attach CLI behavior, pending-login CLI behavior, server credential state, and HTTP routes: + +```sh +pnpm --filter @caplets/core test -- \ + test/attach-cli.test.ts \ + test/remote-login-cli.test.ts \ + test/remote-pairing.test.ts \ + test/serve-http.test.ts +pnpm --filter @caplets/core typecheck +``` + +Session history also reported a later full `pnpm verify` and pre-push verification passing after PR review fixes (session history). + +## Related + +- Requirements source: [Unified Remote Attach Auth requirements](../../brainstorms/2026-06-19-unified-remote-attach-auth-requirements.md). +- Related solution: [Native Remote Clients Refresh Runtime Credentials Before Polls and Reconnects](../integration-issues/stale-remote-profile-credentials-refresh.md). It covers the same Remote Profile/runtime-owned credential direction for long-lived native clients. +- Related solution: [Vault CLI, Runtime, and Remote Paths Handle $vault Refs Consistently](../integration-issues/vault-cli-runtime-integration-fixes.md). It covers the same principle that secret-bearing values belong in runtime-owned state, not generic remote-control or agent config boundaries. +- User-facing docs: [`apps/docs/src/content/docs/remote-attach.mdx`](../../../apps/docs/src/content/docs/remote-attach.mdx) and [`README.md`](../../../README.md). +- Release notes: [self-hosted pending Remote Login](../../../.changeset/self-hosted-pending-remote-login.md) and [attach positional URL](../../../.changeset/attach-positional-url.md). +- Historical plan: [`docs/plans/2026-06-19-001-feat-unified-remote-attach-auth-plan.md`](../../plans/2026-06-19-001-feat-unified-remote-attach-auth-plan.md) records the earlier `--remote-url` and operator-minted Pairing Code shape and now carries a supersession note for those details. diff --git a/packages/core/src/cli.ts b/packages/core/src/cli.ts index bee635e6..5147f81a 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"; @@ -250,6 +250,12 @@ function collectValues(value: string, previous: string[]): string[] { return [...previous, value]; } +const HIDDEN_INPUT_PROMPT_LABELS = { + vaultValue: "Value: ", +} as const; + +export const readHiddenInputForTest = readHiddenInput; + function remoteProfileStore( authDir: string | undefined, env: NodeJS.ProcessEnv | Record, @@ -257,6 +263,19 @@ function remoteProfileStore( return createRemoteProfileStore({ authDir, env }); } +function attachRemoteUrlFromArgs( + positionalUrl: string | undefined, + legacyRemoteUrl: string | undefined, +): string | undefined { + if (positionalUrl && legacyRemoteUrl && positionalUrl !== legacyRemoteUrl) { + throw new CapletsError( + "REQUEST_INVALID", + "Pass either attach URL or --remote-url, not both. Use caplets attach for new configs.", + ); + } + return positionalUrl ?? legacyRemoteUrl; +} + function remoteServerCredentialStore( statePath: string | undefined, env: NodeJS.ProcessEnv | Record, @@ -381,45 +400,33 @@ async function loginCloudRemoteProfile( }); } -async function pairingCodeFromOptions( - options: { code?: string; codeStdin?: boolean }, - readStdin: (() => Promise) | undefined, - writeErr: (value: string) => void, +async function readHiddenInput( + label: string, + options: { + input?: NodeJS.ReadableStream; + output?: Pick; + } = {}, ): 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 }); + const input = options.input ?? process.stdin; + const output = options.output ?? process.stdout; + output.write(label); + const hiddenOutput = new HiddenPromptOutput(output, { echoFirstChunk: false }); + const readline = createInterface({ input, output: hiddenOutput, terminal: true }); try { - const code = (await readline.question("Pairing Code: ")).trim(); - if (code) return code; + return await readline.question(""); } finally { readline.close(); - process.stdout.write("\n"); + output.write("\n"); } - throw new CapletsError( - "REQUEST_INVALID", - "Pairing Code is required for self-hosted Remote Login.", - ); } class HiddenPromptOutput extends Writable { private wrotePrompt = false; - constructor(private readonly output: NodeJS.WriteStream) { + constructor( + private readonly output: Pick, + private readonly options: { echoFirstChunk?: boolean } = { echoFirstChunk: true }, + ) { super(); } @@ -428,7 +435,7 @@ class HiddenPromptOutput extends Writable { _encoding: BufferEncoding, callback: (error?: Error | null) => void, ): void { - if (!this.wrotePrompt) { + if (this.options.echoFirstChunk !== false && !this.wrotePrompt) { this.output.write(chunk); this.wrotePrompt = true; } @@ -445,6 +452,7 @@ async function readAllStdin(): Promise { } type RemoteLoginCredentialsResponse = { + hostUrl?: string | undefined; clientId: string; clientLabel: string; accessToken: string; @@ -453,6 +461,17 @@ type RemoteLoginCredentialsResponse = { expiresAt?: string | undefined; }; +type PendingRemoteLoginStartResponse = { + flowId: string; + operatorCode: string; + operatorCodeFingerprint?: string | undefined; + pendingRefreshSecret: string; + pendingCompletionSecret: string; + codeExpiresAt: string; + flowExpiresAt: string; + intervalSeconds: number; +}; + async function parseRemoteLoginCredentials( response: Response, ): Promise { @@ -472,6 +491,7 @@ async function parseRemoteLoginCredentials( ); } return { + ...(typeof record.hostUrl === "string" ? { hostUrl: record.hostUrl } : {}), clientId: record.clientId, clientLabel: typeof record.clientLabel === "string" ? record.clientLabel : "Caplets CLI", accessToken: record.accessToken, @@ -481,6 +501,279 @@ 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)) { + 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.codeExpiresAt !== "string" || + typeof record.flowExpiresAt !== "string" + ) { + throw new CapletsError( + "DOWNSTREAM_PROTOCOL_ERROR", + "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, + ...(typeof record.operatorCodeFingerprint === "string" + ? { operatorCodeFingerprint: record.operatorCodeFingerprint } + : {}), + pendingRefreshSecret: record.pendingRefreshSecret, + 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; + signal?: AbortSignal | 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."); + let pending = await parsePendingRemoteLoginStart(start); + if (input.json) { + input.writeOut( + `${JSON.stringify({ + code: "pending_login_started", + flowId: pending.flowId, + operatorCode: pending.operatorCode, + operatorCodeFingerprint: pending.operatorCodeFingerprint, + codeExpiresAt: pending.codeExpiresAt, + flowExpiresAt: pending.flowExpiresAt, + })}\n`, + ); + } else { + input.writeOut(`Remote Login Code: ${pending.operatorCode}\n`); + if (pending.operatorCodeFingerprint) { + input.writeOut(`Code fingerprint: ${pending.operatorCodeFingerprint}\n`); + } + input.writeOut( + `Approve from the host with caplets remote host approve ${pending.operatorCode} --yes\n`, + ); + } + + const intervalMs = numberEnv( + input.env.CAPLETS_REMOTE_LOGIN_POLL_INTERVAL_MS, + pending.intervalSeconds * 1_000, + ); + try { + while (true) { + const poll = await fetchPendingRemoteLoginStatus(fetchImpl, baseUrl, pending, input.signal); + if (!poll.ok) throw new CapletsError("AUTH_FAILED", "Remote Login pending poll failed."); + const status = await parsePendingRemoteLoginStatus(poll); + if (status === "approved") { + if (input.json) { + input.writeOut( + `${JSON.stringify({ code: "pending_login_approved", flowId: pending.flowId })}\n`, + ); + } + 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}.`); + } + 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, + }), + ...(input.signal ? { signal: input.signal } : {}), + }); + if (!refresh.ok) { + const retryPoll = await fetchPendingRemoteLoginStatus(fetchImpl, baseUrl, pending); + if (retryPoll.ok && (await parsePendingRemoteLoginStatus(retryPoll)) === "approved") { + if (input.json) { + input.writeOut( + `${JSON.stringify({ code: "pending_login_approved", flowId: pending.flowId })}\n`, + ); + } + break; + } + 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, + operatorCodeFingerprint: pending.operatorCodeFingerprint, + codeExpiresAt: pending.codeExpiresAt, + flowExpiresAt: pending.flowExpiresAt, + })}\n`, + ); + } else { + input.writeOut(`Remote Login Code refreshed: ${pending.operatorCode}\n`); + if (pending.operatorCodeFingerprint) { + input.writeOut(`Code fingerprint: ${pending.operatorCodeFingerprint}\n`); + } + } + } + await sleep(intervalMs, input.signal); + } + + const complete = await completePendingRemoteLogin(fetchImpl, baseUrl, pending, input.signal); + if (!complete.ok) + throw new CapletsError("AUTH_FAILED", "Remote Login pending complete failed."); + return parseRemoteLoginCredentials(complete); + } catch (error) { + if (input.signal?.aborted || isAbortError(error)) { + await cancelPendingRemoteLogin(fetchImpl, baseUrl, pending); + 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."); + } + throw error; + } +} + +async function completePendingRemoteLogin( + fetchImpl: typeof fetch, + baseUrl: URL, + pending: PendingRemoteLoginStartResponse, + signal?: AbortSignal | undefined, +): Promise { + try { + return await fetchImpl( + appendBasePath(baseUrl, "v1/remote/login/complete"), + pendingRemoteLoginCompletionRequest(pending, signal), + ); + } catch { + return fetchImpl( + appendBasePath(baseUrl, "v1/remote/login/complete"), + pendingRemoteLoginCompletionRequest(pending), + ); + } +} + +function pendingRemoteLoginCompletionRequest( + pending: PendingRemoteLoginStartResponse, + signal?: AbortSignal | undefined, +): RequestInit { + return { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + flowId: pending.flowId, + pendingCompletionSecret: pending.pendingCompletionSecret, + }), + ...(signal ? { signal } : {}), + }; +} + +async function fetchPendingRemoteLoginStatus( + fetchImpl: typeof fetch, + baseUrl: URL, + pending: PendingRemoteLoginStartResponse, + signal?: AbortSignal | undefined, +): Promise { + return fetchImpl(appendBasePath(baseUrl, "v1/remote/login/poll"), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + flowId: pending.flowId, + pendingCompletionSecret: pending.pendingCompletionSecret, + }), + ...(signal ? { signal } : {}), + }); +} + +async function cancelPendingRemoteLogin( + fetchImpl: typeof fetch, + baseUrl: URL, + pending: PendingRemoteLoginStartResponse, +): Promise { + 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); +} + +function isAbortError(error: unknown): boolean { + return ( + (error instanceof Error && error.name === "AbortError") || + (typeof DOMException !== "undefined" && + error instanceof DOMException && + error.name === "AbortError") + ); +} + async function revokeSelfHostedRemoteClient( remoteUrl: string, accessToken: string, @@ -611,9 +904,36 @@ function createSetupPromptHandle( }; } -async function sleep(ms: number): Promise { - if (ms <= 0) return; - await new Promise((resolve) => setTimeout(resolve, ms)); +async function sleep(ms: number, signal?: AbortSignal | undefined): Promise { + if (ms <= 0 || signal?.aborted) return; + await new Promise((resolve) => { + const timeout = setTimeout(done, ms); + const abort = () => done(); + function done() { + clearTimeout(timeout); + signal?.removeEventListener("abort", abort); + resolve(); + } + signal?.addEventListener("abort", abort, { once: true }); + }); +} + +function cliInterruptSignal(existing: AbortSignal | undefined): { + signal: AbortSignal | undefined; + dispose: () => void; +} { + if (existing) return { signal: existing, dispose: () => {} }; + const controller = new AbortController(); + const abort = () => controller.abort(); + process.once("SIGINT", abort); + process.once("SIGTERM", abort); + return { + signal: controller.signal, + dispose: () => { + process.off("SIGINT", abort); + process.off("SIGTERM", abort); + }, + }; } export function createProgram(io: CliIO = {}): Command { @@ -983,11 +1303,17 @@ export function createProgram(io: CliIO = {}): Command { program .command(cliCommands.attach) .description("Start a remote-backed Caplets MCP server.") + .argument("[url]", "remote Caplets service base URL") .option("--transport ", "server transport: stdio or http") .option("--host ", "HTTP bind host") .option("--port ", "HTTP bind port") .option("--path ", "HTTP service base path") - .option("--remote-url ", "remote Caplets service base URL") + .addOption( + new Option( + "--remote-url ", + "legacy alias for the remote Caplets service base URL", + ).hideHelp(), + ) .option("--workspace ", "hosted Cloud workspace ID or slug") .option( "--allow-unauthenticated-http", @@ -999,23 +1325,28 @@ export function createProgram(io: CliIO = {}): Command { .option("--once", "validate Project Binding once and exit") .option("--project-root ", "test-only project root override") .action( - async (options: { - remoteUrl?: string; - transport?: string; - host?: string; - port?: string; - path?: string; - workspace?: string; - allowUnauthenticatedHttp?: boolean; - trustProxy?: boolean; - json?: boolean; - verbose?: boolean; - once?: boolean; - projectRoot?: string; - }) => { + async ( + url: string | undefined, + options: { + remoteUrl?: string; + transport?: string; + host?: string; + port?: string; + path?: string; + workspace?: string; + allowUnauthenticatedHttp?: boolean; + trustProxy?: boolean; + json?: boolean; + verbose?: boolean; + once?: boolean; + projectRoot?: string; + }, + ) => { try { + const remoteUrl = attachRemoteUrlFromArgs(url, options.remoteUrl); const attachOptions = { ...options, + ...(remoteUrl ? { remoteUrl } : {}), ...(io.fetch ? { fetch: io.fetch } : {}), ...(io.authDir ? { authDir: io.authDir } : {}), }; @@ -1100,8 +1431,8 @@ export function createProgram(io: CliIO = {}): Command { .option("--workspace ", "Cloud workspace ID or slug to select") .option("--client-label