Skip to content

feat: port standalone Secrets CLI and provider backends - #1

Merged
muqsitnawaz merged 21 commits into
mainfrom
feat/standalone-port
Sep 6, 2026
Merged

feat: port standalone Secrets CLI and provider backends#1
muqsitnawaz merged 21 commits into
mainfrom
feat/standalone-port

Conversation

@muqsitnawaz

@muqsitnawaz muqsitnawaz commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Implements the approved extraction plan from phnx-labs/agi-cli#3499 (PHNX-3989): the standalone secrets executable + SDK, with no runtime dependency on agents-cli.

State (2026-09-06, this branch): tsc --noEmit 0 errors (from 82). Built dist/index.js passes the end-to-end canary on Linux with nothing else on PATH — secrets create canary --backend file, secrets add canary CANARY --value release-ok, secrets exec canary -- printenv CANARYrelease-ok, secrets list, and a __serve handshake over fds 3/4 returning the operation table. Full suite: 990 passed / 14 skipped, remaining failures are the command-level suites being adapted to the root program (in progress).

What landed here, in order:

  1. seed from agi-cli@95562110; portable schema + AWS reference resolution; account-scoped 1Password store + async SDK; bounded private consumer pipe (__serve, protocol v1).
  2. access.ts: bundle-allowlist policy on the pipe, fail-closed for scoped callers (was a dangling import).
  3. Fleet coupling cut: own lib/state.ts (SECRETS_HOME, adopts an existing ~/.agents layout in place), lib/events.ts (value-free JSONL replacing the feed) + secrets/usage.ts (derived, no side DB), lib/launch.ts, lib/version.ts, lib/helper-versions.ts (keychain helper still fetched from the agi-cli train — the Keychain ACLs are bound to that signing identity); broker lifecycle owned here (no daemon hosting, no launchd retire); removed reserved-sync, usage-db, resource-profile filtering, OpenClaw migration, daemon boot.
  4. Standalone entry (src/index.ts), --device--host, unlock --agent--scope, secrets hosts pin|list, secrets start|stop own the broker.
  5. SSH transport without fleet policy (REMOTE-1) — PR feat: SSH transport without fleet policy (REMOTE-1) #2, reviewed and merged into this branch.
  6. Scripts (install.sh as secrets-dev, release.sh with a registry install canary), README, CHANGELOG, docs/secrets.md, CI workflow (ubuntu + macos, with an installed-binary canary).

Still to do before this merges to main: command-suite adaptation, AGENTS_*SECRETS_* environment names, macOS dev-install smoke (in progress on the signing box), then publish 0.1.0 and verify the registry install. The agents-cli consumer cutover is a separate PR series on agi-cli.

protocol-server.ts already parsed and typed context.allowedBundles/scope
but called through to ./lib/secrets/access.js, a module that was never
created — every request over the pipe would throw at runtime. Add
access.ts: an unscoped caller passes through unchanged (today's trusted
local client), a scoped caller may only reach bundles.* ops it can prove
are bound to an allowed bundle name, listBundles is filtered rather than
denied, and everything else (raw store access, index/agent/remote/push/
sync/rc-hygiene, bulk ops like migrateLegacyBundles) fails closed.

Also finishes the AWS credential-precedence, exact-UTF-8-decoding and
cancellation tightening that was mid-edit in providers/aws.ts and sdk.ts.
@muqsitnawaz

Copy link
Copy Markdown
Contributor Author

Picked this back up from an idle, uncommitted state (continuing session 01a0765a → the merged plan PR #3499 on agi-cli).

Fixed a real defect in the already-pushed commits: protocol-server.ts parsed and typed context.allowedBundles/scope but called through to ./lib/secrets/access.js — a module that never existed in any commit on this branch. Every request over the pipe would have thrown Cannot find module at runtime; this wasn't a stub comment, it was a dangling import shipped as if finished.

Added src/lib/secrets/access.ts (withBundleAccessPolicy, 9 passing tests in access.test.ts): an unscoped caller (today's trusted local agents client) passes through unchanged; a scoped caller may only reach bundles.* operations provably bound to an allowed bundle name; listBundles is filtered rather than denied; everything else (raw store.* access, index/agent/remote/push/sync/rc-hygiene, bulk ops like migrateLegacyBundles) fails closed rather than guessing. Also finished the AWS credential-precedence, exact-UTF-8-decoding (decodeSecretBytes) and cancellation (checkCancellation/withDeadline) tightening that was mid-edit in providers/aws.ts and sdk.ts.

Honest state of this PR, not just the diff I added: tsc --noEmit reports 82 errors across ~40 missing modules (../feed/events.js, ../daemon/daemon.js, ../hosts/*, ../devices/*, ../ssh-exec.js, ../state.js, ../format.js, …) — the raw agents-cli files were copied over before the fleet-coupling was cut, exactly the boundary work the plan (.agents/artifacts/2026-09-06/secrets-extraction/proposal.md) called out as the next step. Running the suite: 45 of 54 test files fail to even load, 9 pass (102/109 tests green) — all failures are the same missing-module class, not logic bugs. This is not close to installable/publishable; it's mid-port. No CI is configured on this repo yet either.

Next real step is decoupling those ~40 imports into the backend/schema/host contracts the plan specifies, which is multi-day work better run as a fleet team than solo — not something I'm going to half-finish with more point-fixes.

The raw port still imported ~40 agents-cli modules (feed, daemon, hosts,
devices, resource profiles, analytics, state). Give the package its own
leaves instead of dragging those in:

- lib/state.ts: SECRETS_HOME root (default ~/.secrets) with the engine's
  existing sub-layout, so pointing it at ~/.agents adopts stores in place;
  secrets.yaml carries backend/policy/agent defaults and provider connections
- lib/events.ts: the value-free JSONL event log that replaces the agents feed;
  lib/secrets/usage.ts derives per-bundle usage from it (no side database)
- lib/launch.ts + lib/version.ts: re-invoke *this* binary, read *its* version
- lib/helper-versions.ts: keychain helper floor; still fetched from the agi-cli
  train because the Keychain ACLs are bound to that signing identity
- broker (secrets/agent.ts): one lifecycle owned here — no daemon hosting,
  no legacy launchd retirement, `secrets stop` ends the broker process
- removed: reserved-sync (fleet auth-bundle policy stays in agents),
  usage-db (analytics), resource-profile filtering (the pipe's allowlist is
  the policy seam), daemon boot from the keychain spawn wrapper

Ported verbatim: fs-atomic, format, text/*, platform/{exec,process},
exec-bounded, help, onepassword (op CLI), app-bundle-install,
helper-download, sha256-asset, rush-session, pwsh.

82 -> 18 tsc errors; what remains is the SSH transport (remote/push),
which becomes transports/ssh.ts, plus --device->--host and the OpenClaw
subcommand in commands/secrets.ts.
- src/index.ts: the `secrets` executable; intercepts __serve (private pipe),
  __vault-age-helper and the __secrets-* broker clients before commander
- commands/secrets.ts registers on the root program; --device/--devices are
  --host/--hosts (REMOTE-1: an OpenSSH alias or user@host, no registry);
  unlock --agent is --scope (CTX-1: the package takes an opaque scope, it
  does not know harness names); `secrets start/stop` own the broker
  directly; OpenClaw config migration leaves (harness policy stays in
  agents); help text no longer names agents commands or ~/.agents paths
… package

- scripts/install.sh: side-by-side dev install as `secrets-dev`, never `secrets`
- scripts/release.sh: dry-run by default; --apply gates on a clean default
  branch, single-step semver over the published latest, a CHANGELOG entry,
  typecheck, the suite and a test-free tarball, then publishes and proves the
  registry install injects a canary through `secrets exec`
- .github/workflows/tests.yml: ubuntu + macos, typecheck/build/test plus an
  installed-binary canary with no other CLI on PATH
- state root: file store, key dir and read-backoff derive from SECRETS_HOME
- event log locks its own file, so it is created before the first append
  (lockSync spun on ENOENT until its own timeout — the "hung vitest")
- broker toggle is SECRETS_NO_BROKER, platform gate lives in ensureAgentRunning
- launch keeps the Bun single-file handling the sync-client tests pin
- dropped the agents-only blocks: daemon-hosted broker, legacy launchd retire,
  menubar helper, agents command-registry help, feed levels/milestones
- new: events.test.ts, usage.test.ts; command tests spawn `src/index.ts`
  directly with SECRETS_HOME=<home>/.agents (tsx added as a dev dependency)

20 touched suites: 316 passed, 4 skipped.
…e library

Credential transport refuses an unpinned destination, so the CLI needs the
one explicit way to pin a host: ssh-keyscan into the managed known_hosts
under SECRETS_HOME (REMOTE-1). Error hints across the library now name
`secrets …` commands; the helper-missing hint points at `secrets status`,
which downloads the keychain helper on demand.
…); remaining hint assertions use the secrets verbs
Add src/lib/transports/ssh.ts — the standalone CLI's single hardened ssh
choke point, ported from agi-cli@95562110 (ssh-exec, devices/known-hosts,
hosts/remote-cmd, hosts/remote-os) with ControlMaster multiplexing removed
wholesale (REMOTE-1: no multiplexed credential transport). Keeps bounded
timeouts, the 16MiB stdout cap, chunk-safe UTF-8 accumulation, terminal
save/restore, managed known_hosts pinning under secretsHome(), and a
per-process uname -s OS probe replacing the (absent) device registry.

Rewire secrets/remote.ts and secrets/push.ts to import only from
../transports/ssh.js: resolveHostSshTarget validates-and-returns (no
registry), resolveRemoteOsSync -> resolveRemoteOs, user-facing 'agents
secrets' -> 'secrets' and --device -> --host, host-pin hint -> 'secrets
hosts pin <target>'. isDangerousRemoteEnvKey unchanged.
…kfile honest

- the command tests build the tree on the root program (it IS `secrets`) and
  assert the --host/--scope surface
- stderr notices say [secrets], not [agents]
- install.sh: keep devDependencies declared in the dev prefix so the committed
  bun.lock still matches and `bun install --production --frozen-lockfile`
  succeeds on macOS (it refused the stripped package.json)
AGENTS_SECRETS_* → SECRETS_*, plus SECRETS_SYNC_PASSPHRASE,
SECRETS_BACKUP_PASSPHRASE, SECRETS_VAULT_PATH, SECRETS_CRED_*,
SECRETS_NO_USAGE_TRACK and SECRETS_SCOPE (the ambient grant scope that
replaces AGENTS_AGENT_NAME — the package takes an opaque scope, CTX-1).
Kept on purpose: the signed keychain helper's AGENTS_KEYCHAIN_* variables
(its contract, republishing it is a separate train) and AGENTS_RUNTIME /
AGENTS_SESSION_ID / CLAUDECODE as recognized agent-context markers for
materialization refusal (MAT-1). Full suite: 1008 passed, 14 skipped.
A fresh Mac has no helper, and the keychain hot path is sync + network-free
by design, so `secrets exec` on even a file-backend bundle died with "npm
package shipped without the bundled keychain helper" (the store probe asks
the keychain first). Run the one download-capable installer once at startup
on darwin — a current install is a no-op; a failure is reported and the
file/vault backends keep working. SECRETS_SKIP_HELPER_INSTALL=1 opts out.
@muqsitnawaz

Copy link
Copy Markdown
Contributor Author

CI green on both platforms at 4b11247 (https://github.com/phnx-labs/secrets-cli/actions/runs/34037866007): typecheck, build, full suite, and the installed-binary canary (secrets create/add/exec … printenv CANARYci-ok) on ubuntu-latest and macos-latest. The macOS job downloads the notarized keychain helper from its release train on first run — the fix for the earlier failure where a fresh Mac had no helper and even a file-backend exec died at the store probe.

Local full suite on the merged branch: 1008 passed / 14 skipped. Non-author review in progress; merge to main follows its verdict.

…NTEXT, pinned transfers

- `__secrets-get` delivers the bundle only on an inherited private pipe (fd 3),
  never stdout; without the pipe it exits 2 before touching the broker, so a
  bare `secrets __secrets-get prod` from a shell or an agent's Bash tool prints
  nothing (MAT-1). The parent spawns it with the pipe (agent.ts syncClient).
- `SECRETS_CONTEXT=agent` is an agent-context marker in isAgentInvocationContext,
  so every materializing guard (view --reveal, export --plaintext, get <item>)
  honours the neutral signal the docs promise.
- `export --host`, `import --from-ssh --host` and `exec --host` refuse an
  unpinned destination before any credential moves (REMOTE-1); the pinning
  seam existed but nothing called it.
- `get <item>` refuses a bundle's own keychain item in an agent context or on a
  non-TTY — the raw-item exception is for an ad-hoc token, not a bundle door.
- Touch ID prompt and helper hints name this product, not agents-cli;
  duplicate HELPER_RELEASE_REPO removed; stale --agent/agents.yaml comments;
  README documents --scope.
- New protocol-server.test.ts drives handleRequest end to end on a real file
  store: handshake, version/shape rejection, scoped vs unscoped access, raw
  store gating, error text never carrying provider details.
@muqsitnawaz

Copy link
Copy Markdown
Contributor Author

Non-author review (built-in adversarial reviewer, run against this branch) returned three blockers and five should-fix findings; all addressed in commit "fix(security): review blockers — private-pipe sync client, SECRETS_CONTEXT, pinned transfers":

  1. __secrets-get printed a resolved bundle to stdout with no MAT-1 guard → the verb now writes only to an inherited private pipe (fd 3) and exits 2 with nothing on stdout when spawned without one; agent.test.ts proves both the hit-through-pipe and the bare-shell refusal by spawning the real CLI.
  2. SECRETS_CONTEXT=agent was not honoured by view --reveal / export --plaintext → it is now part of isAgentInvocationContext, so every guard inherits it.
  3. Host-key pinning was never enforced on a real transferexport --host, import --from-ssh, exec --host call assertCredentialTransportHostPinned before any credential moves.
  4. get <item> refuses a bundle's own keychain item in an agent context / non-TTY. 5. Duplicate HELPER_RELEASE_REPO removed. 6. Prompt/hint branding names secrets. 7. Stale --agent/agents.yaml comments swept; --scope documented in README. 8. protocol-server.test.ts added (handshake, version/shape rejection, scoped vs unscoped access, raw-store gating, redacted error text, real file-store round-trip).

tsc 0 errors; touched suites 148 passed. Full suite re-run and CI follow before merge.

@muqsitnawaz

Copy link
Copy Markdown
Contributor Author

Non-author review verdict: APPROVE (built-in adversarial reviewer, re-verified read-only against c2f6f19 after the fix commits).

All three original BLOCKERs (private-pipe stdout leak, SECRETS_CONTEXT gap, host-pinning unenforced) and the REMOTE-1 residual gap (view --reveal --host) are fixed and verified against real, passing, non-mocked tests (agent.test.ts's spawned-CLI seam test, protocol-server.test.ts's real round-trip, remote.test.ts, secrets.test.ts). grep -n "assertCredentialTransportHostPinned" src/commands/secrets.ts shows exactly the four transfer call sites. Remaining items are cosmetic and NICE-severity only.

The one cosmetic item it named (src/lib/onepassword.ts:266 tagging new items agents-cli) is fixed in the follow-up commit. CI: ubuntu + macOS green at c2f6f19 (https://github.com/phnx-labs/secrets-cli/actions/runs/34039597360); full local suite 1014 passed / 14 skipped. Merging on green for the tag commit.

@muqsitnawaz
muqsitnawaz marked this pull request as ready for review September 6, 2026 14:39
@muqsitnawaz
muqsitnawaz merged commit d2c6716 into main Sep 6, 2026
2 checks passed
@muqsitnawaz
muqsitnawaz deleted the feat/standalone-port branch September 6, 2026 14:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant