Compare commits
6 Commits
fix/766-ex
...
feat/790-m
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79e2fa35d5 | ||
|
|
ab8c9a2d4b | ||
| 59f5f51ffd | |||
| 9745bc3f29 | |||
| adad486b6f | |||
| c1aecfabe9 |
@@ -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 applicable acceptance evidence before merge. Issue #758 remains open until M5 closes.
|
||||
|
||||
| id | status | description | issue | agent | repo | branch | depends_on | estimate | notes |
|
||||
| ---------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | ------------- | ----------------- | --------------------------------------- | ---------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------ |
|
||||
| FCM-M0-001 | done | Publish normative PRD requirements/acceptance criteria, this M0–M5 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-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-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-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-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-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-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-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-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 |
|
||||
| id | status | description | issue | agent | repo | branch | depends_on | estimate | notes |
|
||||
| ---------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | ------------- | ----------------- | --------------------------------------- | ---------------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------- |
|
||||
| FCM-M0-001 | done | Publish normative PRD requirements/acceptance criteria, this M0–M5 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-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 | 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 | 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 | 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 | 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 | 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-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-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
|
||||
|
||||
|
||||
@@ -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. |
|
||||
| `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
|
||||
|
||||
```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
|
||||
|
||||
86
docs/fleet/migration/v1-to-v2.md
Normal file
86
docs/fleet/migration/v1-to-v2.md
Normal 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.
|
||||
40
docs/fleet/operations/backup-restore.md
Normal file
40
docs/fleet/operations/backup-restore.md
Normal 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.
|
||||
@@ -11,8 +11,15 @@ mosaic fleet restart [name] --expected-generation <n> [--dry-run]
|
||||
mosaic fleet status [name]
|
||||
mosaic fleet verify
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -62,24 +62,24 @@ agents:
|
||||
|
||||
## Nested fields
|
||||
|
||||
| Path | Required | Constraint |
|
||||
| ---------------------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------- |
|
||||
| `tmux.socket_name` | yes | non-empty `[A-Za-z0-9_.-]+`; an explicit named socket prevents default-versus-named socket ambiguity |
|
||||
| `tmux.holder_session` | yes | non-empty `[A-Za-z0-9_.-]+` |
|
||||
| `defaults.working_directory` | yes | non-empty string |
|
||||
| `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 |
|
||||
| `agents[].name` | yes | unique `[A-Za-z0-9][A-Za-z0-9_.-]*` stable machine identity |
|
||||
| `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[].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[].reasoning` | yes | `low`, `medium`, or `high` |
|
||||
| `agents[].tool_policy` | yes | `[a-z][a-z0-9-]*`; structural only in M1 |
|
||||
| `agents[].persistent_persona`, `reset_between_tasks` | yes | booleans |
|
||||
| `agents[].lifecycle.enabled` | yes | boolean; stored now, reconciled in FCM-M3-001 |
|
||||
| `agents[].lifecycle.desired_state` | yes | `running` or `stopped` |
|
||||
| `agents[].launch.yolo` | yes | boolean; structured data only, not an arbitrary command escape hatch |
|
||||
| Path | Required | Constraint |
|
||||
| ---------------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------- |
|
||||
| `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_.-]+` |
|
||||
| `defaults.working_directory` | yes | non-empty string |
|
||||
| `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 |
|
||||
| `agents[].name` | yes | unique `[A-Za-z0-9][A-Za-z0-9_.-]*` stable machine identity |
|
||||
| `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[].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[].reasoning` | yes | `low`, `medium`, or `high` |
|
||||
| `agents[].tool_policy` | yes | `[a-z][a-z0-9-]*`; structural only in M1 |
|
||||
| `agents[].persistent_persona`, `reset_between_tasks` | yes | booleans |
|
||||
| `agents[].lifecycle.enabled` | yes | boolean; stored now, reconciled in FCM-M3-001 |
|
||||
| `agents[].lifecycle.desired_state` | yes | `running` or `stopped` |
|
||||
| `agents[].launch.yolo` | yes | boolean; structured data only, not an arbitrary command escape hatch |
|
||||
|
||||
## Semantic handoff
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
"properties": {
|
||||
"socket_name": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z0-9_.-]+$"
|
||||
"pattern": "^[A-Za-z0-9_.-]*$"
|
||||
},
|
||||
"holder_session": {
|
||||
"type": "string",
|
||||
|
||||
@@ -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.
|
||||
80
docs/scratchpads/758-fcm-m4-001-v1-v2-migrator.md
Normal file
80
docs/scratchpads/758-fcm-m4-001-v1-v2-migrator.md
Normal 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.
|
||||
@@ -47,6 +47,61 @@ export MOSAIC_ADMIN_PASSWORD="securepass123"
|
||||
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
|
||||
|
||||
After running `mosaic wizard`, Claude hooks are installed in `~/.claude/hooks-config.json`.
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Command } from 'commander';
|
||||
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
|
||||
// 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]> = [
|
||||
['auth', registerAuthCommand],
|
||||
['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', () => {
|
||||
const program = new Command();
|
||||
for (const [, register] of REGISTRARS) register(program);
|
||||
|
||||
862
packages/mosaic/src/commands/claudex-proxy.spec.ts
Normal file
862
packages/mosaic/src/commands/claudex-proxy.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
700
packages/mosaic/src/commands/claudex-proxy.ts
Normal file
700
packages/mosaic/src/commands/claudex-proxy.ts
Normal 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' };
|
||||
}
|
||||
732
packages/mosaic/src/commands/claudex.spec.ts
Normal file
732
packages/mosaic/src/commands/claudex.spec.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
464
packages/mosaic/src/commands/claudex.ts
Normal file
464
packages/mosaic/src/commands/claudex.ts
Normal file
@@ -0,0 +1,464 @@
|
||||
/**
|
||||
* Claudex launch composition (P2–P4 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);
|
||||
}
|
||||
}
|
||||
280
packages/mosaic/src/commands/fleet-migration-command.spec.ts
Normal file
280
packages/mosaic/src/commands/fleet-migration-command.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
170
packages/mosaic/src/commands/fleet-migration-command.ts
Normal file
170
packages/mosaic/src/commands/fleet-migration-command.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
@@ -90,6 +90,7 @@ describe('registerFleetCommand', () => {
|
||||
'init',
|
||||
'install',
|
||||
'install-systemd',
|
||||
'migrate-v1',
|
||||
'persona',
|
||||
'plan',
|
||||
'profile',
|
||||
@@ -197,6 +198,26 @@ describe('fleet roster parsing', () => {
|
||||
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 () => {
|
||||
cleanup = await tempDir();
|
||||
const rosterPath = join(cleanup, 'roster.yaml');
|
||||
@@ -270,6 +291,32 @@ describe('fleet roster parsing', () => {
|
||||
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 () => {
|
||||
cleanup = await tempDir();
|
||||
const rosterPath = join(cleanup, 'roster.json');
|
||||
|
||||
@@ -35,6 +35,10 @@ import {
|
||||
registerFleetAgentCrudCommands,
|
||||
type FleetAgentCrudCommandDeps,
|
||||
} from './fleet-agent-crud-command.js';
|
||||
import {
|
||||
registerFleetMigrationCommand,
|
||||
type FleetMigrationCommandDeps,
|
||||
} from './fleet-migration-command.js';
|
||||
import {
|
||||
executeReconcilerCommandJson,
|
||||
registerFleetReconcilerCommands,
|
||||
@@ -97,6 +101,7 @@ export interface FleetCommandDeps {
|
||||
isStdinTTY?: boolean;
|
||||
projectionApplier?: FleetAgentCrudCommandDeps['projectionApplier'];
|
||||
reconcileDeps?: FleetReconcilerCommandDeps['reconcileDeps'];
|
||||
migrationDeps?: Omit<FleetMigrationCommandDeps, 'mosaicHome'>;
|
||||
}
|
||||
|
||||
export interface FleetPaths {
|
||||
@@ -480,7 +485,7 @@ function generateAgentEnvValues(
|
||||
MOSAIC_AGENT_MODEL: agent.modelHint ?? '',
|
||||
MOSAIC_AGENT_REASONING: agent.reasoningLevel ?? '',
|
||||
MOSAIC_AGENT_TOOL_POLICY: agent.toolPolicy ?? '',
|
||||
MOSAIC_AGENT_WORKDIR: expandHome(workingDirectory),
|
||||
MOSAIC_AGENT_WORKDIR: workingDirectory,
|
||||
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
|
||||
// plane; they do not share the root `mosaic agent` gateway-backed surface.
|
||||
registerFleetAgentCrudCommands(cmd, deps);
|
||||
registerFleetMigrationCommand(cmd, {
|
||||
...deps.migrationDeps,
|
||||
mosaicHome: deps.mosaicHome,
|
||||
});
|
||||
registerFleetReconcilerCommands(cmd, {
|
||||
runner,
|
||||
mosaicHome: deps.mosaicHome,
|
||||
@@ -2411,10 +2420,6 @@ function resolveMosaicHomeFromCommand(command: Command, override?: string): stri
|
||||
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> {
|
||||
const failures: string[] = [];
|
||||
for (const agentName of agentNames) {
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
piForceSkillNames,
|
||||
registerRuntimeLaunchers,
|
||||
type RuntimeLaunchHandler,
|
||||
type ClaudexLaunchHandler,
|
||||
} from './launch.js';
|
||||
|
||||
/**
|
||||
@@ -31,6 +32,16 @@ function buildProgram(handler: RuntimeLaunchHandler): Command {
|
||||
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 fakeForced = ['--skill', '/skills/mosaic-tools'];
|
||||
|
||||
@@ -280,3 +291,61 @@ describe('registerRuntimeLaunchers — yolo <runtime>', () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
import { readRegularFileSecure } from '../fleet/secure-file.js';
|
||||
import { readPersonaContractBlock } from '../fleet/persona-contract.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 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. */
|
||||
function execRuntime(cmd: string, args: string[]): void {
|
||||
function execRuntime(cmd: string, args: string[], env: NodeJS.ProcessEnv = process.env): void {
|
||||
try {
|
||||
// Use execFileSync with inherited stdio to replace the process
|
||||
const result = spawnSync(cmd, args, {
|
||||
stdio: 'inherit',
|
||||
env: process.env,
|
||||
env,
|
||||
});
|
||||
process.exit(result.status ?? 0);
|
||||
} 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 ───────────────────────────────────────
|
||||
|
||||
function delegateToScript(scriptPath: string, args: string[], env?: Record<string, string>): never {
|
||||
@@ -1034,12 +1058,25 @@ export type RuntimeLaunchHandler = (
|
||||
yolo: boolean,
|
||||
) => 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
|
||||
* pluggable launch handler. Separated from `registerLaunchCommands` so tests
|
||||
* 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) {
|
||||
program
|
||||
.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
|
||||
.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)
|
||||
.allowExcessArguments(true)
|
||||
.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'];
|
||||
if (!valid.includes(runtime as RuntimeName)) {
|
||||
console.error(
|
||||
`[mosaic] ERROR: Unsupported yolo runtime '${runtime}'. Use: ${valid.join('|')}`,
|
||||
`[mosaic] ERROR: Unsupported yolo runtime '${runtime}'. Use: ${valid.join('|')}|claudex`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
18
packages/mosaic/src/fleet/deterministic-order.ts
Normal file
18
packages/mosaic/src/fleet/deterministic-order.ts
Normal 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;
|
||||
}
|
||||
484
packages/mosaic/src/fleet/fleet-reconciler.acceptance.spec.ts
Normal file
484
packages/mosaic/src/fleet/fleet-reconciler.acceptance.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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> => {
|
||||
const calls: string[][] = [];
|
||||
const runningRoster: FleetRosterV2 = {
|
||||
|
||||
@@ -176,6 +176,7 @@ export async function executeFleetReconcile(
|
||||
assertLocalRosterOnly(request.roster);
|
||||
const validateRoster = request.deps.validateRoster ?? defaultValidateRoster(request);
|
||||
await validateRoster(request.roster);
|
||||
assertLifecycleSocketAuthority(request);
|
||||
const plan = scopePlan(await observeFleet(request.roster, request.deps), request.agentName);
|
||||
|
||||
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[] {
|
||||
if (agentName === undefined) return roster.agents;
|
||||
const agent = roster.agents.find(
|
||||
|
||||
@@ -9,12 +9,13 @@ import {
|
||||
symlink,
|
||||
writeFile,
|
||||
} from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { homedir, tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
AgentEnvBoundaryError,
|
||||
parseAgentEnvironment,
|
||||
previewAgentEnvironmentProjection,
|
||||
renderGeneratedAgentEnvironment,
|
||||
writeAgentEnvironmentProjection,
|
||||
} from './generated-env-boundary.js';
|
||||
@@ -86,6 +87,77 @@ describe('generated fleet agent environment boundary', (): void => {
|
||||
}).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> => {
|
||||
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-generated-env-'));
|
||||
const mosaicHome = join(cleanup, 'mosaic');
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
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 { compareCodePoints } from './deterministic-order.js';
|
||||
|
||||
export type AgentEnvironmentKind = 'generated' | 'local';
|
||||
|
||||
@@ -30,6 +32,15 @@ export interface AgentEnvironmentProjectionResult {
|
||||
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. */
|
||||
export interface PreparedAgentEnvironmentProjection {
|
||||
readonly mosaicHome: string;
|
||||
@@ -40,6 +51,7 @@ export interface PreparedAgentEnvironmentProjection {
|
||||
readonly generated: string;
|
||||
readonly local: string;
|
||||
readonly legacy?: string;
|
||||
readonly legacyRelocatedKeys: readonly string[];
|
||||
readonly quarantinePath?: string;
|
||||
readonly diagnostics: readonly AgentEnvironmentDiagnostic[];
|
||||
}
|
||||
@@ -186,6 +198,7 @@ export async function prepareAgentEnvironmentProjection(
|
||||
generated,
|
||||
local,
|
||||
...(legacy === undefined ? {} : { legacy }),
|
||||
legacyRelocatedKeys: Object.keys(legacyDisposition.localValues).sort(compareCodePoints),
|
||||
...(quarantinePath === undefined ? {} : { quarantinePath }),
|
||||
diagnostics: legacyDisposition.diagnostics,
|
||||
};
|
||||
@@ -208,6 +221,33 @@ export async function prepareAgentGeneratedProjectionDeletion(
|
||||
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. */
|
||||
export async function applyPreparedAgentEnvironmentProjection(
|
||||
prepared: PreparedAgentEnvironmentProjection,
|
||||
@@ -279,7 +319,7 @@ function normalizeGeneratedValues(
|
||||
for (const key of GENERATED_AGENT_ENV_KEYS) {
|
||||
const value = values[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)) {
|
||||
if (!GENERATED_KEY_SET.has(key)) throw new AgentEnvBoundaryError('unknown-key', key, value);
|
||||
@@ -288,17 +328,23 @@ function normalizeGeneratedValues(
|
||||
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 {
|
||||
if (Object.keys(values).length === 0) return '';
|
||||
const parsed = parseAgentEnvironment(
|
||||
Object.entries(values)
|
||||
.sort(([left], [right]): number => left.localeCompare(right))
|
||||
.sort(([left], [right]): number => compareCodePoints(left, right))
|
||||
.map(([key, value]): string => `${key}=${value}`)
|
||||
.join('\n'),
|
||||
'local',
|
||||
);
|
||||
return `${Object.entries(parsed)
|
||||
.sort(([left], [right]): number => left.localeCompare(right))
|
||||
.sort(([left], [right]): number => compareCodePoints(left, right))
|
||||
.map(([key, value]): string => `${key}=${value}`)
|
||||
.join('\n')}\n`;
|
||||
}
|
||||
|
||||
@@ -45,6 +45,12 @@ agents:
|
||||
|
||||
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> => {
|
||||
if (semanticTmp) await rm(semanticTmp, { recursive: true, force: true });
|
||||
semanticTmp = undefined;
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
type PersonaResolution,
|
||||
type RoleAuthority,
|
||||
} 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_REASONING_LEVELS = ['low', 'medium', 'high'] as const;
|
||||
@@ -172,7 +173,7 @@ export const ROSTER_V2_JSON_SCHEMA: JsonSchema = {
|
||||
additionalProperties: false,
|
||||
required: ['socket_name', 'holder_session'],
|
||||
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_.-]+$' },
|
||||
},
|
||||
},
|
||||
@@ -272,6 +273,7 @@ const AGENT_KEYS = [
|
||||
const LIFECYCLE_KEYS = ['enabled', 'desired_state'];
|
||||
const LAUNCH_KEYS = ['yolo'];
|
||||
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 POLICY_IDENTIFIER = /^[a-z][a-z0-9-]*$/;
|
||||
|
||||
@@ -327,7 +329,7 @@ function normalizeTmux(value: unknown): FleetRosterV2Tmux {
|
||||
const raw = requiredObject(value, 'Roster v2 tmux');
|
||||
assertKnownKeys(raw, 'Roster v2 tmux', TMUX_KEYS);
|
||||
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'),
|
||||
};
|
||||
}
|
||||
@@ -348,7 +350,7 @@ function normalizeRuntimes(value: unknown): Readonly<Record<string, FleetRosterV
|
||||
throw new RosterV2ValidationError('Roster v2 runtimes must not be empty.');
|
||||
|
||||
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 config = requiredObject(raw[name], `Roster v2 runtime "${runtime}"`);
|
||||
assertKnownKeys(config, `Roster v2 runtime "${runtime}"`, RUNTIME_KEYS);
|
||||
@@ -416,7 +418,7 @@ function normalizeAgents(
|
||||
};
|
||||
});
|
||||
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;
|
||||
}
|
||||
|
||||
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 {
|
||||
const result = requiredString(value, label);
|
||||
if (!TMUX_IDENTIFIER.test(result))
|
||||
@@ -524,7 +537,7 @@ function toSourceShape(roster: FleetRosterV2): Record<string, unknown> {
|
||||
},
|
||||
runtimes: Object.fromEntries(
|
||||
Object.entries(roster.runtimes)
|
||||
.sort(([left], [right]): number => left.localeCompare(right))
|
||||
.sort(([left], [right]): number => compareCodePoints(left, right))
|
||||
.map(([name, runtime]): [string, unknown] => [
|
||||
name,
|
||||
{ reset_command: runtime.resetCommand },
|
||||
@@ -532,7 +545,7 @@ function toSourceShape(roster: FleetRosterV2): Record<string, unknown> {
|
||||
),
|
||||
agents: [...roster.agents]
|
||||
.sort((left: FleetRosterV2Agent, right: FleetRosterV2Agent): number =>
|
||||
left.name.localeCompare(right.name),
|
||||
compareCodePoints(left.name, right.name),
|
||||
)
|
||||
.map(
|
||||
(agent: FleetRosterV2Agent): Record<string, unknown> => ({
|
||||
|
||||
1874
packages/mosaic/src/fleet/v1-v2-migration.spec.ts
Normal file
1874
packages/mosaic/src/fleet/v1-v2-migration.spec.ts
Normal file
File diff suppressed because it is too large
Load Diff
1506
packages/mosaic/src/fleet/v1-v2-migration.ts
Normal file
1506
packages/mosaic/src/fleet/v1-v2-migration.ts
Normal file
File diff suppressed because it is too large
Load Diff
8
packages/mosaic/turbo.json
Normal file
8
packages/mosaic/turbo.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": ["//"],
|
||||
"tasks": {
|
||||
"test": {
|
||||
"dependsOn": ["^build", "build"]
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user