From ffad8c0fd597e63f6f48e11343ff19dda73e380c Mon Sep 17 00:00:00 2001 From: Ethan Date: Wed, 26 Aug 2026 19:39:10 -0700 Subject: [PATCH] Add Hosted Cloud onboarding to the am CLI init flow ## Summary Add a Hosted Cloud onboarding path to `am init` so users can get a managed AtomicMemory project without Docker or an OpenAI key, alongside credential-handling and installer hardening. ## Changes - Add a Hosted Cloud initialization module that signs in, selects or creates a managed project, and stores a project-bound credential. - Make Hosted Cloud the default `am init` path; keep Connected Local available via `am init --local`, with `--project` inferring Cloud vs. Local from the resolved project type. - Scope managed API key names per installation (`am-cli-<12-hex>`, `connected-local-runtime-<12-hex>`). - Prefer the stored project-bound credential over an exported `ATOMICMEMORY_API_KEY` for init-managed Hosted Cloud profiles; set `ATOMICMEMORY_API_KEY_FORCE=1` to restore the environment override, and surface the active credential source in `am doctor`, `am config env show`, and `am init`. - Harden credential selection and OAuth session handling across CLI commands, and tighten the installer's build-attestation verification. - Improve the R2 mirror workflow's handling of release assets. - Update `README.md` and `crates/cli/README.md` to document the Hosted Cloud quickstart, project selection, and installer verification flags. - Bump the workspace version to 0.2.1 ahead of the next CLI release and refresh a transitive dependency. ## Validation ```bash pnpm run ci:rust pnpm run test:mirror-cli-r2 pnpm run test:install-cli ``` --- .github/workflows/ci.yml | 1 + .github/workflows/mirror-cli-r2.yml | 12 +- Cargo.lock | 28 +- Cargo.toml | 4 +- README.md | 474 ++--- crates/cli/README.md | 106 +- crates/cli/src/auth/token.rs | 157 +- crates/cli/src/cli.rs | 29 + crates/cli/src/commands/client.rs | 136 +- crates/cli/src/commands/cloud_api_key.rs | 391 +++- crates/cli/src/commands/config_cmd.rs | 66 +- crates/cli/src/commands/connect.rs | 1 + crates/cli/src/commands/connect_project.rs | 120 +- crates/cli/src/commands/doctor_cmd.rs | 9 +- crates/cli/src/commands/init.rs | 700 ++++++- crates/cli/src/commands/init/hosted_cloud.rs | 1855 +++++++++++++++++ crates/cli/src/commands/instance.rs | 852 ++++++-- crates/cli/src/commands/memory/mod.rs | 6 +- crates/cli/src/commands/migrate.rs | 299 ++- crates/cli/src/config.rs | 1536 +++++++++++++- crates/cli/src/environment.rs | 118 +- crates/cli/src/instance/docker.rs | 22 + crates/cli/src/instance/mod.rs | 22 +- crates/cli/src/progress.rs | 45 + crates/cli/src/telemetry.rs | 27 + crates/cli/src/validation/openai.rs | 2 +- crates/cli/src/validation/recovery.rs | 246 ++- crates/cli/src/verification/receipt.rs | 2 +- crates/cli/src/verification/smoke.rs | 59 +- crates/cloud-types/src/onboarding.rs | 2 +- package.json | 3 +- packages/core/README.md | 2 +- plugins/claude-code/README.md | 4 +- plugins/codex/README.md | 4 +- .../install-cli-attestation-cases.sh | 121 ++ .../__tests__/install-cli-internal.test.sh | 10 + scripts/__tests__/install-cli-path-cases.sh | 121 ++ scripts/__tests__/install-cli.test.sh | 105 +- scripts/__tests__/mirror-cli-r2.test.sh | 71 + scripts/__tests__/run-installer-in-pty.py | 46 + scripts/install-cli-internal.sh | 4 +- scripts/install-cli.sh | 270 ++- .../docs-contract/public-smoke-contract.json | 3 +- .../scripts/validate-public-smoke-contract.sh | 8 +- 44 files changed, 7250 insertions(+), 849 deletions(-) create mode 100644 crates/cli/src/commands/init/hosted_cloud.rs create mode 100644 scripts/__tests__/install-cli-attestation-cases.sh create mode 100644 scripts/__tests__/install-cli-path-cases.sh create mode 100755 scripts/__tests__/mirror-cli-r2.test.sh create mode 100644 scripts/__tests__/run-installer-in-pty.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 23e83f2..525b9a4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -280,5 +280,6 @@ jobs: run: | pnpm run test:security-compliance pnpm run test:release-cli-version + pnpm run test:mirror-cli-r2 pnpm run test:install-cli pnpm run security-compliance diff --git a/.github/workflows/mirror-cli-r2.yml b/.github/workflows/mirror-cli-r2.yml index 7b3b76d..b571feb 100644 --- a/.github/workflows/mirror-cli-r2.yml +++ b/.github/workflows/mirror-cli-r2.yml @@ -147,13 +147,23 @@ jobs: env: VERSION: ${{ steps.rel.outputs.version }} R2_PUBLIC_BASE_URL: ${{ secrets.R2_PUBLIC_BASE_URL }} + # The installer defaults to AM_VERIFY_ATTESTATION=auto and `gh` is + # present on runners, so it runs `gh attestation verify --repo + # atomicstrata/atomicmemory`. Without a token gh refuses outright and + # the whole verification fails. Same token the download step uses to + # reach the public repo from here. + GH_TOKEN: ${{ github.token }} run: | set -euo pipefail ver="$VERSION" base="${R2_PUBLIC_BASE_URL:-https://get.atomicstrata.ai}" base="${base%/}" + # Invoke through `sh`: release assets downloaded from GitHub do not + # keep their executable bit, so calling dist/install.sh directly + # exits 126 (Permission denied). This also matches how users consume + # it (`curl ... | sh`). AM_BASE_URL="${base}" AM_VERSION="${ver}" \ - dist/install.sh --bin-dir "$HOME/.am/bin" --no-modify-path + sh dist/install.sh --bin-dir "$HOME/.am/bin" --no-modify-path got="$("$HOME/.am/bin/am" --version)" expected="am ${ver}" if [ "$got" != "$expected" ]; then diff --git a/Cargo.lock b/Cargo.lock index baafd24..fde2f2c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -13,7 +13,7 @@ dependencies = [ [[package]] name = "am-cloud-client" -version = "0.2.0" +version = "0.2.1" dependencies = [ "am-cloud-types", "am-core-types", @@ -30,7 +30,7 @@ dependencies = [ [[package]] name = "am-cloud-types" -version = "0.2.0" +version = "0.2.1" dependencies = [ "am-core-types", "anyhow", @@ -47,7 +47,7 @@ dependencies = [ [[package]] name = "am-core-types" -version = "0.2.0" +version = "0.2.1" dependencies = [ "chrono", "serde", @@ -149,7 +149,7 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "atomicmemory" -version = "0.2.0" +version = "0.2.1" dependencies = [ "am-cloud-client", "am-cloud-types", @@ -556,7 +556,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -595,7 +595,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -758,9 +758,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.15" +version = "0.4.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" dependencies = [ "atomic-waker", "bytes", @@ -1282,7 +1282,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -1479,7 +1479,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -1683,7 +1683,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -1740,7 +1740,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -2046,7 +2046,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.4", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -2564,7 +2564,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index ed66500..4aa62f8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,7 +16,7 @@ lto = "thin" codegen-units = 1 [workspace.package] -version = "0.2.0" +version = "0.2.1" edition = "2024" rust-version = "1.88" license = "Apache-2.0" @@ -25,7 +25,7 @@ publish = false [workspace.dependencies] async-trait = "0.1.89" axum = { version = "0.8.9", features = ["macros", "json", "tokio"] } -tokio = { version = "1.52.3", features = ["full"] } +tokio = { version = "1.52.3", features = ["full", "test-util"] } reqwest = { version = "0.13.3", default-features = false, features = [ "rustls", "json", diff --git a/README.md b/README.md index f8fac09..22e1918 100644 --- a/README.md +++ b/README.md @@ -3,30 +3,21 @@ [![CI](https://github.com/atomicstrata/atomicmemory/actions/workflows/ci.yml/badge.svg)](https://github.com/atomicstrata/atomicmemory/actions/workflows/ci.yml) [![Core npm](https://img.shields.io/npm/v/%40atomicmemory%2Fcore?label=core)](https://www.npmjs.com/package/@atomicmemory/core) [![SDK npm](https://img.shields.io/npm/v/%40atomicmemory%2Fsdk?label=sdk)](https://www.npmjs.com/package/@atomicmemory/sdk) -[![CLI npm](https://img.shields.io/npm/v/%40atomicmemory%2Fcli?label=cli)](https://www.npmjs.com/package/@atomicmemory/cli) [![Docker](https://img.shields.io/badge/docker-GHCR-2496ED?logo=docker&logoColor=white)](packages/core/Dockerfile) [![Docs](https://img.shields.io/badge/docs-docs.atomicstrata.ai-blue)](https://docs.atomicstrata.ai) [![License: Apache 2.0](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE) -**Inspectable, portable semantic memory for agents and applications.** +**Inspectable, correction-aware memory for agents and AI applications.** -AtomicMemory is a memory layer you embed where your AI code already runs. Capture -context, ground generations in prior interactions, and carry knowledge across -sessions — from a direct SDK call, a CLI, an MCP server, a framework adapter, or -a host plugin. Local-first where supported, hosted where convenient, and -designed so the choice can change later without rewriting your application. +AtomicMemory gives agents durable context across sessions without coupling your +application to one model, framework, or deployment. Start with managed Hosted +Cloud, run the open-source Core locally, or integrate through the TypeScript SDK +and MCP server. -Most memory products ask you to trust a hosted black box with the layer that -decides what an AI believes about your users. AtomicMemory takes the opposite -position: the interface should be portable, the engine should be inspectable, -and the memory system should be able to revise itself when facts change. - -This repository is the public source of truth for the AtomicMemory JavaScript / -TypeScript packages, framework adapters, host plugins, and public smoke tests. - -**Docs:** [docs.atomicstrata.ai](https://docs.atomicstrata.ai) - -**Field note:** [The AI Memory Industry Has A Black Box Problem](https://www.atomicstrata.ai/blog/the-ai-memory-industry-has-a-black-box-problem) +[Documentation](https://docs.atomicstrata.ai) · +[Hosted Cloud](https://memory.atomicstrata.ai) · +[Open-source quickstart](https://docs.atomicstrata.ai/open-source/quickstart) · +[Why inspectable memory matters](https://www.atomicstrata.ai/blog/the-ai-memory-industry-has-a-black-box-problem) ## Headline Results @@ -47,153 +38,166 @@ reported category while preserving the lower-cost operating profile that matters for real applications. Reproducibility artifacts and harness details will be published with the benchmark materials. +## Quickstart + +Install the `am` CLI and initialize Hosted Cloud in one guided command: + +```bash +curl --proto '=https' --tlsv1.2 -fsSL https://get.atomicstrata.ai/install.sh | sh -s -- --init +``` + +Plain `am init` defaults to managed Cloud, selects your project, and saves its +project-bound credential. Managed server keys use a per-installation name such +as `am-cli-a1b2c3d4e5f6`, so another machine's credential is not rotated. No +Docker or OpenAI key is required. +Non-interactive Cloud automation uses `am init --yes --project `; +Local automation must opt in with `am init --local --yes`. + +The installer runs initialization through the verified binary directly. If it +updates PATH, open a new terminal before the next commands or use the +shell-specific activation command it prints. + +Store a preference and retrieve it: + +```bash +am memory ingest "I prefer aisle seats when flying." +am memory search "seat preference" +``` + +Connect the active profile to an agent host when you are ready: + +```bash +am integrate --yes --host cursor # or claude-code / codex +``` + +`am integrate` writes the host's user-level MCP configuration. It does not +install a marketplace plugin. + +## Choose your path + +| Path | Best for | Start here | +| --- | --- | --- | +| **Hosted Cloud** | Managed memory with the fastest setup | Guided installer above, or `am init` | +| **Connected Local** | Running open-source Core on your machine | `am init --local` | +| **TypeScript SDK** | Server-side application integration against Core | `npm install @atomicmemory/sdk` | + +Agent integration through MCP works with either an active Cloud or Local +profile. See the [documentation](https://docs.atomicstrata.ai) for framework and +host-specific guides. + ## Why AtomicMemory -- **Portable**: a single memory protocol consumed by direct SDK calls, CLIs, - the MCP server, framework adapters, and host plugins. The same memory store - serves a LangGraph agent, a Claude Code session, and a custom Vercel AI - application without re-implementing capture or retrieval semantics. -- **SDK-agnostic**: every adapter is built on the same SDK. Adapters are - conveniences, not gatekeepers. You can drop down to the SDK at any time and - keep the same data, indexes, and retrieval behavior. -- **Inspectable**: Core is open source, self-hostable, and built around - explicit mutation decisions rather than an opaque hosted opinion. -- **Correction-aware**: memory is not just append and recall. Real products - need supersession, clarification, deletion, no-op decisions, lineage, and - trust-sensitive revision when users change their mind. -- **Model-surface portable**: the SDK lets applications swap memory backends; - Core separates embeddings, extraction, mutation, reranking, retrieval - packaging, and evaluation so the memory engine is not frozen to one model - vintage. -- **Local or hosted, your choice**: the core engine runs locally for - privacy-sensitive workloads. The hosted profile is available where it makes - sense and is marked clearly in the package matrix below. There is no - capability cliff between the two. -- **No lock-in**: package APIs are stable and semver-disciplined. Migrating - between direct SDK use, adapters, and host plugins is documented and does not - require re-ingesting your data. You own your memory store. - -## What This Repository Provides - -- **Core** — Docker-deployable memory backend with durable context, semantic - retrieval, memory mutation, and Postgres/pgvector storage. -- **SDK** — backend-agnostic TypeScript client surface with provider interfaces, - storage helpers, local embeddings, and semantic search primitives. -- **CLI and MCP** — command-line and MCP surfaces for setup, diagnostics, - capture, retrieval, and context packaging. -- **Framework adapters** — integration packages for Vercel AI SDK, OpenAI - Agents SDK, LangChain, LangGraph, and Mastra. -- **Host plugins** — package and manifest surfaces for agent hosts such as - Claude Code, OpenClaw, Hermes, Codex, and Cursor. -- **Public validation** — package metadata checks, smoke contracts, and - contributor-safe CI gates that keep install paths, docs, and package status in - sync. - -The SDK is the portability contract: applications depend on a typed interface -and provider boundary instead of one memory vendor. Core is the engine that can -earn that slot: self-hosted, auditable, and designed around revision rather than -append-only recall. - -## What This Is Not - -- Not the hosted AtomicMemory service infrastructure. See [memory.atomicstrata.ai](https://memory.atomicstrata.ai). -- Not the release orchestration or marketplace operations system. -- Not the Python SDK; the Python package remains in its own repository and PyPI - metadata for now. -- Not the benchmark research repo. Reproducible benchmark suites and raw eval - harnesses live outside this public monorepo until they are ready to publish as - public artifacts. -- Not a replacement for package-level READMEs. Package-specific setup still - lives under `packages/`, `adapters/`, and `plugins/`. - -## Performance posture - -We make supportable performance claims, not marketing ones. The headline -results above are benchmark scores under matched methodology; latency, -recall@k, and scale-envelope claims should only be quoted when paired with the -linked benchmark, hardware, dataset, and date used to produce them. - -Until latency benchmarks are linked from the docs, treat the engine as -"designed for single-digit-ms local retrieval on a developer laptop at typical -agent corpus sizes" — a design target, not a guarantee. - -## Validation boundary - -This section documents what this repository's public CI proves on its own, so -readers can see exactly what is verified here and do not assume this repository -independently proves every product claim. For what this repository is not -responsible for, see "What This Is Not" above. - -**Proven in this repository's public CI (every pull request):** - -- repo hygiene -- package metadata checks -- affected build, typecheck, lint, and self-contained package tests (Node 22 - and 24) -- code-health gates -- package `pack` dry-run plus tarball-shape verification -- docs contract (install commands, package status labels, and smoke rows stay - in sync) -- public integration smoke -- security compliance - -**Also in this repository, run in package or release contexts (not the per-PR -affected lane):** - -- Core OpenAPI generation and drift check (`generate:openapi` / `check:openapi`) -- Core API schema tests (Schemathesis) -- Core Docker image smoke (runs in the Docker image publish workflow) -- DB-backed Core tests, which require Postgres/pgvector provisioning +- **Correction-aware** — supersede, clarify, delete, or retain memories as facts + change instead of treating memory as append-only recall. +- **Portable** — use one memory protocol through the CLI, MCP server, SDK, + framework adapters, and host plugins. +- **Inspectable** — run the open-source Core and audit the mutation and retrieval + path rather than depending only on a hosted black box. +- **Model-flexible** — keep extraction, embeddings, mutation, reranking, and + retrieval packaging behind explicit provider boundaries. +- **Cloud or Local** — begin with managed Cloud or operate Core yourself without + rewriting the integration surface. + +## Use AtomicMemory + +### Hosted Cloud + +Interactive `am init` offers **Hosted Cloud** as option 1/default and +**Connected Local** as option 2. Hosted Cloud needs no Docker or OpenAI key: + +- With one project, the CLI selects it automatically. With multiple projects, + it prompts for a selection. +- With no project, it opens onboarding and waits for project creation in an + interactive terminal. Non-interactive runs print the URL and recovery command + instead of waiting. +- Credentials are bound to the Cloud origin and project, stored with owner-only + permissions, and never printed. +- A working stored project credential is reused. Otherwise the CLI rotates only + this installation's exact `am-cli-<12-hex>` key and creates it when absent. + Legacy unsuffixed keys and other installations' keys are left untouched. + +`--project` accepts a unique ID or case-insensitive slug and infers Cloud versus +Local from the resolved project type. Explicit selectors assert the type: -## Quickstart +```bash +am init --project # infer Cloud or Local +am init --cloud --project # require a Cloud project +am init --local --project # require a Local project +``` + +Ambiguous slugs fail with a request for the unique project ID. At the API-key +limit, initialization preserves the previous default profile and prints the +dashboard URL plus exact commands to list or revoke a key and retry. It never +rotates or revokes unrelated keys as quota recovery. -For the full walkthrough, see the -[AtomicMemory quickstart](https://docs.atomicstrata.ai/quickstart). +### Connected Local -### Cloud and agent hosts (recommended) +Connected Local runs Core on your machine and can link it to Cloud for trace +visibility. It requires: -Install the CLI **`am`**, sign in, and wire the published MCP server -into Cursor, Claude Code, or Codex. `am integrate` updates **host MCP config** -only — it does not install marketplace plugin packages. Codex and Cursor plugin -packages remain **coming soon** in the package matrix below. +- Docker Desktop or Docker Engine running +- An [OpenAI API key](https://platform.openai.com/api-keys); interactive setup + reads it with hidden input and stores it with owner-only permissions +- macOS or glibc Linux on x86_64 or arm64 + +Initialize and verify Local: ```bash -curl -fsSL https://get.atomicstrata.ai/install.sh | sh -. "$HOME/.atomicmemory/env" # activate PATH in this shell -am init -am integrate --yes --host cursor # or claude-code / codex +am init --local +am doctor --smoke ``` -The installer writes `~/.atomicmemory/env` and adds it to your shell profile, so -the activation line is only needed in the shell you installed from — new -terminals pick `am` up automatically. `am integrate` installs into your user -(global) config by default. +The Local defaults remain profile `local` and Core URL +`http://127.0.0.1:17350`. For headless automation, seed dashboard auth and the +OpenAI key before selecting Local explicitly: -See [`crates/cli/README.md`](crates/cli/README.md) for auth, Connected Local, -`am integrate doctor`, and distribution details. +```bash +am auth login --token "$AM_DASHBOARD_JWT" +export OPENAI_API_KEY=sk-... # inject through your secret manager +am init --local --yes +``` + +For Core-only Docker without a Cloud account, use the +[Core package guide](packages/core/README.md#docker-image-recommended). The +[open-source quickstart](https://docs.atomicstrata.ai/open-source/quickstart) +covers lifecycle, custom URLs, and troubleshooting; the +[CLI README](crates/cli/README.md) documents every initialization flag. -### Library and framework adapters +### Agent hosts and MCP -These commands use currently-published npm packages. Host plugin surfaces that -are not yet public are listed in the package matrix below and are not part of the -main install path. +Both initialization paths leave an active profile that the published MCP server +can use. Configure a supported host with: ```bash -# direct SDK -npm install @atomicmemory/sdk +am integrate --yes --host cursor # or claude-code / codex +``` + +Codex and Cursor marketplace plugin packages remain **coming soon** in the +matrix below; direct MCP configuration through `am integrate` is a separate +supported path. Use `am integrate doctor` to diagnose host configuration. -# framework adapter (example: Vercel AI SDK) -npm install @atomicmemory/vercel-ai @atomicmemory/sdk +### TypeScript SDK + +The SDK is server-side only in v1. Start Core first, then reveal the Local client +environment only in a trusted terminal: + +```bash +am connect env --for clients --show-secrets +npm install @atomicmemory/sdk ``` -Minimal SDK shape: +Copy `ATOMICMEMORY_CORE_URL` and `CORE_API_KEY` into the trusted server process; +never expose `CORE_API_KEY` in a browser bundle. Then use the memory client: ```ts import { MemoryClient } from '@atomicmemory/sdk'; const memory = new MemoryClient({ providers: { - atomicmemory: { apiUrl: 'http://localhost:17350' }, + atomicmemory: { + apiUrl: process.env.ATOMICMEMORY_CORE_URL!, + apiKey: process.env.CORE_API_KEY!, + }, }, }); @@ -210,25 +214,35 @@ const results = await memory.search({ }); ``` -The minimal example, environment setup, and the full list of supported hosts -and frameworks live in the docs site linked below. Adapter and plugin install -contracts (install type, local-core requirement, hosted-mode status) appear at -the top of each integration page. +Use `AtomicMemoryClient` when the application also needs the storage namespace. +See the [SDK quickstart](https://docs.atomicstrata.ai/sdk/quickstart) and +[`packages/sdk/README.md`](packages/sdk/README.md) for the full API. + +### Installer verification + +Every CLI download is checked against `SHA256SUMS`. If an authenticated GitHub +CLI is available, the installer also verifies build provenance. Without GitHub +authentication it warns, skips optional attestation, and continues only after +checksum verification. To require attestation: + +```bash +curl --proto '=https' --tlsv1.2 -fsSL https://get.atomicstrata.ai/install.sh | AM_VERIFY_ATTESTATION=1 sh -s -- --init +``` + +Required verification fails before installation unless `gh auth login` or +`GH_TOKEN` supplies working GitHub authentication. ## Package matrix -Status labels follow the docs contract: +Status labels are part of the public docs contract: - **published** — available on the npm registry and supported. - **implemented, publish pending** — code lives in this repo and works locally, - but the first monorepo-era release has not been cut yet. Do not put these in - install commands until the row flips to `published`. -- **coming soon** — public source is present, but the host install path is not - supported yet. Do not use these in install commands until the row flips to - `published`. + but the monorepo-era package has not been released. +- **coming soon** — source is present, but the public host install path is not + supported yet. - **deprecated** — still published and supported for the workflows named in - its row, but superseded; new work targets the replacement. -- **unsupported** / **planned** — reserved for future entries. + its row, but superseded. ### Packages @@ -270,110 +284,84 @@ coming soon until each host marketplace manifest format is validated end to end. | CLI (`am`) | `crates/cli` | published; canonical artifacts on GitHub Releases (`get.atomicstrata.ai` mirrors them) | | Python SDK (`atomicmemory` on PyPI) | separate repository | published; not part of this monorepo | -## Local development +## Repository and trust boundaries -The skeleton uses pnpm workspaces with Turborepo as the task graph and cache -layer. pnpm owns dependency resolution, workspace linking, and packing. Turbo -owns task ordering, caching, and affected-task selection. +This repository is the public source of truth for AtomicMemory's JavaScript and +TypeScript packages, Rust CLI, framework adapters, host plugins, and public smoke +contracts. It contains: -```bash -# install (uses the pinned pnpm@9.15.4 from packageManager) -pnpm install +- **Core** — Docker-deployable memory backend with mutation, retrieval, and + Postgres/pgvector storage. +- **SDK** — typed provider boundary, memory and storage clients, embeddings, and + search primitives. +- **CLI and MCP server** — setup, diagnostics, capture, retrieval, and agent + integration surfaces. +- **Adapters and plugins** — thin integrations for supported frameworks and + agent hosts. -# build / typecheck / test -pnpm run build -pnpm run typecheck -pnpm run test # self-contained packages -pnpm run test:core # requires core test services -pnpm run lint +Hosted service infrastructure, release orchestration, marketplace operations, +the Python SDK, and unpublished benchmark harnesses live outside this monorepo. +Package-specific setup remains in each package README. -# release / hygiene gates (not cached; always re-run) -pnpm run pack-dry-run -pnpm run package-metadata -pnpm run docs-contract -pnpm run public-integration-smoke -pnpm run repo-hygiene -pnpm run security-compliance -``` +### Performance posture -### CLI (`crates/`) +The Headline Results above are benchmark scores under matched methodology. +Latency, recall@k, and scale-envelope claims should only be quoted with the +benchmark, hardware, dataset, and measurement date. Until linked latency +benchmarks are available, single-digit-millisecond local retrieval remains a +design target rather than a guarantee. -The CLI ships as the **`am`** binary (from `crates/cli`). +### Public validation -```bash -curl -fsSL https://get.atomicstrata.ai/install.sh | sh -. "$HOME/.atomicmemory/env" -am --help -``` - -Release artifacts are published on GitHub Releases; `get.atomicstrata.ai` mirrors -the same binaries for the curl installer. See -[`crates/cli/README.md`](crates/cli/README.md) for install and checksum -verification. - -Contributors: - -```bash -cargo install --path crates/cli --force -am --help -pnpm run ci:rust -``` +Pull requests verify repository hygiene, package metadata, affected build and +type checks, lint, self-contained tests, package tarball shape, documentation +contracts, public integration smoke, and security compliance. Package and +release contexts additionally cover Core OpenAPI drift, schema tests, Docker +image smoke, and DB-backed Core tests when their required services are present. -NOTE: npm `@atomicmemory/cli` package is deprecated and installs a separate -`atomicmemory` binary. If you have both, use `am`. +## Local development -Package versions are intentionally scoped by release family instead of one -global monorepo version. `@atomicmemory/core` and `@atomicmemory/sdk` move -independently. Host plugins move together, framework adapters move together, -and the CLI/MCP-server tool pair moves together: +The monorepo uses pnpm workspaces and Turborepo. Check `package.json` for the +complete command surface. ```bash -pnpm check:version-families # CI guard for drift +pnpm install +pnpm run build +pnpm run typecheck +pnpm run test +pnpm run lint ``` -Release bumping, public sync, and registry publish preparation are owned by the -ops repo. Keep this source repo focused on package metadata and version-family -consistency checks. - -Build, test, lint, and docs-contract run through Turborepo's task graph. -Typecheck declares no cache outputs because package scripts use `tsc --noEmit`. -The side-effecting checks (`pack-dry-run`, `public-integration-smoke`, -`repo-hygiene`, `code-health`, and `security-compliance`) always re-run through -`cache: false` tasks or direct root node scripts. `package-metadata` is a -direct root check so it always reads the current package manifests. - -CI lanes use thin aliases over the same Turbo tasks: +Core DB-backed tests require Postgres/pgvector provisioning. Rust CLI changes +use the pinned toolchain and the repository's CI-equivalent command: ```bash -pnpm run ci:affected # build / typecheck / lint for affected packages; tests for self-contained packages -pnpm run ci:code-health # fallow/code-health coverage -pnpm run ci:pack-dry-run # pack-dry-run, affected-only -pnpm run ci:docs-contract # docs-contract -pnpm run ci:public-smoke # public-integration-smoke +pnpm run ci:rust ``` -`ci:affected` and `ci:pack-dry-run` use Turbo's `--affected` filter for normal -PRs; full release-green validation runs the unprefixed scripts so the required -surface is never narrowed by affected detection. The core package's DB-backed -test suite requires service provisioning and is intentionally outside the -generic affected lane; build, typecheck, lint, metadata, and pack validation -still cover `@atomicmemory/core` in public CI. - -Per-package commands (`pnpm --filter @atomicmemory/sdk run build`, etc.) work -for packages in `packages/`, `adapters/`, and `plugins/`. +See [`CONTRIBUTING.md`](CONTRIBUTING.md) for the full workflow, required checks, +and package-level commands. The canonical CLI source and contributor setup are +documented in [`crates/cli/README.md`](crates/cli/README.md). ## Companion: llmwiki -[llmwiki](https://github.com/atomicstrata/llmwiki) is a separate knowledge compiler that turns raw sources into an interlinked markdown wiki. It is **valuable on its own** — useful as a notebook, RAG index, CI-checked knowledge base, or domain pack source — and remains so whether or not AtomicMemory is in the picture. - -`@atomicmemory/llmwiki` (in this monorepo at `packages/llmwiki/`) is a one-way bridge: it imports an `llmwiki export --target json` envelope as one verbatim AtomicMemory record per wiki page, with all advisory metadata (kind, citations, confidence, provenance state, contradictions, aliases, freshness) preserved under `memory.metadata.llmwiki.*`. Either direction holds value standalone; the bridge just lets you choose runtime semantic recall on top of compiled knowledge. +[llmwiki](https://github.com/atomicstrata/llmwiki) compiles raw sources into an +interlinked Markdown knowledge base. The `@atomicmemory/llmwiki` bridge imports +its JSON export into AtomicMemory while preserving advisory metadata under +`memory.metadata.llmwiki.*`. -See [`packages/llmwiki/README.md`](packages/llmwiki/README.md) and [`packages/llmwiki/docs/cookbook.md`](packages/llmwiki/docs/cookbook.md) for the full workflow. +See [`packages/llmwiki/README.md`](packages/llmwiki/README.md) and +[`packages/llmwiki/docs/cookbook.md`](packages/llmwiki/docs/cookbook.md) for the +full workflow. -## Release Notes +## Project information -Per-package changelogs live next to each package. Cross-package and monorepo -rollup changes are recorded in [`CHANGELOG.md`](CHANGELOG.md). +- **Release notes:** package changelogs and [`CHANGELOG.md`](CHANGELOG.md) +- **Roadmap:** [`ROADMAP.md`](ROADMAP.md) +- **Contributing:** [`CONTRIBUTING.md`](CONTRIBUTING.md) +- **Security:** confidential reporting and supported versions in + [`SECURITY.md`](SECURITY.md) +- **License:** Apache License 2.0 — see [`LICENSE`](LICENSE) ## Repository layout @@ -389,21 +377,3 @@ tests/smoke/ public, contributor-safe smoke tests Release orchestration, marketplace operations, sensitive service configuration, and local machine paths are deliberately not part of this repository. - -## Contributing - -See [`CONTRIBUTING.md`](CONTRIBUTING.md) for the workflow, branch protection -rules, and the public CI lanes a pull request runs through. - -AI coding agents should also read [`AGENTS.md`](AGENTS.md). `CLAUDE.md` and -`GEMINI.md` point their respective CLIs at the same public instructions. - -## Security - -Security policy, supported versions, and the confidential reporting channel are -documented in [`SECURITY.md`](SECURITY.md). Please report suspected -vulnerabilities confidentially rather than opening a public issue. - -## License - -Apache License 2.0 — see [`LICENSE`](LICENSE). diff --git a/crates/cli/README.md b/crates/cli/README.md index e324c96..d51f77f 100644 --- a/crates/cli/README.md +++ b/crates/cli/README.md @@ -7,11 +7,25 @@ Phase 2 ships prebuilt **`am`** binaries. End users install with one command; contributors can still build from source. ```bash -curl -fsSL https://get.atomicstrata.ai/install.sh | sh -. "$HOME/.atomicmemory/env" # activate PATH in this shell (new terminals: not needed) -am --help +curl --proto '=https' --tlsv1.2 -fsSL https://get.atomicstrata.ai/install.sh | sh -s -- --init ``` +The installer invokes the installed binary directly for onboarding. If it adds +`am` to PATH, open a new terminal before subsequent `am` commands or use the +shell-specific activation command it prints. It always checks `SHA256SUMS`. +Default `auto` mode also verifies the GitHub build attestation when `gh` is +installed and authenticated; if `gh` is unavailable or logged out, installation +continues with a checksum-only warning. + +To require provenance verification and fail before installation when GitHub +authentication is unavailable: + +```bash +curl --proto '=https' --tlsv1.2 -fsSL https://get.atomicstrata.ai/install.sh | AM_VERIFY_ATTESTATION=1 sh -s -- --init +``` + +Authenticate first with `gh auth login`, or provide `GH_TOKEN` in automation. + Canonical artifacts live on [GitHub Releases](https://github.com/atomicstrata/atomicmemory/releases) (checksums + build provenance). The domain above is a mirrored convenience channel with the same digests; verify either against `SHA256SUMS` as shown @@ -43,7 +57,7 @@ curl -fsSLO "${base}/am-${ver}-${target}.tar.gz" curl -fsSL "${base}/SHA256SUMS" | shasum -a 256 -c --ignore-missing ``` -With GitHub CLI: +For manual provenance verification with an authenticated GitHub CLI: ```bash gh attestation verify "./am-${ver}-${target}.tar.gz" \ @@ -54,22 +68,74 @@ gh attestation verify "./am-${ver}-${target}.tar.gz" \ ## Quick start +The onboarding installer above runs plain `am init`, whose default path is +Hosted Cloud. It signs in, selects a managed project, reuses a working +project-bound credential or provisions this installation's +`am-cli-<12-hex>` key, saves the active Cloud profile, and leaves the CLI ready +to use: + ```bash -am init am memory ingest "I prefer aisle seats when flying." ``` -`am init` runs browser login (OAuth), bootstraps a personal workspace when -needed, links a local profile, and can start Core in Docker. Skip Core with -`am init --no-instance`. +Interactive `am init` runs browser login (OAuth), bootstraps a personal +workspace when needed, and offers: + +- **Hosted Cloud** — option 1/default; managed memory with no Docker or OpenAI + key. One project is selected automatically; multiple projects are prompted. +- **Connected Local** — option 2; links a Local project and can start Core in + Docker (`--no-instance` to skip). Select it explicitly with `am init --local`. + +`--project ` is type-aware. A Cloud project completes Hosted Cloud +configuration; a Local project follows the existing Local workflow. `--cloud` +and `--local` assert the expected type, and ambiguous case-insensitive slugs +must be replaced with a unique project ID: + +```bash +am init --project +am init --cloud --project +am init --local --project +``` -For a Connected Local project already created in the dashboard: +Plain non-interactive `am init --yes` also follows Hosted Cloud. Local +automation must be explicit and provide `OPENAI_API_KEY` when starting Core: ```bash -am init --project +am init --yes --project +OPENAI_API_KEY=sk-... am init --local --yes ``` -### Manual steps (equivalent) +With no Cloud projects, interactive init opens onboarding and polls every two +seconds for up to ten minutes, then resumes after project creation. Without a +TTY or under `--yes`, it prints the onboarding URL and exact `am init` recovery +command and exits nonzero instead of waiting for browser work. + +Hosted credentials are stored under `hosted-cloud-`, so switching +projects does not discard earlier secrets. A stored key is reused only when its +Cloud origin and project binding match and `MemoryClient::health()` succeeds. +When replacement is necessary, the CLI derives a stable 12-character lowercase +hex suffix from a random installation ID stored in `config.toml`. It rotates +only the exact `am-cli-<12-hex>` key for this installation and creates it when +absent. It never rotates a legacy unsuffixed `am-cli` key or another +installation's suffix. Non-authentication probe or key-list errors fail closed +without rotating or creating a key. + +If API-key quota is full, init preserves the previous default profile, does not +rotate or revoke unrelated keys as recovery, and prints the project dashboard +plus exact `am key list`, `am key revoke`, and `am init --project` recovery +commands. + +Hosted Cloud profiles activated by `am init` are marked `hosted_cloud_managed = +true` in the profile config and use the saved `amc_` key. Pre-marker init +profiles without that field still match the init credential contract +(`api_key_ref = hosted-cloud-` with the same `project_id`) and +prefer the stored key. Hand-created Cloud profiles are marked +`hosted_cloud_managed = false` and honor explicit `ATOMICMEMORY_API_KEY` exports +even when the profile or credential ref is named `hosted-cloud-*`. A stale +shell export is ignored for init-managed profiles unless you set +`ATOMICMEMORY_API_KEY_FORCE=1` for a one-off override. + +### Connected Local manual steps (equivalent to `am init --local`) ```bash am auth login @@ -82,13 +148,12 @@ local `CORE_API_KEY` into the managed container (reused from the state volume on later starts). Local `am memory *` / smoke prefer that persisted Core key over a Cloud-minted JWT. -Cloud key policy for Connected Local: the CLI treats `connected-local-runtime` as -a **singleton per project**. If a working key is already stored locally it is -reused; otherwise an existing active key with that name is **rotated** (obvious -stderr message) instead of creating another and burning API-key quota. Create -only runs when no such key exists. Rotating that key invalidates the previous -secret on every machine that shared it — prefer one operator machine, or re-run -`am init` / `am connect --project` on other machines after a rotate. +Cloud key policy for Connected Local uses the same installation identity. If a +working key is already stored locally it is reused; otherwise the CLI rotates +only `connected-local-runtime-<12-hex>` for this installation and creates it +when absent. Legacy unsuffixed keys and other installations' suffixes are never +automatic rotation or quota-recovery candidates. Independently configured +machines therefore keep independent credentials. ### Token fallback @@ -156,8 +221,9 @@ Run `am --help` for flags. ### Host MCP integration -After `am init` or Connected Local setup, wire AtomicMemory into agent hosts -(global user config only in v1): +After plain `am init` or `am init --local`, the selected project and API key are +already active. Wire AtomicMemory into agent hosts (global user config only in +v1): ```bash am integrate detect diff --git a/crates/cli/src/auth/token.rs b/crates/cli/src/auth/token.rs index 170a482..f076e85 100644 --- a/crates/cli/src/auth/token.rs +++ b/crates/cli/src/auth/token.rs @@ -159,22 +159,30 @@ fn authorize_stored_session( .get(profile_name) .and_then(|p| p.oauth_ref.clone()) .unwrap_or_else(|| profile_name.to_string()); - - let (storage_key, tokens) = if let Some(t) = creds.oauth.get(&oauth_ref).cloned() { - (oauth_ref, t) - } else if config + let allow_local_fallback = config .profiles .get(profile_name) - .is_some_and(|p| p.kind == crate::config::ProfileKind::Local) - { - creds - .oauth - .iter() - .next() - .map(|(k, t)| (k.clone(), t.clone())) - .ok_or_else(|| anyhow!("not logged in — run `am auth login`"))? - } else { - bail!("not logged in — run `am auth login`"); + .is_some_and(|p| p.kind == crate::config::ProfileKind::Local); + // Same selection policy planning uses; here it runs against the freshly + // loaded credentials, preserving the historical by-name behavior. + let storage_key = + crate::config::select_oauth_session_key(creds, &oauth_ref, allow_local_fallback) + .ok_or_else(|| anyhow!("not logged in — run `am auth login`"))?; + authorize_stored_session_pinned(config, creds, &PinnedOAuth { storage_key }, target_base_url) +} + +fn authorize_stored_session_pinned( + config: &ConfigFile, + creds: &CredentialsFile, + pinned: &PinnedOAuth, + target_base_url: &str, +) -> Result { + // No selection here, by design: exactly the planned key or failure. + let (storage_key, tokens) = match creds.oauth.get(&pinned.storage_key).cloned() { + Some(t) => (pinned.storage_key.clone(), t), + None => bail!( + "the OAuth session selected for this operation no longer exists — run `am auth login` and retry" + ), }; // Enforce the origin binding BEFORE the token can be handed out. The @@ -206,12 +214,46 @@ fn authorize_stored_session( pub async fn valid_bearer_token(profile_name: &str, target_base_url: &str) -> Result { let config = load_config()?; let creds = load_credentials()?; + let session = authorize_stored_session(&config, &creds, profile_name, target_base_url)?; + complete_bearer_token(session, target_base_url).await +} + +/// The concrete OAuth session selected at planning time, for callers that +/// must not let any concurrent mutation redirect which stored session +/// authenticates them. `valid_bearer_token` re-derives the selection from +/// fresh config and credentials reads by profile NAME — a replacement can +/// swap `oauth_ref`, and the Local fallback picks the credential map's first +/// session, so a concurrent login could redirect it too. Pinning the storage +/// key means execution makes NO selection decision at all; it looks up +/// exactly this key or fails. The origin binding still re-validates whatever +/// is found under it. +#[derive(Debug, Clone)] +pub struct PinnedOAuth { + /// Storage key in `credentials.toml`'s oauth map, selected at planning + /// time by [`crate::config::select_oauth_session_key`]. + pub storage_key: String, +} + +pub async fn valid_bearer_token_pinned( + pinned: &PinnedOAuth, + target_base_url: &str, +) -> Result { + let config = load_config()?; + let creds = load_credentials()?; + let session = authorize_stored_session_pinned(&config, &creds, pinned, target_base_url)?; + complete_bearer_token(session, target_base_url).await +} + +async fn complete_bearer_token( + session: AuthorizedSession, + target_base_url: &str, +) -> Result { let AuthorizedSession { storage_key, tokens, issuer, client_id, - } = authorize_stored_session(&config, &creds, profile_name, target_base_url)?; + } = session; if token_fresh(&tokens) { return Ok(tokens.id_token); @@ -327,6 +369,91 @@ mod tests { use super::*; use crate::environment::Environment; + #[test] + fn pinned_oauth_is_lookup_only_and_immune_to_concurrent_logins() { + // The scenario from review: export plans against a Local profile whose + // oauth_ref is absent, so planning's fallback selects the only session + // ("zzz-session"). A concurrent login then adds a lexicographically + // earlier session. Re-running selection at execution would now pick + // the newcomer; the pinned path must not select at all. + use crate::config::{ConfigFile, CredentialsFile, OAuthTokens, ProfileConfig, ProfileKind}; + let target = crate::environment::Environment::PROD_BASE_URL; + let issuer = crate::environment::Environment::PROD_OAUTH_ISSUER; + let session = |marker: &str| OAuthTokens { + id_token: marker.to_string(), + refresh_token: None, + expires_at: None, + issuer: Some(issuer.to_string()), + api_origin: Some(target.to_string()), + }; + + // Planning time: one session only. + let mut planning_creds = CredentialsFile::default(); + planning_creds + .oauth + .insert("zzz-session".into(), session("token-planned")); + let planned = crate::config::select_oauth_session_key(&planning_creds, "absent-ref", true) + .expect("fallback selects the only session"); + assert_eq!(planned, "zzz-session"); + + // Execution time: a concurrent login added an earlier-sorting session. + let mut config = ConfigFile::default(); + config.profiles.insert( + "local".into(), + ProfileConfig { + kind: ProfileKind::Local, + ..Default::default() + }, + ); + let mut exec_creds = planning_creds.clone(); + exec_creds + .oauth + .insert("aaa-session".into(), session("token-interloper")); + + // The by-name path re-selects and follows the interloper… + let by_name = authorize_stored_session(&config, &exec_creds, "local", target).unwrap(); + assert_eq!(by_name.storage_key, "aaa-session"); + // …the pinned path keeps the planned session. + let pinned = PinnedOAuth { + storage_key: planned, + }; + let kept = authorize_stored_session_pinned(&config, &exec_creds, &pinned, target).unwrap(); + assert_eq!(kept.storage_key, "zzz-session"); + assert_eq!(kept.tokens.id_token, "token-planned"); + } + + #[test] + fn pinned_oauth_fails_rather_than_reselecting_when_the_session_is_gone() { + use crate::config::{ConfigFile, CredentialsFile, OAuthTokens, ProfileConfig, ProfileKind}; + let target = crate::environment::Environment::PROD_BASE_URL; + let mut creds = CredentialsFile::default(); + creds.oauth.insert( + "other-session".into(), + OAuthTokens { + id_token: "token-other".into(), + refresh_token: None, + expires_at: None, + issuer: Some(crate::environment::Environment::PROD_OAUTH_ISSUER.into()), + api_origin: Some(target.into()), + }, + ); + let mut config = ConfigFile::default(); + config.profiles.insert( + "local".into(), + ProfileConfig { + kind: ProfileKind::Local, + ..Default::default() + }, + ); + // The planned session was deleted; another exists. Falling back here + // is exactly the redirection being prevented, so it must error. + let pinned = PinnedOAuth { + storage_key: "deleted-session".into(), + }; + let err = authorize_stored_session_pinned(&config, &creds, &pinned, target).unwrap_err(); + assert!(err.to_string().contains("no longer exists")); + } + #[test] fn authorize_url_always_requests_consent_not_login() { let url = build_authorize_url( diff --git a/crates/cli/src/cli.rs b/crates/cli/src/cli.rs index 00407f0..0478fd3 100644 --- a/crates/cli/src/cli.rs +++ b/crates/cli/src/cli.rs @@ -90,6 +90,11 @@ impl GlobalOptions { pub fn agent_output(&self) -> bool { self.agent || self.output == OutputFormat::Agent } + + /// Whether stdin prompts are permitted for this invocation. + pub fn allow_prompts(&self, yes: bool) -> bool { + !self.quiet && !yes && self.output != OutputFormat::Json && !self.agent_output() + } } #[derive(Debug, Subcommand)] @@ -165,3 +170,27 @@ pub fn command_path(command: &Command) -> String { Command::Hooks(cmd) => format!("hooks {}", hooks::command_label(cmd)), } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn allow_prompts_honors_quiet_yes_json_and_agent() { + let mut global = GlobalOptions::default(); + assert!(global.allow_prompts(false)); + + global.quiet = true; + assert!(!global.allow_prompts(false)); + + global.quiet = false; + assert!(!global.allow_prompts(true)); + + global.output = OutputFormat::Json; + assert!(!global.allow_prompts(false)); + + global.output = OutputFormat::Table; + global.agent = true; + assert!(!global.allow_prompts(false)); + } +} diff --git a/crates/cli/src/commands/client.rs b/crates/cli/src/commands/client.rs index df7ad46..67b2be4 100644 --- a/crates/cli/src/commands/client.rs +++ b/crates/cli/src/commands/client.rs @@ -7,26 +7,121 @@ use url::Url; use crate::auth::token::valid_bearer_token; use crate::cli::GlobalOptions; use crate::config::{ - ProfileKind, ResolvedProfile, is_cloud_api_key, require_api_key, resolve_core_api_key, - resolve_profile, + DEFAULT_PROFILE, ENV_API_KEY, ENV_PROFILE, ProfileKind, ResolvedProfile, + hosted_cloud_env_key_override_warning, hosted_cloud_managed_for_key_policy, is_cloud_api_key, + load_config, load_credentials, local_profile_cloud_export_warning, require_api_key, + resolve_core_api_key, resolve_profile, }; +use crate::output::message; pub async fn resolve_ctx(global: &GlobalOptions) -> Result { - resolve_profile( + resolve_profile_and_warn(global) +} + +/// Resolve the active profile and print a warning when Cloud URL exports cannot +/// override a stored Local default (same logic as `resolve_ctx`, for commands +/// that call `resolve_profile` directly). +pub fn resolve_profile_and_warn(global: &GlobalOptions) -> Result { + let config = load_config()?; + let profile_name = global + .profile + .as_deref() + .map(str::to_string) + .or_else(|| std::env::var(ENV_PROFILE).ok()) + .or_else(|| config.default_profile.clone()) + .unwrap_or_else(|| DEFAULT_PROFILE.to_string()); + let stored_kind = config + .profiles + .get(&profile_name) + .map(|p| p.kind) + .unwrap_or_default(); + + let profile = resolve_profile( global.profile.as_deref(), global.base_url.as_deref(), global.environment, - ) + )?; + + emit_cloud_export_warning_if_needed(global, stored_kind, &profile); + Ok(profile) +} + +/// Warn when Cloud URL exports cannot override a stored Local profile, or when a +/// Hosted Cloud profile ignores a stale `ATOMICMEMORY_API_KEY`. +pub fn emit_cloud_export_warning_if_needed( + global: &GlobalOptions, + stored_kind: crate::config::ProfileKind, + profile: &ResolvedProfile, +) { + if let Some(warning) = local_profile_cloud_export_warning( + stored_kind, + global.base_url.as_deref(), + &profile.base_url, + std::env::var(ENV_API_KEY).ok().as_deref(), + &profile.memory_base_url, + ) { + message(!global.quiet, &warning); + } + + if let (Ok(config), Ok(creds)) = (load_config(), load_credentials()) { + let api_key_ref = config + .profiles + .get(&profile.name) + .and_then(|entry| entry.api_key_ref.clone()) + .unwrap_or_else(|| profile.name.clone()); + if let Some(warning) = hosted_cloud_env_key_override_warning( + config + .profiles + .get(&profile.name) + .map(hosted_cloud_managed_for_key_policy) + .unwrap_or(false), + std::env::var(ENV_API_KEY).ok().as_deref(), + creds.api_keys.get(&api_key_ref), + &profile.base_url, + profile.project_id.as_deref(), + ) { + message(!global.quiet, &warning); + } + } } pub async fn dashboard_client( global: &GlobalOptions, ) -> Result<(ResolvedProfile, DashboardClient)> { let profile = resolve_ctx(global).await?; + let client = dashboard_client_for_profile(&profile).await?; + Ok((profile, client)) +} + +/// Build a dashboard client for an already-resolved profile. +/// +/// Callers that also touch memory must pin both to one resolution; resolving +/// again reopens `config.toml`, and the active profile can change in between. +pub(crate) async fn dashboard_client_for_profile( + profile: &ResolvedProfile, +) -> Result { let token = valid_bearer_token(&profile.name, &profile.base_url).await?; let base = Url::parse(&profile.base_url).context("parse base_url")?; - let client = DashboardClient::new(base, token)?; - Ok((profile, client)) + DashboardClient::new(base, token).map_err(Into::into) +} + +/// Dashboard client for export: the OAuth session is selected from the +/// planning-time config generation, not re-derived by profile name. See +/// [`crate::auth::token::PinnedOAuth`] — a same-name replacement that swaps +/// `oauth_ref` must not decide which session authenticates an export that +/// began from the original profile. +pub(crate) async fn dashboard_client_for_export( + profile: &ResolvedProfile, + expected: &crate::config::ExpectedExportProfile, +) -> Result { + let storage_key = expected + .oauth_storage_key + .clone() + .ok_or_else(|| anyhow::anyhow!("not logged in — run `am auth login`"))?; + let pinned = crate::auth::token::PinnedOAuth { storage_key }; + let token = crate::auth::token::valid_bearer_token_pinned(&pinned, &profile.base_url).await?; + let base = Url::parse(&profile.base_url).context("parse base_url")?; + DashboardClient::new(base, token).map_err(Into::into) } /// Cloud memory surface authenticated with the project `amc_` API key. @@ -34,27 +129,35 @@ pub async fn cloud_api_key_client( global: &GlobalOptions, ) -> Result<(ResolvedProfile, MemoryClient)> { let profile = resolve_ctx(global).await?; - let api_key = require_api_key(&profile)?; + let client = cloud_api_key_client_for_profile(&profile)?; + Ok((profile, client)) +} + +/// Cloud-key client for an already-resolved profile. See +/// [`dashboard_client_for_profile`] for why this does not resolve again. +pub(crate) fn cloud_api_key_client_for_profile(profile: &ResolvedProfile) -> Result { + let api_key = require_api_key(profile)?; if !is_cloud_api_key(&api_key) { anyhow::bail!( "stored key does not look like a Cloud API key (amc_…) — run `am key create --save` for trace sync and JWT mint" ); } let base = Url::parse(&profile.base_url).context("parse cloud base_url")?; - let client = MemoryClient::new(base, api_key)?; - Ok((profile, client)) + MemoryClient::new(base, api_key).map_err(Into::into) } pub async fn memory_client(global: &GlobalOptions) -> Result<(ResolvedProfile, MemoryClient)> { let profile = resolve_ctx(global).await?; - let client = memory_client_for_profile(&profile, global).await?; + let client = memory_client_for_profile(&profile).await?; Ok((profile, client)) } -async fn memory_client_for_profile( - profile: &ResolvedProfile, - global: &GlobalOptions, -) -> Result { +/// Build a memory client for an already-resolved profile. +/// +/// Takes no `GlobalOptions` on purpose: without it there is nothing to resolve +/// from, so this cannot silently pick up a profile that changed since the +/// caller resolved. +pub(crate) async fn memory_client_for_profile(profile: &ResolvedProfile) -> Result { match profile.kind { ProfileKind::Cloud => { let api_key = require_api_key(profile)?; @@ -75,7 +178,10 @@ async fn memory_client_for_profile( { return MemoryClient::new(base, core_key).context("create core memory client"); } - let (_profile, cloud_client) = cloud_api_key_client(global).await?; + // Pinned: cloud_api_key_client would resolve the active profile + // again, so a "pinned" caller silently minted a token for whatever + // profile was active by then. + let cloud_client = cloud_api_key_client_for_profile(profile)?; let token = cloud_client.mint_local_token().await?; MemoryClient::new(base, token.access_token).context("create core memory client") } diff --git a/crates/cli/src/commands/cloud_api_key.rs b/crates/cli/src/commands/cloud_api_key.rs index eb60596..6ba8e51 100644 --- a/crates/cli/src/commands/cloud_api_key.rs +++ b/crates/cli/src/commands/cloud_api_key.rs @@ -1,11 +1,11 @@ -//! Cloud API key provisioning for Connected Local (`connected-local-runtime`). +//! Per-installation Cloud API key provisioning for Connected Local. //! -//! Singleton policy: reuse a working locally stored `amc_` key; otherwise rotate -//! an existing active key with this name, and only create when none exists. That -//! keeps `am init` / `am instance start` from burning free-plan key quota. +//! A working locally stored `amc_` key is reused. Replacement may rotate only +//! this installation's exact managed name, so another machine's credential is +//! never invalidated automatically. use am_cloud_client::{CloudClientError, DashboardClient, MemoryClient}; -use am_cloud_types::{ApiKey, CreateApiKeyRequest}; +use am_cloud_types::{ApiKey, ApiKeyWithSecret, CreateApiKeyRequest}; use anyhow::{Context, Result, bail}; use tracing::info; use url::Url; @@ -14,20 +14,61 @@ use crate::auth::origin::same_origin; use crate::cli::GlobalOptions; use crate::commands::client::{cloud_api_key_client, dashboard_client}; use crate::config::{ - ResolvedProfile, is_cloud_api_key, require_api_key, require_project_id, store_api_key, + ResolvedProfile, is_cloud_api_key, machine_scoped_key_name, require_api_key, + require_project_id, store_api_key, }; use crate::instance::AUTO_KEY_NAME; use crate::output::message; +#[async_trait::async_trait] +trait ConnectedLocalCredentialBackend: Send + Sync { + async fn list_api_keys(&self, project_id: &str) -> Result, CloudClientError>; + + async fn rotate_api_key( + &self, + project_id: &str, + key_id: &str, + ) -> Result; + + async fn create_api_key( + &self, + project_id: &str, + request: &CreateApiKeyRequest, + ) -> Result; +} + +#[async_trait::async_trait] +impl ConnectedLocalCredentialBackend for DashboardClient { + async fn list_api_keys(&self, project_id: &str) -> Result, CloudClientError> { + DashboardClient::list_api_keys(self, project_id).await + } + + async fn rotate_api_key( + &self, + project_id: &str, + key_id: &str, + ) -> Result { + DashboardClient::rotate_api_key(self, project_id, key_id).await + } + + async fn create_api_key( + &self, + project_id: &str, + request: &CreateApiKeyRequest, + ) -> Result { + DashboardClient::create_api_key(self, project_id, request).await + } +} + /// How a Connected Local Cloud API key was obtained for this run. #[derive(Debug, Clone, PartialEq, Eq)] pub enum ProvisionOutcome { /// Locally stored `amc_` key still mints against this Cloud origin. Reused, - /// Rotated an existing active `connected-local-runtime` key (quota-safe). - Rotated { key_id: String }, - /// Created a new `connected-local-runtime` key (none existed). - Created { key_id: String }, + /// Rotated this installation's existing managed key. + Rotated { key_id: String, key_name: String }, + /// Created this installation's managed key. + Created { key_id: String, key_name: String }, } impl ProvisionOutcome { @@ -35,8 +76,8 @@ impl ProvisionOutcome { pub fn progress_detail(&self) -> &'static str { match self { Self::Reused => "reused stored key", - Self::Rotated { .. } => "rotated existing connected-local-runtime", - Self::Created { .. } => "created connected-local-runtime", + Self::Rotated { .. } => "rotated per-installation key", + Self::Created { .. } => "created per-installation key", } } @@ -49,25 +90,35 @@ impl ProvisionOutcome { pub fn operator_message(&self) -> Option { match self { Self::Reused => None, - Self::Rotated { key_id } => Some(format!( - "Rotated existing Cloud API key '{AUTO_KEY_NAME}' ({key_id}) — quota-safe; \ + Self::Rotated { key_id, key_name } => Some(format!( + "Rotated this installation's Cloud API key '{key_name}' ({key_id}); \ previous secret is invalidated and the new secret is saved locally (not printed)." )), - Self::Created { key_id } => Some(format!( - "Created Cloud API key '{AUTO_KEY_NAME}' ({key_id}) and saved locally (not printed)." + Self::Created { key_id, key_name } => Some(format!( + "Created this installation's Cloud API key '{key_name}' ({key_id}) and saved locally (not printed)." )), } } } -/// Pick which listed key to rotate for the Connected Local singleton. +/// Pick this installation's active Connected Local key to rotate. +/// +/// Prefers the most recently used exact-name match, then newest `created_at`. +/// Legacy and other installations' names are excluded. +pub fn select_runtime_key_for_rotate<'a>(keys: &'a [ApiKey], key_name: &str) -> Option<&'a ApiKey> { + select_named_key_for_rotate(keys, key_name) +} + +/// Pick the active exact-name key that is safest to rotate. /// -/// Prefers `active` keys named [`AUTO_KEY_NAME`], then most recently used, then -/// newest `created_at`. Returns `None` when create is required. -pub fn select_runtime_key_for_rotate(keys: &[ApiKey]) -> Option<&ApiKey> { +/// Prefers the most recently used key, then the newest key. +pub(crate) fn select_named_key_for_rotate<'a>( + keys: &'a [ApiKey], + name: &str, +) -> Option<&'a ApiKey> { let mut candidates: Vec<&ApiKey> = keys .iter() - .filter(|k| k.name == AUTO_KEY_NAME && status_is_active(&k.status)) + .filter(|k| k.name == name && status_is_active(&k.status)) .collect(); if candidates.is_empty() { return None; @@ -89,7 +140,7 @@ pub fn should_rotate_after_probe(err: &CloudClientError) -> bool { matches!(err, CloudClientError::Auth) } -fn is_api_key_quota_exceeded(err: &CloudClientError) -> bool { +pub(crate) fn is_api_key_quota_exceeded(err: &CloudClientError) -> bool { match err { CloudClientError::Status { code, body } => { *code == 429 @@ -144,9 +195,15 @@ pub async fn ensure_connected_local_cloud_api_key( ); } - let (secret, outcome) = - rotate_or_create_runtime_key(&client, &project_id, &profile.name, &profile.base_url) - .await?; + let key_name = machine_scoped_key_name(AUTO_KEY_NAME)?; + let (secret, outcome) = rotate_or_create_runtime_key( + &client, + &project_id, + &key_name, + &profile.base_url, + |secret| store_api_key(&profile.name, secret, &profile.base_url, &project_id), + ) + .await?; probe_cloud_api_key_mint(&profile.base_url, &secret).await?; if let Some(msg) = outcome.operator_message() { message(!global.quiet, &msg); @@ -196,26 +253,37 @@ pub async fn ensure_connected_local_cloud_api_key_stored( } let (profile, client) = dashboard_client(global).await?; - let (_secret, outcome) = - rotate_or_create_runtime_key(&client, project_id, profile_name, &profile.base_url).await?; + let key_name = machine_scoped_key_name(AUTO_KEY_NAME)?; + let (_secret, outcome) = rotate_or_create_runtime_key( + &client, + project_id, + &key_name, + &profile.base_url, + |secret| store_api_key(profile_name, secret, &profile.base_url, project_id), + ) + .await?; if let Some(msg) = outcome.operator_message() { message(!global.quiet, &msg); } Ok(outcome) } -async fn rotate_or_create_runtime_key( - client: &DashboardClient, +async fn rotate_or_create_runtime_key( + client: &dyn ConnectedLocalCredentialBackend, project_id: &str, - profile_name: &str, + key_name: &str, api_origin: &str, -) -> Result<(String, ProvisionOutcome)> { + store: F, +) -> Result<(String, ProvisionOutcome)> +where + F: Fn(&str) -> Result<()>, +{ let keys = client .list_api_keys(project_id) .await .context("list Cloud API keys")?; - if let Some(existing) = select_runtime_key_for_rotate(&keys) { + if let Some(existing) = select_runtime_key_for_rotate(&keys, key_name) { info!( key_id = %existing.id, name = %existing.name, @@ -227,63 +295,62 @@ async fn rotate_or_create_runtime_key( .with_context(|| { format!("rotate Cloud API key '{}' ({})", existing.name, existing.id) })?; - store_api_key(profile_name, &rotated.secret, api_origin, project_id)?; + store(&rotated.secret)?; return Ok(( rotated.secret, ProvisionOutcome::Rotated { key_id: rotated.key.id, + key_name: key_name.to_string(), }, )); } - info!( - name = AUTO_KEY_NAME, - "creating Connected Local Cloud API key" - ); + info!(name = key_name, "creating Connected Local Cloud API key"); match client .create_api_key( project_id, &CreateApiKeyRequest { - name: AUTO_KEY_NAME.to_string(), + name: key_name.to_string(), environment: None, }, ) .await { Ok(created) => { - store_api_key(profile_name, &created.secret, api_origin, project_id)?; + store(&created.secret)?; Ok(( created.secret, ProvisionOutcome::Created { key_id: created.key.id, + key_name: key_name.to_string(), }, )) } Err(err) if is_api_key_quota_exceeded(&err) => { - // Race: list saw no active singleton but quota is full (revoked leftovers, - // or another client created keys). Prefer rotating any same-named key. - if let Some(any_named) = keys.iter().find(|k| k.name == AUTO_KEY_NAME) { + // Race: list saw no active exact-name key but quota is full. A revoked + // key owned by this installation remains safe to rotate; every other + // name is outside this installation's authority. + if let Some(any_named) = keys.iter().find(|k| k.name == key_name) { let rotated = client .rotate_api_key(project_id, &any_named.id) .await .context("rotate Cloud API key after quota exceeded")?; - store_api_key(profile_name, &rotated.secret, api_origin, project_id)?; + store(&rotated.secret)?; return Ok(( rotated.secret, ProvisionOutcome::Rotated { key_id: rotated.key.id, + key_name: key_name.to_string(), }, )); } Err(err).context(format!( - "create Cloud API key '{AUTO_KEY_NAME}' failed (API key quota exceeded).\n\ + "create Cloud API key '{key_name}' failed (API key quota exceeded).\n\ Revoke unused keys in the dashboard, or run: am key list\n\ - Then re-run init — the CLI will rotate an existing '{AUTO_KEY_NAME}' key when present." + Then re-run init — the CLI will rotate only this installation's '{key_name}' key when present." )) } - Err(err) => Err(err).context(format!( - "create Cloud API key '{AUTO_KEY_NAME}' on {api_origin}" - )), + Err(err) => Err(err).context(format!("create Cloud API key '{key_name}' on {api_origin}")), } } @@ -295,9 +362,77 @@ async fn probe_cloud_api_key_mint(base_url: &str, api_key: &str) -> Result<(), C #[cfg(test)] mod tests { + use std::collections::VecDeque; + use std::sync::Mutex; + use super::*; + use am_cloud_types::ApiKeyWithSecret; use chrono::{TimeZone, Utc}; + const TEST_LOCAL_KEY_NAME: &str = "connected-local-runtime-a1b2c3d4e5f6"; + + struct FakeConnectedLocalBackend { + lists: Mutex, CloudClientError>>>, + rotates: Mutex>>, + rotate_calls: Mutex>, + creates: Mutex>>, + create_names: Mutex>, + } + + #[async_trait::async_trait] + impl ConnectedLocalCredentialBackend for FakeConnectedLocalBackend { + async fn list_api_keys(&self, _project_id: &str) -> Result, CloudClientError> { + self.lists + .lock() + .unwrap() + .pop_front() + .unwrap_or_else(|| Ok(Vec::new())) + } + + async fn rotate_api_key( + &self, + project_id: &str, + key_id: &str, + ) -> Result { + self.rotate_calls + .lock() + .unwrap() + .push((project_id.to_string(), key_id.to_string())); + self.rotates + .lock() + .unwrap() + .pop_front() + .unwrap_or_else(|| panic!("missing rotate response")) + } + + async fn create_api_key( + &self, + _project_id: &str, + request: &CreateApiKeyRequest, + ) -> Result { + self.create_names.lock().unwrap().push(request.name.clone()); + self.creates + .lock() + .unwrap() + .pop_front() + .unwrap_or_else(|| panic!("missing create response")) + } + } + + fn backend( + keys: Vec, + create: Result, + rotates: Vec>, + ) -> FakeConnectedLocalBackend { + FakeConnectedLocalBackend { + lists: Mutex::new([Ok(keys)].into()), + rotates: Mutex::new(rotates.into()), + rotate_calls: Mutex::new(Vec::new()), + creates: Mutex::new([create].into()), + create_names: Mutex::new(Vec::new()), + } + } + fn key( id: &str, name: &str, @@ -316,47 +451,154 @@ mod tests { } } + fn key_with_secret(key: ApiKey, secret: &str) -> ApiKeyWithSecret { + ApiKeyWithSecret { + key, + secret: secret.into(), + } + } + #[test] - fn select_prefers_active_runtime_name() { + fn select_matches_only_this_installations_active_runtime_name() { let keys = vec![ - key("k1", "other", "active", 100, Some(200)), - key("k2", AUTO_KEY_NAME, "revoked", 300, Some(400)), - key("k3", AUTO_KEY_NAME, "active", 50, Some(10)), + key( + "legacy", + "connected-local-runtime", + "active", + 100, + Some(200), + ), + key( + "foreign", + "connected-local-runtime-ffffffffffff", + "active", + 300, + Some(400), + ), + key("own", TEST_LOCAL_KEY_NAME, "active", 50, Some(10)), ]; - let picked = select_runtime_key_for_rotate(&keys).unwrap(); - assert_eq!(picked.id, "k3"); + let picked = select_runtime_key_for_rotate(&keys, TEST_LOCAL_KEY_NAME).unwrap(); + assert_eq!(picked.id, "own"); } #[test] fn select_prefers_most_recently_used_among_active_runtime_keys() { let keys = vec![ - key("old", AUTO_KEY_NAME, "active", 10, Some(20)), - key("fresh", AUTO_KEY_NAME, "active", 5, Some(99)), - key("newer_created", AUTO_KEY_NAME, "active", 80, None), + key("old", TEST_LOCAL_KEY_NAME, "active", 10, Some(20)), + key("fresh", TEST_LOCAL_KEY_NAME, "active", 5, Some(99)), + key("newer_created", TEST_LOCAL_KEY_NAME, "active", 80, None), ]; - let picked = select_runtime_key_for_rotate(&keys).unwrap(); + let picked = select_runtime_key_for_rotate(&keys, TEST_LOCAL_KEY_NAME).unwrap(); assert_eq!(picked.id, "fresh"); } #[test] - fn select_returns_none_when_no_active_runtime_key() { + fn select_returns_none_for_legacy_and_foreign_installation_names() { let keys = vec![ - key("k1", AUTO_KEY_NAME, "revoked", 1, None), - key("k2", "connected-traces", "active", 2, None), + key("legacy", "connected-local-runtime", "active", 1, None), + key( + "foreign", + "connected-local-runtime-ffffffffffff", + "active", + 2, + None, + ), ]; - assert!(select_runtime_key_for_rotate(&keys).is_none()); + assert!(select_runtime_key_for_rotate(&keys, TEST_LOCAL_KEY_NAME).is_none()); + } + + #[tokio::test] + async fn quota_never_rotates_legacy_or_foreign_installation_keys() { + let client = backend( + vec![ + key("legacy", "connected-local-runtime", "active", 1, None), + key( + "foreign", + "connected-local-runtime-ffffffffffff", + "active", + 2, + None, + ), + ], + Err(CloudClientError::Status { + code: 429, + body: "quota_exceeded: max_api_keys".into(), + }), + Vec::new(), + ); + + let error = rotate_or_create_runtime_key( + &client, + "proj_test", + TEST_LOCAL_KEY_NAME, + "https://api.atomicstrata.ai", + |_| -> Result<()> { panic!("quota must not store a credential") }, + ) + .await + .expect_err("foreign keys must not be quota recovery candidates"); + + assert!(error.to_string().contains("quota exceeded")); + assert!(client.rotate_calls.lock().unwrap().is_empty()); + assert_eq!( + client.create_names.lock().unwrap().as_slice(), + [TEST_LOCAL_KEY_NAME] + ); + } + + #[tokio::test] + async fn quota_rotates_only_a_non_active_exact_installation_key() { + let rotated = key_with_secret( + key("own", TEST_LOCAL_KEY_NAME, "active", 1, None), + "amc_rotated_secret", + ); + let client = backend( + vec![key("own", TEST_LOCAL_KEY_NAME, "revoked", 1, None)], + Err(CloudClientError::Status { + code: 429, + body: "quota_exceeded: max_api_keys".into(), + }), + vec![Ok(rotated)], + ); + let stored = Mutex::new(Vec::new()); + + let (_, outcome) = rotate_or_create_runtime_key( + &client, + "proj_test", + TEST_LOCAL_KEY_NAME, + "https://api.atomicstrata.ai", + |secret| { + stored.lock().unwrap().push(secret.to_string()); + Ok(()) + }, + ) + .await + .expect("the exact installation key is safe to rotate"); + + assert_eq!( + outcome, + ProvisionOutcome::Rotated { + key_id: "own".into(), + key_name: TEST_LOCAL_KEY_NAME.into(), + } + ); + assert_eq!( + client.rotate_calls.lock().unwrap().as_slice(), + [("proj_test".into(), "own".into())] + ); + assert_eq!(stored.lock().unwrap().as_slice(), ["amc_rotated_secret"]); } #[test] - fn rotated_message_is_obvious_and_quota_safe() { + fn rotated_message_names_this_installations_key() { let msg = ProvisionOutcome::Rotated { key_id: "key_abc".into(), + key_name: TEST_LOCAL_KEY_NAME.into(), } .operator_message() .unwrap(); assert!(msg.contains("Rotated")); - assert!(msg.contains(AUTO_KEY_NAME)); - assert!(msg.contains("quota-safe")); + assert!(msg.contains(TEST_LOCAL_KEY_NAME)); + assert!(!msg.contains("quota-safe")); assert!(msg.contains("invalidated")); assert!(msg.contains("key_abc")); } @@ -365,11 +607,12 @@ mod tests { fn created_message_names_the_key() { let msg = ProvisionOutcome::Created { key_id: "key_new".into(), + key_name: TEST_LOCAL_KEY_NAME.into(), } .operator_message() .unwrap(); assert!(msg.contains("Created")); - assert!(msg.contains(AUTO_KEY_NAME)); + assert!(msg.contains(TEST_LOCAL_KEY_NAME)); assert!(msg.contains("key_new")); } @@ -385,8 +628,20 @@ mod tests { #[test] fn requires_container_sync_only_for_created_and_rotated() { assert!(!ProvisionOutcome::Reused.requires_container_sync()); - assert!(ProvisionOutcome::Rotated { key_id: "k".into() }.requires_container_sync()); - assert!(ProvisionOutcome::Created { key_id: "k".into() }.requires_container_sync()); + assert!( + ProvisionOutcome::Rotated { + key_id: "k".into(), + key_name: TEST_LOCAL_KEY_NAME.into(), + } + .requires_container_sync() + ); + assert!( + ProvisionOutcome::Created { + key_id: "k".into(), + key_name: TEST_LOCAL_KEY_NAME.into(), + } + .requires_container_sync() + ); } #[test] diff --git a/crates/cli/src/commands/config_cmd.rs b/crates/cli/src/commands/config_cmd.rs index 1e2c3b0..12c6226 100644 --- a/crates/cli/src/commands/config_cmd.rs +++ b/crates/cli/src/commands/config_cmd.rs @@ -1,11 +1,14 @@ //! `am config` — inspect and edit profiles and resolved settings. +use std::collections::BTreeMap; + use anyhow::Result; use clap::Subcommand; use serde::Serialize; use crate::auth::clerk_oauth::resolve_oauth_pair; -use crate::cli::GlobalOptions; +use crate::cli::{GlobalOptions, OutputFormat}; +use crate::commands::client::emit_cloud_export_warning_if_needed; use crate::config::{ ProfileConfig, ProfileKind, apply_environment_preset, load_config, resolve_profile, update_config, @@ -111,6 +114,10 @@ struct EnvShowReport { oauth_client_id: Option, } +fn profile_list_json_payload(cfg: &crate::config::ConfigFile) -> &BTreeMap { + &cfg.profiles +} + pub async fn run(cmd: ConfigCommand, global: &GlobalOptions) -> Result<()> { match cmd { ConfigCommand::Env { action } => match action { @@ -195,7 +202,17 @@ pub async fn run(cmd: ConfigCommand, global: &GlobalOptions) -> Result<()> { ConfigCommand::Profile { action } => match action { ProfileAction::List => { let cfg = load_config()?; - emit(global.output, &cfg.profiles, global.quiet) + let default_profile = cfg + .default_profile + .clone() + .unwrap_or_else(|| crate::config::DEFAULT_PROFILE.to_string()); + if global.output == OutputFormat::Table && !global.quiet { + message( + !global.quiet, + &format!("Default profile: {default_profile}"), + ); + } + emit(global.output, profile_list_json_payload(&cfg), global.quiet) } ProfileAction::Show { name } => { let cfg = load_config()?; @@ -222,6 +239,7 @@ pub async fn run(cmd: ConfigCommand, global: &GlobalOptions) -> Result<()> { project_id, } => { let kind = kind.map(ProfileKind::from).unwrap_or(ProfileKind::Cloud); + let hosted_cloud_managed = matches!(kind, ProfileKind::Cloud).then_some(false); update_config(|cfg| { cfg.profiles.insert( name.clone(), @@ -230,6 +248,7 @@ pub async fn run(cmd: ConfigCommand, global: &GlobalOptions) -> Result<()> { kind, local_url, project_id, + hosted_cloud_managed, ..Default::default() }, ); @@ -258,6 +277,12 @@ fn build_env_show_report(global: &GlobalOptions) -> Result { global.base_url.as_deref(), global.environment, )?; + let stored_kind = cfg + .profiles + .get(&profile.name) + .map(|p| p.kind) + .unwrap_or_default(); + emit_cloud_export_warning_if_needed(global, stored_kind, &profile); let profile_base = cfg .profiles .get(&profile.name) @@ -324,6 +349,43 @@ mod tests { ); } + #[test] + fn profile_list_json_emits_profile_map_contract() { + let mut cfg = crate::config::default_config_for_test(); + cfg.profiles.insert( + "local".to_string(), + ProfileConfig { + base_url: Some("http://127.0.0.1:17350".into()), + kind: ProfileKind::Local, + ..Default::default() + }, + ); + + let value = + serde_json::to_value(profile_list_json_payload(&cfg)).expect("serialize list payload"); + assert!(value.get("local").is_some()); + assert!(value.get("default_profile").is_none()); + assert!(value.get("profiles").is_none()); + } + + #[test] + fn profile_list_handler_uses_top_level_map_payload() { + let src = include_str!("config_cmd.rs").replace('\r', ""); + let code: String = src + .lines() + .take_while(|line| !line.starts_with("#[cfg(test)]")) + .collect::>() + .join("\n"); + assert!( + code.contains("profile_list_json_payload(&cfg)"), + "ProfileAction::List must emit the stable top-level profile map helper", + ); + assert!( + !code.contains("struct ProfileListReport"), + "profile list must not revert to the breaking default_profile envelope", + ); + } + #[test] fn env_show_report_builder_runs_without_panicking() { let global = GlobalOptions { diff --git a/crates/cli/src/commands/connect.rs b/crates/cli/src/commands/connect.rs index 1292100..d318c2f 100644 --- a/crates/cli/src/commands/connect.rs +++ b/crates/cli/src/commands/connect.rs @@ -97,6 +97,7 @@ pub async fn run(opts: ConnectOptions, global: &GlobalOptions) -> Result<()> { skip_verify: opts.skip_verify, replace: opts.replace, instance_image: None, + interactive: !global.quiet, }; return run_connect_project(&project, opts.device, &connect_opts, global).await; } diff --git a/crates/cli/src/commands/connect_project.rs b/crates/cli/src/commands/connect_project.rs index 8388ed2..b88651b 100644 --- a/crates/cli/src/commands/connect_project.rs +++ b/crates/cli/src/commands/connect_project.rs @@ -19,9 +19,11 @@ use crate::commands::cloud_api_key::ensure_connected_local_cloud_api_key_stored; use crate::commands::instance::{InstanceCommand, run_start_brief}; use crate::config::{ ProfileConfig, ProfileKind, ensure_config_initialized, jwks_url, require_api_key, - resolve_dashboard_context, resolve_openai_api_key, resolve_profile, update_config, + resolve_dashboard_context, resolve_profile, update_config, +}; +use crate::instance::docker::{ + DockerRunner, RealDockerRunner, ensure_docker_available_with_preflight, }; -use crate::instance::docker::{RealDockerRunner, ensure_docker_available}; use crate::instance::managed_core_needs_env_sync; use crate::onboarding_runtime::{default_runtime_wait, wait_runtime_online_with_progress}; use crate::output::message; @@ -33,12 +35,26 @@ use crate::telemetry::{ use crate::verification::receipt::{InitReceiptInput, build_init_receipt, print_init_receipt}; use crate::verification::smoke::{SmokeOptions, SmokeTelemetry, run_memory_smoke}; -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone)] pub struct ConnectProjectOptions { pub no_instance: bool, pub skip_verify: bool, pub replace: bool, pub instance_image: Option, + /// When false, stdin prompts (Docker preflight retry, etc.) are skipped. + pub interactive: bool, +} + +impl Default for ConnectProjectOptions { + fn default() -> Self { + Self { + no_instance: false, + skip_verify: false, + replace: false, + instance_image: None, + interactive: true, + } + } } /// Full `am connect --project` / dashboard-first onboarding from a project ref. @@ -321,6 +337,66 @@ pub fn local_projects(projects: &[Project]) -> Vec<&Project> { .collect() } +pub fn cloud_projects(projects: &[Project]) -> Vec<&Project> { + projects + .iter() + .filter(|p| p.kind == ProjectType::Cloud) + .collect() +} + +pub fn ensure_local_project_for_connect(project: &Project) -> Result<()> { + if project.kind == ProjectType::Cloud { + bail!( + "project '{}' ({}) is Hosted Cloud — Connected Local needs a Local project.\n\ + Run `am init --cloud --project ` for Hosted Cloud, or `am project list` to find a Local project.", + project.name, + project.slug + ); + } + Ok(()) +} + +pub fn ensure_cloud_project_for_handoff(project: &Project) -> Result<()> { + if project.kind != ProjectType::Cloud { + bail!( + "project '{}' ({}) is a Local project — Hosted Cloud requires a Cloud project.\n\ + Run `am init --project ` or `am init --local` for Connected Local, or choose a Cloud project ID.", + project.name, + project.slug + ); + } + Ok(()) +} + +/// How Cloud-first init chooses a Hosted Cloud project when none is explicit. +#[derive(Debug, Clone, Copy)] +pub enum HostedCloudTarget<'a> { + Project(&'a Project), + Prompt, + /// No Hosted Cloud projects yet — open onboarding/create. + OnboardingDashboard, + /// Multiple projects and no explicit selection — open the projects list. + ProjectsDashboard, +} + +pub fn hosted_cloud_target_policy<'a>( + clouds: &[&'a Project], + interactive: bool, + stdin_is_tty: bool, +) -> HostedCloudTarget<'a> { + match clouds.len() { + 0 => HostedCloudTarget::OnboardingDashboard, + 1 => HostedCloudTarget::Project(clouds[0]), + _ if interactive && stdin_is_tty => HostedCloudTarget::Prompt, + _ => HostedCloudTarget::ProjectsDashboard, + } +} + +/// Whether Docker preflight may block on stdin (respects `am init --yes`). +pub fn docker_preflight_may_prompt(interactive: bool, stdin_is_tty: bool) -> bool { + interactive && stdin_is_tty +} + pub fn pick_local_project(projects: &[Project], interactive: bool) -> Result> { let locals = local_projects(projects); match locals.len() { @@ -522,7 +598,17 @@ async fn start_core_with_env_sync( progress.start_step("runtime", "Start local Core (Docker)"); progress.tick("runtime", "checking Docker"); let docker = RealDockerRunner::new(); - if let Err(err) = ensure_docker_available(&docker).await { + let docker_unavailable = docker.version().await.is_err(); + let may_prompt_docker = docker_unavailable + && docker_preflight_may_prompt(opts.interactive, io::stdin().is_terminal()); + if may_prompt_docker { + progress.pause_for_input(); + } + let docker_result = ensure_docker_available_with_preflight(&docker, opts.interactive).await; + if may_prompt_docker { + progress.resume_after_input(); + } + if let Err(err) = docker_result { progress.fail("runtime", Some(&err.to_string())); capture_step_failure(InitStep::Docker, &err, Some(actx.props()), no_telemetry); return Err(err); @@ -553,18 +639,6 @@ async fn start_core_with_env_sync( progress.tick("runtime", "starting container"); } - // Pause for possible OpenAI stdin prompts (missing key or rejected key re-prompt). - let may_prompt_openai = !local_global.quiet && io::stdin().is_terminal(); - let missing_openai = may_prompt_openai - && std::env::var("OPENAI_API_KEY").is_err() - && resolve_openai_api_key(profile_name).is_none(); - if may_prompt_openai { - progress.pause_for_input(); - if missing_openai { - progress.tick("runtime", "OpenAI API key required below"); - } - } - let start_result = run_start_brief( local_global, InstanceCommand::Start { @@ -581,13 +655,12 @@ async fn start_core_with_env_sync( }, // The internal requirement, kept out of `replace`. needs_env_sync || env_sync.cloud_key_changed, + Some(progress), + Some("runtime"), + opts.interactive, ) .await; - if may_prompt_openai { - progress.resume_after_input(); - } - match start_result { Ok(started) => { if started { @@ -940,4 +1013,11 @@ mod tests { let locals = vec![&legacy, &canonical]; assert_eq!(default_local_project_index(&locals), 1); } + + #[test] + fn docker_preflight_may_prompt_respects_noninteractive() { + assert!(!docker_preflight_may_prompt(false, true)); + assert!(docker_preflight_may_prompt(true, true)); + assert!(!docker_preflight_may_prompt(true, false)); + } } diff --git a/crates/cli/src/commands/doctor_cmd.rs b/crates/cli/src/commands/doctor_cmd.rs index f8526b4..d283e75 100644 --- a/crates/cli/src/commands/doctor_cmd.rs +++ b/crates/cli/src/commands/doctor_cmd.rs @@ -5,8 +5,9 @@ use clap::Args; use crate::auth::doctor::{DoctorOverrides, report_ok, run_doctor as run_auth_doctor}; use crate::cli::GlobalOptions; +use crate::commands::client::resolve_profile_and_warn; use crate::commands::connect::{ConnectCommand, ConnectOptions, run as run_connect}; -use crate::config::{ProfileKind, resolve_profile}; +use crate::config::ProfileKind; use crate::progress::progress_for; use crate::telemetry::{ ActivationContext, ActivationEvent, InitStep, capture_activation, capture_step_failure, @@ -53,11 +54,7 @@ async fn run_with_progress( } progress.succeed("auth", Some("ok")); - let profile = resolve_profile( - global.profile.as_deref(), - global.base_url.as_deref(), - global.environment, - )?; + let profile = resolve_profile_and_warn(global)?; if profile.kind == ProfileKind::Local { progress.start_step("connect", "Connect wiring checks"); match run_connect( diff --git a/crates/cli/src/commands/init.rs b/crates/cli/src/commands/init.rs index 529e32d..f0ed669 100644 --- a/crates/cli/src/commands/init.rs +++ b/crates/cli/src/commands/init.rs @@ -1,6 +1,8 @@ //! First-run wizard: login → org → project → local link → optional Core instance. -use anyhow::Result; +use std::io::{self, IsTerminal, Write as _}; + +use anyhow::{Context, Result, bail}; use clap::Args; use crate::auth::claims::{decode_id_token, token_has_active_org}; @@ -9,10 +11,10 @@ use crate::auth::ensure_org::{EnsureOrgOptions, ensure_org_context}; use crate::auth::login::{LoginOptions, run_login}; use crate::auth::token::valid_bearer_token; use crate::cli::GlobalOptions; -use crate::commands::client::dashboard_client; +use crate::commands::client::{dashboard_client, resolve_profile_and_warn}; use crate::commands::connect_project::{ - ConnectProjectOptions, OnboardingContext, connect_local_project, pick_local_project, - resolve_project, + ConnectProjectOptions, OnboardingContext, cloud_projects, connect_local_project, + ensure_local_project_for_connect, pick_local_project, }; use crate::commands::link::{LinkLocalOptions, LinkLocalRequest, link_local}; use crate::config::{ @@ -24,14 +26,36 @@ use crate::telemetry::{ ActivationContext, ActivationEvent, InitStep, capture_activation, capture_email_hash, capture_step_failure, }; -use am_cloud_types::Project; +use am_cloud_client::DashboardClient; +use am_cloud_types::{ + CANONICAL_DEFAULT_PROJECT_SLUG, Project, ProjectType, find_project_by_default_alias, +}; + +mod hosted_cloud; + +#[cfg(test)] +use crate::commands::connect_project::{ + HostedCloudTarget, ensure_cloud_project_for_handoff, hosted_cloud_target_policy, +}; +#[cfg(test)] +use hosted_cloud::{CloudProjectChoice, parse_cloud_project_choice}; +use hosted_cloud::{HostedCloudHandoffInput, run_hosted_cloud_handoff}; + +const DEFAULT_LOCAL_PROFILE: &str = "local"; +const DEFAULT_LOCAL_URL: &str = "http://127.0.0.1:17350"; #[derive(Debug, Args)] #[command(about = "First-run setup: login, org, project, local link, and optional Core instance")] pub struct InitOptions { - /// Cloud project id or slug — connect to an existing dashboard local project + /// Cloud project ID or slug; its type selects Hosted Cloud or Connected Local #[arg(long)] pub project: Option, + /// Use Hosted Cloud (no Docker / Local Core on this machine) + #[arg(long, conflicts_with = "local")] + pub cloud: bool, + /// Use Connected Local (Docker + OpenAI on this machine) + #[arg(long, conflicts_with = "cloud")] + pub local: bool, /// Authenticate via OAuth device flow instead of browser login #[arg(long)] pub device: bool, @@ -42,16 +66,16 @@ pub struct InitOptions { #[arg(long)] pub yes: bool, /// Local Core bind URL when linking - #[arg(long, default_value = "http://127.0.0.1:17350")] - pub local_url: String, + #[arg(long)] + pub local_url: Option, /// Profile / link name for the local project - #[arg(long, default_value = "local")] - pub name: String, + #[arg(long)] + pub name: Option, /// Replace an existing foreign `atomic-memory` container when starting Core #[arg(long)] pub replace: bool, - /// Container image for Core (default: derived from environment) - #[arg(long, env = "ATOMICMEMORY_CORE_IMAGE")] + /// Container image for Core (default: derived from environment; `ATOMICMEMORY_CORE_IMAGE` is read at instance start) + #[arg(long)] pub image: Option, /// Skip memory pipeline smoke verification at the end #[arg(long)] @@ -71,9 +95,16 @@ async fn run_with_progress( progress: &mut dyn ProgressReporter, ) -> Result<()> { ensure_config_initialized()?; - let interactive = !global.quiet && !opts.yes; + let interactive = global.allow_prompts(opts.yes); - let mut actx = ActivationContext::local(); + if opts.project.is_none() && !opts.local { + validate_local_only_options(&opts, InitActivationPath::HostedCloud)?; + } + + // Surface Cloud URL / Local profile mismatch before the wizard runs. + resolve_profile_and_warn(global)?; + + let mut actx = ActivationContext::default(); capture_activation( ActivationEvent::InitStarted, Some(actx.props()), @@ -88,15 +119,16 @@ async fn run_with_progress( let cloud_api_url = cloud_api_url.as_str(); progress.start_step("identity", "Sign in"); - if let Err(err) = ensure_init_authenticated( - &cloud_profile, + if let Err(err) = ensure_init_authenticated(InitAuthInput { + cloud_profile: &cloud_profile, cloud_api_url, - opts.device, + use_device: opts.device, + allow_prompts: interactive, global, progress, - &mut actx, - global.no_telemetry, - ) + actx: &mut actx, + no_telemetry: global.no_telemetry, + }) .await { progress.fail("identity", Some(&err.to_string())); @@ -156,9 +188,10 @@ async fn run_with_progress( skip_verify: opts.skip_verify, replace: opts.replace, instance_image: opts.image.clone(), + interactive, }; - let onboarding_ctx = OnboardingContext { + let mut onboarding_ctx = OnboardingContext { actx, org: org.clone(), cloud_profile: cloud_profile.clone(), @@ -166,25 +199,91 @@ async fn run_with_progress( signed_in_as: signed_in_as.clone(), }; - if let Some(project_ref) = opts.project.as_deref() { - let mut cloud_global = global.clone(); - cloud_global.profile = Some(cloud_profile.clone()); - cloud_global.base_url = Some(cloud_api_url.to_string()); - let (_profile, client) = dashboard_client(&cloud_global).await?; - let project = resolve_project(&client, project_ref, cloud_api_url).await?; - return connect_local_project(project, &connect_opts, global, onboarding_ctx, progress) - .await; - } - let mut cloud_global = global.clone(); cloud_global.profile = Some(cloud_profile.clone()); cloud_global.base_url = Some(cloud_api_url.to_string()); let (_profile, client) = dashboard_client(&cloud_global).await?; + + if let Some(project_ref) = opts.project.as_deref() { + let project = resolve_init_project(&client, project_ref, cloud_api_url).await?; + let mode = resolve_init_mode(opts.cloud, opts.local, Some(project.kind), None)?; + validate_local_only_options(&opts, mode)?; + match mode { + InitActivationPath::HostedCloud => { + onboarding_ctx.actx.mode = ActivationContext::cloud().mode; + return run_hosted_cloud_handoff(HostedCloudHandoffInput { + client: &client, + cloud_api_url, + cloud_profile: &cloud_profile, + project: Some(project), + interactive, + actx: &mut onboarding_ctx.actx, + no_telemetry: global.no_telemetry, + global, + progress, + }) + .await; + } + InitActivationPath::ConnectedLocal => { + ensure_local_project_for_connect(&project)?; + onboarding_ctx.actx.mode = ActivationContext::local().mode; + if !opts.no_instance && !global.quiet { + announce_connected_local_prerequisites(interactive); + } + return connect_local_project( + project, + &connect_opts, + global, + onboarding_ctx, + progress, + ) + .await; + } + } + } + + let interactive_choice = + if !opts.cloud && !opts.local && interactive && io::stdin().is_terminal() { + Some(prompt_init_activation_path()?) + } else { + None + }; + let mode = resolve_init_mode(opts.cloud, opts.local, None, interactive_choice)?; + validate_local_only_options(&opts, mode)?; + if mode == InitActivationPath::HostedCloud { + onboarding_ctx.actx.mode = ActivationContext::cloud().mode; + return run_hosted_cloud_handoff(HostedCloudHandoffInput { + client: &client, + cloud_api_url, + cloud_profile: &cloud_profile, + project: None, + interactive, + actx: &mut onboarding_ctx.actx, + no_telemetry: global.no_telemetry, + global, + progress, + }) + .await; + } + + onboarding_ctx.actx.mode = ActivationContext::local().mode; let all_projects = client .list_projects() .await .map_err(|e| anyhow::anyhow!("{e}"))?; + if !opts.no_instance && !global.quiet { + announce_connected_local_prerequisites(interactive); + } + + let cloud_siblings = cloud_projects(&all_projects); + if !cloud_siblings.is_empty() && interactive { + eprintln!( + "\nNote: you have {} Hosted Cloud project(s) in the console — Connected Local is a separate Local project on this machine.\n", + cloud_siblings.len() + ); + } + if let Some(existing) = pick_local_project(&all_projects, interactive)? { return connect_local_project(existing, &connect_opts, global, onboarding_ctx, progress) .await; @@ -193,15 +292,195 @@ async fn run_with_progress( run_create_local_project(opts, global, onboarding_ctx, connect_opts, progress).await } -async fn ensure_init_authenticated( - cloud_profile: &str, +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum InitActivationPath { + ConnectedLocal, + HostedCloud, +} + +fn resolve_init_mode( + cloud: bool, + local: bool, + project_kind: Option, + interactive_choice: Option, +) -> Result { + if cloud && local { + bail!("--cloud and --local cannot be used together"); + } + + if cloud { + if project_kind == Some(ProjectType::Local) { + bail!("--cloud cannot be used with a Local project"); + } + return Ok(InitActivationPath::HostedCloud); + } + if local { + if project_kind == Some(ProjectType::Cloud) { + bail!("--local cannot be used with a Hosted Cloud project"); + } + return Ok(InitActivationPath::ConnectedLocal); + } + + Ok(match project_kind { + Some(ProjectType::Cloud) => InitActivationPath::HostedCloud, + Some(ProjectType::Local) => InitActivationPath::ConnectedLocal, + None => interactive_choice.unwrap_or(InitActivationPath::HostedCloud), + }) +} + +fn validate_local_only_options(opts: &InitOptions, mode: InitActivationPath) -> Result<()> { + if mode == InitActivationPath::ConnectedLocal { + return Ok(()); + } + + let has_local_only_option = opts.no_instance + || opts.skip_verify + || opts.replace + || opts.image.is_some() + || opts.local_url.is_some() + || opts.name.is_some(); + if has_local_only_option { + bail!( + "Local-only options require Connected Local; use `am init --local ...` or select a Local project with `--project `" + ); + } + Ok(()) +} + +fn unique_init_project_by_ref<'a>( + projects: &'a [Project], + id_or_slug: &str, +) -> Result<&'a Project> { + if let Some(project) = projects.iter().find(|project| project.id == id_or_slug) { + return Ok(project); + } + + let matches = projects + .iter() + .filter(|project| project.slug.eq_ignore_ascii_case(id_or_slug)) + .collect::>(); + match matches.as_slice() { + [] if id_or_slug.eq_ignore_ascii_case(CANONICAL_DEFAULT_PROJECT_SLUG) => { + find_project_by_default_alias(projects) + .ok_or_else(|| anyhow::anyhow!("project not found: {id_or_slug}")) + } + [] => bail!("project not found: {id_or_slug}"), + [project] => Ok(project), + _ => { + let ids = matches + .iter() + .map(|project| project.id.as_str()) + .collect::>() + .join(", "); + bail!( + "ambiguous project slug '{id_or_slug}' matches multiple projects ({ids}); rerun with the unique project ID" + ) + } + } +} + +async fn resolve_init_project( + client: &DashboardClient, + id_or_slug: &str, cloud_api_url: &str, +) -> Result { + if id_or_slug.starts_with("proj_") { + return client.get_project(id_or_slug).await.map_err(|err| { + anyhow::anyhow!( + "{err}\nHint: verify the project exists on {cloud_api_url} with `am project list --base-url {cloud_api_url}`." + ) + }); + } + + let projects = client + .list_projects() + .await + .map_err(|err| anyhow::anyhow!("{err}"))?; + unique_init_project_by_ref(&projects, id_or_slug) + .cloned() + .with_context(|| { + format!( + "dashboard API: {cloud_api_url}; run `am project list` on the same profile and --base-url" + ) + }) +} + +/// Printed once before Docker / OpenAI work so operators know what to prepare. +fn announce_connected_local_prerequisites(interactive: bool) { + if interactive { + eprintln!( + "\nConnected Local needs Docker running and an OpenAI API key.\n\ + • Docker: https://docs.docker.com/desktop/\n\ + • OpenAI: have your key ready — `am init` prompts with hidden input.\n" + ); + } else { + eprintln!( + "\nConnected Local needs Docker running and OPENAI_API_KEY set in the environment.\n\ + • Docker: https://docs.docker.com/desktop/\n" + ); + } +} + +fn prompt_init_activation_path() -> Result { + eprintln!(); + eprintln!("How do you want to use AtomicMemory?"); + eprintln!(" 1) Hosted Cloud — managed memory (no Docker)"); + eprintln!(" 2) Connected Local — Core on this machine (Docker + OpenAI)"); + loop { + eprint!("Choose [1/2] (default 1): "); + io::stderr().flush().ok(); + let mut line = String::new(); + io::stdin() + .read_line(&mut line) + .context("read init path choice")?; + match parse_init_activation_path_choice(line.trim()) { + Ok(choice) => return Ok(choice), + Err(err) => eprintln!("{err}"), + } + } +} + +fn parse_init_activation_path_choice(choice: &str) -> Result { + match choice { + "" | "1" => Ok(InitActivationPath::HostedCloud), + "2" => Ok(InitActivationPath::ConnectedLocal), + other => bail!("Enter 1 or 2, not '{other}'."), + } +} + +struct InitAuthInput<'a> { + cloud_profile: &'a str, + cloud_api_url: &'a str, use_device: bool, - global: &GlobalOptions, - progress: &mut dyn ProgressReporter, - actx: &mut ActivationContext, + allow_prompts: bool, + global: &'a GlobalOptions, + progress: &'a mut dyn ProgressReporter, + actx: &'a mut ActivationContext, no_telemetry: bool, -) -> Result<()> { +} + +/// Whether `am init` can actually complete a sign-in, rather than stall. +/// +/// Browser OAuth waits on a loopback callback for two minutes. With stdin not a +/// terminal there is nobody to approve it, so a piped or CI `am init` sat for +/// the full timeout and then failed. `--yes` was already fail-closed; the same +/// reasoning applies whenever there is no terminal. The device flow prints a +/// code to enter elsewhere, so it stays allowed on explicit opt-in. +fn may_run_init_login(allow_prompts: bool, use_device: bool, stdin_is_tty: bool) -> bool { + allow_prompts && (use_device || stdin_is_tty) +} + +async fn ensure_init_authenticated(input: InitAuthInput<'_>) -> Result<()> { + let InitAuthInput { + cloud_profile, + cloud_api_url, + use_device, + allow_prompts, + global, + progress, + actx, + no_telemetry, + } = input; let token_result = valid_bearer_token(cloud_profile, cloud_api_url).await; let needs_login = token_result.is_err(); let needs_org_refresh = token_result @@ -214,6 +493,14 @@ async fn ensure_init_authenticated( return Ok(()); } + if !may_run_init_login(allow_prompts, use_device, io::stdin().is_terminal()) { + bail!( + "sign-in required — run `am auth login --token ` first, \ + or `am init --device` to sign in with a device code. Browser sign-in \ + needs an interactive terminal, and --yes never opens one." + ); + } + if use_device { progress.tick("identity", "device login"); run_device_login( @@ -278,8 +565,14 @@ async fn run_create_local_project( let cloud_api_url = onboarding_ctx.cloud_api_url.clone(); let actx = &mut onboarding_ctx.actx; - let local_profile = opts.name.clone(); - let local_url = opts.local_url.clone(); + let local_profile = opts + .name + .clone() + .unwrap_or_else(|| DEFAULT_LOCAL_PROFILE.to_string()); + let local_url = opts + .local_url + .clone() + .unwrap_or_else(|| DEFAULT_LOCAL_URL.to_string()); // Link/create first; connect_local_project owns the progressive "project" step. // @@ -334,7 +627,7 @@ async fn run_create_local_project( &link_global, LinkLocalRequest { org_id: Some(org.id.clone()), - name: opts.name.clone(), + name: local_profile.clone(), local_url: local_url.clone(), environment: "dev".into(), key: None, @@ -364,8 +657,26 @@ async fn run_create_local_project( mod tests { use super::*; use crate::cli::Cli; + use am_cloud_types::{PrivacyMode, ProjectType}; + use chrono::Utc; use clap::Parser; + fn project(id: &str, slug: &str, kind: ProjectType) -> Project { + Project { + id: id.into(), + org_id: "org_a".into(), + name: slug.into(), + slug: slug.into(), + environment: "dev".into(), + kind, + local_url: None, + privacy_mode: PrivacyMode::Connect, + created_at: Utc::now(), + memory_count: None, + last_activity_at: None, + } + } + #[test] fn init_project_flag_parses() { let cli = Cli::try_parse_from(["am", "init", "--project", "my-local"]).unwrap(); @@ -390,4 +701,313 @@ mod tests { _ => panic!("expected init --device"), } } + + #[test] + fn init_cloud_flag_parses() { + let cli = Cli::try_parse_from(["am", "init", "--cloud"]).unwrap(); + match cli.command { + crate::cli::Command::Init(InitOptions { cloud, .. }) => { + assert!(cloud); + } + _ => panic!("expected init --cloud"), + } + } + + #[test] + fn init_local_flag_parses_and_conflicts_with_cloud() { + let cli = Cli::try_parse_from(["am", "init", "--local"]).unwrap(); + match cli.command { + crate::cli::Command::Init(InitOptions { local, .. }) => assert!(local), + _ => panic!("expected init --local"), + } + assert!(Cli::try_parse_from(["am", "init", "--cloud", "--local"]).is_err()); + } + + #[test] + fn init_mode_defaults_cloud_and_infers_project_kind() { + assert_eq!( + resolve_init_mode(false, false, None, None).unwrap(), + InitActivationPath::HostedCloud + ); + assert_eq!( + resolve_init_mode(false, false, Some(ProjectType::Local), None).unwrap(), + InitActivationPath::ConnectedLocal + ); + assert_eq!( + resolve_init_mode(false, false, Some(ProjectType::Cloud), None).unwrap(), + InitActivationPath::HostedCloud + ); + } + + #[test] + fn explicit_init_mode_rejects_project_type_mismatch() { + let cloud_local = + resolve_init_mode(true, false, Some(ProjectType::Local), None).unwrap_err(); + assert!(cloud_local.to_string().contains("Local project")); + + let local_cloud = + resolve_init_mode(false, true, Some(ProjectType::Cloud), None).unwrap_err(); + assert!(local_cloud.to_string().contains("Hosted Cloud")); + } + + #[test] + fn blank_activation_choice_defaults_to_hosted_cloud() { + assert_eq!( + parse_init_activation_path_choice("").unwrap(), + InitActivationPath::HostedCloud + ); + assert_eq!( + parse_init_activation_path_choice("2").unwrap(), + InitActivationPath::ConnectedLocal + ); + } + + #[test] + fn project_slug_resolution_rejects_ambiguous_kinds() { + let projects = vec![ + project("proj_cloud", "test", ProjectType::Cloud), + project("proj_local", "test", ProjectType::Local), + ]; + let err = unique_init_project_by_ref(&projects, "TEST") + .unwrap_err() + .to_string(); + assert!(err.contains("ambiguous project slug")); + assert!(err.contains("project ID")); + } + + #[test] + fn project_id_resolution_remains_unambiguous() { + let projects = vec![ + project("proj_cloud", "test", ProjectType::Cloud), + project("proj_local", "test", ProjectType::Local), + ]; + let selected = unique_init_project_by_ref(&projects, "proj_local").unwrap(); + assert_eq!(selected.kind, ProjectType::Local); + } + + #[test] + fn default_project_alias_keeps_legacy_slug_support() { + let projects = vec![project( + "proj_legacy", + am_cloud_types::LEGACY_DEFAULT_PROJECT_SLUG, + ProjectType::Local, + )]; + let selected = + unique_init_project_by_ref(&projects, am_cloud_types::CANONICAL_DEFAULT_PROJECT_SLUG) + .unwrap(); + assert_eq!(selected.id, "proj_legacy"); + } + + #[test] + fn local_only_flags_are_validated_after_project_type_resolution() { + let cli = Cli::try_parse_from([ + "am", + "init", + "--project", + "test", + "--no-instance", + "--name", + "test-local", + ]) + .unwrap(); + let crate::cli::Command::Init(opts) = cli.command else { + panic!("expected init command"); + }; + + assert!( + validate_local_only_options(&opts, InitActivationPath::HostedCloud) + .unwrap_err() + .to_string() + .contains("Connected Local") + ); + validate_local_only_options(&opts, InitActivationPath::ConnectedLocal).unwrap(); + } + + #[test] + fn local_string_defaults_are_applied_only_by_local_routing() { + let cli = Cli::try_parse_from(["am", "init"]).unwrap(); + let crate::cli::Command::Init(opts) = cli.command else { + panic!("expected init command"); + }; + assert!(opts.name.is_none()); + assert!(opts.local_url.is_none()); + } + + #[test] + fn init_cloud_allows_core_image_env() { + let exe = std::env::current_exe().expect("test binary path"); + let output = std::process::Command::new(&exe) + .env("ATOMICMEMORY_CORE_IMAGE", "ghcr.io/example/core:1") + .args(["init", "--cloud"]) + .output() + .expect("spawn am init --cloud with core image env"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + !stderr.contains("cannot be used with"), + "ATOMICMEMORY_CORE_IMAGE must not bind to --image (clap conflict): {stderr}" + ); + if let Some(2) = output.status.code() { + panic!("clap rejected init --cloud with core image env set: {stderr}"); + } + } + + #[test] + fn browser_login_needs_a_terminal_but_device_login_does_not() { + // Browser OAuth waits two minutes on a loopback callback; with no + // terminal nobody can approve it, so bare `am init` in CI used to stall + // for the full timeout instead of failing with guidance. + assert!(!may_run_init_login(true, false, false)); + assert!(may_run_init_login(true, false, true)); + // --device prints a code to redeem elsewhere, so a pipe is fine. + assert!(may_run_init_login(true, true, false)); + // --yes / --quiet / -o json stay fail-closed regardless of the terminal. + assert!(!may_run_init_login(false, true, true)); + assert!(!may_run_init_login(false, false, true)); + } + + #[test] + fn parse_cloud_project_choice_rejects_blank_and_eof() { + assert_eq!( + parse_cloud_project_choice("", 1, 3).unwrap(), + CloudProjectChoice::Reprompt + ); + assert_eq!( + parse_cloud_project_choice("", 0, 3).unwrap(), + CloudProjectChoice::Eof + ); + assert_eq!( + parse_cloud_project_choice("2", 2, 3).unwrap(), + CloudProjectChoice::Selected(1) + ); + assert_eq!( + parse_cloud_project_choice("9", 2, 3).unwrap(), + CloudProjectChoice::Reprompt + ); + } + + #[test] + fn hosted_cloud_target_policy_never_picks_first_api_row() { + use am_cloud_types::{PrivacyMode, ProjectType}; + use chrono::Utc; + + let first = Project { + id: "proj_a".into(), + org_id: "org_a".into(), + name: "first".into(), + slug: "first".into(), + environment: "dev".into(), + kind: ProjectType::Cloud, + local_url: None, + privacy_mode: PrivacyMode::Connect, + created_at: Utc::now(), + memory_count: None, + last_activity_at: None, + }; + let second = Project { + id: "proj_b".into(), + org_id: "org_a".into(), + name: "second".into(), + slug: "second".into(), + environment: "dev".into(), + kind: ProjectType::Cloud, + local_url: None, + privacy_mode: PrivacyMode::Connect, + created_at: Utc::now(), + memory_count: None, + last_activity_at: None, + }; + let clouds = vec![&first, &second]; + assert!(matches!( + hosted_cloud_target_policy(&clouds, false, true), + HostedCloudTarget::ProjectsDashboard + )); + assert!(matches!( + hosted_cloud_target_policy(&clouds, true, false), + HostedCloudTarget::ProjectsDashboard + )); + assert!(matches!( + hosted_cloud_target_policy(&clouds, true, true), + HostedCloudTarget::Prompt + )); + assert!(matches!( + hosted_cloud_target_policy(&[], false, false), + HostedCloudTarget::OnboardingDashboard + )); + } + + #[test] + fn activation_context_omits_mode_until_assigned() { + let early = ActivationContext::default(); + assert!(!early.props().contains_key("mode")); + let cloud = ActivationContext::cloud(); + assert_eq!( + cloud.props().get("mode").and_then(|v| v.as_str()), + Some("cloud") + ); + } + + #[test] + fn hosted_cloud_handoff_context_carries_cloud_mode_and_project() { + let actx = ActivationContext { + org_id: Some("org_a".into()), + project_id: Some("proj_cloud".into()), + mode: ActivationContext::cloud().mode, + email_hash: None, + }; + let props = actx.props(); + assert_eq!(props.get("mode").and_then(|v| v.as_str()), Some("cloud")); + assert_eq!( + props.get("project_id").and_then(|v| v.as_str()), + Some("proj_cloud") + ); + } + + #[test] + fn ensure_cloud_project_for_handoff_rejects_local() { + use am_cloud_types::{PrivacyMode, ProjectType}; + use chrono::Utc; + + let local = Project { + id: "proj_local".into(), + org_id: "org_a".into(), + name: "local-dev".into(), + slug: "local-dev".into(), + environment: "dev".into(), + kind: ProjectType::Local, + local_url: Some("http://127.0.0.1:17350".into()), + privacy_mode: PrivacyMode::Connect, + created_at: Utc::now(), + memory_count: None, + last_activity_at: None, + }; + let err = ensure_cloud_project_for_handoff(&local) + .unwrap_err() + .to_string(); + assert!(err.contains("Local project")); + } + + #[test] + fn ensure_local_project_for_connect_rejects_cloud() { + use am_cloud_types::{PrivacyMode, ProjectType}; + use chrono::Utc; + + let cloud = Project { + id: "proj_cloud".into(), + org_id: "org_a".into(), + name: "rapid-walrus".into(), + slug: "rapid-walrus".into(), + environment: "dev".into(), + kind: ProjectType::Cloud, + local_url: None, + privacy_mode: PrivacyMode::Connect, + created_at: Utc::now(), + memory_count: None, + last_activity_at: None, + }; + let err = crate::commands::connect_project::ensure_local_project_for_connect(&cloud) + .unwrap_err() + .to_string(); + assert!(err.contains("Hosted Cloud")); + assert!(err.contains("am init --cloud")); + } } diff --git a/crates/cli/src/commands/init/hosted_cloud.rs b/crates/cli/src/commands/init/hosted_cloud.rs new file mode 100644 index 0000000..8148392 --- /dev/null +++ b/crates/cli/src/commands/init/hosted_cloud.rs @@ -0,0 +1,1855 @@ +//! Hosted Cloud project selection, credential provisioning, and profile activation. + +use std::io::{self, IsTerminal, Write as _}; +use std::time::Duration; + +use am_cloud_client::{CloudClientError, DashboardClient, MemoryClient}; +use am_cloud_types::{ApiKey, ApiKeyWithSecret, CreateApiKeyRequest, Project}; +use anyhow::{Context, Result, bail}; +use url::Url; + +use crate::auth::origin::same_origin; +use crate::cli::GlobalOptions; +use crate::commands::cloud_api_key::{is_api_key_quota_exceeded, select_named_key_for_rotate}; +use crate::commands::connect_project::{ + HostedCloudTarget, cloud_projects, ensure_cloud_project_for_handoff, hosted_cloud_target_policy, +}; +use crate::config::{ + ENV_API_KEY, ENV_API_KEY_FORCE, activate_hosted_cloud_profile, + ensure_hosted_cloud_profile_available, load_config, load_credentials, machine_scoped_key_name, + store_hosted_cloud_api_key, +}; +use crate::environment::{ + dashboard_onboarding_url, dashboard_project_url, dashboard_projects_url, + is_remote_cloud_api_url, +}; +use crate::progress::{ProgressReporter, with_progress_paused_for_input}; +use crate::telemetry::{ActivationContext, ActivationEvent, capture_activation}; + +const HOSTED_PROJECT_POLL_INTERVAL: Duration = Duration::from_secs(2); +const HOSTED_PROJECT_POLL_TIMEOUT: Duration = Duration::from_secs(10 * 60); +const HOSTED_CLI_KEY_NAME: &str = "am-cli"; + +#[async_trait::async_trait] +trait HostedCloudBackend: Send + Sync { + async fn list_projects(&self) -> Result, CloudClientError>; +} + +#[async_trait::async_trait] +impl HostedCloudBackend for DashboardClient { + async fn list_projects(&self) -> Result, CloudClientError> { + DashboardClient::list_projects(self).await + } +} + +#[async_trait::async_trait] +trait HostedCredentialBackend: Send + Sync { + async fn list_api_keys(&self, project_id: &str) -> Result, CloudClientError>; + + async fn rotate_api_key( + &self, + project_id: &str, + key_id: &str, + ) -> Result; + + async fn create_api_key( + &self, + project_id: &str, + request: &CreateApiKeyRequest, + ) -> Result; + + async fn revoke_api_key(&self, project_id: &str, key_id: &str) -> Result<(), CloudClientError>; +} + +#[async_trait::async_trait] +impl HostedCredentialBackend for DashboardClient { + async fn list_api_keys(&self, project_id: &str) -> Result, CloudClientError> { + DashboardClient::list_api_keys(self, project_id).await + } + + async fn rotate_api_key( + &self, + project_id: &str, + key_id: &str, + ) -> Result { + DashboardClient::rotate_api_key(self, project_id, key_id).await + } + + async fn create_api_key( + &self, + project_id: &str, + request: &CreateApiKeyRequest, + ) -> Result { + DashboardClient::create_api_key(self, project_id, request).await + } + + async fn revoke_api_key(&self, project_id: &str, key_id: &str) -> Result<(), CloudClientError> { + DashboardClient::revoke_api_key(self, project_id, key_id).await + } +} + +#[async_trait::async_trait] +trait HostedCredentialProbe: Send + Sync { + async fn health(&self, api_origin: &str, secret: &str) -> Result<(), CloudClientError>; +} + +struct MemoryHealthProbe; + +#[async_trait::async_trait] +impl HostedCredentialProbe for MemoryHealthProbe { + async fn health(&self, api_origin: &str, secret: &str) -> Result<(), CloudClientError> { + let base_url = Url::parse(api_origin)?; + MemoryClient::new(base_url, secret)? + .health() + .await + .map(|_| ()) + } +} + +enum HostedCredentialOutcome { + Reused, + Rotated { key_id: String, secret: String }, + Created { key_id: String, secret: String }, +} + +#[derive(Debug, thiserror::Error)] +enum HostedCredentialError { + #[error("Hosted Cloud API key quota exceeded: {0}")] + Quota(CloudClientError), + #[error(transparent)] + Other(#[from] anyhow::Error), +} + +fn hosted_cloud_credential_ref(project_id: &str) -> String { + format!("hosted-cloud-{project_id}") +} + +async fn provision_hosted_cloud_key( + backend: &dyn HostedCredentialBackend, + probe: &dyn HostedCredentialProbe, + api_origin: &str, + project_id: &str, + stored: Option<&crate::config::ApiKeySecret>, + // Resolved by the caller so this stays free of config I/O. + key_name: &str, +) -> std::result::Result { + if let Some(stored) = stored.filter(|stored| { + stored + .api_origin + .as_deref() + .is_some_and(|origin| same_origin(origin, api_origin)) + && stored.project_id.as_deref() == Some(project_id) + }) { + match probe.health(api_origin, &stored.secret).await { + Ok(()) => return Ok(HostedCredentialOutcome::Reused), + Err(CloudClientError::Auth) => {} + Err(err) => { + return Err(anyhow::anyhow!( + "could not verify the stored Hosted Cloud credential: {err}" + ) + .into()); + } + } + } + + let keys = backend + .list_api_keys(project_id) + .await + .context("list Hosted Cloud API keys")?; + let (provisioned, was_created) = + if let Some(existing) = select_named_key_for_rotate(&keys, key_name) { + let rotated = backend + .rotate_api_key(project_id, &existing.id) + .await + .with_context(|| { + format!("rotate Hosted Cloud API key '{key_name}' ({})", existing.id) + })?; + (rotated, false) + } else { + let created = backend + .create_api_key( + project_id, + &CreateApiKeyRequest { + name: key_name.to_string(), + environment: None, + }, + ) + .await + .map_err(|err| { + if is_api_key_quota_exceeded(&err) { + HostedCredentialError::Quota(err) + } else { + HostedCredentialError::Other(anyhow::anyhow!( + "create Hosted Cloud API key '{key_name}': {err}" + )) + } + })?; + (created, true) + }; + + if provisioned.key.project_id != project_id { + return Err(if was_created { + rollback_invalid_hosted_key( + backend, + project_id, + &provisioned.key.id, + "project mismatch", + ) + .await + } else { + anyhow::anyhow!( + "rotated Hosted Cloud API key failed validation (project mismatch); no key was revoked" + ) + .into() + }); + } + if let Err(err) = probe.health(api_origin, &provisioned.secret).await { + return Err(if was_created { + rollback_invalid_hosted_key(backend, project_id, &provisioned.key.id, &err.to_string()) + .await + } else { + anyhow::anyhow!( + "rotated Hosted Cloud API key failed validation ({}); no key was revoked", + redact_secret(&err.to_string(), &provisioned.secret) + ) + .into() + }); + } + + if was_created { + Ok(HostedCredentialOutcome::Created { + key_id: provisioned.key.id, + secret: provisioned.secret, + }) + } else { + Ok(HostedCredentialOutcome::Rotated { + key_id: provisioned.key.id, + secret: provisioned.secret, + }) + } +} + +async fn rollback_invalid_hosted_key( + backend: &dyn HostedCredentialBackend, + project_id: &str, + key_id: &str, + validation_error: &str, +) -> HostedCredentialError { + match backend.revoke_api_key(project_id, key_id).await { + Ok(()) => anyhow::anyhow!( + "new Hosted Cloud API key failed validation ({validation_error}); revoked newly created key {key_id}" + ) + .into(), + Err(rollback_error) => anyhow::anyhow!( + "new Hosted Cloud API key failed validation ({validation_error}); cleanup of newly created key {key_id} also failed: {rollback_error}" + ) + .into(), + } +} + +/// Persist a freshly created key, giving it back to the server if the write fails. +/// +/// The secret returned by `create_api_key` is never retrievable again, so a +/// failed write leaves a live key on the project that nothing can use and +/// nothing will clean up. Every retry mints another one until the project hits +/// its key quota. `store` is a closure so this decision stays testable without +/// touching the real credentials file. +async fn persist_hosted_key_or_rollback( + backend: &dyn HostedCredentialBackend, + project_id: &str, + key_id: &str, + secret: &str, + store: F, +) -> std::result::Result<(), HostedCredentialError> +where + F: FnOnce() -> Result<()>, +{ + match store() { + Ok(()) => Ok(()), + Err(err) => Err(rollback_unpersisted_hosted_key( + backend, + project_id, + key_id, + // The writer quotes what it was given; never let that reach a message. + &redact_secret(&err.to_string(), secret), + ) + .await), + } +} + +/// Persist a rotated singleton without revoking it if the local write fails. +/// +/// Rotation has already invalidated the previous secret. Revoking the rotated +/// key would remove the singleton entirely, so recovery is another targeted +/// init after the local write problem is fixed. +fn persist_rotated_hosted_key( + project_id: &str, + key_id: &str, + secret: &str, + store: F, +) -> Result<()> +where + F: FnOnce() -> Result<()>, +{ + store().map_err(|err| { + anyhow::anyhow!( + "rotated Hosted Cloud API key {key_id}, but could not save its credential ({}); run `am init --project {project_id}` again", + redact_secret(&err.to_string(), secret) + ) + }) +} + +async fn rollback_unpersisted_hosted_key( + backend: &dyn HostedCredentialBackend, + project_id: &str, + key_id: &str, + store_error: &str, +) -> HostedCredentialError { + match backend.revoke_api_key(project_id, key_id).await { + Ok(()) => anyhow::anyhow!( + "could not save the new Hosted Cloud API key ({store_error}); revoked newly created key {key_id}" + ) + .into(), + Err(rollback_error) => anyhow::anyhow!( + "could not save the new Hosted Cloud API key ({store_error}); cleanup of newly created key {key_id} also failed: {rollback_error} — revoke it in the dashboard" + ) + .into(), + } +} + +/// Replace any occurrence of a secret in text that is about to be shown. +/// +/// Errors from the credential writer quote paths and, depending on the backend, +/// can quote the value being serialized. Nothing downstream should have to +/// reason about which ones do. +fn redact_secret(text: &str, secret: &str) -> String { + if secret.is_empty() { + return text.to_string(); + } + text.replace(secret, "«redacted»") +} + +pub(super) struct HostedCloudHandoffInput<'a> { + pub(super) client: &'a DashboardClient, + pub(super) cloud_api_url: &'a str, + pub(super) cloud_profile: &'a str, + pub(super) project: Option, + pub(super) interactive: bool, + pub(super) actx: &'a mut ActivationContext, + pub(super) no_telemetry: bool, + pub(super) global: &'a GlobalOptions, + pub(super) progress: &'a mut dyn ProgressReporter, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum HostedHandoffUrl { + Project, + Onboarding, + ProjectsList, +} + +pub(super) async fn run_hosted_cloud_handoff(input: HostedCloudHandoffInput<'_>) -> Result<()> { + let HostedCloudHandoffInput { + client, + cloud_api_url, + cloud_profile, + project, + interactive, + actx, + no_telemetry, + global, + progress, + } = input; + progress.start_step("project", "Hosted Cloud"); + + let project_explicit = project.is_some(); + let (mut target, handoff_url) = if let Some(project) = project { + ensure_cloud_project_for_handoff(&project)?; + (Some(project), HostedHandoffUrl::Project) + } else { + select_hosted_cloud_target(client, interactive, progress).await? + }; + + let dashboard_url = hosted_cloud_url(cloud_api_url, target.as_ref(), handoff_url)?; + actx.mode = ActivationContext::cloud().mode; + if let Some(project) = target.as_ref() { + actx.project_id = Some(project.id.clone()); + } + capture_activation( + ActivationEvent::HostedCloudHandoff, + Some(actx.props()), + no_telemetry, + ); + + if !global.quiet && handoff_url != HostedHandoffUrl::Project { + eprintln!("\nHosted Cloud runs in the browser — no Docker required on this path."); + eprintln!("Dashboard: {dashboard_url}"); + } + let stdin_is_tty = io::stdin().is_terminal(); + let may_wait = may_wait_for_onboarding(interactive, stdin_is_tty); + if should_open_handoff_browser(handoff_url, interactive, stdin_is_tty, global.quiet) { + if let Err(err) = open::that(&dashboard_url) { + eprintln!("Could not open a browser ({err}). Open the URL above manually."); + } else { + eprintln!("Opened the dashboard in your browser."); + } + } + + if target.is_none() { + match handoff_url { + HostedHandoffUrl::Onboarding if may_wait => { + progress.tick("project", "waiting for project creation"); + let projects = poll_for_cloud_projects( + client, + HOSTED_PROJECT_POLL_INTERVAL, + HOSTED_PROJECT_POLL_TIMEOUT, + ) + .await + .inspect_err(|err| progress.fail("project", Some(&err.to_string())))?; + let project = select_polled_cloud_project(&projects, progress)?; + actx.project_id = Some(project.id.clone()); + target = Some(project); + } + HostedHandoffUrl::Onboarding => { + let error = unattended_onboarding_error(&dashboard_url); + progress.fail("project", Some(&error.to_string())); + return Err(error); + } + HostedHandoffUrl::ProjectsList => { + let error = unattended_project_selection_error(&dashboard_url); + progress.fail("project", Some(&error.to_string())); + return Err(error); + } + HostedHandoffUrl::Project => unreachable!("project handoff always has a target"), + } + } + + let project = target.ok_or_else(|| anyhow::anyhow!("Hosted Cloud project is required"))?; + progress.succeed( + "project", + Some(&format!("{} ({})", project.name, project.slug)), + ); + configure_hosted_cloud(ConfigureHostedCloudInput { + backend: client, + probe: &MemoryHealthProbe, + cloud_api_url, + cloud_profile, + project: &project, + interactive, + project_explicit, + actx, + no_telemetry, + global, + progress, + }) + .await +} + +fn may_wait_for_onboarding(interactive: bool, stdin_is_tty: bool) -> bool { + interactive && stdin_is_tty +} + +fn should_open_handoff_browser( + handoff_url: HostedHandoffUrl, + interactive: bool, + stdin_is_tty: bool, + quiet: bool, +) -> bool { + handoff_url != HostedHandoffUrl::Project && interactive && stdin_is_tty && !quiet +} + +struct ConfigureHostedCloudInput<'a> { + backend: &'a dyn HostedCredentialBackend, + probe: &'a dyn HostedCredentialProbe, + cloud_api_url: &'a str, + cloud_profile: &'a str, + project: &'a Project, + interactive: bool, + project_explicit: bool, + actx: &'a mut ActivationContext, + no_telemetry: bool, + global: &'a GlobalOptions, + progress: &'a mut dyn ProgressReporter, +} + +async fn configure_hosted_cloud(input: ConfigureHostedCloudInput<'_>) -> Result<()> { + let ConfigureHostedCloudInput { + backend, + probe, + cloud_api_url, + cloud_profile, + project, + interactive, + project_explicit, + actx, + no_telemetry, + global, + progress, + } = input; + ensure_hosted_cloud_profile_available(cloud_profile)?; + if let Some(previous_project_id) = + hosted_profile_project_change(&load_config()?, cloud_profile, &project.id) + { + if !global.quiet { + eprintln!( + "Profile '{cloud_profile}' will switch from project '{previous_project_id}' to '{}' and become the default profile.", + project.id + ); + } + let stdin_is_tty = io::stdin().is_terminal(); + if requires_project_switch_confirmation(true, interactive, stdin_is_tty, project_explicit) + && !with_progress_paused_for_input(progress, true, || { + confirm_hosted_project_switch(cloud_profile, &previous_project_id, &project.id) + })? + { + bail!( + "Hosted Cloud project switch cancelled before credentials or profile configuration changed" + ); + } + } + let credential_ref = hosted_cloud_credential_ref(&project.id); + let stored = load_credentials()?.api_keys.get(&credential_ref).cloned(); + + // Scoped to this install: rotation invalidates the old secret, so a + // project-wide name would let this machine rotate a key another machine is + // actively using. + let key_name = machine_scoped_key_name(HOSTED_CLI_KEY_NAME)?; + + progress.start_step("credential", "Hosted Cloud API key"); + let outcome = match provision_hosted_cloud_key( + backend, + probe, + cloud_api_url, + &project.id, + stored.as_ref(), + &key_name, + ) + .await + { + Ok(outcome) => outcome, + Err(HostedCredentialError::Quota(err)) => { + progress.fail("credential", Some("API key quota exceeded")); + return Err(hosted_quota_error( + cloud_api_url, + &project.id, + interactive, + global, + err, + )); + } + Err(err) => { + progress.fail("credential", Some(&err.to_string())); + return Err(anyhow::anyhow!("{err}")); + } + }; + + let detail = match outcome { + HostedCredentialOutcome::Reused => "reused stored am-cli key".to_string(), + HostedCredentialOutcome::Rotated { key_id, secret } => { + if let Err(err) = persist_rotated_hosted_key(&project.id, &key_id, &secret, || { + store_hosted_cloud_api_key(&credential_ref, &secret, cloud_api_url, &project.id) + }) { + progress.fail("credential", Some(&err.to_string())); + return Err(err); + } + format!("rotated existing am-cli key ({key_id})") + } + HostedCredentialOutcome::Created { key_id, secret } => { + if let Err(err) = + persist_hosted_key_or_rollback(backend, &project.id, &key_id, &secret, || { + store_hosted_cloud_api_key(&credential_ref, &secret, cloud_api_url, &project.id) + }) + .await + { + progress.fail("credential", Some(&err.to_string())); + return Err(err.into()); + } + format!("created am-cli key ({key_id})") + } + }; + if let Err(err) = activate_hosted_cloud_profile( + cloud_profile, + cloud_api_url, + &project.id, + cloud_profile, + &credential_ref, + ) { + let error = + hosted_profile_activation_error(err, cloud_profile, &project.id, &credential_ref); + progress.fail("credential", Some(&error.to_string())); + return Err(error); + } + + actx.project_id = Some(project.id.clone()); + capture_activation( + ActivationEvent::HostedCloudConfigured, + Some(actx.props()), + no_telemetry, + ); + progress.succeed("credential", Some(&detail)); + maybe_warn_shell_cloud_env_exports(!global.quiet); + if !global.quiet { + eprintln!( + "\nHosted Cloud is ready on profile '{cloud_profile}'. You can now run `am memory …` or `am integrate`." + ); + } + Ok(()) +} + +fn maybe_warn_shell_cloud_env_exports(verbose: bool) { + if !verbose { + return; + } + const ENV_API_URL: &str = "ATOMICMEMORY_API_URL"; + let has_api_key = std::env::var(ENV_API_KEY) + .ok() + .is_some_and(|value| !value.is_empty()); + let has_cloud_url = std::env::var(ENV_API_URL) + .ok() + .filter(|value| !value.is_empty()) + .is_some_and(|url| is_remote_cloud_api_url(&url)); + if has_api_key || has_cloud_url { + eprintln!( + "note: shell still has ATOMICMEMORY_API_* set; Hosted Cloud will use the saved profile key unless {ENV_API_KEY_FORCE}=1 — unset the vars to avoid confusion" + ); + } +} + +fn hosted_profile_project_change( + config: &crate::config::ConfigFile, + profile_name: &str, + project_id: &str, +) -> Option { + config + .profiles + .get(profile_name) + .filter(|profile| profile.kind == crate::config::ProfileKind::Cloud) + .and_then(|profile| profile.project_id.as_deref()) + .filter(|previous| *previous != project_id) + .map(str::to_string) +} + +fn requires_project_switch_confirmation( + has_change: bool, + interactive: bool, + stdin_is_tty: bool, + project_explicit: bool, +) -> bool { + has_change && interactive && stdin_is_tty && !project_explicit +} + +fn confirm_hosted_project_switch( + profile_name: &str, + previous_project_id: &str, + project_id: &str, +) -> Result { + eprint!( + "Switch profile '{profile_name}' from project '{previous_project_id}' to '{project_id}'? [y/N]: " + ); + io::stderr().flush().ok(); + let mut line = String::new(); + io::stdin() + .read_line(&mut line) + .context("read Hosted Cloud project switch confirmation")?; + Ok(matches!( + line.trim().to_ascii_lowercase().as_str(), + "y" | "yes" + )) +} + +fn hosted_profile_activation_error( + source: anyhow::Error, + profile_name: &str, + project_id: &str, + credential_ref: &str, +) -> anyhow::Error { + anyhow::anyhow!( + "Hosted Cloud credential is already saved as '{credential_ref}', but profile '{profile_name}' could not be activated: {source}. Fix the configuration write problem, then run `am init --project {project_id}` again" + ) +} + +fn hosted_quota_error( + cloud_api_url: &str, + project_id: &str, + interactive: bool, + global: &GlobalOptions, + source: CloudClientError, +) -> anyhow::Error { + let dashboard_url = dashboard_project_url(cloud_api_url, project_id) + .or_else(|| dashboard_onboarding_url(cloud_api_url)) + .unwrap_or_else(|| cloud_api_url.to_string()); + if interactive && io::stdin().is_terminal() && !global.quiet { + if let Err(err) = open::that(&dashboard_url) { + eprintln!("Could not open a browser ({err}). Open the URL below manually."); + } + } + anyhow::anyhow!( + "{source}\n{}\nThe previous default profile was preserved; no existing key was rotated or revoked.", + quota_recovery_message(project_id, &dashboard_url) + ) +} + +fn quota_recovery_message(project_id: &str, dashboard_url: &str) -> String { + format!( + "Manage this project's API keys at {dashboard_url}, then run:\n\ + am key list --project {project_id}\n\ + am key revoke --project {project_id} \n\ + am init --project {project_id}" + ) +} + +async fn select_hosted_cloud_target( + client: &DashboardClient, + interactive: bool, + progress: &mut dyn ProgressReporter, +) -> Result<(Option, HostedHandoffUrl)> { + let projects = client + .list_projects() + .await + .map_err(|err| anyhow::anyhow!("{err}"))?; + let clouds = cloud_projects(&projects); + let policy = hosted_cloud_target_policy(&clouds, interactive, io::stdin().is_terminal()); + match policy { + HostedCloudTarget::Project(project) => { + Ok((Some((*project).clone()), HostedHandoffUrl::Project)) + } + HostedCloudTarget::Prompt => { + let may_prompt = interactive && io::stdin().is_terminal(); + let picked = with_progress_paused_for_input(progress, may_prompt, || { + prompt_cloud_project(&clouds) + })?; + Ok((Some(picked.clone()), HostedHandoffUrl::Project)) + } + HostedCloudTarget::OnboardingDashboard => Ok((None, HostedHandoffUrl::Onboarding)), + HostedCloudTarget::ProjectsDashboard => Ok((None, HostedHandoffUrl::ProjectsList)), + } +} + +fn hosted_cloud_url( + cloud_api_url: &str, + project: Option<&Project>, + handoff_url: HostedHandoffUrl, +) -> Result { + let url = if let Some(project) = project { + dashboard_project_url(cloud_api_url, &project.id) + .or_else(|| dashboard_onboarding_url(cloud_api_url)) + } else { + match handoff_url { + HostedHandoffUrl::ProjectsList => dashboard_projects_url(cloud_api_url), + HostedHandoffUrl::Onboarding | HostedHandoffUrl::Project => { + dashboard_onboarding_url(cloud_api_url) + } + } + }; + url.ok_or_else(|| { + anyhow::anyhow!( + "could not build dashboard URL for {cloud_api_url} — open the console in your browser manually" + ) + }) +} + +fn select_polled_cloud_project( + projects: &[Project], + progress: &mut dyn ProgressReporter, +) -> Result { + match projects { + [project] => Ok(project.clone()), + [] => bail!("Hosted Cloud project polling completed without a project"), + _ => { + let clouds = projects.iter().collect::>(); + let picked = + with_progress_paused_for_input(progress, true, || prompt_cloud_project(&clouds))?; + Ok(picked.clone()) + } + } +} + +fn prompt_cloud_project<'a>(clouds: &[&'a Project]) -> Result<&'a Project> { + eprintln!(); + eprintln!("Select a Hosted Cloud project:"); + for (index, project) in clouds.iter().enumerate() { + eprintln!(" {}) {} ({})", index + 1, project.name, project.slug); + } + loop { + eprint!("Choose [1-{}]: ", clouds.len()); + io::stderr().flush().ok(); + let mut line = String::new(); + let bytes_read = io::stdin() + .read_line(&mut line) + .context("read cloud project choice")?; + match parse_cloud_project_choice(line.trim(), bytes_read, clouds.len())? { + CloudProjectChoice::Reprompt => { + eprintln!("Enter a number between 1 and {}.", clouds.len()); + } + CloudProjectChoice::Eof => { + bail!( + "Hosted Cloud project selection required — choose a number or rerun with --cloud --project " + ); + } + CloudProjectChoice::Selected(index) => return Ok(clouds[index]), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum CloudProjectChoice { + Reprompt, + Eof, + Selected(usize), +} + +pub(super) fn parse_cloud_project_choice( + choice: &str, + bytes_read: usize, + count: usize, +) -> Result { + if bytes_read == 0 { + return Ok(CloudProjectChoice::Eof); + } + if choice.is_empty() { + return Ok(CloudProjectChoice::Reprompt); + } + let Ok(num) = choice.parse::() else { + return Ok(CloudProjectChoice::Reprompt); + }; + if !(1..=count).contains(&num) { + return Ok(CloudProjectChoice::Reprompt); + } + Ok(CloudProjectChoice::Selected(num - 1)) +} + +async fn poll_for_cloud_projects( + backend: &dyn HostedCloudBackend, + interval: Duration, + timeout: Duration, +) -> Result> { + let deadline = tokio::time::Instant::now() + timeout; + loop { + let next_poll = (tokio::time::Instant::now() + interval).min(deadline); + tokio::time::sleep_until(next_poll).await; + + match backend.list_projects().await { + Ok(projects) => { + let clouds = projects + .into_iter() + .filter(|project| project.kind == am_cloud_types::ProjectType::Cloud) + .collect::>(); + if !clouds.is_empty() { + return Ok(clouds); + } + } + Err(err) if is_transient_poll_error(&err) => {} + Err(err) => return Err(anyhow::anyhow!("{err}")), + } + + if tokio::time::Instant::now() >= deadline { + bail!( + "timed out after 10 minutes waiting for a Hosted Cloud project; finish onboarding, then rerun `am init`" + ); + } + } +} + +fn is_transient_poll_error(error: &CloudClientError) -> bool { + matches!( + error, + CloudClientError::Timeout | CloudClientError::Network(_) + ) || matches!(error, CloudClientError::Status { code, .. } if *code >= 500 || *code == 429) +} + +fn unattended_onboarding_error(onboarding_url: &str) -> anyhow::Error { + anyhow::anyhow!( + "Hosted Cloud has no project yet. Complete onboarding at {onboarding_url}, then run `am init` again." + ) +} + +fn unattended_project_selection_error(projects_url: &str) -> anyhow::Error { + anyhow::anyhow!( + "multiple Hosted Cloud projects are available. Choose one at {projects_url}, then run `am init --project `." + ) +} + +#[cfg(test)] +mod tests { + /// Stands in for this install's scoped key name. + const TEST_KEY_NAME: &str = "am-cli-testmachine"; + + use std::collections::VecDeque; + use std::sync::Mutex; + + use am_cloud_client::CloudClientError; + use am_cloud_types::{ApiKey, ApiKeyWithSecret, PrivacyMode, ProjectType}; + use chrono::Utc; + + use super::*; + + struct FakeHostedCloudBackend { + responses: Mutex, CloudClientError>>>, + } + + struct FakeCredentialBackend { + lists: Mutex, CloudClientError>>>, + list_projects: Mutex>, + rotates: Mutex>>, + rotate_calls: Mutex>, + creates: Mutex>>, + create_projects: Mutex>, + create_names: Mutex>, + revokes: Mutex>, + revoke_error: Mutex>, + } + + struct FakeCredentialProbe { + responses: Mutex>>, + calls: Mutex, + } + + #[async_trait::async_trait] + impl HostedCloudBackend for FakeHostedCloudBackend { + async fn list_projects(&self) -> Result, CloudClientError> { + self.responses + .lock() + .expect("fake backend lock") + .pop_front() + .unwrap_or_else(|| Ok(Vec::new())) + } + } + + #[async_trait::async_trait] + impl HostedCredentialBackend for FakeCredentialBackend { + async fn list_api_keys(&self, project_id: &str) -> Result, CloudClientError> { + self.list_projects + .lock() + .expect("list projects lock") + .push(project_id.to_string()); + self.lists + .lock() + .expect("list responses lock") + .pop_front() + .unwrap_or_else(|| Ok(Vec::new())) + } + + async fn rotate_api_key( + &self, + project_id: &str, + key_id: &str, + ) -> Result { + self.rotate_calls + .lock() + .expect("rotate calls lock") + .push((project_id.to_string(), key_id.to_string())); + self.rotates + .lock() + .expect("rotate responses lock") + .pop_front() + .unwrap_or_else(|| panic!("missing rotate response")) + } + + async fn create_api_key( + &self, + project_id: &str, + request: &am_cloud_types::CreateApiKeyRequest, + ) -> Result { + self.create_projects + .lock() + .expect("create projects lock") + .push(project_id.to_string()); + self.create_names + .lock() + .expect("create names lock") + .push(request.name.clone()); + self.creates + .lock() + .expect("create responses lock") + .pop_front() + .unwrap_or_else(|| panic!("missing create response")) + } + + async fn revoke_api_key( + &self, + project_id: &str, + key_id: &str, + ) -> Result<(), CloudClientError> { + self.revokes + .lock() + .expect("revoke calls lock") + .push((project_id.to_string(), key_id.to_string())); + self.revoke_error + .lock() + .expect("revoke error lock") + .take() + .map_or(Ok(()), Err) + } + } + + #[async_trait::async_trait] + impl HostedCredentialProbe for FakeCredentialProbe { + async fn health(&self, _api_origin: &str, _secret: &str) -> Result<(), CloudClientError> { + *self.calls.lock().expect("probe calls lock") += 1; + self.responses + .lock() + .expect("probe responses lock") + .pop_front() + .unwrap_or_else(|| panic!("missing probe response")) + } + } + + fn project(id: &str, kind: ProjectType) -> Project { + Project { + id: id.into(), + org_id: "org_a".into(), + name: id.into(), + slug: id.into(), + environment: "dev".into(), + kind, + local_url: None, + privacy_mode: PrivacyMode::Connect, + created_at: Utc::now(), + memory_count: None, + last_activity_at: None, + } + } + + fn backend(responses: Vec, CloudClientError>>) -> FakeHostedCloudBackend { + FakeHostedCloudBackend { + responses: Mutex::new(responses.into()), + } + } + + fn credential_backend( + creates: Vec>, + ) -> FakeCredentialBackend { + FakeCredentialBackend { + lists: Mutex::new(VecDeque::new()), + list_projects: Mutex::new(Vec::new()), + rotates: Mutex::new(VecDeque::new()), + rotate_calls: Mutex::new(Vec::new()), + creates: Mutex::new(creates.into()), + create_projects: Mutex::new(Vec::new()), + create_names: Mutex::new(Vec::new()), + revokes: Mutex::new(Vec::new()), + revoke_error: Mutex::new(None), + } + } + + fn credential_probe(responses: Vec>) -> FakeCredentialProbe { + FakeCredentialProbe { + responses: Mutex::new(responses.into()), + calls: Mutex::new(0), + } + } + + /// A listed key owned by someone else, identified purely by its name. + fn named_key(project_id: &str, key_id: &str, name: &str) -> ApiKey { + ApiKey { + id: key_id.into(), + project_id: project_id.into(), + name: name.into(), + prefix: "amc_test".into(), + status: "active".into(), + created_at: Utc::now(), + last_used_at: None, + } + } + + fn created_key(project_id: &str, key_id: &str, secret: &str) -> ApiKeyWithSecret { + ApiKeyWithSecret { + key: ApiKey { + id: key_id.into(), + project_id: project_id.into(), + name: TEST_KEY_NAME.into(), + prefix: "amc_test".into(), + status: "active".into(), + created_at: Utc::now(), + last_used_at: None, + }, + secret: secret.into(), + } + } + + fn stored_key(origin: &str, project_id: &str) -> crate::config::ApiKeySecret { + crate::config::ApiKeySecret { + secret: "amc_stored_secret".into(), + api_origin: Some(origin.into()), + project_id: Some(project_id.into()), + } + } + + #[tokio::test(start_paused = true)] + async fn onboarding_poll_retries_transient_failures_until_cloud_exists() { + let backend = backend(vec![ + Err(CloudClientError::Timeout), + Err(CloudClientError::Network("offline".into())), + Err(CloudClientError::Status { + code: 503, + body: "unavailable".into(), + }), + Ok(vec![project("proj_local", ProjectType::Local)]), + Ok(vec![project("proj_cloud", ProjectType::Cloud)]), + ]); + + let projects = poll_for_cloud_projects( + &backend, + HOSTED_PROJECT_POLL_INTERVAL, + HOSTED_PROJECT_POLL_TIMEOUT, + ) + .await + .unwrap(); + assert_eq!(projects[0].id, "proj_cloud"); + } + + #[tokio::test(start_paused = true)] + async fn onboarding_poll_fails_immediately_on_authentication_error() { + let backend = backend(vec![Err(CloudClientError::Auth)]); + let started = tokio::time::Instant::now(); + + let error = poll_for_cloud_projects( + &backend, + HOSTED_PROJECT_POLL_INTERVAL, + HOSTED_PROJECT_POLL_TIMEOUT, + ) + .await + .unwrap_err(); + assert!(error.to_string().contains("authentication failed")); + assert_eq!(started.elapsed(), HOSTED_PROJECT_POLL_INTERVAL); + } + + #[tokio::test(start_paused = true)] + async fn onboarding_poll_fails_immediately_without_an_organization() { + let backend = backend(vec![Err(CloudClientError::NoActiveOrganization)]); + let started = tokio::time::Instant::now(); + + let error = poll_for_cloud_projects( + &backend, + HOSTED_PROJECT_POLL_INTERVAL, + HOSTED_PROJECT_POLL_TIMEOUT, + ) + .await + .unwrap_err(); + assert!(error.to_string().contains("no active organization")); + assert_eq!(started.elapsed(), HOSTED_PROJECT_POLL_INTERVAL); + } + + #[tokio::test(start_paused = true)] + async fn onboarding_poll_stops_at_ten_minute_deadline() { + let backend = backend(Vec::new()); + let started = tokio::time::Instant::now(); + + let error = poll_for_cloud_projects( + &backend, + HOSTED_PROJECT_POLL_INTERVAL, + HOSTED_PROJECT_POLL_TIMEOUT, + ) + .await + .unwrap_err(); + assert!(error.to_string().contains("10 minutes")); + assert_eq!(started.elapsed(), HOSTED_PROJECT_POLL_TIMEOUT); + } + + #[test] + fn unattended_zero_project_recovery_is_actionable() { + let error = unattended_onboarding_error("https://app.atomicmemory.test/onboarding"); + let message = error.to_string(); + assert!(message.contains("https://app.atomicmemory.test/onboarding")); + assert!(message.contains("am init")); + } + + #[test] + fn browser_onboarding_wait_requires_prompts_and_a_tty() { + assert!(may_wait_for_onboarding(true, true)); + assert!(!may_wait_for_onboarding(false, true)); + assert!(!may_wait_for_onboarding(true, false)); + } + + #[test] + fn browser_opens_only_for_unresolved_project_handoffs() { + assert!(should_open_handoff_browser( + HostedHandoffUrl::Onboarding, + true, + true, + false + )); + assert!(should_open_handoff_browser( + HostedHandoffUrl::ProjectsList, + true, + true, + false + )); + assert!(!should_open_handoff_browser( + HostedHandoffUrl::Project, + true, + true, + false + )); + assert!(!should_open_handoff_browser( + HostedHandoffUrl::Onboarding, + false, + true, + false + )); + assert!(!should_open_handoff_browser( + HostedHandoffUrl::Onboarding, + true, + false, + false + )); + assert!(!should_open_handoff_browser( + HostedHandoffUrl::Onboarding, + true, + true, + true + )); + } + + #[test] + fn project_switch_policy_detects_cloud_project_changes_only() { + let mut config = crate::config::ConfigFile::default(); + config.profiles.insert( + "cloud".into(), + crate::config::ProfileConfig { + kind: crate::config::ProfileKind::Cloud, + project_id: Some("proj_old".into()), + ..Default::default() + }, + ); + + assert_eq!( + hosted_profile_project_change(&config, "cloud", "proj_new").as_deref(), + Some("proj_old") + ); + assert_eq!( + hosted_profile_project_change(&config, "cloud", "proj_old"), + None + ); + assert!(requires_project_switch_confirmation( + true, true, true, false + )); + assert!(!requires_project_switch_confirmation( + true, true, true, true + )); + assert!(!requires_project_switch_confirmation( + true, false, true, false + )); + } + + #[test] + fn activation_failure_reports_that_the_credential_is_already_saved() { + let error = hosted_profile_activation_error( + anyhow::anyhow!("permission denied"), + "cloud", + "proj_a", + "hosted-cloud-proj_a", + ); + let message = error.to_string(); + assert!(message.contains("credential is already saved")); + assert!(message.contains("hosted-cloud-proj_a")); + assert!(message.contains("am init --project proj_a")); + assert!(!message.contains("secret")); + } + + #[test] + fn hosted_credentials_use_a_project_specific_reference() { + assert_eq!(hosted_cloud_credential_ref("proj_a"), "hosted-cloud-proj_a"); + assert_ne!( + hosted_cloud_credential_ref("proj_a"), + hosted_cloud_credential_ref("proj_b") + ); + } + + #[tokio::test] + async fn valid_bound_hosted_key_is_reused_without_creation() { + let backend = credential_backend(Vec::new()); + let probe = credential_probe(vec![Ok(())]); + let stored = stored_key("https://api.atomicstrata.ai", "proj_a"); + + let outcome = provision_hosted_cloud_key( + &backend, + &probe, + "https://api.atomicstrata.ai", + "proj_a", + Some(&stored), + TEST_KEY_NAME, + ) + .await + .expect("reuse bound key"); + + assert!(matches!(outcome, HostedCredentialOutcome::Reused)); + assert!( + backend + .create_projects + .lock() + .expect("create projects lock") + .is_empty() + ); + } + + #[tokio::test] + async fn wrong_binding_skips_probe_and_creates_project_key() { + let backend = + credential_backend(vec![Ok(created_key("proj_b", "key_b", "amc_new_secret"))]); + let probe = credential_probe(vec![Ok(())]); + let stored = stored_key("https://api.atomicstrata.ai", "proj_a"); + + let outcome = provision_hosted_cloud_key( + &backend, + &probe, + "https://api.atomicstrata.ai", + "proj_b", + Some(&stored), + TEST_KEY_NAME, + ) + .await + .expect("create project-bound key"); + + assert!(matches!(outcome, HostedCredentialOutcome::Created { .. })); + assert_eq!(*probe.calls.lock().expect("probe calls lock"), 1); + assert_eq!( + backend + .create_projects + .lock() + .expect("create projects lock") + .as_slice(), + ["proj_b"] + ); + assert_eq!( + backend + .create_names + .lock() + .expect("create names lock") + .as_slice(), + [TEST_KEY_NAME] + ); + } + + #[tokio::test] + async fn foreign_origin_skips_probe_and_creates_origin_bound_key() { + let backend = + credential_backend(vec![Ok(created_key("proj_a", "key_a", "amc_new_secret"))]); + let probe = credential_probe(vec![Ok(())]); + let stored = stored_key("https://api.other.example", "proj_a"); + + let outcome = provision_hosted_cloud_key( + &backend, + &probe, + "https://api.atomicstrata.ai", + "proj_a", + Some(&stored), + TEST_KEY_NAME, + ) + .await + .expect("replace foreign-origin key"); + + assert!(matches!(outcome, HostedCredentialOutcome::Created { .. })); + assert_eq!(*probe.calls.lock().expect("probe calls lock"), 1); + } + + #[tokio::test] + async fn auth_rejection_creates_only_without_a_singleton_and_transport_failure_stops() { + let stored = stored_key("https://api.atomicstrata.ai", "proj_a"); + let auth_backend = + credential_backend(vec![Ok(created_key("proj_a", "key_new", "amc_new_secret"))]); + let auth_probe = credential_probe(vec![Err(CloudClientError::Auth), Ok(())]); + let outcome = provision_hosted_cloud_key( + &auth_backend, + &auth_probe, + "https://api.atomicstrata.ai", + "proj_a", + Some(&stored), + TEST_KEY_NAME, + ) + .await + .expect("replace rejected key"); + assert!(matches!(outcome, HostedCredentialOutcome::Created { .. })); + assert_eq!( + auth_backend + .list_projects + .lock() + .expect("list projects lock") + .as_slice(), + ["proj_a"] + ); + + let network_backend = credential_backend(Vec::new()); + let network_probe = + credential_probe(vec![Err(CloudClientError::Network("offline".into()))]); + let error = provision_hosted_cloud_key( + &network_backend, + &network_probe, + "https://api.atomicstrata.ai", + "proj_a", + Some(&stored), + TEST_KEY_NAME, + ) + .await + .err() + .expect("transport probe must fail"); + assert!(error.to_string().contains("offline")); + assert!( + network_backend + .create_projects + .lock() + .expect("create projects lock") + .is_empty() + ); + } + + #[tokio::test] + async fn rejected_stored_key_rotates_the_existing_hosted_singleton() { + let backend = credential_backend(Vec::new()); + backend + .lists + .lock() + .expect("list responses lock") + .push_back(Ok(vec![ + created_key("proj_a", "key_existing", "unused").key, + ])); + backend + .rotates + .lock() + .expect("rotate responses lock") + .push_back(Ok(created_key( + "proj_a", + "key_existing", + "amc_rotated_secret", + ))); + let stored = stored_key("https://api.atomicstrata.ai", "proj_a"); + let probe = credential_probe(vec![Err(CloudClientError::Auth), Ok(())]); + + let outcome = provision_hosted_cloud_key( + &backend, + &probe, + "https://api.atomicstrata.ai", + "proj_a", + Some(&stored), + TEST_KEY_NAME, + ) + .await + .expect("rotate rejected singleton key"); + + assert!(matches!(outcome, HostedCredentialOutcome::Rotated { .. })); + assert_eq!( + backend + .rotate_calls + .lock() + .expect("rotate calls lock") + .as_slice(), + [("proj_a".into(), "key_existing".into())] + ); + assert!( + backend + .create_projects + .lock() + .expect("create projects lock") + .is_empty() + ); + } + + #[tokio::test] + async fn a_foreign_machines_key_is_never_rotated() { + // The regression: rotation invalidates the old secret, so rotating a key + // this machine does not own silently breaks whichever machine is using + // it. A fresh install must mint its own key instead. + let backend = credential_backend(vec![Ok(created_key( + "proj_a", + "key_mine", + "amc_new_secret", + ))]); + backend + .lists + .lock() + .expect("list responses lock") + .push_back(Ok(vec![ + // Another machine's key, and the legacy project-wide name. + named_key("proj_a", "key_other", "am-cli-othermachine"), + named_key("proj_a", "key_legacy", "am-cli"), + ])); + let probe = credential_probe(vec![Ok(())]); + + let outcome = provision_hosted_cloud_key( + &backend, + &probe, + "https://api.atomicstrata.ai", + "proj_a", + None, + TEST_KEY_NAME, + ) + .await + .expect("fresh install provisions a key"); + + assert!(matches!(outcome, HostedCredentialOutcome::Created { .. })); + // Nothing belonging to another machine was touched. + assert!( + backend + .rotate_calls + .lock() + .expect("rotate calls lock") + .is_empty() + ); + // And the new key carries this machine's scoped name. + assert_eq!( + backend + .create_names + .lock() + .expect("create names lock") + .as_slice(), + [TEST_KEY_NAME] + ); + } + + #[tokio::test] + async fn missing_stored_key_rotates_only_this_machines_key() { + let backend = credential_backend(Vec::new()); + backend + .lists + .lock() + .expect("list responses lock") + .push_back(Ok(vec![ + created_key("proj_a", "key_existing", "unused").key, + ])); + backend + .rotates + .lock() + .expect("rotate responses lock") + .push_back(Ok(created_key( + "proj_a", + "key_existing", + "amc_rotated_secret", + ))); + let probe = credential_probe(vec![Ok(())]); + + let outcome = provision_hosted_cloud_key( + &backend, + &probe, + "https://api.atomicstrata.ai", + "proj_a", + None, + TEST_KEY_NAME, + ) + .await + .expect("rotate project singleton"); + + assert!(matches!(outcome, HostedCredentialOutcome::Rotated { .. })); + assert!( + backend + .create_projects + .lock() + .expect("create projects lock") + .is_empty() + ); + } + + #[tokio::test] + async fn api_key_listing_failure_stops_before_rotation_or_creation() { + let backend = credential_backend(Vec::new()); + backend + .lists + .lock() + .expect("list responses lock") + .push_back(Err(CloudClientError::Network("offline".into()))); + let probe = credential_probe(Vec::new()); + + let error = provision_hosted_cloud_key( + &backend, + &probe, + "https://api.atomicstrata.ai", + "proj_a", + None, + TEST_KEY_NAME, + ) + .await + .err() + .expect("listing failure must fail closed"); + + assert!(error.to_string().contains("list Hosted Cloud API keys")); + assert!( + backend + .rotate_calls + .lock() + .expect("rotate calls lock") + .is_empty() + ); + assert!( + backend + .create_projects + .lock() + .expect("create projects lock") + .is_empty() + ); + } + + #[tokio::test] + async fn failed_rotated_key_validation_never_revokes_or_creates() { + let backend = credential_backend(Vec::new()); + backend + .lists + .lock() + .expect("list responses lock") + .push_back(Ok(vec![ + created_key("proj_a", "key_existing", "unused").key, + ])); + backend + .rotates + .lock() + .expect("rotate responses lock") + .push_back(Ok(created_key( + "proj_a", + "key_existing", + "amc_do_not_print_me", + ))); + let probe = credential_probe(vec![Err(CloudClientError::Auth)]); + + let error = provision_hosted_cloud_key( + &backend, + &probe, + "https://api.atomicstrata.ai", + "proj_a", + None, + TEST_KEY_NAME, + ) + .await + .err() + .expect("invalid rotated key must fail"); + + assert!(!error.to_string().contains("amc_do_not_print_me")); + assert!(error.to_string().contains("no key was revoked")); + assert!( + backend + .revokes + .lock() + .expect("revoke calls lock") + .is_empty() + ); + assert!( + backend + .create_projects + .lock() + .expect("create projects lock") + .is_empty() + ); + } + + #[tokio::test] + async fn failed_persistence_revokes_the_new_key_and_redacts_the_secret() { + // The secret is unrecoverable after creation, so a failed local write + // must hand the key back rather than stranding it on the project. + let backend = credential_backend(vec![]); + let err = persist_hosted_key_or_rollback( + &backend, + "proj_a", + "key_new", + "amc_do_not_print_me", + || { + Err(anyhow::anyhow!( + "permission denied writing amc_do_not_print_me" + )) + }, + ) + .await + .expect_err("failed persistence must surface an error"); + + assert!(!err.to_string().contains("amc_do_not_print_me")); + assert!( + err.to_string() + .contains("revoked newly created key key_new") + ); + assert_eq!( + backend + .revokes + .lock() + .expect("revoke calls lock") + .as_slice(), + [("proj_a".into(), "key_new".into())] + ); + } + + #[tokio::test] + async fn successful_persistence_keeps_the_key() { + // The rollback must not fire on the happy path. + let backend = credential_backend(vec![]); + persist_hosted_key_or_rollback(&backend, "proj_a", "key_new", "amc_secret", || Ok(())) + .await + .expect("successful persistence must succeed"); + assert!( + backend + .revokes + .lock() + .expect("revoke calls lock") + .is_empty() + ); + } + + #[test] + fn failed_rotated_key_persistence_is_redacted_and_never_requests_revocation() { + let error = + persist_rotated_hosted_key("proj_a", "key_existing", "amc_do_not_print_me", || { + Err(anyhow::anyhow!( + "permission denied writing amc_do_not_print_me" + )) + }) + .expect_err("failed rotated-key persistence must surface an error"); + + assert!(!error.to_string().contains("amc_do_not_print_me")); + assert!(error.to_string().contains("key_existing")); + assert!(error.to_string().contains("am init --project proj_a")); + assert!(!error.to_string().contains("revoke")); + } + + #[tokio::test] + async fn failed_persistence_reports_when_cleanup_also_fails() { + // A key we could neither save nor revoke must say so; it needs manual + // cleanup in the dashboard. + let backend = credential_backend(vec![]); + *backend.revoke_error.lock().expect("revoke error lock") = Some(CloudClientError::Auth); + let err = + persist_hosted_key_or_rollback(&backend, "proj_a", "key_new", "amc_secret", || { + Err(anyhow::anyhow!("disk full")) + }) + .await + .expect_err("failed persistence must surface an error"); + assert!( + err.to_string() + .contains("cleanup of newly created key key_new also failed") + ); + assert!(err.to_string().contains("revoke it in the dashboard")); + } + + #[test] + fn redact_secret_removes_every_occurrence() { + let text = redact_secret("wrote sk_live_1 then sk_live_1 again", "sk_live_1"); + assert!(!text.contains("sk_live_1")); + assert_eq!(text, "wrote «redacted» then «redacted» again"); + // An empty secret must not turn the message into confetti. + assert_eq!(redact_secret("unchanged", ""), "unchanged"); + } + + #[test] + fn rate_limited_polling_is_transient() { + // A 2s poll over 10 minutes is ~300 requests; 429 is expected, not fatal. + assert!(is_transient_poll_error(&CloudClientError::Status { + code: 429, + body: "too many requests".into(), + })); + assert!(is_transient_poll_error(&CloudClientError::Status { + code: 503, + body: "unavailable".into(), + })); + // Client errors that will never resolve on retry must still abort. + assert!(!is_transient_poll_error(&CloudClientError::Status { + code: 403, + body: "forbidden".into(), + })); + } + + #[tokio::test] + async fn failed_new_key_validation_revokes_only_that_key_and_redacts_secret() { + let backend = credential_backend(vec![Ok(created_key( + "proj_a", + "key_new", + "amc_do_not_print_me", + ))]); + let probe = credential_probe(vec![Err(CloudClientError::Auth)]); + + let error = provision_hosted_cloud_key( + &backend, + &probe, + "https://api.atomicstrata.ai", + "proj_a", + None, + TEST_KEY_NAME, + ) + .await + .err() + .expect("invalid new key must fail"); + + assert!(!error.to_string().contains("amc_do_not_print_me")); + assert_eq!( + backend + .revokes + .lock() + .expect("revoke calls lock") + .as_slice(), + [("proj_a".into(), "key_new".into())] + ); + } + + #[tokio::test] + async fn failed_new_key_validation_reports_rollback_failure() { + let backend = credential_backend(vec![Ok(created_key( + "proj_a", + "key_new", + "amc_do_not_print_me", + ))]); + *backend.revoke_error.lock().expect("revoke error lock") = + Some(CloudClientError::Network("cleanup offline".into())); + let probe = credential_probe(vec![Err(CloudClientError::Auth)]); + + let error = provision_hosted_cloud_key( + &backend, + &probe, + "https://api.atomicstrata.ai", + "proj_a", + None, + TEST_KEY_NAME, + ) + .await + .err() + .expect("rollback failure must be reported"); + + assert!(error.to_string().contains("cleanup offline")); + assert!(!error.to_string().contains("amc_do_not_print_me")); + } + + #[tokio::test] + async fn quota_never_revokes_and_a_later_rerun_can_succeed() { + let backend = credential_backend(vec![ + Err(CloudClientError::Status { + code: 429, + body: "quota_exceeded: max_api_keys".into(), + }), + Ok(created_key("proj_a", "key_new", "amc_new_secret")), + ]); + let probe = credential_probe(vec![Ok(())]); + + let first = provision_hosted_cloud_key( + &backend, + &probe, + "https://api.atomicstrata.ai", + "proj_a", + None, + TEST_KEY_NAME, + ) + .await + .err() + .expect("quota must fail"); + assert!(matches!(first, HostedCredentialError::Quota(_))); + assert!( + backend + .revokes + .lock() + .expect("revoke calls lock") + .is_empty() + ); + + let second = provision_hosted_cloud_key( + &backend, + &probe, + "https://api.atomicstrata.ai", + "proj_a", + None, + TEST_KEY_NAME, + ) + .await + .expect("rerun after capacity"); + assert!(matches!(second, HostedCredentialOutcome::Created { .. })); + } + + #[test] + fn quota_recovery_names_dashboard_and_exact_cli_commands() { + let message = + quota_recovery_message("proj_a", "https://app.atomicmemory.test/projects/proj_a"); + assert!(message.contains("https://app.atomicmemory.test/projects/proj_a")); + assert!(message.contains("am key list --project proj_a")); + assert!(message.contains("am key revoke --project proj_a ")); + assert!(message.contains("am init --project proj_a")); + } +} diff --git a/crates/cli/src/commands/instance.rs b/crates/cli/src/commands/instance.rs index 8c7d3ad..a53b178 100644 --- a/crates/cli/src/commands/instance.rs +++ b/crates/cli/src/commands/instance.rs @@ -15,8 +15,9 @@ use crate::commands::cloud_api_key::{ProvisionOutcome, ensure_connected_local_cl use crate::commands::connect::next_step_after_instance_start; use crate::commands::local_clients::{render_local_clients_card, resolve_local_clients}; use crate::config::{ - ENV_CORE_IMAGE, ProfileKind, jwks_url, load_config, require_project_id, resolve_core_api_key, - resolve_openai_api_key, store_openai_api_key, + ENV_CORE_IMAGE, OpenAiKeySource, ProfileKind, clear_openai_api_key, jwks_url, load_config, + require_project_id, resolve_core_api_key, resolve_openai_api_key, + resolve_openai_api_key_with_source, store_openai_api_key, }; use crate::environment::{CoreImageInput, resolve_core_image}; use crate::instance::docker::{ @@ -106,6 +107,8 @@ pub async fn run(cmd: InstanceCommand, global: &GlobalOptions) -> Result<()> { show_secrets, brief_output: false, progress: Some(progress.as_mut()), + brief_progress_id: None, + allow_prompts: global.allow_prompts(false), }, ) .await @@ -130,12 +133,16 @@ pub async fn run(cmd: InstanceCommand, global: &GlobalOptions) -> Result<()> { } /// Start Core during `am init` — progress on stderr, no JSON status blob. -pub(crate) async fn run_start_brief( +pub(crate) async fn run_start_brief<'a>( global: &GlobalOptions, cmd: InstanceCommand, // INTERNAL recreate requirement, passed separately so it can never be // mistaken for the operator's `--replace` authority downstream. sync_managed: bool, + progress: Option<&'a mut dyn ProgressReporter>, + brief_progress_id: Option<&'a str>, + // When false, OpenAI key stdin prompts are skipped (`am init --yes`). + allow_prompts: bool, ) -> Result { let InstanceCommand::Start { image, @@ -159,7 +166,9 @@ pub(crate) async fn run_start_brief( wait_secs, show_secrets, brief_output: true, - progress: None, + progress, + brief_progress_id, + allow_prompts, }, ) .await @@ -176,18 +185,16 @@ async fn ensure_local_profile(global: &GlobalOptions) -> Result Result { +fn read_openai_api_key_from_prompt(reason: &str) -> Result { eprintln!("{reason}"); eprint!("Paste OPENAI_API_KEY (input hidden): "); io::stderr().flush().ok(); let key = rpassword::read_password().context("read OPENAI_API_KEY")?; if key.trim().is_empty() { bail!( - "OPENAI_API_KEY is required — pass --openai-api-key, export OPENAI_API_KEY, or enter it at the prompt" + "OPENAI_API_KEY is required — enter a non-empty key at the prompt or set OPENAI_API_KEY in the environment" ); } - store_openai_api_key(profile_name, key.trim())?; - message(true, "OpenAI API key saved for this profile (not printed)."); Ok(key.trim().to_string()) } @@ -199,45 +206,66 @@ async fn ensure_openai_api_key( let can_prompt = interactive && io::stdin().is_terminal(); let mut candidate = flag_override .filter(|s| !s.is_empty()) - .or_else(|| resolve_openai_api_key(profile_name)); + .map(|key| (key, OpenAiKeySource::Flag)) + .or_else(|| resolve_openai_api_key_with_source(profile_name)); if candidate.is_none() { if !can_prompt { bail!( - "OPENAI_API_KEY is required to start Core — export it, pass --openai-api-key, or run interactively to save it" + "OPENAI_API_KEY is required to start Core — set OPENAI_API_KEY in the environment or run interactively" ); } - candidate = Some(prompt_openai_api_key( - profile_name, - "OpenAI API key required to start Core (stored in credentials.toml, mode 0600).", - )?); + candidate = Some(( + read_openai_api_key_from_prompt( + "OpenAI API key required to start Core (stored in credentials.toml, mode 0600).", + )?, + OpenAiKeySource::Prompted, + )); } // Allow a couple of fresh pastes after 401/403/format failures on TTY. const MAX_REPROMPTS: u8 = 2; let mut reprompts = 0u8; loop { - let key = candidate.clone().expect("openai key candidate must be set"); + let (key, source) = candidate.clone().expect("openai key candidate must be set"); + message(can_prompt, "Validating OpenAI API key with api.openai.com…"); match validate_openai_api_key(&key).await { - Ok(()) => return Ok(key), + Ok(()) => { + // A flag or environment value is an override for this run only. + // Persisting it wrote a CI secret to disk without asking. + if source.should_persist() { + store_openai_api_key(profile_name, &key)?; + message( + can_prompt, + "OpenAI API key saved for this profile (not printed).", + ); + } + return Ok(key); + } Err(err) if can_prompt && is_repromptable_openai_key_error(&err) && reprompts < MAX_REPROMPTS => { reprompts += 1; + // Clear only the credential that was actually rejected. The old + // guard asked whether *any* key resolved, so an exported + // OPENAI_API_KEY made a bad flag delete the stored key. + if source.should_clear_stored() { + clear_openai_api_key(profile_name)?; + } let head = err .to_string() .lines() .next() .unwrap_or("OpenAI rejected the key") .to_string(); - candidate = Some(prompt_openai_api_key( - profile_name, - &format!( + candidate = Some(( + read_openai_api_key_from_prompt(&format!( "{head}\nEnter a fresh key to continue (stored in credentials.toml, mode 0600)." - ), - )?); + ))?, + OpenAiKeySource::Prompted, + )); } Err(err) => return Err(err), } @@ -249,6 +277,10 @@ fn may_prompt_openai_key(interactive: bool) -> bool { interactive && io::stdin().is_terminal() } +fn may_prompt_for_input(allow_prompts: bool) -> bool { + allow_prompts && io::stdin().is_terminal() +} + fn needs_interactive_openai_key( profile_name: &str, flag_override: &Option, @@ -282,19 +314,29 @@ struct ReplacementPlan { } impl ReplacementPlan { - fn resolve(operator_replace: bool, needs_credential_sync: bool) -> Self { + fn resolve( + operator_replace: bool, + needs_credential_sync: bool, + confirmed_managed_recreate: bool, + ) -> Self { Self { - recreate_managed: operator_replace || needs_credential_sync, + recreate_managed: operator_replace + || needs_credential_sync + || confirmed_managed_recreate, may_replace_foreign: operator_replace, } } } -fn confirm_replace_foreign_container(container_name: &str, replace_flag: bool) -> Result { +fn confirm_replace_foreign_container( + container_name: &str, + replace_flag: bool, + allow_prompts: bool, +) -> Result { if replace_flag { return Ok(true); } - if !io::stdin().is_terminal() { + if !allow_prompts || !io::stdin().is_terminal() { bail!( "container '{container_name}' exists but was not created by `am instance` (likely a manual docker run).\n\ Remove it: docker rm -f {container_name}\n\ @@ -312,6 +354,29 @@ fn confirm_replace_foreign_container(container_name: &str, replace_flag: bool) - Ok(matches!(line.trim().to_lowercase().as_str(), "y" | "yes")) } +/// Interactive consent to recreate a CLI-managed Core that no longer matches the active profile. +fn confirm_recreate_mismatched_managed_container( + container_name: &str, + profile_name: &str, + allow_prompts: bool, +) -> Result { + if !allow_prompts || !io::stdin().is_terminal() { + bail!( + "Core container profile or Cloud env does not match active CLI profile '{profile_name}' — run `am instance start --replace`" + ); + } + eprint!( + "Core container '{container_name}' does not match profile '{profile_name}' (profile/Cloud env).\n\ + Recreate it for this profile? [y/N] (--replace): " + ); + io::stderr().flush().ok(); + let mut line = String::new(); + io::stdin() + .read_line(&mut line) + .context("read recreate confirmation")?; + Ok(matches!(line.trim().to_lowercase().as_str(), "y" | "yes")) +} + async fn ensure_cloud_api_key( global: &GlobalOptions, profile: &crate::config::ResolvedProfile, @@ -397,18 +462,58 @@ async fn core_health_probe( Ok(()) } -#[allow(unused_assignments)] -async fn wait_for_core_health( - global: &GlobalOptions, - docker: &dyn DockerRunner, - container_name: &str, - // Cloud base URL for the auth-chain DIAGNOSTIC only. Never probed and - // never sent a credential; the probe URL is derived below. - cloud_base_url_for_diag: &str, +struct CoreHealthWaitContext<'a> { + global: &'a GlobalOptions, + docker: &'a dyn DockerRunner, + container_name: &'a str, + /// Cloud base URL for the auth-chain DIAGNOSTIC only. Never probed and + /// never sent a credential; the probe URL is derived below. + cloud_base_url_for_diag: &'a str, timeout: Duration, emit_plain_ticks: bool, - bootstrap_core_key: Option<&str>, + bootstrap_core_key: Option<&'a str>, + brief_parent: Option<&'a str>, +} + +/// Keep the failure from the deepest startup stage ever reached, and within a +/// stage the most recent message. +/// +/// Stages: 0 = port closed, 1 = HTTP reachable but unhealthy, 2 = authenticated +/// health failed. A container that is still booting reports stage 0 on the +/// first poll every single time, so preferring the *earliest* failure pinned +/// every timeout to "port not accepting connections yet" and threw away the +/// stage-2 auth diagnosis that says what is actually wrong. Preferring the +/// latest failure instead was the original bug: the unconditional stage-2 probe +/// overwrote a genuine stage-0 result. Deepest-stage-wins is correct for both: +/// it reports how far startup got before it stopped. +fn record_deepest_health_failure( + deepest: &mut Option<(u8, String)>, + priority: u8, + message: String, +) { + let replace = deepest + .as_ref() + .map(|(existing, _)| priority >= *existing) + .unwrap_or(true); + if replace { + *deepest = Some((priority, message)); + } +} + +async fn wait_for_core_health( + ctx: CoreHealthWaitContext<'_>, + progress: &mut Option<&mut dyn ProgressReporter>, ) -> Result<()> { + let CoreHealthWaitContext { + global, + docker, + container_name, + cloud_base_url_for_diag, + timeout, + emit_plain_ticks, + bootstrap_core_key, + brief_parent, + } = ctx; // Probe what we PUBLISHED, never what the profile claims. This function // sends the bootstrap Core key as a bearer to whatever URL it probes, and // `profile.memory_base_url` derives from the Cloud API's @@ -424,7 +529,7 @@ async fn wait_for_core_health( let deadline = tokio::time::Instant::now() + timeout; let mut last_progress = tokio::time::Instant::now() - Duration::from_secs(10); - let mut last_diag = "starting Core container".to_string(); + let mut deepest_failure: Option<(u8, String)> = None; loop { if let Some(inspect) = docker.inspect(container_name).await? @@ -442,11 +547,14 @@ async fn wait_for_core_health( ); } - if tokio::net::TcpStream::connect((host, host_port)) + let port_open = tokio::net::TcpStream::connect((host, host_port)) .await - .is_err() - { - last_diag = format!("port {host}:{host_port} not accepting connections yet"); + .is_ok(); + + let mut current_diag = if !port_open { + let message = format!("port {host}:{host_port} not accepting connections yet"); + record_deepest_health_failure(&mut deepest_failure, 0, message.clone()); + message } else if let Ok(resp) = reqwest::Client::new() .get( local_url @@ -457,38 +565,48 @@ async fn wait_for_core_health( .send() .await { - if resp.status() == reqwest::StatusCode::UNAUTHORIZED { - last_diag = - "Core HTTP is up (401 on unauthenticated health) — verifying auth chain" - .to_string(); - } else if resp.status().is_success() { + if resp.status().is_success() { return Ok(()); + } + if resp.status() == reqwest::StatusCode::UNAUTHORIZED { + "Core HTTP is up (401 on unauthenticated health) — verifying auth chain".to_string() } else { - last_diag = format!("Core returned HTTP {}", resp.status()); + let message = format!("Core returned HTTP {}", resp.status()); + record_deepest_health_failure(&mut deepest_failure, 1, message.clone()); + message } } else { - last_diag = "Core port open but HTTP health probe failed".to_string(); - } + let message = "Core port open but HTTP health probe failed".to_string(); + record_deepest_health_failure(&mut deepest_failure, 1, message.clone()); + message + }; - match core_health_probe( - global, - docker, - container_name, - &local_url, - bootstrap_core_key, - ) - .await - { - Ok(()) => return Ok(()), - Err(err) => { - last_diag = format_auth_chain_diag( - cloud_base_url_for_diag, - &format!("authenticated health failed: {err}"), - ); + if port_open { + match core_health_probe( + global, + docker, + container_name, + &local_url, + bootstrap_core_key, + ) + .await + { + Ok(()) => return Ok(()), + Err(err) => { + let message = format_auth_chain_diag( + cloud_base_url_for_diag, + &format!("authenticated health failed: {err}"), + ); + current_diag = message.clone(); + record_deepest_health_failure(&mut deepest_failure, 2, message); + } } } if tokio::time::Instant::now() >= deadline { + let timeout_diag = deepest_failure + .map(|(_, message)| message) + .unwrap_or(current_diag); bail!( concat!( "Core health check timed out after {}s — last status: {}\n", @@ -500,23 +618,36 @@ async fn wait_for_core_health( " then: am --base-url instance start --replace" ), timeout.as_secs(), - last_diag + timeout_diag ); } - if emit_plain_ticks && last_progress.elapsed() >= Duration::from_secs(8) { + if last_progress.elapsed() >= Duration::from_secs(8) { let elapsed = timeout.as_secs().saturating_sub( deadline .saturating_duration_since(tokio::time::Instant::now()) .as_secs(), ); - message( - true, - &format!( - "Waiting for Core ({elapsed}s/{}) — {last_diag}", - timeout.as_secs() - ), - ); + let progress_diag = current_diag.as_str(); + if brief_parent.is_some() { + progress_tick( + progress, + brief_parent, + "health", + &format!( + "waiting for Core ({elapsed}s/{}) — {progress_diag}", + timeout.as_secs() + ), + ); + } else if emit_plain_ticks { + message( + true, + &format!( + "Waiting for Core ({elapsed}s/{}) — {progress_diag}", + timeout.as_secs() + ), + ); + } last_progress = tokio::time::Instant::now(); } @@ -538,6 +669,111 @@ struct StartOptions<'a> { show_secrets: bool, brief_output: bool, progress: Option<&'a mut dyn ProgressReporter>, + /// During init (`brief_output`), tick this parent step instead of nested steps. + brief_progress_id: Option<&'a str>, + // When false, OpenAI key stdin prompts are skipped (`am init --yes`). + allow_prompts: bool, +} + +/// Routes instance-start progress to nested steps or init parent ticks. +fn progress_step( + progress: &mut Option<&mut dyn ProgressReporter>, + brief_parent: Option<&str>, + id: &str, + label: &str, + brief_detail: &str, +) { + if let Some(p) = progress.as_deref_mut() { + if let Some(parent) = brief_parent { + p.tick(parent, brief_detail); + } else { + p.start_step(id, label); + } + } +} + +fn progress_tick( + progress: &mut Option<&mut dyn ProgressReporter>, + brief_parent: Option<&str>, + id: &str, + detail: &str, +) { + if let Some(p) = progress.as_deref_mut() { + let step = brief_parent.unwrap_or(id); + p.tick(step, detail); + } +} + +fn progress_succeed( + progress: &mut Option<&mut dyn ProgressReporter>, + brief_parent: Option<&str>, + id: &str, + detail: Option<&str>, +) { + if let Some(parent) = brief_parent { + if let Some(d) = detail.filter(|s| !s.is_empty()) + && let Some(p) = progress.as_deref_mut() + { + p.tick(parent, d); + } + } else if let Some(p) = progress.as_deref_mut() { + p.succeed(id, detail); + } +} + +fn progress_warn( + progress: &mut Option<&mut dyn ProgressReporter>, + brief_parent: Option<&str>, + id: &str, + detail: Option<&str>, +) { + if let Some(p) = progress.as_deref_mut() { + let target = brief_parent.unwrap_or(id); + p.warn(target, detail); + } +} + +fn progress_fail( + progress: &mut Option<&mut dyn ProgressReporter>, + brief_parent: Option<&str>, + id: &str, + detail: Option<&str>, +) { + if let Some(p) = progress.as_deref_mut() { + let target = brief_parent.unwrap_or(id); + p.fail(target, detail); + } +} + +fn progress_pause(progress: &mut Option<&mut dyn ProgressReporter>) { + if let Some(p) = progress.as_deref_mut() { + p.pause_for_input(); + } +} + +fn progress_resume(progress: &mut Option<&mut dyn ProgressReporter>) { + if let Some(p) = progress.as_deref_mut() { + p.resume_after_input(); + } +} + +fn with_progress_paused_for_input( + progress: &mut Option<&mut dyn ProgressReporter>, + should_pause: bool, + op: impl FnOnce() -> T, +) -> T { + if should_pause { + progress_pause(progress); + } + let result = op(); + if should_pause { + progress_resume(progress); + } + result +} + +fn progress_is_active(progress: &Option<&mut dyn ProgressReporter>) -> bool { + progress.is_some() } /// Whether an existing managed container must not be reused as-is. @@ -567,9 +803,13 @@ async fn run_start( docker: &dyn DockerRunner, mut opts: StartOptions<'_>, ) -> Result { - if let Some(p) = opts.progress.as_deref_mut() { - p.start_step("credentials", "Resolve instance credentials"); - } + progress_step( + &mut opts.progress, + opts.brief_progress_id, + "credentials", + "Resolve instance credentials", + "resolve credentials", + ); let profile = ensure_local_profile(global).await?; docker.version().await?; @@ -584,6 +824,7 @@ async fn run_start( let expected_jwks = jwks_url(&profile.base_url)?; let existing = docker.inspect(&config.container_name).await?; + let mut confirmed_managed_recreate = false; if let Some(inspect) = &existing && existing_container_blocks_start( inspect, @@ -593,26 +834,52 @@ async fn run_start( opts.replace, ) { - bail!( - "Core container profile or Cloud env does not match active CLI profile '{}' — run `am instance start --replace`", - profile.name + let may_prompt = may_prompt_for_input(opts.allow_prompts); + let confirmed = with_progress_paused_for_input(&mut opts.progress, may_prompt, || { + confirm_recreate_mismatched_managed_container( + &config.container_name, + &profile.name, + opts.allow_prompts, + ) + })?; + if !confirmed { + bail!( + "leaving Core container unchanged — recreate later with `am instance start --replace`" + ); + } + confirmed_managed_recreate = true; + progress_tick( + &mut opts.progress, + opts.brief_progress_id, + "credentials", + "recreate approved for profile mismatch", ); } - let may_prompt = may_prompt_openai_key(!global.quiet); let missing_key = - needs_interactive_openai_key(&profile.name, &opts.openai_api_key, !global.quiet); - if may_prompt && let Some(p) = opts.progress.as_deref_mut() { - p.pause_for_input(); - if missing_key { - p.tick("credentials", "OpenAI API key required below"); - } + needs_interactive_openai_key(&profile.name, &opts.openai_api_key, opts.allow_prompts); + let pause_for_openai = may_prompt_openai_key(opts.allow_prompts) && missing_key; + if pause_for_openai { + progress_pause(&mut opts.progress); + progress_tick( + &mut opts.progress, + opts.brief_progress_id, + "credentials", + "OpenAI API key required below", + ); } + progress_tick( + &mut opts.progress, + opts.brief_progress_id, + "credentials", + "validating OpenAI key", + ); let openai_key = - ensure_openai_api_key(&profile.name, opts.openai_api_key, !global.quiet).await?; - if may_prompt && let Some(p) = opts.progress.as_deref_mut() { - p.resume_after_input(); + ensure_openai_api_key(&profile.name, opts.openai_api_key, opts.allow_prompts).await; + if pause_for_openai { + progress_resume(&mut opts.progress); } + let openai_key = openai_key?; let shell_override = resolve_core_api_key(); ensure_core_key_override_allowed( @@ -623,6 +890,12 @@ async fn run_start( ) .await?; + progress_tick( + &mut opts.progress, + opts.brief_progress_id, + "credentials", + "Cloud API key", + ); let (api_key, cloud_key_outcome) = ensure_cloud_api_key(global, &profile).await?; let existing_for_drift = docker.inspect(&config.container_name).await?; @@ -640,25 +913,49 @@ async fn run_start( let plan = ReplacementPlan::resolve( opts.replace, opts.sync_managed || cloud_key_outcome.requires_container_sync() || credentials_drifted, + confirmed_managed_recreate, ); let needs_recreate = plan.recreate_managed; let core_api_key = resolve_instance_core_api_key(docker, false).await?; let env = build_instance_env(&profile, &api_key, &openai_key, core_api_key.clone())?; - if let Some(p) = opts.progress.as_deref_mut() { - p.succeed("credentials", Some("ready")); - p.start_step("container", "Create or start container"); - } + progress_succeed( + &mut opts.progress, + opts.brief_progress_id, + "credentials", + Some("ready"), + ); + progress_step( + &mut opts.progress, + opts.brief_progress_id, + "container", + "Create or start container", + "create or start container", + ); let existing = docker.inspect(&config.container_name).await?; match &existing { Some(inspect) if !inspect.managed_by_cli => { - if confirm_replace_foreign_container(&config.container_name, plan.may_replace_foreign)? - { + let may_prompt_foreign = + may_prompt_for_input(opts.allow_prompts) && !plan.may_replace_foreign; + let should_replace = + with_progress_paused_for_input(&mut opts.progress, may_prompt_foreign, || { + confirm_replace_foreign_container( + &config.container_name, + plan.may_replace_foreign, + opts.allow_prompts, + ) + })?; + if should_replace { docker.rm_force(&config.container_name).await?; docker.run(&config, &env).await?; - if let Some(p) = opts.progress.as_deref_mut() { - p.succeed("container", Some("replaced foreign container")); + if progress_is_active(&opts.progress) { + progress_succeed( + &mut opts.progress, + opts.brief_progress_id, + "container", + Some("replaced foreign container"), + ); } else { message( !global.quiet, @@ -666,8 +963,13 @@ async fn run_start( ); } } else { - if let Some(p) = opts.progress.as_deref_mut() { - p.warn("container", Some("left unchanged")); + if progress_is_active(&opts.progress) { + progress_warn( + &mut opts.progress, + opts.brief_progress_id, + "container", + Some("left unchanged"), + ); } else { message(!global.quiet, "Leaving existing container unchanged."); } @@ -676,8 +978,13 @@ async fn run_start( } Some(inspect) if inspect.state.is_running() && !needs_recreate => { if opts.brief_output { - if let Some(p) = opts.progress.as_deref_mut() { - p.succeed("container", Some("already running")); + if progress_is_active(&opts.progress) { + progress_succeed( + &mut opts.progress, + opts.brief_progress_id, + "container", + Some("already running"), + ); } else { message( !global.quiet, @@ -690,8 +997,13 @@ async fn run_start( instance_status_report(&profile, Some(inspect), docker, global, opts.show_secrets) .await?; emit_instance_report(global, &report, opts.show_secrets)?; - if let Some(p) = opts.progress.as_deref_mut() { - p.succeed("container", Some("already running")); + if progress_is_active(&opts.progress) { + progress_succeed( + &mut opts.progress, + opts.brief_progress_id, + "container", + Some("already running"), + ); } else { message(!global.quiet, "Instance already running."); } @@ -700,24 +1012,39 @@ async fn run_start( Some(_) if needs_recreate => { docker.rm_force(&config.container_name).await?; docker.run(&config, &env).await?; - if let Some(p) = opts.progress.as_deref_mut() { - p.succeed("container", Some("recreated")); + if progress_is_active(&opts.progress) { + progress_succeed( + &mut opts.progress, + opts.brief_progress_id, + "container", + Some("recreated"), + ); } else { message(!global.quiet, "Recreated managed container."); } } Some(_) => { docker.start(&config.container_name).await?; - if let Some(p) = opts.progress.as_deref_mut() { - p.succeed("container", Some("started existing")); + if progress_is_active(&opts.progress) { + progress_succeed( + &mut opts.progress, + opts.brief_progress_id, + "container", + Some("started existing"), + ); } else { message(!global.quiet, "Started existing managed container."); } } None => { docker.run(&config, &env).await?; - if let Some(p) = opts.progress.as_deref_mut() { - p.succeed("container", Some("started new")); + if progress_is_active(&opts.progress) { + progress_succeed( + &mut opts.progress, + opts.brief_progress_id, + "container", + Some("started new"), + ); } else { message(!global.quiet, "Started new managed container."); } @@ -725,33 +1052,50 @@ async fn run_start( } if opts.wait_secs > 0 { - let has_progress = opts.progress.is_some(); - if let Some(p) = opts.progress.as_deref_mut() { - p.start_step("health", "Wait until Core healthy"); - } + progress_step( + &mut opts.progress, + opts.brief_progress_id, + "health", + "Wait until Core healthy", + "wait until Core healthy", + ); // Wizard spinner keeps ticking; plain/brief emit periodic messages. - let emit_plain_ticks = !has_progress && !global.quiet; + let emit_plain_ticks = !progress_is_active(&opts.progress) && !global.quiet; let health = wait_for_core_health( - global, - docker, - &config.container_name, - &profile.base_url, - Duration::from_secs(opts.wait_secs), - emit_plain_ticks, - Some(core_api_key.as_str()), + CoreHealthWaitContext { + global, + docker, + container_name: &config.container_name, + cloud_base_url_for_diag: &profile.base_url, + timeout: Duration::from_secs(opts.wait_secs), + emit_plain_ticks, + bootstrap_core_key: Some(core_api_key.as_str()), + brief_parent: opts.brief_progress_id, + }, + &mut opts.progress, ) .await; match health { Ok(()) => { - if let Some(p) = opts.progress.as_deref_mut() { - p.succeed("health", Some("healthy")); + if progress_is_active(&opts.progress) { + progress_succeed( + &mut opts.progress, + opts.brief_progress_id, + "health", + Some("healthy"), + ); } else if !opts.brief_output { message(!global.quiet, "Core is healthy."); } } Err(err) => { - if let Some(p) = opts.progress.as_deref_mut() { - p.fail("health", Some(&err.to_string())); + if progress_is_active(&opts.progress) { + progress_fail( + &mut opts.progress, + opts.brief_progress_id, + "health", + Some(&err.to_string()), + ); } if let Ok(logs) = docker.logs_tail(&config.container_name, 20).await { message( @@ -823,14 +1167,19 @@ async fn run_restart( None => bail!("no managed instance '{name}' — run `am instance start`"), } if wait_secs > 0 { + let mut progress: Option<&mut dyn ProgressReporter> = None; wait_for_core_health( - global, - docker, - name, - &profile.base_url, - Duration::from_secs(wait_secs), - !global.quiet, - None, + CoreHealthWaitContext { + global, + docker, + container_name: name, + cloud_base_url_for_diag: &profile.base_url, + timeout: Duration::from_secs(wait_secs), + emit_plain_ticks: !global.quiet, + bootstrap_core_key: None, + brief_parent: None, + }, + &mut progress, ) .await?; message(!global.quiet, "Core is healthy."); @@ -1074,7 +1423,7 @@ mod tests { /// `--replace` ever supplied and no prompt shown. #[test] fn credential_sync_never_authorises_replacing_a_foreign_container() { - let plan = ReplacementPlan::resolve(false, true); + let plan = ReplacementPlan::resolve(false, true, false); assert!( plan.recreate_managed, @@ -1088,7 +1437,7 @@ mod tests { #[test] fn the_operator_flag_authorises_both() { - let plan = ReplacementPlan::resolve(true, false); + let plan = ReplacementPlan::resolve(true, false, false); assert!(plan.recreate_managed); assert!( plan.may_replace_foreign, @@ -1098,7 +1447,7 @@ mod tests { #[test] fn neither_without_a_reason() { - let plan = ReplacementPlan::resolve(false, false); + let plan = ReplacementPlan::resolve(false, false, false); assert!(!plan.recreate_managed); assert!(!plan.may_replace_foreign); } @@ -1236,12 +1585,89 @@ mod tests { assert!(validate_purge_confirmed(true).is_ok()); } + #[test] + fn foreign_container_confirm_fails_closed_without_allow_prompts() { + let err = confirm_replace_foreign_container("atomic-memory", false, false) + .unwrap_err() + .to_string(); + assert!(err.contains("not created by `am instance`")); + assert!(err.contains("am instance start --replace")); + } + + #[test] + fn may_prompt_for_input_requires_allow_prompts() { + assert!(!may_prompt_for_input(false)); + } + #[test] fn may_prompt_openai_key_requires_interactive_flag() { // Non-interactive (--yes / quiet) must stay fail-fast even on a TTY. assert!(!may_prompt_openai_key(false)); } + #[test] + fn openai_key_source_persists_only_prompted_keys() { + // The prompt is the only surface that tells the user the key is saved. + assert!(OpenAiKeySource::Prompted.should_persist()); + // A CI runner exporting OPENAI_API_KEY must not get it written to disk, + // and a one-off --openai-api-key must not outlive the invocation. + assert!(!OpenAiKeySource::Environment.should_persist()); + assert!(!OpenAiKeySource::Flag.should_persist()); + // Already on disk; rewriting it is a no-op at best. + assert!(!OpenAiKeySource::Stored.should_persist()); + } + + #[test] + fn openai_key_source_clears_only_a_rejected_stored_key() { + // Only the credential that was actually tried and rejected may be + // deleted. Rejecting a flag or environment override says nothing about + // the stored key, which was never even sent to OpenAI. + assert!(OpenAiKeySource::Stored.should_clear_stored()); + assert!(!OpenAiKeySource::Flag.should_clear_stored()); + assert!(!OpenAiKeySource::Environment.should_clear_stored()); + assert!(!OpenAiKeySource::Prompted.should_clear_stored()); + } + + #[test] + fn health_failure_keeps_deepest_stage_not_first_poll() { + // A booting container reports a closed port on the first poll every + // time. That must not outrank the auth-chain failure that follows. + let mut failure = None; + record_deepest_health_failure(&mut failure, 0, "port closed".to_string()); + record_deepest_health_failure(&mut failure, 0, "port closed".to_string()); + record_deepest_health_failure(&mut failure, 2, "auth chain failed".to_string()); + assert_eq!( + failure.as_ref().map(|(_, m)| m.as_str()), + Some("auth chain failed") + ); + + // Within one stage the freshest message wins. + record_deepest_health_failure(&mut failure, 2, "auth chain failed (jwks)".to_string()); + assert_eq!( + failure.as_ref().map(|(_, m)| m.as_str()), + Some("auth chain failed (jwks)") + ); + + // A shallower later probe must not undo real progress. + record_deepest_health_failure(&mut failure, 1, "HTTP 503".to_string()); + assert_eq!( + failure.as_ref().map(|(_, m)| m.as_str()), + Some("auth chain failed (jwks)") + ); + } + + #[test] + fn health_failure_reports_closed_port_when_core_never_starts() { + // The original bug: an unconditional later probe overwrote the only + // honest diagnosis. If the port never opens, that is the answer. + let mut failure = None; + record_deepest_health_failure(&mut failure, 0, "port closed".to_string()); + assert_eq!( + failure.as_ref().map(|(_, m)| m.as_str()), + Some("port closed") + ); + } + #[test] fn instance_remove_parser_accepts_purge_flags() { use crate::cli::Cli; @@ -1322,12 +1748,13 @@ mod tests { fn cloud_key_tier_mismatch_hint_mentions_key_create() { let msg = crate::commands::cloud_api_key::ProvisionOutcome::Rotated { key_id: "key_x".into(), + key_name: "connected-local-runtime-a1b2c3d4e5f6".into(), } .operator_message() .unwrap(); - assert!(msg.contains(crate::instance::AUTO_KEY_NAME)); + assert!(msg.contains("connected-local-runtime-a1b2c3d4e5f6")); assert!(msg.contains("Rotated")); - assert!(msg.contains("quota-safe")); + assert!(!msg.contains("quota-safe")); } fn managed_inspect(state: ContainerState, profile: &str) -> ContainerInspect { @@ -1349,7 +1776,8 @@ mod tests { fn cloud_key_rotated_outcome_forces_replace_sync() { assert!( ProvisionOutcome::Rotated { - key_id: "key_x".into() + key_id: "key_x".into(), + key_name: "connected-local-runtime-a1b2c3d4e5f6".into(), } .requires_container_sync() ); @@ -1426,4 +1854,156 @@ mod tests { false, )); } + + struct RecordingReporter { + steps: Vec, + ticks: Vec, + outcomes: Vec, + input_events: Vec<&'static str>, + } + + impl ProgressReporter for RecordingReporter { + fn start_step(&mut self, id: &str, label: &str) { + self.steps.push(format!("{id}:{label}")); + } + fn tick(&mut self, id: &str, detail: &str) { + self.ticks.push(format!("{id}:{detail}")); + } + fn pause_for_input(&mut self) { + self.input_events.push("pause"); + } + fn resume_after_input(&mut self) { + self.input_events.push("resume"); + } + fn succeed(&mut self, id: &str, detail: Option<&str>) { + self.outcomes + .push(format!("ok:{id}:{}", detail.unwrap_or(""))); + } + fn warn(&mut self, _id: &str, _detail: Option<&str>) {} + fn fail(&mut self, _id: &str, _detail: Option<&str>) {} + fn finish(&mut self) {} + } + + #[test] + fn brief_instance_progress_ticks_parent_step() { + let mut reporter = RecordingReporter { + steps: Vec::new(), + ticks: Vec::new(), + outcomes: Vec::new(), + input_events: Vec::new(), + }; + let mut progress: Option<&mut dyn ProgressReporter> = Some(&mut reporter); + progress_step( + &mut progress, + Some("runtime"), + "credentials", + "Resolve instance credentials", + "resolve credentials", + ); + progress_tick( + &mut progress, + Some("runtime"), + "credentials", + "validating OpenAI key", + ); + progress_succeed(&mut progress, Some("runtime"), "credentials", Some("ready")); + progress_step( + &mut progress, + Some("runtime"), + "container", + "Create or start container", + "create or start container", + ); + assert!( + reporter.steps.is_empty(), + "brief mode must not open nested steps" + ); + assert_eq!( + reporter.ticks, + vec![ + "runtime:resolve credentials".to_string(), + "runtime:validating OpenAI key".to_string(), + "runtime:ready".to_string(), + "runtime:create or start container".to_string(), + ] + ); + } + + #[test] + fn nested_instance_progress_uses_step_ids() { + let mut reporter = RecordingReporter { + steps: Vec::new(), + ticks: Vec::new(), + outcomes: Vec::new(), + input_events: Vec::new(), + }; + let mut progress: Option<&mut dyn ProgressReporter> = Some(&mut reporter); + progress_step( + &mut progress, + None, + "credentials", + "Resolve instance credentials", + "unused", + ); + progress_succeed(&mut progress, None, "credentials", Some("ready")); + assert_eq!(reporter.steps.len(), 1); + assert_eq!( + reporter.steps[0], + "credentials:Resolve instance credentials" + ); + assert_eq!(reporter.outcomes[0], "ok:credentials:ready"); + assert!(reporter.ticks.is_empty()); + } + + #[test] + fn managed_recreate_confirmation_does_not_authorise_foreign_replace() { + let plan = ReplacementPlan::resolve(false, false, true); + assert!( + plan.recreate_managed, + "confirmed managed mismatch must recreate our container", + ); + assert!( + !plan.may_replace_foreign, + "managed recreate consent must not grant foreign-container authority", + ); + } + + #[test] + fn mismatched_managed_recreate_fails_closed_when_noninteractive() { + let err = confirm_recreate_mismatched_managed_container("atomic-memory", "tesdt", false) + .unwrap_err() + .to_string(); + assert!(err.contains("does not match active CLI profile 'tesdt'")); + assert!(err.contains("am instance start --replace")); + } + + #[test] + fn progress_pause_resumes_even_when_paused_op_fails() { + let mut reporter = RecordingReporter { + steps: Vec::new(), + ticks: Vec::new(), + outcomes: Vec::new(), + input_events: Vec::new(), + }; + let mut progress: Option<&mut dyn ProgressReporter> = Some(&mut reporter); + let result: Result<(), &str> = + with_progress_paused_for_input(&mut progress, true, || Err("openai key rejected")); + assert!(result.is_err()); + assert_eq!(reporter.input_events, vec!["pause", "resume"]); + } + + #[test] + fn progress_pause_resumes_around_foreign_confirm_gate() { + let mut reporter = RecordingReporter { + steps: Vec::new(), + ticks: Vec::new(), + outcomes: Vec::new(), + input_events: Vec::new(), + }; + let mut progress: Option<&mut dyn ProgressReporter> = Some(&mut reporter); + let confirmed = + with_progress_paused_for_input(&mut progress, true, || Ok::(false)); + assert!(!confirmed.unwrap()); + assert_eq!(reporter.input_events, vec!["pause", "resume"]); + } } diff --git a/crates/cli/src/commands/memory/mod.rs b/crates/cli/src/commands/memory/mod.rs index 9b38b89..54060b6 100644 --- a/crates/cli/src/commands/memory/mod.rs +++ b/crates/cli/src/commands/memory/mod.rs @@ -205,17 +205,17 @@ async fn run_ingest( stdin, ) .await?; - let (_profile, client) = memory_client(global).await?; + let (profile, client) = memory_client(global).await?; let resp = if is_verbatim { client .ingest_quick(&req) .await - .map_err(|e| with_operation_recovery(e.into(), "Memory ingest"))? + .map_err(|e| with_operation_recovery(e.into(), "Memory ingest", profile.kind))? } else { client .ingest(&req) .await - .map_err(|e| with_operation_recovery(e.into(), "Memory ingest"))? + .map_err(|e| with_operation_recovery(e.into(), "Memory ingest", profile.kind))? }; if let Ok(profile) = resolve_profile( global.profile.as_deref(), diff --git a/crates/cli/src/commands/migrate.rs b/crates/cli/src/commands/migrate.rs index 9b37ff0..fcd0303 100644 --- a/crates/cli/src/commands/migrate.rs +++ b/crates/cli/src/commands/migrate.rs @@ -18,8 +18,13 @@ use chrono::Utc; use clap::Subcommand; use crate::cli::GlobalOptions; -use crate::commands::client::{dashboard_client, memory_client}; -use crate::config::{resolve_profile, store_project_id}; +use crate::commands::client::{ + dashboard_client, dashboard_client_for_export, emit_cloud_export_warning_if_needed, + memory_client_for_profile, +}; +use crate::config::{ + resolve_profile, resolve_profile_with_export_identity, store_local_export_project_id, +}; use crate::output::{emit, message}; use crate::validation::with_operation_recovery; @@ -88,7 +93,35 @@ async fn run_export( out: Option<&Path>, user_id: &str, ) -> Result<()> { - let (_profile, dashboard) = dashboard_client(global).await?; + // Resolve exactly once, before the first await, and build every client + // from that one profile. Each additional resolution reopens config.toml, + // and `am config profile use` can land in the gap — most easily across the + // dashboard round trip below. The old code resolved for the dashboard, ran + // a network lookup, then resolved again for memory, checking only kind and + // not identity. Two Local profiles passed that check, so project A was + // bound while profile B's memories were exported. + // One config read yields both the resolved profile and the raw identity + // for the store's in-lock check. A separate re-load for the capture left a + // filesystem-level gap where another process could swap the profile, so + // the resolution described A while the identity described B — and the + // locked store then accepted B and wrote A's project into it. Raw fields, + // not resolved ones: resolved base_url can carry a --base-url override and + // memory_base_url is derived. + let (client_profile, expected_profile) = resolve_profile_with_export_identity( + global.profile.as_deref(), + global.base_url.as_deref(), + global.environment, + )?; + // Same-generation stored kind; the warning helper needs it to classify. + emit_cloud_export_warning_if_needed(global, expected_profile.entry.kind, &client_profile); + if client_profile.kind != crate::config::ProfileKind::Local { + bail!( + "export requires an active Connected Local profile — active profile '{}' is Cloud", + client_profile.name + ); + } + + let dashboard = dashboard_client_for_export(&client_profile, &expected_profile).await?; let project = resolve_project(&dashboard, project_ref).await?; if project.kind != am_cloud_types::ProjectType::Local { bail!( @@ -97,21 +130,36 @@ async fn run_export( ); } - store_project_id( - &resolve_profile( - global.profile.as_deref(), - global.base_url.as_deref(), - global.environment, - )? - .name, - &project.id, + store_local_export_project_id(&client_profile.name, &expected_profile, &project.id)?; + + // Refresh after our own write, pinned to the resolved NAME rather than the + // ambient default. The write above sets the profile's project_id, and API + // key selection only returns a stored key when the key's persisted project + // binding matches the resolved one — so the pre-write snapshot reports no + // key at all for a profile that had no project_id yet (the state left by + // `am key create --project --save`, which binds the credential without + // touching the profile). Re-resolving by name keeps the cross-profile race + // closed while letting the command see the mutation it just made. + let pinned_profile = client_profile; + let client_profile = resolve_profile( + Some(&pinned_profile.name), + global.base_url.as_deref(), + global.environment, )?; - - let (_resolved, client) = memory_client(global).await?; - client - .health() - .await - .map_err(|e| with_operation_recovery(e.into(), "Core health check before export"))?; + if let Some(field) = export_profile_replaced(&pinned_profile, &client_profile) { + bail!( + "profile '{}' was replaced during export ({field} changed) — rerun `am migrate export`", + pinned_profile.name + ); + } + let client = memory_client_for_profile(&client_profile).await?; + client.health().await.map_err(|e| { + with_operation_recovery( + e.into(), + "Core health check before export", + client_profile.kind, + ) + })?; let page_size = IMPORT_CHUNK_SIZE as i64; let mut offset = 0i64; @@ -130,7 +178,9 @@ async fn run_export( session_id: None, }) .await - .map_err(|e| with_operation_recovery(e.into(), "Export list memories"))?; + .map_err(|e| { + with_operation_recovery(e.into(), "Export list memories", client_profile.kind) + })?; if page.memories.is_empty() { break; @@ -291,6 +341,36 @@ async fn run_import( ) } +/// Report which identity field changed when the named profile was replaced. +/// +/// Pinning the name is not enough on its own: `am config profile add` inserts +/// over an existing entry, so Local A can become a different Local B under the +/// same name while the kind check still passes, and export would read B's Core +/// for a project chosen through A's dashboard session. +/// +/// Deliberately excludes `project_id` and `api_key`. Export writes the project +/// binding itself, and that write is what makes a project-bound key resolvable, +/// so both legitimately differ between the snapshot and the refresh. Comparing +/// them would reject the very flow the refresh exists to support. +fn export_profile_replaced( + before: &crate::config::ResolvedProfile, + after: &crate::config::ResolvedProfile, +) -> Option<&'static str> { + if before.name != after.name { + return Some("name"); + } + if before.kind != after.kind { + return Some("kind"); + } + if before.base_url != after.base_url { + return Some("base_url"); + } + if before.memory_base_url != after.memory_base_url { + return Some("local_url"); + } + None +} + fn read_export_records(path: &Path) -> Result> { let file = File::open(path).with_context(|| format!("open export file {}", path.display()))?; let reader = BufReader::new(file); @@ -333,6 +413,189 @@ async fn resolve_project( #[cfg(test)] mod tests { use super::*; + use crate::config::{ + ConfigStore, DEFAULT_CLOUD_URL, ExpectedExportProfile, ProfileConfig, ProfileKind, + ResolvedProfile, default_config_for_test, store_local_export_project_id_in, + }; + + #[test] + fn export_detects_same_name_profile_replacement() { + // `am config profile add` inserts over an existing name, so pinning the + // name alone still let Local A become a different Local B mid-export. + let base = |name: &str, kind, base_url: &str, mem: &str| ResolvedProfile { + name: name.into(), + base_url: base_url.into(), + kind, + project_id: None, + memory_base_url: mem.into(), + api_key: None, + oauth: None, + }; + let before = base( + "local", + ProfileKind::Local, + "https://api.x/", + "http://127.0.0.1:17350", + ); + assert_eq!(export_profile_replaced(&before, &before), None); + + let swapped_core = base( + "local", + ProfileKind::Local, + "https://api.x/", + "http://127.0.0.1:9999", + ); + assert_eq!( + export_profile_replaced(&before, &swapped_core), + Some("local_url") + ); + let swapped_cloud = base( + "local", + ProfileKind::Local, + "https://evil.example/", + "http://127.0.0.1:17350", + ); + assert_eq!( + export_profile_replaced(&before, &swapped_cloud), + Some("base_url") + ); + let swapped_kind = base( + "local", + ProfileKind::Cloud, + "https://api.x/", + "http://127.0.0.1:17350", + ); + assert_eq!( + export_profile_replaced(&before, &swapped_kind), + Some("kind") + ); + } + + #[test] + fn export_replacement_check_ignores_what_the_export_itself_writes() { + // Regression guard. Export persists the project binding, and that write + // is what makes a project-bound key resolvable, so project_id and + // api_key differ between snapshot and refresh by design. An earlier + // attempt at pinning rejected exactly this and broke `am key create + // --project --save` followed by export. + let before = ResolvedProfile { + name: "local".into(), + base_url: "https://api.x/".into(), + kind: ProfileKind::Local, + project_id: None, + memory_base_url: "http://127.0.0.1:17350".into(), + api_key: None, + oauth: None, + }; + let after = ResolvedProfile { + project_id: Some("proj_a".into()), + api_key: Some("amc_resolved_after_write".into()), + ..before.clone() + }; + assert_eq!(export_profile_replaced(&before, &after), None); + } + + #[test] + fn export_resolves_the_active_profile_exactly_once() { + // Structural, not behavioural: a two-Local-profile test would need an + // injectable resolver, which `resolve_profile_and_warn` is not. What it + // does catch is a second resolution being reintroduced, which is the + // whole defect — the old code resolved for the dashboard, awaited a + // network lookup, then resolved again for memory and compared only + // kind, so two Local profiles passed and B's memories were exported + // under A's project. + // + // The downstream half needs no test: dashboard_client_for_profile and + // memory_client_for_profile take no GlobalOptions, so they have nothing + // to resolve from and the compiler enforces it. + let src = include_str!("migrate.rs"); + let body = src + .split("async fn run_export(") + .nth(1) + .expect("run_export present"); + let body = &body[..body.find("\nasync fn ").unwrap_or(body.len())]; + let ambient = body + .matches("resolve_profile_with_export_identity(") + .count() + + body.matches("resolve_profile_and_warn(").count() + + body.matches("resolve_ctx(").count() + + body.matches("dashboard_client(").count() + + body.matches("memory_client(").count(); + assert_eq!( + ambient, 1, + "run_export must consult the ambient default profile exactly once" + ); + // The resolution and the store's expected identity must come from ONE + // config read. A separate load_config for the capture reopens the + // cross-process window where the resolved profile describes A while + // the identity describes its same-name replacement B. + assert!( + !body.contains("load_config("), + "run_export must not re-read config.toml outside the combined resolver" + ); + // Refreshing after our own write is required — the project binding it + // persists is what makes the stored key resolvable. It must be pinned + // to the already-resolved name, never re-read from the default. + let refresh = body + .split("resolve_profile(") + .nth(1) + .expect("post-write refresh present"); + let args = &refresh[..refresh.find("?;").unwrap_or(refresh.len().min(200))]; + assert!( + args.contains("Some(&pinned_profile.name)"), + "post-write refresh must be pinned to the snapshot's name, not the default" + ); + // And that resolution must precede the dashboard round trip. + let resolve_at = body + .find("resolve_profile_with_export_identity(") + .expect("resolution present"); + let dashboard_at = body + .find("dashboard_client_for_export(") + .expect("dashboard build present"); + assert!( + resolve_at < dashboard_at, + "resolution must happen before the first await" + ); + } + + #[test] + fn export_rejects_cloud_profile_without_mutating_project_id() { + let dir = tempfile::tempdir().unwrap(); + let store = ConfigStore::at(dir.path().join("config.toml")); + let mut cfg = default_config_for_test(); + cfg.default_profile = Some("cloud".into()); + cfg.profiles.insert( + "cloud".into(), + ProfileConfig { + base_url: Some(DEFAULT_CLOUD_URL.into()), + kind: ProfileKind::Cloud, + project_id: Some("proj_before".into()), + hosted_cloud_managed: Some(true), + ..Default::default() + }, + ); + store + .update(|config| { + *config = cfg; + Ok(()) + }) + .unwrap(); + + let err = store_local_export_project_id_in( + &store, + "cloud", + &ExpectedExportProfile::capture(&store.load().unwrap(), "cloud").unwrap(), + "proj_after", + ) + .unwrap_err(); + assert!(err.to_string().contains("Connected Local")); + assert_eq!( + store.load().unwrap().profiles["cloud"] + .project_id + .as_deref(), + Some("proj_before") + ); + } /// The Cloud API's per-request record cap. Named separately from /// IMPORT_CHUNK_SIZE on purpose: if someone raises the chunk size, this diff --git a/crates/cli/src/config.rs b/crates/cli/src/config.rs index ba03be0..d4f6d35 100644 --- a/crates/cli/src/config.rs +++ b/crates/cli/src/config.rs @@ -5,16 +5,19 @@ use std::fs::{self, OpenOptions}; use std::io::Write as _; use std::path::{Path, PathBuf}; -use anyhow::{Context, Result, anyhow}; +use anyhow::{Context, Result, anyhow, bail}; use fs4::fs_std::FileExt; use serde::{Deserialize, Deserializer, Serialize}; use crate::auth::origin::check_api_key_origin; -use crate::environment::{BaseUrlInput, Environment, resolve_base_url}; +use crate::environment::{BaseUrlInput, Environment, is_remote_cloud_api_url, resolve_base_url}; pub use crate::environment::ENV_CORE_IMAGE; pub const ENV_PROFILE: &str = "ATOMICMEMORY_PROFILE"; +pub const ENV_API_KEY: &str = "ATOMICMEMORY_API_KEY"; +/// When truthy, `ATOMICMEMORY_API_KEY` overrides a bound Hosted Cloud profile key. +pub const ENV_API_KEY_FORCE: &str = "ATOMICMEMORY_API_KEY_FORCE"; pub const ENV_CORE_API_KEY: &str = "ATOMICMEMORY_CORE_API_KEY"; pub const ENV_LEGACY_CORE_API_KEY: &str = "CORE_API_KEY"; pub const DEFAULT_PROFILE: &str = "cloud"; @@ -55,6 +58,14 @@ pub struct ConfigFile { /// Whether `first_real_memory_created` has been emitted for this install. #[serde(default, skip_serializing_if = "Option::is_none")] pub telemetry_first_real_memory_sent: Option, + /// Stable per-install id used to name the Cloud API keys this machine owns. + /// + /// Lives in `config.toml`, not `credentials.toml`, so clearing credentials + /// does not change which key this machine claims. Deliberately separate + /// from `telemetry_distinct_id`: key names are visible in the dashboard and + /// must not correlate an install with its telemetry identity. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cli_key_id: Option, /// Host MCP installs performed by `am integrate` (key = canonical config path). #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub integrations: BTreeMap, @@ -91,6 +102,11 @@ pub struct ProfileConfig { pub api_key_ref: Option, pub local_url: Option, pub oauth_ref: Option, + /// Init-managed Hosted Cloud marker. `Some(true)` is set by Hosted Cloud + /// init; `Some(false)` marks a hand-created Cloud profile; absent (`None`) + /// applies legacy inference for pre-marker init profiles only. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub hosted_cloud_managed: Option, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] @@ -205,7 +221,7 @@ fn write_config_at(path: &Path, file: &ConfigFile) -> Result<()> { } pub fn load_config() -> Result { - read_config_at(&config_path()?) + ConfigStore::production()?.load() } /// Read, mutate, and write `config.toml` while holding the file lock for the @@ -313,6 +329,214 @@ fn bind_key_origin(secret: &str, api_origin: &str, project_id: &str) -> ApiKeySe } } +fn api_key_force_enabled() -> bool { + std::env::var(ENV_API_KEY_FORCE).ok().is_some_and(|value| { + matches!( + value.trim().to_ascii_lowercase().as_str(), + "1" | "true" | "yes" | "on" + ) + }) +} + +/// Credential ref written by Hosted Cloud init for a project (`hosted-cloud-`). +pub(crate) fn hosted_cloud_init_credential_ref(project_id: &str) -> String { + format!("hosted-cloud-{project_id}") +} + +/// Pre-`hosted_cloud_managed` profiles activated by Hosted Cloud init. +/// +/// Inference requires the init contract: Cloud kind, a project id, an +/// `api_key_ref` that exactly matches the init credential ref for that project, +/// and no explicit `hosted_cloud_managed = false`. Hand-created Cloud profiles +/// must set `hosted_cloud_managed = false` (via `am config profile add` or +/// `am key create --save`) even when the profile name matches the init ref. +fn legacy_init_managed_hosted_cloud_profile(profile: &ProfileConfig) -> bool { + if profile.kind != ProfileKind::Cloud { + return false; + } + matches!( + ( + profile.project_id.as_deref(), + profile.api_key_ref.as_deref(), + ), + (Some(project_id), Some(api_key_ref)) + if api_key_ref == hosted_cloud_init_credential_ref(project_id) + ) +} + +/// Whether Hosted Cloud init key precedence applies to this profile. +pub(crate) fn hosted_cloud_managed_for_key_policy(profile: &ProfileConfig) -> bool { + if profile.kind != ProfileKind::Cloud { + return false; + } + match profile.hosted_cloud_managed { + Some(true) => true, + Some(false) => false, + None => legacy_init_managed_hosted_cloud_profile(profile), + } +} + +/// Atomically require a Connected Local profile and link an export project id. +/// Identity of the profile an export was planned against, captured from the +/// raw config entry at resolve time. Compared inside the store's locked +/// mutation so a same-name replacement is rejected BEFORE its project binding +/// is overwritten — checking after the write already corrupted the +/// replacement's binding on the way to the abort. +/// +/// Raw stored fields, not resolved ones: a resolved `base_url` can carry a +/// per-invocation `--base-url` override and `memory_base_url` is derived, so +/// comparing resolved values against the stored entry would reject legitimate +/// runs. `project_id` is deliberately absent — it is the field this store +/// exists to change. +/// Identity of the profile an export was planned against: the COMPLETE raw +/// config entry, captured from the same loaded ConfigFile the resolution used. +/// +/// Compared inside the store's locked mutation so a same-name replacement is +/// rejected BEFORE its project binding is overwritten. Holding the full entry +/// rather than a field allowlist is what terminates the class: every previous +/// hole here was a field somebody forgot to compare (local_url, then +/// oauth_ref/api_key_ref/hosted_cloud_managed), and `mismatch` destructures +/// both entries exhaustively, so adding a field to ProfileConfig refuses to +/// compile until it is classified as identity or export-mutable. +/// +/// `project_id` is the one export-mutable field — changing it is what the +/// store exists to do. +#[derive(Debug, Clone)] +pub struct ExpectedExportProfile { + pub entry: ProfileConfig, + /// The concrete OAuth session selected at planning time, from the same + /// credentials read the resolution used. Execution authenticates with + /// exactly this key and never re-selects: the Local fallback picks the + /// credential map's first session, so re-running it at execution let a + /// concurrent login redirect the export to a different same-origin + /// account. `None` means no session existed at planning time; the + /// dashboard step fails with the usual not-logged-in guidance. + pub oauth_storage_key: Option, +} + +impl ExpectedExportProfile { + /// Test-only convenience; production captures inside + /// `resolve_profile_with_export_identity` from the same loaded config that + /// resolution used, which this by-name lookup cannot guarantee. + #[cfg(test)] + pub fn capture(config: &ConfigFile, profile_name: &str) -> Result { + let entry = config + .profiles + .get(profile_name) + .ok_or_else(|| anyhow!("profile '{profile_name}' not found"))?; + Ok(Self { + entry: entry.clone(), + oauth_storage_key: None, + }) + } + + fn mismatch(&self, current: &ProfileConfig) -> Option<&'static str> { + // Exhaustive on purpose — see the type docs. `..` is forbidden here. + let ProfileConfig { + base_url, + kind, + project_id: _, // export-mutable: this store's own write target + api_key_ref, + local_url, + oauth_ref, + hosted_cloud_managed, + } = current; + let ProfileConfig { + base_url: expected_base_url, + kind: expected_kind, + project_id: _, + api_key_ref: expected_api_key_ref, + local_url: expected_local_url, + oauth_ref: expected_oauth_ref, + hosted_cloud_managed: expected_hosted_cloud_managed, + } = &self.entry; + if kind != expected_kind { + return Some("kind"); + } + if base_url != expected_base_url { + return Some("base_url"); + } + if local_url != expected_local_url { + return Some("local_url"); + } + if api_key_ref != expected_api_key_ref { + return Some("api_key_ref"); + } + if oauth_ref != expected_oauth_ref { + return Some("oauth_ref"); + } + if hosted_cloud_managed != expected_hosted_cloud_managed { + return Some("hosted_cloud_managed"); + } + None + } +} + +pub fn store_local_export_project_id( + profile_name: &str, + expected: &ExpectedExportProfile, + project_id: &str, +) -> Result<()> { + update_config(|config| { + store_local_export_project_id_mut(config, profile_name, expected, project_id) + }) +} + +fn store_local_export_project_id_mut( + config: &mut ConfigFile, + profile_name: &str, + expected: &ExpectedExportProfile, + project_id: &str, +) -> Result<()> { + let entry = config + .profiles + .get_mut(profile_name) + .ok_or_else(|| anyhow!("profile '{profile_name}' not found"))?; + if entry.kind != ProfileKind::Local { + bail!( + "export requires an active Connected Local profile — active profile '{profile_name}' is Cloud" + ); + } + // Same lock as the write: reject a same-name replacement before touching + // its binding, not after. + if let Some(field) = expected.mismatch(entry) { + bail!( + "profile '{profile_name}' was replaced during export ({field} changed) — rerun `am migrate export`" + ); + } + entry.project_id = Some(project_id.to_string()); + Ok(()) +} + +#[cfg(test)] +pub(crate) fn store_local_export_project_id_in( + store: &ConfigStore, + profile_name: &str, + expected: &ExpectedExportProfile, + project_id: &str, +) -> Result<()> { + store.update(|config| { + store_local_export_project_id_mut(config, profile_name, expected, project_id) + }) +} + +/// Stored key that matches the resolved origin and project binding, if any. +fn bound_stored_api_key( + stored: Option<&ApiKeySecret>, + resolved_base_url: &str, + resolved_project_id: Option<&str>, +) -> Option { + let stored = stored?; + match stored.api_origin.as_deref() { + Some(origin) if check_api_key_origin(origin, resolved_base_url) => {} + _ => return None, + } + match (stored.project_id.as_deref(), resolved_project_id) { + (Some(issued_for), Some(target)) if issued_for == target => Some(stored.secret.clone()), + _ => None, + } +} + /// Choose the API key to send to `resolved_base_url`. /// /// A stored `amc_` key belongs to the origin it was minted against — the @@ -321,34 +545,72 @@ fn bind_key_origin(secret: &str, api_origin: &str, project_id: &str) -> ApiKeySe /// stored key is withheld unless the two origins agree; this is the same /// invariant that governs session tokens (see [`crate::auth::origin`]). /// -/// An explicit `ATOMICMEMORY_API_KEY` is per-invocation user intent, like a -/// flag, and is passed through unchanged. +/// Local profiles and hand-created Cloud profiles: an explicit +/// `ATOMICMEMORY_API_KEY` is per-invocation user intent, like a flag. +/// +/// Init-managed Hosted Cloud profiles (`hosted-cloud-` credential +/// refs from `am init`): the bound stored key wins over a stale shell export +/// unless `ATOMICMEMORY_API_KEY_FORCE=1`. fn select_api_key( env_override: Option, stored: Option<&ApiKeySecret>, resolved_base_url: &str, resolved_project_id: Option<&str>, + init_managed_hosted: bool, +) -> Option { + select_api_key_with_force( + env_override, + stored, + resolved_base_url, + resolved_project_id, + init_managed_hosted, + api_key_force_enabled(), + ) +} + +fn select_api_key_with_force( + env_override: Option, + stored: Option<&ApiKeySecret>, + resolved_base_url: &str, + resolved_project_id: Option<&str>, + init_managed_hosted: bool, + force_env_override: bool, ) -> Option { + let env_override = env_override.filter(|value| !value.is_empty()); + if init_managed_hosted { + let bound = bound_stored_api_key(stored, resolved_base_url, resolved_project_id); + if force_env_override { + return env_override.or(bound); + } + if let Some(secret) = bound { + return Some(secret); + } + return env_override; + } + if let Some(key) = env_override { return Some(key); } - let stored = stored?; - match stored.api_origin.as_deref() { - Some(origin) if check_api_key_origin(origin, resolved_base_url) => {} - // No recorded origin means the key cannot be proven to belong to this - // destination — including production. Re-save it with - // `am key create --save` rather than assuming where it came from. - _ => return None, - } + bound_stored_api_key(stored, resolved_base_url, resolved_project_id) +} - // The origin is necessary but not sufficient: one origin hosts many - // projects. A key issued for project A must not be sent on behalf of a - // profile now linked to project B. - match (stored.project_id.as_deref(), resolved_project_id) { - (Some(issued_for), Some(target)) if issued_for == target => Some(stored.secret.clone()), - // Unknown binding fails closed, like an originless key. - _ => None, +/// Select which stored OAuth session a profile authenticates with. +/// +/// One implementation shared by the by-name path and export planning, so the +/// two cannot drift: the named ref when present, any session for Local +/// profiles (BTreeMap order — first key), none otherwise. +pub(crate) fn select_oauth_session_key( + creds: &CredentialsFile, + oauth_ref: &str, + allow_local_fallback: bool, +) -> Option { + if creds.oauth.contains_key(oauth_ref) { + return Some(oauth_ref.to_string()); } + if allow_local_fallback { + return creds.oauth.keys().next().cloned(); + } + None } pub fn resolve_profile( @@ -358,6 +620,74 @@ pub fn resolve_profile( ) -> Result { let config = load_config()?; let creds = load_credentials()?; + resolve_profile_from( + &config, + &creds, + profile_name, + base_url_override, + environment_override, + ) +} + +/// Resolve the profile and capture its raw export identity from ONE config +/// read. +/// +/// Resolving and then re-loading config.toml to capture the identity leaves a +/// filesystem-level gap: another PROCESS can replace the profile between the +/// two reads, so the resolved profile describes A while the captured identity +/// describes its replacement B — and the store's in-lock check then compares +/// B against B and happily writes A's project into it. "No await between them" +/// only rules out same-process interleaving. Deriving both from a single +/// loaded ConfigFile closes the class: the pair cannot disagree about which +/// generation it saw. +pub fn resolve_profile_with_export_identity( + profile_name: Option<&str>, + base_url_override: Option<&str>, + environment_override: Option, +) -> Result<(ResolvedProfile, ExpectedExportProfile)> { + let config = load_config()?; + let creds = load_credentials()?; + let resolved = resolve_profile_from( + &config, + &creds, + profile_name, + base_url_override, + environment_override, + )?; + // Mirror resolve_profile_from's missing-entry semantics (a synthesized + // Cloud default) so a nonexistent profile still fails export on the kind + // check with the same message as before, not on a lookup error here. + let entry = config + .profiles + .get(&resolved.name) + .cloned() + .unwrap_or_else(|| ProfileConfig { + base_url: Some(DEFAULT_CLOUD_URL.to_string()), + kind: ProfileKind::Cloud, + ..Default::default() + }); + let oauth_ref = entry + .oauth_ref + .clone() + .unwrap_or_else(|| resolved.name.clone()); + let oauth_storage_key = + select_oauth_session_key(&creds, &oauth_ref, entry.kind == ProfileKind::Local); + Ok(( + resolved, + ExpectedExportProfile { + entry, + oauth_storage_key, + }, + )) +} + +pub(crate) fn resolve_profile_from( + config: &ConfigFile, + creds: &CredentialsFile, + profile_name: Option<&str>, + base_url_override: Option<&str>, + environment_override: Option, +) -> Result { let name = profile_name .map(str::to_string) .or_else(|| std::env::var(ENV_PROFILE).ok()) @@ -383,13 +713,11 @@ pub fn resolve_profile( }) .value; - let memory_base_url = match profile.kind { - ProfileKind::Local => profile - .local_url - .clone() - .unwrap_or_else(|| base_url.clone()), - ProfileKind::Cloud => base_url.clone(), - }; + let stored_kind = profile.kind; + let local_memory_url = profile + .local_url + .clone() + .unwrap_or_else(|| base_url.clone()); // A stored `amc_` key belongs to the origin it was minted against, which is // the profile's own base URL (or the default when it has none). The @@ -399,27 +727,135 @@ pub fn resolve_profile( // An explicit ATOMICMEMORY_API_KEY is per-invocation user intent, like a // flag, and is passed through. let api_key_ref = profile.api_key_ref.clone().unwrap_or_else(|| name.clone()); + let init_managed_hosted = hosted_cloud_managed_for_key_policy(&profile); + let env_api_key = std::env::var(ENV_API_KEY) + .ok() + .filter(|value| !value.is_empty()); let api_key = select_api_key( - std::env::var("ATOMICMEMORY_API_KEY").ok(), + env_api_key.clone(), creds.api_keys.get(&api_key_ref), &base_url, profile.project_id.as_deref(), + init_managed_hosted, ); let oauth_ref = profile.oauth_ref.clone().unwrap_or_else(|| name.clone()); let oauth = creds.oauth.get(&oauth_ref).cloned(); + let ephemeral_cloud_override = ephemeral_cloud_override_applies( + stored_kind, + base_url_override, + environment_override, + &base_url, + env_api_key.as_deref(), + ); + + let (kind, memory_base_url, project_id) = if ephemeral_cloud_override { + (ProfileKind::Cloud, base_url.clone(), None) + } else { + let memory_base_url = match stored_kind { + ProfileKind::Local => local_memory_url, + ProfileKind::Cloud => base_url.clone(), + }; + (stored_kind, memory_base_url, profile.project_id) + }; + Ok(ResolvedProfile { name, base_url, - kind: profile.kind, - project_id: profile.project_id, + kind, + project_id, memory_base_url, api_key, oauth, }) } +/// Whether a stored Local profile should flip to Cloud for this invocation. +/// +/// Requires an explicitly exported `amc_` key — stored Local trace-sync keys +/// must not satisfy this gate. +fn ephemeral_cloud_override_applies( + stored_kind: ProfileKind, + base_url_override: Option<&str>, + environment_override: Option, + resolved_base_url: &str, + env_api_key: Option<&str>, +) -> bool { + let has_override_intent = base_url_override + .filter(|value| !value.is_empty()) + .is_some() + || environment_override.is_some(); + stored_kind == ProfileKind::Local + && has_override_intent + && is_remote_cloud_api_url(resolved_base_url) + && env_api_key.is_some_and(is_cloud_api_key) +} + +/// Warn when an init-managed Hosted Cloud profile ignores a stale `ATOMICMEMORY_API_KEY`. +pub fn hosted_cloud_env_key_override_warning( + hosted_cloud_managed: bool, + env_api_key: Option<&str>, + stored: Option<&ApiKeySecret>, + resolved_base_url: &str, + project_id: Option<&str>, +) -> Option { + hosted_cloud_env_key_override_warning_with_force( + hosted_cloud_managed, + env_api_key, + stored, + resolved_base_url, + project_id, + api_key_force_enabled(), + ) +} + +fn hosted_cloud_env_key_override_warning_with_force( + hosted_cloud_managed: bool, + env_api_key: Option<&str>, + stored: Option<&ApiKeySecret>, + resolved_base_url: &str, + project_id: Option<&str>, + force_env_override: bool, +) -> Option { + if !hosted_cloud_managed || force_env_override { + return None; + } + let env = env_api_key.filter(|value| !value.is_empty())?; + let stored_secret = bound_stored_api_key(stored, resolved_base_url, project_id)?; + if env == stored_secret { + return None; + } + Some(format!( + "warning: {ENV_API_KEY} is set but ignored — Hosted Cloud uses the saved profile key. \ + Unset it or set {ENV_API_KEY_FORCE}=1 to override." + )) +} + +/// Warn when Cloud URL exports cannot override a stored Local profile. +pub fn local_profile_cloud_export_warning( + stored_kind: ProfileKind, + base_url_override: Option<&str>, + resolved_base_url: &str, + env_api_key: Option<&str>, + local_memory_url: &str, +) -> Option { + if stored_kind != ProfileKind::Local { + return None; + } + base_url_override.filter(|value| !value.is_empty())?; + if !is_remote_cloud_api_url(resolved_base_url) { + return None; + } + if env_api_key.is_some_and(is_cloud_api_key) { + return None; + } + Some(format!( + "warning: ATOMICMEMORY_API_URL points to Cloud ({resolved_base_url}) but the active profile is Local — \ + commands will use {local_memory_url} unless you set ATOMICMEMORY_API_KEY (amc_…) or switch profiles" + )) +} + /// Dashboard session + API base URL aligned with `am project list`. #[derive(Debug, Clone, PartialEq, Eq)] pub struct DashboardContext { @@ -576,16 +1012,138 @@ pub fn store_api_key( })?; update_config(|config| { - let entry = config.profiles.entry(profile_name.to_string()).or_default(); - entry.api_key_ref = Some(profile_name.to_string()); + store_api_key_profile_mut(config, profile_name); + Ok(()) + }) +} + +fn store_api_key_profile_mut(config: &mut ConfigFile, profile_name: &str) { + let entry = config.profiles.entry(profile_name.to_string()).or_default(); + let init_managed = hosted_cloud_managed_for_key_policy(entry); + entry.api_key_ref = Some(profile_name.to_string()); + entry.hosted_cloud_managed = Some(init_managed); +} + +/// Save a project-scoped Hosted Cloud key without changing the active profile. +pub(crate) fn store_hosted_cloud_api_key( + credential_ref: &str, + secret: &str, + api_origin: &str, + project_id: &str, +) -> Result<()> { + let record = bind_key_origin(secret, api_origin, project_id); + update_credentials(|creds| { + creds.api_keys.insert(credential_ref.to_string(), record); Ok(()) }) } +/// Activate Hosted Cloud only after its project credential is ready. +pub(crate) fn activate_hosted_cloud_profile( + profile_name: &str, + api_origin: &str, + project_id: &str, + oauth_ref: &str, + credential_ref: &str, +) -> Result<()> { + update_config(|config| { + configure_hosted_cloud_profile( + config, + profile_name, + api_origin, + project_id, + oauth_ref, + credential_ref, + ) + }) +} + +/// Refuse Hosted Cloud activation when its OAuth profile name belongs to Local. +pub(crate) fn ensure_hosted_cloud_profile_available(profile_name: &str) -> Result<()> { + validate_hosted_cloud_profile(&load_config()?, profile_name) +} + +fn validate_hosted_cloud_profile(config: &ConfigFile, profile_name: &str) -> Result<()> { + if config + .profiles + .get(profile_name) + .is_some_and(|profile| profile.kind == ProfileKind::Local) + { + bail!( + "profile '{profile_name}' is Connected Local and cannot be overwritten by Hosted Cloud; rerun with an unused global profile, for example `am --profile hosted-cloud init`" + ); + } + Ok(()) +} + +fn configure_hosted_cloud_profile( + config: &mut ConfigFile, + profile_name: &str, + api_origin: &str, + project_id: &str, + oauth_ref: &str, + credential_ref: &str, +) -> Result<()> { + validate_hosted_cloud_profile(config, profile_name)?; + + config.profiles.insert( + profile_name.to_string(), + ProfileConfig { + base_url: Some(api_origin.to_string()), + kind: ProfileKind::Cloud, + project_id: Some(project_id.to_string()), + api_key_ref: Some(credential_ref.to_string()), + local_url: None, + oauth_ref: Some(oauth_ref.to_string()), + hosted_cloud_managed: Some(true), + }, + ); + config.default_profile = Some(profile_name.to_string()); + Ok(()) +} + +/// Where an OpenAI key came from. Collapsing these to a bare `String` made +/// every override look like a stored credential: an `--openai-api-key` or +/// `OPENAI_API_KEY` value got written to `credentials.toml`, and a rejected +/// override deleted the stored key it never even tried. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OpenAiKeySource { + /// Passed as `--openai-api-key` on this invocation. + Flag, + /// Read from `OPENAI_API_KEY` in the environment. + Environment, + /// Loaded from `credentials.toml` for this profile. + Stored, + /// Typed at the hidden prompt, which states that it will be saved. + Prompted, +} + +impl OpenAiKeySource { + /// Only a prompted key is persisted. The prompt is the one place the user + /// is told the key will be saved, so it is the only place that consents. + pub fn should_persist(self) -> bool { + matches!(self, Self::Prompted) + } + + /// Only a rejected *stored* key may be cleared. A bad flag or environment + /// value says nothing about the credential on disk. + pub fn should_clear_stored(self) -> bool { + matches!(self, Self::Stored) + } +} + pub fn resolve_openai_api_key(profile_name: &str) -> Option { + resolve_openai_api_key_with_source(profile_name).map(|(key, _)| key) +} + +/// Resolve the key and report which source supplied it. Environment still wins +/// over the stored credential; the difference is that the caller can now tell +/// them apart. +pub fn resolve_openai_api_key_with_source(profile_name: &str) -> Option<(String, OpenAiKeySource)> { std::env::var("OPENAI_API_KEY") .ok() .filter(|s| !s.is_empty()) + .map(|key| (key, OpenAiKeySource::Environment)) .or_else(|| { load_credentials() .ok() @@ -595,6 +1153,7 @@ pub fn resolve_openai_api_key(profile_name: &str) -> Option { .and_then(|s| s.openai_api_key.clone()) }) .filter(|s| !s.is_empty()) + .map(|key| (key, OpenAiKeySource::Stored)) }) } @@ -609,6 +1168,15 @@ pub fn store_openai_api_key(profile_name: &str, key: &str) -> Result<()> { }) } +pub fn clear_openai_api_key(profile_name: &str) -> Result<()> { + update_credentials(|creds| { + if let Some(secrets) = creds.profile_secrets.get_mut(profile_name) { + secrets.openai_api_key = None; + } + Ok(()) + }) +} + pub fn clear_oauth(profile_name: &str) -> Result<()> { update_credentials(|creds| { creds.oauth.remove(profile_name); @@ -678,10 +1246,64 @@ fn default_config() -> ConfigFile { profiles, telemetry_distinct_id: None, telemetry_first_real_memory_sent: None, + cli_key_id: None, integrations: BTreeMap::new(), } } +/// Stable id for the Cloud API keys this machine owns. +/// +/// Read-or-create under a single `update_config` lock so two concurrent +/// invocations agree on one id instead of each writing its own. +pub fn machine_key_id() -> Result { + machine_key_id_with(&ConfigStore::production()?) +} + +fn machine_key_id_with(store: &ConfigStore) -> Result { + store.update(|cfg| { + if let Some(id) = cfg.cli_key_id.clone() { + if !is_valid_machine_key_id(&id) { + bail!( + "invalid cli_key_id in config.toml: expected exactly 12 lowercase hexadecimal characters" + ); + } + return Ok(id); + } + let id = random_key_id(); + cfg.cli_key_id = Some(id.clone()); + Ok(id) + }) +} + +fn random_key_id() -> String { + use rand::Rng as _; + let mut bytes = [0u8; 6]; + rand::rng().fill_bytes(&mut bytes); + bytes.iter().map(|b| format!("{b:02x}")).collect() +} + +fn is_valid_machine_key_id(id: &str) -> bool { + id.len() == 12 + && id + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +/// Name the key this machine owns, e.g. `am-cli-9f2c1a4b7e05`. +/// +/// Key provisioning rotates an existing key rather than creating a new one, to +/// stay inside the project's key quota. Rotation invalidates the old secret, so +/// selecting by a shared name meant a second machine's first `am init` rotated +/// the key the first machine was actively using and broke it. Scoping the name +/// to this install means a machine can only ever rotate a key it owns. +pub fn machine_scoped_key_name(base: &str) -> Result { + Ok(machine_scoped_key_name_with(base, &machine_key_id()?)) +} + +fn machine_scoped_key_name_with(base: &str, id: &str) -> String { + format!("{base}-{id}") +} + fn lock_path_for(target: &Path) -> PathBuf { target.with_extension("lock") } @@ -806,28 +1428,64 @@ pub fn default_config_for_test() -> ConfigFile { #[cfg(test)] mod tests { - fn key_for(origin: &str, project: &str) -> ApiKeySecret { - ApiKeySecret { - secret: "amc_live_example".into(), - api_origin: Some(origin.into()), - project_id: Some(project.into()), - } - } - - /// A Cloud origin hosts many projects, so matching origins is not enough. + /// Why migrate export must refresh the profile after binding the project. /// - /// The defect: keys recorded only their origin. A profile relinked from - /// project A to project B reused A's key, so Core came up with A's - /// credential while the profile and receipt claimed B, routing trace sync to - /// the wrong project. + /// `am key create --project --save` binds the credential to a project + /// without setting the profile's project_id, so until something writes that + /// binding the key is unusable. Export performs that write itself, which is + /// why it cannot keep using the snapshot it took beforehand. #[test] - fn a_key_issued_for_another_project_is_refused() { + fn stored_key_needs_the_profile_project_to_be_set() { + let stored = key_for("https://api.atomicstrata.ai/", "proj_a"); + assert_eq!( + super::bound_stored_api_key(Some(&stored), "https://api.atomicstrata.ai/", None), + None, + "no resolved project means the bound key is withheld" + ); + assert_eq!( + super::bound_stored_api_key( + Some(&stored), + "https://api.atomicstrata.ai/", + Some("proj_a") + ) + .as_deref(), + Some("amc_live_example"), + "once the profile names the same project the key resolves" + ); + // A different project must still be refused. + assert_eq!( + super::bound_stored_api_key( + Some(&stored), + "https://api.atomicstrata.ai/", + Some("proj_b") + ), + None + ); + } + + fn key_for(origin: &str, project: &str) -> ApiKeySecret { + ApiKeySecret { + secret: "amc_live_example".into(), + api_origin: Some(origin.into()), + project_id: Some(project.into()), + } + } + + /// A Cloud origin hosts many projects, so matching origins is not enough. + /// + /// The defect: keys recorded only their origin. A profile relinked from + /// project A to project B reused A's key, so Core came up with A's + /// credential while the profile and receipt claimed B, routing trace sync to + /// the wrong project. + #[test] + fn a_key_issued_for_another_project_is_refused() { let stored = key_for("https://api.atomicstrata.ai", "proj_a"); let selected = select_api_key( None, Some(&stored), "https://api.atomicstrata.ai", Some("proj_b"), + false, ); assert_eq!( selected, None, @@ -843,6 +1501,7 @@ mod tests { Some(&stored), "https://api.atomicstrata.ai", Some("proj_a"), + false, ); assert_eq!(selected.as_deref(), Some("amc_live_example")); } @@ -856,6 +1515,7 @@ mod tests { Some(&stored), "http://127.0.0.1:38767", Some("proj_a"), + false, ); assert_eq!(selected, None, "the origin check must still apply"); } @@ -873,19 +1533,36 @@ mod tests { Some(&stored), "https://api.atomicstrata.ai", Some("proj_a"), + false, ); assert_eq!(selected, None, "an unproven binding must not be trusted"); } - /// An explicit env override is per-invocation user intent, like a flag. + /// Local profiles still treat an explicit env override as per-invocation intent. #[test] - fn an_explicit_env_key_still_passes_through() { + fn an_explicit_env_key_still_passes_through_for_local() { let stored = key_for("https://api.atomicstrata.ai", "proj_a"); let selected = select_api_key( Some("amc_from_env".into()), Some(&stored), "https://api.atomicstrata.ai", Some("proj_b"), + false, + ); + assert_eq!(selected.as_deref(), Some("amc_from_env")); + } + + /// Hand-created Cloud profiles (`am config profile add --kind cloud`) keep + /// explicit env override even when a bound stored key exists. + #[test] + fn custom_cloud_profile_honors_explicit_env_over_bound_stored() { + let stored = key_for("https://api.atomicstrata.ai", "proj_a"); + let selected = select_api_key( + Some("amc_from_env".into()), + Some(&stored), + "https://api.atomicstrata.ai", + Some("proj_a"), + false, ); assert_eq!(selected.as_deref(), Some("amc_from_env")); } @@ -1093,7 +1770,7 @@ mod tests { let key = stored_key("amc_stored", Some(PROD)); assert_eq!( - select_api_key(None, Some(&key), PROD, Some(TEST_PROJECT)).as_deref(), + select_api_key(None, Some(&key), PROD, Some(TEST_PROJECT), false).as_deref(), Some("amc_stored") ); for target in [ @@ -1103,7 +1780,7 @@ mod tests { "https://api.staging.example.com", ] { assert_eq!( - select_api_key(None, Some(&key), target, Some(TEST_PROJECT)), + select_api_key(None, Some(&key), target, Some(TEST_PROJECT), false), None, "stored key must not be sent to {target}" ); @@ -1121,7 +1798,8 @@ mod tests { None, Some(&key), "https://api.b.example", - Some(TEST_PROJECT) + Some(TEST_PROJECT), + false, ), None ); @@ -1130,7 +1808,8 @@ mod tests { None, Some(&key), "https://api.a.example", - Some(TEST_PROJECT) + Some(TEST_PROJECT), + false, ) .as_deref(), Some("amc_source_secret") @@ -1146,7 +1825,7 @@ mod tests { "http://127.0.0.1:38767", ] { assert_eq!( - select_api_key(None, Some(&legacy), target, Some(TEST_PROJECT)), + select_api_key(None, Some(&legacy), target, Some(TEST_PROJECT), false), None, "legacy key must not be trusted for {target}" ); @@ -1161,13 +1840,620 @@ mod tests { Some("amc_env".into()), Some(&key), "https://api.staging.example.com", - Some(TEST_PROJECT) + Some(TEST_PROJECT), + false, ) .as_deref(), Some("amc_env") ); } + #[test] + fn legacy_pre_marker_toml_prefers_stored_key_over_stale_env() { + let raw = r#" +kind = "cloud" +base_url = "https://api.atomicstrata.ai" +project_id = "proj_a" +api_key_ref = "hosted-cloud-proj_a" +"#; + let profile: ProfileConfig = toml::from_str(raw).expect("parse legacy profile"); + assert_eq!(profile.hosted_cloud_managed, None); + assert!(hosted_cloud_managed_for_key_policy(&profile)); + let stored = key_for("https://api.atomicstrata.ai", "proj_a"); + let selected = select_api_key_with_force( + Some("amc_stale_env".into()), + Some(&stored), + "https://api.atomicstrata.ai", + Some("proj_a"), + hosted_cloud_managed_for_key_policy(&profile), + false, + ); + assert_eq!(selected.as_deref(), Some("amc_live_example")); + } + + #[test] + fn legacy_inference_does_not_match_hand_created_cloud_profile() { + let profile = ProfileConfig { + base_url: Some("https://api.atomicstrata.ai".into()), + kind: ProfileKind::Cloud, + project_id: Some("proj_a".into()), + api_key_ref: Some("my-cloud-profile".into()), + ..Default::default() + }; + assert!(!hosted_cloud_managed_for_key_policy(&profile)); + let stored = key_for("https://api.atomicstrata.ai", "proj_a"); + let selected = select_api_key_with_force( + Some("amc_from_env".into()), + Some(&stored), + "https://api.atomicstrata.ai", + Some("proj_a"), + hosted_cloud_managed_for_key_policy(&profile), + false, + ); + assert_eq!(selected.as_deref(), Some("amc_from_env")); + } + + #[test] + fn explicit_false_manual_hosted_cloud_ref_honors_env_key() { + let raw = r#" +kind = "cloud" +base_url = "https://api.atomicstrata.ai" +project_id = "proj_a" +api_key_ref = "hosted-cloud-proj_a" +hosted_cloud_managed = false +"#; + let profile: ProfileConfig = toml::from_str(raw).expect("parse manual profile"); + assert_eq!(profile.hosted_cloud_managed, Some(false)); + assert!(!hosted_cloud_managed_for_key_policy(&profile)); + let stored = key_for("https://api.atomicstrata.ai", "proj_a"); + let selected = select_api_key_with_force( + Some("amc_from_env".into()), + Some(&stored), + "https://api.atomicstrata.ai", + Some("proj_a"), + hosted_cloud_managed_for_key_policy(&profile), + false, + ); + assert_eq!(selected.as_deref(), Some("amc_from_env")); + } + + #[test] + fn legacy_init_profile_stays_managed_after_key_save() { + let mut config = default_config_for_test(); + config.profiles.insert( + "cloud".into(), + ProfileConfig { + base_url: Some(Environment::PROD_BASE_URL.into()), + kind: ProfileKind::Cloud, + project_id: Some("proj_a".into()), + api_key_ref: Some(hosted_cloud_init_credential_ref("proj_a")), + ..Default::default() + }, + ); + assert!(hosted_cloud_managed_for_key_policy( + config.profiles.get("cloud").unwrap() + )); + store_api_key_profile_mut(&mut config, "cloud"); + let profile = &config.profiles["cloud"]; + assert_eq!(profile.hosted_cloud_managed, Some(true)); + assert_eq!(profile.api_key_ref.as_deref(), Some("cloud")); + assert!(hosted_cloud_managed_for_key_policy(profile)); + let stored = key_for(Environment::PROD_BASE_URL, "proj_a"); + let selected = select_api_key_with_force( + Some("amc_stale_env".into()), + Some(&stored), + Environment::PROD_BASE_URL, + Some("proj_a"), + hosted_cloud_managed_for_key_policy(profile), + false, + ); + assert_eq!(selected.as_deref(), Some("amc_live_example")); + } + + #[test] + fn manual_false_profile_stays_manual_after_key_save() { + let mut config = default_config_for_test(); + config.profiles.insert( + "hosted-cloud-proj_a".into(), + ProfileConfig { + base_url: Some(Environment::PROD_BASE_URL.into()), + kind: ProfileKind::Cloud, + project_id: Some("proj_a".into()), + api_key_ref: Some(hosted_cloud_init_credential_ref("proj_a")), + hosted_cloud_managed: Some(false), + ..Default::default() + }, + ); + store_api_key_profile_mut(&mut config, "hosted-cloud-proj_a"); + let profile = &config.profiles["hosted-cloud-proj_a"]; + assert_eq!(profile.hosted_cloud_managed, Some(false)); + assert!(!hosted_cloud_managed_for_key_policy(profile)); + let stored = key_for(Environment::PROD_BASE_URL, "proj_a"); + let selected = select_api_key_with_force( + Some("amc_from_env".into()), + Some(&stored), + Environment::PROD_BASE_URL, + Some("proj_a"), + hosted_cloud_managed_for_key_policy(profile), + false, + ); + assert_eq!(selected.as_deref(), Some("amc_from_env")); + } + + #[test] + fn local_profile_ignores_stale_hosted_cloud_managed_marker() { + let profile = ProfileConfig { + kind: ProfileKind::Local, + hosted_cloud_managed: Some(true), + ..Default::default() + }; + assert!(!hosted_cloud_managed_for_key_policy(&profile)); + let stored = key_for(Environment::PROD_BASE_URL, "proj_a"); + let selected = select_api_key_with_force( + Some("amc_from_env".into()), + Some(&stored), + Environment::PROD_BASE_URL, + Some("proj_a"), + hosted_cloud_managed_for_key_policy(&profile), + false, + ); + assert_eq!(selected.as_deref(), Some("amc_from_env")); + } + + #[test] + fn load_does_not_persist_hosted_cloud_managed_marker() { + let dir = tempfile::tempdir().unwrap(); + let store = ConfigStore::at(dir.path().join("config.toml")); + let raw = r#" +default_profile = "cloud" + +[profiles.cloud] +kind = "cloud" +base_url = "https://api.atomicstrata.ai" +project_id = "proj_a" +api_key_ref = "hosted-cloud-proj_a" +"#; + std::fs::write(store.path(), raw).unwrap(); + let loaded = store.load().unwrap(); + assert_eq!(loaded.profiles["cloud"].hosted_cloud_managed, None); + assert!(hosted_cloud_managed_for_key_policy( + &loaded.profiles["cloud"] + )); + let disk = std::fs::read_to_string(store.path()).unwrap(); + assert!( + !disk.contains("hosted_cloud_managed"), + "load must not rewrite config.toml" + ); + } + + #[test] + fn export_store_rejects_replacement_on_every_identity_field() { + // The comparator destructures ProfileConfig exhaustively, so this + // matrix plus the compiler covers the whole struct: every field except + // project_id is identity, and a replacement differing in ANY of them + // is rejected without mutation. project_id alone must be accepted — + // rewriting it is what the store is for. + let baseline = || ProfileConfig { + kind: ProfileKind::Local, + base_url: Some("https://a.example/".into()), + local_url: Some("http://127.0.0.1:17350".into()), + project_id: Some("proj_old".into()), + api_key_ref: Some("key-a".into()), + oauth_ref: Some("oauth-a".into()), + hosted_cloud_managed: None, + }; + let cases: Vec<(&str, ProfileConfig)> = vec![ + ( + "kind", + ProfileConfig { + kind: ProfileKind::Cloud, + ..baseline() + }, + ), + ( + "base_url", + ProfileConfig { + base_url: Some("https://b.example/".into()), + ..baseline() + }, + ), + ( + "local_url", + ProfileConfig { + local_url: Some("http://127.0.0.1:9999".into()), + ..baseline() + }, + ), + ( + "api_key_ref", + ProfileConfig { + api_key_ref: Some("key-b".into()), + ..baseline() + }, + ), + ( + "oauth_ref", + ProfileConfig { + oauth_ref: None, + ..baseline() + }, + ), + ( + "hosted_cloud_managed", + ProfileConfig { + hosted_cloud_managed: Some(false), + ..baseline() + }, + ), + ]; + for (field, replacement) in cases { + let dir = tempfile::tempdir().unwrap(); + let store = ConfigStore::at(dir.path().join("config.toml")); + let mut cfg = default_config_for_test(); + cfg.profiles.insert("local".into(), baseline()); + store + .update(|config| { + *config = cfg.clone(); + Ok(()) + }) + .unwrap(); + let expected = ExpectedExportProfile::capture(&store.load().unwrap(), "local").unwrap(); + store + .update(|config| { + config.profiles.insert("local".into(), replacement.clone()); + Ok(()) + }) + .unwrap(); + let result = store_local_export_project_id_in(&store, "local", &expected, "proj_new"); + if replacement.kind != ProfileKind::Local { + // The kind bail fires first with its own message; still no write. + assert!(result.is_err(), "{field}: replacement must be rejected"); + } else { + let err = result.unwrap_err().to_string(); + assert!( + err.contains(&format!("{field} changed")), + "{field}: expected named rejection, got: {err}" + ); + } + // The replacement survives untouched either way. + let after = store.load().unwrap().profiles["local"].clone(); + assert_eq!( + after.project_id, replacement.project_id, + "{field}: replacement binding must not be overwritten" + ); + } + + // project_id alone differing is NOT a replacement. + let dir = tempfile::tempdir().unwrap(); + let store = ConfigStore::at(dir.path().join("config.toml")); + let mut cfg = default_config_for_test(); + cfg.profiles.insert("local".into(), baseline()); + store + .update(|config| { + *config = cfg; + Ok(()) + }) + .unwrap(); + let expected = ExpectedExportProfile::capture(&store.load().unwrap(), "local").unwrap(); + store + .update(|config| { + config.profiles.get_mut("local").unwrap().project_id = Some("proj_drift".into()); + Ok(()) + }) + .unwrap(); + store_local_export_project_id_in(&store, "local", &expected, "proj_new").unwrap(); + assert_eq!( + store.load().unwrap().profiles["local"] + .project_id + .as_deref(), + Some("proj_new") + ); + } + + #[test] + fn export_store_rejects_same_name_replacement_without_mutation() { + // The atomicity property: the identity check runs inside the same + // locked mutation as the write. Checking after the store already + // overwrote the replacement's project binding on the way to the abort. + let dir = tempfile::tempdir().unwrap(); + let store = ConfigStore::at(dir.path().join("config.toml")); + let mut cfg = default_config_for_test(); + cfg.profiles.insert( + "local".into(), + ProfileConfig { + kind: ProfileKind::Local, + base_url: Some("https://a.example/".into()), + local_url: Some("http://127.0.0.1:17350".into()), + project_id: Some("proj_a_old".into()), + ..Default::default() + }, + ); + store + .update(|config| { + *config = cfg; + Ok(()) + }) + .unwrap(); + // Export plans against A… + let expected = ExpectedExportProfile::capture(&store.load().unwrap(), "local").unwrap(); + // …and A is then replaced by a different Local B under the same name, + // exactly what `am config profile add` does. + let profile_b = ProfileConfig { + kind: ProfileKind::Local, + base_url: Some("https://b.example/".into()), + local_url: Some("http://127.0.0.1:9999".into()), + project_id: Some("proj_b".into()), + ..Default::default() + }; + store + .update(|config| { + config.profiles.insert("local".into(), profile_b.clone()); + Ok(()) + }) + .unwrap(); + + let err = store_local_export_project_id_in(&store, "local", &expected, "proj_a_selected") + .unwrap_err(); + assert!(err.to_string().contains("replaced during export")); + assert!(err.to_string().contains("base_url changed")); + // B survives byte-for-byte — its binding was NOT overwritten first. + let after = store.load().unwrap().profiles["local"].clone(); + assert_eq!(after.project_id.as_deref(), Some("proj_b")); + assert_eq!(after.base_url.as_deref(), Some("https://b.example/")); + assert_eq!(after.local_url.as_deref(), Some("http://127.0.0.1:9999")); + } + + #[test] + fn export_store_writes_when_identity_matches() { + // The identity fields exclude project_id by construction: changing it + // is what this store is for, so a differing prior binding must not be + // read as a replacement. + let dir = tempfile::tempdir().unwrap(); + let store = ConfigStore::at(dir.path().join("config.toml")); + let mut cfg = default_config_for_test(); + cfg.profiles.insert( + "local".into(), + ProfileConfig { + kind: ProfileKind::Local, + local_url: Some("http://127.0.0.1:17350".into()), + project_id: Some("proj_old".into()), + ..Default::default() + }, + ); + store + .update(|config| { + *config = cfg; + Ok(()) + }) + .unwrap(); + let expected = ExpectedExportProfile::capture(&store.load().unwrap(), "local").unwrap(); + store_local_export_project_id_in(&store, "local", &expected, "proj_new").unwrap(); + assert_eq!( + store.load().unwrap().profiles["local"] + .project_id + .as_deref(), + Some("proj_new") + ); + } + + #[test] + fn store_local_export_project_id_rejects_cloud_without_mutation() { + let dir = tempfile::tempdir().unwrap(); + let store = ConfigStore::at(dir.path().join("config.toml")); + let mut cfg = default_config_for_test(); + cfg.profiles.insert( + "cloud".into(), + ProfileConfig { + kind: ProfileKind::Cloud, + project_id: Some("proj_before".into()), + ..Default::default() + }, + ); + store + .update(|config| { + *config = cfg; + Ok(()) + }) + .unwrap(); + store_local_export_project_id_in( + &store, + "cloud", + &ExpectedExportProfile::capture(&store.load().unwrap(), "cloud").unwrap(), + "proj_after", + ) + .unwrap_err(); + assert_eq!( + store.load().unwrap().profiles["cloud"] + .project_id + .as_deref(), + Some("proj_before") + ); + } + + #[test] + fn init_managed_hosted_cloud_prefers_bound_stored_over_stale_env() { + let stored = key_for("https://api.atomicstrata.ai", "proj_a"); + let selected = select_api_key_with_force( + Some("amc_stale_env".into()), + Some(&stored), + "https://api.atomicstrata.ai", + Some("proj_a"), + true, + false, + ); + assert_eq!(selected.as_deref(), Some("amc_live_example")); + } + + #[test] + fn hosted_cloud_named_profile_without_marker_honors_explicit_env() { + let stored = key_for("https://api.atomicstrata.ai", "proj_a"); + let selected = select_api_key_with_force( + Some("amc_from_env".into()), + Some(&stored), + "https://api.atomicstrata.ai", + Some("proj_a"), + false, + false, + ); + assert_eq!(selected.as_deref(), Some("amc_from_env")); + } + + #[test] + fn init_managed_hosted_cloud_env_force_restores_override() { + let stored = key_for("https://api.atomicstrata.ai", "proj_a"); + let selected = select_api_key_with_force( + Some("amc_from_env".into()), + Some(&stored), + "https://api.atomicstrata.ai", + Some("proj_a"), + true, + true, + ); + assert_eq!(selected.as_deref(), Some("amc_from_env")); + } + + #[test] + fn empty_env_is_treated_as_unset_for_init_managed_hosted_cloud() { + let stored = key_for("https://api.atomicstrata.ai", "proj_a"); + let selected = select_api_key_with_force( + Some(String::new()), + Some(&stored), + "https://api.atomicstrata.ai", + Some("proj_a"), + true, + false, + ); + assert_eq!(selected.as_deref(), Some("amc_live_example")); + } + + #[test] + fn hosted_cloud_env_key_override_warning_when_env_differs() { + let stored = key_for("https://api.atomicstrata.ai", "proj_a"); + let warning = super::hosted_cloud_env_key_override_warning( + true, + Some("amc_stale"), + Some(&stored), + "https://api.atomicstrata.ai", + Some("proj_a"), + ) + .expect("expected warning"); + assert!(warning.contains(ENV_API_KEY)); + assert!(warning.contains(ENV_API_KEY_FORCE)); + } + + #[test] + fn hosted_cloud_env_key_override_warning_suppressed_for_custom_cloud_profile() { + let stored = key_for("https://api.atomicstrata.ai", "proj_a"); + assert!( + super::hosted_cloud_env_key_override_warning( + false, + Some("amc_stale"), + Some(&stored), + "https://api.atomicstrata.ai", + Some("proj_a"), + ) + .is_none() + ); + } + + #[test] + fn hosted_cloud_env_key_override_warning_suppressed_when_env_matches() { + let stored = key_for("https://api.atomicstrata.ai", "proj_a"); + assert!( + super::hosted_cloud_env_key_override_warning( + true, + Some("amc_live_example"), + Some(&stored), + "https://api.atomicstrata.ai", + Some("proj_a"), + ) + .is_none() + ); + } + + #[test] + fn hosted_cloud_env_key_override_warning_suppressed_with_force() { + let stored = key_for("https://api.atomicstrata.ai", "proj_a"); + assert!( + super::hosted_cloud_env_key_override_warning_with_force( + true, + Some("amc_stale"), + Some(&stored), + "https://api.atomicstrata.ai", + Some("proj_a"), + true, + ) + .is_none() + ); + } + + #[test] + fn local_profile_cloud_export_warning_when_cloud_url_without_cloud_key() { + let warning = super::local_profile_cloud_export_warning( + ProfileKind::Local, + Some(Environment::PROD_BASE_URL), + Environment::PROD_BASE_URL, + None, + "http://127.0.0.1:17350", + ) + .expect("expected warning"); + assert!(warning.contains("active profile is Local")); + assert!(warning.contains("127.0.0.1:17350")); + } + + #[test] + fn local_profile_cloud_export_warning_is_suppressed_with_exported_cloud_key() { + assert!( + super::local_profile_cloud_export_warning( + ProfileKind::Local, + Some(Environment::PROD_BASE_URL), + Environment::PROD_BASE_URL, + Some("amc_dashboard_key"), + "http://127.0.0.1:17350", + ) + .is_none() + ); + } + + #[test] + fn ephemeral_cloud_override_requires_exported_amc_key() { + assert!(!ephemeral_cloud_override_applies( + ProfileKind::Local, + Some(Environment::PROD_BASE_URL), + None, + Environment::PROD_BASE_URL, + None, + )); + assert!(!ephemeral_cloud_override_applies( + ProfileKind::Local, + Some(Environment::PROD_BASE_URL), + None, + Environment::PROD_BASE_URL, + Some("core_local_key"), + )); + assert!(ephemeral_cloud_override_applies( + ProfileKind::Local, + Some(Environment::PROD_BASE_URL), + None, + Environment::PROD_BASE_URL, + Some("amc_dashboard_key"), + )); + assert!(!ephemeral_cloud_override_applies( + ProfileKind::Local, + Some(""), + None, + Environment::PROD_BASE_URL, + Some("amc_dashboard_key"), + )); + assert!(ephemeral_cloud_override_applies( + ProfileKind::Local, + None, + Some(Environment::Prod), + Environment::PROD_BASE_URL, + Some("amc_dashboard_key"), + )); + } + #[test] fn config_round_trips_through_the_path_helpers() { let dir = tempfile::tempdir().unwrap(); @@ -1192,6 +2478,148 @@ mod tests { ); } + #[test] + fn machine_key_id_is_twelve_lowercase_hex_and_persisted() { + let dir = tempfile::tempdir().unwrap(); + let store = ConfigStore::at(dir.path().join("config.toml")); + + let first = machine_key_id_with(&store).unwrap(); + let second = machine_key_id_with(&store).unwrap(); + + assert_eq!(first, second); + assert_eq!(first.len(), 12); + assert!( + first + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + ); + assert_eq!( + store.load().unwrap().cli_key_id.as_deref(), + Some(first.as_str()) + ); + } + + #[test] + fn machine_key_id_rejects_malformed_persisted_values() { + let dir = tempfile::tempdir().unwrap(); + let store = ConfigStore::at(dir.path().join("config.toml")); + let mut file = default_config(); + file.cli_key_id = Some("ABC123".into()); + write_config_at(store.path(), &file).unwrap(); + + let error = machine_key_id_with(&store).unwrap_err(); + + assert!( + error + .to_string() + .contains("exactly 12 lowercase hexadecimal") + ); + assert_eq!(store.load().unwrap().cli_key_id.as_deref(), Some("ABC123")); + } + + #[test] + fn machine_scoped_key_name_uses_the_complete_identifier() { + assert_eq!( + machine_scoped_key_name_with("am-cli", "a1b2c3d4e5f6"), + "am-cli-a1b2c3d4e5f6" + ); + assert_eq!( + machine_scoped_key_name_with("connected-local-runtime", "a1b2c3d4e5f6"), + "connected-local-runtime-a1b2c3d4e5f6" + ); + } + + #[cfg(unix)] + #[test] + fn credentials_are_written_with_owner_only_permissions() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("credentials.toml"); + let mut credentials = CredentialsFile::default(); + credentials.api_keys.insert( + "hosted-cloud-proj_a".into(), + key_for(DEFAULT_CLOUD_URL, "proj_a"), + ); + + write_credentials_at(&path, &credentials).unwrap(); + + assert_eq!( + std::fs::metadata(path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } + + #[test] + fn hosted_cloud_activation_preserves_local_profiles() { + let mut config = default_config(); + config.profiles.insert( + "local-work".into(), + ProfileConfig { + base_url: Some(DEFAULT_CLOUD_URL.into()), + kind: ProfileKind::Local, + project_id: Some("proj_local".into()), + api_key_ref: Some("local-work".into()), + local_url: Some("http://127.0.0.1:17350".into()), + oauth_ref: Some("cloud".into()), + ..Default::default() + }, + ); + let local_before = toml::to_string(&config.profiles["local-work"]).unwrap(); + + configure_hosted_cloud_profile( + &mut config, + "cloud", + DEFAULT_CLOUD_URL, + "proj_cloud", + "cloud", + "hosted-cloud-proj_cloud", + ) + .unwrap(); + + assert_eq!( + toml::to_string(&config.profiles["local-work"]).unwrap(), + local_before + ); + let cloud = &config.profiles["cloud"]; + assert_eq!(cloud.kind, ProfileKind::Cloud); + assert_eq!(cloud.project_id.as_deref(), Some("proj_cloud")); + assert_eq!( + cloud.api_key_ref.as_deref(), + Some("hosted-cloud-proj_cloud") + ); + assert_eq!(cloud.hosted_cloud_managed, Some(true)); + assert!(cloud.local_url.is_none()); + assert_eq!(config.default_profile.as_deref(), Some("cloud")); + } + + #[test] + fn hosted_cloud_activation_never_overwrites_a_local_target_profile() { + let mut config = default_config(); + config.profiles.insert( + "cloud".into(), + ProfileConfig { + kind: ProfileKind::Local, + local_url: Some("http://127.0.0.1:17350".into()), + ..Default::default() + }, + ); + let before = toml::to_string(&config).unwrap(); + + let error = configure_hosted_cloud_profile( + &mut config, + "cloud", + DEFAULT_CLOUD_URL, + "proj_cloud", + "cloud", + "hosted-cloud-proj_cloud", + ) + .unwrap_err(); + + assert!(error.to_string().contains("unused global profile")); + assert_eq!(toml::to_string(&config).unwrap(), before); + } + #[test] fn with_path_lock_serializes_read_modify_write_across_threads() { // The lost-update race this guards: without holding the lock across the diff --git a/crates/cli/src/environment.rs b/crates/cli/src/environment.rs index 9354e19..4e0e558 100644 --- a/crates/cli/src/environment.rs +++ b/crates/cli/src/environment.rs @@ -11,6 +11,12 @@ pub const ENV_CORE_IMAGE: &str = "ATOMICMEMORY_CORE_IMAGE"; /// Hostnames treated as production Cloud API endpoints (exact match, lowercase). pub const PROD_API_HOSTS: [&str; 1] = ["api.atomicstrata.ai"]; +/// Sanctioned API hostname → memory web hostname for automatic browser open. +const SANCTIONED_MEMORY_WEB_HOSTS: [(&str, &str); 2] = [ + ("api.atomicstrata.ai", "memory.atomicstrata.ai"), + ("api.dev.atomicstrata.ai", "memory.dev.atomicstrata.ai"), +]; + /// Named Cloud tier — production preset only in the public CLI. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize, ValueEnum)] #[serde(rename_all = "lowercase")] @@ -91,14 +97,62 @@ pub fn is_production_api_url(raw: &str) -> bool { .is_some_and(|host| PROD_API_HOSTS.contains(&host.as_str())) } -/// Dashboard project overview URL for production Cloud hosts only. -pub fn dashboard_project_url(api_base_url: &str, project_id: &str) -> Option { - if !is_production_api_url(api_base_url) { +/// True when the URL targets a remote Cloud API (HTTPS, not loopback). +/// +/// Used to honor dashboard shell exports (`ATOMICMEMORY_API_URL` + `amc_` key) +/// over a stored Local default profile for memory and MCP wiring. +pub fn is_remote_cloud_api_url(raw: &str) -> bool { + let Ok(url) = parse_api_base_url(raw) else { + return false; + }; + if url.scheme() != "https" { + return false; + } + match url.host_str().map(str::to_ascii_lowercase) { + Some(host) if host == "localhost" || host == "127.0.0.1" || host == "::1" => false, + Some(_) => true, + None => false, + } +} + +/// Memory web app origin for sanctioned Cloud API hosts only (HTTPS, default port). +pub fn memory_web_origin(api_base_url: &str) -> Option { + let url = parse_api_base_url(api_base_url).ok()?; + if url.scheme() != "https" { + return None; + } + if url.port().is_some_and(|port| port != 443) { + return None; + } + if !url.username().is_empty() || url.password().is_some() { return None; } - let normalized = parse_api_base_url(api_base_url).ok()?.to_string(); - let memory = normalized.replace("://api.", "://memory."); - Some(format!("{memory}app/projects/{project_id}/overview")) + let host = url.host_str()?.to_ascii_lowercase(); + let memory_host = sanctioned_memory_web_host(&host)?; + Some(format!("https://{memory_host}/")) +} + +fn sanctioned_memory_web_host(api_host: &str) -> Option<&'static str> { + SANCTIONED_MEMORY_WEB_HOSTS + .iter() + .find(|(api, _)| *api == api_host) + .map(|(_, memory)| *memory) +} + +/// Dashboard project overview URL when the API host follows `api.…`. +pub fn dashboard_project_url(api_base_url: &str, project_id: &str) -> Option { + memory_web_origin(api_base_url) + .map(|origin| format!("{origin}app/projects/{project_id}/overview")) +} + +/// Dashboard onboarding entry (create Hosted Cloud project). +pub fn dashboard_onboarding_url(api_base_url: &str) -> Option { + memory_web_origin(api_base_url).map(|origin| format!("{origin}app/onboarding")) +} + +/// Dashboard projects list (pick among existing Hosted Cloud projects). +pub fn dashboard_projects_url(api_base_url: &str) -> Option { + memory_web_origin(api_base_url).map(|origin| format!("{origin}app/projects")) } /// Map a Cloud API base URL to Core's `CLOUD_ENV` tier label. @@ -267,6 +321,20 @@ mod tests { assert!(!is_production_api_url("http://127.0.0.1:8080")); } + #[test] + fn is_remote_cloud_api_url_accepts_https_non_loopback() { + assert!(is_remote_cloud_api_url("https://api.atomicstrata.ai")); + assert!(is_remote_cloud_api_url("https://api.dev.atomicstrata.ai")); + } + + #[test] + fn is_remote_cloud_api_url_rejects_loopback_and_cleartext() { + assert!(!is_remote_cloud_api_url("http://127.0.0.1:17350")); + assert!(!is_remote_cloud_api_url("https://127.0.0.1:17350")); + assert!(!is_remote_cloud_api_url("https://localhost:17350")); + assert!(!is_remote_cloud_api_url("http://api.atomicstrata.ai")); + } + #[test] fn is_production_api_url_requires_https() { // Cleartext to the production host would expose the bearer token. @@ -296,12 +364,46 @@ mod tests { } #[test] - fn dashboard_project_url_prod_only() { + fn memory_web_origin_rejects_cleartext_and_lookalike_hosts() { + assert!(memory_web_origin("http://api.atomicstrata.ai").is_none()); + assert!(memory_web_origin("https://api.atomicstrata.ai:8443").is_none()); + assert!(memory_web_origin("https://api.atomicstrata.ai.evil.test").is_none()); + assert!(memory_web_origin("https://api.atomicstrata.ai@evil.test").is_none()); + assert!(memory_web_origin("https://custom.example.com").is_none()); + } + + #[test] + fn memory_web_origin_accepts_sanctioned_hosts() { + let prod = memory_web_origin("https://api.atomicstrata.ai").unwrap(); + assert_eq!(prod, "https://memory.atomicstrata.ai/"); + + let dev = memory_web_origin("https://api.dev.atomicstrata.ai").unwrap(); + assert_eq!(dev, "https://memory.dev.atomicstrata.ai/"); + } + + #[test] + fn dashboard_project_url_maps_api_to_memory_host() { let prod = dashboard_project_url("https://api.atomicstrata.ai", "proj_1").unwrap(); assert!(prod.contains("memory.atomicstrata.ai")); assert!(prod.contains("/app/projects/proj_1/overview")); - assert!(dashboard_project_url("https://api.staging.example.com", "proj_1").is_none()); + let dev = dashboard_project_url("https://api.dev.atomicstrata.ai", "proj_2").unwrap(); + assert!(dev.contains("memory.dev.atomicstrata.ai")); + + assert!(dashboard_project_url("https://custom.example.com", "proj_1").is_none()); + } + + #[test] + fn dashboard_projects_url_maps_api_to_memory_host() { + let url = dashboard_projects_url("https://api.dev.atomicstrata.ai").unwrap(); + assert!(url.contains("memory.dev.atomicstrata.ai/app/projects")); + assert!(!url.contains("/onboarding")); + } + + #[test] + fn dashboard_onboarding_url_maps_api_to_memory_host() { + let url = dashboard_onboarding_url("https://api.dev.atomicstrata.ai").unwrap(); + assert!(url.contains("memory.dev.atomicstrata.ai/app/onboarding")); } #[test] diff --git a/crates/cli/src/instance/docker.rs b/crates/cli/src/instance/docker.rs index 92df907..125c425 100644 --- a/crates/cli/src/instance/docker.rs +++ b/crates/cli/src/instance/docker.rs @@ -4,6 +4,8 @@ use std::collections::HashMap; use std::fmt::Debug; use std::process::Stdio; +use std::io::{self, IsTerminal, Write as _}; + use anyhow::{Context, Result, bail}; use serde::{Deserialize, Serialize}; use tokio::process::Command; @@ -281,6 +283,26 @@ pub async fn ensure_docker_available(docker: &dyn DockerRunner) -> Result<()> { docker.version().await } +/// Soft preflight: surface install links and optional retry before hard-failing. +pub async fn ensure_docker_available_with_preflight( + docker: &dyn DockerRunner, + interactive: bool, +) -> Result<()> { + if docker.version().await.is_ok() { + return Ok(()); + } + eprintln!("\n{}\n", docker_install_links()); + if interactive && io::stdin().is_terminal() { + eprint!("Press Enter after Docker is installed and running (Ctrl+C to abort)… "); + io::stderr().flush().ok(); + let mut line = String::new(); + io::stdin() + .read_line(&mut line) + .context("read docker preflight confirmation")?; + } + ensure_docker_available(docker).await +} + #[async_trait::async_trait] pub trait DockerRunner: Send + Sync { async fn version(&self) -> Result<()>; diff --git a/crates/cli/src/instance/mod.rs b/crates/cli/src/instance/mod.rs index 9f07fd2..3580cd2 100644 --- a/crates/cli/src/instance/mod.rs +++ b/crates/cli/src/instance/mod.rs @@ -35,7 +35,7 @@ pub const HEALTH_POLL_INTERVAL_SECS: u64 = 2; /// Max stderr/log lines surfaced on failure. pub const MAX_FAILURE_LOG_LINES: usize = 20; -/// Default API key name when auto-provisioning. +/// Base API key name when auto-provisioning a per-installation credential. pub const AUTO_KEY_NAME: &str = "connected-local-runtime"; /// Path inside Core containers where the entrypoint persists `CORE_API_KEY`. @@ -78,6 +78,11 @@ pub fn managed_core_profile_mismatch(inspect: &ContainerInspect, profile_name: & inspect.managed_by_cli && inspect.profile_label.as_deref() != Some(profile_name) } +/// Normalize Cloud API / JWKS endpoint strings the same way container env is written. +fn canonical_cloud_endpoint_url(url: &str) -> String { + url.trim().trim_end_matches('/').to_string() +} + /// True when the running container still points Core at a different Cloud tier than the profile. pub fn managed_core_cloud_env_mismatch( inspect: &ContainerInspect, @@ -87,12 +92,15 @@ pub fn managed_core_cloud_env_mismatch( if !inspect.managed_by_cli { return false; } + let expected_api = canonical_cloud_endpoint_url(expected_api_url); + let expected_jwks = canonical_cloud_endpoint_url(expected_jwks_url); match ( inspect.atomicmemory_api_url.as_deref(), inspect.cloud_jwks_url.as_deref(), ) { (Some(api_url), Some(jwks_url)) => { - api_url != expected_api_url || jwks_url != expected_jwks_url + canonical_cloud_endpoint_url(api_url) != expected_api + || canonical_cloud_endpoint_url(jwks_url) != expected_jwks } _ => true, } @@ -217,6 +225,16 @@ mod tests { )); } + #[test] + fn cloud_env_matches_when_only_trailing_slash_differs() { + let inspect = inspect_with_profile(Some("default")); + assert!(!managed_core_cloud_env_mismatch( + &inspect, + "https://api.dev.example.com/", + "https://api.dev.example.com/.well-known/atomic-core/jwks.json/", + )); + } + #[test] fn cloud_env_matches_when_urls_align() { let inspect = inspect_with_profile(Some("default")); diff --git a/crates/cli/src/progress.rs b/crates/cli/src/progress.rs index 2e29652..943172e 100644 --- a/crates/cli/src/progress.rs +++ b/crates/cli/src/progress.rs @@ -48,6 +48,22 @@ pub trait ProgressReporter: Send { fn finish(&mut self); } +/// Pause animated progress around an interactive stdin boundary; always resume. +pub fn with_progress_paused_for_input( + progress: &mut dyn ProgressReporter, + should_pause: bool, + op: impl FnOnce() -> T, +) -> T { + if should_pause { + progress.pause_for_input(); + } + let result = op(); + if should_pause { + progress.resume_after_input(); + } + result +} + struct Silent; impl ProgressReporter for Silent { @@ -354,4 +370,33 @@ mod tests { assert!(!p.input_paused(), "double-resume must remain unpaused"); p.succeed("runtime", Some("healthy")); } + + struct RecordingReporter { + input_events: Vec<&'static str>, + } + + impl ProgressReporter for RecordingReporter { + fn start_step(&mut self, _id: &str, _label: &str) {} + fn succeed(&mut self, _id: &str, _detail: Option<&str>) {} + fn warn(&mut self, _id: &str, _detail: Option<&str>) {} + fn fail(&mut self, _id: &str, _detail: Option<&str>) {} + fn finish(&mut self) {} + fn pause_for_input(&mut self) { + self.input_events.push("pause"); + } + fn resume_after_input(&mut self) { + self.input_events.push("resume"); + } + } + + #[test] + fn with_progress_paused_for_input_resumes_on_error() { + let mut reporter = RecordingReporter { + input_events: Vec::new(), + }; + let result: Result<(), &str> = + with_progress_paused_for_input(&mut reporter, true, || Err("prompt failed")); + assert!(result.is_err()); + assert_eq!(reporter.input_events, vec!["pause", "resume"]); + } } diff --git a/crates/cli/src/telemetry.rs b/crates/cli/src/telemetry.rs index 93b38c3..d8944a3 100644 --- a/crates/cli/src/telemetry.rs +++ b/crates/cli/src/telemetry.rs @@ -29,6 +29,8 @@ pub enum ActivationEvent { FirstRetrievalCompleted, FirstRealMemoryCreated, InitStepFailed, + HostedCloudHandoff, + HostedCloudConfigured, } impl ActivationEvent { @@ -44,6 +46,8 @@ impl ActivationEvent { Self::FirstRetrievalCompleted => "first_retrieval_completed", Self::FirstRealMemoryCreated => "first_real_memory_created", Self::InitStepFailed => "init_step_failed", + Self::HostedCloudHandoff => "hosted_cloud_handoff", + Self::HostedCloudConfigured => "hosted_cloud_configured", } } } @@ -91,6 +95,13 @@ impl ActivationContext { } } + pub fn cloud() -> Self { + Self { + mode: Some("cloud"), + ..Default::default() + } + } + pub fn props(&self) -> serde_json::Map { context_props( self.org_id.as_deref(), @@ -337,6 +348,22 @@ mod tests { assert_eq!(ActivationEvent::InitStepFailed.as_str(), "init_step_failed"); } + #[test] + fn hosted_cloud_handoff_event_name_is_stable() { + assert_eq!( + ActivationEvent::HostedCloudHandoff.as_str(), + "hosted_cloud_handoff" + ); + } + + #[test] + fn hosted_cloud_configured_event_name_is_stable() { + assert_eq!( + ActivationEvent::HostedCloudConfigured.as_str(), + "hosted_cloud_configured" + ); + } + #[test] fn is_smoke_scope_detects_smoke_constants() { assert!(is_smoke_scope("am-cli-smoke")); diff --git a/crates/cli/src/validation/openai.rs b/crates/cli/src/validation/openai.rs index e65f09b..578447b 100644 --- a/crates/cli/src/validation/openai.rs +++ b/crates/cli/src/validation/openai.rs @@ -5,7 +5,7 @@ use std::time::Duration; use anyhow::{Context, Result, bail}; pub const OPENAI_MODELS_URL: &str = "https://api.openai.com/v1/models"; -pub const CONNECTED_LOCAL_DOCS: &str = "https://docs.atomicstrata.ai/cloud"; +pub const CONNECTED_LOCAL_DOCS: &str = "https://docs.atomicstrata.ai/cloud/troubleshooting"; /// Lightweight OpenAI auth probe (`GET /v1/models`). pub async fn validate_openai_api_key(key: &str) -> Result<()> { diff --git a/crates/cli/src/validation/recovery.rs b/crates/cli/src/validation/recovery.rs index b06ad5b..fae413a 100644 --- a/crates/cli/src/validation/recovery.rs +++ b/crates/cli/src/validation/recovery.rs @@ -3,12 +3,16 @@ use anyhow::Error; use super::openai::CONNECTED_LOCAL_DOCS; +use crate::config::ProfileKind; + +const HOSTED_CLOUD_DOCS: &str = "https://docs.atomicstrata.ai/cloud/troubleshooting"; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ErrorClass { BadGateway, Auth, Timeout, + UpstreamProvider, OpenAi, /// Core refused raw/unstamped content (`RAW_CONTENT_POLICY=reject`). RawContent, @@ -21,6 +25,7 @@ impl ErrorClass { Self::BadGateway => "bad_gateway", Self::Auth => "auth", Self::Timeout => "timeout", + Self::UpstreamProvider => "upstream_provider", Self::OpenAi => "openai", Self::RawContent => "raw_content", Self::Other => "other", @@ -29,43 +34,19 @@ impl ErrorClass { } /// Attach recovery steps to memory pipeline / ingest failures. -pub fn with_operation_recovery(err: Error, operation: &str) -> Error { +pub fn with_operation_recovery(err: Error, operation: &str, kind: ProfileKind) -> Error { let base = err.to_string(); let class = classify_error(&base); - let hint = match class { - ErrorClass::BadGateway => { - "Likely Core is starting or unreachable — run `am instance start`, wait for health, then `am connect doctor`." - } - ErrorClass::Auth => { - "Authentication failed — check OPENAI_API_KEY and local client key via `am instance status --show-secrets`." - } - ErrorClass::Timeout => { - "Request timed out — Core may still be starting; retry in ~30s or run `am connect doctor`." - } - ErrorClass::OpenAi => { - "OpenAI upstream error — verify OPENAI_API_KEY and re-run `am instance start --openai-api-key sk-...`." - } - ErrorClass::RawContent => { - "This deployment refuses raw or unstamped content (Core `RAW_CONTENT_POLICY=reject`, the default).\n\ - Re-run with `--content-class summary` or `--content-class redacted` to declare what the stored text is.\n\ - `--mode verbatim` with no content class, or `--content-class raw`, is refused unless the operator sets `RAW_CONTENT_POLICY=allow`." - } - ErrorClass::Other => "", - }; + let hint = recovery_hint(class, kind); let mut message = format!("{operation} failed: {base}"); if !hint.is_empty() { message.push_str("\n\n"); message.push_str(hint); } - // A policy refusal is not a connectivity failure: Core answered correctly, - // so the generic "is Core up?" playbook would send the user down the wrong - // path. - if class != ErrorClass::RawContent { - message.push_str( - "\n\nRecovery:\n 1. `am connect doctor`\n 2. `am doctor --smoke`\n 3. Docs: ", - ); - message.push_str(CONNECTED_LOCAL_DOCS); + if let Some(steps) = recovery_steps(class, kind) { + message.push_str("\n\n"); + message.push_str(&steps); } anyhow::anyhow!(message) @@ -73,13 +54,23 @@ pub fn with_operation_recovery(err: Error, operation: &str) -> Error { pub fn classify_error(message: &str) -> ErrorClass { let lower = message.to_lowercase(); - // Checked first: this is a deliberate policy refusal, and its payload can - // otherwise be swallowed by the broader substring checks below. if lower.contains("raw_content_rejected") || (lower.contains("content_class") && lower.contains("422")) { return ErrorClass::RawContent; } + // `upstream_provider` covers the codes Core actually emits + // (upstream_provider_auth_failed / _rate_limited / _quota_exceeded / + // _error). Matching only `upstream_error` meant every real provider + // failure fell through to the 502 arm and told the user to retry, which + // never clears a bad provider credential or an exhausted quota. + if lower.contains("upstream_provider") + || lower.contains("upstream_error") + || lower.contains("ai provider") + || lower.contains("configured ai provider") + { + return ErrorClass::UpstreamProvider; + } if lower.contains("502") || lower.contains("bad gateway") || lower.contains("503") @@ -98,12 +89,93 @@ pub fn classify_error(message: &str) -> ErrorClass { if lower.contains("timeout") || lower.contains("timed out") { return ErrorClass::Timeout; } - if lower.contains("openai") || lower.contains("upstream_provider") || lower.contains("sk-") { + if lower.contains("openai") || lower.contains("sk-") { return ErrorClass::OpenAi; } ErrorClass::Other } +fn recovery_hint(class: ErrorClass, kind: ProfileKind) -> &'static str { + match (class, kind) { + (ErrorClass::BadGateway, ProfileKind::Cloud) => { + "Hosted Cloud returned a gateway error. Retry shortly; if it persists, check service \ + status." + } + (ErrorClass::BadGateway, ProfileKind::Local) => { + "Likely Core is starting or unreachable — run `am instance start`, wait for health, \ + then `am connect doctor`." + } + (ErrorClass::Auth, ProfileKind::Cloud) => { + "Authentication failed — verify the active profile's API key (`am key list`). If you \ + exported ATOMICMEMORY_API_KEY, ensure it matches the intended key; init-managed \ + Hosted Cloud profiles ignore stale exports unless ATOMICMEMORY_API_KEY_FORCE=1." + } + (ErrorClass::Auth, ProfileKind::Local) => { + "Authentication failed — check OPENAI_API_KEY and local client key via `am instance \ + status --show-secrets`." + } + (ErrorClass::Timeout, ProfileKind::Cloud) => { + "Request timed out — retry shortly or check Hosted Cloud status." + } + (ErrorClass::Timeout, ProfileKind::Local) => { + "Request timed out — Core may still be starting; retry in ~30s or run `am connect \ + doctor`." + } + (ErrorClass::UpstreamProvider, ProfileKind::Cloud) => { + "Hosted Cloud rejected the AI extraction request. Check the project's AI provider \ + configuration in the dashboard, or distill/redact the input yourself and re-ingest \ + with a content class that matches the transformed text." + } + (ErrorClass::UpstreamProvider, ProfileKind::Local) => { + "Core's upstream AI provider rejected the request. Check OPENAI_API_KEY and Core logs, \ + or distill/redact the input yourself and re-ingest with a content class that matches \ + the transformed text." + } + (ErrorClass::OpenAi, ProfileKind::Cloud) => { + "Hosted Cloud text extraction uses the project's configured AI provider, not your \ + local OPENAI_API_KEY. Check provider settings in the dashboard." + } + (ErrorClass::OpenAi, ProfileKind::Local) => { + "OpenAI upstream error — verify OPENAI_API_KEY and re-run `am instance start \ + --openai-api-key sk-...`." + } + (ErrorClass::RawContent, _) => { + "This deployment refuses raw or unstamped content (Core `RAW_CONTENT_POLICY=reject`, \ + the default).\nRe-run with `--content-class summary` or `--content-class redacted` to \ + declare what the stored text is.\n`--mode verbatim` with no content class, or \ + `--content-class raw`, is refused unless the operator sets `RAW_CONTENT_POLICY=allow`." + } + (ErrorClass::Other, _) => "", + } +} + +fn recovery_steps(class: ErrorClass, kind: ProfileKind) -> Option { + if class == ErrorClass::RawContent || class == ErrorClass::Other { + return None; + } + Some(match (class, kind) { + (ErrorClass::Auth, ProfileKind::Cloud) => { + format!( + "Recovery:\n 1. `am key list`\n 2. `am init --project `\n 3. Docs: {HOSTED_CLOUD_DOCS}" + ) + } + (ErrorClass::UpstreamProvider | ErrorClass::OpenAi, ProfileKind::Cloud) => { + format!( + "Recovery:\n 1. Check the project's AI provider in the dashboard\n 2. Docs: {HOSTED_CLOUD_DOCS}" + ) + } + (ErrorClass::BadGateway | ErrorClass::Timeout, ProfileKind::Cloud) => { + format!( + "Recovery:\n 1. Retry shortly\n 2. Check Hosted Cloud status\n 3. Docs: {HOSTED_CLOUD_DOCS}" + ) + } + (_, ProfileKind::Local) => format!( + "Recovery:\n 1. `am connect doctor`\n 2. `am doctor --smoke`\n 3. Docs: {CONNECTED_LOCAL_DOCS}" + ), + (_, ProfileKind::Cloud) => None?, + }) +} + #[cfg(test)] mod tests { use super::*; @@ -112,6 +184,7 @@ mod tests { fn error_class_strings_are_stable() { assert_eq!(ErrorClass::BadGateway.as_str(), "bad_gateway"); assert_eq!(ErrorClass::Auth.as_str(), "auth"); + assert_eq!(ErrorClass::UpstreamProvider.as_str(), "upstream_provider"); } #[test] @@ -128,12 +201,117 @@ mod tests { } #[test] - fn recovery_includes_doctor_commands() { - let err = with_operation_recovery(anyhow::anyhow!("core http 502"), "Memory smoke ingest"); + fn core_upstream_provider_codes_are_classified_as_provider_failures() { + // The literal error_code values in packages/core/src/schemas/errors.ts. + // The previous matcher looked for "upstream_error", which none of them + // contain, so all four were misrouted — and with the 502 that Core + // sends alongside them, every one became a "retry shortly" gateway + // hint. + for code in [ + "upstream_provider_auth_failed", + "upstream_provider_rate_limited", + "upstream_provider_quota_exceeded", + "upstream_provider_error", + ] { + assert_eq!( + classify_error(code), + ErrorClass::UpstreamProvider, + "bare {code}" + ); + assert_eq!( + classify_error(&format!("HTTP 502: {code}")), + ErrorClass::UpstreamProvider, + "502 {code}" + ); + } + // A genuine gateway failure with no provider code still reads as one. + assert_eq!( + classify_error("HTTP 502 Bad Gateway"), + ErrorClass::BadGateway + ); + } + + #[test] + fn upstream_error_is_classified_before_bad_gateway() { + assert_eq!( + classify_error("upstream_error: configured AI provider rejected request"), + ErrorClass::UpstreamProvider + ); + } + + #[test] + fn local_recovery_includes_doctor_commands() { + let err = with_operation_recovery( + anyhow::anyhow!("core http 502"), + "Memory smoke ingest", + ProfileKind::Local, + ); let msg = err.to_string(); assert!(msg.contains("am connect doctor")); assert!(msg.contains("am doctor --smoke")); assert!(msg.contains(CONNECTED_LOCAL_DOCS)); assert!(msg.contains("502")); } + + #[test] + fn cloud_auth_recovery_omits_openai_and_instance_start() { + let err = with_operation_recovery( + anyhow::anyhow!("http 401 unauthorized"), + "Memory ingest", + ProfileKind::Cloud, + ); + let msg = err.to_string(); + assert!(msg.contains("ATOMICMEMORY_API_KEY_FORCE")); + assert!(!msg.contains("OPENAI_API_KEY")); + assert!(!msg.contains("am instance start")); + assert!(msg.contains("am key list")); + } + + #[test] + fn cloud_upstream_recovery_does_not_suggest_false_summary_stamp() { + let err = with_operation_recovery( + anyhow::anyhow!("upstream_error: provider rejected"), + "Memory ingest", + ProfileKind::Cloud, + ); + let msg = err.to_string(); + assert!(!msg.contains("--mode verbatim --content-class summary")); + assert!(!msg.contains("am init --project")); + assert!(msg.contains("dashboard")); + } + + #[test] + fn cloud_upstream_recovery_steps_point_at_provider_not_key_init() { + let err = with_operation_recovery( + anyhow::anyhow!("upstream_error: provider rejected"), + "Memory ingest", + ProfileKind::Cloud, + ); + let msg = err.to_string(); + assert!(msg.contains("AI provider")); + assert!(!msg.contains("am key list")); + assert!(!msg.contains("am init --project")); + } + + #[test] + fn local_bad_gateway_recovery_mentions_instance_start() { + let err = with_operation_recovery( + anyhow::anyhow!("core http 502"), + "Memory smoke", + ProfileKind::Local, + ); + assert!(err.to_string().contains("am instance start")); + } + + #[test] + fn smoke_recovery_uses_passed_local_kind_without_cloud_init_steps() { + let err = with_operation_recovery( + anyhow::anyhow!("http 401 unauthorized"), + "Memory smoke", + ProfileKind::Local, + ); + let msg = err.to_string(); + assert!(!msg.contains("am init --project")); + assert!(msg.contains("am instance")); + } } diff --git a/crates/cli/src/verification/receipt.rs b/crates/cli/src/verification/receipt.rs index 94f638a..76e5745 100644 --- a/crates/cli/src/verification/receipt.rs +++ b/crates/cli/src/verification/receipt.rs @@ -160,7 +160,7 @@ mod tests { project_name: "local", project_id: "proj_1", local_url: "http://127.0.0.1:17350", - api_base_url: "https://api.staging.example.com", + api_base_url: "https://custom.example.com", core_healthy: true, no_instance: false, cloud_connection_online: true, diff --git a/crates/cli/src/verification/smoke.rs b/crates/cli/src/verification/smoke.rs index 6440f6c..b1c1ed7 100644 --- a/crates/cli/src/verification/smoke.rs +++ b/crates/cli/src/verification/smoke.rs @@ -2,12 +2,13 @@ use std::time::Duration; +use am_cloud_client::MemoryClient; use am_core_types::{CoreIngestRequest, CoreMemoryQuery, CoreSearchRequest}; use anyhow::{Context, Result, bail}; use serde::Serialize; use crate::cli::GlobalOptions; -use crate::commands::client::memory_client; +use crate::commands::client::{memory_client_for_profile, resolve_ctx}; use crate::telemetry::{ActivationEvent, capture_activation}; use crate::validation::with_operation_recovery; @@ -63,20 +64,27 @@ pub async fn run_memory_smoke( opts: SmokeOptions, telemetry: Option, ) -> Result { - run_memory_smoke_inner(global, opts, telemetry) + let profile = resolve_ctx(global) .await - .map_err(|err| with_operation_recovery(err, "Memory smoke")) + .context("resolve profile for smoke test")?; + // Building the client is where a missing or withheld credential surfaces. + // Returning that straight through `?` gave it only generic context, so the + // profile-aware playbook this function installs below never applied to the + // most likely failure. + let client = memory_client_for_profile(&profile) + .await + .map_err(|err| with_operation_recovery(err, "Memory smoke client", profile.kind))?; + run_memory_smoke_with_client(client, opts, telemetry) + .await + .map_err(|err| with_operation_recovery(err, "Memory smoke", profile.kind)) } -async fn run_memory_smoke_inner( - global: &GlobalOptions, +async fn run_memory_smoke_with_client( + client: MemoryClient, opts: SmokeOptions, telemetry: Option, ) -> Result { let marker = format!("am-cli-smoke-{}", uuid_like_marker()); - let (_profile, client) = memory_client(global) - .await - .context("memory client for smoke test")?; let ingest_req = smoke_ingest_request(&marker); @@ -201,6 +209,7 @@ fn uuid_like_marker() -> String { #[cfg(test)] mod tests { use super::*; + use crate::config::ProfileKind; #[test] fn smoke_constants_are_stable() { @@ -215,4 +224,38 @@ mod tests { assert_eq!(req.content_class.as_deref(), Some("summary")); assert!(req.conversation.contains("marker-abc")); } + + #[test] + fn smoke_wires_recovery_into_client_construction() { + // The formatter test below passes even if run_memory_smoke never calls + // it. Pin the wiring: the client-construction path must carry recovery + // text, which is where a missing credential actually fails. + let src = include_str!("smoke.rs"); + let body = src + .split("pub async fn run_memory_smoke(") + .nth(1) + .expect("run_memory_smoke present"); + let body = &body[..body.find("\nasync fn ").unwrap_or(body.len())]; + assert!( + body.contains("memory_client_for_profile"), + "client must be built from the resolved profile" + ); + assert_eq!( + body.matches("with_operation_recovery").count(), + 2, + "both client construction and the smoke run must install recovery" + ); + } + + #[test] + fn smoke_recovery_uses_client_profile_kind_without_re_resolve() { + let err = with_operation_recovery( + anyhow::anyhow!("http 401 unauthorized"), + "Memory smoke", + ProfileKind::Local, + ); + let msg = err.to_string(); + assert!(!msg.contains("am init --project")); + assert!(msg.contains("am instance")); + } } diff --git a/crates/cloud-types/src/onboarding.rs b/crates/cloud-types/src/onboarding.rs index d3f9448..9b17976 100644 --- a/crates/cloud-types/src/onboarding.rs +++ b/crates/cloud-types/src/onboarding.rs @@ -9,7 +9,7 @@ use crate::projects::Project; #[derive(Debug, Clone, Default, Serialize, Deserialize, ToSchema)] pub struct EnsureOnboardingRequest { /// When true, ensure org membership but do not auto-create the default cloud project. - /// Used by `am init`, which creates a local project instead. + /// Used by `am init`, which selects an existing project or waits for browser onboarding. #[serde(default)] pub skip_default_project: bool, } diff --git a/package.json b/package.json index c182ce9..d59446e 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,8 @@ "ci:public-smoke": "turbo run public-integration-smoke", "ci:contract-conformance": "node scripts/check-ingest-contract-conformance.mjs", "validate:cli": "pnpm --filter @atomicmemory/cli build && node packages/cli/dist/bin.js validate", - "ci:rust": "cargo fmt --all -- --check && cargo clippy --workspace --all-targets --all-features --locked -- -D warnings && cargo test --workspace --locked && cargo run -p atomicmemory --release --locked --bin am -- --help" + "ci:rust": "cargo fmt --all -- --check && cargo clippy --workspace --all-targets --all-features --locked -- -D warnings && cargo test --workspace --locked && cargo run -p atomicmemory --release --locked --bin am -- --help", + "test:mirror-cli-r2": "bash scripts/__tests__/mirror-cli-r2.test.sh" }, "devDependencies": { "@typescript-eslint/parser": "^8.59.3", diff --git a/packages/core/README.md b/packages/core/README.md index 3918dd6..87cda2d 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -44,7 +44,7 @@ These results put AtomicMemory at or near the published ceiling in each reported ## Quick Start -For the full walkthrough, see the [Core Quickstart](https://docs.atomicstrata.ai/quickstart). +For the full walkthrough, see the [Core Quickstart](https://docs.atomicstrata.ai/open-source/quickstart). ### Docker image (recommended) diff --git a/plugins/claude-code/README.md b/plugins/claude-code/README.md index 28540dd..1900b4d 100644 --- a/plugins/claude-code/README.md +++ b/plugins/claude-code/README.md @@ -212,7 +212,9 @@ Claude Code hook environments are commonly spawned with a thinner PATH than the command -v am ``` -If the command is not found, run `curl -fsSL https://get.atomicstrata.ai/install.sh | sh` and open a new terminal. +If the command is not found, run +`curl --proto '=https' --tlsv1.2 -fsSL https://get.atomicstrata.ai/install.sh | sh` +and open a new terminal. ## License diff --git a/plugins/codex/README.md b/plugins/codex/README.md index 6c0daf1..d5760ee 100644 --- a/plugins/codex/README.md +++ b/plugins/codex/README.md @@ -144,7 +144,9 @@ Codex hook environments are usually spawned with a thinner PATH than the interac command -v am ``` -If the command is not found, run `curl -fsSL https://get.atomicstrata.ai/install.sh | sh` and open a new terminal. +If the command is not found, run +`curl --proto '=https' --tlsv1.2 -fsSL https://get.atomicstrata.ai/install.sh | sh` +and open a new terminal. #### Stop-threshold guidance (`ATOMICMEMORY_STOP_MIN_ASSISTANT_CHARS`) diff --git a/scripts/__tests__/install-cli-attestation-cases.sh b/scripts/__tests__/install-cli-attestation-cases.sh new file mode 100644 index 0000000..a98e6b3 --- /dev/null +++ b/scripts/__tests__/install-cli-attestation-cases.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash +# Attestation-mode contract cases sourced by install-cli.test.sh. + +auto_installer="${FIXTURE_ROOT}/install-cli-auto.sh" +sed "s|^AM_DIST_DEFAULT_BASE_URL=.*|AM_DIST_DEFAULT_BASE_URL=\"$AM_BASE_URL\"|" \ + "$INSTALLER" >"$auto_installer" + +logged_out_gh_dir="${FIXTURE_ROOT}/logged-out-gh-bin" +logged_out_gh_log="${FIXTURE_ROOT}/logged-out-gh.log" +mkdir -p "$logged_out_gh_dir" +cat >"${logged_out_gh_dir}/gh" <<'EOF' +#!/bin/sh +printf '%s\n' "$*" >>"$GH_LOG" +[ "$1 $2" = "auth token" ] && exit 1 +[ "$1 $2" = "attestation verify" ] && exit 73 +exit 0 +EOF +chmod +x "${logged_out_gh_dir}/gh" + +printf '\nCase: automatic attestation skips logged-out GitHub CLI\n' +auto_logged_out_dir="$BIN_DIR/attest-auto-logged-out" +set +e +output="$(PATH="${logged_out_gh_dir}:$PATH" GH_LOG="$logged_out_gh_log" \ + AM_VERIFY_ATTESTATION=auto AM_BASE_URL="$AM_BASE_URL" sh "$auto_installer" \ + --version 0.2.0 --bin-dir "$auto_logged_out_dir" --no-modify-path 2>&1)" +auto_logged_out_status=$? +set -e +[ "$auto_logged_out_status" -eq 0 ] \ + && assert "auto mode continues when gh is logged out" true \ + || assert "auto mode continues when gh is logged out" false +case "$output" in + *'GitHub CLI is not authenticated; continuing with checksum verification only'*) + assert "auto mode explains checksum-only verification" true + ;; + *) assert "auto mode explains checksum-only verification" false ;; +esac +grep -qx 'auth token' "$logged_out_gh_log" 2>/dev/null \ + && assert "auto mode checks GitHub CLI authentication" true \ + || assert "auto mode checks GitHub CLI authentication" false +if grep -q '^attestation verify' "$logged_out_gh_log" 2>/dev/null; then + assert "auto mode does not attempt attestation while logged out" false +else + assert "auto mode does not attempt attestation while logged out" true +fi +[ -x "$auto_logged_out_dir/am" ] \ + && assert "auto logged-out mode installs checksum-verified binary" true \ + || assert "auto logged-out mode installs checksum-verified binary" false + +printf '\nCase: required attestation fails closed when GitHub CLI is logged out\n' +: >"$logged_out_gh_log" +required_logged_out_dir="$BIN_DIR/attest-required-logged-out" +set +e +output="$(PATH="${logged_out_gh_dir}:$PATH" GH_LOG="$logged_out_gh_log" \ + AM_VERIFY_ATTESTATION=1 AM_BASE_URL="$AM_BASE_URL" sh "$INSTALLER" \ + --version 0.2.0 --bin-dir "$required_logged_out_dir" --no-modify-path 2>&1)" +required_logged_out_status=$? +set -e +[ "$required_logged_out_status" -ne 0 ] \ + && assert "required mode fails when gh is logged out" true \ + || assert "required mode fails when gh is logged out" false +case "$output" in + *'GitHub CLI authentication is required for attestation verification'*) + assert "required mode explains GitHub authentication requirement" true + ;; + *) assert "required mode explains GitHub authentication requirement" false ;; +esac +if grep -q '^attestation verify' "$logged_out_gh_log" 2>/dev/null; then + assert "required mode stops before unauthenticated attestation request" false +else + assert "required mode stops before unauthenticated attestation request" true +fi +[ ! -x "$required_logged_out_dir/am" ] \ + && assert "required logged-out mode does not install binary" true \ + || assert "required logged-out mode does not install binary" false + +printf '\nCase: automatic attestation uses authenticated GitHub CLI\n' +authenticated_gh_dir="${FIXTURE_ROOT}/authenticated-gh-bin" +authenticated_gh_log="${FIXTURE_ROOT}/authenticated-gh.log" +mkdir -p "$authenticated_gh_dir" +cat >"${authenticated_gh_dir}/gh" <<'EOF' +#!/bin/sh +printf '%s\n' "$*" >>"$GH_LOG" +exit 0 +EOF +chmod +x "${authenticated_gh_dir}/gh" +auto_authenticated_dir="$BIN_DIR/attest-auto-authenticated" +if PATH="${authenticated_gh_dir}:$PATH" GH_LOG="$authenticated_gh_log" \ + AM_VERIFY_ATTESTATION=auto AM_BASE_URL="$AM_BASE_URL" sh "$auto_installer" \ + --version 0.2.0 --bin-dir "$auto_authenticated_dir" --no-modify-path >/dev/null; then + case "$(cat "$authenticated_gh_log" 2>/dev/null || true)" in + *'auth token'*'attestation verify'*'--signer-workflow'*'release-cli.yml'*) + assert "auto mode verifies with authenticated gh" true + ;; + *) assert "auto mode verifies with authenticated gh" false ;; + esac + [ -x "$auto_authenticated_dir/am" ] \ + && assert "auto authenticated mode installs attested binary" true \ + || assert "auto authenticated mode installs attested binary" false +else + assert "auto mode verifies with authenticated gh" false + assert "auto authenticated mode installs attested binary" false +fi + +printf '\nCase: forced attestation verification invokes gh before install\n' +: >"$authenticated_gh_log" +attest_dir="$BIN_DIR/attest" +if PATH="${authenticated_gh_dir}:$PATH" GH_LOG="$authenticated_gh_log" \ + AM_VERIFY_ATTESTATION=1 AM_BASE_URL="$AM_BASE_URL" sh "$INSTALLER" \ + --version 0.2.0 --bin-dir "$attest_dir" --no-modify-path >/dev/null; then + case "$(cat "$authenticated_gh_log" 2>/dev/null || true)" in + *'auth token'*'attestation verify'*'--signer-workflow'*'release-cli.yml'*) + assert "gh attestation verify is invoked for forced verification" true + ;; + *) assert "gh attestation verify is invoked for forced verification" false ;; + esac + [ -x "$attest_dir/am" ] && assert "attested install writes am binary" true \ + || assert "attested install writes am binary" false +else + assert "gh attestation verify is invoked for forced verification" false + assert "attested install writes am binary" false +fi diff --git a/scripts/__tests__/install-cli-internal.test.sh b/scripts/__tests__/install-cli-internal.test.sh index 48a301b..09bed20 100755 --- a/scripts/__tests__/install-cli-internal.test.sh +++ b/scripts/__tests__/install-cli-internal.test.sh @@ -129,6 +129,16 @@ detect_target() { main() { printf 'install-cli-internal tests\n' FIXTURE_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/am-internal-test.XXXXXX")" + local test_home="${FIXTURE_ROOT}/home" + mkdir -p "$test_home" + HOME="$test_home" + PATH="/usr/bin:/bin:/usr/sbin:/sbin" + SHELL="/bin/sh" + XDG_CONFIG_HOME="${test_home}/.config" + ZDOTDIR="$test_home" + export HOME PATH SHELL XDG_CONFIG_HOME ZDOTDIR + unset AM_INSTALL_DIR AM_VERSION AM_NO_MODIFY_PATH AM_ENVIRONMENT AM_CORE_IMAGE + unset AM_VERIFY_ATTESTATION AM_FORCE local ver="0.2.0" local target target="$(detect_target)" || { diff --git a/scripts/__tests__/install-cli-path-cases.sh b/scripts/__tests__/install-cli-path-cases.sh new file mode 100644 index 0000000..35ced76 --- /dev/null +++ b/scripts/__tests__/install-cli-path-cases.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash +# Cross-shell PATH persistence cases sourced by install-cli.test.sh. + +assert_managed_source() { + local name="$1" + local file="$2" + local source_line="$3" + if [ -f "$file" ] && grep -qF "$source_line" "$file"; then + assert "$name" true + else + assert "$name" false + fi +} + +assert_single_marker() { + local name="$1" + local file="$2" + local count + count="$(grep -cF '# >>> atomicmemory >>>' "$file" 2>/dev/null || true)" + [ "$count" = "1" ] && assert "$name" true || assert "$name" false +} + +printf '\nCase: activation makes the installed am win PATH shadowing\n' +shadow_home="${FIXTURE_ROOT}/home-shadow" +shadow_foreign_bin="${BIN_DIR}/shadow-foreign" +shadow_atomic_bin="${BIN_DIR}/shadow-atomic" +mkdir -p "$shadow_home" "$shadow_foreign_bin" +cat >"${shadow_foreign_bin}/am" <<'EOF' +#!/bin/sh +printf 'foreign-am\n' +EOF +chmod +x "${shadow_foreign_bin}/am" +HOME="$shadow_home" SHELL=/bin/bash \ + PATH="${shadow_foreign_bin}:${shadow_atomic_bin}:/usr/bin:/bin" \ + run_install --version 0.2.0 --bin-dir "$shadow_atomic_bin" >/dev/null +shadow_env="${shadow_home}/.atomicmemory/env" +shadow_path="${shadow_foreign_bin}:${shadow_atomic_bin}:/usr/bin:${shadow_atomic_bin}:/bin" +resolved_am="$(PATH="$shadow_path" sh -c '. "$1"; command -v am' sh "$shadow_env")" +[ "$resolved_am" = "${shadow_atomic_bin}/am" ] \ + && assert "activation makes the installed am win" true \ + || assert "activation makes the installed am win" false +activated_path="$( + PATH="$shadow_path" sh -c '. "$1"; . "$1"; printf '\''%s'\'' "$PATH"' sh "$shadow_env" +)" +expected_path="${shadow_atomic_bin}:${shadow_foreign_bin}:/usr/bin:/bin" +[ "$activated_path" = "$expected_path" ] \ + && assert "repeated activation keeps one leading install directory" true \ + || assert "repeated activation keeps one leading install directory" false + +printf '\nCase: bash configures interactive and login shells idempotently\n' +bash_home="${FIXTURE_ROOT}/home-bash" +bash_bin="${BIN_DIR}/shell-bash" +mkdir -p "$bash_home" +: >"$bash_home/.bash_profile" +HOME="$bash_home" ZDOTDIR="$bash_home" SHELL=/bin/bash \ + run_install --version 0.2.0 --bin-dir "$bash_bin" >/dev/null +HOME="$bash_home" ZDOTDIR="$bash_home" SHELL=/bin/bash \ + run_install --version 0.2.0 --bin-dir "$bash_bin" >/dev/null +source_line=". \"${bash_home}/.atomicmemory/env\"" +assert_managed_source "bash configures .bashrc" "$bash_home/.bashrc" "$source_line" +assert_managed_source "bash configures active login profile" "$bash_home/.bash_profile" "$source_line" +assert_single_marker "bash .bashrc entry is idempotent" "$bash_home/.bashrc" +assert_single_marker "bash login entry is idempotent" "$bash_home/.bash_profile" + +printf '\nCase: zsh honors ZDOTDIR\n' +zsh_home="${FIXTURE_ROOT}/home-zsh" +zsh_dotdir="${zsh_home}/zdot" +mkdir -p "$zsh_dotdir" +HOME="$zsh_home" ZDOTDIR="$zsh_dotdir" SHELL=/bin/zsh \ + run_install --version 0.2.0 --bin-dir "$BIN_DIR/shell-zsh" >/dev/null +assert_managed_source "zsh configures ZDOTDIR .zshrc" "$zsh_dotdir/.zshrc" \ + ". \"${zsh_home}/.atomicmemory/env\"" + +printf '\nCase: fish uses conf.d and fish activation syntax\n' +fish_home="${FIXTURE_ROOT}/home-fish" +fish_xdg="${fish_home}/xdg" +fish_conf="${fish_xdg}/fish/conf.d/atomicmemory.fish" +mkdir -p "$fish_xdg/fish" +printf 'set -gx EDITOR vim\n# >>> atomicmemory >>>\nsource old-env.fish\n# <<< atomicmemory <<<\n' \ + >"$fish_xdg/fish/config.fish" +HOME="$fish_home" XDG_CONFIG_HOME="$fish_xdg" ZDOTDIR="$fish_home" SHELL=/usr/bin/fish \ + run_install --version 0.2.0 --bin-dir "$BIN_DIR/shell-fish" >/dev/null +assert_managed_source "fish configures a dedicated conf.d file" "$fish_conf" \ + "source \"${fish_home}/.atomicmemory/env.fish\"" +[ "$(grep -cF '# >>> atomicmemory >>>' "$fish_xdg/fish/config.fish")" = "0" ] \ + && assert "fish migrates the legacy config.fish entry" true \ + || assert "fish migrates the legacy config.fish entry" false +no_modify_home="${FIXTURE_ROOT}/home-fish-no-modify" +no_modify_xdg="${no_modify_home}/xdg" +output="$(HOME="$no_modify_home" XDG_CONFIG_HOME="$no_modify_xdg" \ + ZDOTDIR="$no_modify_home" SHELL=/usr/bin/fish \ + run_install --version 0.2.0 --bin-dir "$BIN_DIR/fish-no-modify" --no-modify-path)" +case "$output" in + *"source \"${no_modify_home}/.atomicmemory/env.fish\""*) + assert "fish no-modify prints fish activation" true ;; + *) assert "fish no-modify prints fish activation" false ;; +esac +[ ! -e "$no_modify_xdg/fish/conf.d/atomicmemory.fish" ] \ + && assert "fish no-modify leaves startup files unchanged" true \ + || assert "fish no-modify leaves startup files unchanged" false + +printf '\nCase: generic POSIX shell configures .profile\n' +posix_home="${FIXTURE_ROOT}/home-posix" +HOME="$posix_home" ZDOTDIR="$posix_home" SHELL=/bin/dash \ + run_install --version 0.2.0 --bin-dir "$BIN_DIR/shell-posix" >/dev/null +assert_managed_source "POSIX shell configures .profile" "$posix_home/.profile" \ + ". \"${posix_home}/.atomicmemory/env\"" + +printf '\nCase: uninstall removes every managed shell entry\n' +HOME="$bash_home" ZDOTDIR="$bash_home" SHELL=/bin/bash AM_INSTALL_DIR="$bash_bin" \ + sh "$INSTALLER" --uninstall >/dev/null +[ "$(grep -cF '# >>> atomicmemory >>>' "$bash_home/.bashrc" 2>/dev/null || true)" = "0" ] \ + && assert "uninstall cleans bash .bashrc" true || assert "uninstall cleans bash .bashrc" false +HOME="$fish_home" XDG_CONFIG_HOME="$fish_xdg" ZDOTDIR="$fish_home" SHELL=/usr/bin/fish \ + AM_INSTALL_DIR="$BIN_DIR/shell-fish" sh "$INSTALLER" --uninstall >/dev/null +[ ! -e "$fish_conf" ] && assert "uninstall removes fish conf.d entry" true \ + || assert "uninstall removes fish conf.d entry" false +HOME="$posix_home" ZDOTDIR="$posix_home" SHELL=/bin/dash \ + AM_INSTALL_DIR="$BIN_DIR/shell-posix" sh "$INSTALLER" --uninstall >/dev/null +[ "$(grep -cF '# >>> atomicmemory >>>' "$posix_home/.profile" 2>/dev/null || true)" = "0" ] \ + && assert "uninstall cleans POSIX profile" true || assert "uninstall cleans POSIX profile" false diff --git a/scripts/__tests__/install-cli.test.sh b/scripts/__tests__/install-cli.test.sh index b6dded1..4b71e3a 100755 --- a/scripts/__tests__/install-cli.test.sh +++ b/scripts/__tests__/install-cli.test.sh @@ -57,7 +57,13 @@ case "\$1" in esac exit 0 ;; - init) exit 0 ;; + init) + if [ -n "\${AM_TEST_INIT_STDIN_FILE:-}" ]; then + if [ -t 0 ]; then init_stdin=tty; else init_stdin=not-tty; fi + printf '%s\n' "\$init_stdin" >"\$AM_TEST_INIT_STDIN_FILE" + fi + exit 0 + ;; *) exit 0 ;; esac EOF @@ -153,6 +159,16 @@ printf '\ninstall-cli contract tests\n' TARGET="$(detect_target)" start_fixture_server "0.2.0" "$TARGET" +TEST_HOME="${FIXTURE_ROOT}/home" +mkdir -p "$TEST_HOME" +HOME="$TEST_HOME" +PATH="/usr/bin:/bin:/usr/sbin:/sbin" +SHELL="/bin/sh" +XDG_CONFIG_HOME="${TEST_HOME}/.config" +ZDOTDIR="$TEST_HOME" +export HOME PATH SHELL XDG_CONFIG_HOME ZDOTDIR +unset AM_INSTALL_DIR AM_VERSION AM_NO_MODIFY_PATH AM_ENVIRONMENT AM_CORE_IMAGE +unset AM_VERIFY_ATTESTATION AM_FORCE BIN_DIR="${FIXTURE_ROOT}/bin" mkdir -p "$BIN_DIR" @@ -184,6 +200,32 @@ esac [ ! -x "$BIN_DIR/reject/am" ] && assert "invalid version does not install binary" true \ || assert "invalid version does not install binary" false +assert_unsafe_bin_dir_rejected() { + local label="$1" + local path="$2" + local unsafe_home="${FIXTURE_ROOT}/unsafe-home-${label}" + local output status + mkdir -p "$unsafe_home" + output="$(HOME="$unsafe_home" run_install \ + --version 0.2.0 --bin-dir "$path" 2>&1)" + status=$? + [ "$status" -ne 0 ] && assert "unsafe ${label} bin dir exits nonzero" true \ + || assert "unsafe ${label} bin dir exits nonzero" false + case "$output" in + *"safe absolute path"*) assert "unsafe ${label} bin dir explains path contract" true ;; + *) assert "unsafe ${label} bin dir explains path contract" false ;; + esac + [ ! -e "${unsafe_home}/.atomicmemory/env" ] \ + && assert "unsafe ${label} bin dir writes no sourced env" true \ + || assert "unsafe ${label} bin dir writes no sourced env" false +} + +printf '\nCase: unsafe install directories fail before sourced env generation\n' +assert_unsafe_bin_dir_rejected "quote" "${BIN_DIR}/unsafe\"path" +assert_unsafe_bin_dir_rejected "dollar" "${BIN_DIR}/unsafe\$path" +unsafe_newline="$(printf '%s\n%s' "${BIN_DIR}/unsafe" "path")" +assert_unsafe_bin_dir_rejected "newline" "$unsafe_newline" + printf '\nCase: regex-like version mismatch is rejected\n' bad_stage="${FIXTURE_ROOT}/bad-stage" mkdir -p "$bad_stage" @@ -253,33 +295,8 @@ esac [ ! -x "$BIN_DIR/bad/am" ] && assert "checksum mismatch does not install binary" true \ || assert "checksum mismatch does not install binary" false -printf '\nCase: forced attestation verification invokes gh before install\n' -fake_gh_dir="${FIXTURE_ROOT}/fake-gh-bin" -gh_log="${FIXTURE_ROOT}/gh.log" -mkdir -p "$fake_gh_dir" -cat >"${fake_gh_dir}/gh" <<'EOF' -#!/bin/sh -printf '%s\n' "$*" >>"$GH_LOG" -exit 0 -EOF -chmod +x "${fake_gh_dir}/gh" -attest_dir="$BIN_DIR/attest" -if PATH="${fake_gh_dir}:$PATH" GH_LOG="$gh_log" AM_VERIFY_ATTESTATION=1 AM_BASE_URL="$AM_BASE_URL" \ - sh "$INSTALLER" --version 0.2.0 --bin-dir "$attest_dir" --no-modify-path >/dev/null; then - case "$(cat "$gh_log" 2>/dev/null || true)" in - *'attestation verify'*'--signer-workflow'*'release-cli.yml'*) - assert "gh attestation verify is invoked for forced verification" true - ;; - *) - assert "gh attestation verify is invoked for forced verification" false - ;; - esac - [ -x "$attest_dir/am" ] && assert "attested install writes am binary" true \ - || assert "attested install writes am binary" false -else - assert "gh attestation verify is invoked for forced verification" false - assert "attested install writes am binary" false -fi +# shellcheck source=scripts/__tests__/install-cli-attestation-cases.sh +. "$ROOT/scripts/__tests__/install-cli-attestation-cases.sh" printf '\nCase: uninstall refuses foreign am binary\n' foreign_am_dir="${BIN_DIR}/foreign-am" @@ -337,13 +354,34 @@ HOME="$env_dir" run_install --version 0.2.0 --bin-dir "$BIN_DIR/env-always" --no [ -f "$env_dir/.atomicmemory/env" ] && assert "install writes env file even with --no-modify-path" true \ || assert "install writes env file even with --no-modify-path" false -printf '\nCase: --init runs am init in install subshell\n' -output="$(run_install --version 0.2.0 --bin-dir "$BIN_DIR/init-flag" --no-modify-path --init 2>&1 || true)" +printf '\nCase: --init fails with recovery guidance without a terminal\n' +no_tty_dir="$BIN_DIR/init-no-tty" +set +e +output="$(run_install --version 0.2.0 --bin-dir "$no_tty_dir" --no-modify-path --init /dev/null 2>&1; then + [ "$(cat "$tty_state" 2>/dev/null || true)" = "tty" ] \ + && assert "piped --init gives am init a terminal" true \ + || assert "piped --init gives am init a terminal" false +else + assert "piped --init gives am init a terminal" false +fi + printf '\nCase: requested environment failure fails install\n' set +e output="$(run_install --version 0.2.0 --bin-dir "$BIN_DIR/env-fail" --no-modify-path --env invalid 2>&1)" @@ -368,6 +406,9 @@ case "$output" in *) assert "--core-image failure fails with message" false ;; esac +# shellcheck source=scripts/__tests__/install-cli-path-cases.sh +. "$ROOT/scripts/__tests__/install-cli-path-cases.sh" + printf '\nResults: %s passed, %s failed\n' "$PASS_COUNT" "$FAIL_COUNT" if [ "$FAIL_COUNT" -ne 0 ]; then exit 1 diff --git a/scripts/__tests__/mirror-cli-r2.test.sh b/scripts/__tests__/mirror-cli-r2.test.sh new file mode 100755 index 0000000..ec73800 --- /dev/null +++ b/scripts/__tests__/mirror-cli-r2.test.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +# +# Regression tests for the R2 mirror workflow's release verification. +# +# The 0.2.0 release failed here: the verify step invoked `dist/install.sh` +# directly, but assets downloaded from a GitHub Release do not keep their +# executable bit, so the step exited 126 (Permission denied). Because verify +# runs before promotion, the mirror kept serving the previous version while +# the canonical Release was already published. + +set -uo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +WORKFLOW="$ROOT/.github/workflows/mirror-cli-r2.yml" + +PASS_COUNT=0 +FAIL_COUNT=0 + +assert() { + local name="$1" + local condition="$2" + if [ "$condition" = "true" ]; then + printf ' ✓ %s\n' "$name" + PASS_COUNT=$((PASS_COUNT + 1)) + else + printf ' ✗ %s\n' "$name" >&2 + FAIL_COUNT=$((FAIL_COUNT + 1)) + fi +} + +printf '\nCase: mirror workflow exists\n' +[ -f "$WORKFLOW" ] && assert "mirror-cli-r2.yml is present" true || { assert "mirror-cli-r2.yml is present" false; exit 1; } + +printf '\nCase: downloaded installer is invoked through a shell\n' +# Any bare `dist/install.sh` invocation (start of a command, not preceded by +# `sh `) reintroduces the exec-bit failure. +# Strip comment lines first: prose about the bug legitimately names the bare +# path, and matching it would make this guard fire on its own documentation. +if grep -vE '^[[:space:]]*#' "$WORKFLOW" \ + | grep -E '(^|[^[:alnum:]_/-])dist/install\.sh' \ + | grep -vqE 'sh dist/install\.sh|aws s3 cp dist/install\.sh'; then + assert "verify step does not invoke dist/install.sh directly" false +else + assert "verify step does not invoke dist/install.sh directly" true +fi +grep -q 'sh dist/install\.sh' "$WORKFLOW" \ + && assert "verify step runs the installer via sh" true \ + || assert "verify step runs the installer via sh" false + +printf '\nCase: verification can reach the attestation API\n' +# The installer verifies the GitHub artifact attestation automatically only +# with authenticated `gh`. GH_TOKEN prevents the release gate from taking the +# checksum-only path when it validates the public mirror. +verify_env="$(awk '/- name: Verify pinned install/{f=1} f&&/^ - name:/&&!/Verify pinned install/{f=0} f' "$WORKFLOW")" +printf '%s' "$verify_env" | grep -q 'GH_TOKEN:' \ + && assert "verify step provides GH_TOKEN" true \ + || assert "verify step provides GH_TOKEN" false + +printf '\nCase: promotion stays gated behind verification\n' +# Promotion must come after verify, so a build that cannot be verified never +# becomes the version the mirror advertises. +verify_line="$(grep -n 'Verify pinned install' "$WORKFLOW" | head -1 | cut -d: -f1)" +promote_line="$(grep -n 'Promote install.sh and version.json' "$WORKFLOW" | head -1 | cut -d: -f1)" +if [ -n "$verify_line" ] && [ -n "$promote_line" ] && [ "$promote_line" -gt "$verify_line" ]; then + assert "promote step runs after the verify step" true +else + assert "promote step runs after the verify step" false +fi + +printf '\nResults: %d passed, %d failed\n' "$PASS_COUNT" "$FAIL_COUNT" +[ "$FAIL_COUNT" -eq 0 ] diff --git a/scripts/__tests__/run-installer-in-pty.py b/scripts/__tests__/run-installer-in-pty.py new file mode 100644 index 0000000..8d1a829 --- /dev/null +++ b/scripts/__tests__/run-installer-in-pty.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 +"""Run a piped installer with a controlling terminal for stdin contract tests.""" + +import errno +import os +import pty +import sys + + +def stream_installer(installer: str, target_fd: int) -> None: + with open(installer, "rb") as source: + while chunk := source.read(64 * 1024): + os.write(target_fd, chunk) + + +def run(installer: str, arguments: list[str]) -> int: + child_pid, terminal_fd = pty.fork() + if child_pid == 0: + read_fd, write_fd = os.pipe() + writer_pid = os.fork() + if writer_pid == 0: + os.close(read_fd) + stream_installer(installer, write_fd) + os.close(write_fd) + os._exit(0) + os.close(write_fd) + os.dup2(read_fd, 0) + os.close(read_fd) + os.execvpe("sh", ["sh", "-s", "--", *arguments], os.environ) + + while True: + try: + output = os.read(terminal_fd, 4096) + except OSError as error: + if error.errno == errno.EIO: + break + raise + if not output: + break + os.write(1, output) + _, status = os.waitpid(child_pid, 0) + return os.waitstatus_to_exitcode(status) + + +if __name__ == "__main__": + raise SystemExit(run(sys.argv[1], sys.argv[2:])) diff --git a/scripts/install-cli-internal.sh b/scripts/install-cli-internal.sh index e1caa31..8095e21 100755 --- a/scripts/install-cli-internal.sh +++ b/scripts/install-cli-internal.sh @@ -74,8 +74,8 @@ mv "$version_json" "${TMP}/mirror/version.json" installer="${TMP}/install-cli.sh" [ -f "$installer" ] || err "install-cli.sh missing from release ${AM_INTERNAL_TAG}" -# Public installer defaults attestation to on for get.atomicstrata.ai; this -# channel has no attestations and must never hit the production mirror. +# Public installer automatically verifies authenticated get.atomicstrata.ai +# downloads; this channel has no attestations and must never hit that mirror. export AM_BASE_URL="file://${TMP}/mirror" export AM_VERIFY_ATTESTATION=0 export AM_VERSION diff --git a/scripts/install-cli.sh b/scripts/install-cli.sh index cc47a7c..f9fcd31 100755 --- a/scripts/install-cli.sh +++ b/scripts/install-cli.sh @@ -5,7 +5,7 @@ # (checksums + build provenance attestations). Default downloads use the # mirrored convenience channel at get.atomicstrata.ai (same digests). # -# curl -fsSL https://get.atomicstrata.ai/install.sh | sh +# curl --proto '=https' --tlsv1.2 -fsSL https://get.atomicstrata.ai/install.sh | sh set -eu # --- configuration (override via env for testing) --------------------------- @@ -114,6 +114,22 @@ validate_version_string() { fi } +# Env files are sourced as shell code, so only interpolate a conservative +# absolute-path character set. Do not echo the rejected value: it may contain +# control characters intended to forge installer output. +validate_install_dir() { + dir="$1" + case "$dir" in + /*) ;; + *) err "invalid install directory: expected a safe absolute path using only letters, digits, '.', '_', '-', and '/'" ;; + esac + case "$dir" in + *[!A-Za-z0-9._/-]*) + err "invalid install directory: expected a safe absolute path using only letters, digits, '.', '_', '-', and '/'" + ;; + esac +} + # Fail closed on musl Linux until a dedicated musl artifact exists. reject_musl_linux() { [ "$(uname -s)" = "Linux" ] || return 0 @@ -139,7 +155,7 @@ validate_install_target() { return 0 fi err "refusing to overwrite ${target} (${label}). Install elsewhere, e.g.: - curl -fsSL https://get.atomicstrata.ai/install.sh | sh -s -- --bin-dir \"\$HOME/.local/bin\" + curl --proto '=https' --tlsv1.2 -fsSL https://get.atomicstrata.ai/install.sh | sh -s -- --bin-dir \"\$HOME/.local/bin\" Or set AM_FORCE=1 to overwrite (breaks the other tool's \`am\` command)." } @@ -158,7 +174,7 @@ Options: --env Seed CLI environment preset after install (default: built-in prod) --core-image Seed Core Docker image override after install - --init Run am init after install (uses ~/.atomicmemory/env in this subshell) + --init Run am init after install (requires an interactive terminal) --uninstall, -r Remove am (and legacy atomicmemory binary if present) -h, --help Show this help @@ -172,7 +188,7 @@ Environment: AM_FORCE Set to 1 to overwrite an existing foreign `am` binary (e.g. AppMan) AM_VERIFY_ATTESTATION auto|1|0 (default: auto). auto verifies public mirror downloads - when gh is available; 1 requires gh attestation verification. + when gh is authenticated; 1 requires authenticated gh verification. Trust: release artifacts are built from github.com/atomicstrata/atomicmemory. SHA256SUMS verifies integrity against the mirror; it does not authenticate the publisher. @@ -263,6 +279,7 @@ run_uninstall() { mb="# >>> atomicmemory >>>" me="# <<< atomicmemory <<<" any=0 + fish_config_home="${XDG_CONFIG_HOME:-$HOME/.config}" for f in am atomicmemory; do if remove_binary_if_allowed "$dir/$f"; then @@ -272,7 +289,13 @@ run_uninstall() { done # strip the PATH block from common shell startup files - for rc in "$HOME/.zshrc" "$HOME/.bashrc" "$HOME/.bash_profile" "$HOME/.config/fish/config.fish"; do + for rc in \ + "${ZDOTDIR:-$HOME}/.zshrc" \ + "$HOME/.bashrc" \ + "$HOME/.bash_profile" \ + "$HOME/.bash_login" \ + "$HOME/.profile" \ + "$fish_config_home/fish/config.fish"; do [ -f "$rc" ] || continue if grep -qF "$mb" "$rc" 2>/dev/null && command -v awk >/dev/null 2>&1; then tmp="${rc}.atomicmemory.tmp" @@ -289,6 +312,13 @@ run_uninstall() { fi done + fish_conf="$fish_config_home/fish/conf.d/atomicmemory.fish" + if [ -f "$fish_conf" ] && grep -qF "$mb" "$fish_conf" 2>/dev/null; then + rm -f "$fish_conf" + info " removed PATH entry from $fish_conf" + any=1 + fi + if [ -d "$AM_ENV_DIR" ]; then rm -f "${AM_ENV_DIR}/env" "${AM_ENV_DIR}/env.fish" rmdir "$AM_ENV_DIR" 2>/dev/null || true @@ -338,27 +368,35 @@ fi have tar || err "need tar on PATH" -should_verify_attestation() { +gh_is_authenticated() { + gh auth token >/dev/null 2>&1 +} + +verify_release_attestation() { case "$AM_VERIFY_ATTESTATION" in 1 | true | TRUE | yes | YES | on | ON) - return 0 + have gh || err "gh is required for attestation verification (install GitHub CLI or set AM_VERIFY_ATTESTATION=0)" + gh_is_authenticated \ + || err "GitHub CLI authentication is required for attestation verification (run gh auth login or provide GH_TOKEN)" ;; 0 | false | FALSE | no | NO | off | OFF) - return 1 + return 0 ;; auto | AUTO | "") - [ "$AM_BASE_URL" = "$AM_DIST_DEFAULT_BASE_URL" ] && have gh - return + [ "$AM_BASE_URL" = "$AM_DIST_DEFAULT_BASE_URL" ] || return 0 + if ! have gh; then + warn "GitHub CLI is unavailable; continuing with checksum verification only (set AM_VERIFY_ATTESTATION=1 to require provenance)" + return 0 + fi + if ! gh_is_authenticated; then + warn "GitHub CLI is not authenticated; continuing with checksum verification only (run gh auth login or set AM_VERIFY_ATTESTATION=1 to require provenance)" + return 0 + fi ;; *) err "invalid AM_VERIFY_ATTESTATION: ${AM_VERIFY_ATTESTATION} (expected auto, 1, or 0)" ;; esac -} - -verify_release_attestation() { - should_verify_attestation || return 0 - have gh || err "gh is required for attestation verification (install GitHub CLI or set AM_VERIFY_ATTESTATION=0)" info "info: verifying GitHub artifact attestation" gh attestation verify "${TMP}/${TARBALL}" \ --repo atomicstrata/atomicmemory \ @@ -430,10 +468,8 @@ prefer_install_dir() { # --- PATH persistence -------------------------------------------------------- AM_MARKER_BEGIN="# >>> atomicmemory >>>" AM_MARKER_END="# <<< atomicmemory <<<" -PATH_RC_FILE="" -PATH_RC_ACTION="" +PATH_RC_FILES="" PATH_ENV_FILE="" -SHELL_RC_CONFIGURED=0 write_env_files() { bindir="$1" @@ -442,82 +478,132 @@ write_env_files() { posix="${AM_ENV_DIR}/env" { printf '%s\n' "# atomicmemory shell environment (managed by install-cli.sh; safe to delete)" - printf 'case ":$PATH:" in\n' - printf ' *":%s:"*) ;;\n' "$bindir" - printf ' *) export PATH="%s:$PATH" ;;\n' "$bindir" - printf 'esac\n' + printf 'atomicmemory_bindir="%s"\n' "$bindir" + printf 'atomicmemory_path=":$PATH:"\n' + printf 'while :; do\n' + printf ' case "$atomicmemory_path" in\n' + printf ' *":$atomicmemory_bindir:"*)\n' + printf ' atomicmemory_before=${atomicmemory_path%%%%":$atomicmemory_bindir:"*}\n' + printf ' atomicmemory_after=${atomicmemory_path#*":$atomicmemory_bindir:"}\n' + printf ' atomicmemory_path="${atomicmemory_before}:${atomicmemory_after}"\n' + printf ' ;;\n' + printf ' *) break ;;\n' + printf ' esac\n' + printf 'done\n' + printf 'PATH=${atomicmemory_path#:}\n' + printf 'PATH=${PATH%%:}\n' + printf 'export PATH="$atomicmemory_bindir${PATH:+:$PATH}"\n' + printf 'unset atomicmemory_bindir atomicmemory_path atomicmemory_before atomicmemory_after\n' } >"$posix" || return 1 PATH_ENV_FILE="$posix" fishf="${AM_ENV_DIR}/env.fish" { printf '%s\n' "# atomicmemory shell environment (managed by install-cli.sh; safe to delete)" - printf 'if not contains "%s" $PATH\n' "$bindir" - printf ' set -gx PATH "%s" $PATH\n' "$bindir" - printf 'end\n' + printf 'set -gx PATH "%s" (string match -v -- "%s" $PATH)\n' "$bindir" "$bindir" } >"$fishf" 2>/dev/null || true return 0 } -rc_file_for_shell() { - name="$(basename "${SHELL:-}")" - case "$name" in - zsh) printf '%s' "${ZDOTDIR:-$HOME}/.zshrc" ;; - bash) - if [ -f "$HOME/.bash_profile" ]; then printf '%s' "$HOME/.bash_profile"; else printf '%s' "$HOME/.bashrc"; fi - ;; - fish) printf '%s' "$HOME/.config/fish/config.fish" ;; - *) printf '' ;; - esac -} - -configure_shell_path() { - bindir="$1" - command -v awk >/dev/null 2>&1 || return 1 - rc="$(rc_file_for_shell)" - [ -n "$rc" ] || return 1 - +activation_command() { case "$(basename "${SHELL:-}")" in - fish) line="source \"${AM_ENV_DIR}/env.fish\"" ;; - *) line=". \"${AM_ENV_DIR}/env\"" ;; + fish) printf 'source "%s/env.fish"' "$AM_ENV_DIR" ;; + *) printf '. "%s/env"' "$AM_ENV_DIR" ;; esac +} - mkdir -p "$(dirname "$rc")" 2>/dev/null || return 1 - if [ -e "$rc" ] && { [ ! -f "$rc" ] || [ ! -w "$rc" ]; }; then - return 1 - fi - - if [ -f "$rc" ] && grep -qF "$AM_MARKER_BEGIN" "$rc"; then - PATH_RC_ACTION="Updated" - elif [ -f "$rc" ]; then - PATH_RC_ACTION="Added" +record_rc_file() { + recorded_rc="$1" + if [ -n "$PATH_RC_FILES" ]; then + PATH_RC_FILES="${PATH_RC_FILES}, ${recorded_rc}" else - PATH_RC_ACTION="Created" + PATH_RC_FILES="$recorded_rc" fi +} + +can_write_rc_file() { + writable_rc="$1" + mkdir -p "$(dirname "$writable_rc")" 2>/dev/null || return 1 + [ ! -e "$writable_rc" ] || { [ -f "$writable_rc" ] && [ -w "$writable_rc" ]; } +} - tmp="${rc}.atomicmemory.tmp" - if [ -f "$rc" ]; then +write_managed_rc() { + managed_rc="$1" + managed_line="$2" + managed_tmp="${managed_rc}.atomicmemory.tmp" + if [ -f "$managed_rc" ]; then awk -v b="$AM_MARKER_BEGIN" -v e="$AM_MARKER_END" ' $0==b{skip=1; next} $0==e{skip=0; next} skip{next} {print} - ' "$rc" >"$tmp" || { rm -f "$tmp"; return 1; } + ' "$managed_rc" >"$managed_tmp" || { rm -f "$managed_tmp"; return 1; } else - : >"$tmp" + : >"$managed_tmp" fi - { printf '\n%s\n' "$AM_MARKER_BEGIN" - printf '%s\n' "$line" + printf '%s\n' "$managed_line" printf '%s\n' "$AM_MARKER_END" - } >>"$tmp" + } >>"$managed_tmp" + mv "$managed_tmp" "$managed_rc" || { rm -f "$managed_tmp"; return 1; } + record_rc_file "$managed_rc" +} - mv "$tmp" "$rc" || { rm -f "$tmp"; return 1; } - PATH_RC_FILE="$rc" - SHELL_RC_CONFIGURED=1 - return 0 +strip_managed_rc() { + old_rc="$1" + [ -f "$old_rc" ] && grep -qF "$AM_MARKER_BEGIN" "$old_rc" 2>/dev/null || return 0 + old_tmp="${old_rc}.atomicmemory.tmp" + awk -v b="$AM_MARKER_BEGIN" -v e="$AM_MARKER_END" ' + $0==b{skip=1; next} + $0==e{skip=0; next} + skip{next} + {print} + ' "$old_rc" >"$old_tmp" || { rm -f "$old_tmp"; return 1; } + mv "$old_tmp" "$old_rc" +} + +bash_login_rc() { + if [ -f "$HOME/.bash_profile" ]; then + printf '%s' "$HOME/.bash_profile" + elif [ -f "$HOME/.bash_login" ]; then + printf '%s' "$HOME/.bash_login" + else + printf '%s' "$HOME/.profile" + fi +} + +configure_shell_path() { + command -v awk >/dev/null 2>&1 || return 1 + shell_name="$(basename "${SHELL:-}")" + posix_line=". \"${AM_ENV_DIR}/env\"" + fish_line="source \"${AM_ENV_DIR}/env.fish\"" + case "$shell_name" in + bash) + login_rc="$(bash_login_rc)" + can_write_rc_file "$HOME/.bashrc" && can_write_rc_file "$login_rc" || return 1 + write_managed_rc "$HOME/.bashrc" "$posix_line" || return 1 + [ "$login_rc" = "$HOME/.bashrc" ] || write_managed_rc "$login_rc" "$posix_line" || return 1 + ;; + zsh) + zsh_rc="${ZDOTDIR:-$HOME}/.zshrc" + can_write_rc_file "$zsh_rc" || return 1 + write_managed_rc "$zsh_rc" "$posix_line" || return 1 + ;; + fish) + fish_config_home="${XDG_CONFIG_HOME:-$HOME/.config}" + fish_rc="$fish_config_home/fish/conf.d/atomicmemory.fish" + can_write_rc_file "$fish_rc" || return 1 + write_managed_rc "$fish_rc" "$fish_line" || return 1 + strip_managed_rc "$fish_config_home/fish/config.fish" || return 1 + ;; + sh | dash | ksh | ash) + can_write_rc_file "$HOME/.profile" || return 1 + write_managed_rc "$HOME/.profile" "$posix_line" || return 1 + ;; + *) return 1 ;; + esac } activate_install_path() { @@ -530,6 +616,19 @@ activate_install_path() { fi } +run_init() { + init_bin="${AM_INSTALL_DIR}/am" + if [ -t 0 ]; then + "$init_bin" init || err "am init failed (run: ${init_bin} init)" + return + fi + if ( : /dev/null; then + "$init_bin" init 0) and - ((.public_install_command == null) or (.public_install_command | type == "string" and length > 0)) + ((.public_install_command == null) or (.public_install_command | type == "string" and length > 0)) and + ((.public_onboarding_command == null) or (.public_onboarding_command | type == "string" and length > 0)) ) ' "${CONTRACT}" >/dev/null @@ -71,9 +72,10 @@ jq -e ' any(.rows[]; .name == "am" and .kind == "binary" and .required_for_public_release == true - and .publish_status == "published") + and .publish_status == "published" + and .public_onboarding_command == "curl --proto '\''=https'\'' --tlsv1.2 -fsSL https://get.atomicstrata.ai/install.sh | sh -s -- --init") ' "${CONTRACT}" >/dev/null || { - echo "FAIL: row 'am' must exist and be required_for_public_release (it is the canonical CLI)" >&2 + echo "FAIL: row 'am' must be release-required and carry the hardened Cloud-first onboarding command" >&2 exit 1 }