diff --git a/.changeset/native-remote-background-auth.md b/.changeset/native-remote-background-auth.md new file mode 100644 index 00000000..be625259 --- /dev/null +++ b/.changeset/native-remote-background-auth.md @@ -0,0 +1,5 @@ +--- +"@caplets/core": patch +--- + +Refresh native remote credentials before background polling, attach event reconnects, tool invokes, and stale-manifest retries so long-lived Remote Profile integrations do not reuse expired authorization headers. diff --git a/.changeset/unified-remote-login.md b/.changeset/unified-remote-login.md new file mode 100644 index 00000000..cf9f4d5b --- /dev/null +++ b/.changeset/unified-remote-login.md @@ -0,0 +1,8 @@ +--- +"@caplets/core": minor +"caplets": minor +"@caplets/opencode": minor +"@caplets/pi": minor +--- + +Replace self-hosted remote env-token and Basic Auth setup with unified Remote Login profiles. Remote attach, hosted Cloud, OpenCode, and Pi now resolve Caplets-owned credentials from `caplets remote login ` and use `CAPLETS_REMOTE_URL` only as a non-secret selector. diff --git a/CONCEPTS.md b/CONCEPTS.md index 6debeae8..869b4be7 100644 --- a/CONCEPTS.md +++ b/CONCEPTS.md @@ -53,3 +53,29 @@ Recovery References are separate from session handles. Possessing a session hand ### Progressive Exposure The Caplets exposure mode where agents discover and call backend operations through a small set of wrapper tools instead of receiving every downstream operation as a separate top-level tool. + +## Remote Attach + +### Remote Attach + +The process where a local agent-facing Caplets runtime connects to a trusted Caplets host and exposes that host's capabilities through local or native agent integrations. + +Remote Attach uses Remote Profiles for trust and credentials. Long-lived attach traffic treats credentials as refreshable runtime state rather than fixed startup state. + +### Remote Login + +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. + +### Pairing Code + +A short-lived, one-time code minted by a self-hosted Caplets host and exchanged by a client during Remote Login. + +Pairing Codes are bootstrap material only. They are not reusable client credentials. + +### Remote Profile + +The stored local record for a trusted Caplets host, including the normalized host URL, host kind, selected workspace when applicable, and redacted credential status. + +Remote Profiles are the source of truth for request credentials. Long-lived clients resolve current profile state when sending remote traffic instead of copying credentials into agent config or one-time startup state. diff --git a/README.md b/README.md index adf82fdd..67a4525f 100644 --- a/README.md +++ b/README.md @@ -145,15 +145,14 @@ Native integrations expose `caplets__code_mode` for multi-step TypeScript workfl generated `caplets.` handles. Progressive exposure adds `caplets__` tools; direct exposure adds operation-level tools such as `caplets____`. -Remote mode is available with `caplets attach`, self-hosted HTTP service settings, or -Caplets Cloud auth: +Remote mode uses Remote Login for both self-hosted Caplets and Caplets Cloud. Trust the +host once, then launch attach or a native integration with only non-secret selectors: ```sh -export CAPLETS_MODE=remote -export CAPLETS_REMOTE_URL=https://caplets.example.com/caplets -export CAPLETS_REMOTE_TOKEN=... +caplets remote login https://caplets.example.com/caplets +caplets attach --remote-url https://caplets.example.com/caplets -caplets cloud auth login +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 81df844e..c4658ed4 100644 --- a/apps/docs/src/content/docs/remote-attach.mdx +++ b/apps/docs/src/content/docs/remote-attach.mdx @@ -14,7 +14,7 @@ Caplets native integrations use the same mode names as `caplets attach`: | -------- | ------------------------------------------------------------------------------------------------------------------------- | | `local` | The agent should start Caplets against local user and project config. | | `remote` | The agent should connect to a self-hosted Caplets service. | -| `cloud` | The agent should connect to Caplets Cloud with explicit `CAPLETS_REMOTE_*` or `CAPLETS_CLOUD_*` environment values. | +| `cloud` | The agent should connect to Caplets Cloud through a saved Remote Profile. | | `auto` | Caplets should use Cloud for Cloud URLs, self-hosted remote for non-Cloud URLs, and local mode when no remote URL is set. | ## MCP client config @@ -24,7 +24,7 @@ Codex: ```toml [mcp_servers.caplets] command = "caplets" -args = ["attach"] +args = ["attach", "--remote-url", "https://caplets.example.com/caplets"] ``` Generic MCP JSON: @@ -34,40 +34,45 @@ Generic MCP JSON: "mcpServers": { "caplets": { "command": "caplets", - "args": ["attach"] + "args": ["attach", "--remote-url", "https://caplets.example.com/caplets"] } } } ``` -## Environment +## Remote Login -For a self-hosted remote: +For a self-hosted remote, first create a short-lived Pairing Code on the server: ```sh -export CAPLETS_MODE=remote -export CAPLETS_REMOTE_URL=https://caplets.example.com/caplets -export CAPLETS_REMOTE_TOKEN=... +caplets remote host pair --host-url 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`. -caplets attach +Then log in once on the client and configure the agent to launch attach: + +```sh +caplets remote login https://caplets.example.com/caplets +caplets attach --remote-url https://caplets.example.com/caplets ``` -Use `CAPLETS_REMOTE_USER` and `CAPLETS_REMOTE_PASSWORD` instead of -`CAPLETS_REMOTE_TOKEN` when the remote uses Basic Auth. +Enter the Pairing Code at the hidden prompt from `caplets remote login`. Use +`--code-stdin` for automation instead of putting Pairing Codes in shell history. -For Caplets Cloud native integrations, set the Cloud URL plus a workspace and token: +For Caplets Cloud native integrations, log in once and then set the Cloud URL selector: ```sh +caplets remote login https://cloud.caplets.dev export CAPLETS_MODE=cloud export CAPLETS_REMOTE_URL=https://cloud.caplets.dev -export CAPLETS_REMOTE_WORKSPACE=... -export CAPLETS_REMOTE_TOKEN=... opencode ``` -Saved Cloud auth from `caplets cloud auth login` is used by the `caplets attach` CLI path. -Native integrations do not read the saved auth store directly. +Saved Remote Profiles are used by `caplets attach` and native integrations. Agent configs +should not contain Caplets remote tokens or passwords. Run a finite Project Binding smoke path before wiring an agent: diff --git a/apps/docs/src/content/docs/troubleshooting.mdx b/apps/docs/src/content/docs/troubleshooting.mdx index de3eb8dd..4a507622 100644 --- a/apps/docs/src/content/docs/troubleshooting.mdx +++ b/apps/docs/src/content/docs/troubleshooting.mdx @@ -123,24 +123,18 @@ Expected symptom: remote mode starts, but attach cannot reach the runtime or aut Confirm the remote environment: ```sh -export CAPLETS_MODE=remote -export CAPLETS_REMOTE_URL=https://caplets.example.com/caplets -export CAPLETS_REMOTE_TOKEN=... +caplets remote login https://caplets.example.com/caplets caplets doctor -caplets attach +caplets attach --remote-url https://caplets.example.com/caplets ``` -If the remote requires auth, set either `CAPLETS_REMOTE_TOKEN` or -`CAPLETS_REMOTE_USER` plus `CAPLETS_REMOTE_PASSWORD`. If the URL belongs to Caplets Cloud, -check whether your environment expects `CAPLETS_REMOTE_*` or `CAPLETS_CLOUD_*` values and -keep the family consistent for that shell. Cloud-native integrations also need the selected -workspace: +If the URL belongs to Caplets Cloud, log in through Remote Login and keep the Cloud URL +selector consistent for that shell: ```sh +caplets remote login https://cloud.caplets.dev export CAPLETS_MODE=cloud export CAPLETS_REMOTE_URL=https://cloud.caplets.dev -export CAPLETS_REMOTE_WORKSPACE=... -export CAPLETS_REMOTE_TOKEN=... caplets attach --once ``` 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 new file mode 100644 index 00000000..fcbcf933 --- /dev/null +++ b/docs/brainstorms/2026-06-19-unified-remote-attach-auth-requirements.md @@ -0,0 +1,199 @@ +--- +date: 2026-06-19 +topic: unified-remote-attach-auth +--- + +# 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. + +--- + +## 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. + +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. + +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. + +--- + +## 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. +- **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. + +```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"] +``` + +--- + +## Actors + +- A1. **Self-hosted server operator.** Runs the Caplets HTTP service, creates pairing codes, 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. +- A5. **Caplets Cloud.** Implements the same remote login contract while preserving hosted workspace selection and refresh behavior. + +--- + +## Requirements + +**Unified remote login** + +- 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** + +- 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** + +- 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. + +**Agent setup and docs** + +- 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. + +**Basic Auth removal** + +- 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. + +--- + +## Key Flows + +- F1. Self-hosted host pairing + - **Trigger:** A server operator 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 + +- F2. 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 + +- F3. 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 + +- F4. 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 + +- 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 + +--- + +## 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. + +--- + +## Success Criteria + +- 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. +- The install docs no longer require users to understand `CAPLETS_REMOTE_TOKEN`, `CAPLETS_REMOTE_USER`, or `CAPLETS_REMOTE_PASSWORD` for the normal path. + +--- + +## Scope Boundaries + +**Deferred for later** + +- Hardware-backed or sender-constrained credentials beyond the best available software credential model. +- 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. + +**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. +- Replacing backend Caplet OAuth with remote host login. + +--- + +## Dependencies / Assumptions + +- `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. +- Caplets Cloud can expose the existing hosted login behavior through the unified remote command namespace. + +--- + +## Sources / Research + +- `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. +- `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. diff --git a/docs/ideation/2026-06-18-open-ideation.html b/docs/ideation/2026-06-18-open-ideation.html new file mode 100644 index 00000000..0bbdc350 --- /dev/null +++ b/docs/ideation/2026-06-18-open-ideation.html @@ -0,0 +1,802 @@ + + + + + Ideation: Caplets Surprise-Me Features + + + + + + + +
+
+
Ideation
+

Caplets Surprise-Me Features

+

+ Open-ended feature ideation grounded in the current Caplets repo, recent plans, shipped + docs, and external MCP platform direction. +

+
+ DATE 2026-06-18TOPIC open-ideationMODE repo-grounded +
+
+ 20 raw7 ranked8 cutaxes skipped +
+
+ +
+
Grounding Context
+

Codebase Context

+

+ Caplets gives coding agents a compact Code Mode surface for MCP servers, APIs, and + commands. The repo states the core product problem plainly: flat tool lists inflate + context, force premature tool choice, add repeated round trips, make agents over-carry raw + payloads, and fragment semantics across local, remote, Cloud, and native integrations. +

+
+
+ Product center. Code Mode is the default surface because it keeps + discovery, execution, filtering, and synthesis in one bounded agent call. +
+
+ Recent momentum. The current branch has recent work on reusable Code + Mode sessions, recovery journals, Google Discovery API backends, media artifacts, + Cloud/remote attach, native integration parity, docs, and benchmarks. +
+
+ Open product questions. The PRD asks which result-shaping hints should + become public contract, how much direct exposure should be default, which Cloud/Project + Binding semantics should become product contract, and which benchmark suites should + remain long-term guardrails. +
+
+ External pressure. MCP hosts are moving toward remote connectors, + managed MCP endpoints, Apps SDK surfaces, structured outputs, server-rendered UI, + stronger OAuth/authorization, and stateless remote protocol design. +
+
+
+ +
+
Topic Axes
+

Topic Axes

+

+ Decomposition skipped - surprise-me mode. Different frames were allowed to + surface different subjects from the repo and external context. +

+
+ +
+
Ranked Ideas
+

Ranked Ideas

+ + +
+
+ 1 +
+

Caplet Forge: Workflow-to-Caplet Promotion

+

+ Turn a successful Code Mode session into a reusable, reviewable Caplet file or + Caplet-set recipe. +

+
+ CONF 86% · CX M +
+

+ Description: After an agent uses Code Mode to solve a repeated + workflow, Caplets can offer a promotion path: extract the handles, search terms, + described schemas, result shaping, setup commands, and recovery-safe helper code into a + draft Caplet recipe. The output would be a Markdown Caplet file with inferred + useWhen, avoidWhen, setup.verify, field-selection + defaults, and a deterministic smoke check. +

+
+
Basis
+
+ direct: Code Mode sessions return meta.sessionId for reusable helpers and + cached discovery state, while recovery journals help reconstruct setup code manually + (docs/product/caplets-code-mode-prd.md lines 104-117). Caplet files already support useWhen, avoidWhen, + setup, projectBinding, and runtime metadata (apps/docs/src/content/docs/reference/caplet-files.mdx lines 31-73). +
+
Rationale
+
+ The product already captures enough runtime truth to know what worked. Turning that + into a durable capability artifact compounds agent work instead of letting every + session rediscover the same workflow. +
+
Downsides
+
+ Needs careful distinction between reusable setup and side-effecting calls. The first + version should create drafts and tests, not silently install generated Caplets. +
+
+
+ + + Live Code Mode + session + calls + + + Extract Evidence + schemas, result shape, setup + safe helper candidates + + + Draft CAPLET.md + reviewable + smoke-tested + + + + + + +
+
+ +
+
+ 2 +
+

Trust Ledger: Capability Risk, Scope, and Action Preview

+

+ An inspectable ledger that tells users and agents what a Caplet can read, mutate, + sync, retain, and ask for before it is used. +

+
+ CONF 84% · CX M +
+

+ Description: Add a trust ledger to inspect(), + doctor, native metadata, and Cloud UI surfaces. For each Caplet, it would + summarize auth posture, inferred OAuth scopes, destructive/read-only hints, Project + Binding sync footprint, media artifact behavior, retained logs/recovery, and + remote/local execution mode. +

+
+
Basis
+
+ direct: Caplets already treats trust details as product requirements: keep secrets + redacted, expose enough auth/status detail, validate schemas, bound responses, deny + unsafe Project Binding sync paths, and route I/O through Caplet handles (docs/product/caplets-code-mode-prd.md lines 23-31 and 133-142). external: MCP guidance says tool UIs should make exposed tools and invocations + clear and keep a human in the loop for operations (MCP Tools specification). +
+
Rationale
+
+ Caplets' main value is reducing surface area without hiding capability truth. A ledger + makes that promise legible for humans, agents, and enterprise reviewers. +
+
Downsides
+
+ The ledger can become noisy if it prints every field. It should be a compact summary + with drill-down links, and it must avoid implying Code Mode is a sandbox boundary. +
+
+
+ +
+
+ 3 +
+

Result Contracts: Self-Training Output Shapers

+

+ Promote observed downstream output shapes into typed, reusable result shapers that + agents can call directly inside Code Mode. +

+
+ CONF 82% · CX M +
+

+ Description: Caplets already stores observed output shapes and has + schema-aware field selection. Build a layer that suggests reusable result shapers: + tool.describeResult(), tool.pickFields(), or generated helper + snippets that return compact evidence for common tasks. +

+
+
Basis
+
+ direct: The PRD names generic outputs as a problem and asks which result-shaping hints + should become stable public contract (docs/product/caplets-code-mode-prd.md lines 18 and 152-157). The code already stores observed shapes with type signatures, sample counts, TTLs, + and hashes (packages/core/src/observed-output-shapes/types.ts lines 30-51), and field selection validates paths against output schema (packages/core/src/field-selection.ts lines 11-40). +
+
Rationale
+
+ This turns one of Caplets' deepest advantages into a public contract: not just smaller + tool lists, but smaller and more reliable answers. +
+
Downsides
+
+ Observed shapes can drift. The contract needs confidence and invalidation semantics. +
+
+
+ +
+
+ 4 +
+

Interactive Repair: Elicitation-Aware Recovery

+

+ Convert auth, setup, schema, and Project Binding failures into structured repair + prompts that agents can route through their host UI. +

+
+ CONF 76% · CX H +
+

+ Description: Many failures already have stable diagnostic codes and + recovery commands. Turn those into a portable "repair needed" result shape: missing env + var, Cloud login required, workspace switch required, sync size exceeded, operation + scope changed, unknown session with recovery available. +

+
+
Basis
+
+ direct: Project Binding terminal states carry stable codes and recovery commands (docs/project-binding.md lines 23-57), and doctor reports remote, binding, sync, auth, exposure, Code Mode, + log storage, and observed output shape health (packages/core/src/cli/doctor.ts lines 41-96 and 99-168). external: current MCP tool docs include input-required tool results, and Apps SDK + surfaces emphasize app UX, metadata, authentication, and state management. +
+
Rationale
+
+ Agents are good at following structured next steps when the tool gives them exact + repair options. This would make Caplets feel less like a config parser and more like a + cooperative runtime. +
+
Downsides
+
+ Host support will vary. The fallback must remain plain structured JSON plus recovery + commands; never request secrets through elicitation. +
+
+
+ +
+
+ 5 +
+

Benchmark Flights: Per-Caplet Agent Evals

+

+ Let every serious Caplet ship with a tiny repeatable eval flight, not just docs and + setup commands. +

+
+ CONF 80% · CX M +
+

+ Description: Add a caplets eval init and Caplet-file eval + metadata that creates small, deterministic or opt-in live scenarios for a capability. A + Caplet author could specify task prompts, expected evidence fields, safe fixtures, and + competitor modes. +

+
+
Basis
+
+ direct: The benchmark report already measures initial tool-surface reduction, Code + Mode round-trip reduction, session reuse, and live failure classes (docs/benchmarks/coding-agent.md lines 14-23, 46-75, and 94-155). The PRD requires reproducible benchmark claims and marks live benchmarks as + directional rather than deterministic (docs/product/caplets-code-mode-prd.md lines 144-150). +
+
Rationale
+
+ The best way to prove a capability works for agents is to run agent-shaped tasks + against it. This makes Caplet quality visible and comparable at the configured + capability level. +
+
Downsides
+
+ Eval authoring can be a burden. Start with generated scaffolds from recent successful + Code Mode calls and a small deterministic fixture path. +
+
+
+ +
+
+ 6 +
+

Remote Capability Gateway Packs

+

+ Curated hosted/managed MCP and API packs that add Caplets policy, result shaping, + Project Binding, and trust metadata on top of vendor endpoints. +

+
+ CONF 72% · CX H +
+

+ Description: Create pack definitions for popular managed MCP or remote + API services: Google Cloud, GitHub, Linear, docs/search, package security, and internal + platform tools. A pack would define the remote endpoint, operation filters, auth/scopes, + result contracts, media artifact policy, runtime needs, eval flight, and trust ledger. +

+
+
Basis
+
+ direct: Caplets supports local, self-hosted remote, Cloud, Project Binding, + config/Caplet files, nested sets, and multiple backend families (docs/architecture.md lines 9-24 and 93-127). external: Google announced 50+ managed MCP servers with Agent Registry, IAM, Model + Armor, audit logs, and interoperability across Gemini CLI, Claude, ChatGPT, VS Code, + LangChain, ADK, and CrewAI (Google Cloud blog). +
+
Rationale
+
+ The market is moving from local MCP tinkering to managed remote tool ecosystems. + Caplets can be the layer that makes those endpoints agent-usable, policy-aware, and + benchmarked. +
+
Downsides
+
+ This can drift into marketplace work. Keep the first version as repo-owned packs with + hard quality bars, not a broad public directory. +
+
+
+ +
+
+ 7 +
+

Capability Graph Workspace

+

+ A machine-readable and human-readable map of capabilities, operations, resources, + prompts, auth, artifacts, and runtime requirements. +

+
+ CONF 74% · CX M +
+

+ Description: Add a graph export and docs/Cloud view that shows each + Caplet as a region: backend kind, available tools/resources/prompts, auth scopes, setup + state, Project Binding needs, result contracts, and likely next actions. +

+
+
Basis
+
+ direct: The architecture already has a common manager shape for listing, searching, + describing, calling, checking, and exposing MCP resources/prompts/completion, while + Caplet files include source, setup, binding, runtime, and backend metadata (docs/architecture.md lines 25-53 and 103-121; + apps/docs/src/content/docs/reference/code-mode-api.mdx lines 130-160). +
+
Rationale
+
+ The product metaphor is capability cards for sprawling tool stacks. A graph makes the + capability space inspectable without flattening it into a giant tool wall. +
+
Downsides
+
+ Visual polish is secondary. The graph must first be useful as a stable JSON export + that agents and tests can consume. +
+
+
+
+ +
+
Rejection Summary
+

Rejection Summary

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
#IdeaReason Rejected
1Durable Heap Snapshots + Rejected because current Code Mode deliberately uses live sessions plus recovery + journals, not durable heap persistence. +
2Auto-Confirm Destructive Actions + Rejected because Caplets exposes safety hints; clients and agents decide + confirmation. +
3Inline Media Everywhere + Rejected because media artifacts are already the accepted non-inline result + contract. +
4Direct Fetch Escape HatchRejected because Code Mode intentionally routes I/O through Caplet handles.
5Global MCP Marketplace Clone + Rejected as too broad and less differentiated than quality-gated Gateway Packs. +
6Caplet Pairing Mode + Interesting, but weaker than workflow promotion and evals as a near-term product + direction. +
7Landing Playground + Useful demo work, but less product-leveraged than core capability workflow features. +
8Agent Memory Handoff Bundle + Folded into Caplet Forge and Trust Ledger; not strong enough as a separate feature. +
+
+ +
+ Composed 2026-06-18T00:00:00-04:00 by ce-ideate from open-ended surprise-me prompt. +
+
+ + diff --git a/docs/native-integrations.md b/docs/native-integrations.md index bf3de95c..09c0a178 100644 --- a/docs/native-integrations.md +++ b/docs/native-integrations.md @@ -12,7 +12,7 @@ OpenCode and Pi use the same resolver as `caplets attach`. - `CAPLETS_MODE=local` exposes local/user/project Caplets only. - `CAPLETS_MODE=remote` requires `CAPLETS_REMOTE_URL` and connects to a self-hosted Caplets service. -- `CAPLETS_MODE=cloud` requires `CAPLETS_REMOTE_URL` pointing at Caplets Cloud and uses saved `caplets cloud auth login` credentials. +- `CAPLETS_MODE=cloud` requires `CAPLETS_REMOTE_URL` pointing at Caplets Cloud and uses the saved Remote Profile from `caplets remote login `. - `CAPLETS_MODE=auto` treats Cloud URLs as Cloud, non-Cloud remote URLs as self-hosted, and no remote URL as local. Cloud mode starts Project Binding automatically for the current project and overlays local/project Caplets over the remote workspace. 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 new file mode 100644 index 00000000..5a027023 --- /dev/null +++ b/docs/plans/2026-06-19-001-feat-unified-remote-attach-auth-plan.md @@ -0,0 +1,554 @@ +--- +title: "feat: Unify remote attach authentication" +type: feat +date: 2026-06-19 +origin: docs/brainstorms/2026-06-19-unified-remote-attach-auth-requirements.md +deepened: 2026-06-19 +--- + +# feat: Unify remote attach authentication + +## 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. + +This is a pre-1.0 breaking change. The plan removes Basic Auth and `CAPLETS_REMOTE_TOKEN` from the supported self-hosted remote attach, MCP, and control product path immediately rather than keeping a warning-backed compatibility fallback. + +--- + +## Problem Frame + +Remote attach currently has two user models. Caplets Cloud uses saved Cloud Auth state through `caplets cloud auth login`, while self-hosted remotes rely on `CAPLETS_REMOTE_TOKEN` or Basic Auth through flags and environment variables. That split leaks into setup docs, native integration docs, MCP client config, recovery messages, and tests. + +The target product shape is that Caplets owns remote trust. Agent configs should install a launch command and host URL, not a credential. This aligns with the strategy track for making local, self-hosted remote, and Cloud-backed execution behave as one capability model with explicit auth refresh, diagnostics, and recovery. + +--- + +## Requirements + +This plan carries the origin requirements, with R27-R30 expanded by the user's pre-1.0 decision to remove Basic Auth immediately, by review-confirmed coverage for env-token removal, and by proxy-origin hardening for callback and recovery URLs. + +**Unified remote login** + +- 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 ` with a non-echoing prompt by default, a stdin path for automation, and any `--code` path treated as explicit noninteractive use with warnings. +- 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** + +- 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 uses restrictive local permissions, with OS credential storage preferred when available. +- R15. CLI output, diagnostics, JSON errors, and logs redact remote access credentials, refresh credentials, client secrets, and Pairing Codes. +- R16. The server can list and revoke paired clients by stable client identity, label, created time, and last-used time. + +**Cloud migration** + +- 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 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. + +**Agent setup and docs** + +- 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 reduced to a transitional router that points users at `add-mcp`, Remote Login, and native extension docs. + +**Legacy self-hosted auth removal** + +- R27. Self-hosted Basic Auth is removed from the self-hosted attach, MCP, and control model. +- R28. `CAPLETS_REMOTE_TOKEN`-based self-hosted attach is also removed from the supported path for this pre-1.0 change. +- R29. New tests and docs must not describe Basic Auth or `CAPLETS_REMOTE_TOKEN` as a supported self-hosted setup path. + +**Proxy and callback origin safety** + +- R30. Callback and recovery URL generation behind a proxy uses an explicit public origin or trusted proxy-derived `Host`, `X-Forwarded-Host`, and `X-Forwarded-Proto` values, never arbitrary client-provided origin headers. + +--- + +## Actors and Flows + +- A1. **Self-hosted server operator:** Runs the Caplets HTTP service, creates Pairing Codes, 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. +- A5. **Caplets Cloud:** Implements the same Remote Login contract while preserving hosted workspace selection and refresh behavior. + +The implementation must preserve F1 self-hosted host pairing, F2 Cloud host login, F3 agent wiring after Remote Login, F4 client revocation, and F5 legacy Cloud migration from the origin document. + +--- + +## Key Technical Decisions + +| ID | Decision | Rationale | +| ------ | --------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| KTD1. | Remote Profile is the client-side source of truth for remote host trust. | This satisfies R1-R5 and keeps agent configs free of remote secrets. | +| KTD2. | Stored Remote Profile credentials replace `CAPLETS_REMOTE_TOKEN`, `CAPLETS_REMOTE_USER`, and `CAPLETS_REMOTE_PASSWORD` for supported attach auth. | The normal path cannot depend on env-secret propagation if Caplets owns remote trust. | +| KTD3. | Self-hosted login uses a Caplets-native device-code-style Pairing Code exchange rather than turning self-hosted Caplets into a full OAuth provider. | RFC 8628 gives the right short-code constraints, while Caplets only needs host trust and client revocation. | +| KTD4. | Self-hosted remote auth uses explicit route classes instead of one blanket middleware rule. | Pairing exchange, refresh rotation, backend OAuth callback, MCP, attach, control, and operator lifecycle commands have different bootstrap and authorization needs. | +| KTD5. | Self-hosted refresh material is a one-time rotating token family with hashed verification material and atomic rotation. | RFC 9700 and OWASP OAuth2 guidance both treat refresh-token replay and overbroad token audiences as core risks. | +| KTD6. | Cloud Auth internals are reused behind the Remote Login namespace and migrated into Remote Profiles. | Cloud already has browser/device login, refresh, workspace, and scope behavior; the user-facing model should change without duplicating the hosted auth implementation. | +| KTD7. | `caplets setup` becomes a transitional router rather than the primary first-run path. | This keeps existing users oriented while moving docs and generated config guidance to `add-mcp`, Remote Login, and native extension setup. | +| KTD8. | Basic Auth is removed immediately instead of hidden for a release. | The project is pre-1.0, and keeping Basic Auth creates a second auth model exactly where the origin requirements remove it. | +| KTD9. | Self-hosted client credential state is server-owned and durable. | R16 requires list and revoke by stable client identity, so the registry cannot be only in memory or derived from client-held secrets. | +| KTD10. | Client identity is derived from validated credentials, not client-supplied metadata. | A remote host may sit behind proxies or receive arbitrary headers, so revocation and last-used tracking must not trust forwarded identity fields. | +| KTD11. | Cloud Remote Profiles include a per-host selected workspace pointer in addition to workspace-qualified profiles. | Bare Cloud URLs remain ergonomic while multi-workspace status, logout, and attach stay deterministic. | +| KTD12. | Self-hosted server-operator lifecycle commands are server-environment operations in the first implementation. | Pairing Code issuance and client list/revoke must work before any remote client exists and must not become unauthenticated network admin routes. | +| KTD13. | Public-origin handling is explicit for proxied hosts. | Callback and recovery URLs must survive reverse proxies without trusting spoofable request headers from arbitrary clients. | + +--- + +## High-Level Technical Design + +### Component Topology + +```mermaid +flowchart TB + CLI["caplets CLI"] --> RemoteCommands["remote login/status/logout"] + CLI --> HostCommands["server-local remote host pair/clients/revoke"] + CLI --> Attach["attach --remote-url"] + Native["OpenCode and Pi native services"] --> NativeResolver["native remote resolver"] + Agent["MCP client config"] --> Attach + + RemoteCommands --> ProfileStore["Remote Profile store"] + RemoteCommands --> CloudAuth["Cloud Auth client/store adapter"] + HostCommands --> HostStore["server credential store"] + RemoteCommands --> PairExchange["Self-hosted Pairing Code exchange"] + + Attach --> RemoteSelection["remote selection"] + NativeResolver --> RemoteSelection + RemoteSelection --> ProfileStore + RemoteSelection --> Refresh["credential refresh"] + + PairExchange --> Host["Caplets host"] + Refresh --> Host + Attach --> Host + NativeResolver --> Host + + Host --> Middleware["shared remote auth middleware"] + Middleware --> MCP["MCP route"] + Middleware --> AttachRoutes["attach routes"] + Middleware --> Control["admin/control routes"] + Middleware --> ProjectBinding["Project Binding routes"] +``` + +### Self-Hosted Remote Login Sequence + +```mermaid +sequenceDiagram + participant Operator as Self-hosted operator + participant Host as Caplets host + participant Client as Client CLI + participant Store as Remote Profile store + participant Agent as Agent attach command + + Operator->>Host: Create short-lived Pairing Code from server environment + Host-->>Operator: Suggested prompt-based login command + Client->>Client: Prompt for Pairing Code without echo + Client->>Host: Exchange Pairing Code for client credentials + Host->>Host: Verify code, rate limit, mark one-time use + Host-->>Client: Issue client identity, access credential, refresh material + Client->>Store: Save Remote Profile and secret material + Agent->>Client: Start caplets attach with remote URL + Client->>Store: Resolve and refresh credentials + Client->>Host: Connect with issued credential + Host->>Host: Validate audience, expiry, revocation, client identity +``` + +### Self-Hosted Remote Auth Contract + +| Route class | Actor | Accepted credential | Plan requirement | +| ------------------------------------------------------- | ----------------------------- | ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | +| Health and version | Any caller | Public | Remain public and do not disclose remote credential state. | +| Pairing Code issuance | Self-hosted server operator | Server-environment authority only | Runs against the server-owned credential store; no unauthenticated remote caller can mint Pairing Codes. | +| Pairing Code exchange | Remote client user | Valid Pairing Code verifier | Network-reachable before login, but bound by host, TTL, one-time use, hashing, and rate limits. | +| Refresh and rotation | Remote client runtime | Refresh material | Uses a one-time rotating token family with hashed verifier, atomic compare-and-swap, stale-token reuse detection, and revocation checks. | +| MCP, attach, Project Binding, and normal remote control | Agent or authenticated client | Issued access credential | Protected by the remote credential validator and derives client identity from validated credentials only. | +| Backend OAuth callback | Browser callback | Flow id validated by backend OAuth state | Stays reachable without remote host credentials so `caplets auth login ` remains separate from Remote Login. | +| Self-hosted client list and revoke | Self-hosted server operator | Server-environment authority only | Reads and mutates server-owned credential state; normal paired-client credentials cannot list, mint, or revoke clients. | + +The Basic-shaped HTTP auth state should be replaced with explicit modes such as `none`, `remote_credentials`, and `development_unauthenticated`. Serve option resolution, daemon serialization/redaction, service discovery auth metadata, DNS-rebinding allowed-host logic, and route middleware should treat `remote_credentials` as protected. + +### Attach Selection Flow + +```mermaid +flowchart TB + Start["attach/native request has remote URL"] --> Normalize["Normalize host URL"] + Normalize --> Cloud{"Cloud URL?"} + Cloud -->|yes| CloudProfile["Load Cloud Remote Profile or migrate legacy Cloud Auth"] + Cloud -->|no| HostedProfile["Load self-hosted Remote Profile"] + CloudProfile --> Found{"Profile found?"} + HostedProfile --> Found + Found -->|no| Recover["Fail closed with remote login recovery"] + Found -->|yes| Fresh{"Access credential valid?"} + Fresh -->|yes| Connect["Connect with profile credential"] + Fresh -->|no| Refresh["Refresh or rotate credential"] + Refresh --> RefreshOk{"Refresh accepted?"} + RefreshOk -->|yes| Connect + RefreshOk -->|no| Recover +``` + +### Credential Lifecycle + +```mermaid +stateDiagram-v2 + [*] --> Unpaired + Unpaired --> PairingIssued: operator creates Pairing Code + PairingIssued --> ProfileStored: client exchanges valid code + PairingIssued --> Expired: ttl expires or attempts exhausted + ProfileStored --> AccessValid: access credential issued + AccessValid --> RefreshRotated: refresh succeeds + RefreshRotated --> AccessValid: store new material + AccessValid --> Revoked: operator revokes client + RefreshRotated --> Revoked: operator revokes client + AccessValid --> Expired: refresh fails closed + Revoked --> LoginRequired + Expired --> LoginRequired + LoginRequired --> PairingIssued: new Pairing Code +``` + +--- + +## Implementation Units + +### U1. Remote Profile and Credential Storage + +- **Goal:** Introduce the client-side Remote Profile model and credential store that can represent Cloud and self-hosted host trust. +- **Requirements:** R1-R4, R11-R15, R18-R19, F2, F5, AE1, AE4, AE6. +- **Dependencies:** None. +- **Files:** + - `packages/core/src/remote/profiles.ts` (new) + - `packages/core/src/remote/profile-store.ts` (new) + - `packages/core/src/remote/credential-store.ts` (new) + - `packages/core/src/remote/options.ts` + - `packages/core/src/cloud-auth/store.ts` + - `packages/core/src/auth/store.ts` + - `packages/core/src/redaction.ts` + - `packages/core/test/remote-profiles.test.ts` (new) + - `packages/core/test/cloud-auth.test.ts` + - `packages/core/test/redaction.test.ts` +- **Approach:** Define a normalized Remote Profile key from host URL plus Cloud workspace when applicable. Store redacted metadata separately from secret material, and reuse the atomic restrictive file-write pattern already present in `packages/core/src/auth/store.ts`. The first implementation should use a concrete file-backed credential store behind the Remote Profile API; OS credential backend selection stays follow-up work. Cloud profiles should also maintain a per-host selected workspace pointer for bare Cloud-origin attach. Legacy Cloud Auth state should be read lazily, and migration must not delete the legacy record until after the new Remote Profile write succeeds. +- **Execution note:** Implement the store tests first because every later unit depends on the credential lifecycle contract. +- **Patterns to follow:** `packages/core/src/auth/store.ts` for restrictive permissions and atomic writes, `packages/core/src/cloud-auth/store.ts` for current Cloud credential shape, and `packages/core/src/remote/options.ts` for URL normalization. +- **Test scenarios:** + - Covers AE1. Saving a Cloud Remote Profile with a selected workspace persists host kind, normalized URL, workspace, client label, and redacted status. + - Covers AE4. Loading profile status never returns raw access credentials, refresh credentials, client secrets, or Pairing Codes. + - Covers AE6. A legacy Cloud Auth record is migrated or read through the Remote Profile store without deleting the legacy record before the new write succeeds. + - A malformed or credential-bearing remote URL is rejected or normalized without persisting embedded username, password, query secret, or fragment secret. + - Two Cloud workspaces under the same Cloud host produce distinct profile keys and a redacted selected-workspace pointer. + - Logout removes the selected profile and its secret material without removing unrelated host profiles. + - A bare Cloud host with multiple profiles requires a selected workspace, explicit workspace selector, or all-workspaces operation before status/logout mutates anything. + - File-backed storage creates private directories and credential files with restrictive permissions on supported platforms. +- **Verification:** Remote Profile status is redacted, Cloud legacy credentials still resolve through the new store, and credential files do not expose raw secrets through CLI or JSON status output. + +### U2. Self-Hosted Pairing and Server Credential Validation + +- **Goal:** Replace self-hosted Basic Auth and env-token auth with server-side Pairing Code issuance, issued client credential validation, and server-side revocation. +- **Requirements:** R6-R7, R9-R10, R12-R16, R27-R30, F1, F4, AE5, AE8-AE10. +- **Dependencies:** U1. +- **Files:** + - `packages/core/src/remote/pairing.ts` (new) + - `packages/core/src/remote/server-credentials.ts` (new) + - `packages/core/src/remote/server-credential-store.ts` (new) + - `packages/core/src/serve/http.ts` + - `packages/core/src/serve/options.ts` + - `packages/core/src/server/options.ts` + - `packages/core/src/cli.ts` + - `packages/core/test/remote-pairing.test.ts` (new) + - `packages/core/test/serve-http.test.ts` + - `packages/core/test/serve-options.test.ts` + - `packages/core/test/server-options.test.ts` +- **Approach:** Add server-side Pairing Code issuance and exchange APIs, storing only non-reusable verification material for short-lived codes. Successful exchange creates a stable client identity, metadata for list/revoke, access credential material, and refresh material constrained to the issuing host. Persist the self-hosted client registry and refresh state in single-node server-owned filesystem storage with a configurable state path, restrictive permissions, atomic write/rename, and advisory locking around refresh rotation. Replace the existing Basic Auth middleware path with the route-class contract above, including a remote credential validator for MCP, attach, normal remote control, and Project Binding routes. Pairing Code issuance plus client list/revoke stay server-environment operations for the first implementation. Derive client identity, revocation state, and last-used updates from validated credentials only; labels, request bodies, and forwarded headers are display or transport metadata, not authority. Remove Basic Auth flags, env parsing, env-token parsing, and product tests from the supported self-hosted auth path. +- **Origin handling:** Server options should prefer an explicit configured public origin for generated callback, pairing, and recovery URLs. If proxy-derived origins are supported, they must require an explicit trusted-proxy mode and validate `Host`, `X-Forwarded-Host`, and `X-Forwarded-Proto` against the configured allowed host model. +- **Technical design:** The pairing code should be human-copyable, but the server-side verifier must be high entropy and stored hashed. The copied code is bootstrap input only; attach requests must use issued credentials tied to a client identity. +- **Patterns to follow:** The route grouping and central auth placement in `packages/core/src/serve/http.ts`, current non-loopback HTTP safety checks in `packages/core/src/serve/options.ts`, and RFC 8628's limited-lifetime code and polling/rate-limit constraints. +- **Test scenarios:** + - Covers AE5. A listed client can be revoked by stable identity, and its next attach attempt fails until Remote Login runs again. + - Covers AE8. Basic Auth credentials and `CAPLETS_REMOTE_TOKEN` values are not accepted as the supported auth mechanism on self-hosted MCP, attach, control, or Project Binding routes. + - Covers AE10. Generated callback, pairing, and recovery URLs use configured public origin or trusted proxy headers only; untrusted `Host` or `X-Forwarded-*` spoofing cannot change them. + - Expired Pairing Codes, reused Pairing Codes, wrong-host Pairing Codes, and attempt-exhausted Pairing Codes fail closed. + - Pairing Code verification is rate-limited enough that repeated invalid attempts do not produce unlimited guesses. + - A valid issued credential is accepted by MCP, attach, Project Binding, and normal remote control routes, but not by server-operator pair/list/revoke commands. + - The backend OAuth callback remains flow-id-bound and reachable without a remote host credential, while normal admin/control actions require issued remote credentials. + - Refresh credentials form a one-time rotating token family with hashed verifier storage, atomic compare-and-swap, stale-token reuse detection, and family invalidation on superseded-token reuse. + - A stale refresh credential is not enough to regain access after rotation, superseded-token reuse, or revocation. + - A configured custom server state path stores client identity, revocation, and refresh state with restrictive permissions. + - Client list and revoke metadata survive a host restart when server-owned state is present. + - Revocation survives restart and invalidates both access and refresh material for the revoked client. + - Concurrent refresh attempts do not fork refresh state or leave two valid successors. + - Spoofed client labels, request metadata, or forwarded identity headers do not bypass revocation or alter the validated client identity. + - Non-loopback HTTP still refuses unsafe unauthenticated exposure unless the explicit unauthenticated development override is used. +- **Verification:** Self-hosted routes are protected by issued remote credentials, client list/revoke works without rotating every client, and no Basic Auth or env-token branch remains as a supported remote auth contract. + +### U3. Unified Remote CLI and Cloud Migration + +- **Goal:** Add the provider-neutral `caplets remote` command family and route Cloud Auth behavior through Remote Profiles. +- **Requirements:** R1-R3, R6, R8, R16-R20, F1, F2, F4, F5, AE1, AE2, AE5, AE6. +- **Dependencies:** U1, U2. +- **Files:** + - `packages/core/src/cli/commands.ts` + - `packages/core/src/cli.ts` + - `packages/core/src/cloud-auth/client.ts` + - `packages/core/src/cloud-auth/errors.ts` + - `packages/core/src/cloud-auth/store.ts` + - `packages/core/src/remote/selection.ts` + - `packages/core/src/remote/profiles.ts` + - `packages/core/test/remote-login-cli.test.ts` (new) + - `packages/core/test/cloud-auth-login-cli.test.ts` + - `packages/core/test/cloud-auth.test.ts` + - `packages/core/test/cli-completion.test.ts` +- **Approach:** Add `remote login`, `remote status`, and `remote logout` as the primary client-profile commands. Cloud URLs should reuse the existing hosted login and workspace selection behavior behind the Remote Login name, writing a workspace-qualified Remote Profile and updating the selected-workspace pointer for that Cloud host. Self-hosted URLs should use the Pairing Code exchange API from U2, prompting for the Pairing Code without echo by default and supporting a non-echoing stdin path for automation. Keep any `--code` option explicit and warning-backed for noninteractive use only. Server-operator lifecycle commands should be separated from client-profile commands, such as under `caplets remote host`, so pairing issuance and client list/revoke are visibly server-environment operations. Keep `caplets cloud auth` commands as deprecated wrappers or aliases only where needed for migration; their output should point to `caplets remote`. +- **Technical design:** Command naming should preserve the separate meaning of `caplets auth login ` for backend Caplet OAuth. Remote host auth commands must not be placed under `caplets auth`. Bare Cloud host status/logout should use the selected workspace when one is unambiguous, fail with redacted workspace choices when multiple profiles exist, and support explicit workspace selection plus an all-workspaces operation for destructive cleanup. +- **Patterns to follow:** `packages/core/src/cli/commands.ts` for subcommand inventory, `packages/core/src/cli.ts` for current Cloud Auth command implementation, and `packages/core/test/cloud-auth-login-cli.test.ts` for CLI fixture style. +- **Test scenarios:** + - Covers AE1. `remote login` against a Cloud URL stores a Cloud Remote Profile with selected workspace metadata. + - Covers AE2. `remote login` against a self-hosted URL prompts for a valid Pairing Code, stores a self-hosted Remote Profile, and later attach uses issued client credentials rather than the copied code. + - Covers AE5. Server-side client list output shows stable client identity, label, created time, and last-used time without secrets, and revoke invalidates that client. + - Covers AE6. Existing Cloud Auth state is accepted through the new Remote Profile path, and recovery guidance names `caplets remote login`. + - `remote status` redacts every credential field and can filter or select by host URL. + - `remote status` and `remote logout` handle one workspace, multiple workspaces under one Cloud host, workspace-qualified URLs, explicit workspace selection, and all-workspaces cleanup deterministically. + - `remote logout` removes local profile state and performs best-effort server revocation when a live credential is available. + - Generated self-hosted pairing commands do not echo Pairing Codes in argv, terminal output, or diagnostics. + - `caplets cloud auth login/status/logout/switch` either delegates to the Remote Profile implementation or emits deprecation guidance without creating a separate credential model. + - CLI completions include the new `remote` subcommands and omit removed Basic Auth flags. +- **Verification:** Users can complete Cloud and self-hosted host login through one namespace, legacy Cloud users receive migration-safe behavior, and command help teaches Remote Login rather than provider-specific setup. + +### U4. Attach, Remote Control, and Native Resolution from Remote Profiles + +- **Goal:** Make CLI attach, remote control, OpenCode, and Pi resolve stored Remote Profiles before connecting to a host. +- **Requirements:** R4-R5, R11-R15, R19-R20, R23-R25, R28-R29, F3, F5, AE3, AE4, AE6, AE9. +- **Dependencies:** U1, U2, U3. +- **Files:** + - `packages/core/src/remote/options.ts` + - `packages/core/src/remote/selection.ts` + - `packages/core/src/remote-control/client.ts` + - `packages/core/src/project-binding/attach.ts` + - `packages/core/src/project-binding/errors.ts` + - `packages/core/src/native/options.ts` + - `packages/core/src/native/service.ts` + - `packages/core/src/native/remote.ts` + - `packages/core/test/remote-options.test.ts` + - `packages/core/test/remote-selection.test.ts` + - `packages/core/test/remote-control-client.test.ts` + - `packages/core/test/attach-cli.test.ts` + - `packages/core/test/cloud-auth-refresh-attach.test.ts` + - `packages/core/test/native-options.test.ts` + - `packages/core/test/native-remote.test.ts` + - `packages/core/test/doctor-cli.test.ts` +- **Approach:** Resolve remote mode from URL as today, then load the matching Remote Profile by normalized URL and workspace. For bare Cloud-origin URLs, resolve through the selected-workspace pointer; for workspace-qualified URLs, validate against that exact profile key. Refresh credentials before attach or native remote calls when needed. Remove secret-bearing env variables, env tokens, and Basic Auth fields from the supported resolver path; keep non-secret selectors such as remote URL, workspace, and mode only where they are still needed by existing native launch contracts. All missing, expired, revoked, or workspace-mismatched credentials should fail closed with Remote Login recovery text. +- **Technical design:** Native services should use the same profile store and refresh behavior as `caplets attach`, not a parallel env-token path. +- **Patterns to follow:** `packages/core/src/remote/selection.ts` for current Cloud refresh before attach, `packages/core/src/native/service.ts` for native Cloud selection, and `packages/core/src/project-binding/errors.ts` for structured recovery commands. +- **Test scenarios:** + - Covers AE3. An agent config that runs only `caplets attach --remote-url ` succeeds when a matching Remote Profile exists. + - Covers AE4. Attach refresh and diagnostic output redact credentials and preserve host audience restrictions. + - Covers AE6. A Cloud URL with only legacy Cloud Auth state migrates or resolves through the Remote Profile path before attach. + - Self-hosted attach with no Remote Profile or only legacy env-token state fails with `caplets remote login ` recovery guidance. + - A stored self-hosted profile sends issued credential material, not `CAPLETS_REMOTE_TOKEN`, Basic Auth, or the Pairing Code. + - A bare Cloud-origin attach resolves the selected workspace, while multiple Cloud profiles without a selected workspace fail with redacted choices. + - Workspace mismatch on a Cloud profile fails with Remote Login or workspace selection guidance. + - Native OpenCode and Pi remote modes can connect with a remote URL plus stored profile, without remote credential env vars. + - Remote control 401/403 errors no longer instruct users to check Basic Auth env vars. +- **Verification:** Every remote execution surface uses the same profile-backed auth model and every recovery path points to Remote Login. + +### U5. Agent Setup, Native Docs, and Public Docs Migration + +- **Goal:** Rewrite setup and docs around Remote Login, `add-mcp`, and native extension setup. +- **Requirements:** R21-R29, F3, AE3, AE7-AE9. +- **Dependencies:** U3, U4. +- **Files:** + - `packages/core/src/cli/setup.ts` + - `packages/core/test/setup-runner.test.ts` + - `packages/core/test/agent-plugins.test.ts` + - `README.md` + - `packages/cli/README.md` + - `apps/docs/src/content/docs/index.mdx` + - `apps/docs/src/content/docs/install.mdx` + - `apps/docs/src/content/docs/agent-integrations.mdx` + - `apps/docs/src/content/docs/remote-attach.mdx` + - `apps/docs/src/content/docs/troubleshooting.mdx` + - `docs/native-integrations.md` + - `docs/project-binding.md` + - `docs/product/caplets-code-mode-prd.md` + - `docs/architecture.md` + - `packages/opencode/README.md` + - `packages/pi/README.md` +- **Approach:** Make first-run docs say local MCP setup installs `caplets serve` through `add-mcp`, and remote MCP setup runs Remote Login first, then installs `caplets attach --remote-url ` through `add-mcp`. Do not recommend `add-mcp --env` or any env-secret path for Caplets remote credentials. Self-hosted pairing docs should show a prompt-based Remote Login flow and must not print Pairing Code values in generated commands. Update OpenCode and Pi docs to use native extension setup plus the same Remote Login model. Reduce `caplets setup` to a transitional router that points to the current setup path and no longer suggests remote secret env vars. Verify `add-mcp@1.10.4` syntax during implementation before finalizing examples. +- **Patterns to follow:** Current README and docs structure, `packages/cli` prepack copying from `README.md`, and existing docs check scripts for generated public docs. +- **Test scenarios:** + - Covers AE3. Generated or dry-run setup output for remote MCP contains the attach command and URL but no remote token, password, Basic Auth, or `add-mcp --env` credential instruction. + - Covers AE7. Install docs route MCP users through `add-mcp` and native users through OpenCode or Pi extension setup. + - Covers AE8. Current self-hosted docs never ask a user to configure Basic Auth. + - Current self-hosted docs never ask a user to configure `CAPLETS_REMOTE_TOKEN` for the supported path. + - Remote attach docs show Remote Login before attach for both Cloud and self-hosted hosts. + - Pairing docs and setup output do not echo Pairing Codes inside command argv, generated config, or diagnostics. + - Troubleshooting docs recover missing or revoked credentials with `caplets remote login`, not env-secret instructions. + - Package README copies or generated docs do not drift from the root README. +- **Verification:** Public docs, package READMEs, and setup output all teach the same no-secret agent config model. + +### U6. Diagnostics, Redaction, Package Contract, and Release Gate + +- **Goal:** Align diagnostics, generated surfaces, package metadata, and release artifacts with the new auth model. +- **Requirements:** R2, R12-R15, R20, R28-R29, AE4, AE6, AE8-AE9. +- **Dependencies:** U1, U2, U3, U4, U5. +- **Files:** + - `packages/core/src/cli/doctor.ts` + - `packages/core/src/redaction.ts` + - `packages/core/src/code-mode/logs.ts` + - `packages/core/src/project-binding/errors.ts` + - `packages/core/src/cloud-auth/errors.ts` + - `packages/core/src/index.ts` + - `packages/core/src/native.ts` + - `packages/core/test/doctor-cli.test.ts` + - `packages/core/test/redaction.test.ts` + - `packages/core/test/code-mode-logs.test.ts` + - `packages/core/test/cli-completion.test.ts` + - `.changeset/*.md` +- **Approach:** Update doctor output to report Remote Profile status and Remote Login recovery. Extend redaction to new credential prefixes, refresh material, Pairing Codes, client secret fields, serialized Remote Profile secret payloads, and any noninteractive Pairing Code option. Ensure public exports expose only stable Remote Profile and native resolver types needed by downstream packages. Add a changeset because the CLI, core runtime, docs, and native packages change user-facing auth behavior. +- **Patterns to follow:** Current doctor Cloud Auth checks, existing redaction helper coverage, and the Changesets release process already used by the repo. +- **Test scenarios:** + - Covers AE4. Logs, doctor output, JSON errors, and Code Mode logs redact remote credentials and Pairing Codes. + - Covers AE6. Doctor and Project Binding recovery text name `caplets remote login` for missing or revoked Cloud credentials. + - Covers AE8. Completion and help output do not expose Basic Auth as a remote setup path. + - Completion, help, and doctor output do not expose `CAPLETS_REMOTE_TOKEN` as a supported self-hosted setup path. + - Missing profile diagnostics name host URL and workspace metadata only in redacted form. + - Exported package types compile after adding Remote Profile helpers and native profile-backed resolution. + - The changeset describes the breaking pre-1.0 auth model change. +- **Verification:** Diagnostics are useful without leaking credentials, generated/API checks stay current, and the repo gate passes with the new public contract. + +--- + +## Phased Delivery + +- **Phase 1: Credential substrate.** Land U1 and U2 so both client and server have one trust model and Basic Auth plus env-token auth are removed at the auth boundary. +- **Phase 2: Command and runtime surfaces.** Land U3 and U4 so CLI, attach, remote control, OpenCode, and Pi use Remote Profiles. +- **Phase 3: Public contract cleanup.** Land U5 and U6 so docs, setup, diagnostics, exports, and release notes match the implementation. + +The phases are dependency groups, not separate product promises. The branch should not ship publicly until Phase 3 is complete because partial shipping would leave docs or integrations teaching stale auth behavior. + +--- + +## Scope Boundaries + +### Deferred for later + +- Hardware-backed or sender-constrained credentials beyond the best available software credential model. +- 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. + +### 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. +- Replacing backend Caplet OAuth with remote host login. + +### Deferred to Follow-Up Work + +- A full cross-platform OS keychain dependency decision after the first concrete file-backed Remote Profile credential store ships. +- Automatic editing of existing third-party agent configs that already contain old Caplets credential env vars. +- Fine-grained self-hosted user roles beyond the server operator's ability to list and revoke paired clients. + +--- + +## System-Wide Impact + +- **CLI contract:** Adds `caplets remote` as a top-level auth namespace and demotes `caplets cloud auth` to migration guidance. +- **HTTP auth boundary:** Replaces the current Basic Auth gate for remote host routes with explicit route classes and issued remote credential validation. +- **Native integrations:** Makes OpenCode and Pi resolve stored Remote Profiles instead of secret env vars. +- **Project Binding:** Changes Cloud and self-hosted recovery commands to Remote Login while preserving fail-closed behavior. +- **Docs and setup:** Moves first-run MCP setup to `add-mcp` plus Remote Login, and keeps `caplets setup` as a transitional router. +- **Release posture:** This is a breaking pre-1.0 auth change for `caplets`, `@caplets/core`, `@caplets/opencode`, and `@caplets/pi` users who rely on old self-hosted env-secret, `CAPLETS_REMOTE_TOKEN`, or Basic Auth setup. + +--- + +## Acceptance Examples + +- AE1. Given a user runs `caplets remote login https://cloud.caplets.dev`, when hosted auth completes, then Caplets stores a Cloud Remote Profile with the selected workspace. +- AE2. Given a server operator creates a Pairing Code and a client enters it through prompt or stdin, when the client later attaches, then it uses issued client credentials rather than the original copied code. +- AE3. 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. Given a remote credential is stored locally, when attach refreshes or reports diagnostics, then credentials remain host-scoped and redacted from output. +- AE5. 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. 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. 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. Given a user follows current docs, when they self-host and attach, then they never configure Basic Auth or `CAPLETS_REMOTE_TOKEN`. +- AE9. Given a self-hosted user has only old `CAPLETS_REMOTE_TOKEN` state or env, when attach runs, then it fails closed with `caplets remote login ` recovery guidance and does not use the token. +- AE10. Given a self-hosted host is behind a reverse proxy, when callback, pairing, or recovery URLs are generated, then they use configured public origin or trusted proxy headers and reject spoofed untrusted host/proto input. + +--- + +## Risks and Mitigations + +| Risk | Mitigation | +| --------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| A protected route keeps the old Basic Auth, env-token, or unauthenticated branch. | Replace auth in the shared HTTP middleware boundary and add route-category tests for MCP, attach, admin/control, Project Binding, backend OAuth callback, pairing, refresh, and server-operator routes. | +| Cloud migration breaks users with existing saved Cloud Auth. | Keep legacy Cloud Auth reads during migration and test legacy state before removing any old file. | +| Pairing Codes become reusable credentials in practice. | Store only hashed verification material server-side, enforce one-time use, and test that copied codes cannot authenticate attach. | +| Refresh-token replay gives long-term access after theft. | Use a one-time rotating token family with hashed verifier storage, atomic rotation, stale-token reuse detection, and revocation invalidation for both current access and refresh material. | +| The self-hosted client registry is accidentally in-memory only. | Persist client identity, revocation, and refresh state in server-owned storage and test restart behavior. | +| A reverse proxy or malicious client injects identity or origin headers. | Sanitize identity headers, derive client identity only from validated remote credentials, and generate public URLs only from configured public origin or explicitly trusted proxy `Host` and `X-Forwarded-*` inputs. | +| Native integrations drift from CLI attach. | Route OpenCode, Pi, and CLI attach through the same Remote Profile resolution and refresh helpers. | +| Docs continue teaching env-secret setup through stale pages. | Include README, docs site, package READMEs, troubleshooting docs, and setup dry-run output in U5 verification. | +| `add-mcp` syntax or support changes during implementation. | Verify the current package help/source during U5 before finalizing docs examples; the planning pass confirmed npm package version `1.10.4`. | + +--- + +## Alternative Approaches Considered + +- **Keep Basic Auth for one release:** Rejected. The project is pre-1.0, and a hidden fallback would preserve the split auth model this plan removes. +- **Keep Cloud Auth as a separate user-facing namespace:** Rejected. The origin requirement is one Remote Login model with Cloud as one host kind. +- **Keep env-token auth for self-hosted remotes:** Rejected for supported setup. It keeps secrets in agent launch environments, splits recovery paths, and fails the no-secret config goal. +- **Implement a complete OAuth provider for self-hosted Caplets:** Rejected. A Caplets-native device-code-style exchange covers host trust, client identity, refresh, and revocation without overbuilding identity administration. +- **Block on full OS keychain parity before shipping Remote Profiles:** Rejected. The first implementation uses a concrete restrictive file-backed Remote Profile credential store so the command contract can ship while preserving a path to better platform storage. + +--- + +## Documentation and Operational Notes + +- Public docs should use Remote Login vocabulary consistently: Remote Login, Pairing Code, and Remote Profile are already defined in `CONCEPTS.md`. +- `caplets auth login ` must stay documented as backend Caplet OAuth, not remote host authentication. +- Server operator docs need to state that Pairing Codes are short-lived bootstrap material and revocation is per paired client. +- Pairing examples should use the prompt or stdin flow and should not put Pairing Code values in generated command argv, config, or diagnostics. +- Troubleshooting docs should distinguish missing host login from backend Caplet OAuth failures. +- Self-hosted deployment docs should state that TLS termination and reverse proxies must not inject trusted client identity into Caplets remote auth, and must document explicit public origin or trusted proxy handling for `Host`, `X-Forwarded-Host`, and `X-Forwarded-Proto`. +- Package changes need a changeset because users may need to replace env-secret setup with Remote Login before upgrading. + +--- + +## Verification Strategy + +- Focused unit tests should prove Remote Profile storage, redaction, Cloud migration, Pairing Code lifecycle, server revocation, and URL/workspace keying. +- Integration tests should prove every protected remote route accepts issued credentials, rejects Basic Auth and `CAPLETS_REMOTE_TOKEN` as supported mechanisms, and preserves the backend OAuth callback exception. +- CLI tests should prove `remote login/status/logout`, Cloud migration wrappers, recovery text, and completions match the new command model. +- CLI tests should prove self-hosted login prompts for Pairing Codes or accepts stdin by default, treats `--code` as explicit warning-backed noninteractive use, and does not echo Pairing Codes in generated commands. +- Native tests should prove OpenCode and Pi can connect using stored Remote Profiles and non-secret selectors. +- Docs checks should prove public docs and package READMEs no longer teach Basic Auth or remote credential env vars as the normal path. +- Proxy-origin tests should prove callback, pairing, and recovery URLs cannot be changed through untrusted `Host` or `X-Forwarded-*` headers. +- The final repo gate should pass after generated docs, generated schemas, Code Mode API checks, typecheck, tests, deterministic benchmarks, and build are current. + +--- + +## Sources and Research + +- Origin requirements: `docs/brainstorms/2026-06-19-unified-remote-attach-auth-requirements.md`. +- Strategy and glossary: `STRATEGY.md`, `CONTEXT.md`, `CONCEPTS.md`, `docs/adr/0001-code-mode-default-exposure.md`. +- Credential storage patterns: `packages/core/src/auth/store.ts`, `packages/core/src/cloud-auth/store.ts`. +- Current CLI and setup surfaces: `packages/core/src/cli.ts`, `packages/core/src/cli/commands.ts`, `packages/core/src/cli/setup.ts`. +- Current remote resolution: `packages/core/src/remote/options.ts`, `packages/core/src/remote/selection.ts`, `packages/core/src/remote-control/client.ts`. +- Current server auth boundary: `packages/core/src/serve/http.ts`, `packages/core/src/serve/options.ts`, `packages/core/src/server/options.ts`. +- Current native and Project Binding surfaces: `packages/core/src/native/options.ts`, `packages/core/src/native/service.ts`, `packages/core/src/native/remote.ts`, `packages/core/src/project-binding/errors.ts`. +- Current docs to update: `README.md`, `apps/docs/src/content/docs/remote-attach.mdx`, `apps/docs/src/content/docs/install.mdx`, `apps/docs/src/content/docs/agent-integrations.mdx`, `apps/docs/src/content/docs/troubleshooting.mdx`, `docs/native-integrations.md`, `docs/project-binding.md`, `packages/opencode/README.md`, `packages/pi/README.md`. +- External references: [RFC 8628 OAuth 2.0 Device Authorization Grant](https://www.rfc-editor.org/rfc/rfc8628.html), [RFC 9700 OAuth 2.0 Security Best Current Practice](https://www.rfc-editor.org/rfc/rfc9700.html), [OWASP OAuth2 Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/OAuth2_Cheat_Sheet.html), and [`add-mcp` on npm](https://www.npmjs.com/package/add-mcp). diff --git a/docs/product/caplets-code-mode-prd.md b/docs/product/caplets-code-mode-prd.md index 5472e101..232d8d5e 100644 --- a/docs/product/caplets-code-mode-prd.md +++ b/docs/product/caplets-code-mode-prd.md @@ -66,7 +66,7 @@ OpenCode and Pi use the native service from `@caplets/core/native`. They expose ### Remote And Cloud -Self-hosted remotes use `CAPLETS_MODE=remote` with `CAPLETS_REMOTE_URL` and token or Basic Auth credentials. Hosted Cloud uses `CAPLETS_MODE=cloud`, `CAPLETS_REMOTE_URL`, and saved `caplets cloud auth login` credentials. Project Binding connects a local project root to a remote runtime when Cloud or remote workflows need project-local files. +Self-hosted remotes and hosted Cloud use `caplets remote login ` to create a saved Remote Profile, then use `CAPLETS_MODE` with `CAPLETS_REMOTE_URL` as a non-secret selector. Project Binding connects a local project root to a remote runtime when Cloud or remote workflows need project-local files. ## Capability Model diff --git a/docs/project-binding.md b/docs/project-binding.md index 9fed7fcc..6dcad11e 100644 --- a/docs/project-binding.md +++ b/docs/project-binding.md @@ -8,7 +8,7 @@ Project Binding connects one local project root to a remote Caplets runtime so p `caplets attach` opens a foreground Binding Session. It reports state events, keeps the Project Binding lease alive, and exits only when interrupted or when the session reaches a terminal state. -Hosted Cloud uses `caplets cloud auth login` and a Selected Workspace. Self-hosted remotes use `CAPLETS_REMOTE_URL` plus `CAPLETS_REMOTE_TOKEN` or Basic Auth credentials. Passing `--workspace` must match the saved Selected Workspace; use `caplets cloud auth switch ` for an explicit switch. +Hosted Cloud and self-hosted remotes use `caplets remote login ` and a saved Remote Profile. Passing `--workspace` must match the saved Selected Workspace; run `caplets remote login --workspace ` to select a different workspace. The attach client connects to the remote `/v1/attach` API for runtime Caplet discovery and calls. `/v1/mcp` remains the ordinary agent-facing MCP endpoint and continues to honor configured exposure policy. @@ -51,7 +51,7 @@ Hosted defaults are Free 25 MiB per file and 250 MiB per project, Plus 100 MiB a ## Recovery -- `cloud_auth_required`: `caplets cloud auth login` -- `workspace_switch_required`: `caplets cloud auth switch ` +- `cloud_auth_required`: `caplets remote login ` +- `workspace_switch_required`: `caplets remote login --workspace ` - `sync_size_limit_exceeded`: add exclusions to `.capletsignore` or upgrade the workspace plan - `endpoint_unavailable`: `caplets doctor` diff --git a/docs/solutions/integration-issues/stale-remote-profile-credentials-refresh.md b/docs/solutions/integration-issues/stale-remote-profile-credentials-refresh.md new file mode 100644 index 00000000..308792eb --- /dev/null +++ b/docs/solutions/integration-issues/stale-remote-profile-credentials-refresh.md @@ -0,0 +1,142 @@ +--- +title: Native Remote Clients Refresh Runtime Credentials Before Polls and Reconnects +date: 2026-06-19 +category: integration-issues +module: Native remote +problem_type: integration_issue +component: authentication +symptoms: + - background pollers kept using stale remote profile credentials after refresh + - attach event reconnects reused the old authorization header + - remote reloads and stale retries continued against outdated runtime options +root_cause: logic_error +resolution_type: code_fix +severity: high +tags: + - native-remote + - remote-profile + - credential-refresh + - runtime-options + - background-polling + - attach-events + - reconnects +--- + +# Native Remote Clients Refresh Runtime Credentials Before Polls and Reconnects + +## Problem + +Native remote clients were constructed with a one-time snapshot of remote runtime options. That included auth-bearing `requestInit`, so background manifest polling and attach event stream reconnects could keep using stale Remote Profile credentials after the profile refreshed. + +The immediate fix was commit `c354eaf` (`fix(core): refresh native remote background auth`). + +## Symptoms + +- Background attach event reconnects could keep sending the original `Authorization` header. +- Fallback polling for the remote attach manifest reused stale auth. +- Explicit user actions could refresh through `ProfileBackedNativeCapletsService`, while long-lived SDK clients continued with captured options. +- Stale-manifest retry logic inside tool execution did not help independent background fetch and reconnect paths. + +## What Didn't Work + +- Refreshing credentials before explicit `reload()` and `execute()` calls was not enough; the poll timer and SSE reconnect loop can run without a user action. +- Resetting a remote client on session failures did not cover auth-stale background requests, because the failing background requests were using a stale but structurally valid client. +- Retrying only after `ATTACH_MANIFEST_STALE` addressed changed exports, not changed credentials. +- Env-based remote setup was the wrong product direction for this class of issue. Prior session history settled on `caplets remote login ` plus Caplets-owned Remote Profiles, because agent env inheritance can be unreliable and can persist secrets in agent config (session history). +- One-time Remote Profile resolution at process startup fixed the first request but not long-running native services and attach streams after access credentials expired or rotated (session history). + +## Solution + +Make the SDK remote client resolve runtime options at request time rather than at construction time. + +The SDK client now accepts a resolver: + +```ts +export type SdkRemoteCapletsClientOptions = RemoteCapletsClientOptions["remote"] & { + resolveRuntimeOptions?: () => Promise; +}; +``` + +Then all outbound remote operations go through current runtime options: + +```ts +const fetchCurrentManifest = async (): Promise => { + const runtimeOptions = await resolveRuntimeOptions(); + return await fetchAttachManifest( + runtimeOptions.url, + runtimeOptions.requestInit, + runtimeOptions.fetch ?? fetch, + ); +}; + +const invokeCurrentExport = async (body: { + revision: string; + kind: string; + exportId: string; + input: unknown; +}): Promise => { + const runtimeOptions = await resolveRuntimeOptions(); + return await invokeAttachExport( + runtimeOptions.url, + runtimeOptions.requestInit, + runtimeOptions.fetch ?? fetch, + body, + ); +}; +``` + +Apply the same rule to attach event stream startup and reconnect: + +```ts +const startEventsNow = async () => { + try { + const runtimeOptions = await resolveRuntimeOptions(); + if (closed || eventsAbort || listeners.size === 0) return; + eventsAbort = startAttachEvents( + runtimeOptions.url, + runtimeOptions.requestInit, + runtimeOptions.fetch ?? fetch, + listeners, + onClose, + ); + } catch { + scheduleEventsReconnect(); + } +}; +``` + +The profile-backed service wires its existing profile resolver into the client factory: + +```ts +const sdkOptions: SdkRemoteCapletsClientOptions = { + ...remoteOptions, + resolveRuntimeOptions: resolveRuntimeRemoteOptions, +}; +``` + +That keeps `RemoteNativeCapletsService` responsible for polling and subscription lifecycle while `ProfileBackedNativeCapletsService` remains responsible for turning Remote Profiles into current request credentials. + +## Why This Works + +The root cause was request-time staleness. The client had copied credentials into a long-lived closure, so later refreshes could update the profile without changing the headers used by pollers and reconnects. + +Resolving runtime options immediately before each remote request makes credential freshness independent of client lifetime: + +- manifest polling re-reads auth before each fetch +- attach event stream reconnects re-read auth before opening the next stream +- tool invokes and stale-manifest retries use the same request-time auth path +- profile-backed Remote Login remains the source of truth instead of agent env or startup-only state + +The event path also tracks closed state and event-start in-flight state so reconnect attempts do not duplicate or continue after shutdown. + +## Prevention + +- Treat auth-bearing request options as runtime state whenever credentials can refresh independently of client lifetime. +- Audit background HTTP paths separately from explicit user calls. Pollers, reconnect loops, heartbeat jobs, and retry helpers often bypass the refresh points used by direct commands. +- Regression-test token rotation at the transport boundary. The tests added with this fix assert that a second attach event connection and a later fallback poll observe `Bearer token-2` after the token changes. +- Keep Remote Profile resolution in the profile-backed layer and pass a resolver into lower-level clients instead of teaching lower layers how to read credential stores. + +## Related Issues + +- Related low-overlap doc: [Code Mode REPL sessions](../architecture-patterns/code-mode-repl-sessions.md). It covers stale live state and recovery behavior, but not Remote Profile auth, attach event streams, or background polling. +- GitHub issue search found no matching issues for remote auth, attach profile, stale credentials, or polling event stream credentials. diff --git a/packages/core/src/attach/options.ts b/packages/core/src/attach/options.ts index 854c2048..4e5a8ce0 100644 --- a/packages/core/src/attach/options.ts +++ b/packages/core/src/attach/options.ts @@ -15,6 +15,7 @@ export type AttachServeOptions = ServeOptions & { configPath: string; projectRoot: string; projectConfigPath: string; + authDir?: string | undefined; selection: ResolvedRemoteSelection; }; @@ -30,18 +31,17 @@ export async function resolveAttachServeOptions( configPath: resolveConfigPath(env.CAPLETS_CONFIG?.trim() || undefined), projectRoot, projectConfigPath: env.CAPLETS_PROJECT_CONFIG?.trim() || resolveProjectConfigPath(projectRoot), + ...(raw.authDir ? { authDir: raw.authDir } : {}), selection, }; } function attachLocalServeOptions(raw: RawAttachServeOptions): RawServeOptions { const { - user: _user, - password: _password, - token: _token, remoteUrl: _remoteUrl, workspace: _workspace, fetch: _fetch, + authDir: _authDir, projectRoot: _projectRoot, ...serve } = raw; diff --git a/packages/core/src/attach/server.ts b/packages/core/src/attach/server.ts index fe5ba191..099669b8 100644 --- a/packages/core/src/attach/server.ts +++ b/packages/core/src/attach/server.ts @@ -1,8 +1,5 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio"; -import type { CapletsRemoteAuth } from "../remote/options"; -import { createSdkRemoteCapletsClient } from "../native/remote"; import { createNativeCapletsService } from "../native/service"; -import type { NativeRemoteAuthOptions } from "../native/options"; import { serveHttpWithSessionFactory } from "../serve/http"; import { NativeCapletsMcpSession } from "../serve/native-session"; import type { AttachServeOptions } from "./options"; @@ -39,8 +36,12 @@ function createAttachNativeService(options: AttachServeOptions, io: AttachServeI mode: options.selection.kind === "hosted_cloud" ? "cloud" : "remote", configPath: options.configPath, projectConfigPath: options.projectConfigPath, + ...(options.authDir ? { authDir: options.authDir } : {}), remote: { url: options.selection.remote.baseUrl.toString(), + ...(options.selection.kind === "hosted_cloud" + ? { workspace: options.selection.selectedWorkspace } + : {}), ...(options.selection.remote.fetch ? { fetch: options.selection.remote.fetch } : {}), ...(options.selection.kind === "hosted_cloud" ? { @@ -53,25 +54,9 @@ function createAttachNativeService(options: AttachServeOptions, io: AttachServeI } : {}), }, - remoteClientFactory: (resolved) => - createSdkRemoteCapletsClient({ - ...resolved, - requestInit: options.selection.remote.requestInit, - auth: nativeAuthFromRemoteAuth(options.selection.remote.auth), - url: options.selection.remote.attachUrl, - ...(options.selection.remote.fetch ? { fetch: options.selection.remote.fetch } : {}), - }), exposeLocalArtifactPaths: false, ...(io.writeErr ? { writeErr: io.writeErr } : {}), }); } -function nativeAuthFromRemoteAuth(auth: CapletsRemoteAuth): NativeRemoteAuthOptions { - if (auth.type === "basic") { - return { enabled: true, user: auth.user, password: auth.password }; - } - if (auth.type === "none") { - return { enabled: false, user: auth.user }; - } - return { enabled: false, user: "caplets" }; -} +export const createAttachNativeServiceForTests = createAttachNativeService; diff --git a/packages/core/src/cli.ts b/packages/core/src/cli.ts index f551b286..23142be6 100644 --- a/packages/core/src/cli.ts +++ b/packages/core/src/cli.ts @@ -1,6 +1,8 @@ import { Command, CommanderError } from "commander"; +import { Buffer } from "node:buffer"; import { dirname, join } from "node:path"; import { createInterface } from "node:readline/promises"; +import { Writable } from "node:stream"; import { version as packageJsonVersion } from "../package.json"; import { addCliCaplet, @@ -35,11 +37,6 @@ import { } from "./cli/completion"; import { CloudAuthClient } from "./cloud-auth/client"; import { openBrowserUrl } from "./cloud-auth/open-url"; -import { - CloudAuthStore, - redactedCloudAuthStatus, - type CloudAuthCredentials, -} from "./cloud-auth/store"; import { formatCapletList, formatConfigPaths, @@ -76,7 +73,20 @@ import { ProjectBindingError } from "./project-binding/errors"; import type { ProjectBindingWebSocketFactory } from "./project-binding/transport"; import { RemoteControlClient } from "./remote-control/client"; import type { RemoteCliCommand } from "./remote-control/types"; -import { resolveCapletsRemote, resolveRemoteMode } from "./remote/options"; +import { + cloudCredentialsFromRemoteProfile, + createRemoteProfileStore, + type FileRemoteProfileStore, +} from "./remote/profile-store"; +import { RemoteServerCredentialStore } from "./remote/server-credential-store"; +import { resolveRemoteSelection } from "./remote/selection"; +import { + hostedCloudWorkspaceFromRemoteUrl, + isCapletsCloudUrl, + normalizeRemoteProfileHostUrl, + resolveRemoteMode, +} from "./remote/options"; +import type { RemoteProfileCredential, RemoteProfileStatus } from "./remote/profiles"; import { daemonLogs, daemonStatus, @@ -93,6 +103,8 @@ import { type DaemonOperationOptions, } from "./daemon"; import { resolveServeOptions, serveResolvedCaplets, type ServeOptions } from "./serve"; +import { DEFAULT_AUTH_DIR } from "./config/paths"; +import { appendBasePath } from "./server/options"; export { initConfig, starterConfig } from "./cli/init"; export { installCaplets, normalizeGitRepo } from "./cli/install"; @@ -153,8 +165,7 @@ type DaemonInstallCommandOptions = { host?: string; port?: string; path?: string; - user?: string; - password?: string; + remoteStatePath?: string; allowUnauthenticatedHttp?: boolean; trustProxy?: boolean; json?: boolean; @@ -188,8 +199,7 @@ function addDaemonInstallOptions(command: Command): Command { .option("--host ", "HTTP bind host") .option("--port ", "HTTP bind port") .option("--path ", "HTTP service base path") - .option("--user ", "HTTP Basic Auth username") - .option("--password ", "HTTP Basic Auth password") + .option("--remote-state-path ", "server-owned remote credential state directory") .option( "--allow-unauthenticated-http", "allow unauthenticated HTTP serving on non-loopback hosts", @@ -228,14 +238,21 @@ function collectValues(value: string, previous: string[]): string[] { return [...previous, value]; } -function cloudAuthStore( +function remoteProfileStore( + authDir: string | undefined, env: NodeJS.ProcessEnv | Record, -): CloudAuthStore { - return new CloudAuthStore({ env }); +): FileRemoteProfileStore { + return createRemoteProfileStore({ authDir, env }); } -function cloudAuthStatus(credentials: CloudAuthCredentials | undefined): Record { - return redactedCloudAuthStatus(credentials); +function remoteServerCredentialStore( + statePath: string | undefined, + env: NodeJS.ProcessEnv | Record, +): RemoteServerCredentialStore { + return new RemoteServerCredentialStore({ + dir: + statePath ?? env.CAPLETS_REMOTE_SERVER_STATE_DIR ?? join(DEFAULT_AUTH_DIR, "remote-server"), + }); } function compactCloudCaplet(value: unknown): Record { @@ -266,8 +283,7 @@ function daemonInstallOptions(options: DaemonInstallCommandOptions): DaemonInsta ...(options.host !== undefined ? { host: options.host } : {}), ...(options.port !== undefined ? { port: options.port } : {}), ...(options.path !== undefined ? { path: options.path } : {}), - ...(options.user !== undefined ? { user: options.user } : {}), - ...(options.password !== undefined ? { password: options.password } : {}), + ...(options.remoteStatePath !== undefined ? { remoteStatePath: options.remoteStatePath } : {}), ...(options.allowUnauthenticatedHttp !== undefined ? { allowUnauthenticatedHttp: options.allowUnauthenticatedHttp } : {}), @@ -303,6 +319,244 @@ async function waitForCloudLogin( return { status: "expired" as const, message: "Cloud Auth login timed out." }; } +async function loginCloudRemoteProfile( + url: string, + options: { + workspace?: string; + deviceName?: string; + open?: boolean; + json?: boolean; + }, + store: FileRemoteProfileStore, + io: { + env: NodeJS.ProcessEnv | Record; + fetch?: typeof fetch; + writeOut: (value: string) => void; + }, +): Promise { + const client = new CloudAuthClient({ cloudUrl: url, ...(io.fetch ? { fetch: io.fetch } : {}) }); + const requestedWorkspace = options.workspace ?? hostedCloudWorkspaceFromRemoteUrl(url); + const started = await client.startLogin({ + requestedWorkspace, + deviceName: options.deviceName ?? io.env.CAPLETS_DEVICE_NAME ?? "Caplets CLI", + }); + if (options.open !== false) await openBrowserUrl(started.loginUrl); + if (!options.json) { + io.writeOut(`Open ${started.loginUrl}\n`); + io.writeOut(`Enter code ${started.userCode} if prompted.\n`); + } + + const completed = await waitForCloudLogin(client, started.loginId, io.env); + if (completed.status !== "completed") { + throw new CapletsError("AUTH_FAILED", `Remote Login Cloud flow ${completed.status}.`); + } + const exchanged = await client.exchangeToken({ + loginId: started.loginId, + oneTimeCode: completed.oneTimeCode, + }); + return store.saveCloudProfile({ + hostUrl: exchanged.cloudUrl, + workspaceId: exchanged.workspaceId, + ...(exchanged.workspaceSlug ? { workspaceSlug: exchanged.workspaceSlug } : {}), + clientLabel: exchanged.deviceName ?? options.deviceName ?? "Caplets CLI", + credentials: { + accessToken: exchanged.accessToken, + refreshToken: exchanged.refreshToken ?? "", + expiresAt: exchanged.expiresAt, + scope: exchanged.scope, + tokenType: exchanged.tokenType, + }, + }); +} + +async function pairingCodeFromOptions( + options: { code?: string; codeStdin?: boolean }, + readStdin: (() => Promise) | undefined, + writeErr: (value: string) => void, +): Promise { + if (options.code?.trim()) { + writeErr( + "Warning: --code may store the Pairing Code in shell history; prefer the hidden prompt or --code-stdin for automation.\n", + ); + return options.code.trim(); + } + if (options.codeStdin) { + const value = readStdin ? await readStdin() : await readAllStdin(); + const code = value.trim(); + if (code) return code; + throw new CapletsError( + "REQUEST_INVALID", + "Pairing Code is required when --code-stdin is used.", + ); + } + const output = new HiddenPromptOutput(process.stdout); + const readline = createInterface({ input: process.stdin, output, terminal: true }); + try { + const code = (await readline.question("Pairing Code: ")).trim(); + if (code) return code; + } finally { + readline.close(); + process.stdout.write("\n"); + } + throw new CapletsError( + "REQUEST_INVALID", + "Pairing Code is required for self-hosted Remote Login.", + ); +} + +class HiddenPromptOutput extends Writable { + private wrotePrompt = false; + + constructor(private readonly output: NodeJS.WriteStream) { + super(); + } + + override _write( + chunk: Buffer | string, + _encoding: BufferEncoding, + callback: (error?: Error | null) => void, + ): void { + if (!this.wrotePrompt) { + this.output.write(chunk); + this.wrotePrompt = true; + } + callback(); + } +} + +async function readAllStdin(): Promise { + const chunks: Buffer[] = []; + for await (const chunk of process.stdin) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk))); + } + return Buffer.concat(chunks).toString("utf8"); +} + +type RemoteLoginCredentialsResponse = { + clientId: string; + clientLabel: string; + accessToken: string; + refreshToken: string; + tokenType?: string | undefined; + expiresAt?: string | undefined; +}; + +async function parseRemoteLoginCredentials( + response: Response, +): Promise { + const parsed = await response.json(); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new CapletsError("DOWNSTREAM_PROTOCOL_ERROR", "Remote Login response must be an object."); + } + const record = parsed as Record; + if ( + typeof record.clientId !== "string" || + typeof record.accessToken !== "string" || + typeof record.refreshToken !== "string" + ) { + throw new CapletsError( + "DOWNSTREAM_PROTOCOL_ERROR", + "Remote Login response is missing credentials.", + ); + } + return { + clientId: record.clientId, + clientLabel: typeof record.clientLabel === "string" ? record.clientLabel : "Caplets CLI", + accessToken: record.accessToken, + refreshToken: record.refreshToken, + ...(typeof record.tokenType === "string" ? { tokenType: record.tokenType } : {}), + ...(typeof record.expiresAt === "string" ? { expiresAt: record.expiresAt } : {}), + }; +} + +async function revokeSelfHostedRemoteClient( + remoteUrl: string, + accessToken: string, + fetchImpl: typeof fetch = fetch, +): Promise { + const revokeUrl = appendBasePath( + new URL(normalizeRemoteProfileHostUrl(remoteUrl)), + "v1/remote/client", + ); + await fetchImpl(revokeUrl, { + method: "DELETE", + headers: { Authorization: `Bearer ${accessToken}` }, + }); +} + +async function selfHostedLogoutAccessToken( + remoteUrl: string, + credential: RemoteProfileCredential & { accessToken: string }, + input: { authDir?: string | undefined; fetch?: typeof fetch | undefined }, + env: Record, +): Promise { + const selection = await resolveRemoteSelection( + { + mode: "remote", + remoteUrl, + ...(input.authDir ? { authDir: input.authDir } : {}), + ...(input.fetch ? { fetch: input.fetch } : {}), + }, + env, + ); + return selection.kind === "self_hosted_remote" && selection.remote.auth.type === "bearer" + ? selection.remote.auth.token + : credential.accessToken; +} + +function writeRemoteStatus( + status: RemoteProfileStatus | Record, + json: boolean, + writeOut: (value: string) => void, +): void { + if (json) { + writeOut(`${JSON.stringify(status, null, 2)}\n`); + return; + } + if (status.authenticated) { + const workspace = + typeof status.workspaceSlug === "string" + ? ` workspace ${status.workspaceSlug}` + : typeof status.workspaceId === "string" + ? ` workspace ${status.workspaceId}` + : ""; + writeOut(`Authenticated to ${status.hostUrl}${workspace}.\n`); + return; + } + writeOut(`Not authenticated to ${status.hostUrl}.\n`); +} + +async function loadCloudRemoteProfileCredentials( + store: FileRemoteProfileStore, + input: { cloudUrl: string; workspace?: string | undefined }, +): Promise<{ status: RemoteProfileStatus; credential: RemoteProfileCredential }> { + const status = await store.getCloudProfileStatus({ + hostUrl: input.cloudUrl, + ...(input.workspace ? { workspace: input.workspace } : {}), + }); + const credential = status ? await store.credentials.load(status.key) : undefined; + if (!status || !credential?.accessToken) { + throw new CapletsError("AUTH_REQUIRED", "Run caplets remote login first."); + } + return { status, credential }; +} + +function defaultCloudUrl( + env: NodeJS.ProcessEnv | Record, + cloudUrl?: string, +): string { + return cloudUrl ?? env.CAPLETS_CLOUD_URL ?? "https://cloud.caplets.dev"; +} + +function terminalSafeText(value: string): string { + let safe = ""; + for (const char of value) { + const code = char.codePointAt(0) ?? 0; + safe += code <= 0x1f || (code >= 0x7f && code <= 0x9f) ? "?" : char; + } + return safe; +} + function numberEnv(value: string | undefined, fallback: number): number { if (value === undefined) return fallback; const parsed = Number(value); @@ -529,8 +783,7 @@ export function createProgram(io: CliIO = {}): Command { .option("--host ", "HTTP bind host") .option("--port ", "HTTP bind port") .option("--path ", "HTTP service base path") - .option("--user ", "HTTP Basic Auth username") - .option("--password ", "HTTP Basic Auth password") + .option("--remote-state-path ", "server-owned remote credential state directory") .option( "--allow-unauthenticated-http", "allow unauthenticated HTTP serving on non-loopback hosts", @@ -542,8 +795,7 @@ export function createProgram(io: CliIO = {}): Command { host?: string; port?: string; path?: string; - user?: string; - password?: string; + remoteStatePath?: string; allowUnauthenticatedHttp?: boolean; trustProxy?: boolean; }) => { @@ -724,9 +976,6 @@ export function createProgram(io: CliIO = {}): Command { .option("--port ", "HTTP bind port") .option("--path ", "HTTP service base path") .option("--remote-url ", "remote Caplets service base URL") - .option("--user ", "remote Basic Auth username") - .option("--password ", "remote Basic Auth password") - .option("--token ", "remote bearer token") .option("--workspace ", "hosted Cloud workspace ID or slug") .option( "--allow-unauthenticated-http", @@ -744,9 +993,6 @@ export function createProgram(io: CliIO = {}): Command { host?: string; port?: string; path?: string; - user?: string; - password?: string; - token?: string; workspace?: string; allowUnauthenticatedHttp?: boolean; trustProxy?: boolean; @@ -756,7 +1002,11 @@ export function createProgram(io: CliIO = {}): Command { projectRoot?: string; }) => { try { - const attachOptions = { ...options, ...(io.fetch ? { fetch: io.fetch } : {}) }; + const attachOptions = { + ...options, + ...(io.fetch ? { fetch: io.fetch } : {}), + ...(io.authDir ? { authDir: io.authDir } : {}), + }; if (!options.once) { const resolved = await resolveAttachServeOptions(attachOptions, env); await ( @@ -830,6 +1080,228 @@ export function createProgram(io: CliIO = {}): Command { }, ); + const remote = program.command(cliCommands.remote).description("Manage Caplets Remote Login."); + remote + .command("login") + .description("Log this machine into a Caplets host.") + .argument("", "Caplets host URL") + .option("--workspace ", "Cloud workspace ID or slug to select") + .option("--client-label