Compare commits

..

6 Commits

Author SHA1 Message Date
Hermes Agent
4b50cc93d1 fix(tmux): preserve sender identity across sockets
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
Prefer the pane's MOSAIC_AGENT_NAME and query only the local tmux server when falling back, so destination socket holders cannot corrupt reply routing. Add red-first regression coverage for env, local-session, and unknown sender paths.

Co-Authored-By: OpenAI GPT <noreply@openai.com>
2026-07-16 17:30:56 -05:00
3be443c96d feat(mosaic): claudex isolated config + env-inject + yolo wiring (P2–P4 of #790) (#806)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-16 22:10:33 +00:00
59f5f51ffd feat(mosaic): claudex proxy preflight + lifecycle (P1 of #790) (#793)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-16 21:15:33 +00:00
9745bc3f29 feat(fleet): add reviewed v1-to-v2 migration preview (#788)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-16 13:11:16 +00:00
adad486b6f fix(fleet): enforce exact comms authority (#787)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-16 00:32:23 +00:00
c1aecfabe9 test(fleet): cover reconciler lifecycle gates (#786)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-15 16:46:13 +00:00
35 changed files with 8071 additions and 61 deletions

View File

@@ -52,20 +52,20 @@ Active workstream is **W1 — Federation v1**. Workers should:
> the repository quality gates, independent code and security review, terminal-green CI, and > the repository quality gates, independent code and security review, terminal-green CI, and
> the applicable acceptance evidence before merge. Issue #758 remains open until M5 closes. > the applicable acceptance evidence before merge. Issue #758 remains open until M5 closes.
| id | status | description | issue | agent | repo | branch | depends_on | estimate | notes | | id | status | description | issue | agent | repo | branch | depends_on | estimate | notes |
| ---------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | ------------- | ----------------- | --------------------------------------- | ---------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------ | | ---------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | ------------- | ----------------- | --------------------------------------- | ---------------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------- |
| FCM-M0-001 | done | Publish normative PRD requirements/acceptance criteria, this M0M5 DAG, docs-IA checklist, and legacy example/profile disposition inventory; no implementation changes | #758 | sonnet | mosaicstack/stack | `docs/758-fleet-config-management` | — | 18K | Merged via #760 (`c32d85a`); parent #758 intentionally remains open through M5 | | FCM-M0-001 | done | Publish normative PRD requirements/acceptance criteria, this M0M5 DAG, docs-IA checklist, and legacy example/profile disposition inventory; no implementation changes | #758 | sonnet | mosaicstack/stack | `docs/758-fleet-config-management` | — | 18K | Merged via #760 (`c32d85a`); parent #758 intentionally remains open through M5 |
| FCM-M1-001 | done | Implement narrow local-tmux v2 roster structural contract/compiler with YAML/JSON canonicalization and schema/parser parity tests | #758 | coder0 | mosaicstack/stack | `feat/758-roster-v2-compiler` | FCM-M0-001 | 30K | #764 squash `aa5b43b`; exact-head RoR and PR/main terminal-green CI; no lifecycle or live mutation | | FCM-M1-001 | done | Implement narrow local-tmux v2 roster structural contract/compiler with YAML/JSON canonicalization and schema/parser parity tests | #758 | coder0 | mosaicstack/stack | `feat/758-roster-v2-compiler` | FCM-M0-001 | 30K | #764 squash `aa5b43b`; exact-head RoR and PR/main terminal-green CI; no lifecycle or live mutation |
| FCM-M1-002 | in-progress | Reuse existing profile/persona/provision resolver for roster semantics; add canonical class/authority validation and approved aliases | #758 | native-sonnet | mosaicstack/stack | `feat/758-shared-role-resolution` | FCM-M0-001 | 25K | Started 2026-07-14 from `aa5b43b`; one shared resolver only; validator certificate-only; merge-gate sole merge authority | | FCM-M1-002 | done | Reuse existing profile/persona/provision resolver for roster semantics; add canonical class/authority validation and approved aliases | #758 | native-sonnet | mosaicstack/stack | `feat/758-shared-role-resolution` | FCM-M0-001 | 25K | #768 squash `a5e8e55`; shared resolver and canonical authority/alias validation delivered |
| FCM-M1-003 | not-started | Convert the M0 legacy inventory into executable example/profile/service-preset validation and explicit v1-version/retirement checks | #758 | codex | mosaicstack/stack | `test/758-example-profile-dispositions` | FCM-M1-001, FCM-M1-002 | 20K | Every shipped artifact must validate, be versioned v1, or be retired with replacement | | FCM-M1-003 | done | Convert the M0 legacy inventory into executable example/profile/service-preset validation and explicit v1-version/retirement checks | #758 | codex | mosaicstack/stack | `test/758-example-profile-dispositions` | FCM-M1-001, FCM-M1-002 | 20K | #770 squash `e9c4aa3`; shipped artifact disposition validation delivered |
| FCM-M2-001 | not-started | Migrate generic launch chain to deterministic `.env.generated` plus strict data-only `.env.local`; quarantine forbidden legacy keys | #758 | codex | mosaicstack/stack | `feat/758-generated-env-boundary` | FCM-M1-001, FCM-M1-002 | 30K | No arbitrary command compatibility path; diagnostics expose key names/hashes only | | FCM-M2-001 | done | Migrate generic launch chain to deterministic `.env.generated` plus strict data-only `.env.local`; quarantine forbidden legacy keys | #758 | codex | mosaicstack/stack | `feat/758-generated-env-boundary` | FCM-M1-001, FCM-M1-002 | 30K | #772 squash `191efae`; generated/local boundary and private quarantine delivered |
| FCM-M2-002 | not-started | Add generation-guarded local fleet agent create/get/update/delete mutations with plan/dry-run, atomic roster writes, and recovery output | #758 | codex | mosaicstack/stack | `feat/758-fleet-agent-crud` | FCM-M1-001, FCM-M2-001 | 30K | Fresh create persists stopped unless explicit persisted start | | FCM-M2-002 | done | Add generation-guarded local fleet agent create/get/update/delete mutations with plan/dry-run, atomic roster writes, and recovery output | #758 | codex | mosaicstack/stack | `feat/758-fleet-agent-crud` | FCM-M1-001, FCM-M2-001 | 30K | #773 squash `bc5e736`; generation-guarded atomic CRUD and recovery contracts delivered |
| FCM-M3-001 | not-started | Implement local roster-owned reconcile/apply plus lifecycle/status/verify/doctor contracts and stable JSON/exit codes | #758 | codex | mosaicstack/stack | `feat/758-local-reconciler` | FCM-M2-001, FCM-M2-002 | 35K | Exact systemd/tmux ownership; remote/schema-only entries are inventory only | | FCM-M3-001 | done | Implement local roster-owned reconcile/apply plus lifecycle/status/verify/doctor contracts and stable JSON/exit codes | #758 | codex | mosaicstack/stack | `feat/758-local-reconciler` | FCM-M2-001, FCM-M2-002 | 35K | #785 squash `4990905`; exact roster-owned systemd/tmux reconcile and lifecycle contracts delivered |
| FCM-M3-002 | not-started | Add isolated systemd/tmux lifecycle, drift, socket, unmanaged-session, crash, and rollback acceptance coverage | #758 | sonnet | mosaicstack/stack | `test/758-reconciler-lifecycle-gates` | FCM-M3-001 | 25K | Proves stopped-state preservation and zero fuzzy destructive targeting | | FCM-M3-002 | in-progress | Add isolated systemd/tmux lifecycle, drift, socket, unmanaged-session, crash, and rollback acceptance coverage | #758 | sonnet | mosaicstack/stack | `test/758-reconciler-lifecycle-gates` | FCM-M3-001 | 25K | Canonical v2 named-socket + legacy-v1 default-server boundaries; fake adapters/temp fixtures only |
| FCM-M4-001 | not-started | Implement field-complete v1-to-v2 inventory/preview/migrator with alias, lifecycle, env-quarantine, and remote/connector disposition evidence | #758 | codex | mosaicstack/stack | `feat/758-v1-v2-migrator` | FCM-M1-003, FCM-M3-001 | 35K | Preview first; no unreviewed lifecycle inference | | FCM-M4-001 | not-started | Implement field-complete v1-to-v2 inventory/preview/migrator with alias, lifecycle, env-quarantine, and remote/connector disposition evidence | #758 | codex | mosaicstack/stack | `feat/758-v1-v2-migrator` | FCM-M1-003, FCM-M3-001 | 35K | Preview first; no unreviewed lifecycle inference |
| FCM-M4-002 | not-started | Add reversible canary migration, rollback, stale-projection/orphan classification, and current-host 9-managed/3-unmanaged fixture coverage | #758 | sonnet | mosaicstack/stack | `test/758-migration-rollback-gates` | FCM-M4-001, FCM-M3-002 | 25K | Never starts a previously stopped agent or kills an unproven unmanaged session | | FCM-M4-002 | not-started | Add reversible canary migration, rollback, stale-projection/orphan classification, and current-host 9-managed/3-unmanaged fixture coverage | #758 | sonnet | mosaicstack/stack | `test/758-migration-rollback-gates` | FCM-M4-001, FCM-M3-002 | 25K | Never starts a previously stopped agent or kills an unproven unmanaged session |
| FCM-M5-001 | not-started | Deliver the accepted fleet documentation IA, how-to/operations/migration references, and link/example validation | #758 | haiku | mosaicstack/stack | `docs/758-fleet-config-operator-docs` | FCM-M1-003, FCM-M2-002, FCM-M3-001, FCM-M4-001 | 24K | Must close every checklist item or record an approved deferral | | FCM-M5-001 | not-started | Deliver the accepted fleet documentation IA, how-to/operations/migration references, and link/example validation | #758 | haiku | mosaicstack/stack | `docs/758-fleet-config-operator-docs` | FCM-M1-003, FCM-M2-002, FCM-M3-001, FCM-M4-001 | 24K | Must close every checklist item or record an approved deferral |
| FCM-M5-002 | not-started | Package/update asset-drift checks, rolling local canary, independent validation certificate, and release evidence | #758 | sonnet | mosaicstack/stack | `feat/758-fleet-config-release-gate` | FCM-M3-002, FCM-M4-002, FCM-M5-001 | 30K | Final #758 gate: quality, independent code/security review, validator certificate, merge-gate approval, green CI | | FCM-M5-002 | not-started | Package/update asset-drift checks, rolling local canary, independent validation certificate, and release evidence | #758 | sonnet | mosaicstack/stack | `feat/758-fleet-config-release-gate` | FCM-M3-002, FCM-M4-002, FCM-M5-001 | 30K | Final #758 gate: quality, independent code/security review, validator certificate, merge-gate approval, green CI |
## Thin-core prompt diet (#528) — feat/contract-thin-core ## Thin-core prompt diet (#528) — feat/contract-thin-core

View File

@@ -41,10 +41,27 @@ artifact can be removed.
| `profiles/software-delivery.yaml` | Canonical profile | shared profile/persona resolver | Retains the governance profile; authority validation remains FCM-M1-002 evidence. | | `profiles/software-delivery.yaml` | Canonical profile | shared profile/persona resolver | Retains the governance profile; authority validation remains FCM-M1-002 evidence. |
| `services/operator-interaction.yaml` | Canonical service policy | service-policy reader/provisioner | Generic provisioning supplies the instance name; the policy itself never names Tess. | | `services/operator-interaction.yaml` | Canonical service policy | service-policy reader/provisioner | Generic provisioning supplies the instance name; the policy itself never names Tess. |
## M4 migration-preview evidence
FCM-M4-001 layers an executable migration posture over the same 13-entry M1 inventory without
changing the retained artifact classification:
- every `v1-fixture` is previewed only with explicit class and lifecycle evidence;
- every `canonical-profile` remains validated by the shared baseline-plus-`roles.local` resolver;
- the canonical service policy remains generic and uses only the approved tool-policy alias.
`validateShippedFleetMigrationDispositions` first runs the existing executable M1 guard, then requires
explicit decisions and lifecycle observations and executes `previewV1ToV2Migration` for every shipped
v1 fixture. `collectShippedFleetMigrationDispositions` derives the 13-entry posture directly from
`SHIPPED_FLEET_ARTIFACT_DISPOSITIONS`, so additions or removals continue to fail the M1 guard rather
than creating a second artifact list. None of these dispositions claims a cutover, canary, or
rollback; those gates belong to FCM-M4-002. See [v1-to-v2 preview](./v1-to-v2.md).
## Running the guard ## Running the guard
```bash ```bash
pnpm --filter @mosaicstack/mosaic test -- example-profile-dispositions.spec.ts pnpm --filter @mosaicstack/mosaic test -- v1-v2-migration.spec.ts \
-t "validates all 13 shipped artifacts and executes ready previews for every v1 fixture"
``` ```
The guard is intentionally limited to shipped assets and validation. It does not generate The guard is intentionally limited to shipped assets and validation. It does not generate

View File

@@ -0,0 +1,86 @@
# Previewing a Fleet Roster v1-to-v2 Migration
**Issue:** #758 · **Card:** FCM-M4-001 · **Effect boundary:** preview only
`mosaic fleet migrate-v1 preview` inventories a v1 roster and emits a canonical v2 candidate plus
recovery evidence. It does not write a roster, apply environment projections, invoke systemd or
`tmux`, contact connectors or remote hosts, launch an agent, run a canary, or execute rollback.
FCM-M4-002 owns reversible cutover and rollback.
## Inputs
```bash
mosaic fleet migrate-v1 preview \
--source roster-v1.yaml \
--decisions migration-decisions.json \
--observations reviewed-observations.json
```
The command emits one JSON object and exits nonzero when the preview is blocked, including when any of
`--source`, `--decisions`, or `--observations` is omitted, passed without a path value, or passed an empty
path value. These request-shape failures are reported before any input file is read. Decision and
observation JSON is validated fail-closed: unknown fields, malformed values, and records for non-local
agents are rejected. Decisions must supply a positive v2 `generation`, a reviewed `fleetHost` whenever
v1 agents include `host` or `ssh`, explicit `defaultRuntime`, and per-local-agent provider, model,
reasoning, enabled state, and launch policy. The v1 source remains authoritative for socket semantics:
a supported declared socket field, including an explicit empty value for the default tmux server, is
preserved; if both supported root aliases are absent, the production v1 default is the literal empty socket.
A matching `socketName` decision is accepted and an incompatible decision blocks, but a decision never
supplies or repairs a missing source socket. If v1 omitted `tool_policy`, decisions must supply an
explicit replacement; it is never derived from `class`. `model_hint` is never split or treated as
authority.
Observations are separate reviewed evidence keyed by local agent name:
```json
{
"coder0": { "systemd": "inactive", "tmux": "missing" }
}
```
Only `active` plus `present` maps to `running`; only `inactive` plus `missing` maps to `stopped`.
Missing, extra, unknown, or contradictory evidence blocks output. An observed-running agent cannot
be marked disabled. Observed-stopped agents always remain stopped.
## Field disposition
| v1 field | v2 disposition |
| ------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `version`, `transport`, `tmux`, `defaults`, `runtimes` | Inventoried and structurally compiled; omitted runtimes retain v1 built-in defaults, while each explicitly declared runtime without a reset field follows the production v1 `/clear` fallback; present-empty holder/work-directory/reset values block |
| agent `name`, `alias`, `runtime`, working directory, persona/reset flags | Copied or explicitly defaulted only when absent; present-empty alias/work-directory values block for explicit disposition. Canonical `~`/`~/...` values stay unchanged in roster evidence and traversal-free forms expand only at the shared production environment-projection boundary before unchanged absolute-path validation |
| `provider`, `model_hint`, `reasoning_level` | Explicit provider/model/reasoning decisions; no model-hint inference |
| `class`, `tool_policy` | Only approved aliases canonicalize automatically; other classes require explicit preserve/replace disposition and shared-resolver validation |
| `kickstart_template` | No v2 field; explicit inventory-only disposition required |
| agent `host`, `ssh` | `host != fleetHost` is demonstrably remote and inventory-only; `host == fleetHost` stays local; SSH targets with or without an explicit user must agree with `host`; ssh-only, missing fleet-host evidence, or contradictory targets block |
| agent `socket` | Same-host candidate only when it matches the canonical fleet socket; conflicts block for explicit future disposition |
| root `connector` | Inventory-only; never contacted or reconciled |
| unknown fields or snake/camel synonym collisions | Inventoried and block readiness |
| `.env.generated` | Rebuild from canonical roster data |
| no legacy `.env` | `absent`; no legacy action required |
| legacy `.env` containing generated keys only | `regenerate-only`; replace later from canonical roster data |
| legacy `.env` containing strict local keys | `relocate-local`; preserve those keys in `.env.local` during a later reviewed cutover |
| legacy `.env` containing forbidden/unsafe/sensitive/malformed keys | `quarantine`; private input only, with diagnostics limited to code, key, and SHA-256 |
The only automatic aliases are `implementer → code`, `reviewer → review`, and
`operator-interaction → interaction`. Similar or domain-specific names are never inferred. Automatic
classes do not accept competing disposition records. Semantic validation delegates to the existing
baseline-plus-`roles.local` resolver after the candidate is compiled by the existing v2 compiler.
## Evidence and recovery boundary
Ready output includes source and candidate SHA-256 identities, value-free field inventory, excluded
remote/connector entries, explicit environment dispositions with sanitized diagnostics, and the lifecycle
evidence used for each local candidate. Canonical lifecycle and remote-exclusion evidence ordering compares
Unicode code points directly and does not depend on source-agent order or process locale. Source field
inventory remains position-addressed evidence of the exact input. Recovery is marked non-executable and
assigns the executable gate to FCM-M4-002.
Before any later cutover, preserve these artifacts:
1. authoritative v1 roster backup;
2. agent environment backup, including `.env.local` and private quarantine inputs;
3. reviewed lifecycle observations;
4. canonical candidate v2 roster and its SHA-256.
See [backup and restore](../operations/backup-restore.md). Preview output is migration-readiness
evidence, not proof that migration, canary, or rollback occurred.

View File

@@ -0,0 +1,40 @@
# Fleet Configuration Backup and Restore Boundary
**Issue:** #758 · **Card:** FCM-M4-001
This page defines evidence that must exist before a roster v1-to-v2 cutover. FCM-M4-001 lists these
prerequisites in non-executable recovery evidence but does not validate that backups exist and performs
no backup, migration, canary, or restore. FCM-M4-002 owns the executable reversible canary and rollback
gates.
## Preserve before cutover
- The authoritative v1 roster, byte-for-byte, with a SHA-256 identity.
- Existing per-agent legacy `.env`, strict `.env.local`, and quarantine files under private
permissions.
- Reviewed per-local-agent systemd and exact-socket tmux observations.
- The canonical v2 candidate and its SHA-256 identity.
- Inventory-only remote agents and connector configuration as evidence, not local control-plane input.
`.env.generated` is a rebuildable projection and is not restored as authority. It must be regenerated
from the selected authoritative roster. `.env.local` is operator-owned strict data and must not be
overwritten or absorbed into generated output. Quarantined source remains private evidence; public
diagnostics expose only rule code, key name, and SHA-256.
## Restore requirements
A later rollback implementation must restore the authoritative roster and operator-owned environment
files, regenerate managed projections, and preserve each reviewed pre-cutover stopped/running state.
It must never start an agent observed stopped and must never reconcile an inventory-only remote or
connector entry.
The preview evidence deliberately records:
- `executable: false`;
- required backup artifacts;
- source and candidate identities;
- lifecycle observations and resulting desired states;
- environment relocation/quarantine dispositions;
- FCM-M4-002 as the executable rollback gate owner.
Do not interpret a ready preview as a completed backup, migration, canary, or rollback.

View File

@@ -11,8 +11,15 @@ mosaic fleet restart [name] --expected-generation <n> [--dry-run]
mosaic fleet status [name] mosaic fleet status [name]
mosaic fleet verify mosaic fleet verify
mosaic fleet doctor mosaic fleet doctor
mosaic fleet migrate-v1 preview --source <path> --decisions <path> --observations <path>
``` ```
`migrate-v1 preview` is non-mutating: it emits value-free v1 inventory, a canonical semantically
validated v2 candidate when ready, sanitized environment dispositions, and non-executable recovery
evidence. It has no write, apply, canary, or rollback option. Missing preview inputs also return one stable
blocked JSON object and a non-zero exit, rather than Commander text. See
[the migration preview contract](../migration/v1-to-v2.md).
`apply` and `reconcile` use roster desired state. `start`, `stop`, and `restart` are exact local one-shot lifecycle effects and never persist a desired-state edit. `status`, `verify`, and `doctor` are observational. `apply` and `reconcile` use roster desired state. `start`, `stop`, and `restart` are exact local one-shot lifecycle effects and never persist a desired-state edit. `status`, `verify`, and `doctor` are observational.
Commands emit one JSON object. Handled precondition errors emit `{ "error": { "code": "..." } }` and exit non-zero. Partial derived/lifecycle effects use explicit `authoritativeRoster`, `projections`, `lifecycle`, and bounded `recovery` fields; they never claim rollback. Any additive `cleanup` diagnostic also exits non-zero, even where known effects are complete: it is not a clean completion and the lock requires inspection before retry. Commands emit one JSON object. Handled precondition errors emit `{ "error": { "code": "..." } }` and exit non-zero. Partial derived/lifecycle effects use explicit `authoritativeRoster`, `projections`, `lifecycle`, and bounded `recovery` fields; they never claim rollback. Any additive `cleanup` diagnostic also exits non-zero, even where known effects are complete: it is not a clean completion and the lock requires inspection before retry.

View File

@@ -62,24 +62,24 @@ agents:
## Nested fields ## Nested fields
| Path | Required | Constraint | | Path | Required | Constraint |
| ---------------------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------- | | ---------------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------- |
| `tmux.socket_name` | yes | non-empty `[A-Za-z0-9_.-]+`; an explicit named socket prevents default-versus-named socket ambiguity | | `tmux.socket_name` | yes | `[A-Za-z0-9_.-]*`; empty string means the literal default tmux server, while a non-empty value names a socket |
| `tmux.holder_session` | yes | non-empty `[A-Za-z0-9_.-]+` | | `tmux.holder_session` | yes | non-empty `[A-Za-z0-9_.-]+` |
| `defaults.working_directory` | yes | non-empty string | | `defaults.working_directory` | yes | non-empty string |
| `defaults.runtime` | yes | `claude`, `codex`, `opencode`, or `pi`; it must be declared in `runtimes` | | `defaults.runtime` | yes | `claude`, `codex`, `opencode`, or `pi`; it must be declared in `runtimes` |
| `runtimes.<runtime>.reset_command` | yes | non-empty string; runtime key must be a supported local runtime | | `runtimes.<runtime>.reset_command` | yes | non-empty string; runtime key must be a supported local runtime |
| `agents[].name` | yes | unique `[A-Za-z0-9][A-Za-z0-9_.-]*` stable machine identity | | `agents[].name` | yes | unique `[A-Za-z0-9][A-Za-z0-9_.-]*` stable machine identity |
| `agents[].alias` | yes | non-empty display string | | `agents[].alias` | yes | non-empty display string |
| `agents[].class` | yes | `[a-z][a-z0-9-]*`; structural only in M1, semantic role resolution is FCM-M1-002 | | `agents[].class` | yes | `[a-z][a-z0-9-]*`; structural only in M1, semantic role resolution is FCM-M1-002 |
| `agents[].runtime` | yes | `claude`, `codex`, `opencode`, or `pi`; it must be declared in `runtimes` | | `agents[].runtime` | yes | `claude`, `codex`, `opencode`, or `pi`; it must be declared in `runtimes` |
| `agents[].provider`, `model`, `working_directory` | yes | non-empty strings; provider/model capability resolution is a later card | | `agents[].provider`, `model`, `working_directory` | yes | non-empty strings; provider/model capability resolution is a later card |
| `agents[].reasoning` | yes | `low`, `medium`, or `high` | | `agents[].reasoning` | yes | `low`, `medium`, or `high` |
| `agents[].tool_policy` | yes | `[a-z][a-z0-9-]*`; structural only in M1 | | `agents[].tool_policy` | yes | `[a-z][a-z0-9-]*`; structural only in M1 |
| `agents[].persistent_persona`, `reset_between_tasks` | yes | booleans | | `agents[].persistent_persona`, `reset_between_tasks` | yes | booleans |
| `agents[].lifecycle.enabled` | yes | boolean; stored now, reconciled in FCM-M3-001 | | `agents[].lifecycle.enabled` | yes | boolean; stored now, reconciled in FCM-M3-001 |
| `agents[].lifecycle.desired_state` | yes | `running` or `stopped` | | `agents[].lifecycle.desired_state` | yes | `running` or `stopped` |
| `agents[].launch.yolo` | yes | boolean; structured data only, not an arbitrary command escape hatch | | `agents[].launch.yolo` | yes | boolean; structured data only, not an arbitrary command escape hatch |
## Semantic handoff ## Semantic handoff

View File

@@ -24,7 +24,7 @@
"properties": { "properties": {
"socket_name": { "socket_name": {
"type": "string", "type": "string",
"pattern": "^[A-Za-z0-9_.-]+$" "pattern": "^[A-Za-z0-9_.-]*$"
}, },
"holder_session": { "holder_session": {
"type": "string", "type": "string",

View File

@@ -0,0 +1,84 @@
# FCM-M3-002 — Reconciler lifecycle acceptance gates
- **Task / issue:** FCM-M3-002 / mosaicstack/stack#758
- **Branch:** `test/758-reconciler-lifecycle-gates`
- **Required starting head:** `499090508ef1d768660e4d54e7934cbcf13cb1cd`
- **Required starting tree:** `2f1bb7fed48291f3f7ba8b21c2b52491aa14fe2b`
- **Scope:** isolated acceptance coverage and card-required evidence/tracking only; no live fleet, systemd, tmux, session, site, migration, canary, deployment, runtime, connector, or remote action.
- **Budget:** use the task estimate of 25K as the working cap; keep the delta to one coherent acceptance suite plus required task/scratchpad evidence. No production change unless a failing reproducer proves an in-scope defect.
## Intake evidence
- Clean exact local branch/head/tree verified before editing.
- `origin/test/758-reconciler-lifecycle-gates` fetched and verified at the same required head.
- The Mosaic PR wrapper reported no open pull requests, so no open-PR branch collision exists.
- Parent issue #758 is open and remains intentionally open through M5.
- Requirements loaded from `docs/PRD.md` FCM requirements and `AC-FCM-05`, `docs/TASKS.md` FCM DAG, the M3 rows in `docs/fleet/FLEET-CONFIG-DOCS-IA-CHECKLIST.md`, and the FCM-M3-001 implementation scratchpad.
## Objective
Add broad, behavior-oriented acceptance evidence around the shipped local reconciler contracts. Exercise only injected fake systemd/tmux adapters and temporary filesystem fixtures. Prove exact ownership/targeting, persisted stopped-state safety, truthful partial-failure recovery, rollback behavior, and stable command JSON/exit outcomes without touching live services or sessions.
## Acceptance mapping and evidence
| FCM-M3-002 acceptance concern | Delivered isolated evidence |
| --- | --- |
| Systemd/tmux lifecycle | `fleet-reconciler.acceptance.spec.ts` drives apply, reconcile, stop, restart, status, and recovery reconcile through one stateful injected fake host. The fake models exact systemd effects and tmux session observations; no host commands run. |
| Drift | Canonical roster-v2 YAML drives the Commander `fleet status` boundary and classifies `missing-session`, `unexpected-session`, and `disabled-running`, including combined drift, while asserting observation emits no lifecycle mutation. |
| Exact default/named socket targeting | Canonical v2 requires an explicit non-empty named socket; the parser rejects missing/empty values and the Commander acceptance path asserts exact `-L mosaic-fleet` targeting. A separate canonical legacy-v1 roster loader plus runtime-transport path proves a socket-less compatibility roster targets the literal tmux default server with no `-L`. No unreachable empty-socket v2 fixture is used. |
| Unmanaged-session classification | Stateful fixtures report sorted `coder0-shadow`/`unmanaged` sessions, then prove an exact roster stop leaves both sessions and the near-collision service intact. |
| Crash/partial failure | Injected restart failure is applied after the fake effect to model crash/partial truth: result is `lifecycle: incomplete`, the roster is unchanged, and observed runtime may be active. |
| Rollback/recovery semantics | M3 has no rollback command and explicitly does not claim automatic rollback. The acceptance workflow proves the bounded recovery contract: inspect, then exact reconcile restores the persisted stopped target without a start or fuzzy effect. M4 migration/canary rollback remains outside this card. |
| Stopped-state preservation | Stateful apply and reconcile both stop an initially running observed agent whose persisted target is stopped; failed explicit restart leaves desired state stopped; recovery reconcile restores stopped state. No start call is emitted. |
| Zero fuzzy destructive targeting | Near-collision `coder0-shadow` service/session plus `unmanaged` session remain untouched. The recorded destructive calls contain only exact `mosaic-agent@coder0.service`; no tmux kill action is emitted. |
| Stable JSON/exit behavior | Temporary canonical roster fixture invokes the CLI boundary and asserts exactly one JSON line, exact partial-result shape, and exit code 1. Existing focused command specs continue to cover clean zero-exit and stable error JSON. |
| Redacted truthful recovery | Fake stderr includes `PASSWORD=acceptance-secret`; exact CLI JSON contains only bounded recovery metadata and excludes the key, value, and raw diagnostic. |
## Plan
1. Inventory existing reconciler and command specs against the table above; avoid duplicating narrow assertions already present.
2. Add one acceptance-level spec using only fake/injected adapters and temporary files.
3. If a real defect is exposed, preserve the failing reproducer and make only the smallest FCM-M3-002-required fix; otherwise leave production unchanged.
4. Reconcile `docs/TASKS.md` only for delivered M1/M2/M3-001 truth and mark FCM-M3-002 in progress.
5. Run focused tests, full `@mosaicstack/mosaic` tests, package/root typecheck and lint, Prettier/format and diff checks, plus adversarial fake-runner cases.
6. Record exact evidence and leave the tree uncommitted for independent synthetic-tree review.
## TDD decision
This card adds acceptance coverage to already-delivered behavior. Test-first applies to any product defect discovered: retain a failing reproducer before an in-scope fix. If the shipped behavior already satisfies the acceptance contract, no production code will be changed and the acceptance suite itself is the deliverable.
## Progress
- Intake and immutable baseline verification complete.
- Existing coverage inventory confirmed strong unit coverage but no stateful cross-command lifecycle acceptance harness.
- Added `packages/mosaic/src/fleet/fleet-reconciler.acceptance.spec.ts`: one injected stateful fake systemd/tmux host, temporary canonical v2 and legacy-v1 roster fixtures, and seven acceptance tests.
- Production source is unchanged; no product defect requiring an FCM-M3-002 fix was found.
- `docs/TASKS.md` reconciles only merged M1/M2/M3-001 truth and marks FCM-M3-002 in progress.
## Verification evidence
All commands ran from `/home/jarvis/src/mosaic-stack-local-reconciler` and passed unless explicitly noted.
- `pnpm --filter @mosaicstack/mosaic exec vitest run src/fleet/fleet-reconciler.acceptance.spec.ts` — final remediation run: 1 file, 7 tests passed; canonical v2 named-socket parsing/Commander status, missing/empty v2 rejection, and canonical legacy-v1 default-server runtime targeting are distinct reachable cases.
- Focused reconciler/roster/transport command covering acceptance, reconciler, command, CRUD, v2 parser, and runtime transport specs — 8 files, 304 tests passed.
- `pnpm --filter @mosaicstack/mosaic test` — final remediation run: 57 files, 827 tests passed.
- `pnpm --filter @mosaicstack/mosaic typecheck` — passed.
- `pnpm --filter @mosaicstack/mosaic lint` — passed.
- `pnpm typecheck` — 42/42 Turbo tasks successful.
- `pnpm lint` — 23/23 Turbo tasks successful.
- `pnpm exec prettier --check docs/TASKS.md docs/scratchpads/758-fcm-m3-002-reconciler-lifecycle-gates.md packages/mosaic/src/fleet/fleet-reconciler.acceptance.spec.ts` — passed.
- `pnpm format:check` — all matched files use Prettier style.
- `git diff --check` — passed with no output.
- Initial scoped Prettier check found style drift in the new spec and tracking table; `pnpm exec prettier --write ...` remediated it before all final gates above.
- No live fleet, systemctl, tmux, process, site, migration, canary, deploy, runtime, connector, or remote command was invoked.
## Review boundary
This is an author handoff. No self-review is represented as reviewer-of-record. The uncommitted synthetic tree is intended for independent review.
## Risks / blockers
- M3 truthfully reports incomplete lifecycle effects and bounded recovery; it does not implement or claim an automatic rollback command. This suite proves stopped-state restoration by the documented exact recovery reconcile. M4 retains migration/canary rollback ownership.
- The fake host models only the public systemd/tmux runner contract and temporary roster filesystem boundary. This is intentional under the no-live-effects hold.
- Parent issue closure, commit, push, PR, merge, deployment, and branch cleanup remain explicit holds.
- No residual implementation blocker.

View File

@@ -0,0 +1,80 @@
# FCM-M4-001 — v1-to-v2 inventory, preview, and migrator
- **Task / issue:** FCM-M4-001 / mosaicstack/stack#758
- **Branch / base:** `feat/758-v1-v2-migrator` from `origin/main` `c1aecfabe97a5dc81a72f44910cd4e626f41863f`
- **Base tree:** `46cdfbcdc1d1ff9c7b8b2b9cf3841086590bf774`
- **Scope:** field-complete inventory, non-mutating preview, canonical v2 migration output, and migration/recovery disposition evidence. All effects use injected fakes or temporary fixtures.
- **Budget:** 35K task estimate is the hard working cap. Keep one card/one PR and prefer focused reuse of the v2 compiler, shared role resolver, generated-env boundary, M1 executable disposition inventory, and reconciler observations.
## Objective
Implement preview-first v1 migration that never infers unresolved classes or lifecycle, preserves observed running/stopped state, quarantines forbidden legacy environment inputs with key-name/SHA-256-only diagnostics, inventories remote/connector/schema-only entries without reconciling them, covers every M1-classified shipped artifact, and emits deterministic recovery disposition evidence for the later M4-002 canary/rollback gate.
## Acceptance mapping
1. Field-by-field v1 inventory and no-mutation preview.
2. Canonical output compiled by `roster-v2.ts` and semantically validated by the existing baseline-plus-`roles.local` resolver.
3. Only approved deterministic aliases; every other noncanonical class requires an explicit disposition.
4. Observed stopped/running maps explicitly to persisted lifecycle; stopped observations never produce running targets.
5. Generated env is regenerated; strict local data is relocated; forbidden keys are quarantine inputs reported only by key name and SHA-256.
6. Remote/connector/schema-only entries are inventory-only and excluded from local reconciliation output.
7. Every shipped M1 example/profile/service preset has executable migration disposition evidence.
8. Deterministic migration/recovery evidence records source, output, exclusions, quarantine, and restore prerequisites without executing a canary or rollback.
## Boundaries
Out of scope: FCM-M4-002 executable canary/rollback and host fixture, #766 communications, #636 commands/channels, live fleet/systemd/tmux/session/migration/deploy/connector/remote/gateway effects, `docs/TASKS.md`, parent issue mutation, commit, push, and PR operations.
## TDD plan
Migration rules and redaction are critical data-mutation/security logic, so tests are written red-first for inventory completeness, explicit class disposition, observed-state preservation, quarantine redaction, inventory-only remote/schema entries, compiler/resolver reuse, artifact coverage, and recovery evidence. Production code follows only after the focused tests fail for the missing behavior.
## Plan
1. Map existing v1 loader, v2 compiler/resolver, env quarantine, reconciler observation, and M1 disposition guard.
2. Add behavior-oriented failing migration tests with temporary fixtures and injected observation/filesystem adapters only.
3. Implement the narrow migration module and CLI boundary without a second resolver or live command runner.
4. Add scoped M4 migration/recovery documentation and executable shipped-artifact evidence.
5. Run focused tests, full package tests, package/root typecheck and lint, formatting, diff checks, and adversarial redaction/no-effect verification.
6. Run independent code/security review, remediate findings, reconstruct the synthetic tree using a temporary index, and stop uncommitted.
## Progress
- Collision checks passed: no local/remote branch, worktree, target path, or open PR owned `feat/758-v1-v2-migrator`.
- Dedicated worktree created at the exact green `origin/main` base.
- Required global/repository guides and FCM requirements/evidence loaded.
- No matching migration skill exists under the configured skill directories; no unrelated skill loaded.
- Added a preview-only CLI and migration module that compile with the existing v2 parser/renderer and validate through the shared persona resolver.
- Added value-free raw-v1 inventory, strict unknown-field/synonym/duplicate detection, inventory-only remote and connector handling, and explicit class/tool-policy decisions.
- Added separate reviewed lifecycle observations with only unambiguous running/stopped mappings.
- Added sanitized, non-mutating environment preflight and recovery evidence explicitly marked non-executable.
- Added executable disposition evidence derived from the exact 13-entry M1 inventory and operator documentation.
- Tightened untrusted decisions/observations to reject unknown keys, invalid types/enums, extra local records, and competing automatic-alias dispositions.
- Remediated independent review findings: v1 runtime/reset defaults are preserved, `~` workdirs expand only at env preflight, malformed/required agent fields fail closed before remote exclusion, and all seven shipped v1 fixtures now execute real previews with explicit evidence.
- Remediated socket and locality authority blockers: socket-only agents stay local; `host == fleetHost` stays local; only `host != fleetHost` is inventory-only; ssh-only, missing reviewed fleet-host identity, and contradictory host/ssh targets block explicitly without lifecycle omission.
- Remediated final exact-tree blockers: a declared v1 root socket cannot be overridden; matching/conflicting socket decisions retain reviewed running evidence; canonical ordering uses a shared locale-independent Unicode code-point comparator; migration evidence preserves all four legacy environment dispositions; backup documentation no longer claims validation that M4-001 does not perform.
- Remediated immutable-review socket-presence blocker: both `socket_name` and `socketName` are field-presence-aware, so explicit empty/default-server declarations remain authoritative and incompatible named decisions block rather than replacing them.
- Remediated the replacement-tree blockers: the shared v2 compiler and reconciler accept an explicit empty socket as literal default-server identity; present-empty holder session, default/agent work directory, runtime reset command, and alias values block rather than defaulting; missing preview inputs emit one stable blocked JSON object with non-zero status. Snake/camel aliases and whitespace-only input have adversarial coverage.
- Remediated the subsequent authority blockers: each present-empty CLI path emits exactly one stable blocked JSON object with exit 1 before file reads, and reconciler `start`/`restart` or desired-state `apply`/`reconcile` fail closed before fixed `mosaic-fleet` systemd services can act on a default-server roster.
- Remediated committed-head review blockers: explicitly declared empty runtime objects use the production v1 `/clear` reset fallback while omitted `pi` retains `/new`; lifecycle observations are sorted by canonical agent name; and bare CLI path flags reach preview validation, emit one stable blocked JSON object with exit 1, and perform zero reads. Built production-CLI subprocess tests cover all three bare flags.
- Remediated late-audit blockers: the documented M4 guard invokes the 13-artifact validator and all seven v1 previews; canonical `~`/`~/...` workdirs remain unchanged in migration evidence and traversal-free forms expand at the shared production projection boundary while ordinary relative and home-relative traversal paths remain rejected; remote inventory and exclusion evidence sort canonically; and every default-server lifecycle-mutating reconciler path fails before observation, projection preparation/application, or fixed-unit effects. Explicit regressions preserve `plan`, `status`, `doctor`, and `verify` as observational default-server commands.
- Remediated exact-tree traversal review: `~/../escape` and `~/src/../../escape` remain unexpanded and fail the unchanged shared `unsafe-path` validation. Both the shared generated-environment boundary and the production v1 environment caller have red-first regressions, preventing earlier caller normalization from bypassing the boundary.
## Verification evidence
- Focused migration/compiler/environment/reconciler/CLI: 8 files, 372 tests passed.
- Documented 13-artifact guard: 1 matching test passed and executed all seven v1 previews.
- Full `@mosaicstack/mosaic`: 59 files, 902 tests passed.
- Workspace build: 23 tasks passed.
- Root typecheck: 42 tasks passed.
- Root lint: 23 tasks passed.
- Root format check and `git diff --check`: passed.
- Built production CLI: canonical `~/src` remains in ready roster/YAML evidence; generated projection preflight succeeds with no blockers; a bare path flag emits one blocked JSON object, exit 1, and no stderr.
- Independent high-effort late-audit review: no blocker remained in the four assigned repair surfaces; separate exact-tree security audits found no qualifying newly introduced vulnerability.
- Exact temporary-index synthetic tree includes every tracked changed path; immutable SHA and duplicate reconstruction are recorded in the final handoff.
## Risks / blockers
- M4-001 emits rollback prerequisites/evidence only; executable rollback/canary and the managed/unmanaged host fixture remain owned by M4-002.
- Remote/connector entries remain inventory-only; later federation or connector reconciliation requires separately reviewed work.
- Existing environment data is only preflighted. Cutover backup, quarantine write, legacy removal, and generated projection application remain later reviewed effects.

View File

@@ -0,0 +1,37 @@
# Issue #808 — agent-send sender identity
## Objective
Fix cross-socket `agent-send.sh` preambles so replies route to the real sender rather than a destination-socket holder session.
## Scope and acceptance criteria
- Prefer exported `MOSAIC_AGENT_NAME` as the authoritative sender session name.
- If it is unset, query the sender's local/default tmux socket for `#S` without destination `-L` arguments.
- Preserve `?` when sender identity cannot be determined.
- Do not alter destination socket dispatch.
- Add red-first regressions for all three identity paths.
## Plan
1. Extend `agent-send.test.sh` with deterministic fake-tmux coverage.
2. Run the test against the unpatched implementation and record RED evidence.
3. Apply the minimal sender lookup fix only.
4. Run the focused suite and repository quality gates.
5. Commit, queue-guard, push, and open an author-only PR for independent review.
## Constraints and risks
- Worker lane is author-only: no self-review or merge.
- `docs/TASKS.md` and mission state are orchestrator-owned and will not be modified.
- Pre-existing runtime changes under `.mosaic/orchestrator/` are excluded from this work.
- Budget: no explicit token cap; keep changes limited to the shell tool, sibling regression test, and this scratchpad.
## Evidence
- RED: `bash packages/mosaic/framework/tools/tmux/agent-send.test.sh` failed on the unpatched implementation with `PASS=12 FAIL=3`; it selected `destination-holder` instead of both `MOSAIC_AGENT_NAME=authoritative-agent` and local session `local-agent`. The genuinely unavailable sender case already exercised and preserved `?`.
- GREEN: `bash packages/mosaic/framework/tools/tmux/agent-send.test.sh` passed with `PASS=15 FAIL=0`; coverage includes env authority, local/default tmux fallback across a destination `-L`, explicit rejection of the destination holder, and `?` fallback.
- Syntax: `bash -n packages/mosaic/framework/tools/tmux/agent-send.sh packages/mosaic/framework/tools/tmux/agent-send.test.sh` passed.
- Quality gates: `pnpm typecheck` (42/42 tasks), `pnpm lint` (23/23 tasks), and `pnpm format:check` all passed after installing the frozen lockfile dependencies. The first install attempt failed because pnpm's configured store pointed at `/root`; retrying with the existing user-owned store (`--store-dir /home/hermes/.local/share/pnpm/store`) succeeded without changing tracked dependency files.
- Documentation: no public API or operator workflow changed; the source comment, regression-test contract, and this implementation record cover the internal bug fix.
- Independent review: intentionally pending for the reviewer assigned by `mosaic-100`; this author-only lane will not self-review or merge.

View File

@@ -47,6 +47,61 @@ export MOSAIC_ADMIN_PASSWORD="securepass123"
mosaic gateway install mosaic gateway install
``` ```
## Runtime launchers
```bash
mosaic claude # Launch Claude Code with Mosaic injection
mosaic yolo claude # …with --dangerously-skip-permissions
mosaic codex | opencode | pi
```
### `mosaic claudex` (EXPERIMENTAL)
Runs GPT models **inside the Claude Code harness** by pointing Claude Code at a
local [`claude-code-proxy`](https://github.com/raine/claude-code-proxy) that
translates the Anthropic Messages API to a ChatGPT-subscription (Codex OAuth)
backend. This is **not Anthropic Claude** — model behavior, tool use, and output
quality may differ. Intended for evaluation, not production delivery.
```bash
mosaic claudex # launch (prompts through the proxy readiness gate)
mosaic yolo claudex # …with --dangerously-skip-permissions
mosaic claudex --print "hello" # trailing args are forwarded to Claude Code
```
**Prerequisite:** the `claude-code-proxy` binary must be installed and
authenticated (`claude-code-proxy codex auth …`). `mosaic claudex` runs a
preflight that verifies the binary, the OAuth state (triggering a device re-auth
if needed), and a trusted local listener before launching; it **fails closed**
if the proxy cannot be brought up with a verified identity.
**Isolation (never touches your real Claude state).** claudex always launches
against an isolated `CLAUDE_CONFIG_DIR` (default `~/.config/mosaic/claudex/home`).
The ambient `CLAUDE_CONFIG_DIR` is deliberately ignored, and a guard proves the
resolved dir can never be — or live under — the real `~/.claude`. A claudex
session therefore cannot mutate your normal Claude Code config.
**No token leakage.** claudex never reads the proxy's credential file. Claude
Code is handed only `ANTHROPIC_AUTH_TOKEN=unused` pointed at the loopback proxy;
the entire credential-bearing env family (`ANTHROPIC_*`, `AWS_*`, `GOOGLE_CLOUD_*`,
`GOOGLE_APPLICATION_CREDENTIALS`, `*_TOKEN`, `*_KEY`, `*_SECRET`, …) is stripped
from the composed environment. The Bedrock/Vertex routing switches
(`CLAUDE_CODE_USE_BEDROCK`, `CLAUDE_CODE_USE_VERTEX`, and the `_SKIP_*_AUTH`
pair) are force-removed regardless of value — otherwise their mere presence
would route Claude Code to the real Anthropic API via AWS/GCP and bypass the
proxy. The proxy holds the real OAuth credential.
**Model tiers (override via env).**
| Tier | Env var | Default |
| --------------------- | ---------------------------- | -------------- |
| primary (opus/sonnet) | `ANTHROPIC_MODEL` | `gpt-5.6-sol` |
| small/fast (haiku) | `ANTHROPIC_SMALL_FAST_MODEL` | `gpt-5.6-luna` |
Operator-provided values win over the defaults. Additional overrides:
`MOSAIC_CLAUDEX_CONFIG_DIR` (isolated config dir), `ANTHROPIC_BASE_URL` (proxy
endpoint).
## Hooks management ## Hooks management
After running `mosaic wizard`, Claude hooks are installed in `~/.claude/hooks-config.json`. After running `mosaic wizard`, Claude hooks are installed in `~/.claude/hooks-config.json`.

View File

@@ -122,12 +122,11 @@ fi
# Source label: this agent's host:session (auto-detected, overridable). # Source label: this agent's host:session (auto-detected, overridable).
if [ -z "$SRC_LABEL" ]; then if [ -z "$SRC_LABEL" ]; then
tmux_cmd=(tmux)
if [ -n "$SOCKET_NAME" ]; then
tmux_cmd+=(-L "$SOCKET_NAME")
fi
src_host=$(hostname -s 2>/dev/null || echo "?") src_host=$(hostname -s 2>/dev/null || echo "?")
src_sess=$("${tmux_cmd[@]}" display-message -p '#S' 2>/dev/null || echo "?") src_sess=${MOSAIC_AGENT_NAME:-}
if [ -z "$src_sess" ]; then
src_sess=$(tmux display-message -p '#S' 2>/dev/null || echo "?")
fi
SRC_LABEL="${src_host}:${src_sess}" SRC_LABEL="${src_host}:${src_sess}"
fi fi

View File

@@ -14,6 +14,9 @@
# 5. invalid class => exit 3, nothing sent. # 5. invalid class => exit 3, nothing sent.
# 6. --class with no value => exit 3. # 6. --class with no value => exit 3.
# 7. the documented consumer regex parses producer output for every class. # 7. the documented consumer regex parses producer output for every class.
# 8. MOSAIC_AGENT_NAME is authoritative for sender identity.
# 9. sender fallback queries local tmux, never the destination -L socket.
# 10. an undeterminable sender is stamped as "?".
set -uo pipefail set -uo pipefail
HERE=$(cd -- "$(dirname -- "$0")" && pwd) HERE=$(cd -- "$(dirname -- "$0")" && pwd)
@@ -21,22 +24,45 @@ TOOL="$HERE/agent-send.sh"
# Capture stub: stands in for send-message.sh. Decodes -b and prints the payload. # Capture stub: stands in for send-message.sh. Decodes -b and prints the payload.
STUB=$(mktemp) STUB=$(mktemp)
trap 'rm -f "$STUB"' EXIT FAKE_BIN=$(mktemp -d)
trap 'rm -f "$STUB"; rm -rf "$FAKE_BIN"' EXIT
cat >"$STUB" <<'STUB_EOF' cat >"$STUB" <<'STUB_EOF'
#!/usr/bin/env bash #!/usr/bin/env bash
set -uo pipefail set -uo pipefail
b64="" b64=""
while getopts "t:b:r:v" o; do case "$o" in b) b64=$OPTARG ;; *) : ;; esac; done while getopts "L:t:b:r:v" o; do case "$o" in b) b64=$OPTARG ;; *) : ;; esac; done
printf '%s' "$b64" | base64 -d printf '%s' "$b64" | base64 -d
STUB_EOF STUB_EOF
chmod +x "$STUB" chmod +x "$STUB"
# Fake tmux distinguishes the sender's default socket from a destination socket.
cat >"$FAKE_BIN/tmux" <<'TMUX_EOF'
#!/usr/bin/env bash
set -uo pipefail
case "${FAKE_TMUX_MODE:-sessions}" in
unavailable) exit 1 ;;
sessions)
if [ "${1:-}" = "-L" ]; then
printf '%s\n' 'destination-holder'
else
printf '%s\n' 'local-agent'
fi
;;
esac
TMUX_EOF
chmod +x "$FAKE_BIN/tmux"
PASS=0; FAIL=0 PASS=0; FAIL=0
ok() { PASS=$((PASS+1)); printf 'ok %s\n' "$1"; } ok() { PASS=$((PASS+1)); printf 'ok %s\n' "$1"; }
no() { FAIL=$((FAIL+1)); printf 'FAIL %s\n %s\n' "$1" "$2"; } no() { FAIL=$((FAIL+1)); printf 'FAIL %s\n %s\n' "$1" "$2"; }
# Run the tool with the stub injected; echoes captured payload on stdout. # Run the tool with the stub injected; echoes captured payload on stdout.
run() { AGENT_SEND_SENDER="$STUB" bash "$TOOL" -S a:src -n dsthost "$@"; } run() { AGENT_SEND_SENDER="$STUB" bash "$TOOL" -S a:src -n dsthost "$@"; }
run_auto() {
env -u MOSAIC_AGENT_NAME \
AGENT_SEND_SENDER="$STUB" PATH="$FAKE_BIN:$PATH" \
bash "$TOOL" -n dsthost "$@"
}
# Documented consumer grammar — the daemon will mirror exactly this. # Documented consumer grammar — the daemon will mirror exactly this.
GRAMMAR='^\[(\S+) -> (\S+) class=(terminal-log|actionable|human|reaction)\] (.*)$' GRAMMAR='^\[(\S+) -> (\S+) class=(terminal-log|actionable|human|reaction)\] (.*)$'
@@ -92,6 +118,30 @@ classic=$(run -s mos -m "plain body")
[[ "$classic" =~ $GRAMMAR_NOCLASS ]] && [ "${BASH_REMATCH[3]}" = "plain body" ] \ [[ "$classic" =~ $GRAMMAR_NOCLASS ]] && [ "${BASH_REMATCH[3]}" = "plain body" ] \
&& ok "grammar (no-class) parses classic line" || no "grammar (no-class) parses classic line" "line=[$classic]" && ok "grammar (no-class) parses classic line" || no "grammar (no-class) parses classic line" "line=[$classic]"
# 8. Exported pane identity wins even when dispatch targets another tmux socket.
src_host=$(hostname -s)
got=$(MOSAIC_AGENT_NAME=authoritative-agent FAKE_TMUX_MODE=sessions \
AGENT_SEND_SENDER="$STUB" PATH="$FAKE_BIN:$PATH" \
bash "$TOOL" -L destination-socket -n dsthost -s mos -m "env identity")
want="[$src_host:authoritative-agent -> dsthost:mos] env identity"
[ "$got" = "$want" ] && ok "MOSAIC_AGENT_NAME is authoritative across sockets" \
|| no "MOSAIC_AGENT_NAME is authoritative across sockets" "got=[$got] want=[$want]"
# 9. Without the env identity, self-lookup uses local tmux, not destination -L.
got=$(FAKE_TMUX_MODE=sessions run_auto -L destination-socket -s mos -m "local fallback")
want="[$src_host:local-agent -> dsthost:mos] local fallback"
[ "$got" = "$want" ] && ok "cross-socket fallback uses local sender session" \
|| no "cross-socket fallback uses local sender session" "got=[$got] want=[$want]"
[[ "$got" != *":destination-holder ->"* ]] \
&& ok "cross-socket fallback rejects destination holder identity" \
|| no "cross-socket fallback rejects destination holder identity" "got=[$got]"
# 10. If neither env nor local tmux identifies the sender, preserve '?'.
got=$(FAKE_TMUX_MODE=unavailable run_auto -L destination-socket -s mos -m "unknown fallback")
want="[$src_host:? -> dsthost:mos] unknown fallback"
[ "$got" = "$want" ] && ok "unknown sender falls back to ?" \
|| no "unknown sender falls back to ?" "got=[$got] want=[$want]"
echo "---" echo "---"
echo "PASS=$PASS FAIL=$FAIL" echo "PASS=$PASS FAIL=$FAIL"
[ "$FAIL" -eq 0 ] [ "$FAIL" -eq 0 ]

View File

@@ -1,3 +1,5 @@
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { Command } from 'commander'; import { Command } from 'commander';
import { registerBrainCommand } from '@mosaicstack/brain'; import { registerBrainCommand } from '@mosaicstack/brain';
@@ -16,6 +18,8 @@ import { registerConfigCommand } from './commands/config.js';
// without throwing. This is the "mosaic <cmd> --help exits 0" gate that // without throwing. This is the "mosaic <cmd> --help exits 0" gate that
// guards the sub-package CLI surface (CU-05-01..08) from silent breakage. // guards the sub-package CLI surface (CU-05-01..08) from silent breakage.
const CLI_PATH = fileURLToPath(new URL('../dist/cli.js', import.meta.url));
const REGISTRARS: Array<[string, (program: Command) => void]> = [ const REGISTRARS: Array<[string, (program: Command) => void]> = [
['auth', registerAuthCommand], ['auth', registerAuthCommand],
['brain', registerBrainCommand], ['brain', registerBrainCommand],
@@ -46,6 +50,40 @@ describe('sub-package CLI smoke (CU-05-10)', () => {
}); });
} }
it.each(['source', 'decisions', 'observations'] as const)(
'production CLI emits one blocked JSON object for bare --%s',
(bareOption) => {
const args = [
CLI_PATH,
'fleet',
'migrate-v1',
'preview',
'--source=source',
'--decisions=decisions',
'--observations=observations',
];
args[args.findIndex((argument) => argument.startsWith(`--${bareOption}=`))] =
`--${bareOption}`;
const result = spawnSync(process.execPath, args, { encoding: 'utf8' });
const outputLines = result.stdout.trim().split('\n');
expect(result.status).toBe(1);
expect(result.stderr).toBe('');
expect(outputLines).toHaveLength(1);
expect(JSON.parse(outputLines[0]!)).toEqual({
status: 'blocked',
blockers: [
{
code: 'missing-migration-preview-option-value',
path: `request.${bareOption}`,
detail: 'Required migration preview option value is missing.',
},
],
});
},
);
it('all nine sub-package commands coexist on a single program', () => { it('all nine sub-package commands coexist on a single program', () => {
const program = new Command(); const program = new Command();
for (const [, register] of REGISTRARS) register(program); for (const [, register] of REGISTRARS) register(program);

View File

@@ -0,0 +1,862 @@
import { describe, it, expect, vi } from 'vitest';
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
CLAUDEX_PROXY_HOST,
CLAUDEX_PROXY_PORT,
CLAUDEX_PROXY_URL,
CLAUDEX_PROXY_BINARY,
CLAUDEX_HEALTH_PATH,
CLAUDEX_HEALTH_URL,
buildAuthStatusArgs,
buildDeviceAuthArgs,
buildServeArgs,
parseAuthStatus,
checkProxyBinary,
checkAuthStatus,
runDeviceReauth,
probeLiveness,
buildSystemdUnitContent,
systemdUnitPath,
installSystemdUnit,
startNohupProxy,
verifyListenerIdentity,
runProxyPreflight,
ensureProxyRunning,
type AuthStatus,
type ProxyRunResult,
type SpawnedChild,
type ListenerIdentity,
} from './claudex-proxy.js';
/**
* P1 — Proxy preflight + lifecycle helpers for `mosaic yolo claudex`.
*
* Security-relevant invariants exercised here:
* - Liveness probe hits the proxy's dedicated `GET /healthz` and treats only a
* 2xx as "alive" — a *proxy-specific* health contract, not arbitrary HTTP on
* the port (CWE-345: a local port-squatter must not be trusted as the proxy).
* This also honors spec gotcha #1 (never `curl -f` the root, which returns
* non-2xx): `/healthz` returns 2xx when the proxy is up, so a healthy proxy is
* never mistaken for dead and no duplicate proxy is spawned.
* - Auth-status parsing NEVER surfaces OAuth token material — only a coarse
* state + optional expiry — even if a token-shaped string appears in output.
* - The systemd unit's ExecStart never interpolates an unvalidated path
* (CWE-74: a CR/LF in the path could inject arbitrary systemd directives).
* - The nohup fallback captures spawn's *async* error event instead of crashing.
*/
describe('claudex-proxy constants', () => {
it('pins the proxy endpoint to loopback :18765 (spec table)', () => {
expect(CLAUDEX_PROXY_HOST).toBe('127.0.0.1');
expect(CLAUDEX_PROXY_PORT).toBe(18765);
expect(CLAUDEX_PROXY_URL).toBe('http://127.0.0.1:18765');
expect(CLAUDEX_PROXY_BINARY).toBe('claude-code-proxy');
});
it('exposes the dedicated /healthz liveness endpoint (not the root path)', () => {
expect(CLAUDEX_HEALTH_PATH).toBe('/healthz');
expect(CLAUDEX_HEALTH_URL).toBe('http://127.0.0.1:18765/healthz');
});
it('builds the documented codex subcommand argv', () => {
expect(buildAuthStatusArgs()).toEqual(['codex', 'auth', 'status']);
expect(buildDeviceAuthArgs()).toEqual(['codex', 'auth', 'device']);
expect(buildServeArgs()).toEqual(['serve', '--no-monitor']);
});
});
describe('parseAuthStatus', () => {
it('reports valid on exit 0 with an authenticated marker', () => {
const s = parseAuthStatus({
status: 0,
stdout: 'Authenticated as user; token valid',
stderr: '',
});
expect(s.state).toBe('valid');
});
it('reports expired when output mentions expiry', () => {
const s = parseAuthStatus({ status: 0, stdout: 'Token expired 2 days ago', stderr: '' });
expect(s.state).toBe('expired');
});
it('reports unauthenticated when output says not logged in', () => {
const s = parseAuthStatus({
status: 1,
stdout: '',
stderr: 'not authenticated: run codex auth device',
});
expect(s.state).toBe('unauthenticated');
});
it('reports unknown on an unrecognized non-zero exit', () => {
const s = parseAuthStatus({ status: 2, stdout: 'weird', stderr: '' });
expect(s.state).toBe('unknown');
});
it('does NOT trust a signal-terminated check (status null) even with an auth-looking line', () => {
// status: null means the process was killed by a signal — an INCOMPLETE
// check. An auth-looking line that happened to be flushed must not be read
// as valid, or preflight passes on a check that never finished.
const s = parseAuthStatus({ status: null, stdout: 'Authenticated', stderr: '' });
expect(s.state).toBe('unknown');
});
it('extracts a best-effort expiry in days when present', () => {
const s = parseAuthStatus({
status: 0,
stdout: 'Authenticated; expires in 9 days',
stderr: '',
});
expect(s.state).toBe('valid');
expect(s.expiresInDays).toBe(9);
});
it('treats a clean exit 0 with no explicit markers as valid', () => {
const s = parseAuthStatus({ status: 0, stdout: 'Session active for account foo', stderr: '' });
expect(s.state).toBe('valid');
expect(s.expiresInDays).toBeUndefined();
});
it('NEVER retains token-shaped material from output', () => {
const leaky = 'Authenticated. access_token=sk-abc123SECRETdeadbeef refresh_token=rt-9999';
const s: AuthStatus = parseAuthStatus({ status: 0, stdout: leaky, stderr: '' });
const serialized = JSON.stringify(s);
expect(serialized).not.toContain('sk-abc123SECRETdeadbeef');
expect(serialized).not.toContain('rt-9999');
expect(serialized).not.toContain('access_token');
expect(serialized).not.toContain('refresh_token');
});
});
describe('checkAuthStatus', () => {
it('runs the status subcommand and parses the result', () => {
const run = vi.fn(
(_cmd: string, _args: string[]): ProxyRunResult => ({
status: 0,
stdout: 'Authenticated; expires in 7 days',
stderr: '',
}),
);
const s = checkAuthStatus(run);
expect(run).toHaveBeenCalledWith(CLAUDEX_PROXY_BINARY, ['codex', 'auth', 'status']);
expect(s.state).toBe('valid');
expect(s.expiresInDays).toBe(7);
});
it('surfaces unknown when the default runner cannot find the binary', () => {
// Exercises the default spawnSync path against an absent binary: no throw,
// status is non-zero/null → unknown. Deterministic on a box without the proxy.
const s = checkAuthStatus();
expect(['unknown', 'unauthenticated', 'valid', 'expired']).toContain(s.state);
});
});
describe('runDeviceReauth', () => {
it('spawns the device flow with inherited stdio (never captures the code/token)', () => {
const calls: Array<{ cmd: string; args: string[]; opts: { stdio: string } }> = [];
const status = runDeviceReauth((cmd, args, opts) => {
calls.push({ cmd, args, opts });
return { status: 0 };
});
expect(status).toBe(0);
expect(calls).toHaveLength(1);
expect(calls[0]!.cmd).toBe(CLAUDEX_PROXY_BINARY);
expect(calls[0]!.args).toEqual(['codex', 'auth', 'device']);
// stdio 'inherit' is the security-critical bit: the device code streams to
// the user's TTY; the launcher never pipes/captures it.
expect(calls[0]!.opts.stdio).toBe('inherit');
});
it('returns 1 when the child yields no status (absent binary)', () => {
const status = runDeviceReauth(() => ({ status: null }));
expect(status).toBe(1);
});
});
describe('checkProxyBinary', () => {
it('resolves via the default `which` path (proxy absent → null)', () => {
// Covers the default resolver; on CI/dev the proxy is not installed.
const r = checkProxyBinary();
expect(typeof r.present).toBe('boolean');
if (!r.present) expect(r.path).toBeNull();
});
it('reports present with the resolved path', () => {
const r = checkProxyBinary(() => '/home/u/.local/bin/claude-code-proxy');
expect(r.present).toBe(true);
expect(r.path).toBe('/home/u/.local/bin/claude-code-proxy');
});
it('reports absent when the resolver finds nothing', () => {
const r = checkProxyBinary(() => null);
expect(r.present).toBe(false);
expect(r.path).toBeNull();
});
});
describe('probeLiveness (proxy-specific /healthz, not arbitrary HTTP)', () => {
it('defaults to probing the /healthz endpoint, never the root path', async () => {
const seen: string[] = [];
await probeLiveness(undefined, async (u) => {
seen.push(u);
return { status: 200 };
});
expect(seen[0]).toBe(CLAUDEX_HEALTH_URL);
expect(seen[0]).toContain('/healthz');
});
it('treats a 200 on /healthz as alive', async () => {
const live = await probeLiveness(CLAUDEX_HEALTH_URL, async () => ({ status: 200 }));
expect(live).toBe(true);
});
it('treats a 204 on /healthz as alive', async () => {
const live = await probeLiveness(CLAUDEX_HEALTH_URL, async () => ({ status: 204 }));
expect(live).toBe(true);
});
it('treats a 404 as DEAD — does not trust an arbitrary responder on the port (CWE-345)', async () => {
// The whole point: a random local process squatting :18765 will not honor the
// proxy's /healthz contract, so a non-2xx there must not be mistaken for the proxy.
const live = await probeLiveness(CLAUDEX_HEALTH_URL, async () => ({ status: 404 }));
expect(live).toBe(false);
});
it('treats a 500 as DEAD (unhealthy / not the proxy health contract)', async () => {
const live = await probeLiveness(CLAUDEX_HEALTH_URL, async () => ({ status: 500 }));
expect(live).toBe(false);
});
it('treats a missing status as dead', async () => {
const live = await probeLiveness(CLAUDEX_HEALTH_URL, async () => ({}));
expect(live).toBe(false);
});
it('treats a connection failure (reject) as dead', async () => {
const live = await probeLiveness(CLAUDEX_HEALTH_URL, async () => {
throw new Error('ECONNREFUSED');
});
expect(live).toBe(false);
});
it('treats a timeout as dead', async () => {
const never = () => new Promise<{ status?: number }>(() => {});
const live = await probeLiveness(CLAUDEX_HEALTH_URL, never, 20);
expect(live).toBe(false);
});
});
describe('buildSystemdUnitContent', () => {
it('emits a user unit that execs the given binary with serve args', () => {
const unit = buildSystemdUnitContent('/home/u/.local/bin/claude-code-proxy');
expect(unit).toContain('[Unit]');
expect(unit).toContain('[Service]');
expect(unit).toContain('[Install]');
expect(unit).toContain('/home/u/.local/bin/claude-code-proxy serve --no-monitor');
expect(unit).toContain('WantedBy=default.target');
});
it('never embeds credential material', () => {
const unit = buildSystemdUnitContent('/home/u/.local/bin/claude-code-proxy');
expect(unit).not.toMatch(/token/i);
expect(unit).not.toMatch(/auth\.json/i);
});
it('rejects a path containing a newline (CWE-74 systemd directive injection)', () => {
// A raw newline in ExecStart would let an attacker append arbitrary unit
// directives — e.g. `ExecStartPost=curl evil`. Must be rejected outright.
expect(() =>
buildSystemdUnitContent('/bin/claude-code-proxy\nExecStartPost=/bin/rm -rf /'),
).toThrow();
});
it('rejects a path containing a carriage return', () => {
expect(() => buildSystemdUnitContent('/bin/claude-code-proxy\rmalicious')).toThrow();
});
it('rejects a path with other control characters', () => {
expect(() => buildSystemdUnitContent('/bin/claude-code-proxy\x00nul')).toThrow();
});
it('rejects a non-absolute path', () => {
expect(() => buildSystemdUnitContent('claude-code-proxy')).toThrow();
expect(() => buildSystemdUnitContent('')).toThrow();
});
it('systemd-quotes a path that contains spaces', () => {
const unit = buildSystemdUnitContent('/home/u/my apps/claude-code-proxy');
expect(unit).toContain('ExecStart="/home/u/my apps/claude-code-proxy" serve --no-monitor');
});
it('escapes embedded quotes and backslashes when quoting', () => {
const unit = buildSystemdUnitContent('/home/u/we"ird\\dir/claude-code-proxy');
// No unescaped closing quote can terminate the token early.
expect(unit).toContain('ExecStart="/home/u/we\\"ird\\\\dir/claude-code-proxy" serve');
});
it('leaves a clean absolute path unquoted (no needless churn)', () => {
const unit = buildSystemdUnitContent('/home/u/.local/bin/claude-code-proxy');
expect(unit).toContain('ExecStart=/home/u/.local/bin/claude-code-proxy serve --no-monitor');
});
});
describe('systemdUnitPath', () => {
it('targets the systemd --user unit dir', () => {
expect(systemdUnitPath('/home/u')).toBe(
'/home/u/.config/systemd/user/claude-code-proxy.service',
);
});
});
describe('installSystemdUnit', () => {
it('writes the unit and returns true when daemon-reload succeeds', () => {
let written: { path: string; content: string } | null = null;
const ok = installSystemdUnit('/bin/claude-code-proxy', {
home: '/home/u',
writeUnit: (path, content) => {
written = { path, content };
},
run: () => ({ status: 0, stdout: '', stderr: '' }),
});
expect(ok).toBe(true);
expect(written).not.toBeNull();
expect(written!.path).toBe('/home/u/.config/systemd/user/claude-code-proxy.service');
expect(written!.content).toContain('ExecStart=/bin/claude-code-proxy serve --no-monitor');
});
it('returns false when daemon-reload fails (systemd --user unavailable)', () => {
const ok = installSystemdUnit('/bin/claude-code-proxy', {
home: '/home/u',
writeUnit: () => {},
run: () => ({ status: 1, stdout: '', stderr: 'Failed to connect to bus' }),
});
expect(ok).toBe(false);
});
it('returns false when writing the unit throws', () => {
const ok = installSystemdUnit('/bin/claude-code-proxy', {
home: '/home/u',
writeUnit: () => {
throw new Error('EACCES');
},
run: () => ({ status: 0, stdout: '', stderr: '' }),
});
expect(ok).toBe(false);
});
it('refuses to write a unit for an injection-bearing path (never writes a poisoned unit)', () => {
const writeUnit = vi.fn();
const ok = installSystemdUnit('/bin/claude-code-proxy\nExecStartPost=/bin/rm -rf /', {
home: '/home/u',
writeUnit,
run: () => ({ status: 0, stdout: '', stderr: '' }),
});
expect(ok).toBe(false);
// The poisoned unit content is never even produced, so nothing is written.
expect(writeUnit).not.toHaveBeenCalled();
});
it('writes to a real temp dir via the default writer', () => {
const home = mkdtempSync(join(tmpdir(), 'claudex-unit-'));
try {
const ok = installSystemdUnit('/bin/claude-code-proxy', {
home,
run: () => ({ status: 0, stdout: '', stderr: '' }),
});
expect(ok).toBe(true);
const written = readFileSync(systemdUnitPath(home), 'utf8');
expect(written).toContain('[Service]');
} finally {
rmSync(home, { recursive: true, force: true });
}
});
});
describe('runProxyPreflight', () => {
const trustedListener = () => 'ok' as const;
it('is ok when binary present, auth valid, proxy live, and listener identity-verified', async () => {
const report = await runProxyPreflight({
checkBinary: () => ({ present: true, path: '/bin/claude-code-proxy' }),
checkAuth: () => ({ state: 'valid' }),
probe: async () => true,
verifyListener: trustedListener,
});
expect(report.ok).toBe(true);
expect(report.listenerVerdict).toBe('ok');
expect(report.problems).toEqual([]);
});
it('flags a missing binary', async () => {
const report = await runProxyPreflight({
checkBinary: () => ({ present: false, path: null }),
checkAuth: () => ({ state: 'valid' }),
probe: async () => true,
verifyListener: trustedListener,
});
expect(report.ok).toBe(false);
expect(report.problems.some((p) => /binary/i.test(p))).toBe(true);
});
it('flags expired auth (re-auth needed)', async () => {
const report = await runProxyPreflight({
checkBinary: () => ({ present: true, path: '/bin/claude-code-proxy' }),
checkAuth: () => ({ state: 'expired' }),
probe: async () => true,
verifyListener: trustedListener,
});
expect(report.ok).toBe(false);
expect(report.needsReauth).toBe(true);
expect(report.problems.some((p) => /auth/i.test(p))).toBe(true);
});
it('flags a dead proxy', async () => {
const report = await runProxyPreflight({
checkBinary: () => ({ present: true, path: '/bin/claude-code-proxy' }),
checkAuth: () => ({ state: 'valid' }),
probe: async () => false,
verifyListener: trustedListener,
});
expect(report.ok).toBe(false);
expect(report.live).toBe(false);
});
it('flags an unknown auth state without marking it for re-auth', async () => {
const report = await runProxyPreflight({
checkBinary: () => ({ present: true, path: '/bin/claude-code-proxy' }),
checkAuth: () => ({ state: 'unknown' }),
probe: async () => true,
verifyListener: trustedListener,
});
expect(report.ok).toBe(false);
expect(report.needsReauth).toBe(false);
expect(report.problems.some((p) => /could not determine/i.test(p))).toBe(true);
});
it('does NOT pass preflight when the live responder fails identity verification (F2b)', async () => {
// A squatter answering /healthz-2xx must not yield ok:true just because the
// binary is installed and OAuth is valid — the identity gate holds here too.
for (const verdict of ['foreign-user', 'wrong-exe', 'unknown'] as const) {
const report = await runProxyPreflight({
checkBinary: () => ({ present: true, path: '/bin/claude-code-proxy' }),
checkAuth: () => ({ state: 'valid' }),
probe: async () => true,
verifyListener: () => verdict,
});
expect(report.ok).toBe(false);
expect(report.live).toBe(true);
expect(report.listenerVerdict).toBe(verdict);
expect(report.problems.some((p) => /identity could not be verified/i.test(p))).toBe(true);
// The identity problem is non-sensitive: port + verdict only, no token.
expect(JSON.stringify(report)).not.toMatch(/token|sk-|auth\.json/i);
}
});
it('does not verify listener identity when the proxy is dead (no listener to trust)', async () => {
const verifyListener = vi.fn(() => 'ok' as const);
const report = await runProxyPreflight({
checkBinary: () => ({ present: true, path: '/bin/claude-code-proxy' }),
checkAuth: () => ({ state: 'valid' }),
probe: async () => false,
verifyListener,
});
expect(verifyListener).not.toHaveBeenCalled();
expect(report.listenerVerdict).toBe('unknown');
expect(report.ok).toBe(false);
});
it('does not leak token material for any auth state', async () => {
const report = await runProxyPreflight({
checkBinary: () => ({ present: true, path: '/bin/claude-code-proxy' }),
checkAuth: () => ({ state: 'expired' }),
probe: async () => false,
verifyListener: trustedListener,
});
expect(JSON.stringify(report)).not.toMatch(/token|sk-|auth\.json/i);
});
it('runs end-to-end with all real defaults (no proxy installed → not ok)', async () => {
// Exercises the default checkBinary/checkAuth/probe closures against a box
// with no proxy: absent binary, spawnSync status, real loopback probe that
// fast-fails with ECONNREFUSED. Asserts shape only (never token material).
const report = await runProxyPreflight();
expect(typeof report.ok).toBe('boolean');
expect(Array.isArray(report.problems)).toBe(true);
expect(['valid', 'expired', 'unauthenticated', 'unknown']).toContain(report.auth.state);
expect(JSON.stringify(report)).not.toMatch(/access_token|refresh_token|sk-/i);
});
});
/**
* A minimal fake ChildProcess for the nohup-fallback tests: records once()
* handlers so a test can drive the async 'spawn'/'error' events, and tracks
* whether the 'error' listener was already attached at the moment unref() ran
* (the security-critical ordering from finding #1).
*/
function fakeChild() {
const handlers: Record<string, (arg?: unknown) => void> = {};
const state = { unreffed: false, errorHandlerAtUnref: false };
const child = {
once(event: string, listener: (arg?: unknown) => void) {
handlers[event] = listener;
return child;
},
unref() {
state.unreffed = true;
state.errorHandlerAtUnref = typeof handlers.error === 'function';
},
emit(event: string, arg?: unknown) {
handlers[event]?.(arg);
},
};
return {
child: child as unknown as SpawnedChild & { emit(e: string, a?: unknown): void },
state,
};
}
describe('startNohupProxy (finding #1 — async spawn error must not crash)', () => {
it('resolves status 0 only after a confirmed spawn, and unrefs the child', async () => {
const { child, state } = fakeChild();
const spawnImpl = vi.fn((_cmd: string, _args: string[]) => {
queueMicrotask(() => child.emit('spawn'));
return child;
});
const r = await startNohupProxy({ resolveBin: () => '/bin/claude-code-proxy', spawnImpl });
expect(r.status).toBe(0);
expect(state.unreffed).toBe(true);
// The error listener MUST be registered before unref(), so an ENOENT that
// arrives asynchronously can never become an unhandled 'error' crash.
expect(state.errorHandlerAtUnref).toBe(true);
expect(spawnImpl).toHaveBeenCalledWith('/bin/claude-code-proxy', ['serve', '--no-monitor'], {
detached: true,
stdio: 'ignore',
});
});
it('captures an async spawn error (ENOENT) as a failed start instead of crashing', async () => {
const { child, state } = fakeChild();
const spawnImpl = () => {
queueMicrotask(() => child.emit('error', new Error('spawn claude-code-proxy ENOENT')));
return child;
};
const r = await startNohupProxy({ resolveBin: () => '/bin/claude-code-proxy', spawnImpl });
expect(r.status).toBe(1);
expect(r.stderr).toContain('ENOENT');
expect(state.unreffed).toBe(false); // never unref a child that failed to start
});
it('captures a synchronous spawn throw as a failed start', async () => {
const spawnImpl = () => {
throw new Error('EACCES');
};
const r = await startNohupProxy({ resolveBin: () => '/bin/claude-code-proxy', spawnImpl });
expect(r.status).toBe(1);
expect(r.stderr).toContain('EACCES');
});
it('ignores a late error after a successful spawn (settles once)', async () => {
const { child } = fakeChild();
const spawnImpl = () => {
queueMicrotask(() => {
child.emit('spawn');
child.emit('error', new Error('late boom'));
});
return child;
};
const r = await startNohupProxy({ resolveBin: () => '/bin/claude-code-proxy', spawnImpl });
expect(r.status).toBe(0); // first settle wins; the late error cannot flip it
});
});
describe('verifyListenerIdentity (finding #2 — OS-level listener identity, CWE-345)', () => {
const me: ListenerIdentity = {
pid: 4242,
uid: 1000,
exePath: '/home/me/.local/bin/claude-code-proxy',
};
// Identity canonicalize for tests: fake paths don't exist on disk, so we map
// each path to itself and exercise symlink resolution explicitly where needed.
const idc = (p: string) => p;
it('accepts a listener owned by the current uid whose exe is the expected proxy path', () => {
const verdict = verifyListenerIdentity({
identify: () => me,
currentUid: () => 1000,
expectedExe: () => '/home/me/.local/bin/claude-code-proxy',
canonicalize: idc,
});
expect(verdict).toBe('ok');
});
it('resolves symlinks on BOTH sides before comparing (canonical match → ok)', () => {
// The listener exe and our resolved binary reach the same real file via
// different symlink paths — a canonical comparison must accept it.
const canon: Record<string, string> = {
'/var/run/proxy.link': '/opt/proxy/bin/claude-code-proxy',
'/home/me/.local/bin/claude-code-proxy': '/opt/proxy/bin/claude-code-proxy',
};
const verdict = verifyListenerIdentity({
identify: () => ({ ...me, exePath: '/var/run/proxy.link' }),
currentUid: () => 1000,
expectedExe: () => '/home/me/.local/bin/claude-code-proxy',
canonicalize: (p) => canon[p] ?? null,
});
expect(verdict).toBe('ok');
});
it('does NOT trust a same-uid process at the WRONG path with the right basename (F2a)', () => {
// The squatter vector on a shared-uid host: right basename, wrong path. The
// basename must NEVER be a trust signal when an expected exact path resolved.
const verdict = verifyListenerIdentity({
identify: () => ({ ...me, exePath: '/tmp/claude-code-proxy' }),
currentUid: () => 1000,
expectedExe: () => '/home/me/.local/bin/claude-code-proxy',
canonicalize: idc,
});
expect(verdict).toBe('wrong-exe');
});
it('fails closed (unknown) when our own proxy binary path cannot be resolved (F2a)', () => {
// No expected path → we cannot assert identity → refuse to trust (no basename
// acceptance). Previously this returned `ok` by basename; that was a bypass.
const verdict = verifyListenerIdentity({
identify: () => me,
currentUid: () => 1000,
expectedExe: () => null,
canonicalize: idc,
});
expect(verdict).toBe('unknown');
});
it('fails closed (unknown) when a path cannot be canonicalized (F2a)', () => {
const verdict = verifyListenerIdentity({
identify: () => me,
currentUid: () => 1000,
expectedExe: () => '/home/me/.local/bin/claude-code-proxy',
canonicalize: () => null, // e.g. binary deleted out from under the listener
});
expect(verdict).toBe('unknown');
});
it('rejects a listener owned by a DIFFERENT uid (foreign-user) — fail closed', () => {
const verdict = verifyListenerIdentity({
identify: () => ({ ...me, uid: 0 }),
currentUid: () => 1000,
expectedExe: () => '/home/me/.local/bin/claude-code-proxy',
canonicalize: idc,
});
expect(verdict).toBe('foreign-user');
});
it('rejects a same-user listener whose exe is NOT the proxy (wrong-exe)', () => {
const verdict = verifyListenerIdentity({
identify: () => ({ ...me, exePath: '/usr/bin/nc' }),
currentUid: () => 1000,
expectedExe: () => '/home/me/.local/bin/claude-code-proxy',
canonicalize: idc,
});
expect(verdict).toBe('wrong-exe');
});
it('returns unknown (fail closed) when the listener cannot be identified', () => {
const verdict = verifyListenerIdentity({
identify: () => null,
currentUid: () => 1000,
expectedExe: () => '/home/me/.local/bin/claude-code-proxy',
canonicalize: idc,
});
expect(verdict).toBe('unknown');
});
it('returns unknown when the current uid is unavailable (non-posix)', () => {
const verdict = verifyListenerIdentity({
identify: () => me,
currentUid: () => -1,
expectedExe: () => '/home/me/.local/bin/claude-code-proxy',
canonicalize: idc,
});
expect(verdict).toBe('unknown');
});
it('returns unknown when the listener exe path cannot be read', () => {
const verdict = verifyListenerIdentity({
identify: () => ({ ...me, exePath: null }),
currentUid: () => 1000,
expectedExe: () => '/home/me/.local/bin/claude-code-proxy',
canonicalize: idc,
});
expect(verdict).toBe('unknown');
});
it('runs with real defaults without throwing (identity may be unresolved → verdict)', () => {
const verdict = verifyListenerIdentity();
expect(['ok', 'foreign-user', 'wrong-exe', 'unknown']).toContain(verdict);
});
});
describe('ensureProxyRunning', () => {
const ok: ProxyRunResult = { status: 0, stdout: '', stderr: '' };
const nohupOk = async (): Promise<ProxyRunResult> => ok;
const trusted = () => 'ok' as const;
it('is a no-op when the proxy is already live AND identity-verified', async () => {
const startSystemd = vi.fn(() => ok);
const startNohup = vi.fn(nohupOk);
const r = await ensureProxyRunning({
probe: async () => true,
verifyListener: trusted,
startSystemd,
startNohup,
waitMs: async () => {},
});
expect(r.method).toBe('already');
expect(r.live).toBe(true);
expect(startSystemd).not.toHaveBeenCalled();
expect(startNohup).not.toHaveBeenCalled();
});
it('fails closed as untrusted when a responder holds :18765 but identity is NOT ours', async () => {
// A foreign process answers /healthz but the listener is not our proxy
// (foreign uid / wrong exe / unidentifiable). We must NOT trust it and must
// NOT start a second proxy (the port is already taken) — fail closed.
const startSystemd = vi.fn(() => ok);
const startNohup = vi.fn(nohupOk);
const r = await ensureProxyRunning({
probe: async () => true,
verifyListener: () => 'foreign-user',
startSystemd,
startNohup,
waitMs: async () => {},
});
expect(r.method).toBe('untrusted');
expect(r.live).toBe(false);
expect(startSystemd).not.toHaveBeenCalled();
expect(startNohup).not.toHaveBeenCalled();
});
it('starts via systemd when available and then becomes trusted-live', async () => {
let calls = 0;
const r = await ensureProxyRunning({
probe: async () => calls++ > 0, // dead first, live after start
verifyListener: trusted,
startSystemd: () => ok,
startNohup: async () => {
throw new Error('should not fall back');
},
waitMs: async () => {},
});
expect(r.method).toBe('systemd');
expect(r.live).toBe(true);
});
it('waits past a slow systemd bind before giving up (finding #2 — no duplicate proxy)', async () => {
// systemd `start` returns 0 (job accepted) but the socket only binds on the
// 4th probe — still well within the startup deadline. nohup must NOT run,
// or two proxies would contend for :18765.
let probes = 0;
const startNohup = vi.fn(nohupOk);
const r = await ensureProxyRunning({
probe: async () => probes++ >= 3,
verifyListener: trusted,
startSystemd: () => ok,
startNohup,
waitMs: async () => {},
settleMs: 10,
startupDeadlineMs: 200,
});
expect(r.method).toBe('systemd');
expect(r.live).toBe(true);
expect(startNohup).not.toHaveBeenCalled();
});
it('does NOT fall back to nohup after systemd accepts but never binds (finding #1 — dup-proxy race)', async () => {
// systemctl start exit 0 means the job was ACCEPTED, not bound. If it binds
// just after our deadline (or systemd restarts it), a nohup fallback would
// create a SECOND proxy contending for :18765. Once systemd has accepted the
// job we never spawn nohup — we report a managed-service startup failure.
const startNohup = vi.fn(nohupOk);
const r = await ensureProxyRunning({
probe: async () => false, // never becomes live within the deadline
verifyListener: trusted,
startSystemd: () => ok,
startNohup,
waitMs: async () => {},
settleMs: 10,
startupDeadlineMs: 30,
});
expect(startNohup).not.toHaveBeenCalled();
expect(r.method).toBe('failed');
expect(r.live).toBe(false);
});
it('does NOT trust a systemd-started responder whose identity cannot be verified', async () => {
// Dead at first (so we reach the systemd start), then the socket binds — but
// identity never verifies (e.g. a squatter beat systemd to the port). A live
// responder that fails identity must never be reported as a successful start.
let calls = 0;
const startNohup = vi.fn(nohupOk);
const r = await ensureProxyRunning({
probe: async () => calls++ > 0,
verifyListener: () => 'wrong-exe',
startSystemd: () => ok,
startNohup,
waitMs: async () => {},
settleMs: 10,
startupDeadlineMs: 30,
});
expect(startNohup).not.toHaveBeenCalled();
expect(r.method).toBe('failed');
expect(r.live).toBe(false);
});
it('falls back to nohup only when systemd start FAILS outright (not accepted)', async () => {
let calls = 0;
const r = await ensureProxyRunning({
// A failed systemd start skips its post-start poll, so probes are:
// #0 initial (dead), #1 after nohup (live). nohup fallback is reachable
// ONLY because systemd never accepted the job (status 1).
probe: async () => calls++ > 0,
verifyListener: trusted,
startSystemd: () => ({ status: 1, stdout: '', stderr: 'no systemd' }),
startNohup: nohupOk,
waitMs: async () => {},
settleMs: 10,
startupDeadlineMs: 30,
});
expect(r.method).toBe('nohup');
expect(r.live).toBe(true);
});
it('does NOT trust a nohup-started responder whose identity cannot be verified', async () => {
let calls = 0;
const r = await ensureProxyRunning({
probe: async () => calls++ > 0,
verifyListener: () => 'unknown',
startSystemd: () => ({ status: 1, stdout: '', stderr: 'no systemd' }),
startNohup: nohupOk,
waitMs: async () => {},
settleMs: 10,
startupDeadlineMs: 30,
});
expect(r.method).toBe('failed');
expect(r.live).toBe(false);
});
it('reports failed when nothing brings the proxy up', async () => {
const r = await ensureProxyRunning({
probe: async () => false,
verifyListener: trusted,
startSystemd: () => ({ status: 1, stdout: '', stderr: '' }),
startNohup: async () => ({ status: 1, stdout: '', stderr: '' }),
waitMs: async () => {},
settleMs: 10,
startupDeadlineMs: 30,
});
expect(r.method).toBe('failed');
expect(r.live).toBe(false);
});
});

View File

@@ -0,0 +1,700 @@
/**
* Claudex proxy preflight + lifecycle (P1 of `mosaic yolo claudex`).
*
* `raine/claude-code-proxy` runs a local server on 127.0.0.1:18765 that speaks
* the Anthropic Messages API and translates to the ChatGPT/Codex backend using
* ChatGPT-subscription OAuth. This module owns the *preflight* and *lifecycle*
* concerns for the launcher: is the binary present, is OAuth valid, is the proxy
* listening, and — if not — bring it up (systemd user unit preferred, nohup
* fallback).
*
* Design: every function is pure or dependency-injected so the launch path is
* fully unit-testable without touching a real process, socket, or the OAuth
* token. Nothing here reads `~/.config/claude-code-proxy/codex/auth.json`; the
* proxy holds the real credential and Claude Code only ever sees
* `ANTHROPIC_AUTH_TOKEN=unused`. Parsed auth status is deliberately coarse
* (state + optional expiry) so no token material can be retained or surfaced.
*/
import { execFileSync, spawn, spawnSync } from 'node:child_process';
import { mkdirSync, readFileSync, readlinkSync, realpathSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { dirname, join } from 'node:path';
// ─── Endpoint / command constants (spec table) ──────────────────────────────
export const CLAUDEX_PROXY_HOST = '127.0.0.1';
export const CLAUDEX_PROXY_PORT = 18765;
export const CLAUDEX_PROXY_URL = `http://${CLAUDEX_PROXY_HOST}:${CLAUDEX_PROXY_PORT}`;
export const CLAUDEX_PROXY_BINARY = 'claude-code-proxy';
export const CLAUDEX_SYSTEMD_UNIT = 'claude-code-proxy.service';
/**
* The proxy's dedicated liveness endpoint. We probe this — NOT the root path —
* for two reasons: (1) the root returns non-2xx (spec gotcha #1), which is why
* the original `curl -f` check spawned duplicate proxies; `/healthz` returns 2xx
* when the proxy is healthy. (2) It is a *proxy-specific* contract, so a 2xx here
* is a much stronger signal that the responder on :18765 is actually our proxy
* and not some other local process squatting the port (CWE-345).
*/
export const CLAUDEX_HEALTH_PATH = '/healthz';
export const CLAUDEX_HEALTH_URL = `${CLAUDEX_PROXY_URL}${CLAUDEX_HEALTH_PATH}`;
/** argv for `claude-code-proxy codex auth status`. */
export function buildAuthStatusArgs(): string[] {
return ['codex', 'auth', 'status'];
}
/** argv for `claude-code-proxy codex auth device` (device-code re-auth flow). */
export function buildDeviceAuthArgs(): string[] {
return ['codex', 'auth', 'device'];
}
/** argv for `claude-code-proxy serve --no-monitor`. */
export function buildServeArgs(): string[] {
return ['serve', '--no-monitor'];
}
// ─── Types ──────────────────────────────────────────────────────────────────
export type AuthState = 'valid' | 'expired' | 'unauthenticated' | 'unknown';
/**
* Coarse OAuth status. Intentionally carries NO token material — only a state
* and an optional best-effort expiry-in-days for user-facing messaging.
*/
export interface AuthStatus {
state: AuthState;
expiresInDays?: number;
}
export interface ProxyRunResult {
status: number | null;
stdout: string;
stderr: string;
}
/** Runs a command synchronously and returns its captured result. */
export type CommandRunner = (cmd: string, args: string[]) => ProxyRunResult;
/** Minimal fetch shape used for the liveness probe (any HTTP response = alive). */
export type FetchLike = (
url: string,
init?: { signal?: AbortSignal },
) => Promise<{ status?: number }>;
// ─── Binary presence ─────────────────────────────────────────────────────────
function defaultWhich(cmd: string): string | null {
try {
return execFileSync('which', [cmd], { encoding: 'utf8' }).trim() || null;
} catch {
return null;
}
}
export function checkProxyBinary(resolve: (cmd: string) => string | null = defaultWhich): {
present: boolean;
path: string | null;
} {
const path = resolve(CLAUDEX_PROXY_BINARY);
return { present: path !== null && path !== '', path: path || null };
}
// ─── Auth status ─────────────────────────────────────────────────────────────
/**
* Parse `claude-code-proxy codex auth status` output into a coarse state.
*
* The proxy's exact wording is not contractually pinned, so this matches
* tolerantly on well-known markers and falls back on the exit code. It never
* copies the raw output onto the result — only a state and an optional expiry —
* so token-shaped strings in the output cannot leak downstream.
*/
export function parseAuthStatus(result: ProxyRunResult): AuthStatus {
const text = `${result.stdout}\n${result.stderr}`.toLowerCase();
const expired = /\bexpired\b|token has expired|expires?d? \d+ days? ago/.test(text);
const unauth =
/not authenticated|not logged in|no (?:auth|credentials|token)|please (?:log ?in|authenticate)|run .*auth device/.test(
text,
);
const authed = /\bauthenticated\b|logged in|token valid|valid until|expires? in/.test(text);
let state: AuthState;
if (expired) {
state = 'expired';
} else if (unauth) {
state = 'unauthenticated';
} else if (authed && result.status === 0) {
// A `null` status means the check was killed by a signal — an INCOMPLETE
// run. We require a clean exit 0 for `valid`; a partially-flushed auth line
// from a signal-terminated check must never be trusted (finding #3).
state = 'valid';
} else if (result.status === 0) {
state = 'valid';
} else {
state = 'unknown';
}
const status: AuthStatus = { state };
const days = /expires? in (\d+) days?/.exec(text);
if (state === 'valid' && days) {
status.expiresInDays = Number(days[1]);
}
return status;
}
function defaultRun(cmd: string, args: string[]): ProxyRunResult {
const r = spawnSync(cmd, args, { encoding: 'utf8' });
return { status: r.status, stdout: r.stdout ?? '', stderr: r.stderr ?? '' };
}
export function checkAuthStatus(run: CommandRunner = defaultRun): AuthStatus {
return parseAuthStatus(run(CLAUDEX_PROXY_BINARY, buildAuthStatusArgs()));
}
/** Spawn shape for the interactive device re-auth flow. */
export type InheritSpawn = (
cmd: string,
args: string[],
opts: { stdio: 'inherit' },
) => { status: number | null };
function defaultInheritSpawn(cmd: string, args: string[], opts: { stdio: 'inherit' }) {
return spawnSync(cmd, args, opts);
}
/**
* Run the device-code re-auth flow (`claude-code-proxy codex auth device`).
*
* Deliberately `stdio: 'inherit'` so the device code the proxy prints goes
* straight to the user's terminal — the launcher NEVER captures, stores, or logs
* it, and never observes the resulting OAuth token (the proxy persists that to
* its own config). Returns the child's exit status; 1 on an absent binary.
*/
export function runDeviceReauth(spawnImpl: InheritSpawn = defaultInheritSpawn): number {
const r = spawnImpl(CLAUDEX_PROXY_BINARY, buildDeviceAuthArgs(), { stdio: 'inherit' });
return r.status ?? 1;
}
// ─── Liveness (probe the proxy-specific /healthz; require 2xx) ────────────────
/**
* Probe the proxy for liveness by hitting its dedicated `GET /healthz` endpoint
* and requiring a 2xx response.
*
* This is a LIVENESS check only — it answers "is a healthy proxy responding?",
* not "is that responder actually ours?". Requiring a 2xx on the proxy's own
* `/healthz` contract (rather than "any HTTP response = alive") resolves spec
* gotcha #1: the root path returns non-2xx, but `/healthz` returns 2xx when
* healthy, so a live proxy is never mistaken for dead and no duplicate proxy is
* spawned.
*
* Residual risk (CWE-345): the proxy binds loopback with NO client
* authentication, so on a shared host a local process could occupy :18765 and
* serve a 2xx here. A 2xx therefore does NOT by itself establish that the
* listener is our proxy. Identity is verified SEPARATELY and at every trust
* point by {@link verifyListenerIdentity} (OS-level uid + executable check),
* which fails closed when identity can't be established. See
* {@link ensureProxyRunning}. (Broader multi-user hardening — a persistent
* warning when a foreign listener is seen — is tracked for a later phase.)
*/
export async function probeLiveness(
url: string = CLAUDEX_HEALTH_URL,
fetchImpl: FetchLike = fetch as unknown as FetchLike,
timeoutMs = 1500,
): Promise<boolean> {
const controller = new AbortController();
let timer: ReturnType<typeof setTimeout> | undefined;
// Bound the probe with our own timeout race rather than trusting the fetch
// implementation to honor the abort signal — a hung socket (or a fetch that
// ignores the signal) must never wedge the launcher. We still abort() so a
// signal-aware fetch tears the request down promptly.
const timeout = new Promise<boolean>((resolve) => {
timer = setTimeout(() => {
controller.abort();
resolve(false);
}, timeoutMs);
});
const probe = fetchImpl(url, { signal: controller.signal })
.then((res) => typeof res.status === 'number' && res.status >= 200 && res.status < 300)
.catch(() => false); // connection refused / aborted → dead
try {
return await Promise.race([probe, timeout]);
} finally {
if (timer) clearTimeout(timer);
}
}
// ─── Listener identity (OS-level, CWE-345 mitigation) ─────────────────────────
/**
* The result of verifying who actually owns the :18765 listener.
* - `ok` — same-user process running the expected proxy binary.
* - `foreign-user` — a process owned by a DIFFERENT uid holds the port.
* - `wrong-exe` — same-user, but the executable is not the proxy.
* - `unknown` — identity could not be established (fail closed).
*/
export type ListenerVerdict = 'ok' | 'foreign-user' | 'wrong-exe' | 'unknown';
/** OS-level identity of the process bound to the proxy port. */
export interface ListenerIdentity {
pid: number;
uid: number;
/** Absolute path of the process executable, or null if unreadable. */
exePath: string | null;
}
export interface VerifyListenerDeps {
/** Resolve the process bound to the proxy port (null → unidentifiable). */
identify?: () => ListenerIdentity | null;
/** The current process uid (-1 when unavailable, e.g. non-posix). */
currentUid?: () => number;
/** The expected proxy executable path (null when it can't be resolved). */
expectedExe?: () => string | null;
/** Canonicalize a path (resolve symlinks); null when it can't be resolved. */
canonicalize?: (p: string) => string | null;
}
/** Resolve a path through symlinks to its canonical form; null on any failure. */
function defaultCanonicalize(p: string): string | null {
try {
return realpathSync(p);
} catch {
return null;
}
}
/**
* Identify the process listening on the proxy port via `ss` + `/proc`. Every
* failure path returns null so the caller fails closed. Reads no credential
* material — only pid/uid/exe path of the listener.
*/
function defaultIdentifyListener(port: number = CLAUDEX_PROXY_PORT): ListenerIdentity | null {
try {
const out = execFileSync('ss', ['-H', '-ltnp', `sport = :${port}`], { encoding: 'utf8' });
const pidMatch = /pid=(\d+)/.exec(out);
if (!pidMatch) return null;
const pid = Number(pidMatch[1]);
if (!Number.isInteger(pid) || pid <= 0) return null;
const status = readFileSync(`/proc/${pid}/status`, 'utf8');
const uidLine = /^Uid:\s*(\d+)/m.exec(status);
if (!uidLine) return null;
const uid = Number(uidLine[1]);
let exePath: string | null = null;
try {
exePath = readlinkSync(`/proc/${pid}/exe`);
} catch {
exePath = null;
}
return { pid, uid, exePath };
} catch {
return null;
}
}
/**
* Verify that the process owning :18765 is genuinely OUR proxy before trusting
* it. The proxy binds loopback with NO client authentication, so on a shared
* host any local process could squat the port and a liveness 2xx alone does not
* prove identity (CWE-345). We FAIL CLOSED (`unknown`) whenever identity cannot
* be established. This needs no upstream shared-secret/unix-socket support from
* `claude-code-proxy`.
*
* The executable path is the trust boundary that matters: on a shared-uid host
* (every agent session runs as the same operator) same-uid is NOT sufficient, so
* we require an EXACT canonical-path match against our resolved proxy binary and
* canonicalize both sides for symlinks. There is deliberately NO basename
* fallback — a same-uid process running `/tmp/claude-code-proxy` (right name,
* wrong path) must never be trusted. If our own binary path can't be resolved,
* or either path can't be canonicalized, we fail closed rather than downgrade to
* a weaker check.
*/
export function verifyListenerIdentity(deps: VerifyListenerDeps = {}): ListenerVerdict {
const identify = deps.identify ?? (() => defaultIdentifyListener());
const currentUid =
deps.currentUid ?? (() => (typeof process.getuid === 'function' ? process.getuid() : -1));
const expectedExe = deps.expectedExe ?? (() => checkProxyBinary().path);
const canonicalize = deps.canonicalize ?? defaultCanonicalize;
const id = identify();
if (!id) return 'unknown'; // can't see the listener → don't trust it
const uid = currentUid();
if (uid < 0) return 'unknown'; // can't establish our own identity → fail closed
if (id.uid !== uid) return 'foreign-user'; // someone else's process holds the port
if (!id.exePath) return 'unknown'; // can't confirm the executable → fail closed
const expected = expectedExe();
if (!expected) return 'unknown'; // can't resolve our own binary → fail closed
const expectedReal = canonicalize(expected);
const actualReal = canonicalize(id.exePath);
if (!expectedReal || !actualReal) return 'unknown'; // uncanonicalizable → fail closed
return actualReal === expectedReal ? 'ok' : 'wrong-exe';
}
// ─── systemd user unit ───────────────────────────────────────────────────────
export function systemdUnitPath(home: string = homedir()): string {
return join(home, '.config', 'systemd', 'user', CLAUDEX_SYSTEMD_UNIT);
}
/**
* Validate a path destined for a systemd `ExecStart=` line. A raw newline (or
* other control character) in the path would let an attacker inject arbitrary
* unit directives (e.g. an extra `ExecStartPost=`), a CWE-74 command injection.
* We require a plain absolute path and reject any control character outright.
*/
function validateExecPath(binaryPath: string): string {
if (typeof binaryPath !== 'string' || binaryPath.length === 0) {
throw new Error('systemd ExecStart: binary path is empty');
}
if (!binaryPath.startsWith('/')) {
throw new Error(
`systemd ExecStart: binary path must be absolute: ${JSON.stringify(binaryPath)}`,
);
}
if (/[\x00-\x1f\x7f]/.test(binaryPath)) {
throw new Error('systemd ExecStart: binary path contains control characters');
}
return binaryPath;
}
/**
* Encode a validated path for a systemd `ExecStart=` token. systemd only needs
* quoting when the token carries whitespace or quote/backslash characters; a
* clean path is emitted verbatim. When quoting, we escape backslashes and double
* quotes per systemd's C-style rules so the token cannot be terminated early.
*/
function systemdQuoteExec(path: string): string {
if (!/[\s"'\\]/.test(path)) {
return path;
}
const escaped = path.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
return `"${escaped}"`;
}
/**
* Render the `claude-code-proxy.service` user unit. Contains no credential
* material — the proxy reads its own OAuth token from its config dir at runtime.
* The binary path is validated (absolute, no control characters) and systemd-
* quoted so it cannot inject unit directives.
*/
export function buildSystemdUnitContent(binaryPath: string): string {
const exec = `${systemdQuoteExec(validateExecPath(binaryPath))} ${buildServeArgs().join(' ')}`;
return [
'[Unit]',
'Description=claude-code-proxy (Anthropic->Codex translation proxy for mosaic claudex)',
'After=network-online.target',
'Wants=network-online.target',
'',
'[Service]',
'Type=simple',
`ExecStart=${exec}`,
'Restart=on-failure',
'RestartSec=2',
'',
'[Install]',
'WantedBy=default.target',
'',
].join('\n');
}
/**
* Write the user unit and reload the systemd --user daemon. Returns false when
* systemd --user is unavailable (the caller then falls back to nohup).
*/
export function installSystemdUnit(
binaryPath: string,
deps: {
home?: string;
writeUnit?: (path: string, content: string) => void;
run?: CommandRunner;
} = {},
): boolean {
const home = deps.home ?? homedir();
const write =
deps.writeUnit ??
((path: string, content: string) => {
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, content);
});
const run = deps.run ?? defaultRun;
try {
write(systemdUnitPath(home), buildSystemdUnitContent(binaryPath));
const reload = run('systemctl', ['--user', 'daemon-reload']);
return reload.status === 0;
} catch {
return false;
}
}
// ─── Preflight report ────────────────────────────────────────────────────────
export interface PreflightReport {
binaryPresent: boolean;
binaryPath: string | null;
auth: AuthStatus;
live: boolean;
/** OS-level identity verdict for the :18765 listener (`unknown` when dead). */
listenerVerdict: ListenerVerdict;
needsReauth: boolean;
ok: boolean;
problems: string[];
}
export interface PreflightDeps {
checkBinary?: () => { present: boolean; path: string | null };
checkAuth?: () => AuthStatus;
probe?: () => Promise<boolean>;
verifyListener?: () => ListenerVerdict;
}
/**
* Compose the preflight checks into a single structured report. `ok` is true
* only when the binary is present, OAuth is valid, the proxy responds, AND the
* responding listener's OS-level identity verifies as our proxy.
*
* The identity gate lives here too, not only in {@link ensureProxyRunning}: any
* consumer of this report (notably the phase-2 launch path) would otherwise
* treat a `/healthz`-2xx squatter as healthy and route Claude traffic to it
* (CWE-345). A liveness 2xx is necessary but not sufficient — a live responder
* that fails identity fails the preflight.
*/
export async function runProxyPreflight(deps: PreflightDeps = {}): Promise<PreflightReport> {
const checkBinary = deps.checkBinary ?? (() => checkProxyBinary());
const checkAuth = deps.checkAuth ?? (() => checkAuthStatus());
const probe = deps.probe ?? (() => probeLiveness());
const verifyListener = deps.verifyListener ?? (() => verifyListenerIdentity());
const bin = checkBinary();
const auth = checkAuth();
const live = await probe();
// Only meaningful when something is actually responding; a dead port has no
// listener identity to establish.
const listenerVerdict: ListenerVerdict = live ? verifyListener() : 'unknown';
const problems: string[] = [];
if (!bin.present) {
problems.push(
`claude-code-proxy binary not found in PATH. Install it before launching claudex.`,
);
}
const needsReauth = auth.state === 'expired' || auth.state === 'unauthenticated';
if (needsReauth) {
problems.push(
`claude-code-proxy OAuth is ${auth.state}. Re-auth with: ${CLAUDEX_PROXY_BINARY} ${buildDeviceAuthArgs().join(' ')}`,
);
} else if (auth.state === 'unknown') {
problems.push('Could not determine claude-code-proxy OAuth status.');
}
if (!live) {
problems.push(`No proxy responding on ${CLAUDEX_PROXY_URL}.`);
} else if (listenerVerdict !== 'ok') {
// Non-sensitive: names the port and the verdict only — never any listener
// command line, token, or other process detail.
problems.push(
`A process is listening on ${CLAUDEX_PROXY_URL} but its identity could not be verified as ${CLAUDEX_PROXY_BINARY} (${listenerVerdict}). Refusing to trust it.`,
);
}
const ok = bin.present && auth.state === 'valid' && live && listenerVerdict === 'ok';
return {
binaryPresent: bin.present,
binaryPath: bin.path,
auth,
live,
listenerVerdict,
needsReauth,
ok,
problems,
};
}
// ─── Lifecycle: ensure the proxy is running ──────────────────────────────────
export type ProxyStartMethod = 'already' | 'systemd' | 'nohup' | 'untrusted' | 'failed';
export interface EnsureProxyResult {
live: boolean;
method: ProxyStartMethod;
}
/** Minimal spawned-child shape used by the nohup fallback (testable seam). */
export interface SpawnedChild {
once(event: string, listener: (arg?: unknown) => void): unknown;
unref(): void;
}
/** Spawn shape for the detached fallback process. */
export type SpawnLike = (
cmd: string,
args: string[],
opts: { detached: boolean; stdio: 'ignore' },
) => SpawnedChild;
export interface StartNohupDeps {
resolveBin?: () => string;
spawnImpl?: SpawnLike;
}
/**
* Start the proxy as a detached background process (the fallback when no systemd
* user unit is available).
*
* `spawn()` reports launch failures (ENOENT/EACCES) ASYNCHRONOUSLY via the
* child's `error` event, which a `try/catch` cannot see. If left unhandled that
* event throws and crashes the launcher. So we: (1) attach the `error` listener
* BEFORE `unref()`, capturing a failed launch as a non-zero result instead of a
* crash; and (2) resolve success only after the child's `spawn` event fires —
* never optimistically before the process is known to have started.
*/
export function startNohupProxy(deps: StartNohupDeps = {}): Promise<ProxyRunResult> {
const resolveBin = deps.resolveBin ?? (() => checkProxyBinary().path ?? CLAUDEX_PROXY_BINARY);
const spawnImpl =
deps.spawnImpl ?? ((cmd, args, opts) => spawn(cmd, args, opts) as unknown as SpawnedChild);
return new Promise<ProxyRunResult>((resolve) => {
let settled = false;
const finish = (r: ProxyRunResult) => {
if (!settled) {
settled = true;
resolve(r);
}
};
let child: SpawnedChild;
try {
child = spawnImpl(resolveBin(), buildServeArgs(), { detached: true, stdio: 'ignore' });
} catch (err) {
finish({ status: 1, stdout: '', stderr: err instanceof Error ? err.message : String(err) });
return;
}
// Register error handling BEFORE unref so an async spawn failure is caught.
child.once('error', (err) => {
finish({
status: 1,
stdout: '',
stderr: err instanceof Error ? err.message : String(err),
});
});
child.once('spawn', () => {
child.unref();
finish({ status: 0, stdout: '', stderr: '' });
});
});
}
export interface EnsureProxyDeps {
probe?: () => Promise<boolean>;
/** OS-level identity check for the process holding the proxy port. */
verifyListener?: () => ListenerVerdict;
startSystemd?: () => ProxyRunResult;
startNohup?: () => Promise<ProxyRunResult>;
waitMs?: (ms: number) => Promise<void>;
/** Interval between liveness polls while waiting for a start to bind. */
settleMs?: number;
/** Total budget to wait for a started proxy to bind its socket. */
startupDeadlineMs?: number;
}
function defaultWait(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function defaultStartSystemd(): ProxyRunResult {
return defaultRun('systemctl', ['--user', 'start', CLAUDEX_SYSTEMD_UNIT]);
}
/**
* Poll for a TRUSTED-live proxy up to a bounded startup deadline. A start command
* returning 0 only means the job was ACCEPTED, not that the socket is bound — so
* we keep probing at `intervalMs` until either the deadline elapses or the port
* both responds AND passes the OS-level identity check. Liveness alone is not
* enough: a responder that fails identity (a squatter) must never be trusted.
*/
async function waitForTrusted(
probe: () => Promise<boolean>,
verifyListener: () => ListenerVerdict,
waitMs: (ms: number) => Promise<void>,
intervalMs: number,
deadlineMs: number,
): Promise<boolean> {
let elapsed = 0;
while (elapsed < deadlineMs) {
await waitMs(intervalMs);
elapsed += intervalMs;
if ((await probe()) && verifyListener() === 'ok') {
return true;
}
}
return false;
}
/**
* Ensure a proxy is listening. No-op when already live. Otherwise prefer the
* systemd user unit, then fall back to a detached background process.
*
* Every trust point is gated on OS-level listener identity, not just liveness:
* the proxy has no client authentication, so on a shared host a local process
* could squat :18765 and a 2xx `/healthz` alone would not prove it is our proxy
* (CWE-345, finding #2). We only trust a responder whose owning process is the
* current uid running the expected proxy binary; otherwise we fail closed.
*
* If a responder is already present but its identity does NOT verify, we return
* `untrusted` WITHOUT starting anything — the port is taken, so spawning would
* only create contention, and we must never route Claude traffic through an
* unverified listener.
*
* After a start command is accepted we poll to a bounded startup deadline before
* giving up: `systemctl start` exit 0 means the job was accepted, not that the
* socket bound within one probe interval. Critically, once systemd ACCEPTS the
* job we do NOT fall back to nohup even if it never becomes trusted-live in the
* deadline (finding #1): the accepted unit may bind late or be restarted by
* systemd, and a second proxy would then contend for :18765 — the very
* duplicate-proxy outcome this function exists to prevent. nohup is reachable
* only when systemd never accepted the job at all.
*/
export async function ensureProxyRunning(deps: EnsureProxyDeps = {}): Promise<EnsureProxyResult> {
const probe = deps.probe ?? (() => probeLiveness());
const verifyListener = deps.verifyListener ?? (() => verifyListenerIdentity());
const startSystemd = deps.startSystemd ?? defaultStartSystemd;
const startNohup = deps.startNohup ?? (() => startNohupProxy());
const waitMs = deps.waitMs ?? defaultWait;
const settleMs = deps.settleMs ?? 500;
const startupDeadlineMs = deps.startupDeadlineMs ?? 5000;
if (await probe()) {
// Something answers on :18765 — trust it ONLY if it is provably our proxy.
return verifyListener() === 'ok'
? { live: true, method: 'already' }
: { live: false, method: 'untrusted' };
}
const systemd = startSystemd();
if (systemd.status === 0) {
// systemd accepted the job. Wait for a trusted-live bind, but never fall
// back to nohup afterward — that would risk a duplicate proxy (finding #1).
if (await waitForTrusted(probe, verifyListener, waitMs, settleMs, startupDeadlineMs)) {
return { live: true, method: 'systemd' };
}
return { live: false, method: 'failed' };
}
const nohup = await startNohup();
if (nohup.status === 0) {
if (await waitForTrusted(probe, verifyListener, waitMs, settleMs, startupDeadlineMs)) {
return { live: true, method: 'nohup' };
}
}
return { live: false, method: 'failed' };
}

View File

@@ -0,0 +1,732 @@
import { describe, it, expect, vi } from 'vitest';
import { mkdtempSync, mkdirSync, symlinkSync, rmSync, lstatSync, writeFileSync } from 'node:fs';
import { tmpdir, homedir } from 'node:os';
import { join } from 'node:path';
import {
CLAUDEX_CONFIG_DIR_ENV,
CLAUDEX_DEFAULT_PRIMARY_MODEL,
CLAUDEX_DEFAULT_SMALL_FAST_MODEL,
CLAUDEX_CREDENTIAL_ENV_RE,
defaultClaudexConfigDir,
assertIsolatedConfigDir,
resolveClaudexConfigDir,
resolveClaudexModels,
buildClaudexEnv,
buildClaudexBanner,
buildClaudexContractNote,
runClaudexProxyGate,
launchClaudex,
type ClaudexHarnessAdapter,
} from './claudex.js';
import { CLAUDEX_PROXY_URL, type PreflightReport } from './claudex-proxy.js';
// ─── helpers ─────────────────────────────────────────────────────────────────
function makeReport(overrides: Partial<PreflightReport> = {}): PreflightReport {
return {
binaryPresent: true,
binaryPath: '/usr/bin/claude-code-proxy',
auth: { state: 'valid' },
live: true,
listenerVerdict: 'ok',
needsReauth: false,
ok: true,
problems: [],
...overrides,
};
}
function okAdapter(overrides: Partial<ClaudexHarnessAdapter> = {}): ClaudexHarnessAdapter {
return {
harnessPreflight: () => {},
composePrompt: () => '# Composed Claude contract',
exec: () => {},
...overrides,
};
}
// Identity canonicalizer + no-op FS deps so config-dir logic is tested purely.
const idCanon = (p: string): string => p;
const noFsDeps = { canonicalize: idCanon, mkdir: () => {}, isSymlink: () => false };
// ─── isolated config dir (HARD SECURITY REQ 1 — provable isolation) ───────────
describe('defaultClaudexConfigDir', () => {
it('is namespaced under the mosaic home, never ~/.claude', () => {
const dir = defaultClaudexConfigDir('/home/agent/.config/mosaic');
expect(dir).toBe(join('/home/agent/.config/mosaic', 'claudex', 'home'));
expect(dir).not.toBe(join(homedir(), '.claude'));
});
});
describe('assertIsolatedConfigDir — the isolation guard is provable', () => {
const realClaude = '/home/agent/.claude';
it('accepts a dir that does not resolve to ~/.claude', () => {
const safe = '/home/agent/.config/mosaic/claudex/home';
expect(
assertIsolatedConfigDir(safe, { realClaudeDir: realClaude, canonicalize: idCanon }),
).toBe(safe);
});
it('REJECTS a candidate that is literally ~/.claude', () => {
expect(() =>
assertIsolatedConfigDir(realClaude, { realClaudeDir: realClaude, canonicalize: idCanon }),
).toThrow(/refusing/i);
});
it('REJECTS a descendant of ~/.claude (would pollute the real tree)', () => {
expect(() =>
assertIsolatedConfigDir('/home/agent/.claude/projects/x', {
realClaudeDir: realClaude,
canonicalize: idCanon,
}),
).toThrow(/refusing/i);
});
it('REJECTS a candidate that canonically resolves to ~/.claude (symlink, both sides canonicalized)', () => {
const canon = (p: string): string => (p === '/home/agent/link' ? realClaude : p);
expect(() =>
assertIsolatedConfigDir('/home/agent/link', {
realClaudeDir: realClaude,
canonicalize: canon,
}),
).toThrow(/refusing/i);
});
it('canonicalizes the ~/.claude side too (real dir itself may be a symlink)', () => {
// realClaudeDir is a symlink whose canonical target equals the candidate's target.
const canon = (p: string): string =>
p === '/home/agent/.claude' || p === '/home/agent/link' ? '/canonical/claude' : p;
expect(() =>
assertIsolatedConfigDir('/home/agent/link', {
realClaudeDir: realClaude,
canonicalize: canon,
}),
).toThrow(/refusing/i);
});
it('REJECTS an empty or whitespace candidate (fail closed)', () => {
expect(() =>
assertIsolatedConfigDir('', { realClaudeDir: realClaude, canonicalize: idCanon }),
).toThrow();
expect(() =>
assertIsolatedConfigDir(' ', { realClaudeDir: realClaude, canonicalize: idCanon }),
).toThrow();
});
it('REJECTS a relative candidate (must be absolute)', () => {
expect(() =>
assertIsolatedConfigDir('relative/dir', { realClaudeDir: realClaude, canonicalize: idCanon }),
).toThrow(/absolute/i);
});
});
describe('resolveClaudexConfigDir', () => {
it('uses the namespaced default and never the ambient CLAUDE_CONFIG_DIR', () => {
// Ambient CLAUDE_CONFIG_DIR is deliberately ignored (it could be ~/.claude).
const env = { CLAUDE_CONFIG_DIR: join(homedir(), '.claude') };
const dir = resolveClaudexConfigDir(env, {
mosaicHome: '/home/agent/.config/mosaic',
realClaudeDir: '/home/agent/.claude',
...noFsDeps,
});
expect(dir).toBe(join('/home/agent/.config/mosaic', 'claudex', 'home'));
});
it('honors the dedicated override env when it is safe', () => {
const env = { [CLAUDEX_CONFIG_DIR_ENV]: '/home/agent/custom-claudex' };
const dir = resolveClaudexConfigDir(env, {
mosaicHome: '/home/agent/.config/mosaic',
realClaudeDir: '/home/agent/.claude',
...noFsDeps,
});
expect(dir).toBe('/home/agent/custom-claudex');
});
it('REJECTS a dedicated override that points at ~/.claude (before creating anything)', () => {
const mkdir = vi.fn();
const env = { [CLAUDEX_CONFIG_DIR_ENV]: '/home/agent/.claude' };
expect(() =>
resolveClaudexConfigDir(env, {
mosaicHome: '/home/agent/.config/mosaic',
realClaudeDir: '/home/agent/.claude',
canonicalize: idCanon,
mkdir,
isSymlink: () => false,
}),
).toThrow(/refusing/i);
expect(mkdir).not.toHaveBeenCalled();
});
it('TOCTOU: REJECTS when the created target is itself a symlink (pre-created race)', () => {
const env = {};
expect(() =>
resolveClaudexConfigDir(env, {
mosaicHome: '/home/agent/.config/mosaic',
realClaudeDir: '/home/agent/.claude',
canonicalize: idCanon,
mkdir: () => {},
isSymlink: () => true, // the just-ensured dir is a symlink → fail closed
}),
).toThrow(/refusing|symlink/i);
});
it('real-FS: creates the isolated dir 0700 and returns its canonical path', () => {
const root = mkdtempSync(join(tmpdir(), 'claudex-cfg-'));
try {
const mosaicHome = join(root, '.config', 'mosaic');
const dir = resolveClaudexConfigDir({}, { mosaicHome, realClaudeDir: join(root, '.claude') });
expect(dir).toBe(join(mosaicHome, 'claudex', 'home'));
const st = lstatSync(dir);
expect(st.isDirectory()).toBe(true);
// 0700 (owner-only) — mask off the type bits.
expect(st.mode & 0o777).toBe(0o700);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
it('real-FS: catches an override whose ancestor symlinks into ~/.claude', () => {
const root = mkdtempSync(join(tmpdir(), 'claudex-cfg-'));
try {
const realClaudeDir = join(root, 'dot-claude');
mkdirSync(realClaudeDir, { recursive: true });
const link = join(root, 'link'); // link -> dot-claude
symlinkSync(realClaudeDir, link, 'dir');
const override = join(link, 'sub'); // resolves under ~/.claude
expect(() =>
resolveClaudexConfigDir(
{ [CLAUDEX_CONFIG_DIR_ENV]: override },
{ mosaicHome: join(root, '.config', 'mosaic'), realClaudeDir },
),
).toThrow(/refusing/i);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
it('FAIL CLOSED: default canonicalizer rethrows a non-ENOENT error (ELOOP) instead of a literal fallback', () => {
// A symlink loop makes realpathSync throw ELOOP. The guard must NOT swallow
// it as "does not exist yet, keep walking up" and return a literal path —
// it must fail closed. (REQ 1: fails CLOSED on any uncertainty.)
const root = mkdtempSync(join(tmpdir(), 'claudex-loop-'));
try {
const a = join(root, 'a');
const b = join(root, 'b');
symlinkSync(b, a, 'dir'); // a -> b
symlinkSync(a, b, 'dir'); // b -> a (loop)
const looped = join(a, 'home'); // canonicalizing this hits ELOOP
// No canonicalize dep → the real defaultCanonicalizeIntended runs.
expect(() =>
assertIsolatedConfigDir(looped, { realClaudeDir: join(root, '.claude') }),
).toThrow();
} finally {
rmSync(root, { recursive: true, force: true });
}
});
it('FAIL CLOSED: default isSymlink rethrows a non-ENOENT error (ENOTDIR) rather than reporting "not a symlink"', () => {
// A candidate whose parent is a regular FILE makes lstat throw ENOTDIR.
// The post-create symlink check must fail closed, not treat it as safe.
const root = mkdtempSync(join(tmpdir(), 'claudex-notdir-'));
try {
const file = join(root, 'afile');
writeFileSync(file, 'x');
const candidate = join(file, 'child'); // parent is a file → ENOTDIR on lstat
expect(() =>
// Bypass the guard/mkdir side-effects; only the default isSymlink runs live.
resolveClaudexConfigDir(
{ [CLAUDEX_CONFIG_DIR_ENV]: candidate },
{
realClaudeDir: join(root, '.claude'),
canonicalize: idCanon,
mkdir: () => {},
// isSymlink omitted → real defaultIsSymlink runs on the ENOTDIR path.
},
),
).toThrow();
} finally {
rmSync(root, { recursive: true, force: true });
}
});
it('FAIL CLOSED: an injected canonicalize throwing EACCES is not swallowed', () => {
const eacces = Object.assign(new Error('permission denied'), { code: 'EACCES' });
expect(() =>
resolveClaudexConfigDir(
{ [CLAUDEX_CONFIG_DIR_ENV]: '/home/agent/custom-claudex' },
{
realClaudeDir: '/home/agent/.claude',
canonicalize: () => {
throw eacces;
},
mkdir: () => {},
isSymlink: () => false,
},
),
).toThrow(/permission denied/);
});
});
// ─── model-tier map (P3) ──────────────────────────────────────────────────────
describe('resolveClaudexModels', () => {
it('defaults primary=sol / smallFast=luna', () => {
expect(resolveClaudexModels({})).toEqual({
primary: CLAUDEX_DEFAULT_PRIMARY_MODEL,
smallFast: CLAUDEX_DEFAULT_SMALL_FAST_MODEL,
});
expect(CLAUDEX_DEFAULT_PRIMARY_MODEL).toBe('gpt-5.6-sol');
expect(CLAUDEX_DEFAULT_SMALL_FAST_MODEL).toBe('gpt-5.6-luna');
});
it('env-provided values WIN over defaults', () => {
expect(
resolveClaudexModels({ ANTHROPIC_MODEL: 'gpt-x', ANTHROPIC_SMALL_FAST_MODEL: 'gpt-y' }),
).toEqual({ primary: 'gpt-x', smallFast: 'gpt-y' });
});
it('ignores blank env values (falls back to defaults)', () => {
expect(
resolveClaudexModels({ ANTHROPIC_MODEL: ' ', ANTHROPIC_SMALL_FAST_MODEL: '' }),
).toEqual({
primary: CLAUDEX_DEFAULT_PRIMARY_MODEL,
smallFast: CLAUDEX_DEFAULT_SMALL_FAST_MODEL,
});
});
});
// ─── env injection (HARD SECURITY REQ 2 — zero token leakage) ─────────────────
describe('buildClaudexEnv — zero token leakage', () => {
const models = { primary: 'gpt-5.6-sol', smallFast: 'gpt-5.6-luna' };
const configDir = '/home/agent/.config/mosaic/claudex/home';
it('sets only ANTHROPIC_AUTH_TOKEN=unused and points at the loopback proxy', () => {
const env = buildClaudexEnv({}, { configDir, models });
expect(env.ANTHROPIC_AUTH_TOKEN).toBe('unused');
expect(env.ANTHROPIC_BASE_URL).toBe(CLAUDEX_PROXY_URL);
expect(env.CLAUDE_CONFIG_DIR).toBe(configDir);
expect(env.ANTHROPIC_MODEL).toBe('gpt-5.6-sol');
expect(env.ANTHROPIC_SMALL_FAST_MODEL).toBe('gpt-5.6-luna');
});
it('OVERWRITES an inherited real auth token with the literal "unused"', () => {
const env = buildClaudexEnv(
{ ANTHROPIC_AUTH_TOKEN: 'sk-ant-realsecret-should-never-flow' },
{ configDir, models },
);
expect(env.ANTHROPIC_AUTH_TOKEN).toBe('unused');
});
it('DELETES ANTHROPIC_API_KEY so no real Anthropic key reaches the local proxy', () => {
const env = buildClaudexEnv(
{ ANTHROPIC_API_KEY: 'sk-ant-api03-realkey' },
{ configDir, models },
);
expect('ANTHROPIC_API_KEY' in env).toBe(false);
expect(env.ANTHROPIC_API_KEY).toBeUndefined();
});
it('sweeps the WHOLE credential-bearing env family (token/api-key/secret/oauth), not just two', () => {
const env = buildClaudexEnv(
{
ANTHROPIC_API_KEY: 'sk-ant-api03-leak',
CLAUDE_CODE_OAUTH_TOKEN: 'oauth-leak',
SOME_SERVICE_TOKEN: 'tok-leak',
VENDOR_API_KEY: 'key-leak',
DB_SECRET: 'secret-leak',
HARMLESS: 'kept',
PATH: '/usr/bin',
},
{ configDir, models },
);
expect(env.ANTHROPIC_API_KEY).toBeUndefined();
expect(env.CLAUDE_CODE_OAUTH_TOKEN).toBeUndefined();
expect(env.SOME_SERVICE_TOKEN).toBeUndefined();
expect(env.VENDOR_API_KEY).toBeUndefined();
expect(env.DB_SECRET).toBeUndefined();
// Non-credential vars the harness needs are preserved.
expect(env.HARMLESS).toBe('kept');
expect(env.PATH).toBe('/usr/bin');
});
it('neutralizes Bedrock/Vertex provider switches so Claude cannot bypass the proxy (REQ 2)', () => {
// CLAUDE_CODE_USE_BEDROCK / _USE_VERTEX are ROUTING switches: their mere
// presence makes Claude Code route to AWS Bedrock / GCP Vertex against the
// ambient cloud credential chain — reaching the real Anthropic API and
// bypassing ANTHROPIC_BASE_URL (the loopback proxy) entirely. They MUST be
// gone from the composed env regardless of the launching env.
const env = buildClaudexEnv(
{
CLAUDE_CODE_USE_BEDROCK: '1',
CLAUDE_CODE_USE_VERTEX: '1',
CLAUDE_CODE_SKIP_BEDROCK_AUTH: '1',
CLAUDE_CODE_SKIP_VERTEX_AUTH: '1',
AWS_ACCESS_KEY_ID: 'AKIAREAL',
AWS_SECRET_ACCESS_KEY: 'realsecret',
AWS_SESSION_TOKEN: 'realsession',
AWS_BEARER_TOKEN_BEDROCK: 'bearer-bedrock-real',
AWS_REGION: 'us-east-1',
GOOGLE_APPLICATION_CREDENTIALS: '/home/agent/gcp.json',
GOOGLE_CLOUD_ACCESS_TOKEN: 'gcp-token-real',
PATH: '/usr/bin',
},
{ configDir, models },
);
// Routing switches gone by construction.
expect('CLAUDE_CODE_USE_BEDROCK' in env).toBe(false);
expect('CLAUDE_CODE_USE_VERTEX' in env).toBe(false);
expect('CLAUDE_CODE_SKIP_BEDROCK_AUTH' in env).toBe(false);
expect('CLAUDE_CODE_SKIP_VERTEX_AUTH' in env).toBe(false);
// Cloud credentials swept — none of the Claude-capable creds survive.
expect(env.AWS_ACCESS_KEY_ID).toBeUndefined();
expect(env.AWS_SECRET_ACCESS_KEY).toBeUndefined();
expect(env.AWS_SESSION_TOKEN).toBeUndefined();
expect(env.AWS_BEARER_TOKEN_BEDROCK).toBeUndefined();
expect(env.AWS_REGION).toBeUndefined();
expect(env.GOOGLE_APPLICATION_CREDENTIALS).toBeUndefined();
expect(env.GOOGLE_CLOUD_ACCESS_TOKEN).toBeUndefined();
// The proxy routing is still the only path.
expect(env.ANTHROPIC_BASE_URL).toBe(CLAUDEX_PROXY_URL);
expect(env.ANTHROPIC_AUTH_TOKEN).toBe('unused');
expect(env.PATH).toBe('/usr/bin');
});
it('closes the mid-string _KEY / _SECRET gap (STRIPE_SECRET_KEY, SSH_PRIVATE_KEY)', () => {
const env = buildClaudexEnv(
{
STRIPE_SECRET_KEY: 'sk-live-real',
SSH_PRIVATE_KEY: '-----BEGIN OPENSSH PRIVATE KEY-----',
HARMLESS: 'kept',
},
{ configDir, models },
);
expect(env.STRIPE_SECRET_KEY).toBeUndefined();
expect(env.SSH_PRIVATE_KEY).toBeUndefined();
expect(env.HARMLESS).toBe('kept');
});
it('no credential-NAMED key in the composed env carries a real-looking value', () => {
const env = buildClaudexEnv(
{
ANTHROPIC_API_KEY: 'sk-ant-api03-leak',
ANTHROPIC_AUTH_TOKEN: 'access_token_leak',
SOME_JWT_TOKEN: 'eyJhbGciOiJIUzI1NiJ9.payload.sig',
REFRESH_SECRET: 'refresh_token_value',
},
{ configDir, models },
);
for (const [name, value] of Object.entries(env)) {
if (CLAUDEX_CREDENTIAL_ENV_RE.test(name)) {
// Any surviving credential-named var must carry only a safe sentinel value.
expect(value).not.toMatch(/sk-(ant|proj)-/);
expect(value).not.toMatch(/access_token|refresh_token/);
expect(value).not.toMatch(/eyJ[A-Za-z0-9_-]+\./); // JWT
}
}
});
it('honors a caller-provided baseUrl override (loopback default otherwise)', () => {
const env = buildClaudexEnv({}, { configDir, models, baseUrl: 'http://127.0.0.1:9999' });
expect(env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:9999');
});
it('returns a fresh object without mutating the base env', () => {
const base = { EXISTING: 'kept' };
const env = buildClaudexEnv(base, { configDir, models });
expect(env.EXISTING).toBe('kept');
expect(base).not.toHaveProperty('ANTHROPIC_AUTH_TOKEN');
});
});
// ─── EXPERIMENTAL classification (P4) ─────────────────────────────────────────
describe('buildClaudexBanner / buildClaudexContractNote', () => {
const models = { primary: 'gpt-5.6-sol', smallFast: 'gpt-5.6-luna' };
it('banner marks EXPERIMENTAL and names the models + proxy', () => {
const banner = buildClaudexBanner(models);
expect(banner).toMatch(/EXPERIMENTAL/);
expect(banner).toMatch(/gpt-5\.6-sol/);
expect(banner).toMatch(/gpt-5\.6-luna/);
expect(banner).toMatch(/claude-code-proxy/);
expect(banner).not.toMatch(/unused/); // no token material in the banner
});
it('contract note classifies the runtime as EXPERIMENTAL GPT-via-proxy', () => {
const note = buildClaudexContractNote(models);
expect(note).toMatch(/EXPERIMENTAL/);
expect(note).toMatch(/gpt-5\.6-sol/);
expect(note).toMatch(/not.*Anthropic/i);
});
});
// ─── proxy gate ───────────────────────────────────────────────────────────────
describe('runClaudexProxyGate', () => {
it('is ok when the first preflight already passes', async () => {
const preflight = vi.fn().mockResolvedValue(makeReport());
const ensureProxy = vi.fn();
const reauth = vi.fn();
const gate = await runClaudexProxyGate({ preflight, ensureProxy, reauth });
expect(gate.ok).toBe(true);
expect(ensureProxy).not.toHaveBeenCalled();
expect(reauth).not.toHaveBeenCalled();
});
it('fails fast when the binary is missing (no reauth, no start)', async () => {
const preflight = vi
.fn()
.mockResolvedValue(
makeReport({ binaryPresent: false, ok: false, problems: ['binary not found'] }),
);
const ensureProxy = vi.fn();
const reauth = vi.fn();
const gate = await runClaudexProxyGate({ preflight, ensureProxy, reauth });
expect(gate.ok).toBe(false);
expect(reauth).not.toHaveBeenCalled();
expect(ensureProxy).not.toHaveBeenCalled();
});
it('runs device reauth then re-preflights when OAuth needs it', async () => {
const preflight = vi
.fn()
.mockResolvedValueOnce(
makeReport({
auth: { state: 'expired' },
needsReauth: true,
ok: false,
problems: ['expired'],
}),
)
.mockResolvedValueOnce(makeReport());
const reauth = vi.fn().mockReturnValue(0);
const gate = await runClaudexProxyGate({ preflight, reauth, ensureProxy: vi.fn() });
expect(reauth).toHaveBeenCalledTimes(1);
expect(preflight).toHaveBeenCalledTimes(2);
expect(gate.ok).toBe(true);
});
it('does NOT reauth when auth is already valid', async () => {
const preflight = vi.fn().mockResolvedValue(makeReport());
const reauth = vi.fn();
await runClaudexProxyGate({ preflight, reauth, ensureProxy: vi.fn() });
expect(reauth).not.toHaveBeenCalled();
});
it('aborts when device reauth fails', async () => {
const preflight = vi.fn().mockResolvedValue(
makeReport({
auth: { state: 'unauthenticated' },
needsReauth: true,
ok: false,
problems: ['unauth'],
}),
);
const reauth = vi.fn().mockReturnValue(1);
const gate = await runClaudexProxyGate({ preflight, reauth, ensureProxy: vi.fn() });
expect(gate.ok).toBe(false);
expect(gate.problems.join(' ')).toMatch(/re-auth/i);
});
it('starts the proxy then re-preflights when nothing is live', async () => {
const preflight = vi
.fn()
.mockResolvedValueOnce(
makeReport({ live: false, listenerVerdict: 'unknown', ok: false, problems: ['dead'] }),
)
.mockResolvedValueOnce(makeReport());
const ensureProxy = vi.fn().mockResolvedValue({ live: true, method: 'systemd' });
const gate = await runClaudexProxyGate({ preflight, ensureProxy, reauth: vi.fn() });
expect(ensureProxy).toHaveBeenCalledTimes(1);
expect(gate.ok).toBe(true);
});
it('aborts (non-sensitive) when the proxy cannot come up trusted', async () => {
const preflight = vi
.fn()
.mockResolvedValue(
makeReport({ live: false, listenerVerdict: 'unknown', ok: false, problems: ['dead'] }),
);
const ensureProxy = vi.fn().mockResolvedValue({ live: false, method: 'untrusted' });
const gate = await runClaudexProxyGate({ preflight, ensureProxy, reauth: vi.fn() });
expect(gate.ok).toBe(false);
expect(gate.problems.join(' ')).toMatch(/untrusted/);
// Non-sensitive: no token material in surfaced problems.
expect(gate.problems.join(' ')).not.toMatch(/access_token|refresh_token|sk-/);
});
});
// ─── launch orchestration (fail-closed ordering) ──────────────────────────────
describe('launchClaudex', () => {
const baseDeps = {
baseEnv: {},
proxyGate: () => Promise.resolve({ ok: true, report: makeReport(), problems: [] }),
resolveConfigDir: () => '/home/agent/.config/mosaic/claudex/home',
log: () => {},
errorLog: () => {},
fail: (() => {
throw new Error('exit');
}) as (code: number) => never,
};
it('yolo=true passes --dangerously-skip-permissions + injected env to claude', async () => {
const exec = vi.fn();
await launchClaudex(['--print', 'hi'], true, okAdapter({ exec }), baseDeps);
expect(exec).toHaveBeenCalledTimes(1);
const [cmd, args, env] = exec.mock.calls[0]!;
expect(cmd).toBe('claude');
expect(args[0]).toBe('--dangerously-skip-permissions');
expect(args).toContain('--append-system-prompt');
expect(args).toContain('--print');
expect(args).toContain('hi');
expect(env.ANTHROPIC_AUTH_TOKEN).toBe('unused');
expect(env.ANTHROPIC_BASE_URL).toBe(CLAUDEX_PROXY_URL);
expect(env.CLAUDE_CONFIG_DIR).toBe('/home/agent/.config/mosaic/claudex/home');
expect(env.ANTHROPIC_MODEL).toBe('gpt-5.6-sol');
});
it('non-yolo omits --dangerously-skip-permissions but still injects the proxy env', async () => {
const exec = vi.fn();
await launchClaudex([], false, okAdapter({ exec }), baseDeps);
const [, args, env] = exec.mock.calls[0]!;
expect(args).not.toContain('--dangerously-skip-permissions');
expect(args[0]).toBe('--append-system-prompt');
expect(env.ANTHROPIC_AUTH_TOKEN).toBe('unused');
});
it('appends the EXPERIMENTAL contract note to the composed prompt', async () => {
const exec = vi.fn();
await launchClaudex([], true, okAdapter({ exec, composePrompt: () => '# BASE' }), baseDeps);
const args = exec.mock.calls[0]![1] as string[];
const promptIdx = args.indexOf('--append-system-prompt') + 1;
expect(args[promptIdx]).toContain('# BASE');
expect(args[promptIdx]).toMatch(/EXPERIMENTAL/);
});
it('runs the harness preflight BEFORE the proxy gate and exec', async () => {
const order: string[] = [];
const adapter = okAdapter({
harnessPreflight: () => order.push('preflight'),
exec: () => order.push('exec'),
});
await launchClaudex([], true, adapter, {
...baseDeps,
proxyGate: () => {
order.push('gate');
return Promise.resolve({ ok: true, report: makeReport(), problems: [] });
},
});
expect(order).toEqual(['preflight', 'gate', 'exec']);
});
it('FAIL CLOSED: exits WITHOUT exec when the proxy gate fails', async () => {
const exec = vi.fn();
const errors: string[] = [];
await expect(
launchClaudex([], true, okAdapter({ exec }), {
...baseDeps,
proxyGate: () =>
Promise.resolve({ ok: false, report: makeReport({ ok: false }), problems: ['no proxy'] }),
errorLog: (m: string) => errors.push(m),
}),
).rejects.toThrow('exit');
expect(exec).not.toHaveBeenCalled();
expect(errors.join('\n')).toMatch(/no proxy/);
});
it('FAIL CLOSED: exits WITHOUT exec when the config-dir guard throws', async () => {
const exec = vi.fn();
await expect(
launchClaudex([], true, okAdapter({ exec }), {
...baseDeps,
resolveConfigDir: () => {
throw new Error('refusing to use ~/.claude');
},
}),
).rejects.toThrow('exit');
expect(exec).not.toHaveBeenCalled();
});
it('FAIL CLOSED: reports a non-Error throw via String() and still aborts', async () => {
const exec = vi.fn();
const errors: string[] = [];
await expect(
launchClaudex([], true, okAdapter({ exec }), {
...baseDeps,
resolveConfigDir: () => {
// A non-Error throw exercises the String(err) branch of the catch.
throw { toString: () => 'string-shaped failure' };
},
errorLog: (m: string) => errors.push(m),
}),
).rejects.toThrow('exit');
expect(exec).not.toHaveBeenCalled();
expect(errors.join('\n')).toMatch(/string-shaped failure/);
});
});
// ─── production DI defaults (fallback-branch coverage; no real proxy touched) ──
describe('production dependency defaults', () => {
it('assertIsolatedConfigDir defaults realClaudeDir to ~/.claude', () => {
const safe = join(tmpdir(), 'mosaic-claudex-default-real', 'home');
// Only canonicalize injected; realClaudeDir falls back to ~/.claude.
expect(assertIsolatedConfigDir(safe, { canonicalize: idCanon })).toBe(safe);
});
it('assertIsolatedConfigDir default canonicalizer resolves a non-existent path', () => {
// No canonicalize dep → exercises the real realpath-longest-ancestor walk
// (including the not-yet-existing tail), on a path safely outside ~/.claude.
const safe = join(tmpdir(), 'mosaic-claudex-canon', 'nested', 'home');
expect(assertIsolatedConfigDir(safe)).toContain('mosaic-claudex-canon');
});
it('resolveClaudexConfigDir defaults mosaicHome when not injected', () => {
const dir = mkdtempSync(join(tmpdir(), 'claudex-cfg-'));
try {
const target = join(dir, 'home');
// Override env points elsewhere; mosaicHome dep omitted → MOSAIC_HOME default path is exercised.
const out = resolveClaudexConfigDir(
{ [CLAUDEX_CONFIG_DIR_ENV]: target },
{ canonicalize: idCanon },
);
expect(out).toBe(target);
expect(lstatSync(target).isDirectory()).toBe(true);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it('runClaudexProxyGate defaults ensureProxy/reauth/log without invoking them on a missing binary', async () => {
// Only preflight injected; binary missing → returns before the default
// ensureProxy/reauth thunks could ever reach the real proxy.
const preflight = vi
.fn()
.mockResolvedValue(makeReport({ binaryPresent: false, ok: false, problems: ['missing'] }));
const gate = await runClaudexProxyGate({ preflight });
expect(gate.ok).toBe(false);
});
it('launchClaudex defaults log/errorLog/fail/baseEnv/models/buildEnv on the success path', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
try {
const exec = vi.fn();
// Inject only the boundaries that would touch the real proxy/FS; let the
// rest default. Success path never calls fail/errorLog.
await launchClaudex([], true, okAdapter({ exec }), {
proxyGate: () => Promise.resolve({ ok: true, report: makeReport(), problems: [] }),
resolveConfigDir: () => join(tmpdir(), 'claudex-default-launch'),
});
expect(exec).toHaveBeenCalledTimes(1);
const [, , env] = exec.mock.calls[0]!;
expect(env.ANTHROPIC_AUTH_TOKEN).toBe('unused');
expect(env.ANTHROPIC_MODEL).toBe(CLAUDEX_DEFAULT_PRIMARY_MODEL);
} finally {
logSpy.mockRestore();
}
});
});

View File

@@ -0,0 +1,464 @@
/**
* Claudex launch composition (P2P4 of `mosaic yolo claudex`).
*
* Builds the isolated launch environment for running GPT models inside the
* Claude Code harness via `raine/claude-code-proxy` (ChatGPT-subscription OAuth).
* PR-1 (`claudex-proxy.ts`) owns the proxy preflight/lifecycle; this module owns
* the *composition* the launcher hands to Claude Code:
*
* P2 isolated CLAUDE_CONFIG_DIR (provably never the real ~/.claude) + env
* injection that leaks ZERO token material;
* P3 the model-tier map (primary → gpt-5.6-sol, small/fast → gpt-5.6-luna,
* operator env values win);
* P4 the EXPERIMENTAL classification banner + composed-contract note.
*
* Two hard security invariants (secrev-enforced):
* REQ 1 — Provable isolation. {@link assertIsolatedConfigDir} makes the
* CLAUDE_CONFIG_DIR seam incapable of resolving to `~/.claude` (or any
* descendant of it); it canonicalizes both sides, rejects descendants,
* and — after ensuring the dir — re-checks and rejects a symlinked
* target (TOCTOU). Fails CLOSED on any uncertainty.
* REQ 2 — Zero token leakage. This module never reads the proxy's
* `auth.json`; {@link buildClaudexEnv} strips the ENTIRE
* credential-bearing env family and hands Claude Code only
* `ANTHROPIC_AUTH_TOKEN=unused`. The proxy holds the real credential.
*
* Every side-effecting boundary is dependency-injected so the launch path is
* unit-testable without spawning Claude Code or touching a real config dir.
*/
import { lstatSync, mkdirSync, realpathSync } from 'node:fs';
import { homedir } from 'node:os';
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
import {
CLAUDEX_PROXY_URL,
ensureProxyRunning,
runDeviceReauth,
runProxyPreflight,
type EnsureProxyResult,
type PreflightReport,
} from './claudex-proxy.js';
const MOSAIC_HOME = process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic');
// ─── Isolated CLAUDE_CONFIG_DIR (HARD SECURITY REQ 1) ─────────────────────────
/** Dedicated override env for the isolated config dir. The ambient
* `CLAUDE_CONFIG_DIR` is deliberately NOT honored — it may already point at the
* real `~/.claude` of the launching session. */
export const CLAUDEX_CONFIG_DIR_ENV = 'MOSAIC_CLAUDEX_CONFIG_DIR';
/** The default isolated config dir — structurally under the mosaic home, so it
* can never equal `~/.claude`. */
export function defaultClaudexConfigDir(mosaicHome: string = MOSAIC_HOME): string {
return join(mosaicHome, 'claudex', 'home');
}
export interface ConfigDirDeps {
/** The real Claude state dir to protect (default `~/.claude`). */
realClaudeDir?: string;
/** Resolve a path to canonical form, resolving symlinks on the longest
* existing ancestor (so a not-yet-created dir still canonicalizes). */
canonicalize?: (p: string) => string;
/** Ensure the isolated dir exists (mkdir -p, owner-only 0700). */
mkdir?: (p: string) => void;
/** Whether a path is itself a symlink (lstat). */
isSymlink?: (p: string) => boolean;
}
/** Resolve a path to canonical form, resolving symlinks on the LONGEST EXISTING
* ancestor and re-appending the not-yet-existing tail. A symlinked ancestor that
* points into `~/.claude` is therefore caught even before the leaf exists. */
function defaultCanonicalizeIntended(p: string): string {
const abs = resolve(p);
let existing = abs;
const tail: string[] = [];
// Walk up until we hit an existing ancestor (or the filesystem root).
for (;;) {
try {
const real = realpathSync(existing);
return tail.length > 0 ? join(real, ...tail) : real;
} catch (err) {
// ONLY a genuine "does not exist yet" (ENOENT) justifies walking up to an
// existing ancestor. Any other errno (ELOOP, EACCES, ENOTDIR, …) means we
// cannot establish the canonical form — fail CLOSED rather than fall back
// to a possibly-wrong literal path.
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
const parent = dirname(existing);
if (parent === existing) return abs; // reached root without an existing prefix
tail.unshift(existing.slice(parent.length + 1));
existing = parent;
}
}
}
function defaultMkdir(p: string): void {
mkdirSync(p, { recursive: true, mode: 0o700 });
}
function defaultIsSymlink(p: string): boolean {
try {
return lstatSync(p).isSymbolicLink();
} catch (err) {
// A missing path is genuinely "not a symlink"; anything else (EACCES, ELOOP,
// ENOTDIR, …) is uncertainty the guard must not swallow — fail CLOSED.
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return false;
throw err;
}
}
/** True when `child` is `parent` itself or a descendant of it (path-wise). */
function isWithin(child: string, parent: string): boolean {
if (child === parent) return true;
const rel = relative(parent, child);
return rel.length > 0 && !rel.startsWith('..') && !isAbsolute(rel);
}
/**
* Guard: prove that `candidate` is a legitimate ISOLATED config dir and can
* never be, resolve to, or live under the real `~/.claude`. Canonicalizes BOTH
* sides (either may be a symlink), rejects `~/.claude` and any descendant, and
* fails CLOSED (throws) on an empty/relative candidate. Returns the canonical
* isolated path on success.
*/
export function assertIsolatedConfigDir(candidate: string, deps: ConfigDirDeps = {}): string {
const canonicalize = deps.canonicalize ?? defaultCanonicalizeIntended;
const realClaudeDir = deps.realClaudeDir ?? join(homedir(), '.claude');
if (typeof candidate !== 'string' || candidate.trim() === '') {
throw new Error('claudex: isolated CLAUDE_CONFIG_DIR must be a non-empty path (fail closed).');
}
if (!isAbsolute(candidate)) {
throw new Error(
`claudex: isolated CLAUDE_CONFIG_DIR must be an absolute path: ${JSON.stringify(candidate)}`,
);
}
const canonCandidate = canonicalize(candidate);
const canonReal = canonicalize(realClaudeDir);
// Compare canonical forms AND raw resolved forms — belt and suspenders so a
// canonicalizer that no-ops on a nonexistent real dir still catches the literal.
if (isWithin(canonCandidate, canonReal) || isWithin(resolve(candidate), resolve(realClaudeDir))) {
throw new Error(
`claudex: refusing to use the real Claude config dir (or a descendant of it) as the ` +
`isolated CLAUDE_CONFIG_DIR. Resolved to ${JSON.stringify(canonCandidate)}.`,
);
}
return canonCandidate;
}
/**
* Resolve the isolated CLAUDE_CONFIG_DIR: pick the dedicated override or the
* namespaced default, run the pre-create guard, ensure the dir (0700), then
* RE-CHECK after creation — reject a symlinked target and re-run the guard on
* the now-existing (fully canonicalizable) path. This closes the pre-created
* symlink race (TOCTOU). Every failure throws (fail closed).
*/
export function resolveClaudexConfigDir(
env: NodeJS.ProcessEnv = process.env,
deps: ConfigDirDeps & { mosaicHome?: string } = {},
): string {
const mosaicHome = deps.mosaicHome ?? MOSAIC_HOME;
const mkdir = deps.mkdir ?? defaultMkdir;
const isSymlink = deps.isSymlink ?? defaultIsSymlink;
const override = env[CLAUDEX_CONFIG_DIR_ENV]?.trim();
const candidate =
override && override.length > 0 ? override : defaultClaudexConfigDir(mosaicHome);
// Pre-create guard (before touching the filesystem).
assertIsolatedConfigDir(candidate, deps);
// Ensure the dir, then re-verify against the post-create reality.
mkdir(candidate);
if (isSymlink(candidate)) {
throw new Error(
'claudex: refusing to use the isolated CLAUDE_CONFIG_DIR — the target is a symlink ' +
'(possible pre-created race). Fail closed.',
);
}
// Re-run the guard now that the leaf exists so canonicalization reflects any
// symlinked ancestor introduced between the pre-check and mkdir.
return assertIsolatedConfigDir(candidate, deps);
}
// ─── Model-tier map (P3) ──────────────────────────────────────────────────────
export const CLAUDEX_DEFAULT_PRIMARY_MODEL = 'gpt-5.6-sol';
export const CLAUDEX_DEFAULT_SMALL_FAST_MODEL = 'gpt-5.6-luna';
export interface ClaudexModels {
/** Primary tier (opus/sonnet) → ANTHROPIC_MODEL. */
primary: string;
/** Small/fast tier (haiku) → ANTHROPIC_SMALL_FAST_MODEL. */
smallFast: string;
}
/** Resolve the model-tier map. Operator-provided env values WIN over defaults;
* blank values fall back to the defaults. */
export function resolveClaudexModels(env: NodeJS.ProcessEnv = process.env): ClaudexModels {
const primary = env['ANTHROPIC_MODEL']?.trim() || CLAUDEX_DEFAULT_PRIMARY_MODEL;
const smallFast = env['ANTHROPIC_SMALL_FAST_MODEL']?.trim() || CLAUDEX_DEFAULT_SMALL_FAST_MODEL;
return { primary, smallFast };
}
// ─── Env injection (HARD SECURITY REQ 2 — zero token leakage) ─────────────────
/**
* Names of env vars considered credential-bearing. The whole family is stripped
* from the composed env so no real Anthropic key, OAuth token, or third-party /
* cloud credential can reach the local proxy or be used by Claude Code to bypass
* it. We then re-add ONLY the safe claudex vars (`ANTHROPIC_MODEL`,
* `_SMALL_FAST_MODEL`, `_BASE_URL`, `_AUTH_TOKEN=unused`). A name-pattern sweep
* can't miss a specific var a short denylist forgot, while still preserving the
* arbitrary non-credential env the harness/MCP/hooks require (PATH, HOME, XDG,
* terminal, proxies, …), which a strict allowlist would fragilely drop.
*
* The cloud-provider families (`AWS_*`, `GOOGLE_APPLICATION_CREDENTIALS`,
* `GOOGLE_CLOUD_*`, `GCP_*`) are included because Claude Code can route to the
* real Anthropic API via AWS Bedrock / GCP Vertex using the ambient cloud
* credential chain — a Claude-capable credential that must never survive into a
* claudex launch. `_KEY$` / `_SECRET` (not just the `_API_KEY$` tail) close the
* mid-string gap (`STRIPE_SECRET_KEY`, `SSH_PRIVATE_KEY`, `AWS_SECRET_ACCESS_KEY`).
*/
export const CLAUDEX_CREDENTIAL_ENV_RE =
/^ANTHROPIC_|^CLAUDE_CODE_OAUTH|^AWS_|^GOOGLE_APPLICATION_CREDENTIALS$|^GOOGLE_CLOUD_|^GCP_|_API_?KEY$|_KEY$|_TOKEN$|_SECRET/i;
/**
* Provider ROUTING switches whose mere PRESENCE (independent of any credential)
* makes Claude Code bypass `ANTHROPIC_BASE_URL` (the loopback proxy) and talk to
* the real Anthropic API via Bedrock/Vertex. A name-pattern is the wrong model
* for a boolean switch, so these are force-deleted by exact name — REGARDLESS of
* value — after the credential sweep. (REQ 2: isolation must hold for any
* launching env, including a Bedrock/Vertex-configured enterprise host.)
*/
export const CLAUDEX_FORCED_UNSET_ENV = [
'CLAUDE_CODE_USE_BEDROCK',
'CLAUDE_CODE_USE_VERTEX',
'CLAUDE_CODE_SKIP_BEDROCK_AUTH',
'CLAUDE_CODE_SKIP_VERTEX_AUTH',
] as const;
export interface BuildClaudexEnvOptions {
configDir: string;
models: ClaudexModels;
/** Override the proxy base URL (defaults to the PR-1 loopback constant). */
baseUrl?: string;
}
/**
* Compose the launch env for Claude Code. Returns a FRESH object (never mutates
* the base env). Strips the entire credential-bearing family AND force-deletes
* the Bedrock/Vertex routing switches (REQ 2), then sets the isolated config dir
* and the proxy routing. Claude Code sees only `ANTHROPIC_AUTH_TOKEN=unused`
* pointed at the loopback proxy; the proxy holds the real OAuth credential, which
* this module never reads.
*/
export function buildClaudexEnv(
baseEnv: NodeJS.ProcessEnv,
opts: BuildClaudexEnvOptions,
): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = {};
for (const [name, value] of Object.entries(baseEnv)) {
if (CLAUDEX_CREDENTIAL_ENV_RE.test(name)) continue; // drop the whole credential family
env[name] = value;
}
// Force-delete routing switches by exact name — their presence (not their
// value) is what would route Claude Code off the proxy to the real API.
for (const name of CLAUDEX_FORCED_UNSET_ENV) delete env[name];
env['CLAUDE_CONFIG_DIR'] = opts.configDir;
env['ANTHROPIC_BASE_URL'] = opts.baseUrl ?? CLAUDEX_PROXY_URL;
env['ANTHROPIC_AUTH_TOKEN'] = 'unused';
env['ANTHROPIC_MODEL'] = opts.models.primary;
env['ANTHROPIC_SMALL_FAST_MODEL'] = opts.models.smallFast;
return env;
}
// ─── EXPERIMENTAL classification (P4) ─────────────────────────────────────────
/** Console banner shown at launch. Contains no token material by construction. */
export function buildClaudexBanner(models: ClaudexModels): string {
return [
'',
' ┌─────────────────────────────────────────────────────────────────────┐',
' │ ⚠ EXPERIMENTAL — mosaic claudex │',
' └─────────────────────────────────────────────────────────────────────┘',
` Running GPT models inside the Claude Code harness via claude-code-proxy`,
` (ChatGPT-subscription OAuth). This is NOT Anthropic Claude.`,
` primary : ${models.primary}`,
` small/fast : ${models.smallFast}`,
` Model behavior, tool use, and output quality may differ from Claude.`,
'',
].join('\n');
}
/** Markdown note appended to the composed runtime contract so the model itself
* knows it is running the EXPERIMENTAL GPT-via-proxy configuration. */
export function buildClaudexContractNote(models: ClaudexModels): string {
return [
'# EXPERIMENTAL Runtime — claudex (GPT via claude-code-proxy)',
'',
'You are running in Mosaic **claudex** mode: the Claude Code harness is wired to',
'GPT models through a local `claude-code-proxy` (ChatGPT-subscription OAuth). This',
"runtime is NOT Anthropic's Claude API and is not Claude.",
'',
`- Primary model: \`${models.primary}\``,
`- Small/fast model: \`${models.smallFast}\``,
'',
'Some Claude-specific harness assumptions may not hold under GPT models — verify',
'tool output carefully. This classification is EXPERIMENTAL and is not intended for',
'production delivery without explicit operator sign-off.',
].join('\n');
}
// ─── Proxy gate ───────────────────────────────────────────────────────────────
export interface ProxyGateResult {
ok: boolean;
report: PreflightReport;
/** Non-sensitive problems suitable for surfacing to the operator. */
problems: string[];
}
export interface ProxyGateDeps {
preflight?: () => Promise<PreflightReport>;
ensureProxy?: () => Promise<EnsureProxyResult>;
reauth?: () => number;
log?: (message: string) => void;
}
/**
* Run the proxy readiness gate: preflight → (device reauth if OAuth needs it) →
* (start the proxy if nothing trusted is live) → re-preflight. Returns `ok` only
* when the final preflight passes (binary present, OAuth valid, a TRUSTED-live
* listener — identity verified by PR-1's `verifyListenerIdentity`). All surfaced
* problems are non-sensitive (port + verdict only; never a token).
*/
export async function runClaudexProxyGate(deps: ProxyGateDeps = {}): Promise<ProxyGateResult> {
const preflight = deps.preflight ?? (() => runProxyPreflight());
const ensureProxy = deps.ensureProxy ?? (() => ensureProxyRunning());
const reauth = deps.reauth ?? (() => runDeviceReauth());
const log = deps.log ?? (() => {});
let report = await preflight();
// A missing binary is unrecoverable here — don't attempt reauth or a start.
if (!report.binaryPresent) {
return { ok: false, report, problems: report.problems };
}
if (report.needsReauth) {
log('claudex: claude-code-proxy OAuth needs re-authentication — starting device flow…');
const code = reauth();
if (code !== 0) {
return {
ok: false,
report,
problems: [...report.problems, 'claudex: device re-authentication did not complete.'],
};
}
report = await preflight();
}
if (!report.live) {
log('claudex: no trusted claude-code-proxy responding — starting it…');
const started = await ensureProxy();
if (!started.live) {
return {
ok: false,
report,
problems: [
...report.problems,
`claudex: could not bring up a trusted claude-code-proxy (${started.method}).`,
],
};
}
report = await preflight();
}
return { ok: report.ok, report, problems: report.problems };
}
// ─── Launch orchestration ─────────────────────────────────────────────────────
/**
* The launch.ts-provided seam. Keeps `claudex.ts` free of a circular import back
* into `launch.ts` while letting the orchestration reuse the harness preflight,
* the composed runtime contract, and the process-replacing exec.
*/
export interface ClaudexHarnessAdapter {
/** Runs the Claude-harness preflight (mosaic home, SOUL, `claude` on PATH,
* sequential-thinking). May terminate the process on a hard failure. */
harnessPreflight: () => void;
/** Compose the full Claude runtime contract (== `composeContract('claude')`). */
composePrompt: () => string;
/** Replace the current process with `claude` using the composed env. */
exec: (cmd: string, args: string[], env: NodeJS.ProcessEnv) => void;
}
export interface LaunchClaudexDeps {
baseEnv?: NodeJS.ProcessEnv;
proxyGate?: () => Promise<ProxyGateResult>;
resolveConfigDir?: () => string;
models?: () => ClaudexModels;
buildEnv?: (base: NodeJS.ProcessEnv, opts: BuildClaudexEnvOptions) => NodeJS.ProcessEnv;
log?: (message: string) => void;
errorLog?: (message: string) => void;
fail?: (code: number) => never;
}
/**
* Orchestrate a `mosaic [yolo] claudex` launch. Runs the harness preflight, the
* proxy gate, composes the isolated env (REQ 1 + REQ 2), appends the EXPERIMENTAL
* note, and exec's Claude Code. FAIL CLOSED: on any gate failure or guard throw
* it reports non-sensitive detail and exits WITHOUT reaching exec.
*/
export async function launchClaudex(
args: string[],
yolo: boolean,
adapter: ClaudexHarnessAdapter,
deps: LaunchClaudexDeps = {},
): Promise<void> {
const log = deps.log ?? ((m: string) => console.log(m));
const errorLog = deps.errorLog ?? ((m: string) => console.error(m));
const fail = deps.fail ?? ((code: number) => process.exit(code));
const baseEnv = deps.baseEnv ?? process.env;
const proxyGate = deps.proxyGate ?? (() => runClaudexProxyGate({ log }));
const resolveConfigDir = deps.resolveConfigDir ?? (() => resolveClaudexConfigDir(baseEnv));
const models = deps.models ?? (() => resolveClaudexModels(baseEnv));
const buildEnv = deps.buildEnv ?? buildClaudexEnv;
try {
// Harness readiness first (claude on PATH, mosaic home, sequential-thinking).
adapter.harnessPreflight();
// Proxy readiness (binary, OAuth, trusted-live listener).
const gate = await proxyGate();
if (!gate.ok) {
errorLog('[mosaic] claudex preflight failed:');
for (const problem of gate.problems) errorLog(` - ${problem}`);
return fail(1);
}
// Compose the isolated launch env (guard throws → caught below, fail closed).
const resolvedModels = models();
const configDir = resolveConfigDir();
const env = buildEnv(baseEnv, { configDir, models: resolvedModels });
const prompt = `${adapter.composePrompt()}\n\n${buildClaudexContractNote(resolvedModels)}`;
log(buildClaudexBanner(resolvedModels));
const cliArgs = yolo ? ['--dangerously-skip-permissions'] : [];
cliArgs.push('--append-system-prompt', prompt, ...args);
adapter.exec('claude', cliArgs, env);
} catch (err) {
errorLog(
`[mosaic] claudex launch aborted: ${err instanceof Error ? err.message : String(err)}`,
);
return fail(1);
}
}

View File

@@ -0,0 +1,280 @@
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Command } from 'commander';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { registerFleetMigrationCommand } from './fleet-migration-command.js';
let cleanup: string | undefined;
afterEach(async (): Promise<void> => {
if (cleanup) await rm(cleanup, { recursive: true, force: true });
cleanup = undefined;
});
describe('fleet migrate-v1 preview command', (): void => {
it('emits one ready JSON result without any runtime or file mutation API', async () => {
cleanup = await mkdtemp(join(tmpdir(), 'fleet-migration-command-'));
const rolesDir = join(cleanup, 'roles');
const overrideDir = join(cleanup, 'roles.local');
await mkdir(rolesDir);
await mkdir(overrideDir);
await writeFile(join(rolesDir, 'code.md'), '# code\n');
const files: Record<string, string> = {
source: `version: 1\ntransport: tmux\ntmux:\n socket_name: test\ndefaults:\n working_directory: /srv\nruntimes:\n pi:\n reset_command: /new\nagents:\n - name: coder0\n runtime: pi\n class: implementer\n`,
decisions: JSON.stringify({
generation: 2,
defaultRuntime: 'pi',
agents: {
coder0: {
provider: 'openai',
model: 'gpt-5.6-sol',
reasoning: 'high',
enabled: true,
launchYolo: false,
toolPolicyDisposition: { action: 'replace', className: 'code' },
},
},
}),
observations: JSON.stringify({ coder0: { systemd: 'inactive', tmux: 'missing' } }),
};
const printJson = vi.fn();
const setExitCode = vi.fn();
const program = new Command();
const fleet = program.command('fleet').option('--mosaic-home <path>', '', cleanup);
registerFleetMigrationCommand(fleet, {
mosaicHome: cleanup,
rolesDir,
overrideDir,
readText: async (path): Promise<string> => files[path] ?? '',
printJson,
setExitCode,
});
await program.parseAsync([
'node',
'test',
'fleet',
'migrate-v1',
'preview',
'--source',
'source',
'--decisions',
'decisions',
'--observations',
'observations',
]);
expect(printJson).toHaveBeenCalledTimes(1);
expect(printJson).toHaveBeenCalledWith(expect.objectContaining({ status: 'ready' }));
expect(setExitCode).not.toHaveBeenCalled();
expect(fleet.helpInformation()).toContain('migrate-v1');
const migration = fleet.commands.find((command) => command.name() === 'migrate-v1');
expect(migration?.helpInformation()).not.toMatch(/--write|apply|canary|rollback/);
});
it('emits one stable blocked JSON result when --observations is missing', async () => {
const printJson = vi.fn();
const setExitCode = vi.fn();
const program = new Command().exitOverride();
program.configureOutput({
writeErr: vi.fn(),
writeOut: vi.fn(),
});
const fleet = program.command('fleet').option('--mosaic-home <path>', '', '/unused');
registerFleetMigrationCommand(fleet, {
mosaicHome: '/unused',
readText: vi.fn(),
printJson,
setExitCode,
});
await expect(
program.parseAsync([
'node',
'test',
'fleet',
'migrate-v1',
'preview',
'--source',
'source',
'--decisions',
'decisions',
]),
).resolves.toBe(program);
expect(printJson).toHaveBeenCalledTimes(1);
expect(printJson).toHaveBeenCalledWith({
status: 'blocked',
blockers: [
{
code: 'missing-migration-preview-option',
path: 'request.observations',
detail: 'Required migration preview option is missing.',
},
],
});
expect(setExitCode).toHaveBeenCalledWith(1);
});
it.each(['source', 'decisions', 'observations'] as const)(
'emits one stable blocked JSON result when bare --%s has no value',
async (bareOption) => {
const printJson = vi.fn();
const setExitCode = vi.fn();
const readText = vi.fn();
const writeErr = vi.fn();
const program = new Command().exitOverride();
program.configureOutput({ writeErr, writeOut: vi.fn() });
const fleet = program.command('fleet').option('--mosaic-home <path>', '', '/unused');
registerFleetMigrationCommand(fleet, {
mosaicHome: '/unused',
readText,
printJson,
setExitCode,
});
const args = [
'node',
'test',
'fleet',
'migrate-v1',
'preview',
'--source=source',
'--decisions=decisions',
'--observations=observations',
];
args[args.findIndex((argument) => argument.startsWith(`--${bareOption}=`))] =
`--${bareOption}`;
await expect(program.parseAsync(args)).resolves.toBe(program);
expect(printJson).toHaveBeenCalledTimes(1);
expect(printJson).toHaveBeenCalledWith({
status: 'blocked',
blockers: [
{
code: 'missing-migration-preview-option-value',
path: `request.${bareOption}`,
detail: 'Required migration preview option value is missing.',
},
],
});
expect(setExitCode).toHaveBeenCalledTimes(1);
expect(setExitCode).toHaveBeenCalledWith(1);
expect(readText).not.toHaveBeenCalled();
expect(writeErr).not.toHaveBeenCalled();
},
);
it.each(['source', 'decisions', 'observations'] as const)(
'emits one stable blocked JSON result when --%s is present-empty',
async (emptyOption) => {
const printJson = vi.fn();
const setExitCode = vi.fn();
const readText = vi.fn();
const program = new Command().exitOverride();
program.configureOutput({ writeErr: vi.fn(), writeOut: vi.fn() });
const fleet = program.command('fleet').option('--mosaic-home <path>', '', '/unused');
registerFleetMigrationCommand(fleet, {
mosaicHome: '/unused',
readText,
printJson,
setExitCode,
});
const paths = { source: 'source', decisions: 'decisions', observations: 'observations' };
paths[emptyOption] = '';
await expect(
program.parseAsync([
'node',
'test',
'fleet',
'migrate-v1',
'preview',
`--source=${paths.source}`,
`--decisions=${paths.decisions}`,
`--observations=${paths.observations}`,
]),
).resolves.toBe(program);
expect(printJson).toHaveBeenCalledTimes(1);
expect(printJson).toHaveBeenCalledWith({
status: 'blocked',
blockers: [
{
code: 'empty-migration-preview-option',
path: `request.${emptyOption}`,
detail: 'Required migration preview option must be a non-empty path.',
},
],
});
expect(setExitCode).toHaveBeenCalledTimes(1);
expect(setExitCode).toHaveBeenCalledWith(1);
expect(readText).not.toHaveBeenCalled();
},
);
it('emits a blocked result and sets exit 1 for malformed evidence', async () => {
const printJson = vi.fn();
const setExitCode = vi.fn();
const program = new Command();
const fleet = program.command('fleet').option('--mosaic-home <path>', '', '/unused');
registerFleetMigrationCommand(fleet, {
mosaicHome: '/unused',
readText: async (path): Promise<string> => (path === 'decisions' ? 'not-json' : '{}'),
printJson,
setExitCode,
});
await program.parseAsync([
'node',
'test',
'fleet',
'migrate-v1',
'preview',
'--source',
'source',
'--decisions',
'decisions',
'--observations',
'observations',
]);
expect(printJson).toHaveBeenCalledWith(
expect.objectContaining({
status: 'blocked',
blockers: [expect.objectContaining({ code: 'migration-preview-failed' })],
}),
);
expect(setExitCode).toHaveBeenCalledWith(1);
});
it('redacts adversarial values from validation failures', async () => {
const secret = 'never-print-command-or-token';
const printJson = vi.fn();
const setExitCode = vi.fn();
const program = new Command();
const fleet = program.command('fleet').option('--mosaic-home <path>', '', '/unused');
registerFleetMigrationCommand(fleet, {
mosaicHome: '/unused',
readText: async (path): Promise<string> =>
path === 'decisions'
? JSON.stringify({ generation: 2, agents: {}, [secret]: secret })
: '{}',
printJson,
setExitCode,
});
await program.parseAsync([
'node',
'test',
'fleet',
'migrate-v1',
'preview',
'--source',
'source',
'--decisions',
'decisions',
'--observations',
'observations',
]);
expect(JSON.stringify(printJson.mock.calls)).not.toContain(secret);
expect(setExitCode).toHaveBeenCalledWith(1);
});
});

View File

@@ -0,0 +1,170 @@
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import type { Command } from 'commander';
import {
parseV1MigrationObservations,
parseV1ToV2MigrationDecisions,
previewV1ToV2Migration,
} from '../fleet/v1-v2-migration.js';
export interface FleetMigrationCommandDeps {
readonly mosaicHome?: string;
readonly rolesDir?: string;
readonly overrideDir?: string;
readonly readText?: (path: string) => Promise<string>;
readonly printJson?: (value: unknown) => void;
readonly setExitCode?: (code: number) => void;
}
interface PreviewOptions {
readonly source?: string | boolean;
readonly decisions?: string | boolean;
readonly observations?: string | boolean;
}
/** Registers preview-only v1 migration. This command has no mutation verbs or runners. */
export function registerFleetMigrationCommand(
fleetCommand: Command,
deps: FleetMigrationCommandDeps = {},
): void {
fleetCommand
.command('migrate-v1')
.description('Preview a field-complete v1-to-v2 roster migration')
.command('preview')
.description('Compile migration evidence without writing files or changing runtimes')
.option('--source [path]', 'v1 roster YAML or JSON')
.option('--decisions [path]', 'explicit migration decisions JSON')
.option('--observations [path]', 'reviewed lifecycle observations JSON')
.action(async (options: PreviewOptions): Promise<void> => {
const readText = deps.readText ?? ((path: string): Promise<string> => readFile(path, 'utf8'));
const printJson =
deps.printJson ?? ((value: unknown): void => console.log(JSON.stringify(value)));
const setExitCode =
deps.setExitCode ?? ((code: number): void => void (process.exitCode = code));
try {
const requiredOptionNames = ['source', 'decisions', 'observations'] as const;
const missingOption = requiredOptionNames.find((name) => options[name] === undefined);
if (missingOption !== undefined) {
printJson({
status: 'blocked',
blockers: [
{
code: 'missing-migration-preview-option',
path: `request.${missingOption}`,
detail: 'Required migration preview option is missing.',
},
],
});
setExitCode(1);
return;
}
const missingValueOption = requiredOptionNames.find((name) => options[name] === true);
if (missingValueOption !== undefined) {
printJson({
status: 'blocked',
blockers: [
{
code: 'missing-migration-preview-option-value',
path: `request.${missingValueOption}`,
detail: 'Required migration preview option value is missing.',
},
],
});
setExitCode(1);
return;
}
const emptyOption = requiredOptionNames.find(
(name) => typeof options[name] === 'string' && options[name].trim() === '',
);
if (emptyOption !== undefined) {
printJson({
status: 'blocked',
blockers: [
{
code: 'empty-migration-preview-option',
path: `request.${emptyOption}`,
detail: 'Required migration preview option must be a non-empty path.',
},
],
});
setExitCode(1);
return;
}
const sourcePath = options.source;
const decisionsPath = options.decisions;
const observationsPath = options.observations;
if (
typeof sourcePath !== 'string' ||
typeof decisionsPath !== 'string' ||
typeof observationsPath !== 'string'
) {
throw new Error('Validated migration preview options became unavailable.');
}
const [source, decisionsSource, observationsSource] = await Promise.all([
readText(sourcePath),
readText(decisionsPath),
readText(observationsPath),
]);
const decisions = parseV1ToV2MigrationDecisions(
parseJsonObject(decisionsSource, 'migration decisions'),
);
const observations = parseV1MigrationObservations(
parseJsonObject(observationsSource, 'lifecycle observations'),
);
const mosaicHome =
deps.mosaicHome ?? fleetCommand.opts<{ mosaicHome: string }>().mosaicHome;
const preview = await previewV1ToV2Migration({
source,
sourcePath,
decisions,
observations,
personaDirs: {
rolesDir: deps.rolesDir ?? join(mosaicHome, 'fleet', 'roles'),
overrideDir: deps.overrideDir ?? join(mosaicHome, 'fleet', 'roles.local'),
},
environment: {
mosaicHome,
agentEnvDir: join(mosaicHome, 'fleet', 'agents'),
},
});
printJson(preview);
if (preview.status === 'blocked') setExitCode(1);
} catch (error: unknown) {
printJson({
status: 'blocked',
blockers: [
{
code: 'migration-preview-failed',
path: 'request',
detail: safeErrorDetail(error),
},
],
});
setExitCode(1);
}
});
}
function parseJsonObject(source: string, label: string): unknown {
let value: unknown;
try {
value = JSON.parse(source) as unknown;
} catch {
throw new Error(`${label} must be valid JSON.`);
}
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
throw new Error(`${label} must be a JSON object.`);
}
return value;
}
function safeErrorDetail(error: unknown): string {
if (error instanceof Error && isPublishableValidationMessage(error.message)) return error.message;
return 'Migration preview failed without publishable detail.';
}
function isPublishableValidationMessage(message: string): boolean {
return /^(migration decisions|lifecycle observations) must be (valid JSON|a JSON object)\.$/.test(
message,
);
}

View File

@@ -90,6 +90,7 @@ describe('registerFleetCommand', () => {
'init', 'init',
'install', 'install',
'install-systemd', 'install-systemd',
'migrate-v1',
'persona', 'persona',
'plan', 'plan',
'profile', 'profile',
@@ -197,6 +198,26 @@ describe('fleet roster parsing', () => {
expect(getRosterAgent(roster, 'canary-pi').runtime).toBe('pi'); expect(getRosterAgent(roster, 'canary-pi').runtime).toBe('pi');
}); });
it('uses /clear for an explicitly declared empty pi runtime config', async () => {
cleanup = await tempDir();
const rosterPath = join(cleanup, 'roster.yaml');
await writeFile(
rosterPath,
[
'version: 1',
'transport: tmux',
'runtimes:',
' pi: {}',
'agents:',
' - name: canary-pi',
' runtime: pi',
].join('\n'),
);
const loaded = await loadFleetRoster(rosterPath);
expect(loaded.runtimes.pi?.resetCommand).toBe('/clear');
});
it('accepts optional agent alias and provider metadata without requiring them', async () => { it('accepts optional agent alias and provider metadata without requiring them', async () => {
cleanup = await tempDir(); cleanup = await tempDir();
const rosterPath = join(cleanup, 'roster.yaml'); const rosterPath = join(cleanup, 'roster.yaml');
@@ -270,6 +291,32 @@ describe('fleet roster parsing', () => {
expect(env).toContain('MOSAIC_TMUX_SOCKET=\n'); expect(env).toContain('MOSAIC_TMUX_SOCKET=\n');
}); });
it('preserves home-relative traversal until the shared environment boundary rejects it', async () => {
for (const workingDirectory of ['~/../escape', '~/src/../../escape']) {
cleanup = await tempDir();
const rosterPath = join(cleanup, 'roster.json');
await writeFile(
rosterPath,
JSON.stringify({
version: 1,
transport: 'tmux',
defaults: { working_directory: workingDirectory },
agents: [{ name: 'coder0', runtime: 'pi' }],
}),
);
const roster = await loadFleetRoster(rosterPath);
expect(() => generateAgentEnv(roster, getRosterAgent(roster, 'coder0'))).toThrow(
expect.objectContaining({
diagnostic: expect.objectContaining({
code: 'unsafe-path',
key: 'MOSAIC_AGENT_WORKDIR',
}),
}),
);
}
});
it('generates deterministic per-agent EnvironmentFile content', async () => { it('generates deterministic per-agent EnvironmentFile content', async () => {
cleanup = await tempDir(); cleanup = await tempDir();
const rosterPath = join(cleanup, 'roster.json'); const rosterPath = join(cleanup, 'roster.json');

View File

@@ -35,6 +35,10 @@ import {
registerFleetAgentCrudCommands, registerFleetAgentCrudCommands,
type FleetAgentCrudCommandDeps, type FleetAgentCrudCommandDeps,
} from './fleet-agent-crud-command.js'; } from './fleet-agent-crud-command.js';
import {
registerFleetMigrationCommand,
type FleetMigrationCommandDeps,
} from './fleet-migration-command.js';
import { import {
executeReconcilerCommandJson, executeReconcilerCommandJson,
registerFleetReconcilerCommands, registerFleetReconcilerCommands,
@@ -97,6 +101,7 @@ export interface FleetCommandDeps {
isStdinTTY?: boolean; isStdinTTY?: boolean;
projectionApplier?: FleetAgentCrudCommandDeps['projectionApplier']; projectionApplier?: FleetAgentCrudCommandDeps['projectionApplier'];
reconcileDeps?: FleetReconcilerCommandDeps['reconcileDeps']; reconcileDeps?: FleetReconcilerCommandDeps['reconcileDeps'];
migrationDeps?: Omit<FleetMigrationCommandDeps, 'mosaicHome'>;
} }
export interface FleetPaths { export interface FleetPaths {
@@ -480,7 +485,7 @@ function generateAgentEnvValues(
MOSAIC_AGENT_MODEL: agent.modelHint ?? '', MOSAIC_AGENT_MODEL: agent.modelHint ?? '',
MOSAIC_AGENT_REASONING: agent.reasoningLevel ?? '', MOSAIC_AGENT_REASONING: agent.reasoningLevel ?? '',
MOSAIC_AGENT_TOOL_POLICY: agent.toolPolicy ?? '', MOSAIC_AGENT_TOOL_POLICY: agent.toolPolicy ?? '',
MOSAIC_AGENT_WORKDIR: expandHome(workingDirectory), MOSAIC_AGENT_WORKDIR: workingDirectory,
MOSAIC_TMUX_SOCKET: agent.socket ?? roster.tmux.socketName, MOSAIC_TMUX_SOCKET: agent.socket ?? roster.tmux.socketName,
}; };
} }
@@ -2048,6 +2053,10 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
// Roster-v2 desired-state mutations belong directly to the fleet control // Roster-v2 desired-state mutations belong directly to the fleet control
// plane; they do not share the root `mosaic agent` gateway-backed surface. // plane; they do not share the root `mosaic agent` gateway-backed surface.
registerFleetAgentCrudCommands(cmd, deps); registerFleetAgentCrudCommands(cmd, deps);
registerFleetMigrationCommand(cmd, {
...deps.migrationDeps,
mosaicHome: deps.mosaicHome,
});
registerFleetReconcilerCommands(cmd, { registerFleetReconcilerCommands(cmd, {
runner, runner,
mosaicHome: deps.mosaicHome, mosaicHome: deps.mosaicHome,
@@ -2411,10 +2420,6 @@ function resolveMosaicHomeFromCommand(command: Command, override?: string): stri
return opts.mosaicHome ?? override ?? defaultMosaicHome(); return opts.mosaicHome ?? override ?? defaultMosaicHome();
} }
function expandHome(path: string): string {
return path === '~' || path.startsWith('~/') ? join(homedir(), path.slice(2)) : path;
}
async function stopFleetBestEffort(runner: CommandRunner, agentNames: string[]): Promise<void> { async function stopFleetBestEffort(runner: CommandRunner, agentNames: string[]): Promise<void> {
const failures: string[] = []; const failures: string[] = [];
for (const agentName of agentNames) { for (const agentName of agentNames) {

View File

@@ -9,6 +9,7 @@ import {
piForceSkillNames, piForceSkillNames,
registerRuntimeLaunchers, registerRuntimeLaunchers,
type RuntimeLaunchHandler, type RuntimeLaunchHandler,
type ClaudexLaunchHandler,
} from './launch.js'; } from './launch.js';
/** /**
@@ -31,6 +32,16 @@ function buildProgram(handler: RuntimeLaunchHandler): Command {
return program; return program;
} }
function buildProgramWithClaudex(
handler: RuntimeLaunchHandler,
claudexHandler: ClaudexLaunchHandler,
): Command {
const program = new Command();
program.exitOverride();
registerRuntimeLaunchers(program, handler, claudexHandler);
return program;
}
const fakeSkills = ['--skill', '/skills/test-driven-development', '--skill', '/skills/pdf']; const fakeSkills = ['--skill', '/skills/test-driven-development', '--skill', '/skills/pdf'];
const fakeForced = ['--skill', '/skills/mosaic-tools']; const fakeForced = ['--skill', '/skills/mosaic-tools'];
@@ -280,3 +291,61 @@ describe('registerRuntimeLaunchers — yolo <runtime>', () => {
expect(mockExit).toHaveBeenCalledWith(1); expect(mockExit).toHaveBeenCalledWith(1);
}); });
}); });
describe('registerRuntimeLaunchers — claudex (EXPERIMENTAL overlay)', () => {
let mockExit: MockInstance<typeof process.exit>;
let mockError: MockInstance<typeof console.error>;
beforeEach(() => {
mockExit = vi.spyOn(process, 'exit').mockImplementation(exitThrows);
mockError = vi.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
mockExit.mockRestore();
mockError.mockRestore();
});
it('dispatches `claudex` to the claudex handler (yolo=false), not the runtime handler', () => {
const handler = vi.fn();
const claudex = vi.fn();
const program = buildProgramWithClaudex(handler, claudex);
program.parse(['node', 'mosaic', 'claudex']);
expect(claudex).toHaveBeenCalledTimes(1);
expect(claudex).toHaveBeenCalledWith([], false);
expect(handler).not.toHaveBeenCalled();
});
it('forwards excess args after `claudex`', () => {
const handler = vi.fn();
const claudex = vi.fn();
const program = buildProgramWithClaudex(handler, claudex);
program.parse(['node', 'mosaic', 'claudex', '--print', 'hi']);
expect(claudex).toHaveBeenCalledWith(['--print', 'hi'], false);
});
it('dispatches `yolo claudex` with yolo=true and slices off the runtime name (#454)', () => {
const handler = vi.fn();
const claudex = vi.fn();
const program = buildProgramWithClaudex(handler, claudex);
program.parse(['node', 'mosaic', 'yolo', 'claudex']);
expect(claudex).toHaveBeenCalledTimes(1);
// extraArgs must be empty — the positional 'claudex' must not leak through.
expect(claudex).toHaveBeenCalledWith([], true);
expect(handler).not.toHaveBeenCalled();
expect(mockExit).not.toHaveBeenCalled();
});
it('forwards true excess args after `yolo claudex`', () => {
const handler = vi.fn();
const claudex = vi.fn();
const program = buildProgramWithClaudex(handler, claudex);
program.parse(['node', 'mosaic', 'yolo', 'claudex', '--model', 'gpt-5.6-sol']);
expect(claudex).toHaveBeenCalledWith(['--model', 'gpt-5.6-sol'], true);
expect(mockExit).not.toHaveBeenCalled();
});
});

View File

@@ -27,6 +27,7 @@ import {
import { readRegularFileSecure } from '../fleet/secure-file.js'; import { readRegularFileSecure } from '../fleet/secure-file.js';
import { readPersonaContractBlock } from '../fleet/persona-contract.js'; import { readPersonaContractBlock } from '../fleet/persona-contract.js';
import { canonicalizeRoleClass } from './fleet-personas.js'; import { canonicalizeRoleClass } from './fleet-personas.js';
import { launchClaudex, type ClaudexHarnessAdapter } from './claudex.js';
const MOSAIC_HOME = process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic'); const MOSAIC_HOME = process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic');
const MAX_INSTALLED_TOOLS_BYTES = 256 * 1024; const MAX_INSTALLED_TOOLS_BYTES = 256 * 1024;
@@ -806,12 +807,12 @@ function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): nev
} }
/** exec into the runtime, replacing the current process. */ /** exec into the runtime, replacing the current process. */
function execRuntime(cmd: string, args: string[]): void { function execRuntime(cmd: string, args: string[], env: NodeJS.ProcessEnv = process.env): void {
try { try {
// Use execFileSync with inherited stdio to replace the process // Use execFileSync with inherited stdio to replace the process
const result = spawnSync(cmd, args, { const result = spawnSync(cmd, args, {
stdio: 'inherit', stdio: 'inherit',
env: process.env, env,
}); });
process.exit(result.status ?? 0); process.exit(result.status ?? 0);
} catch (err) { } catch (err) {
@@ -820,6 +821,29 @@ function execRuntime(cmd: string, args: string[]): void {
} }
} }
/**
* Production glue for `mosaic [yolo] claudex` (EXPERIMENTAL — GPT models inside
* the Claude Code harness via claude-code-proxy). Assembles the real harness
* adapter and delegates the security-critical composition + fail-closed
* orchestration to `launchClaudex` in `claudex.ts`. Kept thin so the tested
* logic lives in the DI module, not here.
*/
function launchClaudexProduction(args: string[], yolo: boolean): void {
writeSessionLock('claude');
const adapter: ClaudexHarnessAdapter = {
harnessPreflight: () => {
checkMosaicHome();
checkFile(join(MOSAIC_HOME, 'AGENTS.md'), 'AGENTS.md');
checkSoul();
checkRuntime('claude');
checkSequentialThinking('claude');
},
composePrompt: () => buildRuntimePrompt('claude'),
exec: (cmd, cmdArgs, env) => execRuntime(cmd, cmdArgs, env),
};
void launchClaudex(args, yolo, adapter);
}
// ─── Framework script/tool delegation ─────────────────────────────────────── // ─── Framework script/tool delegation ───────────────────────────────────────
function delegateToScript(scriptPath: string, args: string[], env?: Record<string, string>): never { function delegateToScript(scriptPath: string, args: string[], env?: Record<string, string>): never {
@@ -1034,12 +1058,25 @@ export type RuntimeLaunchHandler = (
yolo: boolean, yolo: boolean,
) => void; ) => void;
/**
* Handler invoked for `claudex` / `yolo claudex`. Kept separate from
* `RuntimeLaunchHandler` because claudex is an EXPERIMENTAL harness overlay
* (GPT-via-proxy), not one of the first-class runtimes. Exposed + injectable so
* the commander wiring can be exercised without composing a real launch.
*/
export type ClaudexLaunchHandler = (extraArgs: string[], yolo: boolean) => void;
/** /**
* Wire `<runtime>` and `yolo <runtime>` subcommands onto `program` using a * Wire `<runtime>` and `yolo <runtime>` subcommands onto `program` using a
* pluggable launch handler. Separated from `registerLaunchCommands` so tests * pluggable launch handler. Separated from `registerLaunchCommands` so tests
* can inject a spy and verify argument forwarding. * can inject a spy and verify argument forwarding.
*/ */
export function registerRuntimeLaunchers(program: Command, handler: RuntimeLaunchHandler): void { export function registerRuntimeLaunchers(
program: Command,
handler: RuntimeLaunchHandler,
claudexHandler: ClaudexLaunchHandler = (extraArgs, yolo) =>
launchClaudexProduction(extraArgs, yolo),
): void {
for (const runtime of ['claude', 'codex', 'opencode', 'pi'] as const) { for (const runtime of ['claude', 'codex', 'opencode', 'pi'] as const) {
program program
.command(runtime) .command(runtime)
@@ -1051,16 +1088,37 @@ export function registerRuntimeLaunchers(program: Command, handler: RuntimeLaunc
}); });
} }
// claudex — EXPERIMENTAL: GPT models inside the Claude Code harness via
// claude-code-proxy (ChatGPT-subscription OAuth). Isolated CLAUDE_CONFIG_DIR
// + zero-token-leak env injection live in claudex.ts.
program
.command('claudex')
.description('EXPERIMENTAL: launch Claude Code harness against GPT via claude-code-proxy')
.allowUnknownOption(true)
.allowExcessArguments(true)
.action((_opts: unknown, cmd: Command) => {
claudexHandler(cmd.args, false);
});
program program
.command('yolo <runtime>') .command('yolo <runtime>')
.description('Launch a runtime in dangerous-permissions mode (claude|codex|opencode|pi)') .description(
'Launch a runtime in dangerous-permissions mode (claude|codex|opencode|pi|claudex)',
)
.allowUnknownOption(true) .allowUnknownOption(true)
.allowExcessArguments(true) .allowExcessArguments(true)
.action((runtime: string, _opts: unknown, cmd: Command) => { .action((runtime: string, _opts: unknown, cmd: Command) => {
// claudex is an EXPERIMENTAL overlay, not a RuntimeName — dispatch it
// before the runtime allowlist check. Slice off the positional runtime
// name for the same reason as below (#454).
if (runtime === 'claudex') {
claudexHandler(cmd.args.slice(1), true);
return;
}
const valid: RuntimeName[] = ['claude', 'codex', 'opencode', 'pi']; const valid: RuntimeName[] = ['claude', 'codex', 'opencode', 'pi'];
if (!valid.includes(runtime as RuntimeName)) { if (!valid.includes(runtime as RuntimeName)) {
console.error( console.error(
`[mosaic] ERROR: Unsupported yolo runtime '${runtime}'. Use: ${valid.join('|')}`, `[mosaic] ERROR: Unsupported yolo runtime '${runtime}'. Use: ${valid.join('|')}|claudex`,
); );
process.exit(1); process.exit(1);
} }

View File

@@ -0,0 +1,18 @@
/** Locale-independent Unicode code-point ordering for canonical fleet evidence. */
export function compareCodePoints(left: string, right: string): number {
const leftPoints = Array.from(left, (character): number => character.codePointAt(0) ?? 0);
const rightPoints = Array.from(right, (character): number => character.codePointAt(0) ?? 0);
const sharedLength = Math.min(leftPoints.length, rightPoints.length);
for (let index = 0; index < sharedLength; index += 1) {
const leftPoint = leftPoints[index];
const rightPoint = rightPoints[index];
if (leftPoint === undefined || rightPoint === undefined) continue;
if (leftPoint !== rightPoint) return leftPoint < rightPoint ? -1 : 1;
}
return leftPoints.length < rightPoints.length
? -1
: leftPoints.length > rightPoints.length
? 1
: 0;
}

View File

@@ -0,0 +1,484 @@
import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Command } from 'commander';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { registerFleetCommand, type CommandResult, type CommandRunner } from '../commands/fleet.js';
import {
executeFleetReconcile,
type FleetReconcileCommandResult,
type FleetReconcileDeps,
type FleetReconcileResult,
} from './fleet-reconciler.js';
import {
parseRosterV2,
renderRosterV2Yaml,
type FleetRosterV2,
type FleetRosterV2Agent,
} from './roster-v2.js';
import { FleetTmuxRuntimeTransport } from './tmux-runtime-transport.js';
const holderIdentity = '11111111-1111-4111-8111-111111111111';
const stoppedAgent: FleetRosterV2Agent = {
name: 'coder0',
alias: 'Coder 0',
className: 'code',
runtime: 'pi',
provider: 'openai',
model: 'gpt-5.6-sol',
reasoning: 'high',
toolPolicy: 'code',
workingDirectory: '/srv/mosaic',
persistentPersona: false,
resetBetweenTasks: true,
lifecycle: { enabled: true, desiredState: 'stopped' },
launch: { yolo: true },
};
const baseRoster: FleetRosterV2 = {
version: 2,
generation: 7,
transport: 'tmux',
tmux: { socketName: 'mosaic-fleet', holderSession: '_holder' },
defaults: { workingDirectory: '/srv/mosaic', runtime: 'pi' },
runtimes: { pi: { resetCommand: '/new' } },
agents: [stoppedAgent],
};
interface InjectedLifecycleFailure {
readonly action: 'start' | 'stop' | 'restart';
readonly service: string;
readonly diagnostic: string;
}
class FakeLifecycleHost {
readonly calls: string[][] = [];
readonly sessions = new Set<string>();
readonly activeServices = new Set<string>();
private failure: InjectedLifecycleFailure | undefined;
constructor(readonly roster: FleetRosterV2) {
this.sessions.add(roster.tmux.holderSession);
}
injectFailure(failure: InjectedLifecycleFailure): void {
this.failure = failure;
}
readonly run = async (
command: string,
args: readonly string[],
): Promise<FleetReconcileCommandResult> => {
this.calls.push([command, ...args]);
if (command === 'tmux') return this.runTmux(args);
if (command === 'systemctl') return this.runSystemctl(args);
return { stdout: '', stderr: 'unsupported fake command', exitCode: 127 };
};
private runTmux(args: readonly string[]): FleetReconcileCommandResult {
if (args.includes('list-sessions')) {
return { stdout: `${[...this.sessions].join('\n')}\n`, stderr: '', exitCode: 0 };
}
if (args.includes('has-session')) {
const targetArgument = args[args.indexOf('-t') + 1];
const sessionName = targetArgument?.replace(/^=/, '').split(':')[0];
return {
stdout: '',
stderr: '',
exitCode: sessionName !== undefined && this.sessions.has(sessionName) ? 0 : 1,
};
}
if (args.includes('show-environment')) {
return {
stdout: [
'HOME=/home/mosaic',
`MOSAIC_FLEET_OWNER=${holderIdentity}`,
`MOSAIC_TMUX_HOLDER=${this.roster.tmux.holderSession}`,
`MOSAIC_TMUX_SOCKET=${this.roster.tmux.socketName}`,
'PATH=/usr/bin:/bin',
'PWD=/home/mosaic',
'',
].join('\n'),
stderr: '',
exitCode: 0,
};
}
return { stdout: '', stderr: 'destructive tmux action rejected by fake', exitCode: 125 };
}
private runSystemctl(args: readonly string[]): FleetReconcileCommandResult {
const action = args[1];
const service = args[2];
if (action === 'show' && service !== undefined) {
return {
stdout: `ActiveState=${this.activeServices.has(service) ? 'active' : 'inactive'}\n`,
stderr: '',
exitCode: 0,
};
}
if (
(action === 'start' || action === 'stop' || action === 'restart') &&
service !== undefined
) {
this.applyLifecycleEffect(action, service);
if (this.failure?.action === action && this.failure.service === service) {
const diagnostic = this.failure.diagnostic;
this.failure = undefined;
return { stdout: '', stderr: diagnostic, exitCode: 1 };
}
return { stdout: '', stderr: '', exitCode: 0 };
}
return { stdout: '', stderr: 'unsupported fake systemctl action', exitCode: 125 };
}
private applyLifecycleEffect(action: 'start' | 'stop' | 'restart', service: string): void {
if (service === 'mosaic-tmux-holder.service') return;
const match = /^mosaic-agent@(.+)\.service$/.exec(service);
if (!match) return;
const agentName = match[1];
if (agentName === undefined) return;
if (action === 'stop') {
this.activeServices.delete(service);
this.sessions.delete(agentName);
return;
}
this.activeServices.add(service);
this.sessions.add(agentName);
}
}
const cleanupDirectories: string[] = [];
afterEach(async (): Promise<void> => {
vi.restoreAllMocks();
process.exitCode = undefined;
await Promise.all(
cleanupDirectories.splice(0).map(async (directory: string): Promise<void> => {
await rm(directory, { recursive: true, force: true });
}),
);
});
function reconcileDeps(host: FakeLifecycleHost): FleetReconcileDeps {
return {
runner: host.run,
homeDirectory: '/home/mosaic',
readHolderIdentity: async () => holderIdentity,
validateRoster: async () => undefined,
prepareProjections: async () => [{ agentName: 'coder0' }],
applyProjection: async () => undefined,
readRoster: async () => host.roster,
acquireMutationLock: async () => async () => undefined,
};
}
async function execute(
host: FakeLifecycleHost,
command: 'apply' | 'reconcile' | 'restart' | 'status' | 'stop',
agentName?: string,
): Promise<FleetReconcileResult> {
return executeFleetReconcile({
roster: host.roster,
command,
...(agentName === undefined ? {} : { agentName }),
...(command === 'status' ? {} : { expectedGeneration: host.roster.generation }),
deps: reconcileDeps(host),
});
}
async function fixtureHome(roster: FleetRosterV2): Promise<string> {
const home = await mkdtemp(join(tmpdir(), 'mosaic-reconciler-acceptance-'));
cleanupDirectories.push(home);
const fleetDirectory = join(home, 'fleet');
await mkdir(fleetDirectory, { mode: 0o700 });
await chmod(home, 0o700);
await chmod(fleetDirectory, 0o700);
await writeFile(join(fleetDirectory, 'roster.yaml'), renderRosterV2Yaml(roster), { mode: 0o600 });
return home;
}
async function fixtureLegacyRoster(): Promise<string> {
const home = await mkdtemp(join(tmpdir(), 'mosaic-reconciler-acceptance-v1-'));
cleanupDirectories.push(home);
const fleetDirectory = join(home, 'fleet');
await mkdir(fleetDirectory, { mode: 0o700 });
await chmod(home, 0o700);
await chmod(fleetDirectory, 0o700);
const rosterPath = join(fleetDirectory, 'roster.yaml');
await writeFile(
rosterPath,
[
'version: 1',
'transport: tmux',
'tmux:',
' holder_session: _holder',
'agents:',
' - name: coder0',
' runtime: pi',
' class: code',
'',
].join('\n'),
{ mode: 0o600 },
);
return rosterPath;
}
function cliProgram(home: string, host: FakeLifecycleHost): Command {
const program = new Command();
program.exitOverride();
registerFleetCommand(program, {
mosaicHome: home,
runner: async (command: string, args: string[]): Promise<CommandResult> =>
host.run(command, args),
reconcileDeps: reconcileDeps(host),
});
return program;
}
function captureJson(): string[] {
const output: string[] = [];
vi.spyOn(console, 'log').mockImplementation((line: string): void => {
output.push(line);
});
return output;
}
describe('FCM-M3-002 reconciler lifecycle acceptance', (): void => {
it('observes named-socket drift through canonical roster-v2 parsing without runtime mutation', async (): Promise<void> => {
const roster: FleetRosterV2 = {
...baseRoster,
agents: [
stoppedAgent,
{
...stoppedAgent,
name: 'reviewer0',
alias: 'Reviewer 0',
lifecycle: { enabled: true, desiredState: 'running' },
},
{
...stoppedAgent,
name: 'validator0',
alias: 'Validator 0',
lifecycle: { enabled: false, desiredState: 'stopped' },
},
],
};
const home = await fixtureHome(roster);
const host = new FakeLifecycleHost(roster);
host.sessions.add('coder0');
host.sessions.add('validator0');
host.sessions.add('coder0-shadow');
const output = captureJson();
await cliProgram(home, host).parseAsync(['node', 'mosaic', 'fleet', 'status']);
expect(output).toHaveLength(1);
expect(JSON.parse(output[0] ?? '{}')).toMatchObject({
plan: {
agents: [
{ name: 'coder0', drift: ['unexpected-session'] },
{ name: 'reviewer0', drift: ['missing-session'] },
{ name: 'validator0', drift: ['unexpected-session', 'disabled-running'] },
],
unmanagedSessions: ['coder0-shadow'],
},
});
expect(host.calls[0]).toEqual([
'tmux',
'-L',
'mosaic-fleet',
'list-sessions',
'-F',
'#{session_name}',
]);
expect(
host.calls.every(
(call: string[]): boolean =>
call[0] !== 'tmux' || (call[1] === '-L' && call[2] === 'mosaic-fleet'),
),
).toBe(true);
expect(
host.calls.every((call: string[]): boolean => call[0] !== 'systemctl' || call[2] === 'show'),
).toBe(true);
expect(process.exitCode).toBe(0);
});
it('rejects a missing canonical roster-v2 tmux socket through parseRosterV2', (): void => {
const source = renderRosterV2Yaml(baseRoster).replace(/^ socket_name:.*\n/m, '');
expect(() => parseRosterV2(source, 'yaml')).toThrow(
'Roster v2 tmux socket_name is required and must be a string.',
);
});
it('accepts an explicit empty canonical roster-v2 socket as the literal default server', (): void => {
const source = renderRosterV2Yaml(baseRoster).replace(
/^ socket_name:.*$/m,
' socket_name: ""',
);
expect(parseRosterV2(source, 'yaml').tmux.socketName).toBe('');
});
it('omits -L for the literal default tmux server at the runtime transport boundary', async (): Promise<void> => {
const rosterPath = await fixtureLegacyRoster();
const runner = vi.fn<CommandRunner>(
async (): Promise<CommandResult> => ({
stdout: '111 pi 0 0 0 0\n',
stderr: '',
exitCode: 0,
}),
);
const transport = new FleetTmuxRuntimeTransport({
mosaicHome: '/unused',
rosterPath,
runner,
});
await expect(transport.verifySession('coder0')).resolves.toEqual({
id: 'coder0',
runtimeId: 'pi',
socketName: '',
});
expect(runner).toHaveBeenCalledTimes(1);
expect(runner).toHaveBeenCalledWith('tmux', [
'list-panes',
'-t',
'=coder0:0.0',
'-F',
'#{pane_pid} #{pane_current_command} #{pane_dead} #{pane_activity} #{window_activity} #{session_activity}',
]);
expect(runner.mock.calls[0]?.[1]).not.toContain('-L');
});
it('classifies unmanaged near-collisions and stops only the exact roster-owned service', async (): Promise<void> => {
const host = new FakeLifecycleHost(baseRoster);
host.sessions.add('coder0');
host.sessions.add('coder0-shadow');
host.sessions.add('unmanaged');
host.activeServices.add('mosaic-agent@coder0.service');
host.activeServices.add('mosaic-agent@coder0-shadow.service');
const observed = await execute(host, 'status');
const stopped = await execute(host, 'stop', 'coder0');
expect(observed.plan.unmanagedSessions).toEqual(['coder0-shadow', 'unmanaged']);
expect(stopped).toMatchObject({ applied: true, lifecycle: 'complete' });
expect(host.activeServices.has('mosaic-agent@coder0.service')).toBe(false);
expect(host.activeServices.has('mosaic-agent@coder0-shadow.service')).toBe(true);
expect(host.sessions.has('coder0-shadow')).toBe(true);
expect(host.sessions.has('unmanaged')).toBe(true);
expect(host.calls).toContainEqual([
'systemctl',
'--user',
'stop',
'mosaic-agent@coder0.service',
]);
expect(
host.calls.some(
(call: string[]): boolean =>
call.includes('kill-session') ||
call.includes('coder0-shadow.service') ||
call.includes('unmanaged.service'),
),
).toBe(false);
});
it('preserves persisted stopped state through apply, reconcile, restart failure, and recovery reconcile', async (): Promise<void> => {
const host = new FakeLifecycleHost(baseRoster);
host.sessions.add('coder0');
host.activeServices.add('mosaic-agent@coder0.service');
const applied = await execute(host, 'apply');
const reconciled = await execute(host, 'reconcile');
host.injectFailure({
action: 'restart',
service: 'mosaic-agent@coder0.service',
diagnostic: 'crash after effect: TOKEN=acceptance-secret',
});
const partialRestart = await execute(host, 'restart', 'coder0');
expect(applied).toMatchObject({ applied: true, lifecycle: 'complete' });
expect(reconciled).toMatchObject({ applied: true, lifecycle: 'complete' });
expect(host.roster.agents[0]?.lifecycle.desiredState).toBe('stopped');
expect(partialRestart).toMatchObject({
applied: false,
authoritativeRoster: 'unchanged',
projections: 'not-applied',
lifecycle: 'incomplete',
recovery: {
code: 'lifecycle-apply-failed',
action: 'rerun-after-inspecting-owned-resources',
},
});
expect(host.activeServices.has('mosaic-agent@coder0.service')).toBe(true);
const recovered = await execute(host, 'reconcile');
expect(recovered).toMatchObject({ applied: true, lifecycle: 'complete' });
expect(host.roster.agents[0]?.lifecycle.desiredState).toBe('stopped');
expect(host.activeServices.has('mosaic-agent@coder0.service')).toBe(false);
expect(host.sessions.has('coder0')).toBe(false);
const destructiveCalls = host.calls.filter(
(call: string[]): boolean =>
call[0] === 'systemctl' && ['start', 'stop', 'restart'].includes(call[2] ?? ''),
);
expect(
destructiveCalls.every(
(call: string[]): boolean => call[3] === 'mosaic-agent@coder0.service',
),
).toBe(true);
expect(destructiveCalls.some((call: string[]): boolean => call[2] === 'start')).toBe(false);
});
it('emits stable non-zero redacted JSON for a partial lifecycle effect', async (): Promise<void> => {
const home = await fixtureHome(baseRoster);
const host = new FakeLifecycleHost(baseRoster);
host.injectFailure({
action: 'restart',
service: 'mosaic-agent@coder0.service',
diagnostic: 'simulated runner stderr with PASSWORD=acceptance-secret',
});
const output = captureJson();
await cliProgram(home, host).parseAsync([
'node',
'mosaic',
'fleet',
'restart',
'coder0',
'--expected-generation',
'7',
]);
const line = output.at(-1) ?? '';
expect(output).toHaveLength(1);
expect(JSON.parse(line)).toEqual({
applied: false,
authoritativeRoster: 'unchanged',
projections: 'not-applied',
lifecycle: 'incomplete',
plan: {
generation: 7,
holder: 'owned',
agents: [
{
name: 'coder0',
desiredState: 'stopped',
enabled: true,
systemd: 'inactive',
tmux: 'missing',
drift: [],
},
],
unmanagedSessions: [],
},
recovery: {
code: 'lifecycle-apply-failed',
action: 'rerun-after-inspecting-owned-resources',
},
});
expect(process.exitCode).toBe(1);
expect(line).not.toContain('PASSWORD');
expect(line).not.toContain('acceptance-secret');
expect(line).not.toContain('simulated runner stderr');
});
});

View File

@@ -274,6 +274,95 @@ describe('fleet roster-owned reconciler', (): void => {
]); ]);
}); });
it.each(['plan', 'status', 'doctor', 'verify'] as const)(
'keeps default-server %s observational and free of lifecycle effects',
async (command) => {
const calls: string[][] = [];
const defaultServerRoster: FleetRosterV2 = {
...roster,
tmux: { ...roster.tmux, socketName: '' },
};
await expect(
executeFleetReconcile({
roster: defaultServerRoster,
command,
deps: deps({
runner: async (executable, args) => {
calls.push([executable, ...args]);
if (executable === 'tmux' && args.includes('list-sessions')) {
return { stdout: '_holder\n', stderr: '', exitCode: 0 };
}
if (executable === 'tmux' && args.includes('show-environment')) {
return {
stdout:
'HOME=/home/mosaic\nMOSAIC_FLEET_OWNER=11111111-1111-4111-8111-111111111111\nMOSAIC_TMUX_HOLDER=_holder\nMOSAIC_TMUX_SOCKET=\nPATH=/usr/bin:/bin\nPWD=/home/mosaic\n',
stderr: '',
exitCode: 0,
};
}
return { stdout: 'ActiveState=inactive\n', stderr: '', exitCode: 0 };
},
}),
}),
).resolves.toMatchObject({ applied: false, lifecycle: 'not-applied' });
expect(
calls.some(
([executable, , action]): boolean =>
executable === 'systemctl' &&
(action === 'start' || action === 'stop' || action === 'restart'),
),
).toBe(false);
},
);
it.each(['start', 'stop', 'restart', 'apply', 'reconcile'] as const)(
'fails closed before %s can route fixed named-socket services for a default-server roster',
async (command) => {
const calls: string[][] = [];
let projectionPrepares = 0;
let projectionApplies = 0;
const defaultServerRoster: FleetRosterV2 = {
...roster,
tmux: { ...roster.tmux, socketName: '' },
agents: [
{
...roster.agents[0]!,
lifecycle: {
enabled: true,
desiredState: command === 'start' || command === 'restart' ? 'running' : 'stopped',
},
},
],
};
await expect(
executeFleetReconcile({
roster: defaultServerRoster,
command,
expectedGeneration: 7,
deps: deps({
readRoster: async () => defaultServerRoster,
prepareProjections: async () => {
projectionPrepares += 1;
return [{ agentName: 'coder0' }];
},
applyProjection: async () => {
projectionApplies += 1;
},
runner: async (executable, args) => {
calls.push([executable, ...args]);
return { stdout: '', stderr: '', exitCode: 0 };
},
}),
}),
).rejects.toMatchObject({ code: 'lifecycle-precondition-failed' });
expect(projectionPrepares).toBe(0);
expect(projectionApplies).toBe(0);
expect(calls).toEqual([]);
},
);
it('starts only an explicitly running roster agent with exact systemd targets', async (): Promise<void> => { it('starts only an explicitly running roster agent with exact systemd targets', async (): Promise<void> => {
const calls: string[][] = []; const calls: string[][] = [];
const runningRoster: FleetRosterV2 = { const runningRoster: FleetRosterV2 = {

View File

@@ -176,6 +176,7 @@ export async function executeFleetReconcile(
assertLocalRosterOnly(request.roster); assertLocalRosterOnly(request.roster);
const validateRoster = request.deps.validateRoster ?? defaultValidateRoster(request); const validateRoster = request.deps.validateRoster ?? defaultValidateRoster(request);
await validateRoster(request.roster); await validateRoster(request.roster);
assertLifecycleSocketAuthority(request);
const plan = scopePlan(await observeFleet(request.roster, request.deps), request.agentName); const plan = scopePlan(await observeFleet(request.roster, request.deps), request.agentName);
if (request.command === 'status' || request.command === 'doctor') { if (request.command === 'status' || request.command === 'doctor') {
@@ -477,6 +478,19 @@ function assertVerificationSafe(plan: FleetReconcilePlan): void {
} }
} }
function assertLifecycleSocketAuthority(request: FleetReconcileRequest): void {
const usesFixedLifecycleUnits =
request.command === 'apply' ||
request.command === 'reconcile' ||
isLifecycleCommand(request.command);
if (usesFixedLifecycleUnits && request.roster.tmux.socketName === '') {
throw new FleetReconcileError(
'lifecycle-precondition-failed',
'Default-server lifecycle mutation is unsupported by the fixed named-socket systemd units.',
);
}
}
function targetAgentsFor(roster: FleetRosterV2, agentName?: string): readonly FleetRosterV2Agent[] { function targetAgentsFor(roster: FleetRosterV2, agentName?: string): readonly FleetRosterV2Agent[] {
if (agentName === undefined) return roster.agents; if (agentName === undefined) return roster.agents;
const agent = roster.agents.find( const agent = roster.agents.find(

View File

@@ -9,12 +9,13 @@ import {
symlink, symlink,
writeFile, writeFile,
} from 'node:fs/promises'; } from 'node:fs/promises';
import { tmpdir } from 'node:os'; import { homedir, tmpdir } from 'node:os';
import { join } from 'node:path'; import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest'; import { afterEach, describe, expect, it } from 'vitest';
import { import {
AgentEnvBoundaryError, AgentEnvBoundaryError,
parseAgentEnvironment, parseAgentEnvironment,
previewAgentEnvironmentProjection,
renderGeneratedAgentEnvironment, renderGeneratedAgentEnvironment,
writeAgentEnvironmentProjection, writeAgentEnvironmentProjection,
} from './generated-env-boundary.js'; } from './generated-env-boundary.js';
@@ -86,6 +87,77 @@ describe('generated fleet agent environment boundary', (): void => {
}).toThrow(AgentEnvBoundaryError); }).toThrow(AgentEnvBoundaryError);
}); });
it('rejects traversal in home-relative workdirs before expansion', (): void => {
for (const workingDirectory of ['~/../escape', '~/src/../../escape']) {
expect((): void => {
renderGeneratedAgentEnvironment({
...generatedValues,
MOSAIC_AGENT_WORKDIR: workingDirectory,
});
}).toThrow(
expect.objectContaining({
diagnostic: expect.objectContaining({
code: 'unsafe-path',
key: 'MOSAIC_AGENT_WORKDIR',
}),
}),
);
}
});
it('expands home-relative workdirs before preserving absolute-path validation', (): void => {
expect(
renderGeneratedAgentEnvironment({
...generatedValues,
MOSAIC_AGENT_WORKDIR: '~/src',
}),
).toContain(`MOSAIC_AGENT_WORKDIR=${join(homedir(), 'src')}\n`);
for (const workingDirectory of ['relative/path', '../outside']) {
expect((): void => {
renderGeneratedAgentEnvironment({
...generatedValues,
MOSAIC_AGENT_WORKDIR: workingDirectory,
});
}).toThrow(AgentEnvBoundaryError);
}
});
it('previews legacy relocation and quarantine without exposing content or mutating files', async (): Promise<void> => {
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-generated-env-'));
const mosaicHome = join(cleanup, 'mosaic');
const agentEnvDir = join(mosaicHome, 'fleet', 'agents');
const legacyPath = join(agentEnvDir, 'coder0.env');
const legacy = 'MOSAIC_RUNTIME_BIN=/opt/mosaic/bin\nMOSAIC_AGENT_COMMAND=never-print-command\n';
await mkdir(agentEnvDir, { recursive: true, mode: 0o700 });
await writeFile(legacyPath, legacy, { mode: 0o600 });
const preview = await previewAgentEnvironmentProjection({
mosaicHome,
agentEnvDir,
agentName: 'coder0',
generated: generatedValues,
});
expect(preview).toMatchObject({
agentName: 'coder0',
generated: 'rebuild',
legacy: 'quarantine',
relocatedKeys: ['MOSAIC_RUNTIME_BIN'],
diagnostics: [
expect.objectContaining({
code: 'unknown-key',
key: 'MOSAIC_AGENT_COMMAND',
sha256: expect.stringMatching(/^[a-f0-9]{64}$/),
}),
],
});
expect(JSON.stringify(preview)).not.toContain('never-print-command');
await expect(readFile(legacyPath, 'utf8')).resolves.toBe(legacy);
await expect(readFile(join(agentEnvDir, 'coder0.env.generated'), 'utf8')).rejects.toThrow();
await expect(readFile(join(agentEnvDir, 'coder0.env.quarantine'), 'utf8')).rejects.toThrow();
});
it('creates missing managed directories privately before writing a projection', async (): Promise<void> => { it('creates missing managed directories privately before writing a projection', async (): Promise<void> => {
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-generated-env-')); cleanup = await mkdtemp(join(tmpdir(), 'mosaic-generated-env-'));
const mosaicHome = join(cleanup, 'mosaic'); const mosaicHome = join(cleanup, 'mosaic');

View File

@@ -1,6 +1,8 @@
import { createHash, randomUUID } from 'node:crypto'; import { createHash, randomUUID } from 'node:crypto';
import { chmod, lstat, mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'; import { chmod, lstat, mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
import { homedir } from 'node:os';
import { dirname, join, resolve } from 'node:path'; import { dirname, join, resolve } from 'node:path';
import { compareCodePoints } from './deterministic-order.js';
export type AgentEnvironmentKind = 'generated' | 'local'; export type AgentEnvironmentKind = 'generated' | 'local';
@@ -30,6 +32,15 @@ export interface AgentEnvironmentProjectionResult {
readonly diagnostics: readonly AgentEnvironmentDiagnostic[]; readonly diagnostics: readonly AgentEnvironmentDiagnostic[];
} }
/** Sanitized, non-mutating projection evidence safe for migration output. */
export interface AgentEnvironmentProjectionPreview {
readonly agentName: string;
readonly generated: 'rebuild';
readonly legacy: 'absent' | 'regenerate-only' | 'relocate-local' | 'quarantine';
readonly relocatedKeys: readonly string[];
readonly diagnostics: readonly AgentEnvironmentDiagnostic[];
}
/** A projection fully validated without changing managed files. */ /** A projection fully validated without changing managed files. */
export interface PreparedAgentEnvironmentProjection { export interface PreparedAgentEnvironmentProjection {
readonly mosaicHome: string; readonly mosaicHome: string;
@@ -40,6 +51,7 @@ export interface PreparedAgentEnvironmentProjection {
readonly generated: string; readonly generated: string;
readonly local: string; readonly local: string;
readonly legacy?: string; readonly legacy?: string;
readonly legacyRelocatedKeys: readonly string[];
readonly quarantinePath?: string; readonly quarantinePath?: string;
readonly diagnostics: readonly AgentEnvironmentDiagnostic[]; readonly diagnostics: readonly AgentEnvironmentDiagnostic[];
} }
@@ -186,6 +198,7 @@ export async function prepareAgentEnvironmentProjection(
generated, generated,
local, local,
...(legacy === undefined ? {} : { legacy }), ...(legacy === undefined ? {} : { legacy }),
legacyRelocatedKeys: Object.keys(legacyDisposition.localValues).sort(compareCodePoints),
...(quarantinePath === undefined ? {} : { quarantinePath }), ...(quarantinePath === undefined ? {} : { quarantinePath }),
diagnostics: legacyDisposition.diagnostics, diagnostics: legacyDisposition.diagnostics,
}; };
@@ -208,6 +221,33 @@ export async function prepareAgentGeneratedProjectionDeletion(
return generatedPath; return generatedPath;
} }
export async function previewAgentEnvironmentProjection(
options: AgentEnvironmentProjectionOptions,
): Promise<AgentEnvironmentProjectionPreview> {
const prepared = await prepareAgentEnvironmentProjection(options);
const relocatedKeys = prepared.legacyRelocatedKeys;
const legacy =
prepared.legacy === undefined
? 'absent'
: prepared.diagnostics.length > 0
? 'quarantine'
: relocatedKeys.length > 0
? 'relocate-local'
: 'regenerate-only';
return {
agentName: options.agentName,
generated: 'rebuild',
legacy,
relocatedKeys,
diagnostics: [...prepared.diagnostics].sort((left, right): number =>
compareCodePoints(
`${left.key}:${left.code}:${left.sha256}`,
`${right.key}:${right.code}:${right.sha256}`,
),
),
};
}
/** Applies a previously prepared deterministic projection. */ /** Applies a previously prepared deterministic projection. */
export async function applyPreparedAgentEnvironmentProjection( export async function applyPreparedAgentEnvironmentProjection(
prepared: PreparedAgentEnvironmentProjection, prepared: PreparedAgentEnvironmentProjection,
@@ -279,7 +319,7 @@ function normalizeGeneratedValues(
for (const key of GENERATED_AGENT_ENV_KEYS) { for (const key of GENERATED_AGENT_ENV_KEYS) {
const value = values[key]; const value = values[key];
if (value === undefined) throw new AgentEnvBoundaryError('missing-key', key, ''); if (value === undefined) throw new AgentEnvBoundaryError('missing-key', key, '');
normalized[key] = value; normalized[key] = key === 'MOSAIC_AGENT_WORKDIR' ? expandHomeDirectory(value) : value;
} }
for (const [key, value] of Object.entries(values)) { for (const [key, value] of Object.entries(values)) {
if (!GENERATED_KEY_SET.has(key)) throw new AgentEnvBoundaryError('unknown-key', key, value); if (!GENERATED_KEY_SET.has(key)) throw new AgentEnvBoundaryError('unknown-key', key, value);
@@ -288,17 +328,23 @@ function normalizeGeneratedValues(
return Object.freeze(normalized); return Object.freeze(normalized);
} }
function expandHomeDirectory(path: string): string {
if (path === '~') return homedir();
if (!path.startsWith('~/') || path.split('/').includes('..')) return path;
return join(homedir(), path.slice(2));
}
function renderLocalAgentEnvironment(values: Readonly<Record<string, string>>): string { function renderLocalAgentEnvironment(values: Readonly<Record<string, string>>): string {
if (Object.keys(values).length === 0) return ''; if (Object.keys(values).length === 0) return '';
const parsed = parseAgentEnvironment( const parsed = parseAgentEnvironment(
Object.entries(values) Object.entries(values)
.sort(([left], [right]): number => left.localeCompare(right)) .sort(([left], [right]): number => compareCodePoints(left, right))
.map(([key, value]): string => `${key}=${value}`) .map(([key, value]): string => `${key}=${value}`)
.join('\n'), .join('\n'),
'local', 'local',
); );
return `${Object.entries(parsed) return `${Object.entries(parsed)
.sort(([left], [right]): number => left.localeCompare(right)) .sort(([left], [right]): number => compareCodePoints(left, right))
.map(([key, value]): string => `${key}=${value}`) .map(([key, value]): string => `${key}=${value}`)
.join('\n')}\n`; .join('\n')}\n`;
} }

View File

@@ -45,6 +45,12 @@ agents:
let semanticTmp: string | undefined; let semanticTmp: string | undefined;
it('preserves an explicit empty socket as the literal default tmux server', () => {
const roster = parseRosterV2(validRoster.replace('socket_name: mosaic-fleet', "socket_name: ''"));
expect(roster.tmux.socketName).toBe('');
expect(renderRosterV2Yaml(roster)).toContain('socket_name: ""');
});
afterEach(async (): Promise<void> => { afterEach(async (): Promise<void> => {
if (semanticTmp) await rm(semanticTmp, { recursive: true, force: true }); if (semanticTmp) await rm(semanticTmp, { recursive: true, force: true });
semanticTmp = undefined; semanticTmp = undefined;

View File

@@ -10,6 +10,7 @@ import {
type PersonaResolution, type PersonaResolution,
type RoleAuthority, type RoleAuthority,
} from '../commands/fleet-personas.js'; } from '../commands/fleet-personas.js';
import { compareCodePoints } from './deterministic-order.js';
export const ROSTER_V2_SUPPORTED_RUNTIMES = ['claude', 'codex', 'opencode', 'pi'] as const; export const ROSTER_V2_SUPPORTED_RUNTIMES = ['claude', 'codex', 'opencode', 'pi'] as const;
export const ROSTER_V2_REASONING_LEVELS = ['low', 'medium', 'high'] as const; export const ROSTER_V2_REASONING_LEVELS = ['low', 'medium', 'high'] as const;
@@ -172,7 +173,7 @@ export const ROSTER_V2_JSON_SCHEMA: JsonSchema = {
additionalProperties: false, additionalProperties: false,
required: ['socket_name', 'holder_session'], required: ['socket_name', 'holder_session'],
properties: { properties: {
socket_name: { type: 'string', pattern: '^[A-Za-z0-9_.-]+$' }, socket_name: { type: 'string', pattern: '^[A-Za-z0-9_.-]*$' },
holder_session: { type: 'string', pattern: '^[A-Za-z0-9_.-]+$' }, holder_session: { type: 'string', pattern: '^[A-Za-z0-9_.-]+$' },
}, },
}, },
@@ -272,6 +273,7 @@ const AGENT_KEYS = [
const LIFECYCLE_KEYS = ['enabled', 'desired_state']; const LIFECYCLE_KEYS = ['enabled', 'desired_state'];
const LAUNCH_KEYS = ['yolo']; const LAUNCH_KEYS = ['yolo'];
const IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/; const IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
const TMUX_SOCKET_IDENTIFIER = /^[A-Za-z0-9_.-]*$/;
const TMUX_IDENTIFIER = /^[A-Za-z0-9_.-]+$/; const TMUX_IDENTIFIER = /^[A-Za-z0-9_.-]+$/;
const POLICY_IDENTIFIER = /^[a-z][a-z0-9-]*$/; const POLICY_IDENTIFIER = /^[a-z][a-z0-9-]*$/;
@@ -327,7 +329,7 @@ function normalizeTmux(value: unknown): FleetRosterV2Tmux {
const raw = requiredObject(value, 'Roster v2 tmux'); const raw = requiredObject(value, 'Roster v2 tmux');
assertKnownKeys(raw, 'Roster v2 tmux', TMUX_KEYS); assertKnownKeys(raw, 'Roster v2 tmux', TMUX_KEYS);
return { return {
socketName: requiredTmuxIdentifier(raw.socket_name, 'Roster v2 tmux socket_name'), socketName: requiredTmuxSocket(raw.socket_name, 'Roster v2 tmux socket_name'),
holderSession: requiredTmuxIdentifier(raw.holder_session, 'Roster v2 tmux holder_session'), holderSession: requiredTmuxIdentifier(raw.holder_session, 'Roster v2 tmux holder_session'),
}; };
} }
@@ -348,7 +350,7 @@ function normalizeRuntimes(value: unknown): Readonly<Record<string, FleetRosterV
throw new RosterV2ValidationError('Roster v2 runtimes must not be empty.'); throw new RosterV2ValidationError('Roster v2 runtimes must not be empty.');
const result: Record<string, FleetRosterV2Runtime> = {}; const result: Record<string, FleetRosterV2Runtime> = {};
for (const name of names.sort()) { for (const name of names.sort(compareCodePoints)) {
const runtime = requiredRuntime(name, 'Roster v2 runtime name'); const runtime = requiredRuntime(name, 'Roster v2 runtime name');
const config = requiredObject(raw[name], `Roster v2 runtime "${runtime}"`); const config = requiredObject(raw[name], `Roster v2 runtime "${runtime}"`);
assertKnownKeys(config, `Roster v2 runtime "${runtime}"`, RUNTIME_KEYS); assertKnownKeys(config, `Roster v2 runtime "${runtime}"`, RUNTIME_KEYS);
@@ -416,7 +418,7 @@ function normalizeAgents(
}; };
}); });
return agents.sort((left: FleetRosterV2Agent, right: FleetRosterV2Agent): number => return agents.sort((left: FleetRosterV2Agent, right: FleetRosterV2Agent): number =>
left.name.localeCompare(right.name), compareCodePoints(left.name, right.name),
); );
} }
@@ -473,6 +475,17 @@ function requiredIdentifier(value: unknown, label: string): string {
return result; return result;
} }
function requiredTmuxSocket(value: unknown, label: string): string {
if (typeof value !== 'string') {
throw new RosterV2ValidationError(`${label} is required and must be a string.`);
}
const result = value.trim();
if (!TMUX_SOCKET_IDENTIFIER.test(result)) {
throw new RosterV2ValidationError(`Invalid ${label}: ${result}.`);
}
return result;
}
function requiredTmuxIdentifier(value: unknown, label: string): string { function requiredTmuxIdentifier(value: unknown, label: string): string {
const result = requiredString(value, label); const result = requiredString(value, label);
if (!TMUX_IDENTIFIER.test(result)) if (!TMUX_IDENTIFIER.test(result))
@@ -524,7 +537,7 @@ function toSourceShape(roster: FleetRosterV2): Record<string, unknown> {
}, },
runtimes: Object.fromEntries( runtimes: Object.fromEntries(
Object.entries(roster.runtimes) Object.entries(roster.runtimes)
.sort(([left], [right]): number => left.localeCompare(right)) .sort(([left], [right]): number => compareCodePoints(left, right))
.map(([name, runtime]): [string, unknown] => [ .map(([name, runtime]): [string, unknown] => [
name, name,
{ reset_command: runtime.resetCommand }, { reset_command: runtime.resetCommand },
@@ -532,7 +545,7 @@ function toSourceShape(roster: FleetRosterV2): Record<string, unknown> {
), ),
agents: [...roster.agents] agents: [...roster.agents]
.sort((left: FleetRosterV2Agent, right: FleetRosterV2Agent): number => .sort((left: FleetRosterV2Agent, right: FleetRosterV2Agent): number =>
left.name.localeCompare(right.name), compareCodePoints(left.name, right.name),
) )
.map( .map(
(agent: FleetRosterV2Agent): Record<string, unknown> => ({ (agent: FleetRosterV2Agent): Record<string, unknown> => ({

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,8 @@
{
"extends": ["//"],
"tasks": {
"test": {
"dependsOn": ["^build", "build"]
}
}
}