Compare commits
8 Commits
docs/771-k
...
feat/790-m
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79e2fa35d5 | ||
|
|
ab8c9a2d4b | ||
| 59f5f51ffd | |||
| 9745bc3f29 | |||
| adad486b6f | |||
| c1aecfabe9 | |||
| 499090508e | |||
| c593a15ef8 |
13
CLAUDE.md
13
CLAUDE.md
@@ -26,13 +26,14 @@ pnpm test # Vitest (all packages)
|
||||
pnpm build # Build all packages
|
||||
|
||||
# Database
|
||||
pnpm --filter @mosaicstack/db db:push # Push schema to PG (dev)
|
||||
pnpm --filter @mosaicstack/db db:generate # Generate migrations
|
||||
pnpm --filter @mosaicstack/db db:migrate # Run migrations
|
||||
pnpm --filter @mosaicstack/db db:generate # Offline migration artifact generation only
|
||||
# PostgreSQL execution is held until KBN-101-00/-03/-05 land. Do not invoke a runner,
|
||||
# init SQL, or Compose PostgreSQL service from this checkout.
|
||||
|
||||
# Dev
|
||||
docker compose up -d # Start PG, Valkey, OTEL, Jaeger
|
||||
pnpm --filter @mosaicstack/gateway exec tsx src/main.ts # Start gateway
|
||||
# Dev: local PGlite data-layer work needs no PostgreSQL. Optional local queue service only:
|
||||
docker compose up -d valkey
|
||||
# Do not start Gateway/Web or root pnpm dev as a local PGlite route: the current unguarded dotenv
|
||||
# loader can inherit a daemon PostgreSQL DSN. KBN-101-02 must make that state fail closed first.
|
||||
```
|
||||
|
||||
## Conventions
|
||||
|
||||
44
README.md
44
README.md
@@ -157,7 +157,12 @@ mosaic storage status
|
||||
mosaic storage tier
|
||||
mosaic storage export
|
||||
mosaic storage import
|
||||
mosaic storage migrate
|
||||
# Schema migration is unavailable in this release. The current storage wrapper shells
|
||||
# directly to `pnpm --filter @mosaicstack/db db:migrate`; it is legacy N-1,
|
||||
# uncertified, and MUST NOT be invoked pending KBN-101-02/-03/-06/-08 activation.
|
||||
# Future schema migration is non-operative: external bootstrap → TLS/roles → runner
|
||||
# --run → runner --verify → readiness. Tier copy uses only the separately held secure
|
||||
# migrate-tier route.
|
||||
```
|
||||
|
||||
### Telemetry
|
||||
@@ -192,29 +197,32 @@ Consent state is persisted in config. Remote upload is a no-op until you run `mo
|
||||
git clone git@git.mosaicstack.dev:mosaicstack/stack.git
|
||||
cd stack
|
||||
|
||||
# Start infrastructure (Postgres, Valkey, Jaeger)
|
||||
docker compose up -d
|
||||
|
||||
# Install dependencies
|
||||
# Install dependencies. The local tier uses in-process PGlite; leave DATABASE_URL unset.
|
||||
pnpm install
|
||||
|
||||
# Run migrations
|
||||
pnpm --filter @mosaicstack/db run db:migrate
|
||||
# Optional local queue service only. This does not start PostgreSQL.
|
||||
docker compose up -d valkey
|
||||
|
||||
# Start all services in dev mode
|
||||
pnpm dev
|
||||
# The current Gateway/Web local process is held; see docs/guides/dev-guide.md.
|
||||
# Do not start it until KBN-101-02 makes inherited dotenv/DSN state fail closed.
|
||||
```
|
||||
|
||||
### Infrastructure
|
||||
### Held future procedure
|
||||
|
||||
Docker Compose provides:
|
||||
The checked-in Compose PostgreSQL service mounts legacy initialization SQL and is **not** a
|
||||
current PostgreSQL, standalone, or federated developer route. Do not start it with Compose,
|
||||
invoke initialization SQL, or treat the planned migrator as currently executable.
|
||||
|
||||
| Service | Port | Purpose |
|
||||
| --------------------- | --------- | ---------------------- |
|
||||
| PostgreSQL (pgvector) | 5433 | Primary database |
|
||||
| Valkey | 6380 | Task queue + caching |
|
||||
| Jaeger | 16686 | Distributed tracing UI |
|
||||
| OTEL Collector | 4317/4318 | Telemetry ingestion |
|
||||
**Held future activation procedure — non-operative and no current command authority until KBN-101-00, KBN-101-03, and KBN-101-05
|
||||
land:** external bootstrap → TLS/roles → `mosaic-db-migrator --run` →
|
||||
`mosaic-db-migrator --verify` → Gateway/Compose readiness. The future deployment artifacts—not
|
||||
this README—will provide the reviewed commands and secret-consumer interface.
|
||||
|
||||
For local data-layer work, PGlite needs no PostgreSQL service. The optional Compose command above
|
||||
starts only Valkey; OTEL Collector and Jaeger may likewise be started individually if needed,
|
||||
without starting PostgreSQL. A Gateway/Web local process is not currently a safe PGlite route:
|
||||
its unguarded dotenv loader may inherit a daemon PostgreSQL DSN. Do not use root `pnpm dev` or a
|
||||
Gateway start command until KBN-101-02 makes that state fail closed.
|
||||
|
||||
### Quality Gates
|
||||
|
||||
@@ -231,7 +239,7 @@ pnpm format # Prettier auto-fix
|
||||
Woodpecker CI runs on every push:
|
||||
|
||||
- `pnpm install --frozen-lockfile`
|
||||
- Database migration against a fresh Postgres
|
||||
- **Legacy N-1 CI status only — active, uncertified, and non-authorizing as an operator route:** the checked-in job currently invokes `pnpm --filter @mosaicstack/db run db:migrate` with `DATABASE_URL` against an isolated disposable PostgreSQL CI database. It performs direct DDL in that CI database, is not approved ordinary behavior or an operator route, and remains a known exception pending KBN-101-06 removal/replacement by the certified runner-backed CI path.
|
||||
- `pnpm test` (Turbo-orchestrated across all packages)
|
||||
|
||||
npm packages are published to the Gitea package registry on main merges.
|
||||
|
||||
@@ -149,15 +149,9 @@ for any `<Image>` components added in the future.
|
||||
|
||||
---
|
||||
|
||||
## How to Apply
|
||||
## Held future procedure
|
||||
|
||||
```bash
|
||||
# Run the DB migration (requires a live DB)
|
||||
pnpm --filter @mosaicstack/db exec drizzle-kit migrate
|
||||
|
||||
# Or, in Docker/Swarm — migrations run automatically on gateway startup
|
||||
# via runMigrations() in packages/db/src/migrate.ts
|
||||
```
|
||||
This report is non-operative evidence, not a current runbook. Until **KBN-101-00, KBN-101-03, and KBN-101-05** land, do not execute a PostgreSQL runner from this checkout. The approved future procedure is exactly: external bootstrap → TLS/roles → `mosaic-db-migrator --run` → `mosaic-db-migrator --verify` → Gateway/Compose readiness. Deployment will supply the reviewed runner, migration-only credentials, and TLS material; Gateway startup only verifies readiness.
|
||||
|
||||
---
|
||||
|
||||
|
||||
107
docs/PRD.md
107
docs/PRD.md
@@ -125,6 +125,105 @@ are defined in [docs/TASKS.md](./TASKS.md) and must remain one card/one PR.
|
||||
|
||||
---
|
||||
|
||||
## Exact Cross-Harness Fleet Communications Contract (#766)
|
||||
|
||||
### Problem and objective
|
||||
|
||||
Fleet runtime contracts currently combine exact peer rows with generic operational metavariables and
|
||||
independently parsed roster data. Non-Claude harnesses can mistake those metavariables for values to
|
||||
infer, producing incorrect host, session, socket, or helper targets. The objective is one
|
||||
roster-resolved communications contract that every supported harness receives unchanged.
|
||||
|
||||
### Normative requirements
|
||||
|
||||
1. `FCOM-REQ-01`: Fleet commands and runtime composition SHALL use one shared v1 roster structural
|
||||
resolver. A second lenient communications parser is forbidden.
|
||||
2. `FCOM-REQ-02`: The composed contract SHALL render the local roster member's authoritative host,
|
||||
exact agent/session name, resolved tmux socket, exact helper path, and deterministic communications
|
||||
generation.
|
||||
3. `FCOM-REQ-03`: Every known peer SHALL have one exact executable command. Same-host commands SHALL
|
||||
omit `-H`; cross-host commands SHALL use only that peer's explicit roster `ssh` target; the one
|
||||
supported fleet-wide named socket SHALL use `-L` with its exact value. A per-agent socket declaration
|
||||
must equal that fleet-wide value; unsupported independent sockets and missing cross-host SSH data SHALL
|
||||
fail closed.
|
||||
4. `FCOM-REQ-04`: Operational fleet examples SHALL not contain unresolved host, session, socket, or
|
||||
helper-path metavariables. Agents SHALL select an exact rendered peer row and SHALL NOT infer,
|
||||
substitute, or fuzzy-match targeting values.
|
||||
5. `FCOM-REQ-05`: An unknown local member or requested peer SHALL fail closed with exact-name discovery
|
||||
guidance. Runtime composition SHALL not silently omit a requested fleet member's communications
|
||||
contract.
|
||||
6. `FCOM-REQ-06`: Claude Code, Codex, OpenCode, and Pi SHALL receive equivalent authoritative
|
||||
communications data through the common runtime composer.
|
||||
7. `FCOM-REQ-07`: Tests SHALL prove the contract from framework-source `TOOLS.md`, through a fresh
|
||||
installed `TOOLS.md`, to final runtime composition and helper executability. User-owned installed
|
||||
`TOOLS.md` content SHALL remain preserved.
|
||||
8. `FCOM-REQ-08`: Stale installed or active composed context SHALL be reported with deterministic
|
||||
generation/repair/relaunch guidance. Currency requires the expected source and installed contract
|
||||
marker/version plus bounded byte equality. The supported current-version repair SHALL run independently
|
||||
of package updates, preserve divergent `TOOLS.md` bytes in a digest-qualified no-clobber backup, restore
|
||||
a regular executable helper without following symlinks, and be idempotent. Detection and reporting SHALL
|
||||
NOT rewrite active context, restart a session, or mutate a live fleet.
|
||||
9. `FCOM-REQ-09`: The shared resolver SHALL preserve and strictly validate every schema-supported v1
|
||||
connector kind (`tmux`, `discord`, and `matrix`) from YAML and JSON. Every accepted snake/camel alias
|
||||
pair SHALL reject differing dual declarations and accept identical declarations. JSON roster fallback
|
||||
SHALL occur only when `roster.yaml` is absent; all other YAML access failures SHALL fail closed.
|
||||
10. `FCOM-REQ-10`: The communications generation SHALL cover the complete canonical rendered semantic
|
||||
contract, including identity, role/class, resolved host/socket/helper, peer metadata, and exact commands.
|
||||
Installed helpers SHALL be validated with no-follow filesystem inspection as regular executable files.
|
||||
Keep-mode reseed and relaunch discovery SHALL preserve and support both YAML and JSON rosters.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
1. `AC-FCOM-01`: Contract fixtures contain no unresolved operational targeting metavariables; local
|
||||
identity contains exact host/session/socket/helper values.
|
||||
2. `AC-FCOM-02`: Same-host, cross-host, named-socket, literal-default-socket, and missing-SSH tests prove
|
||||
exact targeting and fail-closed behavior.
|
||||
3. `AC-FCOM-03`: Unknown identities and peers report known exact names plus an exact self-scoped
|
||||
discovery command; no fuzzy session selection is emitted.
|
||||
4. `AC-FCOM-04`: Four-harness tests prove byte-equal authoritative communications sections.
|
||||
5. `AC-FCOM-05`: Source, fresh-install, preserved-custom-install, stale-installed, composed-generation,
|
||||
helper executable, agent-send socket isolation, and exact-target tests pass.
|
||||
6. `AC-FCOM-06`: Documentation defines non-mutating stale-context detection and operator-authorized,
|
||||
exact-agent relaunch; no implementation path performs automatic session mutation.
|
||||
7. `AC-FCOM-07`: YAML and JSON fixtures cover every connector kind; all snake/camel aliases cover
|
||||
identical acceptance and conflicting rejection; non-`ENOENT` YAML failures do not fall back.
|
||||
8. `AC-FCOM-08`: Missing, directory, symlink, and non-executable installed helpers fail closed. Explicit
|
||||
current-version repair proves partial-deletion recovery, digest-qualified backup collision safety,
|
||||
symlink-target safety, and repeated-run idempotence.
|
||||
9. `AC-FCOM-09`: Markerless-equal and wrong-version source/installed contracts are stale, and a rendered
|
||||
role/class change produces a different communications generation.
|
||||
|
||||
---
|
||||
|
||||
## KBN-101 Database Runtime/Migration Role Split (#771)
|
||||
|
||||
### Problem and objective
|
||||
|
||||
PostgreSQL Gateway/storage currently uses one `DATABASE_URL` for runtime queries and migrations. That makes the deployed application identity an owner and prevents certification that KBN immutable event, artifact, checkpoint, and evidence relations reject runtime `UPDATE`/`DELETE`. KBN-101 freezes a least-privilege runtime/migration split before KBN-100 schema work.
|
||||
|
||||
### Normative requirements
|
||||
|
||||
1. `K101-REQ-01`: `DATABASE_URL` SHALL be the non-owner PostgreSQL runtime connection and `DATABASE_MIGRATION_URL` SHALL be the migration-only owner/migrator connection. They are required respectively for runtime and the dedicated `mosaic-db-migrator --run|--verify` phase in `standalone`/`federated`; local PGlite is the explicit exception. The published `@mosaicstack/db` bin maps exactly `mosaic-db-migrator` to `./dist/cli.js`, its image entrypoint is exactly `mosaic-db-migrator`, accepts no URL/SQL/schema/role argv, and returns stable sanitized exits. Every current/future PostgreSQL DDL entrypoint SHALL route to that runner or be denied, and SHALL reject `DATABASE_URL`-only execution before connection/DDL. Data migration may connect only after the runner prepares and verifies the PostgreSQL target, through dedicated non-DDL `mosaic_data_importer` and exactly `--target-url-file /run/secrets/mosaic-migrate-target-url`, its fixed paired authenticated provider-version file `/run/secrets/mosaic-migrate-target-version`, plus `--target-attestation-file /run/mosaic-attestations/migrate-target.v1.json`. KBN-101-05 obtains URL key `url` and version only from the same successful Vault KV-v2 response at `secret-{env}/mosaic-stack/database/importer` (`data.metadata.version`), renders them as one immutable generation into separate consumer copies, and never infers a provider version from DSN bytes. The trusted runner verifies TLS/identity/manifest, reads its fixed importer URL/version copies only for binding through safe no-follow fd checks, and signs a credential-free JCS/Ed25519 attestation using its runner-only fixed root-owned private-key file; no signing key reaches importer/runtime. The artifact binds secret version and SHA-256 of exact high-entropy credential-file bytes, canonical TLS host/port/database, CA/SPKI, PostgreSQL system identifier/database OID, importer role, manifest/schema fingerprints, producer invocation/build/image digest, issued/expires/nonce, and correlation. Before target connection the importer validates URL/version/attestation/public-key files, signature/key/expiry/replay/authenticated provider version/digest/generation/bindings and the importer-only CA at exact `DATABASE_TLS_CA_CERT_PATH`; after verified TLS and before DML it validates server/database/role/CA/schema identity, with same-fd/in-memory-byte TOCTOU protection, rotation/revocation, a privileged producer-only-to-importer-only artifact handoff controller that verifies/copies/fsyncs/atomically renames/seals before importer start, consumer isolation/no logging-oracle, and sanitized errors. Raw `--target-url`, `DATABASE_URL` fallback, runtime-owner use, missing/unsafe/substituted files, stale/replayed/tampered/wrong-key attestation, wrong binding, and DDL attempt fail before target connection/DDL; post-connect mismatch closes with zero DML/DDL. A reviewed finite classifier inventories executable current source/scripts/package bins, operator docs, deploy manifests, and exact normative contracts by path; active secure records pin both options/files, producer/key/bindings/tests, while normative contracts cannot mask instructions. Unknown active commands, duplicate-owner, ownerless, missing-path, and historical/status-only masking hits fail. `db:push` is forbidden outside an explicitly disposable local developer database and cannot accept a production-like URL.
|
||||
2. `K101-REQ-02`: Gateway runtime/replicas SHALL not execute migrations or DDL. The runner SHALL hold one `max:1` session and fixed two-int advisory namespace `1297044289` (`MOSA`), `1262636593` (`KBN1`) across preflight, reconciliation, migration, verification, and release. It SHALL compare the versioned canonical manifest v1 tuple (journal logical index/tag plus exact SQL-byte SHA-256) to the complete observed ledger mapping; count/set-only, timestamps, and physical insertion order are non-normative and insufficient.
|
||||
3. `K101-REQ-03`: PostgreSQL SHALL separate non-login platform database owner, non-login schema owner, dedicated `NOLOGIN SUPERUSER` `mosaic_extension_owner`, login migrator, dedicated login non-DDL data importer, non-login runtime capability, and login runtime roles. For PostgreSQL 17 + pgvector 0.8.2, `vector` is untrusted (`trusted` is absent and `relocatable=true`): only an externally controlled audited platform-bootstrap superuser session may `SET ROLE mosaic_extension_owner` for CREATE/UPDATE/SET SCHEMA, then `RESET ROLE`; the role has `rolcanlogin=false`, `rolsuper=true`, zero members, no runtime credential/Vault secret, and is never provided to app containers. It owns `mosaic_extensions`, fresh `vector`, and owner-bearing extension members, while `mosaic_schema_owner` receives only `USAGE` for type resolution and never ownership/`CREATE`/`ALTER`/`DROP`/member-change/default-privilege authority there. Superuser cannot be constrained by `GRANT`/`REVOKE`; this is identity/non-login/no-membership/external-control/audit isolation, not a false least-privilege claim. Extension operations require control-plane change, independent review, backup/rollback, maintenance window, and audit evidence. Managed targets that cannot establish this exact role are ineligible until an independently approved versioned provider-owned extension-owner profile exists; app/migrator ownership is never silently retained. Existing approved-owner extension relocation validates exact `pg_namespace.nspowner`, `pg_extension.extowner`, member ownership/schema/version, while legacy runtime-owned extension fails closed to a controlled shadow-database migration—never unsupported ownership alteration, catalog mutation, ownership adoption, or `DROP CASCADE`. Runtime, migrator, schema owner, importer, and all service roles must fail `SET ROLE`, catalog/direct `ALTER`/`UPDATE`/`DROP`/membership-change denial, role ownership, superuser/role-creation/schema-creation/TEMPORARY, unsafe membership, untrusted search path, missing grants, unauthenticated TLS, and immutable privilege drift checks. Application schema is fixed `mosaic` with exact `pg_catalog,mosaic` session path; historical public migrations remain byte-immutable legacy bootstrap only, every future Drizzle application declaration targets `mosaic`, and `vector` is explicitly qualified from non-writable `mosaic_extensions`. No config-derived SQL identifier is permitted.
|
||||
4. `K101-REQ-04`: `mosaicstack/stack` KBN-101-00 SHALL exclusively own `infra/pg-bootstrap/roles.sql`, `infra/pg-bootstrap/extensions.sql`, `infra/pg-bootstrap/README.md`, and bootstrap tests; KBN-101-05 SHALL exclusively own `tools/db/render-postgres-secrets.ts`, its tests, and current Compose/Portainer/two-gateway deployment declarations, consuming the versioned bootstrap interface without overlap. Environment IaC/Vault is named input and Mosaic deployment control plane/Jason is activation authority. Distinct runtime/migrator/importer URL, importer authenticated provider-version, DB-client CA, Gateway leaf, and PostgreSQL server key/certificate materials are provisioned before a production-like database starts. Importer and migrator have separate immutable URL/version copies at fixed `10002:10002`/`10003:10003` identities; runtime/unrelated containers receive neither importer material, attestation private key, or importer artifact. Runtime, migrator, and importer require their mounted CA plus `sslmode=verify-full`. Exact UID/GID/mode/rendering, service-DNS SANs, Vault/compose/Swarm consumer isolation, two-gateway pair ordering, server activation, pre-enforcement legacy-client drain and `hostssl` zero-plaintext-session proof, fresh/existing transition, CA-overlap rotation, TLS-only rollback, and standalone/federated/Swarm/two-gateway positive/negative TLS evidence are required. No application-generated production certificate or plaintext bootstrap exception is permitted.
|
||||
5. `K101-REQ-05`: KBN immutable relations SHALL permit the real runtime role INSERT/SELECT only and deny UPDATE/DELETE; parent retention remains RESTRICT/no-cascade. Role/password/Vault creation is external platform control, never application migration/source.
|
||||
6. `K101-REQ-06`: N-1 single-URL compatibility, rollout/rollback, Vault ownership/rotation/redaction, CI, installer, compose/Portainer, observability, and deployment handoffs SHALL be separately bounded one-card/one-PR work. Prepared slices remain inactive while current owner-runtime deployments stay N-1; Mosaic control plane/Jason alone authorizes one final atomic activation or rollback, with no force-on-red/bypass. KBN-101 planning itself SHALL not mutate production.
|
||||
7. `K101-REQ-07`: KBN-100 SHALL begin only after the KBN-101 foundation role/schema-boundary certificate; it SHALL rebase on that main head, restore generated Drizzle declaration/snapshot/journal consistency, and bound procedural immutable-table grant/trigger/backfill additions to its schema slice. KBN-101 real deployed-role immutable-operation certification SHALL complete after KBN-100 creates those relations and before KBN-105.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
1. `AC-K101-01`: DTO/command-matrix tests prove required modes, PGlite exception, `mosaic-db-migrator --help|--run|--verify`/stable exits/argv refusal, public-import negative, every finite classified DDL/static-bypass inventory path and both harness pairs reject `DATABASE_URL`-only before connection/DDL, no migration-to-runtime fallback, and `db:push` refusal outside an allowlisted disposable DB. Before inventory, ownership, or status masking, the semantic fixture fails README's exact former commented code-fence generic-wrapper form and the user guide's exact former executable generic-wrapper form; source-consistency proves current `packages/storage/src/cli.ts` directly `execSync`s `pnpm --filter @mosaicstack/db db:migrate` and no `mosaic-db-migrator` bin exists, so runner-delegation documentation fails. The active `docs/guides/migrate-tier.md` route is inventoried to KBN-101-07 and proves runner-produced `--target-url-file /run/secrets/mosaic-migrate-target-url`, fixed paired provider-version file, and `--target-attestation-file /run/mosaic-attestations/migrate-target.v1.json`; runner-only signing/private-key isolation; Vault KV-v2 same-response version provenance, separate immutable generation mounts, importer CA, JCS/Ed25519 signature/key rotation/revocation, atomic artifact, expiry/replay, safe-fd secret-version/digest, canonical TLS/CA/server/database/role/manifest/schema bindings, dedicated non-DDL importer, consumer isolation/no log-oracle, and exact no-connection versus zero-DML rejection for missing/wrong/stale/replayed/tampered/wrong-key/substituted/generation-mismatched inputs. The full current non-normative docs inventory—including user guide, federation historical task/MILESTONES status, and non-operative SETUP—has an exact safe disposition. Scanner semantic checks reject automatic first-boot/startup extension/schema/migration wording, Compose-up-before-runner, init-script authority, production `.env`/monorepo auto-load/`EnvironmentFile=`/credential-export-or-argv/restart-as-secret-activation routes, and every unqualified operator-document `mosaic-db-migrator --run|--verify` hit regardless of named/normative/status classification. The exact former README/dev/deployment Compose-first sequences, former SETUP wording, exact former MILESTONES wording `pgvector extension installed + verified on startup`, former architecture-plan/PERFORMANCE/backlog runner routes, and any unqualified runner fixture fail before inventory masking. Only one `Held future procedure` Markdown section—bounded through the next equal-or-higher heading—may contain the explicit non-operative/no-current-command-authority form that names KBN-101-00/-03/-05 and preserves external bootstrap → TLS/roles → `mosaic-db-migrator --run` → `mosaic-db-migrator --verify` → Gateway/Compose readiness; every runner hit outside that section fails. The README assertion for the checked-in direct CI `pnpm --filter @mosaicstack/db run db:migrate` with `DATABASE_URL` passes only as active legacy N-1, uncertified, non-authorizing-as-an-operator-route status against an isolated disposable CI database pending KBN-101-06 removal—not as an ordinary operator or approved DDL-authority route. Only local PGlite data-layer work or non-PostgreSQL Compose is current (Gateway/Web local startup is held pending daemon/inherited/project-DSN rejection).
|
||||
2. `AC-K101-02`: Fixed namespace lock contention/crash/readiness/non-interference and exact manifest-v1 reconciliation tests prove no replica race/runtime auto-migration and fail closed on every missing/unknown/duplicate/ambiguous/corrupt/stale ledger state.
|
||||
3. `AC-K101-03`: Actual PostgreSQL 17 + pgvector 0.8.2 control-file, catalog, Drizzle-generation, vector-query/operator, fresh/approved-owner/legacy-shadow/partial/resume/rollback/N-1, and real deployed-role tests prove `trusted` absent/untrusted plus relocatability, external-superuser `SET ROLE` create/update/`RESET ROLE` audit, exact `rolcanlogin=false`/`rolsuper=true`/zero-membership/no-runtime-secret state, platform/schema/extension-owner/migrator/importer/runtime separation, `pg_extension.extowner` plus owner-bearing extension-member/schema/version assertions, and runtime/migrator/schema-owner/importer/all-service-role `SET ROLE`/ALTER/DROP/member-update denial. They also prove `pg_catalog,mosaic` per-session pool safety, `mosaic_extensions` qualification, identifier injection denial, ownership/membership/ledger-read/TEMP/default grants, and unsafe privilege denial.
|
||||
4. `AC-K101-04`: Disposable standalone, federated/Swarm, and two-gateway verified-TLS positives plus for both pairs missing CA/wrong CA/wrong SAN/sslmode downgrade, server/Gateway key mode, UID/GID, secret-consumer isolation, and legacy-drain/`hostssl` negatives prove server bootstrap, ordering, and readiness; PGlite is expressly excluded from this PostgreSQL evidence.
|
||||
5. `AC-K101-05`: Real runtime-role evidence proves INSERT/SELECT succeeds and UPDATE/DELETE fails for every frozen immutable KBN relation.
|
||||
6. `AC-K101-06`: N-1/atomic activation/rollback, Vault/CA-overlap rotation/redaction, health/operator behavior, CI/deployment handoff, independent exact-head security review, and terminal-green CI evidence the foundation before KBN-100; after KBN-100, the real deployed-role immutable-operation certificate and Ultron approval release KBN-105.
|
||||
|
||||
**Normative implementation contract:** [`docs/native-kanban-sot/KBN-101-DB-ROLE-SPLIT.md`](./native-kanban-sot/KBN-101-DB-ROLE-SPLIT.md). `ASSUMPTION:` existing `standalone` and `federated` are all PostgreSQL production-like modes; any new PostgreSQL tier inherits these requirements until an explicit versioned amendment.
|
||||
|
||||
---
|
||||
|
||||
## Tess Interaction Agent Workstream (TESS)
|
||||
|
||||
### Problem and Objective
|
||||
@@ -1082,10 +1181,10 @@ Telegram remote control channel.
|
||||
|
||||
### AC-10: Deployment
|
||||
|
||||
- [ ] `docker compose up` starts full stack from clean state
|
||||
- [ ] `mosaic` CLI installable and functional on bare metal
|
||||
- [ ] Database migrations run automatically on first start
|
||||
- [ ] `.env.example` documents all required configuration
|
||||
- [ ] PGlite data-layer work uses no PostgreSQL; optional Compose services are selected individually and do not start PostgreSQL; Gateway/Web local start remains held until KBN-101-02 rejects daemon/inherited/project DSNs before connection or DDL
|
||||
- [ ] PostgreSQL/federated activation is unavailable until KBN-101-00/-03/-05 deliver external bootstrap, TLS/roles, runner `--run`, runner `--verify`, and Gateway/Compose readiness in that order
|
||||
- [ ] `mosaic` CLI installable and functional on bare metal after the reviewed KBN-101-05 secret-renderer/process-exec or `LoadCredential` interface exists
|
||||
- [ ] Local-only configuration documentation is distinct from production generation-pinned Vault-rendered consumer material
|
||||
|
||||
### AC-11: @mosaicstack/\* Packages
|
||||
|
||||
|
||||
@@ -21,7 +21,10 @@
|
||||
- [Workstream index](native-kanban-sot/INDEX.md) — artifact map, lane partition, and delivery order.
|
||||
- [Mission manifest](native-kanban-sot/MISSION-MANIFEST.md) — scope, authority, invariants, and gate model.
|
||||
- [Task decomposition](native-kanban-sot/TASKS.md) — dependency-ordered implementation slices and ownership boundaries.
|
||||
- [KBN-101 database role split](native-kanban-sot/KBN-101-DB-ROLE-SPLIT.md) — rc.16 direct-Drizzle storage-wrapper hold: legacy N-1/uncertified/non-operative pending -02/-03/-06/-08; exact README/user-guide wrapper forms fail before masking and source-consistency rejects runner-delegation copy; held bootstrap → TLS/roles → run → verify → readiness; plus prior attestation, pgvector owner, classifier, TLS, activation, and certification prerequisite.
|
||||
- [Federated tier data migration](guides/migrate-tier.md) — active KBN-101-07 operator route: runner-produced target attestation, dedicated non-DDL importer, and paired credential-/attestation-file references only.
|
||||
- [Frozen shared contract](native-kanban-sot/SHARED-CONTRACT.md) — schema, API, Coordinator, health, recovery, and migration contracts.
|
||||
- [KBN-101 exact-head security review](reports/native-kanban-sot/kbn-101-contract-security-review-82ce325.md) — retained prior REQUEST CHANGES evidence for `da742ca`; rc.16 awaits independent exact-head re-review after closing the current generic storage-wrapper authority HIGH finding.
|
||||
- [Initial independent review](reports/native-kanban-sot/canon-initial-review-no-go.md) — KCR-001–016 findings that blocked the first draft.
|
||||
- [Final independent re-review](reports/native-kanban-sot/canon-final-rereview-go.md) — closure evidence and GO verdict.
|
||||
- [Ultron final gate](reports/native-kanban-sot/ultron-final-go.md) — final requirements, authority, schema, migration, recovery, and evidence review.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -70,6 +70,10 @@ export function createQueue(config?: QueueConfig): QueueHandle {
|
||||
|
||||
### `@mosaicstack/db` (packages/db/src/client.ts)
|
||||
|
||||
> **Historical design specimen — status-only, not an operator instruction.** KBN-101 supersedes
|
||||
> this pre-split `DATABASE_URL` fallback shape; it cannot authorize runtime migration, DDL, or a
|
||||
> connection-string fallback. See the KBN-101 runner/role contract for the produced interface.
|
||||
|
||||
```typescript
|
||||
import { drizzle, type PostgresJsDatabase } from 'drizzle-orm/postgres-js';
|
||||
import postgres from 'postgres';
|
||||
|
||||
@@ -54,7 +54,7 @@ Every milestone adds tests to these layers. A milestone cannot be claimed comple
|
||||
- Add `"tier": "federated"` to `mosaic.config.json` schema and validators
|
||||
- Docker Compose `federated` profile (`docker-compose.federated.yml`) adds: Postgres+pgvector (5433), Valkey (6380), dedicated volumes
|
||||
- Tier detector in gateway bootstrap: reads config, asserts required services reachable, refuses to start otherwise
|
||||
- `pgvector` extension installed + verified on startup
|
||||
- **Historical/status only:** the prior startup-provisioning statement is superseded. Runtime/startup extension provisioning is forbidden. PostgreSQL activation remains non-operative with no current command authority until KBN-101-00, KBN-101-03, and KBN-101-05 land; this record authorizes no current DDL, Compose/init, or startup path.
|
||||
- Migration logic: safe upgrade path from `local`/`standalone` → `federated` (data export/import script, one-way)
|
||||
- `mosaic doctor` reports tier + service health
|
||||
- Gateway continues to serve as a normal standalone instance (no federation yet)
|
||||
|
||||
@@ -1,280 +1,74 @@
|
||||
# Federated Tier Setup Guide
|
||||
|
||||
## What is the federated tier?
|
||||
|
||||
The federated tier is designed for multi-user and multi-host deployments. It consists of PostgreSQL 17 with pgvector extension (for embeddings and RAG), Valkey for distributed task queueing and caching, and a shared configuration across multiple Mosaic gateway instances. Use this tier when running Mosaic in production or when scaling beyond a single-host deployment.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Docker and Docker Compose installed
|
||||
- Ports 5433 (PostgreSQL) and 6380 (Valkey) available on your host (or adjust environment variables)
|
||||
- At least 2 GB free disk space for data volumes
|
||||
|
||||
## Start the federated stack
|
||||
|
||||
Run the federated overlay:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.federated.yml --profile federated up -d
|
||||
```
|
||||
|
||||
This starts PostgreSQL 17 with pgvector and Valkey 8. The pgvector extension is created automatically on first boot.
|
||||
|
||||
Verify the services are running:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.federated.yml ps
|
||||
```
|
||||
|
||||
Expected output shows `postgres-federated` and `valkey-federated` both healthy.
|
||||
|
||||
## Configure mosaic for federated tier
|
||||
|
||||
Create or update your `mosaic.config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"tier": "federated",
|
||||
"database": "postgresql://mosaic:mosaic@localhost:5433/mosaic",
|
||||
"queue": "redis://localhost:6380"
|
||||
}
|
||||
```
|
||||
|
||||
If you're using environment variables instead:
|
||||
|
||||
```bash
|
||||
export DATABASE_URL="postgresql://mosaic:mosaic@localhost:5433/mosaic"
|
||||
export REDIS_URL="redis://localhost:6380"
|
||||
```
|
||||
|
||||
## Verify health
|
||||
|
||||
Run the health check:
|
||||
|
||||
```bash
|
||||
mosaic gateway doctor
|
||||
```
|
||||
|
||||
Expected output (green):
|
||||
|
||||
```
|
||||
Tier: federated Config: mosaic.config.json
|
||||
✓ postgres localhost:5433 (42ms)
|
||||
✓ valkey localhost:6380 (8ms)
|
||||
✓ pgvector (embedded) (15ms)
|
||||
```
|
||||
|
||||
For JSON output (useful in CI/automation):
|
||||
|
||||
```bash
|
||||
mosaic gateway doctor --json
|
||||
```
|
||||
|
||||
## Step 2: Step-CA Bootstrap
|
||||
|
||||
Step-CA is a certificate authority that issues X.509 certificates for federation peers. In Mosaic federation, it signs peer certificates with custom OIDs that embed grant and user identities, enforcing authorization at the certificate level.
|
||||
|
||||
### Prerequisites for Step-CA
|
||||
|
||||
Before starting the CA, you must set up the dev password:
|
||||
|
||||
```bash
|
||||
cp infra/step-ca/dev-password.example infra/step-ca/dev-password
|
||||
# Edit dev-password and set your CA password (minimum 16 characters)
|
||||
```
|
||||
|
||||
The password is required for the CA to boot and derive the provisioner key used by the gateway.
|
||||
|
||||
### Start the Step-CA service
|
||||
|
||||
Add the step-ca service to your federated stack:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.federated.yml --profile federated up -d step-ca
|
||||
```
|
||||
|
||||
On first boot, the init script (`infra/step-ca/init.sh`) runs automatically. It:
|
||||
|
||||
- Generates the CA root key and certificate in the Docker volume
|
||||
- Creates the `mosaic-fed` JWK provisioner
|
||||
- Applies the X.509 template from `infra/step-ca/templates/federation.tpl`
|
||||
|
||||
The volume is persistent, so subsequent boots reuse the existing CA keys.
|
||||
|
||||
Verify the CA is healthy:
|
||||
|
||||
```bash
|
||||
curl https://localhost:9000/health --cacert /tmp/step-ca-root.crt
|
||||
```
|
||||
|
||||
(If the root cert file doesn't exist yet, see the extraction steps below.)
|
||||
|
||||
### Extract credentials for the gateway
|
||||
|
||||
The gateway requires two credentials from the running CA:
|
||||
|
||||
**1. Provisioner key (for `STEP_CA_PROVISIONER_KEY_JSON`)**
|
||||
|
||||
```bash
|
||||
docker exec $(docker ps -qf name=step-ca) cat /home/step/secrets/mosaic-fed.json > /tmp/step-ca-provisioner.json
|
||||
```
|
||||
|
||||
This JSON file contains the JWK public and private keys for the `mosaic-fed` provisioner. Store it securely and pass its contents to the gateway via the `STEP_CA_PROVISIONER_KEY_JSON` environment variable.
|
||||
|
||||
**2. Root certificate (for `STEP_CA_ROOT_CERT_PATH`)**
|
||||
|
||||
```bash
|
||||
docker cp $(docker ps -qf name=step-ca):/home/step/certs/root_ca.crt /tmp/step-ca-root.crt
|
||||
```
|
||||
|
||||
This PEM file is the CA's root certificate, used to verify peer certificates issued by step-ca. Pass its path to the gateway via `STEP_CA_ROOT_CERT_PATH`.
|
||||
|
||||
### Custom OID Registry
|
||||
|
||||
Federation certificates include custom OIDs in the certificate extension. These encode authorization metadata:
|
||||
|
||||
| OID | Name | Description |
|
||||
| ------------------- | ---------------------- | --------------------- |
|
||||
| 1.3.6.1.4.1.99999.1 | mosaic_grant_id | Federation grant UUID |
|
||||
| 1.3.6.1.4.1.99999.2 | mosaic_subject_user_id | Subject user UUID |
|
||||
|
||||
These OIDs are verified by the gateway after the CSR is signed, ensuring the certificate was issued with the correct grant and user context.
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Configure the gateway with the following environment variables before startup:
|
||||
|
||||
| Variable | Required | Description |
|
||||
| ------------------------------ | -------- | --------------------------------------------------------------------------------------------------------- |
|
||||
| `STEP_CA_URL` | Yes | Base URL of the step-ca instance, e.g. `https://step-ca:9000` (use `https://localhost:9000` in local dev) |
|
||||
| `STEP_CA_PROVISIONER_KEY_JSON` | Yes | JSON-encoded JWK from `/home/step/secrets/mosaic-fed.json` |
|
||||
| `STEP_CA_ROOT_CERT_PATH` | Yes | Absolute path to the root CA certificate (e.g. `/tmp/step-ca-root.crt`) |
|
||||
| `BETTER_AUTH_SECRET` | Yes | Secret used to seal peer private keys at rest; already required for M1 |
|
||||
|
||||
Example environment setup:
|
||||
|
||||
```bash
|
||||
export STEP_CA_URL="https://localhost:9000"
|
||||
export STEP_CA_PROVISIONER_KEY_JSON="$(cat /tmp/step-ca-provisioner.json)"
|
||||
export STEP_CA_ROOT_CERT_PATH="/tmp/step-ca-root.crt"
|
||||
export BETTER_AUTH_SECRET="<your-secret>"
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Port conflicts
|
||||
|
||||
**Symptom:** `bind: address already in use`
|
||||
|
||||
**Fix:** Stop the base dev stack first:
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
docker compose -f docker-compose.federated.yml --profile federated up -d
|
||||
```
|
||||
|
||||
Or change the host port with an environment variable:
|
||||
|
||||
```bash
|
||||
PG_FEDERATED_HOST_PORT=5434 VALKEY_FEDERATED_HOST_PORT=6381 \
|
||||
docker compose -f docker-compose.federated.yml --profile federated up -d
|
||||
```
|
||||
|
||||
### pgvector extension error
|
||||
|
||||
**Symptom:** `ERROR: could not open extension control file`
|
||||
|
||||
**Fix:** pgvector is created at first boot. Check logs:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.federated.yml logs postgres-federated | grep -i vector
|
||||
```
|
||||
|
||||
If missing, exec into the container and create it manually:
|
||||
|
||||
```bash
|
||||
docker exec <postgres-federated-id> psql -U mosaic -d mosaic -c "CREATE EXTENSION vector;"
|
||||
```
|
||||
|
||||
### Valkey connection refused
|
||||
|
||||
**Symptom:** `Error: connect ECONNREFUSED 127.0.0.1:6380`
|
||||
|
||||
**Fix:** Check service health:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.federated.yml logs valkey-federated
|
||||
```
|
||||
|
||||
If Valkey is running, verify your firewall allows 6380. On macOS, Docker Desktop may require binding to `host.docker.internal` instead of `localhost`.
|
||||
|
||||
## Key rotation (deferred)
|
||||
|
||||
Federation peer private keys (`federation_peers.client_key_pem`) are sealed at rest using AES-256-GCM with a key derived from `BETTER_AUTH_SECRET` via SHA-256. If `BETTER_AUTH_SECRET` is rotated, all sealed `client_key_pem` values in the database become unreadable and must be re-sealed with the new key before rotation completes.
|
||||
|
||||
The full key rotation procedure (decrypt all rows with old key, re-encrypt with new key, atomically swap the secret) is out of scope for M2. Operators must not rotate `BETTER_AUTH_SECRET` without a migration plan for all sealed federation peer keys.
|
||||
|
||||
## OID Assignments — Mosaic Internal OID Arc
|
||||
|
||||
Mosaic uses the private enterprise arc `1.3.6.1.4.1.99999` for custom X.509
|
||||
certificate extensions in federation grant certificates.
|
||||
|
||||
**IMPORTANT:** This is a development/internal OID arc. Before deploying to a
|
||||
production environment accessible by external parties, register a proper IANA
|
||||
Private Enterprise Number (PEN) at <https://pen.iana.org/pen/PenApplication.page>
|
||||
and update these assignments accordingly.
|
||||
|
||||
### Assigned OIDs
|
||||
|
||||
| OID | Symbolic name | Description |
|
||||
| --------------------- | --------------------------------- | --------------------------------------------------------- |
|
||||
| `1.3.6.1.4.1.99999.1` | `mosaic.federation.grantId` | UUID of the `federation_grants` row authorising this cert |
|
||||
| `1.3.6.1.4.1.99999.2` | `mosaic.federation.subjectUserId` | UUID of the local user on whose behalf the cert is issued |
|
||||
|
||||
### Encoding
|
||||
|
||||
Each extension value is DER-encoded as an ASN.1 **UTF8String**:
|
||||
|
||||
```
|
||||
Tag 0x0C (UTF8String)
|
||||
Length 0x24 (36 decimal — fixed length of a UUID string)
|
||||
Value <36 ASCII bytes of the UUID>
|
||||
```
|
||||
|
||||
The step-ca X.509 template at `infra/step-ca/templates/federation.tpl`
|
||||
produces this encoding via the Go template expression:
|
||||
|
||||
```
|
||||
{{ printf "\x0c\x24%s" .Token.mosaic_grant_id | b64enc }}
|
||||
```
|
||||
|
||||
The resulting base64 value is passed as the `value` field of the extension
|
||||
object in the template JSON.
|
||||
|
||||
### CA Environment Variables
|
||||
|
||||
The `CaService` (`apps/gateway/src/federation/ca.service.ts`) requires the
|
||||
following environment variables at gateway startup:
|
||||
|
||||
| Variable | Required | Description |
|
||||
| ------------------------------ | -------- | -------------------------------------------------------------------- |
|
||||
| `STEP_CA_URL` | Yes | Base URL of the step-ca instance, e.g. `https://step-ca:9000` |
|
||||
| `STEP_CA_PROVISIONER_PASSWORD` | Yes | JWK provisioner password for the `mosaic-fed` provisioner |
|
||||
| `STEP_CA_PROVISIONER_KEY_JSON` | Yes | JSON-encoded JWK (public + private) for the `mosaic-fed` provisioner |
|
||||
| `STEP_CA_ROOT_CERT_PATH` | Yes | Absolute path to the step-ca root CA certificate PEM file |
|
||||
|
||||
Set these variables in your environment or secret manager before starting
|
||||
the gateway. In the federated Docker Compose stack they are expected to be
|
||||
injected via Docker secrets and environment variable overrides.
|
||||
|
||||
### Fail-loud contract
|
||||
|
||||
The CA service (and the X.509 template) are designed to fail loudly if the
|
||||
custom OIDs cannot be embedded:
|
||||
|
||||
- The template produces a malformed extension value (zero-length UTF8String
|
||||
body) when the JWT claims `mosaic_grant_id` or `mosaic_subject_user_id` are
|
||||
absent. step-ca rejects the CSR rather than issuing a cert without the OIDs.
|
||||
- `CaService.issueCert()` throws a `CaServiceError` on every error path with
|
||||
a human-readable `remediation` string. It never silently returns a cert that
|
||||
may be missing the required extensions.
|
||||
> **KBN-101 N-1 hold:** This page is **non-operative** and grants no current command
|
||||
> authority until KBN-101-00, KBN-101-03, and KBN-101-05 land and KBN-101-08 activates a
|
||||
> reviewed release. It does not authorize a deployment operation, initialization artifacts,
|
||||
> implicit extension/schema/migration creation, raw `CREATE`, direct database initialization, or
|
||||
> a Gateway against an unverified database. The prior direct-start wording is retired; its
|
||||
> regression fixture is owned by KBN-101-06.
|
||||
|
||||
## Held future procedure
|
||||
|
||||
This section is non-operative and grants no current command authority until KBN-101-00, KBN-101-03, and KBN-101-05 land.
|
||||
|
||||
The deployment control plane—not an operator shell or deployment lifecycle hook—performs this
|
||||
exact held future sequence after activation authorization: external bootstrap → TLS/roles → `mosaic-db-migrator --run` → `mosaic-db-migrator --verify` → Gateway/Compose readiness.
|
||||
|
||||
1. External bootstrap provisions the approved database/extension prerequisites.
|
||||
2. TLS/roles are installed through the generation-pinned renderer.
|
||||
3. The dedicated one-shot runner executes `mosaic-db-migrator --run`.
|
||||
4. The same runner executes `mosaic-db-migrator --verify`, including readiness and the
|
||||
importer-target attestation where that route is enabled.
|
||||
5. Only after successful verification may Gateway reach its independent verified-TLS Gateway
|
||||
readiness gate.
|
||||
|
||||
No step may be reordered, skipped, replaced by a raw SQL command, or delegated to an initialization
|
||||
hook.
|
||||
A missing extension, schema, migration, role, secret generation, or readiness proof is a failed
|
||||
control-plane precondition; it is not an instruction to start Compose, retry startup, or create
|
||||
anything directly.
|
||||
|
||||
## N-1 status and required disposition
|
||||
|
||||
The current branch retains historical federation artifacts, but they are not a deployable
|
||||
procedure. `docs/federation/TASKS.md` records their shipped status only. KBN-101-02 retires
|
||||
runtime/init DDL; KBN-101-05 owns the renderer/deployment handoff; KBN-101-06 verifies the
|
||||
finite scanner and command matrix; and KBN-101-07 owns this operator route. A path named in an
|
||||
inventory, a historical-status label, or a normative requirement cannot suppress the semantic
|
||||
checks above.
|
||||
|
||||
Until the activation certificate names an exact release, use no database startup or recovery
|
||||
command from this document. For the produced importer interface, see
|
||||
[the federated tier migration contract](../guides/migrate-tier.md); it is likewise non-operative
|
||||
until activation.
|
||||
|
||||
## Federation and Step-CA reference
|
||||
|
||||
Federation uses PostgreSQL 17 with pgvector, Valkey, and a shared configuration across multiple
|
||||
Gateway instances. Step-CA issues federation peer X.509 certificates whose custom OIDs carry a
|
||||
grant and subject identity. The following facts are reference material only; provisioning and
|
||||
secret delivery remain deployment-control-plane work under the activation sequence.
|
||||
|
||||
| OID | Name | Description |
|
||||
| ------------------- | ------------------------ | --------------------- |
|
||||
| 1.3.6.1.4.1.99999.1 | `mosaic_grant_id` | Federation grant UUID |
|
||||
| 1.3.6.1.4.1.99999.2 | `mosaic_subject_user_id` | Subject user UUID |
|
||||
|
||||
The internal arc `1.3.6.1.4.1.99999` is development-only. Before an externally reachable
|
||||
production deployment, register an IANA Private Enterprise Number and version the assignments.
|
||||
Each value is DER-encoded as an ASN.1 UTF8String containing the UUID.
|
||||
|
||||
The future activated Gateway requires `STEP_CA_URL`, `STEP_CA_PROVISIONER_PASSWORD`,
|
||||
`STEP_CA_PROVISIONER_KEY_JSON`, `STEP_CA_ROOT_CERT_PATH`, and `BETTER_AUTH_SECRET` through the
|
||||
reviewed secret mechanism. These names do not authorize shell exports, copied credential files,
|
||||
or an ad hoc service start.
|
||||
|
||||
## Failure disposition
|
||||
|
||||
- A TLS, CA, SAN, role, runner, or readiness failure is a control-plane incident. Preserve only
|
||||
sanitized evidence and follow the approved rollback/repair record.
|
||||
- A pgvector/extension failure is a failed external-bootstrap or runner precondition. Do not use
|
||||
direct extension SQL, init artifacts, or a startup retry as remediation.
|
||||
- A port, container, or Valkey problem does not permit bypassing the activation sequence.
|
||||
- Federation peer-key rotation remains deferred until its separately approved migration plan;
|
||||
do not rotate `BETTER_AUTH_SECRET` without that plan.
|
||||
|
||||
@@ -15,20 +15,20 @@
|
||||
|
||||
Goal: Gateway runs in `federated` tier with containerized PG+pgvector+Valkey. No federation logic yet. Existing standalone behavior does not regress.
|
||||
|
||||
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
||||
| --------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----- | ------ | ---------------------------------- | ---------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| FED-M1-01 | done | Extend `mosaic.config.json` schema: add `"federated"` to `tier` enum in validator + TS types. Keep `local` and `standalone` working. Update schema docs/README where referenced. | #460 | sonnet | feat/federation-m1-tier-config | — | 4K | Shipped in PR #470. Renamed `team` → `standalone`; added `team` deprecation alias; added `DEFAULT_FEDERATED_CONFIG`. |
|
||||
| FED-M1-02 | done | Author `docker-compose.federated.yml` as an overlay profile: Postgres 17 + pgvector extension (port 5433), Valkey (6380), named volumes, healthchecks. Compose-up should boot cleanly on a clean machine. | #460 | sonnet | feat/federation-m1-compose | FED-M1-01 | 5K | Shipped in PR #471. Overlay defines `postgres-federated`/`valkey-federated`, profile-gated, with pg-init for pgvector extension. |
|
||||
| FED-M1-03 | done | Add pgvector support to `packages/storage/src/adapters/postgres.ts`: create extension on init (idempotent), expose vector column type in schema helpers. No adapter changes for non-federated tiers. | #460 | sonnet | feat/federation-m1-pgvector | FED-M1-02 | 8K | Shipped in PR #472. `enableVector` flag on postgres StorageConfig; idempotent CREATE EXTENSION before migrations. |
|
||||
| FED-M1-04 | done | Implement `apps/gateway/src/bootstrap/tier-detector.ts`: reads config, asserts PG/Valkey/pgvector reachable for `federated`, fail-fast with actionable error message on failure. Unit tests for each failure mode. | #460 | sonnet | feat/federation-m1-detector | FED-M1-03 | 8K | Shipped in PR #473. 12 tests; 5s timeouts on probes; pgvector library/permission discrimination; rejects non-bullmq for federated. |
|
||||
| FED-M1-05 | done | Write `scripts/migrate-to-federated.ts`: one-way migration from `local` (PGlite) / `standalone` (PG without pgvector) → `federated`. Dumps, transforms, loads; dry-run + confirm UX. Idempotent on re-run. | #460 | sonnet | feat/federation-m1-migrate | FED-M1-04 | 10K | Shipped in PR #474. `mosaic storage migrate-tier`; DrizzleMigrationSource (corrects P0 found in review); 32 tests; idempotent. |
|
||||
| FED-M1-06 | done | Update `mosaic doctor`: report current tier, required services, actual health per service, pgvector presence, overall green/yellow/red. Machine-readable JSON output flag for CI use. | #460 | sonnet | feat/federation-m1-doctor | FED-M1-04 | 6K | Shipped in PR #475 as `mosaic gateway doctor`. Probes lifted to @mosaicstack/storage; structural TierConfig breaks dep cycle. |
|
||||
| FED-M1-07 | done | Integration test: gateway boots in `federated` tier with docker-compose `federated` profile; refuses to boot when PG unreachable (asserts fail-fast); pgvector extension query succeeds. | #460 | sonnet | feat/federation-m1-integration | FED-M1-04 | 8K | Shipped in PR #476. 3 test files, 4 tests, gated by FEDERATED_INTEGRATION=1; reserved-port helper avoids host collisions. |
|
||||
| FED-M1-08 | done | Integration test for migration script: seed a local PGlite with representative data (tasks, notes, users, teams), run migration, assert row counts + key samples equal on federated PG. | #460 | sonnet | feat/federation-m1-migrate-test | FED-M1-05 | 6K | Shipped in PR #477. Caught P0 in M1-05 (camelCase→snake_case) missed by mocked unit tests; fix in same PR. |
|
||||
| FED-M1-09 | done | Standalone regression: full agent-session E2E on existing `standalone` tier with a gateway built from this branch. Must pass without referencing any federation module. | #460 | sonnet | feat/federation-m1-regression | FED-M1-07 | 4K | Clean canary. 351 gateway tests + 85 storage unit tests + full pnpm test all green; only FEDERATED_INTEGRATION-gated tests skip. |
|
||||
| FED-M1-10 | done | Code review pass: security-focused on the migration script (data-at-rest during migration) + tier detector (error-message sensitivity leakage). Independent reviewer, not authors of tasks 01-09. | #460 | sonnet | feat/federation-m1-security-review | FED-M1-09 | 8K | 2 review rounds caught 7 issues: credential leak in pg/valkey/pgvector errors + redact-error util; missing advisory lock; SKIP_TABLES rationale. |
|
||||
| FED-M1-11 | done | Docs update: `docs/federation/` operator notes for tier setup; README blurb on federated tier; `docs/guides/` entry for migration. Do NOT touch runbook yet (deferred to FED-M7). | #460 | haiku | feat/federation-m1-docs | FED-M1-10 | 4K | Shipped: `docs/federation/SETUP.md` (119 lines), `docs/guides/migrate-tier.md` (147 lines), README Configuration blurb. |
|
||||
| FED-M1-12 | done | PR, CI green, merge to main, close #460. | #460 | sonnet | feat/federation-m1-close | FED-M1-11 | 3K | M1 closed. PRs #470-#480 merged across 11 tasks. Issue #460 closed; release tag `fed-v0.1.0-m1` published. |
|
||||
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
||||
| --------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----- | ------ | ---------------------------------- | ---------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| FED-M1-01 | done | Extend `mosaic.config.json` schema: add `"federated"` to `tier` enum in validator + TS types. Keep `local` and `standalone` working. Update schema docs/README where referenced. | #460 | sonnet | feat/federation-m1-tier-config | — | 4K | Shipped in PR #470. Renamed `team` → `standalone`; added `team` deprecation alias; added `DEFAULT_FEDERATED_CONFIG`. |
|
||||
| FED-M1-02 | done | Historical shipped-status record: authored a federated Compose overlay with PostgreSQL/pgvector, Valkey, volumes, and healthchecks. It is not a current startup, init, extension, schema, or migration procedure. | #460 | sonnet | feat/federation-m1-compose | FED-M1-01 | 5K | Shipped in PR #471 status only. KBN-101-02 retires its init authority; KBN-101-05 replaces deployment rendering; KBN-101-07 SETUP is non-operative until activation. |
|
||||
| FED-M1-03 | done | Historical shipped-status record: add pgvector support to `packages/storage/src/adapters/postgres.ts`; no adapter changes for non-federated tiers. | #460 | sonnet | feat/federation-m1-pgvector | FED-M1-02 | 8K | Shipped in PR #472 status only. **KBN-101 supersedes this behavior:** it cannot authorize current runtime extension creation or any DDL; only the runner/external bootstrap contract may do so. |
|
||||
| FED-M1-04 | done | Implement `apps/gateway/src/bootstrap/tier-detector.ts`: reads config, asserts PG/Valkey/pgvector reachable for `federated`, fail-fast with actionable error message on failure. Unit tests for each failure mode. | #460 | sonnet | feat/federation-m1-detector | FED-M1-03 | 8K | Shipped in PR #473. 12 tests; 5s timeouts on probes; pgvector library/permission discrimination; rejects non-bullmq for federated. |
|
||||
| FED-M1-05 | done | Historical shipped-status record: prior tier migration implementation. | #460 | sonnet | feat/federation-m1-migrate | FED-M1-04 | 10K | Shipped in PR #474 status only. **KBN-101 supersedes this route:** it cannot authorize current credentials, target connection, or DDL. The future active route requires runner verification plus target URL-file and signed attestation-file binding. |
|
||||
| FED-M1-06 | done | Update `mosaic doctor`: report current tier, required services, actual health per service, pgvector presence, overall green/yellow/red. Machine-readable JSON output flag for CI use. | #460 | sonnet | feat/federation-m1-doctor | FED-M1-04 | 6K | Shipped in PR #475 as `mosaic gateway doctor`. Probes lifted to @mosaicstack/storage; structural TierConfig breaks dep cycle. |
|
||||
| FED-M1-07 | done | Integration test: gateway boots in `federated` tier with docker-compose `federated` profile; refuses to boot when PG unreachable (asserts fail-fast); pgvector extension query succeeds. | #460 | sonnet | feat/federation-m1-integration | FED-M1-04 | 8K | Shipped in PR #476. 3 test files, 4 tests, gated by FEDERATED_INTEGRATION=1; reserved-port helper avoids host collisions. |
|
||||
| FED-M1-08 | done | Integration test for migration script: seed a local PGlite with representative data (tasks, notes, users, teams), run migration, assert row counts + key samples equal on federated PG. | #460 | sonnet | feat/federation-m1-migrate-test | FED-M1-05 | 6K | Shipped in PR #477. Caught P0 in M1-05 (camelCase→snake_case) missed by mocked unit tests; fix in same PR. |
|
||||
| FED-M1-09 | done | Standalone regression: full agent-session E2E on existing `standalone` tier with a gateway built from this branch. Must pass without referencing any federation module. | #460 | sonnet | feat/federation-m1-regression | FED-M1-07 | 4K | Clean canary. 351 gateway tests + 85 storage unit tests + full pnpm test all green; only FEDERATED_INTEGRATION-gated tests skip. |
|
||||
| FED-M1-10 | done | Code review pass: security-focused on the migration script (data-at-rest during migration) + tier detector (error-message sensitivity leakage). Independent reviewer, not authors of tasks 01-09. | #460 | sonnet | feat/federation-m1-security-review | FED-M1-09 | 8K | 2 review rounds caught 7 issues: credential leak in pg/valkey/pgvector errors + redact-error util; missing advisory lock; SKIP_TABLES rationale. |
|
||||
| FED-M1-11 | done | Docs update: `docs/federation/` operator notes for tier setup; README blurb on federated tier; `docs/guides/` entry for migration. Do NOT touch runbook yet (deferred to FED-M7). | #460 | haiku | feat/federation-m1-docs | FED-M1-10 | 4K | Shipped: `docs/federation/SETUP.md` (119 lines), `docs/guides/migrate-tier.md` (147 lines), README Configuration blurb. |
|
||||
| FED-M1-12 | done | PR, CI green, merge to main, close #460. | #460 | sonnet | feat/federation-m1-close | FED-M1-11 | 3K | M1 closed. PRs #470-#480 merged across 11 tasks. Issue #460 closed; release tag `fed-v0.1.0-m1` published. |
|
||||
|
||||
**M1 total estimate:** ~74K tokens (over-budget vs 20K PRD estimate — explanation below)
|
||||
|
||||
|
||||
@@ -66,8 +66,12 @@ checks the exact `=<agent-name>` tmux target; it never uses an ambient socket or
|
||||
The same strict parser runs before exact-stop behavior. A fresh native Pi heartbeat remains authoritative;
|
||||
the shell sidecar only provides fallback state when the native marker is stale or absent.
|
||||
|
||||
`mosaic fleet comms-block <role>` can inspect the role's resolved Fleet-Comms block. It is a read-only
|
||||
inspection tool and fails loudly for an unknown role or missing roster.
|
||||
`mosaic agent comms-block <exact-member>` can inspect that exact roster member's resolved Fleet-Comms
|
||||
block. It is a read-only inspection tool and fails loudly for an unknown exact member or missing roster.
|
||||
On Linux, the installed roster, TOOLS contract, and executable helper are opened through a held
|
||||
descriptor chain rooted at `/`; every managed path component uses no-follow traversal, and content plus
|
||||
execute validation stay bound to the same opened file. Systems without Linux `/proc/self/fd` support
|
||||
fail closed rather than falling back to pathname revalidation.
|
||||
|
||||
## Current M2 boundary
|
||||
|
||||
|
||||
@@ -14,18 +14,17 @@ and surfaced as `mosaic fleet backlog <sub> --json`.
|
||||
The backlog uses the existing Mosaic storage layer; there is **no** new database
|
||||
engine (no sqlite, no raw client).
|
||||
|
||||
| Condition | Tier | Data location |
|
||||
| ------------------------------ | -------------------- | -------------------------------- |
|
||||
| `DATABASE_URL` set | Full server Postgres | the configured database |
|
||||
| `PGLITE_DATA_DIR` set (no URL) | Embedded PGlite | that directory |
|
||||
| neither (default) | Embedded PGlite | `~/.config/mosaic/fleet/backlog` |
|
||||
| Condition | Tier | Data location |
|
||||
| ---------------------------------- | -------------------- | ---------------------------------------------------------------- |
|
||||
| `DATABASE_URL` injected at runtime | Full server Postgres | the verified runtime database; it never authorizes migration/DDL |
|
||||
| `PGLITE_DATA_DIR` set (no URL) | Embedded PGlite | that directory |
|
||||
| neither (default) | Embedded PGlite | `~/.config/mosaic/fleet/backlog` |
|
||||
|
||||
PGlite is real Postgres semantics in-process — including the row locks the atomic
|
||||
claim relies on — so the **same code** runs on a laptop (embedded, single-host
|
||||
default) and on a full Postgres deployment. Switching tiers is config-only.
|
||||
|
||||
The schema (`backlog` table) is created automatically on first CLI use:
|
||||
`runMigrations()` for Postgres, `runPgliteMigrations()` for embedded PGlite.
|
||||
For embedded PGlite only, the local backlog routine may prepare its local schema on first use. **Current operator behavior is PGlite-only.** The PostgreSQL path is held until KBN-101 activation; no current PostgreSQL CLI route, runner, or first-use migration is available or authorized. A future activated PostgreSQL runtime may connect only after its separately certified readiness gate.
|
||||
|
||||
### Update safety
|
||||
|
||||
|
||||
23
docs/fleet/how-to/start-stop-restart.md
Normal file
23
docs/fleet/how-to/start-stop-restart.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# Safely Reconcile and Control a Local Fleet Agent
|
||||
|
||||
Use the canonical local roster-v2 command surface:
|
||||
|
||||
```sh
|
||||
mosaic fleet apply --expected-generation <n> --dry-run
|
||||
mosaic fleet apply --expected-generation <n>
|
||||
mosaic fleet reconcile --expected-generation <n>
|
||||
mosaic fleet start <name> --expected-generation <n>
|
||||
mosaic fleet stop <name> --expected-generation <n>
|
||||
mosaic fleet restart <name> --expected-generation <n>
|
||||
mosaic fleet status [name]
|
||||
mosaic fleet verify
|
||||
mosaic fleet doctor
|
||||
```
|
||||
|
||||
Start with `--dry-run`. It validates roster semantics, deterministic projections, private managed paths, exact holder ownership, and named-socket state without changing files or lifecycle state. `apply` and `reconcile` rebuild derived projections and enforce only persisted roster state: enabled `running` agents may start, while stopped or disabled agents are not started.
|
||||
|
||||
`start`, `stop`, and `restart` are explicit one-shot exact-service actions. They do not persist a lifecycle change. Roster CRUD is the only way to change persisted desired state.
|
||||
|
||||
Every command prints JSON. Observation commands report drift without mutation; `verify` exits non-zero on ownership mismatch, unmanaged sessions, or drift. A failed apply that wrote some derived projections reports `projections: "incomplete"` with bounded recovery to regenerate from the roster. A lifecycle failure after projections reports incomplete lifecycle work; it is never represented as a rollback or no-op.
|
||||
|
||||
These commands are local only. Remote/SSH/connector entries are inventory/validation-only. Commands do not accept arbitrary runtime commands, channels, secrets, generated-file desired state, or arbitrary tmux sockets.
|
||||
@@ -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.
|
||||
26
docs/fleet/operations/reconcile-and-recover.md
Normal file
26
docs/fleet/operations/reconcile-and-recover.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# Reconcile and Recover a Local Fleet
|
||||
|
||||
## Safe sequence
|
||||
|
||||
1. Read `mosaic fleet doctor` and `mosaic fleet status`.
|
||||
2. Run `mosaic fleet apply --expected-generation <n> --dry-run`.
|
||||
3. Resolve stale generation, ownership mismatch, unsafe path, projection validation, or unmanaged-session findings before applying.
|
||||
4. Run `mosaic fleet apply --expected-generation <n>` only after the plan is understood.
|
||||
|
||||
The reconciler uses the exact roster tmux socket, exact holder session, private installation holder identity, and the complete expected global environment. For mutations it acquires its exclusive lock before rereading the canonical roster and fencing its generation; only that under-lock roster drives validation, planning, projections, and lifecycle effects. Before effects, its exclusive lock proves real private `MOSAIC_HOME` and `fleet` ancestors, uses a private `0600` lock leaf, and binds cleanup to the created file identity and ownership token. A fake holder, contaminated global environment, missing identity, unsafe lock path, or unmanaged session fails closed. It does not adopt, kill, or rename any unproven session. A crash can leave a stale lock for explicit operator inspection; reconciliation deliberately does not guess ownership or remove it.
|
||||
|
||||
## Partial results
|
||||
|
||||
The roster is never changed by reconciliation. If derived projection application partially fails, JSON reports:
|
||||
|
||||
```json
|
||||
{
|
||||
"applied": false,
|
||||
"authoritativeRoster": "unchanged",
|
||||
"projections": "incomplete",
|
||||
"lifecycle": "not-applied",
|
||||
"recovery": { "code": "projection-apply-failed", "action": "regenerate-projections-from-roster" }
|
||||
}
|
||||
```
|
||||
|
||||
If projections completed but lifecycle work failed, JSON reports `projections: "complete"`, `lifecycle: "incomplete"`, and the bounded action `rerun-after-inspecting-owned-resources`. If lock cleanup cannot be proven after an effect result, it adds `cleanup: { "code": "lock-cleanup-failed", "action": "inspect-lock-before-retry" }` without changing the known projection, lifecycle, or primary recovery truth. Inspect the retained lock before retrying; no rollback, release, or stale-lock removal is implied. Results do not include environment values, secrets, or privileged command content.
|
||||
27
docs/fleet/reference/cli.md
Normal file
27
docs/fleet/reference/cli.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# Fleet Control-Plane CLI
|
||||
|
||||
The local roster-v2 control plane is `mosaic fleet`.
|
||||
|
||||
```text
|
||||
mosaic fleet apply --expected-generation <n> [--dry-run]
|
||||
mosaic fleet reconcile --expected-generation <n> [--dry-run]
|
||||
mosaic fleet start [name] --expected-generation <n> [--dry-run]
|
||||
mosaic fleet stop [name] --expected-generation <n> [--dry-run]
|
||||
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.
|
||||
|
||||
This control plane is separate from the gateway-backed `mosaic agent` catalog. It is local-only and rejects remote/connector lifecycle mutation, arbitrary command/channel/secret input, and unproven tmux ownership.
|
||||
14
docs/fleet/reference/lifecycle-transitions.md
Normal file
14
docs/fleet/reference/lifecycle-transitions.md
Normal file
@@ -0,0 +1,14 @@
|
||||
# Local Fleet Lifecycle Transitions
|
||||
|
||||
FCM-M3-001 uses the roster-v2 `lifecycle.enabled` and `lifecycle.desired_state` fields as the only desired-state authority. Systemd, tmux, generated environment files, and heartbeats are derived or observed state.
|
||||
|
||||
| Command | Desired-state write | Runtime effect | Preconditions |
|
||||
| --------------------------------- | ------------------- | -------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `fleet apply` / `fleet reconcile` | Never | Rebuilds projections, then starts only enabled agents desired `running`; stops disabled or desired-`stopped` roster agents | Current generation; private managed paths; valid projections; proven holder ownership; no unmanaged named-socket sessions |
|
||||
| `fleet start <name>` | Never | One-shot exact `mosaic-agent@<name>.service` start | Current generation; exact enabled roster name; proven ownership |
|
||||
| `fleet stop <name>` | Never | One-shot exact service stop | Current generation; exact roster name; proven ownership |
|
||||
| `fleet restart <name>` | Never | One-shot exact service restart | Current generation; exact roster name; proven ownership |
|
||||
|
||||
A stopped roster agent is never started by `apply` or `reconcile`. Direct lifecycle commands are explicit one-shot actions and do not change persisted desired state. Use roster CRUD with the explicit persisted-start option to change that desired state.
|
||||
|
||||
All mutations require `--expected-generation <n>` and acquire one private roster-adjacent reconciliation lock before projection or lifecycle effects. Missing or stale generations and concurrent writers fail before effects; the lock is released after success, partial failure, or thrown lifecycle failure. Stale, ownership, unmanaged-session, unsupported-runtime, path, projection, and lifecycle-precondition failures return stable redacted JSON errors and a non-zero exit. No command targets a fuzzy tmux name, arbitrary socket, arbitrary command, channel, secret, or generated file as authority.
|
||||
@@ -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",
|
||||
|
||||
13
docs/fleet/reference/status-and-drift.md
Normal file
13
docs/fleet/reference/status-and-drift.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# Local Fleet Status and Drift
|
||||
|
||||
`mosaic fleet status [name]`, `verify`, and `doctor` are observational roster-v2 commands. They emit one JSON result and do not write projections, change desired state, start services, stop services, restart services, or mutate tmux.
|
||||
|
||||
The report distinguishes:
|
||||
|
||||
- `missing-session`: an enabled agent desired `running` has no exact roster-named tmux session.
|
||||
- `unexpected-session`: a desired-`stopped` agent still has its exact session.
|
||||
- `disabled-running`: a disabled roster agent has its exact session.
|
||||
- `unmanagedSessions`: sessions on the configured named socket that are neither the exact holder nor an exact roster agent.
|
||||
- `holder`: `owned`, `missing`, or `ownership-mismatch` after exact holder, private install identity, and complete global tmux environment validation.
|
||||
|
||||
`doctor` and `status` classify rather than adopt, destroy, or repair unmanaged state. `verify` is observational too, but exits non-zero if ownership cannot be proven, unmanaged sessions exist, or drift is present. Reconciliation fails closed under those conditions and never kills or adopts an unmanaged session.
|
||||
@@ -223,10 +223,10 @@ external clients. Authentication requires a valid BetterAuth session (cookie or
|
||||
|
||||
### Required
|
||||
|
||||
| Variable | Description |
|
||||
| -------------------- | ----------------------------------------------------------------------------------------- |
|
||||
| `BETTER_AUTH_SECRET` | Secret key for BetterAuth session signing. Must be set or gateway will not start. |
|
||||
| `DATABASE_URL` | PostgreSQL connection string. Default: `postgresql://mosaic:mosaic@localhost:5433/mosaic` |
|
||||
| Variable | Description |
|
||||
| -------------------- | ----------------------------------------------------------------------------------------------------------- |
|
||||
| `BETTER_AUTH_SECRET` | Secret key for BetterAuth session signing. Must be set or gateway will not start. |
|
||||
| `DATABASE_URL` | Runtime-only PostgreSQL connection injected from the dedicated deployment secret; no default or inline DSN. |
|
||||
|
||||
### Gateway
|
||||
|
||||
|
||||
@@ -1,384 +1,67 @@
|
||||
# Deployment Guide
|
||||
|
||||
This guide covers deploying Mosaic in two modes: **Docker Compose** (recommended for quick setup) and **bare-metal** (production, full control).
|
||||
> **Status: non-operative for PostgreSQL, federated, and bare-metal production.** The checked-in
|
||||
> Compose PostgreSQL service mounts legacy initialization SQL and the KBN-101 bootstrap, runner,
|
||||
> secret-renderer, and process-exec interfaces do not exist yet. This page does not authorize a
|
||||
> production deployment, database initialization, manual DDL, secret provisioning, or service
|
||||
> activation.
|
||||
|
||||
---
|
||||
## Current safe local route
|
||||
|
||||
## Prerequisites
|
||||
|
||||
| Dependency | Minimum version | Notes |
|
||||
| ---------------- | --------------- | ---------------------------------------------- |
|
||||
| Node.js | 22 LTS | Required for ESM + `--experimental-vm-modules` |
|
||||
| pnpm | 9 | `npm install -g pnpm` |
|
||||
| PostgreSQL | 17 | Must have the `pgvector` extension |
|
||||
| Valkey | 8 | Redis-compatible; Redis 7+ also works |
|
||||
| Docker + Compose | v2 | For the Docker Compose path only |
|
||||
|
||||
---
|
||||
|
||||
## Docker Compose Deployment (Quick Start)
|
||||
|
||||
The `docker-compose.yml` at the repository root starts PostgreSQL 17 (with pgvector), Valkey 8, an OpenTelemetry Collector, and Jaeger.
|
||||
|
||||
### 1. Clone and configure
|
||||
Use PGlite only for current in-process data-layer work; it requires no PostgreSQL. A Gateway/Web
|
||||
local process is held because its unguarded dotenv loader can inherit a daemon PostgreSQL DSN and
|
||||
reach runtime DDL. If a local queue service is useful, start only Valkey:
|
||||
|
||||
```bash
|
||||
git clone <repo-url> mosaic
|
||||
cd mosaic
|
||||
cp .env.example .env
|
||||
docker compose up -d valkey
|
||||
```
|
||||
|
||||
Edit `.env`. The minimum required change is:
|
||||
This command intentionally does not start PostgreSQL. Do not run a broad Compose start, use its
|
||||
PostgreSQL initialization mount, infer that current Compose is a production/federated route, or
|
||||
start Gateway/Web until KBN-101-02 supplies fail-closed local-tier/DSN isolation.
|
||||
|
||||
```dotenv
|
||||
BETTER_AUTH_SECRET=<output of: openssl rand -base64 32>
|
||||
```
|
||||
## Held future procedure
|
||||
|
||||
### 2. Start infrastructure services
|
||||
PostgreSQL local, federated, Compose, and bare-metal production activation are held until these
|
||||
artifacts land and pass their independent gates:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
1. **KBN-101-00** external privileged bootstrap artifact;
|
||||
2. **KBN-101-03** sole `mosaic-db-migrator` runner and verified-readiness artifact; and
|
||||
3. **KBN-101-05** Vault/secret-renderer-backed deployment and consumer-isolation artifact.
|
||||
|
||||
Services and their ports:
|
||||
The required future order is external bootstrap → TLS/roles → `mosaic-db-migrator --run` → `mosaic-db-migrator --verify` → Gateway/Compose readiness.
|
||||
|
||||
| Service | Default port |
|
||||
| --------------------- | ------------------------ |
|
||||
| PostgreSQL | `localhost:5433` |
|
||||
| Valkey | `localhost:6380` |
|
||||
| OTEL Collector (HTTP) | `localhost:4318` |
|
||||
| OTEL Collector (gRPC) | `localhost:4317` |
|
||||
| Jaeger UI | `http://localhost:16686` |
|
||||
This is a held, non-operative future activation specification with no current command authority. Do not invoke the named
|
||||
runner, start PostgreSQL, or substitute a Compose/init/manual-SQL route until the owned artifacts
|
||||
are implemented and reviewed.
|
||||
|
||||
Override host ports via `PG_HOST_PORT` and `VALKEY_HOST_PORT` in `.env` if the defaults conflict.
|
||||
## Future production secret and unit boundary (schematic only)
|
||||
|
||||
### 3. Install dependencies
|
||||
No current bare-metal production unit or command is published. KBN-101-05 must supply a reviewed,
|
||||
generation-pinned Vault renderer and a process-exec or systemd `LoadCredential` interface before
|
||||
production units can exist. The interface must preserve these exact consumer boundaries:
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
| Consumer | May receive | Must never receive |
|
||||
| ----------------- | -------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- |
|
||||
| Gateway/runtime | Its own runtime URL and DB client CA at process exec | Migrator URL, importer URL/version, attestation material, signing key, PostgreSQL private key |
|
||||
| One-shot migrator | Its own migration URL, DB client CA, and runner-only signing capability | Runtime URL, importer consumer copy, Gateway/private PostgreSQL keys |
|
||||
| Data importer | Its own immutable URL/version copies, importer CA, pinned public key, and sealed attestation | Runtime/migrator URLs, signing key, shared writable mount |
|
||||
| PostgreSQL | Its own server certificate/key and only its approved server material | Application, migrator, importer, or Gateway secrets |
|
||||
|
||||
### 4. Initialize the database
|
||||
A future unit specification is non-executable until KBN-101-05 supplies it. It must obtain
|
||||
credentials through the renderer’s Vault generation and process-exec/`LoadCredential` boundary;
|
||||
it must not place credentials in a production environment file, a monorepo auto-load path, a shell
|
||||
export, command arguments, logs, or a manual secret-activation lifecycle instruction. Rotation and
|
||||
process replacement semantics must be delivered by the reviewed renderer/interface with generation,
|
||||
consumer-isolation, mode/owner, and no-mixed-generation evidence—not improvised in this guide.
|
||||
|
||||
```bash
|
||||
pnpm --filter @mosaicstack/db db:migrate
|
||||
```
|
||||
## Readiness and troubleshooting status
|
||||
|
||||
### 5. Build all packages
|
||||
Until the future procedure is implemented, do not diagnose PostgreSQL with ad hoc SQL, connection
|
||||
strings, or initialization scripts. The future sanitized runner-verification readiness artifact is
|
||||
the required PostgreSQL readiness authority after its bootstrap/TLS prerequisites pass.
|
||||
For local PGlite development, diagnose application behavior without introducing a PostgreSQL
|
||||
connection.
|
||||
|
||||
```bash
|
||||
pnpm build
|
||||
```
|
||||
|
||||
### 6. Start the gateway
|
||||
|
||||
```bash
|
||||
pnpm --filter @mosaicstack/gateway dev
|
||||
```
|
||||
|
||||
Or for production (after build):
|
||||
|
||||
```bash
|
||||
node apps/gateway/dist/main.js
|
||||
```
|
||||
|
||||
### 7. Start the web app
|
||||
|
||||
```bash
|
||||
# Development
|
||||
pnpm --filter @mosaicstack/web dev
|
||||
|
||||
# Production (after build)
|
||||
pnpm --filter @mosaicstack/web start
|
||||
```
|
||||
|
||||
The web app runs on port `3000` by default.
|
||||
|
||||
---
|
||||
|
||||
## Bare-Metal Deployment
|
||||
|
||||
Use this path when you want to manage PostgreSQL and Valkey yourself (e.g., existing infrastructure, managed cloud databases).
|
||||
|
||||
### Step 1 — Install system dependencies
|
||||
|
||||
```bash
|
||||
# Node.js 22 via nvm
|
||||
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
|
||||
nvm install 22
|
||||
nvm use 22
|
||||
|
||||
# pnpm
|
||||
npm install -g pnpm
|
||||
|
||||
# PostgreSQL 17 with pgvector (Debian/Ubuntu example)
|
||||
sudo apt-get install -y postgresql-17 postgresql-17-pgvector
|
||||
|
||||
# Valkey
|
||||
# Follow https://valkey.io/download/ for your distribution
|
||||
```
|
||||
|
||||
### Step 2 — Create the database
|
||||
|
||||
```sql
|
||||
-- Run as the postgres superuser
|
||||
CREATE USER mosaic WITH PASSWORD 'change-me';
|
||||
CREATE DATABASE mosaic OWNER mosaic;
|
||||
\c mosaic
|
||||
CREATE EXTENSION IF NOT EXISTS vector;
|
||||
```
|
||||
|
||||
### Step 3 — Clone and configure
|
||||
|
||||
```bash
|
||||
git clone <repo-url> /opt/mosaic
|
||||
cd /opt/mosaic
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Edit `/opt/mosaic/.env`. Required fields:
|
||||
|
||||
```dotenv
|
||||
DATABASE_URL=postgresql://mosaic:<password>@localhost:5432/mosaic
|
||||
VALKEY_URL=redis://localhost:6379
|
||||
BETTER_AUTH_SECRET=<openssl rand -base64 32>
|
||||
BETTER_AUTH_URL=https://your-domain.example.com
|
||||
GATEWAY_CORS_ORIGIN=https://your-domain.example.com
|
||||
NEXT_PUBLIC_GATEWAY_URL=https://your-domain.example.com
|
||||
```
|
||||
|
||||
### Step 4 — Install dependencies and build
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm build
|
||||
```
|
||||
|
||||
### Step 5 — Run database migrations
|
||||
|
||||
```bash
|
||||
pnpm --filter @mosaicstack/db db:migrate
|
||||
```
|
||||
|
||||
### Step 6 — Start the gateway
|
||||
|
||||
```bash
|
||||
node apps/gateway/dist/main.js
|
||||
```
|
||||
|
||||
The gateway reads `.env` from the monorepo root automatically (via `dotenv` in `main.ts`).
|
||||
|
||||
### Step 7 — Start the web app
|
||||
|
||||
```bash
|
||||
# Next.js standalone output
|
||||
node apps/web/.next/standalone/server.js
|
||||
```
|
||||
|
||||
The standalone build is self-contained; it does not require `node_modules` to be present at runtime.
|
||||
|
||||
### Step 8 — Configure a reverse proxy
|
||||
|
||||
#### Nginx example
|
||||
|
||||
```nginx
|
||||
# /etc/nginx/sites-available/mosaic
|
||||
|
||||
# Gateway API
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name your-domain.example.com;
|
||||
|
||||
ssl_certificate /etc/ssl/certs/your-domain.crt;
|
||||
ssl_certificate_key /etc/ssl/private/your-domain.key;
|
||||
|
||||
# WebSocket support (for chat.gateway.ts / Socket.IO)
|
||||
location /socket.io/ {
|
||||
proxy_pass http://127.0.0.1:14242;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
# REST + auth
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:14242;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
|
||||
# Web app (optional — serve on a subdomain or a separate server block)
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name app.your-domain.example.com;
|
||||
|
||||
ssl_certificate /etc/ssl/certs/your-domain.crt;
|
||||
ssl_certificate_key /etc/ssl/private/your-domain.key;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:3000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Caddy example
|
||||
|
||||
```caddyfile
|
||||
# /etc/caddy/Caddyfile
|
||||
|
||||
your-domain.example.com {
|
||||
reverse_proxy /socket.io/* localhost:14242 {
|
||||
header_up Upgrade {http.upgrade}
|
||||
header_up Connection {http.connection}
|
||||
}
|
||||
reverse_proxy localhost:14242
|
||||
}
|
||||
|
||||
app.your-domain.example.com {
|
||||
reverse_proxy localhost:3000
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Production Considerations
|
||||
|
||||
### systemd Services
|
||||
|
||||
Create a service unit for each process.
|
||||
|
||||
**Gateway** — `/etc/systemd/system/mosaic-gateway.service`:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Mosaic Gateway
|
||||
After=network.target postgresql.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=mosaic
|
||||
WorkingDirectory=/opt/mosaic
|
||||
EnvironmentFile=/opt/mosaic/.env
|
||||
ExecStart=/usr/bin/node apps/gateway/dist/main.js
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
**Web app** — `/etc/systemd/system/mosaic-web.service`:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Mosaic Web App
|
||||
After=network.target mosaic-gateway.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=mosaic
|
||||
WorkingDirectory=/opt/mosaic/apps/web
|
||||
EnvironmentFile=/opt/mosaic/.env
|
||||
ExecStart=/usr/bin/node .next/standalone/server.js
|
||||
Environment=PORT=3000
|
||||
Environment=HOSTNAME=127.0.0.1
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
Enable and start:
|
||||
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now mosaic-gateway mosaic-web
|
||||
```
|
||||
|
||||
### Log Management
|
||||
|
||||
Gateway and web app logs go to systemd journal by default. View with:
|
||||
|
||||
```bash
|
||||
journalctl -u mosaic-gateway -f
|
||||
journalctl -u mosaic-web -f
|
||||
```
|
||||
|
||||
Rotate logs by configuring `journald` in `/etc/systemd/journald.conf`:
|
||||
|
||||
```ini
|
||||
SystemMaxUse=500M
|
||||
MaxRetentionSec=30day
|
||||
```
|
||||
|
||||
### Security Checklist
|
||||
|
||||
- Set `BETTER_AUTH_SECRET` to a cryptographically random value (`openssl rand -base64 32`).
|
||||
- Restrict `GATEWAY_CORS_ORIGIN` to your exact frontend origin — do not use `*`.
|
||||
- Run services as a dedicated non-root system user (e.g., `mosaic`).
|
||||
- Firewall: only expose ports 80/443 externally; keep 14242 and 3000 bound to `127.0.0.1`.
|
||||
- Set `AGENT_FILE_SANDBOX_DIR` to a directory outside the application root to prevent agent tools from accessing source code.
|
||||
- If using `AGENT_USER_TOOLS`, enumerate only the tools non-admin users need.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Gateway fails to start — "BETTER_AUTH_SECRET is required"
|
||||
|
||||
`BETTER_AUTH_SECRET` is missing or empty. Set it in `.env` and restart.
|
||||
|
||||
### `DATABASE_URL` connection refused
|
||||
|
||||
Verify PostgreSQL is running and the port matches. The Docker Compose default is `5433`; bare-metal typically uses `5432`.
|
||||
|
||||
```bash
|
||||
psql "$DATABASE_URL" -c '\conninfo'
|
||||
```
|
||||
|
||||
### pgvector extension missing
|
||||
|
||||
```sql
|
||||
\c mosaic
|
||||
CREATE EXTENSION IF NOT EXISTS vector;
|
||||
```
|
||||
|
||||
### Valkey / Redis connection refused
|
||||
|
||||
Check the URL in `VALKEY_URL`. The Docker Compose default is port `6380`.
|
||||
|
||||
```bash
|
||||
redis-cli -u "$VALKEY_URL" ping
|
||||
```
|
||||
|
||||
### WebSocket connections fail in production
|
||||
|
||||
Ensure your reverse proxy forwards the `Upgrade` and `Connection` headers. See the Nginx/Caddy examples above.
|
||||
|
||||
### Ollama models not appearing
|
||||
|
||||
Set `OLLAMA_BASE_URL` to the URL where Ollama is running (e.g., `http://localhost:11434`) and set `OLLAMA_MODELS` to a comma-separated list of model IDs you have pulled.
|
||||
|
||||
```bash
|
||||
ollama pull llama3.2
|
||||
```
|
||||
|
||||
### OTEL traces not appearing in Jaeger
|
||||
|
||||
Verify the collector is reachable at `OTEL_EXPORTER_OTLP_ENDPOINT`. With Docker Compose the default is `http://localhost:4318`. Check `docker compose ps` and `docker compose logs otel-collector`.
|
||||
|
||||
### Summarization / embedding features not working
|
||||
|
||||
These features require `OPENAI_API_KEY` to be set, or you must point `SUMMARIZATION_API_URL` / `EMBEDDING_API_URL` to an OpenAI-compatible endpoint (e.g., a local Ollama instance with an embeddings model).
|
||||
Non-database local services may be inspected with their ordinary local health/log tools. Those
|
||||
checks do not certify PostgreSQL, federated deployment, or production readiness.
|
||||
|
||||
@@ -39,7 +39,7 @@ mosaic-mono-v1/
|
||||
│ ├── queue/ # Valkey-backed task queue
|
||||
│ └── types/ # Shared TypeScript types
|
||||
├── docker/ # Dockerfile(s) for containerized deployment
|
||||
├── infra/ # Infra config (OTEL collector, pg-init scripts)
|
||||
├── infra/ # Infrastructure configuration (for example, OTEL collector)
|
||||
├── docker-compose.yml # Local services (Postgres, Valkey, OTEL, Jaeger)
|
||||
└── CLAUDE.md # Project conventions for AI coding agents
|
||||
```
|
||||
@@ -86,71 +86,54 @@ cd mosaic-mono-v1
|
||||
pnpm install
|
||||
```
|
||||
|
||||
### 2. Start Infrastructure Services
|
||||
### 2. Use the local PGlite tier
|
||||
|
||||
The supported local tier is in-process PGlite and requires no PostgreSQL service. Leave
|
||||
`DATABASE_URL` unset for this route. Its default local configuration uses PGlite and performs no
|
||||
external database probe.
|
||||
|
||||
If a local queue service is useful, start only that non-PostgreSQL service:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
docker compose up -d valkey
|
||||
```
|
||||
|
||||
This starts:
|
||||
Do not use the current Compose PostgreSQL service: it mounts legacy `infra/pg-init` SQL and is
|
||||
not qualified for KBN-101. Start OTEL Collector or Jaeger individually only when needed and
|
||||
without starting PostgreSQL.
|
||||
|
||||
| Service | Port | Description |
|
||||
| ------------------------ | -------------- | -------------------- |
|
||||
| PostgreSQL 17 + pgvector | `5433` (host) | Primary database |
|
||||
| Valkey 8 | `6380` (host) | Queue and cache |
|
||||
| OpenTelemetry Collector | `4317`, `4318` | OTEL gRPC and HTTP |
|
||||
| Jaeger | `16686` | Distributed trace UI |
|
||||
### 3. Gateway/Web local process (held)
|
||||
|
||||
### 3. Configure Environment
|
||||
Do not start the current Gateway or web process as a local PGlite route. Gateway first loads the
|
||||
daemon configuration and then project environment files without a tier guard; a pre-existing
|
||||
`DATABASE_URL` can select PostgreSQL, where current startup still reaches runtime DDL/migrations.
|
||||
Creating a root `.env` that omits `DATABASE_URL` does not make this safe, so neither a local
|
||||
credential file nor a web environment file is a current developer procedure.
|
||||
|
||||
Create a `.env` file in the monorepo root:
|
||||
PGlite remains the supported in-process data-layer implementation, and the optional Valkey command
|
||||
above remains safe because it does not start PostgreSQL. A safe Gateway/Web local procedure is held
|
||||
until KBN-101-02 rejects a daemon, inherited, root, or app-local PostgreSQL DSN and any non-local
|
||||
tier before connection or DDL; KBN-101-05 then supplies the production renderer/Vault process-exec
|
||||
or `LoadCredential` boundary.
|
||||
|
||||
```env
|
||||
# Database (matches docker-compose defaults)
|
||||
DATABASE_URL=postgresql://mosaic:mosaic@localhost:5433/mosaic
|
||||
### Held future procedure
|
||||
|
||||
# Auth (required — generate a random 32+ char string)
|
||||
BETTER_AUTH_SECRET=change-me-to-a-random-secret
|
||||
PostgreSQL local and federated deployment are held until KBN-101-00 (external bootstrap),
|
||||
KBN-101-03 (runner), and KBN-101-05 (renderer-backed deployment) land. The following is the
|
||||
**held, non-operative future activation order with no current command authority**:
|
||||
|
||||
# Gateway
|
||||
GATEWAY_PORT=14242
|
||||
GATEWAY_CORS_ORIGIN=http://localhost:3000
|
||||
external bootstrap → TLS/roles → `mosaic-db-migrator --run` →
|
||||
`mosaic-db-migrator --verify` → Gateway/Compose readiness.
|
||||
|
||||
# Web
|
||||
NEXT_PUBLIC_GATEWAY_URL=http://localhost:14242
|
||||
Neither current Compose nor this development guide authorizes PostgreSQL initialization SQL,
|
||||
manual DDL, or a pre-runner start.
|
||||
|
||||
# Optional: Ollama
|
||||
OLLAMA_BASE_URL=http://localhost:11434
|
||||
OLLAMA_MODELS=llama3.2
|
||||
```
|
||||
### 5. Gateway/Web start (held)
|
||||
|
||||
The gateway loads `.env` from the monorepo root via `dotenv` at startup
|
||||
(`apps/gateway/src/main.ts`).
|
||||
|
||||
### 4. Push the Database Schema
|
||||
|
||||
```bash
|
||||
pnpm --filter @mosaicstack/db db:push
|
||||
```
|
||||
|
||||
This applies the Drizzle schema directly to the database (development only; use
|
||||
migrations in production).
|
||||
|
||||
### 5. Start the Gateway
|
||||
|
||||
```bash
|
||||
pnpm --filter @mosaicstack/gateway exec tsx src/main.ts
|
||||
```
|
||||
|
||||
The gateway starts on port `14242` by default.
|
||||
|
||||
### 6. Start the Web App
|
||||
|
||||
```bash
|
||||
pnpm --filter @mosaicstack/web dev
|
||||
```
|
||||
|
||||
The web app starts on port `3000` by default.
|
||||
No Gateway/Web start command is currently authorized for the local PGlite route. Do not use root
|
||||
`pnpm dev` as a workaround: it additionally starts configured integrations and cannot establish the
|
||||
required local-tier/DSN isolation. Resume this section only after KBN-101-02 provides its
|
||||
fail-closed local-startup evidence.
|
||||
|
||||
---
|
||||
|
||||
@@ -300,26 +283,13 @@ Implement a standard MCP server that exposes tools via the streamable HTTP
|
||||
transport or SSE transport. The server must accept connections at a `/mcp`
|
||||
endpoint.
|
||||
|
||||
### 2. Configure `MCP_SERVERS`
|
||||
### 2. Gateway MCP configuration (held)
|
||||
|
||||
In your `.env`:
|
||||
|
||||
```env
|
||||
MCP_SERVERS='[{"name":"my-server","url":"http://localhost:3001/mcp"}]'
|
||||
```
|
||||
|
||||
With authentication:
|
||||
|
||||
```env
|
||||
MCP_SERVERS='[{"name":"secure-server","url":"http://my-server/mcp","headers":{"Authorization":"Bearer token"}}]'
|
||||
```
|
||||
|
||||
### 3. Restart the Gateway
|
||||
|
||||
On startup, `McpClientService` (`apps/gateway/src/mcp-client/mcp-client.service.ts`)
|
||||
connects to each configured server, calls `tools/list`, and bridges the results
|
||||
to Pi SDK `ToolDefinition` format. These tools become available in all new agent
|
||||
sessions.
|
||||
Do not configure MCP endpoint credentials, write them to a local environment file, or restart the
|
||||
Gateway from this guide. Gateway/Web startup is held until KBN-101-02 supplies fail-closed
|
||||
local-tier/DSN isolation and KBN-101-05 supplies the renderer/Vault process-exec or
|
||||
`LoadCredential` secret-consumer interface. The future authenticated MCP route requires verified
|
||||
HTTPS and certificate validation; plaintext bearer-token examples are forbidden.
|
||||
|
||||
### Tool Naming
|
||||
|
||||
@@ -355,42 +325,31 @@ The schema lives in a single file:
|
||||
|
||||
The `insights` table uses a `vector(1536)` column (pgvector) for semantic search.
|
||||
|
||||
### Development: Push Schema
|
||||
### PostgreSQL schema work (held)
|
||||
|
||||
Apply schema changes directly to the dev database (no migration files created):
|
||||
Do not prepare or run a PostgreSQL target from this branch. The sole runner, bootstrap, and
|
||||
renderer are future KBN-101 artifacts, not current commands. When KBN-101-00/-03/-05 land, the
|
||||
owned activation documentation will require external bootstrap → TLS/roles → runner `--run` →
|
||||
runner `--verify` → Gateway/Compose readiness.
|
||||
|
||||
```bash
|
||||
pnpm --filter @mosaicstack/db db:push
|
||||
```
|
||||
### Generating migration artifacts
|
||||
|
||||
### Generating Migrations
|
||||
|
||||
For production-safe, versioned changes:
|
||||
|
||||
```bash
|
||||
pnpm --filter @mosaicstack/db db:generate
|
||||
```
|
||||
|
||||
This creates a new SQL migration file in `packages/db/drizzle/`.
|
||||
|
||||
### Running Migrations
|
||||
|
||||
```bash
|
||||
pnpm --filter @mosaicstack/db db:migrate
|
||||
```
|
||||
`pnpm --filter @mosaicstack/db db:generate` is an offline artifact-generation command. It does
|
||||
not authorize connecting to or initializing PostgreSQL. A future reviewed PostgreSQL procedure
|
||||
will determine when its output is applied.
|
||||
|
||||
### Drizzle Config
|
||||
|
||||
Config is at `packages/db/drizzle.config.ts`. The schema file path and output
|
||||
directory are defined there.
|
||||
Config is at `packages/db/drizzle.config.ts`. The schema file path and output directory are
|
||||
defined there.
|
||||
|
||||
### Adding a New Table
|
||||
|
||||
1. Add the table definition to `packages/db/src/schema.ts`.
|
||||
2. Export it from `packages/db/src/index.ts`.
|
||||
3. Run `pnpm --filter @mosaicstack/db db:push` (dev) or
|
||||
`pnpm --filter @mosaicstack/db db:generate && pnpm --filter @mosaicstack/db db:migrate`
|
||||
(production).
|
||||
3. Generate the offline artifact with `pnpm --filter @mosaicstack/db db:generate`.
|
||||
4. Do not apply it to PostgreSQL until the future KBN-101 activation artifacts and their owned
|
||||
procedure are available. Direct schema push is not a production-like workflow.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,147 +1,98 @@
|
||||
# Migrating to the Federated Tier
|
||||
|
||||
Step-by-step guide to migrate from `local` (PGlite) or `standalone` (PostgreSQL without pgvector) to `federated` (PostgreSQL 17 + pgvector + Valkey).
|
||||
> **KBN-101-07 ownership:** This active documentation is a **non-operative KBN-101
|
||||
> contract** with no current command authority until KBN-101-00, KBN-101-02, KBN-101-03, KBN-101-05, and KBN-101-06 land and
|
||||
> KBN-101-08 activates an exact reviewed release. The commands below describe the produced interface only. Do not run them on the
|
||||
> current branch or replace them with direct PostgreSQL, raw SQL, legacy storage migration, or
|
||||
> credential-on-argv procedures.
|
||||
|
||||
## When to migrate
|
||||
## Held future procedure
|
||||
|
||||
Migrate to federated tier when:
|
||||
This section is non-operative and grants no current command authority until KBN-101-00, KBN-101-03, and KBN-101-05 land.
|
||||
|
||||
- Scaling from single-user to multi-user deployments
|
||||
- Adding vector embeddings or RAG features
|
||||
- Running Mosaic across multiple hosts
|
||||
- Requires distributed task queueing and caching
|
||||
- Moving to production with high availability
|
||||
The deployment control plane executes the complete held future procedure, in order: external bootstrap → TLS/roles → `mosaic-db-migrator --run` → `mosaic-db-migrator --verify` → Gateway/Compose readiness. The
|
||||
runner is the only attestation producer after its verified TLS, identity, manifest, and schema
|
||||
checks. A data importer is never a schema bootstrap, extension installer, repair command, or DDL
|
||||
consumer.
|
||||
|
||||
## Prerequisites
|
||||
## Target material contract
|
||||
|
||||
- Federated stack running and healthy (see [Federated Tier Setup](../federation/SETUP.md))
|
||||
- Source database accessible and empty target database at the federated URL
|
||||
- Backup of source database (recommended before any migration)
|
||||
KBN-101-05 obtains the target URL from Vault KV-v2
|
||||
`secret-{env}/mosaic-stack/database/importer`, key `url`, and reads its authenticated version from
|
||||
the same successful response `data.metadata.version`. A hash or DSN byte sequence is not a
|
||||
provider version. The renderer treats URL bytes and provider version as one generation, writes a
|
||||
temporary generation directory with fsync plus atomic rename, and creates separate immutable
|
||||
consumer mounts. Swarm uses distinct versioned secret/config references. A deployment cannot mix
|
||||
generations.
|
||||
|
||||
## Dry-run first
|
||||
| Consumer | Permitted material |
|
||||
| --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Migrator-attestation producer (`10003:10003`) | Its own migration URL/CA; read-only `/run/secrets/mosaic-migrate-target-url` and `/run/secrets/mosaic-migrate-target-version`, each `0400`, solely to bind; producer-only attestation output at `/run/mosaic-attestations-producer/migrate-target.v1.json`; root-wrapper-only signing key. It never connects with, uses, exports, logs, or forwards the importer URL/version. |
|
||||
| Privileged deployment handoff controller | After runner success and before importer creation, it receives only root-owned non-secret expected provider-version/URL-SHA-256/generation descriptor and pinned public verifier key—not URL bytes or private key. It safe-opens/verifies descriptor and producer artifact, copies exact bytes to a new importer-only mount with fsync/atomic rename, sets `10002:10002` `0400`, seals it read-only, and refuses importer start on any partial/wrong-generation/wrong-owner/mode result. |
|
||||
| Importer (`10002:10002`) | Its own immutable `0400` copies at the same URL/version paths; CA at exact `DATABASE_TLS_CA_CERT_PATH=/run/secrets/mosaic-db-ca.crt`; pinned Ed25519 public key; read-only `/run/mosaic-attestations/migrate-target.v1.json` supplied only by the sealed handoff. |
|
||||
| Gateway/runtime/unrelated container | No importer URL/version, importer artifact, attestation private key, or unrelated CA mount. |
|
||||
|
||||
Always run a dry-run to validate the migration:
|
||||
The migrator and importer safe-open URL, provider-version, attestation, and public-key files only
|
||||
with `O_RDONLY|O_CLOEXEC|O_NOFOLLOW`; they validate from the opened fd that the file is regular,
|
||||
has its expected owner/mode and link count one. The migrator digests only that URL fd for binding,
|
||||
then zeroizes/closes it. The importer reads URL bytes once into protected memory, validates the
|
||||
signed binding and exact CA before connecting from those same bytes, then zeroizes/closes every
|
||||
fd. It neither logs nor exposes a URL/version/attestation/key oracle.
|
||||
|
||||
## Produced command interface
|
||||
|
||||
After activation and only after approved target preparation, the future interface is:
|
||||
|
||||
```bash
|
||||
# Deployment control plane has already completed the held runner procedure above.
|
||||
mosaic storage migrate-tier --to federated \
|
||||
--target-url postgresql://mosaic:mosaic@localhost:5433/mosaic \
|
||||
--target-url-file /run/secrets/mosaic-migrate-target-url \
|
||||
--target-attestation-file /run/mosaic-attestations/migrate-target.v1.json \
|
||||
--dry-run
|
||||
```
|
||||
|
||||
Expected output (partial example):
|
||||
The provider-version file is fixed deployment material, not argv. This connecting dry-run consumes its nonce; before an actual copy, the deployment control plane must provide fresh runner verification and a new sealed handoff. The runner uses its migration
|
||||
identity; the importer connects only as non-DDL `mosaic_data_importer` and only after all
|
||||
pre-connect validation. After verified TLS and before DML it compares PostgreSQL system ID,
|
||||
database OID, `current_user`, CA/SPKI, and manifest/schema fingerprints to the artifact.
|
||||
|
||||
```
|
||||
[migrate-tier] Analyzing source tier: pglite
|
||||
[migrate-tier] Analyzing target tier: federated
|
||||
[migrate-tier] Precondition: target is empty ✓
|
||||
users: 5 rows
|
||||
teams: 2 rows
|
||||
conversations: 12 rows
|
||||
messages: 187 rows
|
||||
... (all tables listed)
|
||||
[migrate-tier] NOTE: Source tier has no pgvector support. insights.embedding will be NULL on all migrated rows.
|
||||
[migrate-tier] DRY-RUN COMPLETE (no data written). 206 total rows would be migrated.
|
||||
```
|
||||
## Required refusals and evidence
|
||||
|
||||
Review the output. If it shows an error (e.g., target not empty), address it before proceeding.
|
||||
KBN-101-02/-03/-05/-06 must prove, with stable sanitized errors, that no target connection occurs
|
||||
for missing/unsafe URL/version/attestation/public-key files; symlink, hardlink, owner, mode, or
|
||||
TOCTOU violations; mixed URL/version generations; missing/wrong CA mount; stale/replayed/tampered
|
||||
or revoked-key artifacts; provider rotation/revocation; wrong TLS/server/database/role/manifest
|
||||
binding; raw `--target-url`; `DATABASE_URL` fallback; runtime/owner identity; consumer leakage;
|
||||
or any DDL attempt. Post-connect identity mismatch closes with zero DML/DDL. Tests also prove no
|
||||
forwarding, child environment, logging, or error oracle leaks URL/version/key/artifact contents.
|
||||
|
||||
## Run the migration
|
||||
The attestation is credential-free JCS with detached Ed25519 signature and binds issued/expiry,
|
||||
nonce, authenticated provider version, exact URL-fd SHA-256, TLS host/port/database, CA/SPKI,
|
||||
PostgreSQL system ID/database OID, importer role, manifest/schema, and producer identity. Provider
|
||||
version rotation invalidates an old artifact and requires a fresh rendered generation plus runner
|
||||
verification.
|
||||
|
||||
When ready, run without `--dry-run`:
|
||||
## Actual copy after dry-run
|
||||
|
||||
After reviewed dry-run, obtain the required fresh verification/attestation generation, then use:
|
||||
|
||||
```bash
|
||||
# Deployment control plane has supplied fresh runner verification and attestation.
|
||||
mosaic storage migrate-tier --to federated \
|
||||
--target-url postgresql://mosaic:mosaic@localhost:5433/mosaic \
|
||||
--target-url-file /run/secrets/mosaic-migrate-target-url \
|
||||
--target-attestation-file /run/mosaic-attestations/migrate-target.v1.json \
|
||||
--yes
|
||||
```
|
||||
|
||||
The `--yes` flag skips the confirmation prompt (required in non-TTY environments like CI).
|
||||
The dry-run artifact is terminally replayed and must be rejected; `--yes` bypasses no file,
|
||||
generation, signature, TLS, identity, or DDL control.
|
||||
|
||||
The command will:
|
||||
## Data boundary and recovery
|
||||
|
||||
1. Acquire an advisory lock (blocks concurrent invocations)
|
||||
2. Copy data from source to target in dependency order
|
||||
3. Report rows migrated per table
|
||||
4. Display any warnings (e.g., null vector embeddings)
|
||||
The importer has only an allowlisted mutable-table DML registry. It has no grant for immutable KBN
|
||||
relations, schemas, roles, memberships, extensions, catalogs, or the Drizzle ledger. Source PGlite
|
||||
uses its explicit local directory and does not make a PostgreSQL URL fallback valid.
|
||||
|
||||
## What gets migrated
|
||||
|
||||
All persistent, user-bound data is migrated in dependency order:
|
||||
|
||||
- **users, teams, team_members** — user and team ownership
|
||||
- **accounts** — OAuth provider tokens (durable credentials)
|
||||
- **projects, agents, missions, tasks** — all project and agent definitions
|
||||
- **conversations, messages** — all chat history
|
||||
- **preferences, insights, agent_logs** — preferences and observability
|
||||
- **provider_credentials** — stored API keys and secrets
|
||||
- **tickets, events, skills, routing_rules, appreciations** — auxiliary records
|
||||
|
||||
Full order is defined in code (`MIGRATION_ORDER` in `packages/storage/src/migrate-tier.ts`).
|
||||
|
||||
## What gets skipped and why
|
||||
|
||||
Three tables are intentionally not migrated:
|
||||
|
||||
| Table | Reason |
|
||||
| ----------------- | ----------------------------------------------------------------------------------------------- |
|
||||
| **sessions** | TTL'd auth sessions from the old environment; they will fail JWT verification on the new target |
|
||||
| **verifications** | One-time tokens (email verify, password reset) that have either expired or been consumed |
|
||||
| **admin_tokens** | Hashed tokens bound to the old environment's secret keys; must be re-issued |
|
||||
|
||||
**Note on accounts and provider_credentials:** These durable credentials ARE migrated because they are user-bound and required for resuming agent work on the target environment. After migration to a multi-tenant federated deployment, operators may want to audit or wipe these if users are untrusted or credentials should not be shared.
|
||||
|
||||
## Idempotency and concurrency
|
||||
|
||||
The migration is **idempotent**:
|
||||
|
||||
- Re-running is safe (uses `ON CONFLICT DO UPDATE` internally)
|
||||
- Ideal for retries on transient failures
|
||||
- Concurrent invocations are blocked by a Postgres advisory lock; the second caller will wait
|
||||
|
||||
If a previous run is stuck, check for advisory locks:
|
||||
|
||||
```sql
|
||||
SELECT * FROM pg_locks WHERE locktype='advisory';
|
||||
```
|
||||
|
||||
If you need to force-unlock (dangerous):
|
||||
|
||||
```sql
|
||||
SELECT pg_advisory_unlock(<lock_id>);
|
||||
```
|
||||
|
||||
## Verify the migration
|
||||
|
||||
After migration completes, spot-check the target:
|
||||
|
||||
```bash
|
||||
# Count rows on a few critical tables
|
||||
psql postgresql://mosaic:mosaic@localhost:5433/mosaic -c \
|
||||
"SELECT 'users' as table, COUNT(*) FROM users UNION ALL
|
||||
SELECT 'conversations' as table, COUNT(*) FROM conversations UNION ALL
|
||||
SELECT 'messages' as table, COUNT(*) FROM messages;"
|
||||
```
|
||||
|
||||
Verify a known user or project exists by ID:
|
||||
|
||||
```bash
|
||||
psql postgresql://mosaic:mosaic@localhost:5433/mosaic -c \
|
||||
"SELECT id, email FROM users WHERE email='<your-email>';"
|
||||
```
|
||||
|
||||
Ensure vector embeddings are NULL (if source was PGlite) or populated (if source was postgres + pgvector):
|
||||
|
||||
```bash
|
||||
psql postgresql://mosaic:mosaic@localhost:5433/mosaic -c \
|
||||
"SELECT embedding IS NOT NULL as has_vector FROM insights LIMIT 5;"
|
||||
```
|
||||
|
||||
## Rollback
|
||||
|
||||
There is no in-place rollback. If the migration fails:
|
||||
|
||||
1. Restore the target database from a pre-migration backup
|
||||
2. Investigate the failure logs
|
||||
3. Rerun the migration
|
||||
|
||||
Always test migrations in a staging environment first.
|
||||
A failed or ambiguous migration is a control-plane incident: preserve sanitized evidence, retain
|
||||
the approved backup/rollback state, and retry only after independent review. Never inspect,
|
||||
unlock, repair, or initialize the target with ad hoc SQL or copied credentials.
|
||||
|
||||
@@ -522,8 +522,14 @@ mosaic storage export --bucket agent-artifacts --output ./artifacts.tar.gz
|
||||
# Import data into storage
|
||||
mosaic storage import --bucket agent-artifacts --input ./artifacts.tar.gz
|
||||
|
||||
# Migrate data between tiers
|
||||
mosaic storage migrate --from hot --to cold --older-than 30d
|
||||
# Schema migration is unavailable in this release. The current storage wrapper shells
|
||||
# directly to `pnpm --filter @mosaicstack/db db:migrate`; it is legacy N-1,
|
||||
# uncertified, and MUST NOT be invoked pending KBN-101-02/-03/-06/-08 activation.
|
||||
# Future schema migration is non-operative: external bootstrap → TLS/roles → runner
|
||||
# --run → runner --verify → readiness.
|
||||
|
||||
# Tier copy uses only the separately held secure migrate-tier route. Never use a legacy
|
||||
# --from/--to storage-migrate command or pass a credential on argv.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -1,26 +1,28 @@
|
||||
# Native Kanban/SOT Canon
|
||||
|
||||
**Status:** KCR-001–016 independently cleared; canonical publication is in progress under issue [#751](https://git.mosaicstack.dev/mosaicstack/stack/issues/751)
|
||||
**Status:** KCR-001–016 independently cleared; KBN-101 rc.16 current generic storage-wrapper authority remediation awaits independent exact-head re-review under issue [#771](https://git.mosaicstack.dev/mosaicstack/stack/issues/771)
|
||||
**Date:** 2026-07-14
|
||||
**Implementation hold:** no feature implementation starts until this canon is squash-merged to `main` with terminal-green CI; after merge, every slice remains held until its KBN prerequisite graph is satisfied.
|
||||
|
||||
## Artifacts
|
||||
|
||||
| Artifact | Purpose |
|
||||
| ---------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| [Canonical requirements](../requirements/native-kanban-sot.md) | Canonical P0–P3 requirements, all seven ratified decisions, fixed invariants, thin MVP, recovery tiers, non-goals, and per-requirement acceptance criteria |
|
||||
| [`MISSION-MANIFEST.md`](./MISSION-MANIFEST.md) | Mission/authority boundaries, exact role chain, gate model, mandatory SecReview triggers, Certifier final/no-merge rule, and collision-free slice ownership |
|
||||
| [`TASKS.md`](./TASKS.md) | Dependency-ordered, bounded P0–P3 slices with IN/OUT scope, dependencies, shared contracts, file ownership, evidence, and USC coder2/3/4/5 parallelization |
|
||||
| [`SHARED-CONTRACT.md`](./SHARED-CONTRACT.md) | Remediated v1 integration contract: proof authority, exact failures/routes/DTOs/MCP ownership, concrete current-main field migration map, relational invariants, Coordinator split, recovery delivery |
|
||||
| [`contracts/kanban-schema.v1.ts`](./contracts/kanban-schema.v1.ts) | Drizzle target declarations including exact owner/principal membership, project congruence, tags/archive, proposals, persisted assignments, monotonic fences, durable retry, immutable evidence/audit |
|
||||
| [`contracts/mechanical-coordinator.v1.ts`](./contracts/mechanical-coordinator.v1.ts) | Pure snapshot decision engine separated from persistence/service adapter; ID-bound approvals, bigint-safe fences, durable retry/quarantine, artifact-backed checkpoints, exact failures |
|
||||
| [`contracts/health-state.v1.ts`](./contracts/health-state.v1.ts) | Discriminated public health, separate branded transaction-local write proof, and non-overlapping denial/transport/version-conflict mappings |
|
||||
| [`contracts/recovery-posture.v1.ts`](./contracts/recovery-posture.v1.ts) | Provider-neutral shape schema plus normative runtime refinement, cross-field constraints, and Lite/Standard/High-assurance defaults |
|
||||
| [`tsconfig.json`](./tsconfig.json) | Strict no-emit project scope for linting and compiling the four frozen TypeScript contracts against the current Stack Drizzle declarations |
|
||||
| [`DOCUMENTATION-CHECKLIST.md`](./DOCUMENTATION-CHECKLIST.md) | Publication documentation gate and implementation-slice deferrals |
|
||||
| [Initial independent review](../reports/native-kanban-sot/canon-initial-review-no-go.md) | KCR-001–016 findings that blocked the first draft |
|
||||
| [Final independent re-review](../reports/native-kanban-sot/canon-final-rereview-go.md) | Closure matrix, reproducible validation evidence, and GO verdict |
|
||||
| [Ultron final gate](../reports/native-kanban-sot/ultron-final-go.md) | Final requirements, authority, schema, migration, recovery, decomposition, and evidence review GO |
|
||||
| Artifact | Purpose |
|
||||
| -------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| [Canonical requirements](../requirements/native-kanban-sot.md) | Canonical P0–P3 requirements, all seven ratified decisions, fixed invariants, thin MVP, recovery tiers, non-goals, and per-requirement acceptance criteria |
|
||||
| [`MISSION-MANIFEST.md`](./MISSION-MANIFEST.md) | Mission/authority boundaries, exact role chain, gate model, mandatory SecReview triggers, Certifier final/no-merge rule, and collision-free slice ownership |
|
||||
| [`TASKS.md`](./TASKS.md) | Dependency-ordered, bounded P0–P3 slices with IN/OUT scope, dependencies, shared contracts, file ownership, evidence, and USC coder2/3/4/5 parallelization |
|
||||
| [`KBN-101-DB-ROLE-SPLIT.md`](./KBN-101-DB-ROLE-SPLIT.md) | rc.16 direct-Drizzle current storage-wrapper hold: legacy N-1/uncertified/non-operative pending -02/-03/-06/-08; exact README commented/user-guide executable forms fail before masking and source-consistency rejects runner-delegation copy; held future bootstrap → TLS/roles → run → verify → readiness; plus prior production boundary, pgvector owner, attestation, inventory, manifests, DDL classifier, TLS/bootstrap, activation, and certification contract; foundation prerequisite of KBN-100 and real-role gate before KBN-105 |
|
||||
| [`SHARED-CONTRACT.md`](./SHARED-CONTRACT.md) | Remediated v1 integration contract: proof authority, exact failures/routes/DTOs/MCP ownership, concrete current-main field migration map, relational invariants, Coordinator split, recovery delivery |
|
||||
| [`contracts/kanban-schema.v1.ts`](./contracts/kanban-schema.v1.ts) | Drizzle target declarations including exact owner/principal membership, project congruence, tags/archive, proposals, persisted assignments, monotonic fences, durable retry, immutable evidence/audit |
|
||||
| [`contracts/mechanical-coordinator.v1.ts`](./contracts/mechanical-coordinator.v1.ts) | Pure snapshot decision engine separated from persistence/service adapter; ID-bound approvals, bigint-safe fences, durable retry/quarantine, artifact-backed checkpoints, exact failures |
|
||||
| [`contracts/health-state.v1.ts`](./contracts/health-state.v1.ts) | Discriminated public health, separate branded transaction-local write proof, and non-overlapping denial/transport/version-conflict mappings |
|
||||
| [`contracts/recovery-posture.v1.ts`](./contracts/recovery-posture.v1.ts) | Provider-neutral shape schema plus normative runtime refinement, cross-field constraints, and Lite/Standard/High-assurance defaults |
|
||||
| [`tsconfig.json`](./tsconfig.json) | Strict no-emit project scope for linting and compiling the four frozen TypeScript contracts against the current Stack Drizzle declarations |
|
||||
| [`DOCUMENTATION-CHECKLIST.md`](./DOCUMENTATION-CHECKLIST.md) | Publication documentation gate and implementation-slice deferrals |
|
||||
| [KBN-101 exact-head security review](../reports/native-kanban-sot/kbn-101-contract-security-review-82ce325.md) | Historical `da742ca` REQUEST CHANGES report retained as prior closure evidence; rc.16 awaits independent exact-head re-review after closing the current generic storage-wrapper authority HIGH finding |
|
||||
| [Initial independent review](../reports/native-kanban-sot/canon-initial-review-no-go.md) | KCR-001–016 findings that blocked the first draft |
|
||||
| [Final independent re-review](../reports/native-kanban-sot/canon-final-rereview-go.md) | Closure matrix, reproducible validation evidence, and GO verdict |
|
||||
| [Ultron final gate](../reports/native-kanban-sot/ultron-final-go.md) | Final requirements, authority, schema, migration, recovery, decomposition, and evidence review GO |
|
||||
|
||||
## Recommended USC lane partition
|
||||
|
||||
@@ -32,7 +34,7 @@
|
||||
| **coder5** | Web | Tasks/Projects Kanban/List/detail and later Coordinator/migration-review UI |
|
||||
| **Mos** | Serialized integration | Canon publication, frozen-contract changes, shared-root/exports, integration gates, merge authority |
|
||||
|
||||
The safe order is KBN-010 → KBN-100 → KBN-105, then coder3 Gateway/MCP server, coder4 CLI/projection, coder5 web, and coder2 recovery can proceed on disjoint files. coder4 then runs pure Coordinator → importer → cutover tooling serially. No two active slices edit the same files.
|
||||
The safe order is KBN-010 → KBN-101 foundation → KBN-100 → KBN-101 deployed-role immutable-operation certificate → KBN-105, then coder3 Gateway/MCP server, coder4 CLI/projection, coder5 web, and coder2 recovery can proceed on disjoint files. KBN-100 is blocked on the KBN-101 foundation; real deployed-role certification—not synthetic test roles—is required before KBN-105. coder4 then runs pure Coordinator → importer → cutover tooling serially. No two active slices edit the same files.
|
||||
|
||||
## Recovery defaults
|
||||
|
||||
|
||||
266
docs/native-kanban-sot/KBN-101-DB-ROLE-SPLIT.md
Normal file
266
docs/native-kanban-sot/KBN-101-DB-ROLE-SPLIT.md
Normal file
File diff suppressed because one or more lines are too long
@@ -1,14 +1,95 @@
|
||||
# Native Kanban/SOT — Remediated Shared Contract v1
|
||||
|
||||
**Status:** CONTROL-PLANE SI-001 AMENDMENT AUTHORIZED; prior KCR-001–016 independent-review GO retained; rc.4 requires independent schema/SecReview before KBN-100
|
||||
**Version:** 1.0.0-rc.4
|
||||
**Date:** 2026-07-14
|
||||
**Status:** CONTROL-PLANE rc.16 KBN-101 current generic storage-wrapper authority remediation complete; awaiting independent exact-head re-review. Prior KCR-001–016 and rc.4 SI-001 decisions retained; KBN-101 foundation certification precedes KBN-100 and real immutable-operation certification precedes KBN-105
|
||||
**Version:** 1.0.0-rc.16
|
||||
**Date:** 2026-07-15
|
||||
**Change authority:** Mosaic control plane/Jason only
|
||||
**SI-001 amendment authority:** `web1:mosaic-100` control-plane decision under issue #753
|
||||
|
||||
## Amendment record
|
||||
|
||||
### 1.0.0-rc.4 — KBN010-SI-001
|
||||
### 1.0.0-rc.16 — Current generic storage-wrapper authority closure
|
||||
|
||||
- **Current-source truth:** `packages/storage/src/cli.ts` currently shells `storage migrate --run` directly to `pnpm --filter @mosaicstack/db db:migrate` through `execSync`; no `mosaic-db-migrator` executable exists. README and user-guide command guidance therefore remove that command and any runner-delegation claim. The current wrapper is legacy N-1, uncertified, non-operative, and MUST NOT be invoked pending KBN-101-02/-03/-06/-08 activation.
|
||||
- **Future-only boundary:** future schema migration remains non-operative and follows external bootstrap → TLS/roles → runner `--run` → runner `--verify` → readiness; tier copy uses only the separately held secure migrate-tier route.
|
||||
- **Unmaskable semantic/source-consistency evidence:** before inventory, ownership, or status masking, -06 fails the exact former README commented code-fence generic-wrapper form and exact user-guide executable generic-wrapper form. Its source-consistency test proves the direct-Drizzle `execSync` target and absent runner bin, so any documentation describing current wrapper delegation to the runner fails.
|
||||
- **Non-effect:** prior runner, legacy-CI, Compose, production-secret, attestation, pgvector, manifest, lock, TLS, activation, and serial-gate closures remain unchanged.
|
||||
|
||||
### 1.0.0-rc.15 — Held runner and legacy-CI authority closure
|
||||
|
||||
- **Held runner only:** Current operator documents cannot advertise `mosaic-db-migrator --run|--verify` as executable. The sole passing future form is one `Held future procedure` Markdown section, bounded through its next equal-or-higher heading, that explicitly says non-operative/no-current-command-authority, names KBN-101-00/-03/-05, and preserves external bootstrap → TLS/roles → `mosaic-db-migrator --run` → `mosaic-db-migrator --verify` → Gateway/Compose readiness. Any runner hit outside that section fails before inventory/ownership/status masking.
|
||||
- **PGlite/current-CI boundary:** Fleet backlog current behavior is PGlite-only; PostgreSQL CLI/runner authority remains held until activation. README classifies the checked-in direct `db:migrate` CI job as active legacy N-1, uncertified, non-authorizing as an operator route, and pending KBN-101-06 removal; it is a known direct-DDL exception against an isolated disposable CI database, not approved ordinary behavior. The -06 fixture asserts every required status term and rejects ordinary-authority presentation.
|
||||
- **Non-effect:** prior Compose, production-secret, attestation, pgvector, manifest, lock, TLS, activation, and serial-gate closures remain unchanged.
|
||||
|
||||
### 1.0.0-rc.14 — Current Compose and production-secret route closure
|
||||
|
||||
- **Current developer boundary:** `README.md` and `docs/guides/dev-guide.md` permit only in-process PGlite data-layer work and explicitly selected non-PostgreSQL Compose services. Gateway/Web local start is held because the current unguarded loader can inherit a daemon/project PostgreSQL DSN and reach runtime DDL; KBN-101-02 must reject it before connection. The current PostgreSQL Compose mount is legacy/unqualified; PostgreSQL and federated activation are held until KBN-101-00/-03/-05 and then follow external bootstrap → TLS/roles → runner `--run` → `--verify` → Gateway/Compose readiness.
|
||||
- **Production boundary:** `docs/guides/deployment.md` is non-operative until the KBN-101-05 renderer-backed process-exec or `LoadCredential` interface exists. It contains no active production environment-file, monorepo auto-load, credential export/argv, or secret-activation lifecycle route; future units must preserve generation-pinned Vault consumer isolation.
|
||||
- **Unmaskable semantic negatives:** -06 fails the exact former README/dev/deployment Compose-first sequences and every production `.env`, `EnvironmentFile=`, credential export/argv, or restart-as-secret-activation fixture before owned/status/normative classification. The held PGlite/non-PostgreSQL route and future ordered activation are the only passing fixtures.
|
||||
|
||||
### 1.0.0-rc.13 — Federation-MILESTONES indirect-startup closure
|
||||
|
||||
- **Complete operator inventory:** `docs/federation/MILESTONES.md` is exclusively KBN-101-07 and an exact KBN-101-06 `operator-document` `status-only` record. Its former `pgvector extension installed + verified on startup` wording is superseded and forbidden; it authorizes no current DDL, Compose/init, or runtime/startup path.
|
||||
- **Unmaskable semantic negative:** before inventory disposition, the scanner fixture proves that exact former wording fails. The only passing status-only sequence is external bootstrap → TLS/roles → `mosaic-db-migrator --run` → `mosaic-db-migrator --verify` → Gateway readiness.
|
||||
|
||||
### 1.0.0-rc.12 — Deployable importer generation and indirect-DDL-route closure
|
||||
|
||||
- **Authenticated generation:** KBN-101-05 owns one canonical Vault KV-v2 importer record, `secret-{env}/mosaic-stack/database/importer` key `url`, with its version taken only from the same successful `data.metadata.version` response. Value plus provider version are one generation, never inferred from DSN bytes. The renderer creates separate immutable `0400` URL/version copies for migrator `10003:10003` binding-only access and importer `10002:10002` access; it uses fsync/atomic generation replacement for Compose and distinct versioned secret/config references for Swarm, so deployment cannot mix generations.
|
||||
- **Bounded consumers:** importer alone receives its URL/version, CA at `DATABASE_TLS_CA_CERT_PATH`, pinned public key, and read-only attestation; migrator receives its own migration URL/CA, the URL/version only for no-connect/no-export binding, attestation output, and the root-wrapper-only private key. Safe fd open/fstat/digest/zeroize/close semantics, a privileged producer-only-to-importer-only attestation handoff controller (verify, exact-byte copy, fsync/atomic rename, `10002:10002` `0400` seal, then importer start), no shared writable file, no logging/oracle, provider rotation/revocation, CA/mount, consumer-isolation, and symlink/hardlink/owner/mode/TOCTOU negatives are mandatory.
|
||||
- **Indirect-DDL closure:** `docs/federation/SETUP.md` is non-operative until KBN-101 activation and documents only external bootstrap → TLS/roles → runner `--run` → `--verify` → Gateway readiness. The -06 scanner performs unsuppressible semantic checks for automatic first-boot/startup extension/schema/migration language, Compose-up-before-runner, and init-script authority; the former SETUP wording fails and the remediated sequence passes.
|
||||
|
||||
### 1.0.0-rc.11 — Target-bound importer attestation and exhaustive operator-route closure
|
||||
|
||||
- **Target-bound proof:** trusted `mosaic-db-migrator --verify` now produces the atomic, credential-free `migrate-target.v1.json` JCS/Ed25519 artifact from a runner-only root-owned signing-key reference; the importer receives only pinned public verification keys and the artifact. Its signed v1 fields bind issued/expiry/nonce, exact secret version and SHA-256 of high-entropy target-file bytes, canonical TLS host/port/database, CA/SPKI, PostgreSQL system identifier/database OID, expected importer role, manifest/schema fingerprints, and producer invocation/build/image/correlation. No DSN, username, password, credential bytes, or signing key enters the artifact, importer, runtime, logs, or output.
|
||||
- **Fail-closed importer:** `mosaic storage migrate-tier` requires both `--target-url-file /run/secrets/mosaic-migrate-target-url` and `--target-attestation-file /run/mosaic-attestations/migrate-target.v1.json`. Before target connection it validates files, signature/key/expiry/replay, secret version/digest, TLS/CA/role/manifest bindings and opens/digests/connects from the same in-memory URL bytes. After verified TLS but before transaction/DML it matches server ID, database OID, `current_user`, CA/SPKI, and manifest/schema; failure distinguishes zero connection from connection/zero-DML and DDL remains impossible. Rotation overlap/revocation, atomic rename, replay cache, secret rotation invalidation, and wrong/substituted/stale/tampered/file-change tests are mandatory.
|
||||
- **Closed documentation surface:** KBN-101-06 inventories every current non-normative scanner hit, including `docs/guides/user-guide.md` and status-only `docs/federation/TASKS.md`; the latter is historical and cannot authorize DDL. The legacy `storage migrate` tier-copy syntax is unavailable. `storage migrate` is schema-wrapper delegation only; secure tier data copy is `migrate-tier`. Exact KBN PRD/contract/shared/task paths may be `normative-contract` scan class but are still scanned and cannot mask executable instructions. The normative detail remains [`KBN-101-DB-ROLE-SPLIT.md`](./KBN-101-DB-ROLE-SPLIT.md).
|
||||
- **Non-effect:** pgvector closure, manifest, lock, role graph, TLS, activation, and KBN-100/KBN-105 serial gates are unchanged.
|
||||
|
||||
### 1.0.0-rc.10 — PostgreSQL-valid untrusted pgvector owner and active migrate-tier closure
|
||||
|
||||
- **Valid extension authority:** PostgreSQL 17 + pgvector 0.8.2 `vector` is untrusted (`trusted` absent; `relocatable=true`), so `mosaic_extension_owner` is exactly `NOLOGIN SUPERUSER`, not `NOSUPERUSER`. It is dedicated solely to `mosaic_extensions`, `vector`, and owner-bearing extension members; `rolcanlogin=false`, `rolsuper=true`, zero members, no runtime credential/Vault secret, and no app-container delivery are catalog and deployment proof. An externally controlled audited bootstrap-superuser session alone `SET ROLE`s for extension CREATE/UPDATE/SET SCHEMA, then `RESET ROLE`; fresh and shadow paths do so, while in-place existing work requires exact pre-existing `extowner`.
|
||||
- **Explicit superuser exception:** `GRANT`/`REVOKE` cannot privilege-limit a superuser. The containment is dedicated identity, no login, no membership, external control plane, audit, independent review, backup/rollback, and maintenance window—not a false least-privilege claim. Runtime, migrator, schema owner, importer, and every service role cannot assume the role or alter/update/drop/change extension membership. Managed targets without this exact role are ineligible unless a versioned provider-owned extension-owner profile is independently approved.
|
||||
- **Active secure data-migration route:** `docs/guides/migrate-tier.md` is exclusively KBN-101-07, is active rather than historical, and specifies runner-prepared/verified PostgreSQL destination plus a dedicated non-DDL importer. KBN-101-02 freezes `--target-url-file /run/secrets/mosaic-migrate-target-url`, never credential argv; raw `--target-url`, `DATABASE_URL` fallback, runtime owner, missing/unsafe file, wrong mode, and DDL all fail before target connection/DDL. KBN-101-06 inventory/matrix records the route and exact secure fields, then tests its finite operator-document closure.
|
||||
- **Non-effect:** manifest, lock, `mosaic` application schema, TLS, activation, and KBN-100/KBN-105 serial gates remain unchanged. The normative detail remains [`KBN-101-DB-ROLE-SPLIT.md`](./KBN-101-DB-ROLE-SPLIT.md).
|
||||
|
||||
### 1.0.0-rc.9 — KBN-101 extension-schema boundary, disjoint manifests, and scanner mechanics
|
||||
|
||||
- **Extension schema owner:** `mosaic_extension_owner`, not `mosaic_schema_owner`, creates and owns `mosaic_extensions`, `vector`, and extension-member objects. The external bootstrap actor `SET ROLE`s for fresh creation or approved-owner relocation, then `RESET ROLE`s; rc.10 replaces the earlier membership wording with the PostgreSQL-valid zero-member superuser exception. Schema owner has only `USAGE` for legacy type resolution—never ownership, `CREATE`, `ALTER`, `DROP`, member change, or default-privilege authority. Runtime, migrator, and schema owner must fail catalog and direct DDL denials; shadow/resume/rollback repeat the owner/default-privilege proof.
|
||||
- **Exclusive delivery DAG:** KBN-101-00…09 now has a complete, nonoverlapping exact file/glob manifest with named tests/evidence. The runner mapping is exactly `"mosaic-db-migrator": "./dist/cli.js"` and image `ENTRYPOINT ["mosaic-db-migrator"]`; `packages/storage/src/{cli,migrate-tier}.ts` belongs only to -02, and -07 is documentation only. -08/-09 own evidence paths only. -00…07 are prepared artifacts; the immutable N-1 image remains live until -08 atomic activation, so no independently deployed intermediate can bypass runtime controls.
|
||||
- **Mechanical classifier:** -06 owns the exact scanner, inventory fixture, command-matrix harness, and CI wiring. Inventory records pin path/class/owner/disposition/allowed tokens/rationale/expiry/review revision; unknown, duplicate-owner, ownerless, missing-path, invalid allowlist, and historical-category masking fail. The architecture plan's operative direct `db:migrate` is replaced by sole-runner guidance rather than hidden under a historical category.
|
||||
- **Non-effect:** manifest v1, lock, `mosaic` application-schema ownership, TLS, activation, KBN-100/KBN-105 serial gates, and all earlier canon decisions remain unchanged. The normative detail remains [`KBN-101-DB-ROLE-SPLIT.md`](./KBN-101-DB-ROLE-SPLIT.md).
|
||||
|
||||
### 1.0.0-rc.8 — KBN-101 finite authority, executable runner, and pgvector-owner remediation
|
||||
|
||||
- **Finite authority closure:** KBN-101-06 classifies every current executable source/script/package bin, operator document, and deploy manifest by exact path; unclassified current hits fail. Byte-immutable historical SQL, PGlite-only routines, negative-test literals, vendored/generated artifacts, and clearly labeled historical reports are exact-path/category reviewed allowlists only. `packages/db/src/index.ts` loses its public `runMigrations` export with a direct-import/compile negative; `docs/fleet/backlog-conventions.md` and `docs/PERFORMANCE.md` lose first-use/direct-Drizzle/Gateway-startup migration instructions and carry runner/readiness route negatives. A token scan is only input to the classifier, never proof of authority.
|
||||
- **Executable exclusive cards:** KBN-101-03 alone publishes `mosaic-db-migrator` from `packages/db/package.json`/`src/cli.ts`, owns `docker/db-migrator.Dockerfile`, and keeps `{runner,config.dto,manifest,identity,tls}` private, with exact `--run|--verify|--help`, env-only input, stable exits, and command tests. KBN-101-00 alone owns `infra/pg-bootstrap/roles.sql`, `infra/pg-bootstrap/extensions.sql`, `infra/pg-bootstrap/README.md`, plus bootstrap tests. KBN-101-05 alone owns `tools/db/render-postgres-secrets.ts`, renderer tests, and Compose/Portainer/Swarm/two-gateway declarations, consuming the versioned bootstrap interface. No card overlaps renderer/bootstrap/deployment ownership.
|
||||
- **Extension-owner transition:** `mosaic_extension_owner` is a dedicated NOLOGIN role whose membership/credentials never reach services; the external bootstrap actor alone may `SET ROLE` during bootstrap. Fresh vector and member objects retain that owner. PostgreSQL has no supported extension-owner alteration: approved-owner existing extension relocation validates `pg_extension.extowner`, members/schema/version and uses tested `ALTER EXTENSION ... SET SCHEMA`; legacy runtime-owned extension fails closed to a controlled shadow database migration with backup, evidence, quiesce/final delta, atomic switch, and read-only rollback window. No catalog mutation, ownership adoption, or `DROP CASCADE` is permitted. Runtime/migrator/schema-owner extension ALTER/DROP/member-update denial is mandatory.
|
||||
- **Non-effect:** manifest v1, lock namespace, role/search-path, relocation/TLS/activation, KBN-100/KBN-105 serial gates, and all retained canon decisions are strengthened, not weakened. The normative detail remains [`KBN-101-DB-ROLE-SPLIT.md`](./KBN-101-DB-ROLE-SPLIT.md).
|
||||
|
||||
### 1.0.0-rc.7 — KBN-101 complete current-path, relocation, and two-gateway remediation
|
||||
|
||||
- **Finite current-path closure:** static inventory and the `DATABASE_URL`-only-before-connect/DDL denial matrix now explicitly include Gateway's former temporary-table pgvector test (runner-prepared persistent read/query-only fixture), `docker/init-db.sql` retirement, `migrate-tier.ts` runner/bootstrap-only guidance, and the active two-gateway harness. The harness is migrated, not retired: `postgres-a/b → mosaic-db-migrator-a/b → gateway-a/b`, each with isolated URL/CA material, verified readiness, SANs, and positive/negative TLS evidence.
|
||||
- **Executable relocation:** KBN-101-03 exclusively owns `schema.ts`, Drizzle snapshots/journal/generated relocation and exact tests. All future application declarations use exported `pgSchema('mosaic')`; immutable historical SQL runs only in trusted legacy `public`. `vector` is fixed in non-writable `mosaic_extensions`, with exact catalog relocatability/version eligibility, explicit type/operator qualification, catalog-class ordering, unknown-object fail-closed behavior, clean/current-public/partial/reverse rollback tests, and an N-1 release order.
|
||||
- **Bound deployment ownership:** `mosaicstack/stack` KBN-101-00/05 owns current Compose, Portainer, two-gateway, bootstrap renderer/templates, UID/GID declarations, and rendered validation. Gateway is fixed to `10001:10001`; PostgreSQL UID/GID is image-inspected and frozen only after digest pinning. Exact secret paths, atomic renderer behavior, Compose/Swarm targets/modes, Gateway/PostgreSQL leaf separation, and two-pair TLS failure evidence are required. Mosaic deployment control plane/Jason is the named activation authority; environment IaC/Vault supplies versioned input only.
|
||||
- **Correct traceability:** REQ-03 maps to role/schema/search-path, REQ-04 to TLS, REQ-05 to post-KBN-100 immutability, REQ-06 to rollout/rollback, and REQ-07 to the KBN-101 → KBN-100 → KBN-101 → KBN-105 sequence. No prior manifest/lock/role/DAG/activation decision is weakened.
|
||||
|
||||
### 1.0.0-rc.6 — KBN-101 closed DDL/TLS/ledger activation remediation
|
||||
|
||||
- **Choice:** `mosaic-db-migrator` is the sole application/CI/test PostgreSQL DDL control plane. Every legacy entrypoint is routed or denied, rejects `DATABASE_URL`-only before connection/DDL, and `db:push` is unavailable outside an allowlisted disposable developer target. The runner holds one `max:1` session with fixed `pg_try_advisory_lock(1297044289,1262636593)` across preflight through release.
|
||||
- **Exact ledger:** manifest v1 canonically serializes journal logical index/tag and SHA-256 of exact shipped migration bytes. It maps each observed ledger hash to one tuple; physical insertion order is non-normative, while missing/unknown/duplicate/ambiguous/corrupt/stale states fail closed. Shipped `0009` bytes remain unchanged; a missing/effects-absent `0009` runs normally, an applied-late hash maps normally, and partial/full effects with missing hash require backup restoration or separately reviewed repair—not manual adoption.
|
||||
- **TLS/search path:** operator/IaC owns CA and server leaf lifecycle, exact compose/Swarm secret mounts, server TLS activation, service-DNS SANs, verified-TLS readiness, transition, CA overlap rotation, and rollback. Runtime/migrator use `verify-full`; PGlite is not PostgreSQL TLS evidence. Application sessions use only `pg_catalog,mosaic`; no URL/config-derived identifier reaches SQL.
|
||||
- **Safe release:** cards 00–07 land prepared but inactive; owner-runtime deployments remain N-1. Mosaic control plane/Jason alone authorizes one atomic TLS/roles → runner → readiness → runtime activation or rollback. No runtime-operator compatibility switch, bypass, plaintext interval, or force-on-red exists; all temporary support is removed before KBN-101-08.
|
||||
- **Non-effect:** role graph, immutable certification after KBN-100, KBN-105 gate, rc.5’s preserved rc.4 SI-001 invariants, and all KCR-001–016 decisions remain unchanged. Exact detail is normative in [`KBN-101-DB-ROLE-SPLIT.md`](./KBN-101-DB-ROLE-SPLIT.md).
|
||||
|
||||
### 1.0.0-rc.5 — KBN-101 role/connection split
|
||||
|
||||
- **Choice:** PostgreSQL `standalone` and `federated` runtime uses `DATABASE_URL` only as a non-owner `mosaic_runtime` login; an explicit migration phase uses `DATABASE_MIGRATION_URL` only as `mosaic_migrator`, which `SET ROLE`s to non-login `mosaic_schema_owner` for DDL. Local PGlite remains an explicit embedded exception.
|
||||
- **No fallback / no startup DDL:** missing migration URL fails the migration phase; it never falls back to runtime URL/default/config. Gateway replicas do not run migrations. An advisory-locked migration phase verifies the exact ordered Drizzle ledger fingerprint before replicas may become ready.
|
||||
- **Privilege model:** non-login `mosaic_platform_database_owner` is outside application paths; `mosaic_schema_owner` owns only application/ledger schemas. `mosaic_runtime` has only `mosaic_runtime_capability`, owns no object/schema, cannot assume owner/migrator, has no TEMPORARY privilege, has only read access to the Drizzle ledger, and must fail startup if effective identity, unsafe attributes, authenticated TLS, search path, schema version, grants, or immutable relation privileges differ from the frozen contract. `task_events`, `artifacts`, `task_checkpoints`, `task_checkpoint_artifacts`, and `approval_decision_artifacts` grant runtime only INSERT/SELECT; KBN-100 retains RESTRICT/no-cascade semantics.
|
||||
- **Non-effect:** rc.4 SI-001 candidate-key/FK order and all KCR-001–016 tenancy, SOT, proposal-audit, approval, fence, recovery, no-cascade, endpoint, and wire invariants are unchanged. This amendment neither creates roles/secrets nor changes production deployment.
|
||||
- **Gate:** KBN-101’s role/schema-boundary foundation certificate, Vault/redaction/rotation, N-1/rollback, and independent security GO are mandatory before KBN-100. After KBN-100 creates the immutable relations, KBN-101 real deployed-role immutable-operation certification plus Ultron GO is mandatory before KBN-105; synthetic test-role success alone is insufficient. Exact implementation detail is normative in [`KBN-101-DB-ROLE-SPLIT.md`](./KBN-101-DB-ROLE-SPLIT.md).
|
||||
|
||||
### 1.0.0-rc.4 — KBN010-SI-001 (preserved)
|
||||
|
||||
- **Choice:** add the explicitly named, non-partial unique candidate key `missions_workspace_id_uidx` on `missions(workspace_id, id)` and retain `missions_workspace_project_id_uidx` on `(workspace_id, project_id, id)`.
|
||||
- **Rationale:** mission `id` remains globally unique, while the composite candidate key makes the frozen tenant-safe generic mission relations valid. `artifacts` and `approval_decisions` are polymorphic exactly-one-target records and do not consistently carry `project_id`; widening both children would unnecessarily broaden v1 and its target semantics.
|
||||
@@ -19,7 +100,7 @@
|
||||
|
||||
## 1. Authority
|
||||
|
||||
Concrete contracts are the four `contracts/*.v1.ts` files. PostgreSQL/current-main Drizzle is the sole writable SOT. Public health, Valkey, files, exports, providers, browser state, and outage notes cannot authorize/reconstruct writes. Mechanical Coordinator is non-LLM with no scope/gate/certification/merge authority. Certifier is final independent gate with no merge authority. No feature lane starts until this canon merges and the KBN-010/KBN-105 prerequisites are satisfied.
|
||||
Concrete contracts are the four `contracts/*.v1.ts` files. PostgreSQL/current-main Drizzle is the sole writable SOT. In PostgreSQL standalone/federated deployments, KBN-101 rc.13 DDL/ledger/TLS/role/attestation/generation separation is a precondition to schema implementation and certification. Public health, Valkey, files, exports, providers, browser state, and outage notes cannot authorize/reconstruct writes. Mechanical Coordinator is non-LLM with no scope/gate/certification/merge authority. Certifier is final independent gate with no merge authority. No feature lane starts until this canon merges and the KBN-010/KBN-105 prerequisites are satisfied.
|
||||
|
||||
## 2. Health proof and exact failures
|
||||
|
||||
@@ -104,7 +185,7 @@ The candidate key is intentionally redundant with globally unique `missions.id`,
|
||||
|
||||
### 5.3 New audit/proposal DDL order
|
||||
|
||||
KBN-100 migration DDL must execute in this order:
|
||||
KBN-100 migration DDL may begin only after KBN-101 foundation role/schema-boundary certification. It runs in the explicit migrator/owner phase—not Gateway startup—and its generated Drizzle declaration/snapshot/journal must be mutually consistent. It must execute in this order:
|
||||
|
||||
1. create `task_events` and its unique `(workspace_id, id)` key;
|
||||
2. create `change_proposals` with nullable acceptance-event ID and required submission-event ID;
|
||||
@@ -243,7 +324,7 @@ KBN-115/coder2 owns `packages/config/src/recovery-posture.ts`, tests, and recove
|
||||
|
||||
## 9. Integration, security, and hold
|
||||
|
||||
Required release evidence includes empty/prod/partial/rollback/N-1 migration tests; cross-workspace and same-workspace wrong-project negatives; active-membership owners/principals; proposal inertness/normal acceptance; exact failure mapping; concurrent monotonic bigint fences; relational lease/checkpoint/evidence mismatch; immutability privileges/RESTRICT; recovery validation/mechanism evidence; endpoint registry alignment; accessible web journeys; author≠reviewer; mandatory SecReview; final Certifier pass/no merge authority.
|
||||
Required release evidence includes KBN-101 foundation role/schema-boundary and post-KBN-100 real immutable-operation deployed-role certificates (not synthetic roles), empty/prod/partial/rollback/N-1 migration tests; cross-workspace and same-workspace wrong-project negatives; active-membership owners/principals; proposal inertness/normal acceptance; exact failure mapping; concurrent monotonic bigint fences; relational lease/checkpoint/evidence mismatch; immutability privileges/RESTRICT; recovery validation/mechanism evidence; endpoint registry alignment; accessible web journeys; author≠reviewer; mandatory SecReview; final Certifier pass/no merge authority.
|
||||
|
||||
### 9.1 SI-001 amendment gate and #757 boundary
|
||||
|
||||
|
||||
@@ -43,12 +43,14 @@ Shared roots, package exports/manifests, lockfiles, and generated artifacts are
|
||||
```text
|
||||
KBN-000 canon remediation
|
||||
-> KBN-010 threat/auth/constraint-impact gate (MUST COMPLETE)
|
||||
-> KBN-100 schema + concrete N-1 migration implementation
|
||||
├─ KBN-105 exact endpoint/DTO/error/registry freeze (SERIAL)
|
||||
│ ├─ KBN-110 domain + Gateway + MCP server implementation
|
||||
│ ├─ KBN-120 CLI/projection implementation [coder4 first]
|
||||
│ └─ KBN-130 web MVP implementation
|
||||
└─ KBN-115 recovery parser/mechanism slice [coder2 lane-serial]
|
||||
-> KBN-101 foundation role/schema-boundary certificate (SERIAL)
|
||||
-> KBN-100 schema + concrete N-1 migration implementation
|
||||
├─ KBN-101 post-KBN-100 deployed-role immutable-operation certificate (SERIAL)
|
||||
│ -> KBN-105 exact endpoint/DTO/error/registry freeze (SERIAL)
|
||||
│ ├─ KBN-110 domain + Gateway + MCP server implementation
|
||||
│ ├─ KBN-120 CLI/projection implementation [coder4 first]
|
||||
│ └─ KBN-130 web MVP implementation
|
||||
└─ KBN-115 recovery parser/mechanism slice [coder2 lane-serial]
|
||||
KBN-110 + KBN-120 + KBN-130 + KBN-115
|
||||
-> KBN-140 P1 integration/SIT
|
||||
-> KBN-200 pure decision engine [coder4 after KBN-120]
|
||||
@@ -64,7 +66,7 @@ KBN-310 + KBN-320
|
||||
-> KBN-340 owner-gated cutover/stabilization
|
||||
```
|
||||
|
||||
No consumer implementation begins before KBN-105. No schema work begins before KBN-010 completes. The coder4 order is always KBN-120 → KBN-200 → KBN-300 → KBN-320.
|
||||
No consumer implementation begins before KBN-105. No schema work begins before KBN-010 completes and the KBN-101 foundation role/schema-boundary certificate passes; the real immutable-operation certificate follows KBN-100 and blocks KBN-105. The coder4 order is always KBN-120 → KBN-200 → KBN-300 → KBN-320.
|
||||
|
||||
## 4. P0 — Canon, threat gate, schema, and exact API freeze
|
||||
|
||||
@@ -91,6 +93,17 @@ No consumer implementation begins before KBN-105. No schema work begins before K
|
||||
- **Contract surfaces:** schema constraints, health proof, exact errors, command-family authorization.
|
||||
- **Evidence:** signed constraint-impact matrix; no unresolved schema-impact finding; SecReview pass.
|
||||
|
||||
### KBN-101 — PostgreSQL runtime/migration role split and deployed-role certification
|
||||
|
||||
- **Status:** IN PROGRESS — issue [#771](https://git.mosaicstack.dev/mosaicstack/stack/issues/771); rc.16 closes HIGH-1 current generic storage-wrapper authority: README/user-guide remove `storage migrate --run` guidance and false runner delegation; current source is direct-Drizzle, legacy N-1, uncertified, non-operative, and forbidden pending -02/-03/-06/-08 activation. The -06 fixture fails both exact former forms before inventory/status masking and source-consistency rejects current direct-Drizzle wrapper as runner delegation. It awaits independent exact-head re-review; implementation remains held.
|
||||
- **Owner:** Mos integration control plane; independently reviewed by security/Ultron.
|
||||
- **Mode:** SERIAL foundation certificate blocks KBN-100; its post-KBN-100 real immutable-operation certificate blocks KBN-105.
|
||||
- **IN:** Exact `DATABASE_URL` non-owner runtime versus `DATABASE_MIGRATION_URL` owner/migrator connection contract; sole published `mosaic-db-migrator --run|--verify` PostgreSQL DDL path and all legacy/future entrypoint closure; active migrate-tier destination only after runner prepare/verify through exact `--target-url-file /run/secrets/mosaic-migrate-target-url`, paired authenticated provider-version file, and signed `--target-attestation-file /run/mosaic-attestations/migrate-target.v1.json`; runner-only signing key/public-key isolation; canonical Vault KV-v2 target URL/version, generation-pinned renderer, importer CA/public-key/attestation plus privileged sealed producer-to-importer handoff, safe-fd/consumer-isolation/no-log-oracle, TLS/server/database/role/manifest/schema binding, expiry/replay/provider-rotation/TOCTOU/no-DML controls, and dedicated non-DDL importer; finite exact-path scanner/allowlist/active-route review plus unsuppressible automatic-startup/init/Compose-before-runner semantic negatives and every-path before-connect denial matrix; `DATABASE_TLS_CA_CERT_PATH` plus operator/IaC CA/server-key/cert lifecycle, exact service-DNS SANs, Vault/compose/Swarm mount modes, TLS server/bootstrap/rotation/rollback; PGlite exception; fixed two-int advisory lock; manifest-v1 logical-index/tag/exact-byte-SHA-256 ledger reconciliation including safe `0009`; fixed `mosaic` schema and exact `pg_catalog,mosaic` pooled session path; platform/schema/`NOLOGIN SUPERUSER` extension-owner/migrator/importer/runtime roles; approved-owner versus legacy-owner shadow pgvector transition; ownership, zero membership/no runtime secret, TEMP/ledger-read/default privilege and immutable grant proof; N-1 inactive prepared cards then atomic activation/rollback authority; Vault/redaction/observability/operator runbooks; one-card/one-PR implementation DAG.
|
||||
- **OUT:** Production mutation in this planning card; KBN-100 tables/data backfill; application API behavior; KBN-105 route/DTO freeze.
|
||||
- **Depends on:** KBN-010 completed.
|
||||
- **Contract surfaces:** [`KBN-101-DB-ROLE-SPLIT.md`](./KBN-101-DB-ROLE-SPLIT.md); `SHARED-CONTRACT.md` rc.15 amendment.
|
||||
- **Evidence:** foundation: exact `--help|--run|--verify`/exit/argv/import-negative plus DTO entrypoint negatives for every finite classified current DDL/static-bypass path (including `DATABASE_URL`-only, runner fixture, retired init, sanitized current operator guidance, both harness pairs, and `db:push` refusal); active migrate-tier paired URL/version/attestation files, signing/public-key isolation, canonical Vault KV-v2 authenticated version, generation-pinned renderer, importer CA, safe fd/TOCTOU/consumer-isolation/no-log-oracle, atomic JCS/Ed25519, digest/TLS/server/database/role/manifest/schema binding, expiry/replay/provider rotation/revocation, zero-connection versus zero-DML, prepared-target/importer/no-DDL negatives; clean/pre-0009/skipped/applied-late/duplicate/unknown/missing/corrupt/stale/backup plus public-to-`mosaic`/partial/reverse runner proof; fixed-lock contention/crash/readiness/unrelated-key tests; runtime cannot invoke migrations/DDL/TEMP; actual pgvector 0.8.2 control metadata, fresh/approved-owner existing/legacy-owner shadow/partial-resume-rollback/N-1 pgvector evidence with `rolcanlogin=false`, `rolsuper=true`, zero members, external-superuser `SET ROLE`/`RESET ROLE` audit, `pg_extension.extowner`, owner-bearing member/schema/version and runtime/migrator/schema-owner/importer/all-service-role `SET ROLE`/ALTER/DROP/member-update denial; disposable standalone, federated/Swarm, and two-gateway verified-TLS positives plus both-pair CA/SAN/downgrade/key mode/UID-GID/URL-secret consumer-isolation and legacy-drain/`hostssl` zero-plaintext negatives; exclusive bootstrap/renderer/manifest ownership test; catalog relocation/vector-query/operator/Drizzle-only-`mosaic`, role/grant/search-path/pool-reset/identifier checks; N-1/atomic TLS-only rollback/no-force-on-red rehearsal; named Vault/bootstrap-control-plane/CA-overlap/redaction/operator evidence; independent author≠reviewer security GO. Post-KBN-100: real deployed non-owner INSERT/SELECT and UPDATE/DELETE denial for immutable event/artifact/evidence relations plus Ultron GO.
|
||||
|
||||
### KBN-100 — Unified Drizzle schema and concrete N-1 migration
|
||||
|
||||
- **Owner:** **coder2**.
|
||||
@@ -98,7 +111,7 @@ No consumer implementation begins before KBN-105. No schema work begins before K
|
||||
- **Exclusive files:** `packages/db/src/schema.ts`, `packages/db/drizzle/**`, DB tests.
|
||||
- **IN:** All frozen tables/joins/enums; workspace/project-congruent constraints; owners/principals; tags/archive; change proposals with both workspace-aware task-event composite FKs and frozen event-before-proposal DDL order; assignment approvals; durable execution/quarantine; monotonic bigint fence; exact checkpoint/evidence joins; RESTRICT/immutability; concrete current-main expand/backfill/switch/contract map.
|
||||
- **OUT:** Repositories, Gateway, Coordinator behavior, UI, importer.
|
||||
- **Depends on:** **KBN-010 completed**.
|
||||
- **Depends on:** **KBN-010 completed and KBN-101 foundation role/schema-boundary certificate PASS**. KBN-100 is blocked until both are terminal; it rebases on KBN-101 main, restores generated Drizzle declaration/snapshot/journal consistency, and confines procedural immutable-table grant/trigger/backfill work to its schema ownership. Its new relations are then subject to KBN-101 post-KBN-100 deployed-role certification.
|
||||
- **Contract surfaces:** `kanban-schema.v1.ts`; SHARED-CONTRACT current-main delta map.
|
||||
- **Evidence:** reviewed SQL; empty/prod-shape/partial-resume/rollback tests; N-1 app safety; legacy columns remain declared; workspace/project mismatch negatives; proposal event-FK missing/foreign-workspace tests; one active lease; monotonic fence; parent-delete RESTRICT; immutability privileges; SecReview.
|
||||
|
||||
@@ -109,7 +122,7 @@ No consumer implementation begins before KBN-105. No schema work begins before K
|
||||
- **Exclusive files:** canonical endpoint-registry/DTO contract docs; no implementation.
|
||||
- **IN:** Exact routes and methods from SHARED-CONTRACT §8; request/success/error fields; status codes; pagination/filter/revision envelopes; idempotency/expected-version headers/fields; proposal commands; health proof exclusion from public DTOs; MCP tool-to-route map.
|
||||
- **OUT:** Controller/service/client implementation.
|
||||
- **Depends on:** KBN-100.
|
||||
- **Depends on:** KBN-100 and KBN-101 post-KBN-100 deployed-role immutable-operation certification PASS.
|
||||
- **Contract surfaces:** health/error unions; schema IDs/statuses; Gateway DTO freeze.
|
||||
- **Evidence:** every FE/CLI/MCP call maps 1:1 to a route; 503/502-504/409 non-cross-map fixtures; contract digest published.
|
||||
|
||||
@@ -251,13 +264,15 @@ No consumer implementation begins before KBN-105. No schema work begins before K
|
||||
|
||||
## 8. Consistent USC wave schedule
|
||||
|
||||
| Wave | coder2 | coder3 | coder4 | coder5 |
|
||||
| ---- | ------------------------- | -------------------------------------- | ------------------------------ | ------------------------------ |
|
||||
| 0 | Wait | **KBN-010** | Wait | Wait |
|
||||
| 1 | **KBN-100** | Review constraint implementation | Wait | Wait |
|
||||
| 2 | **KBN-115** after KBN-100 | **KBN-105** exact freeze, then KBN-110 | **KBN-120** only after KBN-105 | **KBN-130** only after KBN-105 |
|
||||
| 3 | Review support | Finish KBN-110 | **KBN-200 after KBN-120** | Finish KBN-130 |
|
||||
| 4 | — | **KBN-210 after KBN-200** | Review/support | **KBN-220 after KBN-210 DTOs** |
|
||||
| 5 | — | P2 remediation | **KBN-300 then KBN-320** | **KBN-310** |
|
||||
| Wave | coder2 | coder3 | coder4 | coder5 |
|
||||
| ---- | ----------------------------------------- | ------------------------------------------------------------------------------------ | ------------------------------ | ------------------------------ |
|
||||
| 0 | Wait | **KBN-010** | Wait | Wait |
|
||||
| 0.5 | Wait | **KBN-101 foundation** Mos-controlled role/connection contract and certificate | Wait | Wait |
|
||||
| 1 | **KBN-100** after KBN-101 foundation PASS | Review bounded schema/grant implementation | Wait | Wait |
|
||||
| 1.5 | Certification support | **KBN-101 post-KBN-100 deployed-role immutable-operation certificate**, then KBN-105 | Wait | Wait |
|
||||
| 2 | **KBN-115** after KBN-100 | **KBN-105** exact freeze, then KBN-110 | **KBN-120** only after KBN-105 | **KBN-130** only after KBN-105 |
|
||||
| 3 | Review support | Finish KBN-110 | **KBN-200 after KBN-120** | Finish KBN-130 |
|
||||
| 4 | — | **KBN-210 after KBN-200** | Review/support | **KBN-220 after KBN-210 DTOs** |
|
||||
| 5 | — | P2 remediation | **KBN-300 then KBN-320** | **KBN-310** |
|
||||
|
||||
Mos alone releases slices and lifts the build hold after independent re-review GO.
|
||||
|
||||
@@ -1460,12 +1460,11 @@ Add to `packages/db/src/schema.ts` in the `preferences` table definition:
|
||||
mutable: boolean('mutable').notNull().default(true),
|
||||
```
|
||||
|
||||
Generate and apply:
|
||||
### Held future procedure
|
||||
|
||||
```bash
|
||||
pnpm --filter @mosaicstack/db db:generate # generates migration SQL
|
||||
pnpm --filter @mosaicstack/db db:migrate # applies to PG
|
||||
```
|
||||
This historical architecture plan grants **no current command authority**. PostgreSQL execution is non-operative until **KBN-101-00, KBN-101-03, and KBN-101-05** land; do not invoke a PostgreSQL runner from this checkout. After those cards land, the approved future procedure is exactly: external bootstrap → TLS/roles → `mosaic-db-migrator --run` → `mosaic-db-migrator --verify` → Gateway/Compose readiness. Offline migration artifact generation belongs to its owning implementation card and does not activate PostgreSQL execution.
|
||||
|
||||
> **KBN-101 supersession:** `pnpm --filter @mosaicstack/db db:migrate` is superseded and MUST NOT be used. The future runner receives only deployment-injected migration credentials; it accepts no URL, SQL, schema, or role argv.
|
||||
|
||||
Platform enforcement keys (seeded with `mutable = false` by gateway `PreferencesService.onModuleInit()`):
|
||||
|
||||
|
||||
@@ -946,13 +946,11 @@ pnpm --filter @mosaicstack/types typecheck
|
||||
|
||||
Expected: All PASS
|
||||
|
||||
**Step 2: Manual smoke test**
|
||||
**Step 2: Manual smoke test (held)**
|
||||
|
||||
```bash
|
||||
cd /home/jwoltje/src/mosaic-mono-v1-worktrees/tui-improvements
|
||||
docker compose up -d
|
||||
pnpm --filter @mosaicstack/cli exec tsx src/cli.ts tui
|
||||
```
|
||||
This historical TUI smoke test is unavailable until KBN-101-02 supplies a fail-closed Gateway local
|
||||
startup route. Do not start current Compose PostgreSQL or infer a local Gateway from PGlite support.
|
||||
A future reviewed test must use the correct Mosaic CLI package and an independently verified Gateway.
|
||||
|
||||
Verify:
|
||||
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
# KBN-101 contract independent security/architecture review
|
||||
|
||||
**Verdict: REQUEST CHANGES**
|
||||
|
||||
## Review identity and scope
|
||||
|
||||
- **Exact reviewed head:** `da742ca2da4a2ff466916c818fe275c4f7ffd384` (`docs(#771): record role-split review evidence`)
|
||||
- **Required comparison:** `origin/main...da742ca2da4a2ff466916c818fe275c4f7ffd384`
|
||||
- **Range:** `82ce3252df38a687c50485f8d048b53ca8db5989` is an ancestor of the reviewed head; the final head adds the scratchpad evidence commit and was reviewed.
|
||||
- **Changed docs:** `docs/PRD.md`, `docs/SITEMAP.md`, `docs/native-kanban-sot/{INDEX.md,KBN-101-DB-ROLE-SPLIT.md,SHARED-CONTRACT.md,TASKS.md}`, and `docs/scratchpads/771-kbn101-db-role-split.md` (300 additions / 25 deletions).
|
||||
- **Reviewed inputs:** issue #771; current DB/Gateway/storage/config/wizard/installer/compose/Portainer/CI sources; all current migration/DDL references; KBN-010, rc.4/rc.5 shared contract, requirements/canon, KBN-100 #769 branch context, and the final scratchpad.
|
||||
- **Repository/provider state:** not modified. The pre-existing `.mosaic/orchestrator/*` dirt was not touched.
|
||||
|
||||
The role graph itself is sound in principle: a NOLOGIN platform database owner, separate NOLOGIN schema owner, NOINHERIT migrator which explicitly `SET ROLE`s, and runtime membership only in a capability role with `SET FALSE` does not create circular privilege or application-created login roles. The split of foundation certification before KBN-100 and immutable-operation certification after KBN-100 is also correctly ordered.
|
||||
|
||||
## Findings
|
||||
|
||||
### HIGH — DDL/migration control plane is not closed at every current entrypoint
|
||||
|
||||
The contract requires an explicit, locked migration phase and forbids Gateway/runtime DDL (`KBN-101-DB-ROLE-SPLIT.md:34-39`), but its KBN-101-02 result merely says migration-capable commands use the migration DTO (`:109`). It does not prohibit or route every existing bypass through that one command.
|
||||
|
||||
Current bypasses include:
|
||||
|
||||
- `runMigrations()` falls back from an argument to `DATABASE_URL` and a hard-coded URL (`packages/db/src/migrate.ts:24-35`), while `drizzle.config.ts` likewise uses `DATABASE_URL` plus a default (`packages/db/drizzle.config.ts:3-9`).
|
||||
- Package scripts expose direct `drizzle-kit migrate` **and** `drizzle-kit push` (`packages/db/package.json:23-26`); `db:push` bypasses the planned journal/fingerprint/lock entirely.
|
||||
- `mosaic storage migrate --run` shells out to the direct `db:migrate` script (`packages/storage/src/cli.ts:413-452`).
|
||||
- The federated integration test can create types, tables, and indexes directly against `DATABASE_URL` and intentionally operates without a Drizzle ledger (`packages/db/src/federation.integration.test.ts:28-30,46-134`).
|
||||
|
||||
**Failure mode:** a runtime or CI environment with only `DATABASE_URL`, or an operator invoking an existing command, can apply unverified DDL outside the lock, `SET ROLE` preflight, exact-ledger gate, and deployment sequencing. This breaks the requested fail-closed split even if Gateway startup is repaired.
|
||||
|
||||
**Required remediation:** amend KBN-101-02/03/06 to enumerate these entrypoints and make the dedicated migrator runner the only PostgreSQL DDL path. Production-like `db:push` must be removed/blocked; `db:migrate`, `storage migrate --run`, and migration tests must invoke the same migration runner with `DATABASE_MIGRATION_URL`, lock, identity preflight, and ledger verification. Tests needing schema must consume a pre-migrated disposable database, or be explicitly run only by that migration phase. Add negative tests showing each command refuses `DATABASE_URL`-only execution and cannot reach DDL.
|
||||
|
||||
### HIGH — TLS requirement has no deployable server/bootstrap contract
|
||||
|
||||
The contract correctly requires a mounted CA and hostname-verified TLS (`KBN-101-DB-ROLE-SPLIT.md:25,28,93-95`). However KBN-101-05 promises only a “migration phase and secret binding boundary” (`:112`), not PostgreSQL server TLS, certificate issuance/SANs, CA distribution, startup ordering, or the fresh/existing-database bootstrap trust path.
|
||||
|
||||
Current standalone and federated compose expose plain PostgreSQL with no server TLS configuration or CA mount (`docker-compose.yml:2-14`; `docker-compose.federated.yml:27-44`). The Portainer test stack passes a single plaintext in-network URL and uses the same database login for Gateway and database bootstrap (`deploy/portainer/federated-test.stack.yml:51-60,110-117`).
|
||||
|
||||
**Failure mode:** enforcing the mandatory CA makes current local standalone/federated topologies unable to start; relaxing it to make bootstrap work silently violates K101-REQ-03. A first database cannot be safely migrated until the server certificate, its SAN for the actual service/DNS name, and trusted CA are provisioned, but this lifecycle is not owned or tested.
|
||||
|
||||
**Required remediation:** add a concrete KBN-101-00/05 TLS bootstrap sub-contract: issuer/CA owner; server key/cert and SAN inputs; secure storage/mount permissions; `postgresql.conf`/container TLS enablement; migration and runtime CA mounts; hostname used by each compose/Swarm service; readiness only after TLS authentication; CA overlap rotation; and an existing-database transition. Require a disposable standalone and federated/Swarm test to prove verified TLS succeeds and missing CA, wrong CA, wrong SAN, and `sslmode` downgrade fail before readiness. Do not merge KBN-101-05 with an implicit plaintext exception.
|
||||
|
||||
### HIGH — exact ledger fingerprint and historical 0009 repair are underspecified for existing databases
|
||||
|
||||
The contract requires an “ordered complete set” and rejection of out-of-order rows (`KBN-101-DB-ROLE-SPLIT.md:36-38`), but does not define the canonical serialized tuple, ledger ordering source, or safe upgrade rule for a historical ledger. The current ledger stores only `id`, `hash`, and `created_at` (`packages/db/src/migrate.ts:70-82,105-107`). Its journal is demonstrably non-monotonic: `0008` has `when=1776822435828`, followed by `0009` at `1745280000000` (`packages/db/drizzle/meta/_journal.json:62-79`); the existing PostgreSQL runner documents that this causes skipping (`packages/db/src/migrate.ts:29-35`).
|
||||
|
||||
**Failure mode:** an implementation can either reject a legitimate historical database after correcting 0009, or accept a reordered/duplicated ledger because no precise comparison rule exists. A count/hash-set implementation would fail to detect the condition that this contract explicitly calls unsafe; physical `id` order is not an adequate substitute after historical repair.
|
||||
|
||||
**Required remediation:** freeze a versioned manifest algorithm before implementation: canonical record fields (at least journal index/tag, corrected logical order, migration content hash, and an explicit migration-manifest version), canonical byte serialization, SHA-256 input, and exact observed-ledger mapping. State whether physical ledger insertion order is normative; if not, compare hash-to-manifest tuples rather than timestamps. Add an idempotent migrator-only 0009 existing-database remediation/reconciliation procedure with backup/rollback evidence. Require clean, pre-0009, 0009-skipped, 0009-applied-late, duplicate, unknown, missing, corrupt-pair, and stale-replica cases. No manual ledger insertion is an acceptable production recovery path.
|
||||
|
||||
### MEDIUM — advisory-lock namespace is collision-prone and lacks a fixed identifier contract
|
||||
|
||||
The specified lock is `pg_try_advisory_lock(hashtext('mosaic-schema-migration-v1'))` (`KBN-101-DB-ROLE-SPLIT.md:34`). `hashtext` produces a 32-bit key. Session ownership/crash behavior is otherwise correctly stated (one session, same-session release, connection-close release), but an unrelated database user can accidentally collide or deliberately hold the key and force `DATABASE_MIGRATION_LOCKED`.
|
||||
|
||||
**Failure mode:** avoidable migration denial of service in a shared PostgreSQL database. The current repository already uses separate `hashtext` advisory-lock names for migrate-tier, demonstrating the need for a documented namespace rather than a collision-prone implicit one.
|
||||
|
||||
**Required remediation:** freeze a two-int advisory-lock namespace (fixed documented class/object values) or a documented 64-bit `hashtextextended` key with fixed seed; keep acquisition, migration, verification, and release on the single `max:1` migrator session. Add tests for concurrent migration, connection loss/crash release, readiness while the lock holder is active, and an unrelated lock-key non-interference case.
|
||||
|
||||
### MEDIUM — identifier safety and `search_path` verification need executable constraints
|
||||
|
||||
The contract rightly requires `pg_catalog, <mosaic_application_schema>` and rejects writable paths (`KBN-101-DB-ROLE-SPLIT.md:54,70-77`), but uses dynamic placeholders for database/schema and does not state how migration/bootstrap SQL will avoid identifier interpolation. Existing code has raw-SQL facilities (`packages/storage/src/migrate-tier.ts` uses `.unsafe`), so this is not merely theoretical.
|
||||
|
||||
**Failure mode:** a future operator-configured database/schema value that reaches bootstrap or `SET search_path` through raw string construction can inject DDL, or a pooled connection can retain a mutable search path.
|
||||
|
||||
**Required remediation:** require fixed allowlisted identifiers or server-side identifier quoting (`format('%I', ...)`) only; never interpolate URL/config values into SQL. Set and verify the trusted path per connection/session before any query (`SET LOCAL` inside transactions where applicable), forbid `public`/`$user` additions, and add injection-shaped identifier and pooled-connection reset negatives. Include this in KBN-101-00/01 tests.
|
||||
|
||||
## Acceptance and threat traceability
|
||||
|
||||
| Requirement / threat | Review result | Evidence or blocking finding |
|
||||
| --- | --- | --- |
|
||||
| K101-REQ-01 / AC-K101-01 split runtime/migration URLs | Partial | Role/DTO boundary is coherent; HIGH DDL-path finding requires all current commands to be closed. |
|
||||
| K101-REQ-02 / AC-K101-02 explicit migration/readiness | Blocked | HIGH ledger definition and HIGH DDL-bypass findings. |
|
||||
| K101-REQ-03 / AC-K101-03 least privilege, TLS, grants | Partial | Role model, default privileges, ledger read-only, TEMP/function checks are well specified (`KBN-101...:47-56,70-79`); HIGH TLS bootstrap and MEDIUM identifier constraints remain. |
|
||||
| K101-REQ-04 / AC-K101-04 immutable relations | Correctly deferred | KBN-101-09 after KBN-100 is the correct serial gate (`KBN-101...:58-66,115-118`); no synthetic-only certification claim found. |
|
||||
| K101-REQ-05 / AC-K101-05 N-1, secrets, rollback | Partial | No owner-runtime exception and rollback keeps migration URL out of Gateway (`:83-95`); deployable TLS and full command inventory are missing. |
|
||||
| K101-REQ-06 / AC-K101-07 KBN gates and DAG | Structurally sound | DAG is acyclic: 00→01/{03}; 02→06; 00/01/03→05; 00/04/05/06→07→08→KBN-100→09→KBN-105. KBN-100’s current branch contains docs-only baseline tracking, not schema implementation. |
|
||||
| T: runtime DDL / migration fallback | Blocked | HIGH finding 1. Current Gateway/storage, CLI, direct Drizzle scripts, and integration DDL require explicit closure. |
|
||||
| T: race/crash/readiness | Partial | Same-session nonblocking lock and replica-unready rules are present (`:34-38`); lock namespace remediation required. |
|
||||
| T: immutable evidence rewrite | Correctly staged | Explicit INSERT/SELECT-only matrix and RESTRICT retention are retained; proof is properly after table creation. |
|
||||
| T: secret leakage / TLS downgrade | Partial | Redaction and distinct Vault paths are specified (`:93-97`), but no server TLS/bootstrap implementation contract exists. |
|
||||
|
||||
## Unresolved assumptions
|
||||
|
||||
1. `standalone` and `federated` are the complete PostgreSQL production-like set (K101-A1).
|
||||
2. Each eligible deployment can execute a dedicated migration Job/one-shot phase (K101-A2).
|
||||
3. Vault path names are targets, not verified existing paths; deployment ownership remains to be established.
|
||||
4. PostgreSQL 17 is available for the selected membership and advisory-lock implementation.
|
||||
5. The required server-side TLS issuer/certificate lifecycle and Swarm/compose secret transport have not been decided; this is blocking, not a permissible implicit plaintext bootstrap.
|
||||
6. Historical databases containing the 0009 journal/ledger anomaly have no frozen reconciliation procedure.
|
||||
|
||||
## Independent test and consistency evidence
|
||||
|
||||
Read-only checks run in this review:
|
||||
|
||||
| Check | Result |
|
||||
| --- | --- |
|
||||
| `git diff --check origin/main...da742ca2...` | PASS |
|
||||
| `pnpm exec prettier --check` on all seven changed docs | PASS |
|
||||
| `pnpm exec tsc --noEmit -p docs/native-kanban-sot/tsconfig.json` | PASS |
|
||||
| `docker compose -f docker-compose.yml config --quiet` (isolated test ports) | PASS |
|
||||
| `docker compose -f docker-compose.federated.yml --profile federated config --quiet` (isolated test ports) | PASS |
|
||||
| Static journal inspection | FAILS the required monotonic ordering premise: 0008 → 0009 `when` decreases; current runner documents skipping behavior. |
|
||||
| Static DDL-entrypoint inventory | Found direct Drizzle scripts, storage CLI shell-out, runtime extension/migration calls, fleet backlog migration, tier probe extension creation, and a direct-DLL federated integration test. |
|
||||
|
||||
No live database, Vault, CI, deployment, issue, PR, or repository mutation was performed. The pass results validate documentation syntax/contract compilation and compose syntax only; they do **not** certify the proposed security behavior.
|
||||
|
||||
## Conclusion
|
||||
|
||||
Do not merge this frozen contract as implementation-ready until the HIGH findings are corrected and independently re-reviewed. The central role ownership/default-privilege design, immutable-table staging, and KBN-100/KBN-105 serial gating should be retained; they are not the reason for this REQUEST CHANGES verdict.
|
||||
39
docs/scratchpads/758-fcm-m3-001-local-reconciler.md
Normal file
39
docs/scratchpads/758-fcm-m3-001-local-reconciler.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# FCM-M3-001 — Local roster-owned reconciliation and lifecycle
|
||||
|
||||
- **Task / issue:** FCM-M3-001 / #758
|
||||
- **Branch / base:** `feat/758-local-reconciler` from `origin/main` `bc5e73629e92c56a80fa6a769ebad17c0177f504`
|
||||
- **Base tree:** `1b9ebe4fa1a90734b6f81118e120bae5290cd350`
|
||||
- **Scope:** source, isolated fake-adapter tests, and card documentation only. No live fleet/systemd/tmux action.
|
||||
|
||||
## Objective
|
||||
|
||||
Provide local roster-v2 `apply`/`reconcile` and lifecycle/status contracts. The roster remains desired-state authority; projections and runtime observations are derived state.
|
||||
|
||||
## Red-first evidence
|
||||
|
||||
The initial focused reconciler test failed because `fleet-reconciler.ts` did not exist. The initial new-worktree test invocation also exposed absent dependencies; `pnpm install --frozen-lockfile --store-dir /home/jarvis/.local/share/pnpm/store` restored local workspace dependencies without changing source.
|
||||
|
||||
## Design
|
||||
|
||||
- A new `fleet-reconciler.ts` accepts only typed roster-v2 input plus injected command and projection adapters.
|
||||
- It targets only exact `mosaic-agent@<roster-name>.service` units and the exact configured tmux socket/session.
|
||||
- It classifies unowned/unmanaged state and fails mutation closed rather than adopting or killing it.
|
||||
- It validates the private install-derived holder identity and complete expected tmux global environment before mutating lifecycle state.
|
||||
- REVIEW-1 remediation: RED review evidence found service-level apply could omit generation and had no mutation lock. Every non-observational command now requires an expected generation; a private exclusive roster-adjacent lock is acquired before effects and released on success or partial failure. Tests cover missing/stale values, concurrent denial, no effects, release, and lock-free observation.
|
||||
- REVIEW-2 remediation: lock acquisition now validates private real `MOSAIC_HOME`/`fleet` ancestors, rejects symlink or unsafe leaves, distinguishes `EEXIST` concurrency from other I/O, and binds release to the created inode plus random ownership token. A replacement lock is retained and reported, not unlinked. A crash may leave a stale lock for inspection; no stale-lock break is claimed.
|
||||
- REVIEW-3 remediation: a lock cleanup failure now adds bounded `cleanup` diagnostics to a known successful or partial effect result without replacing its projection/lifecycle/recovery truth. Cleanup is not claimed as complete, and the retained lock requires inspection before retry.
|
||||
- REVIEW-4 remediation: command JSON with an additive cleanup diagnostic now exits non-zero even where known effects completed; clean effect and observational JSON remain zero-exit.
|
||||
- REVIEW-5 remediation: mutating operations acquire the private lock before rereading canonical `roster.yaml`; the fenced reread, not a caller snapshot, supplies generation validation, plan, projection, and lifecycle authority.
|
||||
- `apply` starts only enabled agents whose persisted desired state is `running`; stopped/default agents are never started by reconciliation.
|
||||
- Observational commands produce JSON classification only. Partial projection or lifecycle effects report explicit recovery without values.
|
||||
|
||||
## Boundaries
|
||||
|
||||
Excluded: live host actions, remote/SSH/connector lifecycle mutation, migrations, canaries, deployment, gateway changes, arbitrary command/channel/secret inputs, `docs/TASKS.md`, and orchestration ledgers.
|
||||
|
||||
## Verification
|
||||
|
||||
- Focused reconciler/Commander/CRUD/fleet tests: 4 files / 229 tests passed.
|
||||
- Full `@mosaicstack/mosaic` suite: 56 files / 820 tests passed after REVIEW-5 canonical roster fencing remediation.
|
||||
- Package and root typecheck/lint, root format check, and `git diff --check`: passed.
|
||||
- Isolated launcher and systemd template harnesses passed; they use fixtures only. No live fleet, systemd, tmux, session, remote, connector, or runtime action occurred.
|
||||
@@ -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.
|
||||
183
docs/scratchpads/771-kbn101-db-role-split.md
Normal file
183
docs/scratchpads/771-kbn101-db-role-split.md
Normal file
@@ -0,0 +1,183 @@
|
||||
# Scratchpad — KBN-101 DB runtime/migration role split (#771)
|
||||
|
||||
- **Branch:** `docs/771-kbn101-db-role-split`
|
||||
- **Base:** `main` `e9c4aa3`
|
||||
- **Scope:** planning/documentation only; authorized files are PRD, Native Kanban task/shared/index docs, sitemap, this scratchpad, and the new KBN-101 contract.
|
||||
- **Explicit exclusions:** source/runtime/config/deployment/secret/migration/compose/CI/lock/package/KBN-100 branch edits; no production mutation.
|
||||
|
||||
## Objective
|
||||
|
||||
Freeze an implementation-ready PostgreSQL role/connection split so the Gateway uses a non-owner runtime identity and only a dedicated migration phase uses an owner/migrator identity. Make real deployed-role certification—not synthetic role tests—a serial prerequisite of KBN-100 and KBN-105.
|
||||
|
||||
## Intake and current-state evidence
|
||||
|
||||
- Mission MVP is active; W3 Native Kanban/SOT is planning-complete. The task state shows KBN-010 as the predecessor and KBN-100 as the current schema slice.
|
||||
- Current branch started at `e9c4aa3`; `.mosaic/orchestrator/{mission.json,session.lock}` were already runtime-modified and remain untouched.
|
||||
- `packages/db/src/client.ts`, `migrate.ts`, and `drizzle.config.ts` resolve one `DATABASE_URL` (with default fallback). `packages/storage/src/adapters/postgres.ts` calls `runMigrations(this.url)`.
|
||||
- `apps/gateway/src/database/database.module.ts` calls `storageAdapter.migrate()` at startup for PostgreSQL; this is the owner-runtime defect to remove in KBN-101 implementation.
|
||||
- `packages/config/src/mosaic-config.ts`, installer wizard, local/federated compose, Portainer test stack, and `.woodpecker/ci.yml` currently expose one URL. PGlite has an existing explicit local migration path.
|
||||
- Current KBN contract requires immutable events/checkpoints/artifacts/evidence, `RESTRICT`, and KBN-100 generated Drizzle consistency. It did not establish a deployable runtime identity split.
|
||||
|
||||
## Frozen decisions
|
||||
|
||||
1. `DATABASE_URL` is the non-owner runtime URL; `DATABASE_MIGRATION_URL` is migration-only. Both are required in their respective PostgreSQL phases; PGlite is the explicit local exception; migration never falls back to runtime/default/config URL.
|
||||
2. PostgreSQL Gateway runtime never auto-runs migration/DDL. Dedicated migrator uses `pg_try_advisory_lock(hashtext('mosaic-schema-migration-v1'))`; replicas only check exact ordered Drizzle-ledger readiness and fail closed.
|
||||
3. Roles are non-login `mosaic_platform_database_owner`, non-login `mosaic_schema_owner`, login/noinherit `mosaic_migrator`, non-login `mosaic_runtime_capability`, and login/inherit `mosaic_runtime`. Runtime inherits only its capability role with SET/ADMIN denied, has no owner/migrator membership, no unsafe attributes/ownership/DDL authority, and an explicit trusted search path.
|
||||
4. Runtime gets mutable DML only as needed, but INSERT/SELECT only on `task_events`, `artifacts`, `task_checkpoints`, `task_checkpoint_artifacts`, and `approval_decision_artifacts`. KBN-100 still enforces RESTRICT/no-cascade.
|
||||
5. Startup verifies effective role/ownership/attributes/inherited capability/TEMP/function-execute/ledger grants/search path/immutable denials/schema fingerprint without DSN exposure. It also requires authenticated CA/hostname-verified TLS. Stable sanitized errors and redaction rules are required.
|
||||
6. N-1 retains single runtime URL only as a non-certified compatibility release; staged role provisioning/migration/runtime deployment then enforces the split. Rollback never injects migration URL into Gateway.
|
||||
7. Vault target paths, rotation, deployment injection, CI, installer, compose, Portainer, and observability are separate one-card/one-PR handoffs. The migration-only file manifest includes `packages/db/drizzle.config.ts`; KBN-101 repairs the known PostgreSQL runner/journal ordering defect and proves a clean database applies every hash once before its foundation certificate. No application migration creates roles/passwords or hardcodes credentials.
|
||||
8. KBN-101 foundation merges/certifies first. KBN-100 then rebases, restores Drizzle declaration/snapshot/journal consistency, and bounds procedural immutable-table grant/trigger/backfill work to its own slice. Because those immutable relations do not exist until KBN-100, KBN-101’s real deployed-role immutable-operation certificate follows KBN-100 and is the serial gate before KBN-105.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- `standalone` and `federated` are all current PostgreSQL production-like modes; a future PostgreSQL tier inherits this contract unless versioned otherwise.
|
||||
- Deployment will support a dedicated migration Job/one-shot command. A target that cannot run it cannot receive production/federated KBN certification.
|
||||
- Canonical Vault target paths require deployment-owner verification before provisioning; the planning document does not claim they already exist.
|
||||
|
||||
## Documentation produced
|
||||
|
||||
- `docs/PRD.md`: bounded KBN-101 requirements and acceptance criteria.
|
||||
- `docs/native-kanban-sot/KBN-101-DB-ROLE-SPLIT.md`: normative rc.5 implementation, threat, migration/rollback, evidence, and exact file DAG contract.
|
||||
- `docs/native-kanban-sot/SHARED-CONTRACT.md`: rc.5 amendment preserving rc.4.
|
||||
- `docs/native-kanban-sot/TASKS.md`: KBN-101 inserted before and blocks KBN-100; KBN-105 held.
|
||||
- Native Kanban index and root sitemap links.
|
||||
|
||||
## Validation plan
|
||||
|
||||
1. Prettier only for changed Markdown.
|
||||
2. Markdown link target/check checks scoped to modified docs.
|
||||
3. Strict contract check with `pnpm exec tsc --noEmit -p docs/native-kanban-sot/tsconfig.json` (the frozen TypeScript contracts remain unchanged).
|
||||
4. Diff allowlist proves only authorized documentation files changed, apart from pre-existing Mosaic runtime state.
|
||||
5. Independent documentation/security self-review: role escalation, fallback, startup DDL, schema readiness, grants/default privileges, immutable tables, secret leakage, deployment and KBN-100 boundaries.
|
||||
|
||||
## Review corrections
|
||||
|
||||
Independent Codex review found two blockers and security review found two medium defects; all were remediated in the frozen contract:
|
||||
|
||||
1. Split the KBN-101 certificate into a foundation role/schema-boundary certificate (before KBN-100) and real immutable-operation certificate (after KBN-100, before KBN-105). This preserves the requested KBN-100 block without requiring evidence for tables not yet created.
|
||||
2. Removed the legacy owner-runtime exception. N-1 compatibility preserves variable/config shape only; the KBN-101 runtime refuses owner/migrator identity and current single-URL installs remain on their previous release until role cutover.
|
||||
3. Introduced `mosaic_platform_database_owner` as a separate non-login platform role. `mosaic_schema_owner` owns application/ledger schemas only, not the database and has no database CREATE/ALTER/extension authority.
|
||||
4. Replaced blocking `pg_advisory_lock` with `pg_try_advisory_lock` and the deterministic `DATABASE_MIGRATION_LOCKED` failure.
|
||||
5. Review also flagged active `.mosaic/orchestrator` state. It was pre-existing launcher state and remains unstaged/uncommitted.
|
||||
6. Second review added `packages/db/drizzle.config.ts` to the migration-only slice, mandates `DATABASE_MIGRATION_URL` with a missing-variable negative, grants runtime only `USAGE` plus `SELECT` on `drizzle.__drizzle_migrations`, and verifies/revokes its ledger writes.
|
||||
7. Security review added `DATABASE_TLS_CA_CERT_PATH` / `DatabaseTlsConfigDto` with authenticated TLS and hostname/CA verification in production-like modes, explicit database `TEMPORARY` revocation/catalog denial testing, and default-PUBLIC function EXECUTE revocation with SECURITY DEFINER prohibited by default.
|
||||
8. Final review corrected the runtime login to inherit only its capability role with SET/ADMIN denied, and moved the known hash-complete migration-runner/journal repair into KBN-101-03 before the foundation certificate.
|
||||
9. Final manifest review added all live runtime DDL paths (`packages/storage/src/tier-detection.ts`, Gateway startup, and `fleet-backlog`) to KBN-101-02, requiring read-only extension probes and no PostgreSQL runtime auto-migration. It also requires KBN-101-04 to stop persisting either DSN into generated `.env`/`mosaic.config.json`, using only Vault/deployment references and injected variables.
|
||||
10. Provisioning review separated the external privileged platform bootstrap actor from the NOCREATEDB database-owner role and added KBN-101-00. That IaC/bootstrap card owns fresh/existing database role/ownership/grant/Vault transition evidence and is a foundation-certificate dependency.
|
||||
|
||||
## Results
|
||||
|
||||
- `pnpm exec prettier --check` on every authorized Markdown file: PASS.
|
||||
- Markdown link and whitespace checker on all seven authorized Markdown files: PASS.
|
||||
- `pnpm exec tsc --noEmit -p docs/native-kanban-sot/tsconfig.json`: PASS (frozen strict contracts unchanged).
|
||||
- Codex code review iterated through role inheritability, hash-complete migration ordering, all reachable runtime DDL entrypoints, installer DSN persistence, and platform-bootstrap ownership; each finding was incorporated into the final frozen contract/DAG. The last security review found no new KBN-101 vulnerability; its sole low finding is the pre-existing unstaged Mosaic session-lock metadata, which is excluded from this commit.
|
||||
- Commit: `82ce3252df38a687c50485f8d048b53ca8db5989` (`docs(#771): freeze database runtime role split`).
|
||||
- Pre-push queue guard: `ci-queue-wait.sh --purpose push -B main` returned `state=unknown` without failure. The push hook ran repository `pnpm typecheck`, `pnpm lint`, and `pnpm format:check`: PASS.
|
||||
- Pushed branch `docs/771-kbn101-db-role-split` at the exact commit above; no PR was opened, merged, or closed. `web1:mosaic-100` received the handoff with head, decisions, DAG, and validation.
|
||||
- Awaiting independent security/Ultron review.
|
||||
|
||||
## 2026-07-15 — rc.6 exact-head remediation session
|
||||
|
||||
- **Objective / correction:** Replace the prior planning-author handoff and close every finding in the [independent exact-head report](../reports/native-kanban-sot/kbn-101-contract-security-review-82ce325.md) against `da742ca2da4a2ff466916c818fe275c4f7ffd384`. The report is a verbatim durable copy of the task-supplied review artifact; scope remains documentation-only, `.mosaic` is excluded, and source/config/compose/CI/deployment/secrets/migrations remain untouched.
|
||||
- **Finding 1 — closed DDL control plane:** rc.6 names `mosaic-db-migrator` as the sole application/CI/test PostgreSQL DDL runner, requires `DATABASE_MIGRATION_URL` before connection/DDL, inventories `runMigrations`, Drizzle config/scripts, `db:push`, storage CLI, adapter/Gateway startup, fleet-backlog, extension probes/bootstrap, direct federated integration DDL, CI, and future scripts, and specifies route/deny/test disposition for each. Tests use runner-prepared disposable PostgreSQL or invoke that runner; `db:push` is local-disposable-only and rejects production-like URLs.
|
||||
- **Finding 2 — deployable TLS:** rc.6 freezes distinct runtime/migrator URL and CA/server leaf Vault/compose/Swarm secret identifiers, `0400` key and `0600` URL/cert/CA mount requirements, actual compose/Swarm service-DNS SANs, PostgreSQL TLS settings, legacy-client drain/termination plus `hostssl` enforcement, verified-TLS readiness ordering, fresh/existing transition, CA-overlap rotation/TLS-only rollback, and standalone plus federated/Swarm positive and missing/wrong CA/SAN/downgrade negatives. PGlite is explicitly non-PostgreSQL evidence.
|
||||
- **Finding 3 — manifest/0009:** rc.6 defines manifest v1 canonical UTF-8 serialization and raw SQL-byte SHA-256, logical journal order, manifest ownership/grants, exact one-to-one observed hash tuple mapping, non-normative physical insertion order, safe original-0009 conditions, ambiguous-effect fail-closed recovery, and the full required reconciliation/backup test matrix. It preserves shipped 0009 bytes and forbids manual ledger adoption/insertion.
|
||||
- **Finding 4 — advisory lock:** replaced `hashtext` with fixed signed-int4-safe `(1297044289,1262636593)` (`MOSA`,`KBN1`), one `max:1` runner session, close-on-crash semantics, and contention/crash/readiness/unrelated-key evidence.
|
||||
- **Finding 5 — identifiers/search path:** selects `mosaic`, exact `pg_catalog,mosaic` per pooled connection and `SET LOCAL` transactions, plans audited public-object/extension/Drizzle relocation, forbids config-derived identifiers, limits bootstrap quoting to server-side `%I` on fixed allowlist, and requires injection/pool-reset negatives.
|
||||
- **Finding 6 — safe DAG:** cards 00–07 are inactive prepared capability while owner-runtime remains N-1; KBN-101-08 is the one atomic activation release after platform roles/TLS and compatible code. Mosaic control plane/Jason alone can activate/rollback; no force-on-red, runtime bypass, or temporary compatibility survives the gate. The approved role graph, post-KBN-100 immutable certification, and KBN-105 gate remain unchanged.
|
||||
- **Review remediation:** Codex review found the legacy plaintext cutover gap, missing URL-secret bindings, historical `public` migration incompatibility, non-reproducible checkout-byte hashing, CONNECT allowlisting regression, and undocumented direct-DDL operator instructions. rc.6 now requires drain/scale-to-zero, residual non-TLS session termination, `hostssl` with no `host` rule, zero plaintext-session proof, TLS-only post-enforcement rollback, distinct named runtime/migrator secret consumers, canonical Git-blob/LF manifest bytes, a runner-only owner-controlled legacy-public bootstrap followed by `mosaic` relocation, explicit CONNECT/TEMP revocation, and KBN-101-07 replacement of direct-DDL documentation. It also required the durable exact-head report link above. Pre-existing `.mosaic` runtime state remains excluded.
|
||||
- **Validation:** Prettier on all changed Markdown, repository Markdown link/whitespace check, and strict native-kanban contract TypeScript passed before final staging; the final staged diff excludes `.mosaic`. No source-code TDD applies because this is contract-only remediation.
|
||||
|
||||
## 2026-07-15 — rc.7 residual remediation session
|
||||
|
||||
- **Objective / correction:** Close every residual in the independent exact-head rc.6 re-review at `/home/hermes/agent-work/reviews/771-kbn101-contract-rereview-45ba3d6.md` for `45ba3d6ad4d5383f457a303c05bc816144cfa48a`, without changing source, compose, CI, deployment, migration, or secret artifacts. Only the existing authorized planning/documentation paths are eligible; pre-existing `.mosaic` state remains excluded.
|
||||
- **Source-backed scope confirmed:** the active `federated-pgvector.integration.test.ts` executes `CREATE TEMP TABLE`; tracked `docker/init-db.sql` and `infra/pg-init/01-extensions.sql` both create `vector`; `migrate-tier.ts` advertises raw `CREATE EXTENSION`; and `tools/federation-harness/docker-compose.two-gateways.yml` is current plaintext two-PostgreSQL/two-Gateway topology. Current `schema.ts` has 36 default-schema `pgTable` declarations, 6 default `pgEnum` declarations, and an unqualified `vector` custom type; historical migrations contain `public` references.
|
||||
- **Plan:** (1) make the finite DDL/static-bypass inventory and `DATABASE_URL`-only denial matrix exact, including the runner-prepared persistent pgvector fixture and migrated two-gateway harness; (2) freeze executable `public`-to-`mosaic` and `mosaic_extensions` transition, Drizzle ownership, object-catalog classes/order, eligibility and rollback tests; (3) bind repository/control-plane ownership, UID/GID validation, exact artifact/mount rules, and both gateway TLS topology; (4) correct PRD acceptance mapping and cross-document rc.7 status; then run formatting, link/contract, source-path, diff, review, commit, queue guard, and push.
|
||||
- **Independent review closure:** initial Codex review found Gateway-key consumer wording, `CLAUDE.md` omission, final schema-owner set, and placeholder SANs; all are now explicit. Re-review found the legacy `0001` vector-type resolution problem and `docs/federation/SETUP.md` raw-DDL instruction; the legacy runner now uses only its fixed non-writable `pg_catalog,public,mosaic_extensions` history path, while runtime remains `pg_catalog,mosaic`, and the federation setup path is assigned to KBN-101-07/static inventory. Security review final verdict: no confident vulnerability. The review also repeated the pre-existing tracked `.mosaic` session-state concern; it remains deliberately unstaged/excluded by this task.
|
||||
- **Completion evidence:** changed Markdown is Prettier-formatted; local links and strict native-kanban TypeScript passed; source-path inventory confirmed all current referenced paths (the new `apps/gateway/Dockerfile` is explicitly a planned KBN-101-05 artifact); diff check and authorized-doc allowlist passed. No source-code TDD applies to this documentation-only remediation.
|
||||
|
||||
## 2026-07-15 — rc.8 exact residual remediation session
|
||||
|
||||
- **Objective / correction:** Close all three HIGH findings in the independent rc.7 exact-head re-review at `/home/hermes/agent-work/reviews/771-kbn101-contract-rereview2-0778eba.md` for `0778eba2db3c2dfbaca3af352b12ba0389d3552b`. Scope remains documentation-only: no source, config, Compose, CI, deployment, secret, or migration artifact changed; pre-existing `.mosaic/orchestrator` state remains excluded.
|
||||
- **Finite authority closure:** KBN-101-06 now classifies exact current source/scripts/package bins, operator docs, and deploy manifests. `packages/db/src/index.ts` has explicit removal/compile-import negative ownership; `docs/fleet/backlog-conventions.md` and `docs/PERFORMANCE.md` now remove first-use/direct-Drizzle/Gateway-startup migration instructions and point to sole runner/readiness. Byte-immutable historical SQL, PGlite-only routines, negative-test literals, vendored/generated artifacts, and labeled historical reports are exact-path/category reviewed allowlists; unknown hits fail. The contract explicitly rejects relying on a naive token scan alone.
|
||||
- **Executable and exclusive handoff closure:** KBN-101-03 exclusively owns the published `mosaic-db-migrator` bin, `packages/db/src/cli.ts`, private migrator modules, `docker/db-migrator.Dockerfile`, exact `--run|--verify|--help`, environment/argv limits, sanitized exits, and command/order tests. KBN-101-00 exclusively owns `infra/pg-bootstrap/roles.sql`, `infra/pg-bootstrap/extensions.sql`, `infra/pg-bootstrap/README.md`, and bootstrap tests. KBN-101-05 exclusively owns `tools/db/render-postgres-secrets.ts`, its tests, and deployment declarations, consuming the versioned bootstrap interface without overlap.
|
||||
- **pgvector owner closure:** `mosaic_extension_owner` is dedicated NOLOGIN, available only to the external bootstrap actor during bootstrap; fresh vector/member ownership remains there. The contract records PostgreSQL's unsupported extension-owner transfer and forbids catalog mutation, ownership adoption, and `DROP CASCADE`. Approved-owner existing extensions use verified `ALTER EXTENSION ... SET SCHEMA`; legacy runtime-owned extensions fail closed to a controlled backup/shadow/runner/copy-evidence/quiesce/final-delta/atomic-switch/read-only-rollback migration. It requires `pg_extension.extowner`, member/schema/version, and runtime/migrator/schema-owner ALTER/DROP/member-update denial tests across clean, approved-owner, legacy shadow, partial/resume/rollback, and N-1.
|
||||
- **Cross-document state:** PRD, KBN contract, shared contract, task decomposition, index, sitemap, current operator docs, and this scratchpad are rc.8-consistent. The only intended next action is a fresh independent exact-head re-review after validation/push.
|
||||
- **Validation / review:** Prettier passed for all nine changed Markdown documents; local links passed (9 documents); `pnpm exec tsc --noEmit -p docs/native-kanban-sot/tsconfig.json` passed; source-path inventory passed (20 paths: 8 current, 12 explicitly planned); finite-authority requirement checklist and `git diff --check` passed. Manual documentation/security review checked the three requested paths, private-only runner boundary/exit contract, non-overlapping 00/03/05 ownership, extension-owner denial and shadow path, and `.mosaic` exclusion. No source-code TDD applies because this is contract-only remediation.
|
||||
- **Delivery evidence:** committed `1423c2ad02b5471eab006fb4c878808e5b29c387` as `docs(#771): close role split rc.8 residuals`. Push queue guard returned `state=unknown` without error; push hook ran repository `pnpm typecheck`, `pnpm lint`, and `pnpm format:check`, all PASS; branch push succeeded. This final evidence append is committed next, then the exact remote head is verified. The only intended next action is a fresh independent exact-head re-review.
|
||||
|
||||
## 2026-07-15 — rc.9 final residual remediation session
|
||||
|
||||
- **Objective / correction:** Close the three findings in `/home/hermes/agent-work/reviews/771-kbn101-contract-rereview3-9cf5d2f.md` against exact head `9cf5d2f6641b14082dc3294e2a84d1fb4ccc019d`: move `mosaic_extensions` schema ownership to `mosaic_extension_owner`; replace all broad/conflicting KBN-101 card ownership with a complete disjoint exact-path/test manifest; and classify the current architecture-plan `db:migrate` instruction with pinned scanner mechanics. Scope remains documentation-only; no source/config/Compose/CI/deployment/secret/migration artifact and no `.mosaic` path may be modified.
|
||||
- **Plan:** inspect current tracked source topology to name only existing paths; update the normative contract first and synchronize PRD/shared/task/index/sitemap/version language; run Prettier, changed-doc links, strict contract TypeScript, source/path and manifest-overlap checks, diff allowlist, independent documentation/security review; then stage docs only, commit, queue-guard, push, and verify exact remote SHA. No source-code TDD applies because this is contract-only remediation.
|
||||
- **Closure implemented:** rc.9 makes `mosaic_extension_owner` create and own `mosaic_extensions`, `vector`, and members; the external bootstrap actor alone temporarily `SET ROLE`s for fresh/approved-owner work, while schema owner has only `USAGE` for legacy type resolution and never temporary `CREATE`. Catalog/default-ACL plus direct DDL/member denials now cover runtime, migrator, and schema owner through fresh, relocation, shadow/resume, and rollback evidence.
|
||||
- **Delivery decomposition:** Replaced broad ownership with complete disjoint 00–09 manifests, exact tests/evidence, producer-before-consumer edges, and an explicit no-intermediate-deploy N-1 activation statement. `packages/storage/src/{cli,migrate-tier}.ts` belongs only to -02; the current tracked init artifacts are retired by -02 as direct-DLL closure; -03 owns all runner/index/migrate/config/schema assets and exact compiled-bin/image mapping; -07 owns docs only; -08/-09 own evidence only.
|
||||
- **Classifier closure:** -06 has exact scanner/inventory/matrix paths, canonical inventory fields, classes/dispositions, fixed token/rule set, exact allowlist categories/restrictions, and self-test requirements for unknown, duplicate-owner, ownerless, missing-path, and historical masking cases. The architecture plan now marks direct `db:migrate` superseded and uses `mosaic-db-migrator --run`.
|
||||
- **Validation:** Prettier check, strict native-kanban contract TypeScript, changed-document local-link resolution, `git diff --check`, and an automated manifest-overlap/owner/current-source-path check passed. Targeted documentation/security review verified role ownership/default privileges/search path/preflight/legacy/shadow/rollback consistency, disjoint manifests/DAG/activation, scanner mechanics, exact bin/entrypoint, and no `.mosaic` staging intent. No source-code TDD applies because this is documentation-only remediation.
|
||||
- **Next:** stage documentation only, commit, queue-guard, push, verify exact remote SHA, then wait for fresh exact-head review.
|
||||
- **Delivery evidence:** committed `8cbad2bcd9bc7507052f74f35670ef7c8e39e44e` as `docs(#771): close role split rc.9 residuals`; `ci-queue-wait.sh --purpose push -B main` returned `state=unknown` without error; push-hook `pnpm typecheck`, `pnpm lint`, and `pnpm format:check` all passed; push succeeded and `origin/docs/771-kbn101-db-role-split` resolved to that exact SHA. Pre-existing `.mosaic/orchestrator/{mission.json,session.lock}` remains modified but intentionally unstaged/excluded. The only next action is a fresh independent exact-head re-review.
|
||||
|
||||
## 2026-07-15 — rc.10 Ultron NO-GO remediation intake
|
||||
|
||||
- **Objective / scope:** Close only HIGH-1 and HIGH-2 in `/home/hermes/agent-work/reviews/771-kbn101-ultron-11f09a1.md` against exact head `11f09a15e43e72afda6a0374668996b4bda9e536`. Documentation only: preserve approved content; no source/config/Compose/CI/deploy/secret/migration edits and no `.mosaic` staging.
|
||||
- **Plan:** (1) replace the impossible `NOLOGIN NOSUPERUSER` extension owner with the exact non-login, zero-member `NOLOGIN SUPERUSER` external-control exception and document the non-delegable superuser residual; (2) require the audited external superuser session to `SET ROLE`/`RESET ROLE` for fresh, approved-owner, and shadow extension work, then prove catalog ownership and all service-role denial; (3) assign the active migrate-tier guide exclusively to KBN-101-07, add its active secure route to the -06 inventory/matrix/scanner schema, and freeze `--target-url-file /run/secrets/mosaic_migrate_target_url` plus pre-migrated target/dedicated non-DDL importer requirements; (4) synchronize PRD/shared/tasks/index/sitemap/guide and rc.10 status; (5) validate formatting, links, contracts, source paths, finite operator inventory, diff, review, commit, queue guard, push, and exact remote SHA.
|
||||
- **Target-image evidence before edits:** local `pgvector/pgvector:pg17` control file reports `default_version = '0.8.2'`, `relocatable = true`, and no `trusted`/`superuser` override (untrusted PostgreSQL extension). An isolated PostgreSQL 17 container proved a `NOLOGIN SUPERUSER` `mosaic_extension_owner` can create `mosaic_extensions` and `vector` under external-superuser `SET ROLE`, returns to the external session after `RESET ROLE`, has `rolcanlogin=false`, `rolsuper=true`, zero role members, exact extension/schema and owner-bearing-member ownership, and denies `SET ROLE`, `ALTER EXTENSION`, `DROP EXTENSION`, and schema ownership changes to runtime, migrator, schema owner, and data importer. Ownerless PostgreSQL catalog member classes (`pg_am`, `pg_cast`) were intentionally not misrepresented as ownable members.
|
||||
- **TDD decision:** skipped as not applicable: this is a documentation-only contract remediation. Future KBN-101-00/-02/-06 tests are specified as the situational evidence; no source/test artifact is permitted in this task.
|
||||
- **Final scope correction:** Per control-plane direction, remediation remains bounded to the two Ultron findings. The active guide is explicitly a non-operative KBN-101 contract until its owned implementation/activation lands; no additional design, source, CI, deployment, secret, or test artifact was added.
|
||||
- **Validation evidence:** target-image/container role proof PASS (pgvector `0.8.2`, `relocatable=true`, trusted absent/untrusted; external-superuser `SET ROLE`/`RESET ROLE`; exact extension/schema/owner-bearing-member ownership; zero membership and service-role denials). Changed-doc Prettier, strict native-kanban contract TypeScript, local-link resolver (8 docs), finite operator-doc inventory (one active KBN-101-07 route with no credential argv in executable blocks), source-path check (6 current paths), and `git diff --check` PASS. Pre-existing `.mosaic/orchestrator/{mission.json,session.lock}` remains intentionally excluded.
|
||||
|
||||
## 2026-07-15 — rc.11 exact-head re-review remediation intake
|
||||
|
||||
- **Objective / scope:** Close only HIGH-1 and HIGH-2 in `/home/hermes/agent-work/reviews/771-kbn101-contract-rereview5-f60144e.md` against `f60144eb3eab6234ab01bda592052081c777897e`: target-bind the non-DDL tier importer with a runner-produced signed attestation, and disposition every current non-normative documentation scanner hit, including the active user-guide route and federation historical status. Documentation only; no source/config/Compose/CI/deployment/secret/migration edits and no `.mosaic` staging.
|
||||
- **Frozen decision:** `mosaic-db-migrator --verify` is trusted only after TLS/identity/manifest/schema verification and signs a credential-free JCS/Ed25519 v1 artifact from a runner-only fixed root-owned private-key file. The artifact binds secret version/exact URL-file digest, canonical TLS/CA/SPKI/server/database/importer/manifest/schema identity, issued/expiry/nonce, and producer build/correlation; importer gets pinned public key plus artifact only. It validates both files and all bindings before target connection, opens/digests/connects from one in-memory URL read, validates server identity before DML, and distinguishes zero connection from zero DML. Key overlap/revocation, secret-rotation invalidation, replay cache, atomic rename, and sanitized errors are mandatory.
|
||||
- **Ownership:** -03 owns producer/signing DTO/tests; -02 importer interface/verification tests; -05 key/artifact mounts/render tests; -06 inventory/matrix and non-masking scanner tests; -07 operator guide. The exact manifests remain disjoint.
|
||||
- **Operator correction:** `storage migrate` is schema-wrapper delegation only; legacy `--from hot --to cold` tier-copy guidance is unavailable. Secure tier copy is `migrate-tier` with `--target-url-file` plus `--target-attestation-file`. Federation M1 task language is status-only and adjacent KBN-101 text says it authorizes no current DDL.
|
||||
- **TDD decision:** skipped as not applicable: this bounded task changes documentation only. Future -02/-03/-05/-06 tests are specified as the required implementation evidence.
|
||||
- **Validation / review:** Prettier PASS on all 18 changed Markdown/root-doc files; `pnpm exec tsc --noEmit -p docs/native-kanban-sot/tsconfig.json` PASS; changed-doc local-link resolver PASS (71 links); full current docs scanner/disposition PASS (10 non-normative paths, 4 normative-contract paths; no unknown active command); legacy `storage migrate --from` and raw target-URL bypass scan PASS; attestation field/interface assertion PASS; exact source ownership/manifest-overlap assertion PASS; `git diff --check`, docs-only scope, and secret-leak scan PASS. Manual documentation/security review verified key isolation, JCS/detached signature, secret-file hash as non-secret evidence, verification ordering/TOCTOU/replay/rotation, no connection vs zero DML, non-masking scanner class, status-only federation history, and no regression to pgvector closure. No source-code TDD applies.
|
||||
- **Delivery evidence:** committed `6227f076c819bd124383851633b16d4ef9c88a98` as `docs(#771): bind tier importer to verified target`; staged scope was 18 documentation files only and excluded `.mosaic`. Pre-push queue guard returned `state=unknown` without failure. Push hook ran repository `pnpm typecheck`, `pnpm lint`, and `pnpm format:check`: PASS. Branch push succeeded. Verify the exact remote SHA after this final delivery-evidence append, then idle for independent exact-head re-review and Ultron reverify. `.mosaic/orchestrator/{mission.json,session.lock}` remains pre-existing and excluded.
|
||||
|
||||
## 2026-07-15 — rc.12 bounded deployable-importer/SETUP remediation plan
|
||||
|
||||
- **Objective / scope:** Close the two HIGH findings in `/home/hermes/agent-work/reviews/771-kbn101-contract-rereview6-65663d4.md` against exact head `65663d4f72f2ace5148bce9aeba04b5a8d5beee9`. Documentation/tracking only; preserve every earlier closure; do not modify source, deployment, Compose, CI, migration, Vault, or `.mosaic` artifacts.
|
||||
- **Plan:** (1) make KBN-101-05 own canonical KV-v2 importer URL/version provenance, separate immutable generation-pinned renderer consumers, importer CA/public-key/attestation mounts, fixed importer/migrator identities, safe-fd lifecycle, isolation/rotation/TOCTOU/error evidence, and -02/-03/-06 handoffs; (2) convert `docs/federation/SETUP.md` to a non-operative N-1 reference with only the required external-bootstrap → TLS/roles → runner `--run` → `--verify` → Gateway-readiness sequence; (3) broaden scanner grammar plus path-specific semantic negatives so indirect first-boot/startup/init/Compose authority cannot be masked by a named, normative, or status record; (4) synchronize PRD/shared/tasks/index/sitemap/federation task state and this scratchpad; (5) run formatting, links, strict contracts, complete-doc scanner/semantic assertions, diff/operator inventory, material/manifest overlap, review, docs-only stage, commit, queue guard, push, and exact remote-SHA verification.
|
||||
- **TDD decision:** skipped because this bounded change is documentation-only; the affected -02/-03/-05/-06 implementation tests and scanner semantic fixtures are specified as mandatory future evidence.
|
||||
- **Review correction:** independent Codex review found the initial `10003` producer → immutable `10002` importer artifact handoff impossible. rc.12 now specifies the required privileged deployment handoff controller: after runner success it safe-opens/verifies producer artifact plus generation, exact-byte copies/fsyncs/atomically renames to a distinct `10002:10002` `0400` importer mount, seals it read-only, and starts no importer on partial/wrong-generation/owner/mode failure. It receives only a root-owned non-secret expected-version/URL-digest/generation descriptor plus public verifier key, never URL bytes/private key; producer/importer share no writable file or mount. Dry-run nonce consumption now requires fresh `--verify` and artifact before `--yes`; the active-route schema requires `targetCredentialVersionFile`. Pre-existing `.mosaic` state is confirmed excluded from staging.
|
||||
- **Validation result:** changed-doc Prettier and `git diff --check` PASS; strict `pnpm exec tsc --noEmit -p docs/native-kanban-sot/tsconfig.json` PASS; contract/SETUP material and non-operative semantic assertions PASS. Pending final docs-only stage, commit, queue guard, push, and remote-head verification.
|
||||
|
||||
## 2026-07-15 — rc.13 MILESTONES semantic-scan remediation intake
|
||||
|
||||
- **Objective / scope:** Close only HIGH-1 from `/home/hermes/agent-work/reviews/771-kbn101-contract-rereview7-7365dcf.md` against `7365dcf15c09262a46132b9c011769ad98243641`. Documentation/tracking only: assign `docs/federation/MILESTONES.md` exclusively to KBN-101-07 and its exact former startup-extension wording to the KBN-101-06 semantic fixture/inventory; replace the wording with a non-operative historical/status disposition. No source, deployment, Compose, CI, Vault, migration, provider, or `.mosaic` artifact is authorized.
|
||||
- **Frozen remediation:** runtime/startup extension provisioning is superseded and forbidden. The sole eligible sequence is external bootstrap → TLS/roles → `mosaic-db-migrator --run` → `mosaic-db-migrator --verify` → Gateway readiness. The MILESTONES record authorizes no current DDL, Compose/init, or startup path. The scanner must prove the exact former wording fails before any inventory/status-only mask and rerun its full current-doc operator/deploy-manifest scan outside reports/scratchpads.
|
||||
- **Plan:** update only MILESTONES plus the exact KBN-101 contract/inventory/manifests and necessary PRD/shared/task/index/sitemap/version/status references; run full lexical+semantic scan, Prettier, links, strict contract TypeScript, diff and manifest-overlap checks; stage docs only (excluding pre-existing `.mosaic`), commit, queue-guard, push, and verify the exact remote SHA. No source-code TDD applies because this bounded task changes documentation only.
|
||||
- **Remediation result (pre-commit):** `MILESTONES.md` now makes runtime/startup extension provisioning superseded and forbidden, with only external bootstrap → TLS/roles → `mosaic-db-migrator --run` → `--verify` → Gateway readiness; it authorizes no current DDL/Compose/init/startup path. The KBN-101-07 manifest/inventory is exclusive and KBN-101-06 documents the exact former wording as a semantic negative that fails before inventory masking. Full current-doc scan outside reports/scratchpads: 103 Markdown files, 10 lexical-hit paths classified, 2 owned Compose-before-runner references, and zero ownerless indirect/literal routes. Prettier, strict native-kanban TypeScript, local links (64/0), diff check, and 95-path manifest-overlap reconstruction (0 overlaps; MILESTONES only -07) passed. Pre-existing `.mosaic/orchestrator/{mission.json,session.lock}` remains excluded.
|
||||
- **Delivery checkpoint:** committed remediation as `237bac81c93dc4305470cea23a67e4ced730bd61` (`docs(#771): close MILESTONES startup authority`). Push queue guard returned `state=unknown` without failure; the push hook ran repository `pnpm typecheck`, `pnpm lint`, and `pnpm format:check`, all PASS; the remote branch resolved to that exact SHA. This final evidence append is committed next, then the exact remote head is verified and the branch waits for independent exact-head rereview/Ultron reverify. `.mosaic` remains unstaged.
|
||||
|
||||
## 2026-07-15 — rc.13 current-document safety remediation intake
|
||||
|
||||
- **Objective / scope:** Close only HIGH-1 and HIGH-2 in `/home/hermes/agent-work/reviews/771-kbn101-contract-rereview8-aeacc70.md` against exact head `aeacc702353740aad0f2f086974cc0670e360d1d`. This is documentation/tracking only. Preserve all prior gates; do not edit source, Compose, deployment artifacts, CI, migrations, Vault data, reports, or `.mosaic`.
|
||||
- **Source-backed decision:** Current `docker-compose.yml` mounts `infra/pg-init` into PostgreSQL init and that SQL creates `vector`; it cannot be used as a current PostgreSQL start route before KBN-101 bootstrap/runner artifacts exist. The checked-in configuration declares a supported `local` PGlite tier (`DEFAULT_LOCAL_CONFIG` and `tier-detection` both establish in-process PGlite with no external service probe), so docs may retain a local/PGlite route and start only non-PostgreSQL Compose services such as `valkey`.
|
||||
- **Plan:** (1) replace the README and dev-guide Compose-first PostgreSQL instructions with a PGlite/no-PostgreSQL developer path and an explicit held PostgreSQL/federated future activation sequence; (2) replace the deployment quick-start and bare-metal production procedure with non-operative status, no production `.env`/automatic dotenv/`EnvironmentFile`/credential export-or-argv/restart guidance, and only a non-executable future renderer/Vault generation-pinned process-exec or `LoadCredential` schematic; (3) expand KBN-101-06/-07 semantic fixture/disposition language to fail the exact former README/dev/deployment Compose-first sequences and production credential routes before ownership/status masking; (4) synchronize PRD/shared/tasks/index/sitemap/status/version and this scratchpad; (5) run formatting, links, strict contract TypeScript, full current-doc lexical+semantic scan outside reports/scratchpads, manifest-overlap, review, docs-only stage, commit, queue guard, push, and exact remote-SHA verification.
|
||||
- **TDD decision:** no source or fixture implementation may be changed in this documentation-only remediation. The -06 future fixture requirements are frozen as acceptance evidence; validation here is static semantic inventory plus documentation quality gates.
|
||||
- **Correction from independent review:** The initial local-Gateway PGlite wording was unsafe. `apps/gateway/src/main.ts` loads daemon/root/app-local environment files before tier selection; an inherited daemon `DATABASE_URL` can select PostgreSQL, whose current startup reaches extension creation and migrations. No source is authorized in this docs-only task. The remediation therefore holds Gateway/Web local startup, preserves only PGlite data-layer plus selected non-PostgreSQL Compose work, and assigns KBN-101-02 the fail-closed daemon/inherited/root/app-local DSN and non-local-tier rejection before connection/DDL, with a regression proof. The future renderer boundary remains KBN-101-05.
|
||||
- **Remediation evidence:** Removed active PostgreSQL Compose-first and production credential guidance from README, CLAUDE, dev/deployment, and the residual historical TUI/MCP routes; local documentation now permits only PGlite data-layer/non-PostgreSQL Valkey work while Gateway/Web startup is explicitly held. KBN-101-06/-07 now freeze exact former README/dev/deployment Compose sequences plus production credential patterns as pre-classification semantic negatives. Independent review surfaced the current daemon/root/app dotenv loader as an unsafe source boundary; no source is authorized here, so the docs hold that startup and assign fail-closed removal/regression proof to KBN-101-02.
|
||||
- **Validation:** Prettier PASS (12 changed docs); local-link resolver PASS (71 links); `pnpm exec tsc --noEmit -p docs/native-kanban-sot/tsconfig.json` PASS; full README/CLAUDE/docs inventory PASS (104 documents, 0 active non-normative Compose/init/production-credential violations); manifest reconstruction PASS (10 cards, 95 declared path tokens, 0 overlaps); `git diff --check` PASS. Pre-existing `.mosaic/orchestrator/{mission.json,session.lock}` remains intentionally unstaged.
|
||||
- **Final route correction:** Held the residual MCP environment/restart and historical TUI smoke-test routes after review showed they could bypass the Gateway local-start hold; no bearer-token-over-HTTP or Gateway restart route remains in this remediation scope.
|
||||
- **Delivery:** committed `7cc156b777189ee89448e4d569a8b3f69560a240` (`docs(#771): hold unsafe database startup routes`) and `d8f935c20ade835aa3ec03fe5d6961885d8b5f0b` (`docs(#771): record final route correction`). Push queue guard returned `state=unknown` without failure; both pushes completed and the remote matched `d8f935c` before this final delivery-evidence append. `.mosaic/orchestrator/{mission.json,session.lock}` remains pre-existing and unstaged. Await a fresh independent exact-head re-review/Ultron verification.
|
||||
|
||||
## 2026-07-15 — rc.15 exact one-finding runner/legacy-CI remediation intake
|
||||
|
||||
- **Objective / scope:** Close only HIGH-1 in `/home/hermes/agent-work/reviews/771-kbn101-contract-rereview9-be0ebfd.md` against `be0ebfdc6a2b32a0ab6989117ebbb12f43854d71`. Documentation/tracking only: no source, Compose, CI, deployment, migration, Vault, reports, or `.mosaic` edit.
|
||||
- **Plan:** Replace imperative current runner routes in the architecture plan and PERFORMANCE with one explicit non-operative future procedure; make fleet backlog current behavior PGlite-only and PostgreSQL held; classify README's checked-in direct CI `db:migrate` as legacy N-1/uncertified/non-authorizing pending KBN-101-06 removal; then extend the future KBN-101-06 lexical/semantic inventory contract so unqualified runner/current-CI authority fails before masking while only the complete named held procedure passes. Synchronize required contract/PRD/shared/task/index/sitemap state, run full docs scan and document gates, stage docs only, commit, queue-guard, push, and verify remote SHA.
|
||||
- **TDD decision:** not applicable: the user authorizes documentation only and the named -06 fixture/inventory files do not yet exist; the contract records their required future implementation evidence.
|
||||
- **Remediation / review closure:** Architecture, PERFORMANCE, federation SETUP/MILESTONES, deployment/dev/migrate-tier, and README now use one Markdown-bounded `Held future procedure` form where needed: non-operative/no-current-command-authority; KBN-101-00/-03/-05; external bootstrap → TLS/roles → `mosaic-db-migrator --run` → `mosaic-db-migrator --verify` → Gateway/Compose readiness. Any runner hit outside that section is a -06 semantic failure. Fleet backlog current behavior is PGlite-only. README accurately records the checked-in direct CI migration as an active, isolated-disposable-database, uncertified legacy N-1 DDL exception that is non-authorizing as an operator route and pending -06 removal; it no longer falsely claims the current CI role lacks DDL capability. Independent Codex review found and this pass closed the readiness-endpoint, standalone-verify, CI-factuality, and scanner-boundary findings. Its only residual finding concerns pre-existing tracked `.mosaic/orchestrator` runtime state, which is explicitly excluded and unstaged by task scope; security review found no vulnerability.
|
||||
- **Validation:** changed-doc Prettier PASS; `pnpm exec tsc --noEmit -p docs/native-kanban-sot/tsconfig.json` PASS; changed-doc local links 72/0; `git diff --check` PASS; full README/CLAUDE/docs lexical+semantic scan outside reports/scratchpads PASS (106 Markdown documents; 7 operator documents with runner tokens; zero unqualified future-runner/current-CI authority routes); KBN-101 manifest check PASS (10 dependency-ordered cards; -07 docs/-06 fixtures disjoint); docs-only allowlist PASS (16 docs, pre-existing `.mosaic` excluded).
|
||||
- **Delivery evidence:** committed `d857463a8a4658e34a77177737860cf82cc26ac6` (`docs(#771): hold unimplemented runner routes`). Pre-push queue guard returned `state=unknown` without failure; the push hook ran repository `pnpm typecheck`, `pnpm lint`, and `pnpm format:check`, all PASS. Push succeeded and `origin/docs/771-kbn101-db-role-split` matched `d857463a8a4658e34a77177737860cf82cc26ac6`. This evidence append is committed and pushed next; `.mosaic/orchestrator/{mission.json,session.lock}` stays pre-existing, unstaged, and excluded. Await exact-head independent re-review/Ultron reverify.
|
||||
|
||||
## 2026-07-15 — rc.16 exact one-finding generic-wrapper remediation intake
|
||||
|
||||
- **Objective / scope:** Close only HIGH-1 in `/home/hermes/agent-work/reviews/771-kbn101-contract-rereview10-18e253c.md` against exact head `18e253c8790bdbb5bc30a06c116472213b83b22f`. Documentation/tracking only: no source, Compose, CI, deployment, migration, Vault, report, or `.mosaic` change.
|
||||
- **Source-backed correction:** `packages/storage/src/cli.ts` currently labels `storage migrate` a thin wrapper for `pnpm --filter @mosaicstack/db db:migrate` and executes that direct Drizzle command with `execSync`; no `mosaic-db-migrator` executable exists. Therefore the README commented form and user-guide executable form must not describe runner delegation or provide current command authority.
|
||||
- **Plan:** Remove the current wrapper command from README/user-guide command guidance; record it as legacy N-1, uncertified, non-operative, and forbidden pending KBN-101-02/-03/-06/-08 activation. Retain only the held future ordered bootstrap → TLS/roles → runner `--run` → `--verify` → readiness sequence and the separately held secure migrate-tier route. Extend KBN-101-06's future semantic fixture/matrix with both exact former forms (including the README commented code-fence form), requiring their failure before inventory/status masking and a source-consistency assertion that direct Drizzle wrapper source cannot be described as runner delegation. Synchronize status/version references only where required, then run document gates, stage docs only, commit, queue-guard, push, and verify the remote SHA.
|
||||
- **TDD decision:** not applicable: this bounded task changes documentation only; the required future -06 semantic/source-consistency fixtures are specified as implementation acceptance evidence.
|
||||
- **Remediation / validation:** README and user-guide remove the generic wrapper from command guidance and state the direct-Drizzle current-source truth, legacy-N-1/uncertified/non-operative MUST-NOT-INVOKE boundary, named -02/-03/-06/-08 activation cards, future external-bootstrap → TLS/roles → runner `--run` → `--verify` → readiness sequence, and separately held secure migrate-tier route. The KBN-101 rc.16 contract records both exact former forms (README commented code fence and user-guide executable code fence), requires failure before inventory/ownership/status masking, and requires the direct-Drizzle/no-runner-bin source-consistency proof. Prettier passed on all nine changed Markdown documents; changed-doc local links passed (72/0); strict native-kanban contract TypeScript and `git diff --check` passed; full README/CLAUDE/docs scan outside reports/scratchpads passed (106 documents, zero non-normative executable generic-wrapper or false runner-delegation route); and manifest validation passed (10 cards, 90 exact tokens, zero overlaps). Pre-existing `.mosaic/orchestrator/{mission.json,session.lock}` remains excluded.
|
||||
80
docs/scratchpads/issue-766-exact-fleet-comms.md
Normal file
80
docs/scratchpads/issue-766-exact-fleet-comms.md
Normal file
@@ -0,0 +1,80 @@
|
||||
# Issue 766 — exact cross-harness fleet comms targeting
|
||||
|
||||
- **Issue:** #766
|
||||
- **Branch:** `fix/766-exact-fleet-comms`
|
||||
- **Worktree:** `/home/jarvis/src/stack-issue-766`
|
||||
- **Delivery boundary:** source/tests/docs only; no live tmux, session, or fleet actions; leave uncommitted for independent review.
|
||||
|
||||
## Objective
|
||||
|
||||
Replace inference-prone fleet onboarding guidance with one roster-resolved contract that gives Claude Code, Codex, OpenCode, and Pi the same authoritative local identity and exact executable command for every known peer.
|
||||
|
||||
## Plan
|
||||
|
||||
1. Add issue-specific normative requirements to `docs/PRD.md` before source changes; do not modify orchestrator-owned `docs/TASKS.md`.
|
||||
2. Extract the existing v1 roster parsing/normalization into one lightweight shared resolver used by both fleet commands and runtime comms composition.
|
||||
3. Write failing contract tests for explicit SSH-only cross-host targeting, global/default socket authority, authoritative identity, unknown-peer failure, no operational metavariables, and four-harness parity.
|
||||
4. Make source `TOOLS.md` non-operational and marker-versioned; prove fresh installation preserves that exact contract and composition detects a stale installed copy without rewriting it.
|
||||
5. Render a deterministic comms generation and document comparison/relaunch handling; never rewrite an active session.
|
||||
6. Run focused Vitest and shell exact-target tests, then package/repository typecheck, lint, format, test, and build gates as relevant.
|
||||
7. Reconstruct the exact uncommitted tree, including untracked files, for independent review and remediate findings without committing.
|
||||
|
||||
## Contract decisions
|
||||
|
||||
- `tmux.socket_name` is the one supported socket authority for every local fleet session. A per-agent `socket`, when present for compatibility, must equal that global value; independent per-agent sockets fail closed because the runtime does not provision them. A named socket renders `-L`, while the empty literal default renders no `-L`.
|
||||
- A peer is same-host only when its resolved host equals the current roster member's resolved host. Every host-omitted member resolves against the stable local fleet-host baseline, never against the viewer's explicit host. Same-host rows never render `-H`.
|
||||
- A cross-host row requires that peer's explicit roster `ssh`; absence is a contract error. Never substitute `host` as an SSH target.
|
||||
- The current member's explicit roster `host` wins; otherwise the local machine's short hostname is the baseline for host-omitted local members.
|
||||
- Unknown members/peers return a deterministic error listing exact known names and an exact self-scoped discovery command. No fuzzy session lookup.
|
||||
- Exact command fields are structurally constrained to safe targeting grammars and shell-rendered as individual arguments. Unsafe host/SSH/socket values fail roster normalization rather than entering executable guidance.
|
||||
- Existing installed `TOOLS.md` remains user-owned during ordinary keep-mode updates. Currency requires the expected source and installed marker/version plus bounded SHA-256 byte identity. Explicit `mosaic update --repair-tools` is the supported current-version recovery path: it makes a digest-qualified no-clobber backup, restores the contract and regular executable helper, and does not rewrite active context.
|
||||
- The v1 resolver preserves and validates `tmux`, `discord`, and `matrix` connector blocks in YAML and JSON; conflicting snake/camel aliases fail closed unless their values are identical.
|
||||
- JSON roster fallback occurs only when `roster.yaml` is absent. Keep-mode reseed preserves both formats, and relaunch discovery uses the same canonical resolver.
|
||||
- The helper is inspected without following symlinks and must be a regular executable file. Missing, directory, symlink, and non-executable installations fail closed with deterministic repair guidance.
|
||||
- Active contexts carry a deterministic comms generation. Operators compare it to `mosaic agent comms-block <exact-agent>` output; mismatch means stale and requires an explicit exact-agent relaunch.
|
||||
|
||||
## Risks
|
||||
|
||||
- Import cycles if runtime composition imports the command-heavy `fleet.ts`; mitigate with a lightweight shared roster module and re-export compatibility.
|
||||
- Existing schema prose allowed independent per-agent sockets even though runtime provisioning used one global socket; constrain compatibility declarations to the global value and preserve empty-global default behavior.
|
||||
- Remote inventory may be incomplete. Fail composition closed for an unreachable cross-host row rather than generating a guessed command.
|
||||
- `TOOLS.md` is user-seeded and intentionally preserved. Detect/report drift instead of overwriting custom content.
|
||||
|
||||
## Planned evidence
|
||||
|
||||
- `comms-onboarding.spec.ts`: resolver/renderer/failure/generation contracts.
|
||||
- `compose-contract.spec.ts`: identical authoritative comms section for all four harnesses and stale installed-contract reporting without mutation.
|
||||
- `file-adapter.test.ts`: source-to-fresh-install byte equality and preservation of customized installed `TOOLS.md`.
|
||||
- Existing `agent-send.test.sh`, socket isolation, and tmux runtime transport tests.
|
||||
- Repository quality gates and independent uncommitted-tree review.
|
||||
|
||||
## Evidence log
|
||||
|
||||
- Preflight collision scan: no issue-766 local/remote branch, worktree, or open PR collision before branch creation.
|
||||
- Isolated branch created from fetched `origin/main` at `4990905`; original checkout not edited.
|
||||
- One strict v1 resolver now serves fleet commands and communications composition; roster writes preserve `host`, `ssh`, and `socket`.
|
||||
- Exact renderer covers authoritative self identity, global/default socket authority, rejected independent sockets, stable hostless-peer resolution, same-host omission of `-H`, explicit-SSH-only cross-host rows, shell-safe argv rendering, deterministic generations, and fail-closed unknown/missing targets.
|
||||
- Real framework `defaults/TOOLS.md` is tested byte-equal through a fresh `FileConfigAdapter` install, the installed helper is executable, and the final Pi contract contains the same source contract plus exact generated command; separate parity coverage proves byte-equivalent comms sections for Claude Code, Codex, OpenCode, and Pi.
|
||||
- Second review remediation adds strict connector/alias coverage, full-semantic generation coverage, ENOENT-only fallback, no-follow helper validation, unconditional current-version repair, digest-qualified no-clobber backups, and marker/version-gated currency.
|
||||
- The helper, roster, installed TOOLS, and framework source files are read with canonical containment, every existing ancestor and target rejected if symlinked, `O_NOFOLLOW` descriptor reads, inode stability checks, and effective-identity execute access. Read-only TOOLS status treats source/installed symlinks as unavailable without following or rewriting them.
|
||||
- Explicit repair validates both bundled inputs before destination creation, stages backup/TOOLS/helper plus exact-mode rollback files before any persistent file commit, revalidates destination identity at each commit boundary, installs the digest backup without clobber, and removes or exactly rolls back every committed output on injected failure. `changed: false` is returned only after full cleanup; cleanup/rollback failure is reported as `changed: true`.
|
||||
- Connector schema and runtime normalization require kind-matching settings and reject inactive connector blocks. Keep-mode installers preserve only exact `roster.yaml`, `roster.json`, `agents/`, and `run/` paths while refreshing framework `roster.schema.json`; shell evidence covers byte preservation and schema refresh.
|
||||
- Solo contracts render normalized role/class plus explicit no-peer/no-remote authority boundaries; composed-contract evidence keeps role Mandate/Boundaries before Fleet Comms.
|
||||
- Operational documentation and CLI metavariable now use `mosaic agent comms-block <exact-member>`; historical issue-633 scratchpad text remains historical.
|
||||
- The latest independent review rejected synthetic tree `556ae4ea04f2715a4e9d381f3cafaf4c8b991b2e` on three mandatory findings: installed `TOOLS.md` could be read through target/ancestor symlinks before unsafe status was reported; ambient class/tool-policy state could split identity authority from the canonical roster member; and the connector schema admitted empty or whitespace-only Discord/Matrix strings rejected by runtime parsing.
|
||||
- Red-first reproduction proved all three findings with 20 failures and 79 passes. Remediation routes installed `TOOLS.md` through the bounded secure regular-file reader before composition, resolves one exact canonical fleet identity for persona/tool policy/normalized class/Fleet Comms, rejects canonicalized ambient class mismatches, canonicalizes compatibility classes during roster parsing, and aligns parser/schema non-whitespace requirements.
|
||||
- Four-runtime coverage proves unsafe target and ancestor symlink content is omitted without mutation, while Claude Code, Codex, OpenCode, and Pi all project the same canonical member authority. Connector parser/schema coverage includes empty and whitespace-only Discord `channel_id` and Matrix `homeserver_url`, `user_id`, and `room_id` values.
|
||||
- Remediated focused gates passed: 99/99 across the two finding-focused suites plus connector schema regression PASS; the six changed-suite matrix passed 341/341; secure-file/transaction coverage remains green, including 28/28 transactional repair tests; installer migration passed 21/21.
|
||||
- Mosaic package suite passed 906/906. Shell/runtime regressions passed: `agent-send.test.sh` `PASS=11 FAIL=0`; named-socket isolation; matrix/tmux transport 12/12 (Matrix 5/5, tmux 7/7).
|
||||
- Final repository gates passed: format check; typecheck 42/42 tasks; lint 23/23; tests 42/42 tasks (Mosaic 906/906, gateway 628 passed/12 skipped); build 23/23.
|
||||
- A subsequent immutable review of tree `aa6414123643a504145fce6ac1d66f0b535feb5e` found one roster-authority blocker: a canonical member with omitted `tool_policy` inherited ambient `MOSAIC_AGENT_TOOL_POLICY`. Red-first four-runtime coverage failed 4/39 specifically on the leaked operator-interaction policy. Composition now branches on canonical membership: fleet launches use only `canonicalMember.toolPolicy` (including canonical absence), while genuinely non-fleet launches retain ambient fallback.
|
||||
- Final remediated gates passed: four-runtime regression 39/39; six changed-suite matrix 345/345; connector schema regression PASS; Mosaic package 910/910; installer migration 21/21; `agent-send.test.sh` 11/11; named-socket isolation PASS; Matrix/tmux transport 12/12; repository format PASS; typecheck 42/42 tasks; lint 23/23 tasks; tests 42/42 tasks; build 23/23 tasks.
|
||||
- No live tmux/session/fleet mutation, commit, push, PR mutation, issue mutation, context mutation, or reviewer launch performed.
|
||||
- Exact synthetic-tree reconstruction and frozen evidence are included in the coordinator handoff.
|
||||
- Sole-remediation preflight reverified the clean committed checkout at head `0dc47cac92c93a3ffd39ba9dd6685ac4165f6361`, tree `538de6ccce1f8c44ba288a7493286e63a3413e75`, branch `fix/766-exact-fleet-comms`; issue and PR state were read only through Mosaic wrappers.
|
||||
- Deterministic red-first ancestor substitution swapped validated `root/tools` for an external symlink immediately after `lstat`; current head returned `external marker` (`1 failed, 4 passed`) before implementation.
|
||||
- Secure reads now hold `/` and every root/descendant directory descriptor, traverse appended components through Linux `/proc/self/fd` with `O_DIRECTORY|O_NOFOLLOW`, and read plus effective-identity execute-check the same final descriptor. Non-Linux or unavailable proc-fd capability fails closed; stable errors redact managed paths while retaining Node `code` compatibility for missing/non-executable repair behavior.
|
||||
- Added deterministic root-selection, descendant-ancestor, and final-target substitution coverage. All return trusted descriptor-bound bytes after rename/symlink replacement; the race suite passed 50/50 repeated runs.
|
||||
- Isolated CLI verification drove `mosaic agent --mosaic-home <fixture> comms-block self` while repeatedly swapping `fleet/` with an external symlink: `trusted=2 fail_closed=10 external_marker=0`; a persistent symlink ancestor exited 1 with a redacted unsafe-ancestor error. No live fleet state was used or mutated.
|
||||
- Remediation gates: focused secure-file/comms/launch/tmux/Matrix `110/110`; full `@mosaicstack/mosaic` `914/914`; package and repository typecheck pass (`42/42` repository tasks); package and repository lint pass (`23/23` repository tasks); repository format check and `git diff --check` pass.
|
||||
- Independent review found one production hardening blocker (nonblocking final open), one redacted-error blocker, and a deterministic ancestor-test gap. Remediation added `O_NONBLOCK`, normalized execute errors while preserving errno, proved the ancestor hook fires, and added final-target substitution coverage; post-remediation review evidence is clean on the production invariant.
|
||||
@@ -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`.
|
||||
|
||||
@@ -5,20 +5,20 @@ Tool suites live at `~/.config/mosaic/tools/<suite>/`. This is the index only.
|
||||
read it (or the relevant service guide) when your task actually touches that service.
|
||||
Project-specific tooling belongs in the project's `AGENTS.md`, not here.
|
||||
|
||||
## ⚡ Most-used fleet tools (reach for these FIRST — don't hand-roll)
|
||||
## Most-used fleet tools (reach for these first)
|
||||
|
||||
You are a Mosaic fleet agent. These cover the highest-frequency cross-agent and git-provider
|
||||
tasks — use them before improvising with raw `tmux send-keys`, raw `tea`/`gh`/`glab`, or `curl`.
|
||||
<!-- fleet-comms-contract: 1 -->
|
||||
|
||||
**1. Message another agent** → `tools/tmux/agent-send.sh` (NOT raw `tmux send-keys`):
|
||||
You are a Mosaic fleet agent. Use the runtime-composed **Fleet Comms — authoritative exact targets**
|
||||
section for inter-agent messaging. It renders your authoritative local host, exact agent/session, resolved
|
||||
tmux socket, installed helper path, generation, and one executable command per known peer.
|
||||
|
||||
```bash
|
||||
tools/tmux/agent-send.sh -s <target-session> -m "message" # or -f <file> to send a file's contents
|
||||
```
|
||||
Select only a peer row rendered for your exact roster identity. Never invent, substitute, or fuzzy-match
|
||||
a host, session, socket, SSH destination, or helper path. If a peer is absent, stop and run the exact
|
||||
self-scoped discovery command shown in that composed section; report the peer as unknown if it remains
|
||||
absent. Do not use raw `tmux send-keys` for fleet messaging.
|
||||
|
||||
The coordinator session is `mos-claude` — send status, findings, and questions there.
|
||||
|
||||
**2. Issues / PRs / milestones** → `tools/git/*.sh` wrappers (before raw `tea`/`gh`/`glab`):
|
||||
**Issues / PRs / milestones** → `tools/git/*.sh` wrappers (before raw `tea`/`gh`/`glab`):
|
||||
|
||||
```bash
|
||||
tools/git/pr-create.sh ... tools/git/issue-create.sh ... tools/git/pr-merge.sh ...
|
||||
|
||||
@@ -94,11 +94,11 @@
|
||||
"type": "string"
|
||||
},
|
||||
"ssh": {
|
||||
"description": "SSH target (user@host) for a cross-host peer, so onboarding renders the `agent-send.sh -H <user@host>` form. Optional; only needed for agents on a different host than the fleet.",
|
||||
"description": "Explicit SSH target (normally user@host) for a cross-host inventory peer. Exact comms rendering requires this whenever the peer's resolved host differs from the current agent's host; the host value is never substituted as an SSH destination.",
|
||||
"type": "string"
|
||||
},
|
||||
"socket": {
|
||||
"description": "tmux socket the agent's session runs on. Onboarding renders `-L <socket>` when set; absent = the default socket (no `-L`). Must match the LIVE socket, not blindly inherit the roster's tmux.socket_name.",
|
||||
"description": "Optional compatibility declaration of the fleet-wide tmux socket. When present it must exactly equal tmux.socket_name; independent per-agent sockets are rejected because the local fleet runtime provisions every session on the fleet-wide socket.",
|
||||
"type": "string"
|
||||
},
|
||||
"working_directory": {
|
||||
@@ -150,29 +150,67 @@
|
||||
"description": "Orchestrator chat connector (F4). Optional — absent means tmux (back-compat). Secrets (access/bot tokens) come from the environment, never this file.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["kind"],
|
||||
"properties": {
|
||||
"kind": {
|
||||
"enum": ["tmux", "discord", "matrix"]
|
||||
},
|
||||
"matrix": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["homeserver_url", "user_id", "room_id"],
|
||||
"properties": {
|
||||
"homeserver_url": { "type": "string" },
|
||||
"user_id": { "type": "string" },
|
||||
"room_id": { "type": "string" }
|
||||
"oneOf": [
|
||||
{
|
||||
"properties": { "kind": { "const": "tmux" } },
|
||||
"required": ["kind"],
|
||||
"not": {
|
||||
"anyOf": [{ "required": ["discord"] }, { "required": ["matrix"] }]
|
||||
}
|
||||
},
|
||||
"discord": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["channel_id"],
|
||||
{
|
||||
"properties": {
|
||||
"channel_id": { "type": "string" }
|
||||
}
|
||||
"kind": { "const": "discord" },
|
||||
"discord": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["channel_id"],
|
||||
"properties": {
|
||||
"channel_id": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"pattern": "\\S"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["kind", "discord"],
|
||||
"not": { "required": ["matrix"] }
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"kind": { "const": "matrix" },
|
||||
"matrix": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["homeserver_url", "user_id", "room_id"],
|
||||
"properties": {
|
||||
"homeserver_url": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"pattern": "\\S"
|
||||
},
|
||||
"user_id": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"pattern": "\\S"
|
||||
},
|
||||
"room_id": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"pattern": "\\S"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["kind", "matrix"],
|
||||
"not": { "required": ["discord"] }
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"kind": { "enum": ["tmux", "discord", "matrix"] },
|
||||
"matrix": { "type": "object" },
|
||||
"discord": { "type": "object" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,11 +27,11 @@ INSTALL_MODE="${MOSAIC_INSTALL_MODE:-prompt}"
|
||||
# fleet/* — the framework SEEDS fleet/examples, fleet/roles, fleet/profiles, and
|
||||
# fleet/roster.schema.json (synced normally — every fleet/roles/*.md role contract
|
||||
# and fleet/profiles/*.yaml system-type profile lands automatically via this sync,
|
||||
# so no per-file entry is needed; the preserved "fleet/*.yaml" glob is anchored to
|
||||
# the top level only and does NOT shadow fleet/profiles/*.yaml). The user's
|
||||
# so no per-file entry is needed; exact preserved roster paths are anchored to
|
||||
# the top level only and do NOT shadow fleet/profiles/*.yaml). The user's
|
||||
# own fleet files MUST
|
||||
# survive `mosaic update` (which runs this sync automatically): the active
|
||||
# roster (`fleet/roster.yaml` + any other `fleet/*.yaml`), per-agent env
|
||||
# rosters (`fleet/roster.yaml` and `fleet/roster.json`), per-agent env
|
||||
# (`fleet/agents/`), heartbeat run dir (`fleet/run/`), and the Mosaic-native
|
||||
# backlog-of-record store (`fleet/backlog/` — embedded PGlite data dir; see
|
||||
# packages/mosaic/src/commands/fleet-backlog.ts). Without these, an update
|
||||
@@ -44,7 +44,7 @@ INSTALL_MODE="${MOSAIC_INSTALL_MODE:-prompt}"
|
||||
# and user-ADDED personas instead live in fleet/roles.local/ and MUST survive
|
||||
# `mosaic update` — they win over the baseline on merge (AC-NS-7; see
|
||||
# packages/mosaic/src/commands/fleet-personas.ts).
|
||||
PRESERVE_PATHS=("CONSTITUTION.md" "AGENTS.md" "SOUL.md" "USER.md" "TOOLS.md" "STANDARDS.md" "memory" "sources" "credentials" "fleet/*.yaml" "fleet/agents" "fleet/run" "fleet/backlog" "fleet/roles.local")
|
||||
PRESERVE_PATHS=("CONSTITUTION.md" "AGENTS.md" "SOUL.md" "USER.md" "TOOLS.md" "STANDARDS.md" "memory" "sources" "credentials" "fleet/roster.yaml" "fleet/roster.json" "fleet/agents" "fleet/run" "fleet/backlog" "fleet/roles.local")
|
||||
|
||||
# Framework-owned contract files: re-copied from defaults/ on every upgrade (the
|
||||
# user must not edit them; a divergent copy is backed up once before overwrite).
|
||||
@@ -200,8 +200,8 @@ sync_framework() {
|
||||
return
|
||||
fi
|
||||
|
||||
# Fallback: cp-based sync. Glob-aware so entries like "fleet/*.yaml" preserve
|
||||
# every matching user file (parity with the rsync --exclude path above).
|
||||
# Fallback: cp-based sync. Exact top-level preserved paths mirror the
|
||||
# root-anchored rsync excludes above.
|
||||
local preserve_tmp=""
|
||||
if [[ "$INSTALL_MODE" == "keep" ]]; then
|
||||
preserve_tmp="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-preserve-XXXXXX")"
|
||||
|
||||
@@ -61,25 +61,33 @@ MOSAIC_HOME="$T5" MOSAIC_INSTALL_MODE=bogus MOSAIC_SYNC_ONLY=1 bash "$INSTALL" >
|
||||
chk "F5 failure: invalid mode rejected (nonzero exit)" "[ $rc -ne 0 ]"
|
||||
chk "F5 failure: SOUL + credentials intact" "grep -q orig '$T5/SOUL.md' && grep -q keepme '$T5/credentials/c.json'"
|
||||
|
||||
# F6 — keep-mode re-seed (the `mosaic update` path) MUST NOT wipe user fleet data.
|
||||
# Regression for the roster-loss bug: fleet/ was not in PRESERVE_PATHS.
|
||||
# F6 — keep-mode re-seed (the `mosaic update` path) MUST preserve only the
|
||||
# exact user-owned roster paths while refreshing framework-owned schema/examples.
|
||||
T6=$(mktemp -d); mkdir -p "$T6/fleet/examples" "$T6/fleet/run" "$T6/fleet/agents"
|
||||
printf '# persona\n' > "$T6/SOUL.md" # makes it a recognized existing install (→ keep mode)
|
||||
printf 'version: 1\nagents:\n - name: coder0\n' > "$T6/fleet/roster.yaml"
|
||||
printf 'version: 1\nagents:\n - name: custom\n' > "$T6/fleet/my-fleet.yaml"
|
||||
printf '{"version":1,"agents":[{"name":"json-user"}]}\n' > "$T6/fleet/roster.json"
|
||||
printf 'version: 1\nagents:\n - name: not-active-roster\n' > "$T6/fleet/my-fleet.yaml"
|
||||
printf 'ts=x\n' > "$T6/fleet/run/coder0.hb"
|
||||
printf 'MOSAIC_AGENT_NAME=coder0\n' > "$T6/fleet/agents/coder0.env"
|
||||
printf '# stale preset\n' > "$T6/fleet/examples/general.yaml"
|
||||
printf '{"stale":true}\n' > "$T6/fleet/roster.schema.json"
|
||||
E6=$(mktemp -d)
|
||||
cp "$T6/fleet/roster.yaml" "$E6/roster-yaml.expected"
|
||||
cp "$T6/fleet/roster.json" "$E6/roster-json.expected"
|
||||
cp "$T6/fleet/run/coder0.hb" "$E6/run.expected"
|
||||
cp "$T6/fleet/agents/coder0.env" "$E6/agent.expected"
|
||||
echo 3 > "$T6/.framework-version"
|
||||
run "$T6" keep
|
||||
chk "F6 reseed: user roster.yaml SURVIVES keep-mode sync" "grep -q coder0 '$T6/fleet/roster.yaml'"
|
||||
chk "F6 reseed: other user fleet/*.yaml survives (glob)" "[ -f '$T6/fleet/my-fleet.yaml' ]"
|
||||
chk "F6 reseed: per-agent env (fleet/agents) survives" "[ -f '$T6/fleet/agents/coder0.env' ]"
|
||||
chk "F6 reseed: heartbeat run dir (fleet/run) survives" "[ -f '$T6/fleet/run/coder0.hb' ]"
|
||||
chk "F6 reseed: framework examples ARE refreshed (not preserved stale)" "grep -q orchestrator '$T6/fleet/examples/general.yaml'"
|
||||
chk "F6 reseed: framework roster.schema.json seeded" "[ -f '$T6/fleet/roster.schema.json' ]"
|
||||
chk "F6 reseed: exact roster.yaml bytes survive keep-mode sync" "cmp -s '$T6/fleet/roster.yaml' '$E6/roster-yaml.expected'"
|
||||
chk "F6 reseed: exact roster.json bytes survive keep-mode sync" "cmp -s '$T6/fleet/roster.json' '$E6/roster-json.expected'"
|
||||
chk "F6 reseed: unrelated fleet YAML is not preserved" "[ ! -f '$T6/fleet/my-fleet.yaml' ]"
|
||||
chk "F6 reseed: per-agent env bytes survive" "cmp -s '$T6/fleet/agents/coder0.env' '$E6/agent.expected'"
|
||||
chk "F6 reseed: heartbeat bytes survive" "cmp -s '$T6/fleet/run/coder0.hb' '$E6/run.expected'"
|
||||
chk "F6 reseed: framework examples are refreshed" "grep -q orchestrator '$T6/fleet/examples/general.yaml'"
|
||||
chk "F6 reseed: framework roster schema is refreshed" "cmp -s '$T6/fleet/roster.schema.json' '$FW/fleet/roster.schema.json'"
|
||||
|
||||
rm -rf "$T1" "$T2" "$T3" "$T4" "$T5" "$T6"
|
||||
rm -rf "$T1" "$T2" "$T3" "$T4" "$T5" "$T6" "$E6"
|
||||
echo
|
||||
echo "RESULT: $pass passed, $fail failed"
|
||||
[ "$fail" -eq 0 ]
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Regression checks for connector-kind-conditional fleet roster schema."""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from jsonschema import Draft202012Validator
|
||||
|
||||
schema_path = Path(__file__).resolve().parents[3] / "fleet" / "roster.schema.json"
|
||||
schema = json.loads(schema_path.read_text(encoding="utf-8"))
|
||||
validator = Draft202012Validator(schema)
|
||||
base = {
|
||||
"version": 1,
|
||||
"transport": "tmux",
|
||||
"agents": [{"name": "orchestrator", "runtime": "pi"}],
|
||||
}
|
||||
|
||||
valid = [
|
||||
{"kind": "tmux"},
|
||||
{"kind": "discord", "discord": {"channel_id": "123"}},
|
||||
{
|
||||
"kind": "matrix",
|
||||
"matrix": {
|
||||
"homeserver_url": "https://matrix.example",
|
||||
"user_id": "@mosaic:example",
|
||||
"room_id": "!room:example",
|
||||
},
|
||||
},
|
||||
]
|
||||
invalid = [
|
||||
{"kind": "tmux", "discord": {"channel_id": "123"}},
|
||||
{"kind": "tmux", "matrix": {}},
|
||||
{"kind": "discord"},
|
||||
{"kind": "discord", "matrix": {}},
|
||||
{
|
||||
"kind": "discord",
|
||||
"discord": {"channel_id": "123"},
|
||||
"matrix": {
|
||||
"homeserver_url": "https://matrix.example",
|
||||
"user_id": "@mosaic:example",
|
||||
"room_id": "!room:example",
|
||||
},
|
||||
},
|
||||
{"kind": "matrix"},
|
||||
{"kind": "matrix", "discord": {"channel_id": "123"}},
|
||||
{"kind": "discord", "discord": {"channel_id": ""}},
|
||||
{"kind": "discord", "discord": {"channel_id": " "}},
|
||||
{
|
||||
"kind": "matrix",
|
||||
"matrix": {
|
||||
"homeserver_url": "",
|
||||
"user_id": "@mosaic:example",
|
||||
"room_id": "!room:example",
|
||||
},
|
||||
},
|
||||
{
|
||||
"kind": "matrix",
|
||||
"matrix": {
|
||||
"homeserver_url": "\t",
|
||||
"user_id": "@mosaic:example",
|
||||
"room_id": "!room:example",
|
||||
},
|
||||
},
|
||||
{
|
||||
"kind": "matrix",
|
||||
"matrix": {
|
||||
"homeserver_url": "https://matrix.example",
|
||||
"user_id": "",
|
||||
"room_id": "!room:example",
|
||||
},
|
||||
},
|
||||
{
|
||||
"kind": "matrix",
|
||||
"matrix": {
|
||||
"homeserver_url": "https://matrix.example",
|
||||
"user_id": " ",
|
||||
"room_id": "!room:example",
|
||||
},
|
||||
},
|
||||
{
|
||||
"kind": "matrix",
|
||||
"matrix": {
|
||||
"homeserver_url": "https://matrix.example",
|
||||
"user_id": "@mosaic:example",
|
||||
"room_id": "",
|
||||
},
|
||||
},
|
||||
{
|
||||
"kind": "matrix",
|
||||
"matrix": {
|
||||
"homeserver_url": "https://matrix.example",
|
||||
"user_id": "@mosaic:example",
|
||||
"room_id": "\n",
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
for connector in valid:
|
||||
errors = list(validator.iter_errors({**base, "connector": connector}))
|
||||
if errors:
|
||||
print(f"expected valid connector {connector}: {errors}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
|
||||
for connector in invalid:
|
||||
if not list(validator.iter_errors({**base, "connector": connector})):
|
||||
print(f"expected invalid connector: {connector}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
|
||||
print("connector schema regression: PASS")
|
||||
@@ -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);
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
checkForAllUpdates,
|
||||
formatAllPackagesTable,
|
||||
getInstallAllCommand,
|
||||
repairFleetCommsTools,
|
||||
runFrameworkReseed,
|
||||
refreshActiveFleetUnits,
|
||||
readRosterAgentNames,
|
||||
@@ -420,115 +421,142 @@ program
|
||||
'Skip re-seeding framework files into ~/.config/mosaic after the CLI update',
|
||||
)
|
||||
.option('--relaunch', 'Restart durable fleet agents so the new launcher/runtime takes effect')
|
||||
.action(async (opts: { check?: boolean; reseed?: boolean; relaunch?: boolean }) => {
|
||||
// checkForAllUpdates imported statically above
|
||||
const { execSync } = await import('node:child_process');
|
||||
|
||||
// Re-seed the framework from the freshly-installed package, propagate shipped
|
||||
// systemd unit fixes to the active units, and (opt-in) relaunch durable
|
||||
// agents. Shared by the "packages updated" and the "framework drift" paths.
|
||||
const reseedFramework = (reason: string): void => {
|
||||
console.log(reason);
|
||||
const reseed = runFrameworkReseed();
|
||||
if (!reseed.ok) {
|
||||
console.error(
|
||||
`\n⚠ Framework re-seed skipped: ${reseed.reason ?? 'unknown'}.\n` +
|
||||
' Activate manually: bash "$(npm root -g)/@mosaicstack/mosaic/framework/install.sh" ' +
|
||||
'(MOSAIC_SYNC_ONLY=1 MOSAIC_INSTALL_MODE=keep)',
|
||||
.option(
|
||||
'--repair-tools',
|
||||
'Restore the supported current-version TOOLS contract and executable fleet helper',
|
||||
)
|
||||
.action(
|
||||
async (opts: {
|
||||
check?: boolean;
|
||||
reseed?: boolean;
|
||||
relaunch?: boolean;
|
||||
repairTools?: boolean;
|
||||
}) => {
|
||||
if (opts.repairTools) {
|
||||
const repair = repairFleetCommsTools();
|
||||
if (!repair.ok) {
|
||||
console.error(`Fleet communications tools repair failed: ${repair.reason ?? 'unknown'}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
console.log(
|
||||
repair.changed
|
||||
? 'Fleet communications tools repaired from the supported current framework.'
|
||||
: 'Fleet communications tools already match the supported current framework.',
|
||||
);
|
||||
if (repair.backupPath) console.log(`Preserved previous TOOLS.md at ${repair.backupPath}.`);
|
||||
console.log('No active context or session was rewritten.');
|
||||
return;
|
||||
}
|
||||
console.log('✔ Framework re-seeded.');
|
||||
// Propagate shipped systemd unit fixes to the ACTIVE units (re-seed only
|
||||
// touches ~/.config/mosaic/systemd/user; systemd runs ~/.config/systemd/user).
|
||||
const units = refreshActiveFleetUnits();
|
||||
if (units.refreshed.length > 0) {
|
||||
console.log(`✔ Refreshed ${units.refreshed.length} active systemd unit(s).`);
|
||||
}
|
||||
const agents = readRosterAgentNames();
|
||||
if (agents.length === 0) return;
|
||||
if (opts.relaunch) {
|
||||
console.log(`\nRelaunching ${agents.length} fleet agent(s) to pick up the new runtime…`);
|
||||
for (const restart of buildRelaunchCommands(agents)) {
|
||||
try {
|
||||
execSync(restart.join(' '), { stdio: 'inherit', timeout: 30_000 });
|
||||
} catch {
|
||||
console.error(` ⚠ failed to restart agent — run: ${restart.join(' ')}`);
|
||||
}
|
||||
// checkForAllUpdates imported statically above
|
||||
const { execSync } = await import('node:child_process');
|
||||
|
||||
// Re-seed the framework from the freshly-installed package, propagate shipped
|
||||
// systemd unit fixes to the active units, and (opt-in) relaunch durable
|
||||
// agents. Shared by the "packages updated" and the "framework drift" paths.
|
||||
const reseedFramework = (reason: string): void => {
|
||||
console.log(reason);
|
||||
const reseed = runFrameworkReseed();
|
||||
if (!reseed.ok) {
|
||||
console.error(
|
||||
`\n⚠ Framework re-seed skipped: ${reseed.reason ?? 'unknown'}.\n` +
|
||||
' Activate manually: bash "$(npm root -g)/@mosaicstack/mosaic/framework/install.sh" ' +
|
||||
'(MOSAIC_SYNC_ONLY=1 MOSAIC_INSTALL_MODE=keep)',
|
||||
);
|
||||
return;
|
||||
}
|
||||
console.log('✔ Agents relaunched.');
|
||||
} else {
|
||||
console.log(
|
||||
`\nℹ ${agents.length} fleet agent(s) are still running the previous runtime. ` +
|
||||
'Restart them to activate the update:\n mosaic update --relaunch ' +
|
||||
'(or: mosaic fleet restart <agent>)',
|
||||
);
|
||||
console.log('✔ Framework re-seeded.');
|
||||
// Propagate shipped systemd unit fixes to the ACTIVE units (re-seed only
|
||||
// touches ~/.config/mosaic/systemd/user; systemd runs ~/.config/systemd/user).
|
||||
const units = refreshActiveFleetUnits();
|
||||
if (units.refreshed.length > 0) {
|
||||
console.log(`✔ Refreshed ${units.refreshed.length} active systemd unit(s).`);
|
||||
}
|
||||
const agents = readRosterAgentNames();
|
||||
if (agents.length === 0) return;
|
||||
if (opts.relaunch) {
|
||||
console.log(`\nRelaunching ${agents.length} fleet agent(s) to pick up the new runtime…`);
|
||||
for (const restart of buildRelaunchCommands(agents)) {
|
||||
try {
|
||||
execSync(restart.join(' '), { stdio: 'inherit', timeout: 30_000 });
|
||||
} catch {
|
||||
console.error(` ⚠ failed to restart agent — run: ${restart.join(' ')}`);
|
||||
}
|
||||
}
|
||||
console.log('✔ Agents relaunched.');
|
||||
} else {
|
||||
console.log(
|
||||
`\nℹ ${agents.length} fleet agent(s) are still running the previous runtime. ` +
|
||||
'Restart them to activate the update:\n mosaic update --relaunch ' +
|
||||
'(or: mosaic fleet restart <agent>)',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
console.log('Checking for updates…');
|
||||
const results = checkForAllUpdates({ skipCache: true });
|
||||
|
||||
console.log('');
|
||||
console.log(formatAllPackagesTable(results));
|
||||
|
||||
const outdated = results.filter((r: { updateAvailable: boolean }) => r.updateAvailable);
|
||||
if (outdated.length === 0) {
|
||||
const anyInstalled = results.some((r: { current: string }) => r.current);
|
||||
if (!anyInstalled) {
|
||||
console.error('No @mosaicstack/* packages are installed.');
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('\n✔ All packages up to date.');
|
||||
// #642: the CLI may have been upgraded outside `mosaic update` (e.g. a
|
||||
// direct `npm i -g`), leaving the framework files stale even though no
|
||||
// package is reported outdated. Detect that via the framework version and
|
||||
// re-seed so shipped launcher/runtime fixes still activate.
|
||||
const drift = checkFrameworkDrift();
|
||||
if (drift.drifted && opts.reseed !== false) {
|
||||
reseedFramework(
|
||||
`\nFramework drift detected (on-disk v${drift.installed} < bundled v${drift.bundled}) — ` +
|
||||
'the CLI was updated outside `mosaic update`. Re-seeding framework files into ' +
|
||||
'~/.config/mosaic (data-safe; keeps your edits)…',
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
console.log('Checking for updates…');
|
||||
const results = checkForAllUpdates({ skipCache: true });
|
||||
if (opts.check) {
|
||||
process.exit(2); // Signal to callers that an update exists
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log(formatAllPackagesTable(results));
|
||||
|
||||
const outdated = results.filter((r: { updateAvailable: boolean }) => r.updateAvailable);
|
||||
if (outdated.length === 0) {
|
||||
const anyInstalled = results.some((r: { current: string }) => r.current);
|
||||
if (!anyInstalled) {
|
||||
console.error('No @mosaicstack/* packages are installed.');
|
||||
console.log(`\nInstalling ${outdated.length} update(s)…`);
|
||||
try {
|
||||
// Relies on @mosaicstack:registry in ~/.npmrc
|
||||
const cmd = getInstallAllCommand(outdated);
|
||||
execSync(cmd, {
|
||||
stdio: 'inherit',
|
||||
timeout: 60_000,
|
||||
});
|
||||
console.log('\n✔ Updated successfully.');
|
||||
} catch {
|
||||
console.error('\nUpdate failed. Try manually: bash tools/install.sh');
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('\n✔ All packages up to date.');
|
||||
// #642: the CLI may have been upgraded outside `mosaic update` (e.g. a
|
||||
// direct `npm i -g`), leaving the framework files stale even though no
|
||||
// package is reported outdated. Detect that via the framework version and
|
||||
// re-seed so shipped launcher/runtime fixes still activate.
|
||||
|
||||
// F3-m3 / R13: the CLI is updated, but the framework files in
|
||||
// ~/.config/mosaic/ are still the previous version. Re-seed them from the
|
||||
// freshly-installed package so shipped launcher/runtime changes ACTIVATE.
|
||||
// Re-seed when the framework-bearing package itself updated OR the on-disk
|
||||
// framework is older than the freshly-installed one (#642 — e.g. only
|
||||
// sibling packages were outdated but the CLI was already ahead).
|
||||
const mosaicUpdated = outdated.some(
|
||||
(r: { package: string }) => r.package === FRAMEWORK_RESEED_PACKAGE,
|
||||
);
|
||||
const drift = checkFrameworkDrift();
|
||||
if (drift.drifted && opts.reseed !== false) {
|
||||
if ((mosaicUpdated || drift.drifted) && opts.reseed !== false) {
|
||||
reseedFramework(
|
||||
`\nFramework drift detected (on-disk v${drift.installed} < bundled v${drift.bundled}) — ` +
|
||||
'the CLI was updated outside `mosaic update`. Re-seeding framework files into ' +
|
||||
'~/.config/mosaic (data-safe; keeps your edits)…',
|
||||
'\nRe-seeding framework files into ~/.config/mosaic (data-safe; keeps your edits)…',
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (opts.check) {
|
||||
process.exit(2); // Signal to callers that an update exists
|
||||
}
|
||||
|
||||
console.log(`\nInstalling ${outdated.length} update(s)…`);
|
||||
try {
|
||||
// Relies on @mosaicstack:registry in ~/.npmrc
|
||||
const cmd = getInstallAllCommand(outdated);
|
||||
execSync(cmd, {
|
||||
stdio: 'inherit',
|
||||
timeout: 60_000,
|
||||
});
|
||||
console.log('\n✔ Updated successfully.');
|
||||
} catch {
|
||||
console.error('\nUpdate failed. Try manually: bash tools/install.sh');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// F3-m3 / R13: the CLI is updated, but the framework files in
|
||||
// ~/.config/mosaic/ are still the previous version. Re-seed them from the
|
||||
// freshly-installed package so shipped launcher/runtime changes ACTIVATE.
|
||||
// Re-seed when the framework-bearing package itself updated OR the on-disk
|
||||
// framework is older than the freshly-installed one (#642 — e.g. only
|
||||
// sibling packages were outdated but the CLI was already ahead).
|
||||
const mosaicUpdated = outdated.some(
|
||||
(r: { package: string }) => r.package === FRAMEWORK_RESEED_PACKAGE,
|
||||
);
|
||||
const drift = checkFrameworkDrift();
|
||||
if ((mosaicUpdated || drift.drifted) && opts.reseed !== false) {
|
||||
reseedFramework(
|
||||
'\nRe-seeding framework files into ~/.config/mosaic (data-safe; keeps your edits)…',
|
||||
);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// ─── wizard ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,20 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync } from 'node:fs';
|
||||
import {
|
||||
accessSync,
|
||||
chmodSync,
|
||||
constants,
|
||||
mkdtempSync,
|
||||
mkdirSync,
|
||||
writeFileSync,
|
||||
rmSync,
|
||||
readFileSync,
|
||||
renameSync,
|
||||
symlinkSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { FileConfigAdapter } from '../config/file-adapter.js';
|
||||
import { composeContract } from './launch.js';
|
||||
|
||||
/**
|
||||
@@ -22,7 +35,9 @@ import { composeContract } from './launch.js';
|
||||
const CONSTITUTION = '# CONSTITUTION\n\nGATE-1: the non-negotiable law.\n';
|
||||
const AGENTS = '# Mosaic Agent Dispatcher\n\nLoad order + guide router.\n';
|
||||
const USER = '# operator\n\nName: Test Operator\n';
|
||||
const TOOLS = '# tools index\n';
|
||||
const TOOLS = '# tools index\n\n<!-- fleet-comms-contract: 1 -->\n';
|
||||
const FRAMEWORK_SOURCE = fileURLToPath(new URL('../../framework', import.meta.url));
|
||||
const SOURCE_TOOLS_PATH = join(FRAMEWORK_SOURCE, 'defaults', 'TOOLS.md');
|
||||
|
||||
function makeHome(): { home: string; root: string } {
|
||||
const root = mkdtempSync(join(tmpdir(), 'mosaic-compose-'));
|
||||
@@ -31,6 +46,12 @@ function makeHome(): { home: string; root: string } {
|
||||
mkdirSync(join(home, 'runtime', h), { recursive: true });
|
||||
writeFileSync(join(home, 'runtime', h, 'RUNTIME.md'), `# ${h} runtime contract\n`);
|
||||
}
|
||||
mkdirSync(join(home, 'defaults'), { recursive: true });
|
||||
mkdirSync(join(home, 'tools', 'tmux'), { recursive: true });
|
||||
writeFileSync(join(home, 'defaults', 'TOOLS.md'), TOOLS);
|
||||
const helper = join(home, 'tools', 'tmux', 'agent-send.sh');
|
||||
writeFileSync(helper, '#!/bin/sh\n');
|
||||
chmodSync(helper, 0o755);
|
||||
writeFileSync(join(home, 'CONSTITUTION.md'), CONSTITUTION);
|
||||
writeFileSync(join(home, 'AGENTS.md'), AGENTS);
|
||||
writeFileSync(join(home, 'USER.md'), USER);
|
||||
@@ -42,16 +63,31 @@ describe('composeContract — overlay composer', () => {
|
||||
let fixture: ReturnType<typeof makeHome>;
|
||||
let prevCwd: string;
|
||||
let cwdDir: string;
|
||||
let prevAgentName: string | undefined;
|
||||
let prevAgentClass: string | undefined;
|
||||
let prevAgentToolPolicy: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = makeHome();
|
||||
prevCwd = process.cwd();
|
||||
prevAgentName = process.env['MOSAIC_AGENT_NAME'];
|
||||
prevAgentClass = process.env['MOSAIC_AGENT_CLASS'];
|
||||
prevAgentToolPolicy = process.env['MOSAIC_AGENT_TOOL_POLICY'];
|
||||
delete process.env['MOSAIC_AGENT_NAME'];
|
||||
delete process.env['MOSAIC_AGENT_CLASS'];
|
||||
delete process.env['MOSAIC_AGENT_TOOL_POLICY'];
|
||||
cwdDir = mkdtempSync(join(tmpdir(), 'mosaic-cwd-'));
|
||||
process.chdir(cwdDir); // neutralize cwd-relative mission/PRD blocks
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(prevCwd);
|
||||
if (prevAgentName === undefined) delete process.env['MOSAIC_AGENT_NAME'];
|
||||
else process.env['MOSAIC_AGENT_NAME'] = prevAgentName;
|
||||
if (prevAgentClass === undefined) delete process.env['MOSAIC_AGENT_CLASS'];
|
||||
else process.env['MOSAIC_AGENT_CLASS'] = prevAgentClass;
|
||||
if (prevAgentToolPolicy === undefined) delete process.env['MOSAIC_AGENT_TOOL_POLICY'];
|
||||
else process.env['MOSAIC_AGENT_TOOL_POLICY'] = prevAgentToolPolicy;
|
||||
rmSync(fixture.root, { recursive: true, force: true });
|
||||
rmSync(cwdDir, { recursive: true, force: true });
|
||||
});
|
||||
@@ -64,13 +100,17 @@ describe('composeContract — overlay composer', () => {
|
||||
[
|
||||
'version: 1',
|
||||
'transport: tmux',
|
||||
'tmux:',
|
||||
' socket_name: mosaic-fleet',
|
||||
'agents:',
|
||||
' - name: orchestrator',
|
||||
' runtime: claude',
|
||||
' class: orchestrator',
|
||||
' host: w-jarvis',
|
||||
' - name: enhancer',
|
||||
' runtime: claude',
|
||||
' class: enhancer',
|
||||
' host: w-jarvis',
|
||||
' - name: coder0-0',
|
||||
' runtime: claude',
|
||||
' class: implementer',
|
||||
@@ -82,19 +122,266 @@ describe('composeContract — overlay composer', () => {
|
||||
const prev = process.env['MOSAIC_AGENT_NAME'];
|
||||
try {
|
||||
process.env['MOSAIC_AGENT_NAME'] = 'enhancer';
|
||||
const out = composeContract('claude', fixture.home);
|
||||
expect(out).toContain('# Fleet Comms');
|
||||
expect(out).toMatch(/`\[[^\]]+:enhancer\]`/); // own [host:session] identity (host machine-dependent)
|
||||
// local peer → no -H; cross-host peer → -H ssh
|
||||
expect(out).toContain('-s orchestrator -m "…"');
|
||||
expect(out).toContain('-H jwoltje@10.1.10.37 -s coder0-0 -m "…"');
|
||||
expect(out).not.toContain('-H jwoltje@10.1.10.37 -s orchestrator'); // local stays local
|
||||
const outputs = (['claude', 'codex', 'opencode', 'pi'] as const).map((runtime) =>
|
||||
composeContract(runtime, fixture.home),
|
||||
);
|
||||
for (const out of outputs) {
|
||||
expect(out).toContain('# Fleet Comms');
|
||||
expect(out).toContain('Host: `w-jarvis`');
|
||||
expect(out).toContain('Agent/session: `enhancer`');
|
||||
expect(out).toContain('tmux socket: `mosaic-fleet`');
|
||||
expect(out).toContain(
|
||||
`Helper: \`${join(fixture.home, 'tools', 'tmux', 'agent-send.sh')}\``,
|
||||
);
|
||||
expect(out).toContain('-L mosaic-fleet -s orchestrator -m "…"');
|
||||
expect(out).toContain('-L mosaic-fleet -H jwoltje@10.1.10.37 -s coder0-0 -m "…"');
|
||||
expect(out).not.toContain('-H jwoltje@10.1.10.37 -s orchestrator');
|
||||
}
|
||||
const commsSection = (out: string): string => out.slice(out.indexOf('# Fleet Comms'));
|
||||
const authoritative = commsSection(outputs[0]!);
|
||||
expect(outputs.map(commsSection)).toEqual([
|
||||
authoritative,
|
||||
authoritative,
|
||||
authoritative,
|
||||
authoritative,
|
||||
]);
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env['MOSAIC_AGENT_NAME'];
|
||||
else process.env['MOSAIC_AGENT_NAME'] = prev;
|
||||
}
|
||||
});
|
||||
|
||||
it.each(['claude', 'codex', 'opencode', 'pi'] as const)(
|
||||
'derives canonical class, persona, tool policy, and comms from one roster member for %s',
|
||||
(runtime) => {
|
||||
mkdirSync(join(fixture.home, 'fleet', 'roles'), { recursive: true });
|
||||
writeFileSync(
|
||||
join(fixture.home, 'fleet', 'roles', 'code.md'),
|
||||
'# Code\n\n(`class: code`)\n\nCANONICAL-CODE-MANDATE.\n',
|
||||
);
|
||||
writeFileSync(
|
||||
join(fixture.home, 'fleet', 'roster.yaml'),
|
||||
[
|
||||
'version: 1',
|
||||
'transport: tmux',
|
||||
'agents:',
|
||||
' - name: exact-coder',
|
||||
` runtime: ${runtime}`,
|
||||
' class: implementer',
|
||||
' tool_policy: operator-interaction',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
process.env['MOSAIC_AGENT_NAME'] = 'exact-coder';
|
||||
process.env['MOSAIC_AGENT_CLASS'] = 'implementer';
|
||||
process.env['MOSAIC_AGENT_TOOL_POLICY'] = 'ambient-policy-must-not-win';
|
||||
|
||||
const out = composeContract(runtime, fixture.home);
|
||||
|
||||
expect(out).toContain('# Persona Contract (code)');
|
||||
expect(out).toContain('CANONICAL-CODE-MANDATE');
|
||||
expect(out).toContain('Role/class: `code`');
|
||||
expect(out).toContain('# Fleet Tool Policy (operator-interaction)');
|
||||
expect(out).not.toContain('ambient-policy-must-not-win');
|
||||
expect(out.indexOf('# Persona Contract')).toBeLessThan(out.indexOf('# Fleet Comms'));
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['claude', 'codex', 'opencode', 'pi'] as const)(
|
||||
'does not inherit ambient tool policy when canonical member omits tool_policy for %s',
|
||||
(runtime) => {
|
||||
mkdirSync(join(fixture.home, 'fleet', 'roles'), { recursive: true });
|
||||
writeFileSync(
|
||||
join(fixture.home, 'fleet', 'roles', 'code.md'),
|
||||
'# Code\n\n(`class: code`)\n\nCANONICAL-CODE-MANDATE.\n',
|
||||
);
|
||||
writeFileSync(
|
||||
join(fixture.home, 'fleet', 'roster.yaml'),
|
||||
[
|
||||
'version: 1',
|
||||
'transport: tmux',
|
||||
'agents:',
|
||||
' - name: exact-coder',
|
||||
` runtime: ${runtime}`,
|
||||
' class: implementer',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
process.env['MOSAIC_AGENT_NAME'] = 'exact-coder';
|
||||
process.env['MOSAIC_AGENT_CLASS'] = 'implementer';
|
||||
process.env['MOSAIC_AGENT_TOOL_POLICY'] = 'operator-interaction';
|
||||
|
||||
const out = composeContract(runtime, fixture.home);
|
||||
|
||||
expect(out).toContain('# Persona Contract (code)');
|
||||
expect(out).toContain('Role/class: `code`');
|
||||
expect(out).toContain('# Fleet Comms');
|
||||
expect(out).not.toContain('# Fleet Tool Policy (operator-interaction)');
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['claude', 'codex', 'opencode', 'pi'] as const)(
|
||||
'rejects an ambient class that mismatches the canonical roster member for %s',
|
||||
(runtime) => {
|
||||
mkdirSync(join(fixture.home, 'fleet'), { recursive: true });
|
||||
writeFileSync(
|
||||
join(fixture.home, 'fleet', 'roster.yaml'),
|
||||
[
|
||||
'version: 1',
|
||||
'transport: tmux',
|
||||
'agents:',
|
||||
' - name: exact-coder',
|
||||
` runtime: ${runtime}`,
|
||||
' class: implementer',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
process.env['MOSAIC_AGENT_NAME'] = 'exact-coder';
|
||||
process.env['MOSAIC_AGENT_CLASS'] = 'reviewer';
|
||||
|
||||
expect(() => composeContract(runtime, fixture.home)).toThrow(
|
||||
/ambient MOSAIC_AGENT_CLASS.*review.*canonical roster.*code/i,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('composes solo role mandate and boundaries before explicit no-peer authority', () => {
|
||||
mkdirSync(join(fixture.home, 'fleet', 'roles'), { recursive: true });
|
||||
writeFileSync(
|
||||
join(fixture.home, 'fleet', 'roles', 'orchestrator.md'),
|
||||
'# Orchestrator\n\n## Mandate\n\nCoordinate exact work.\n\n## Boundaries\n\nDo not infer authority.\n',
|
||||
);
|
||||
writeFileSync(
|
||||
join(fixture.home, 'fleet', 'roster.yaml'),
|
||||
[
|
||||
'version: 1',
|
||||
'transport: tmux',
|
||||
'agents:',
|
||||
' - name: solo',
|
||||
' runtime: claude',
|
||||
' class: orchestrator',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
process.env['MOSAIC_AGENT_NAME'] = 'solo';
|
||||
process.env['MOSAIC_AGENT_CLASS'] = 'orchestrator';
|
||||
|
||||
const out = composeContract('claude', fixture.home);
|
||||
|
||||
expect(out).toContain('## Mandate');
|
||||
expect(out).toContain('## Boundaries');
|
||||
expect(out).toContain('Role/class: `orchestrator`');
|
||||
expect(out).toContain('## Solo authority boundaries');
|
||||
expect(out.indexOf('## Mandate')).toBeLessThan(out.indexOf('# Fleet Comms'));
|
||||
expect(out.indexOf('## Boundaries')).toBeLessThan(out.indexOf('# Fleet Comms'));
|
||||
expect(out).toContain('no peer, orchestrator, or remote communication authority');
|
||||
});
|
||||
|
||||
it('proves real source TOOLS.md through fresh install, executable helper, and final composition', async () => {
|
||||
const installRoot = mkdtempSync(join(tmpdir(), 'mosaic-real-contract-'));
|
||||
const installedHome = join(installRoot, 'mosaic-home');
|
||||
mkdirSync(installedHome, { recursive: true });
|
||||
const previous = process.env['MOSAIC_AGENT_NAME'];
|
||||
|
||||
try {
|
||||
const adapter = new FileConfigAdapter(installedHome, FRAMEWORK_SOURCE);
|
||||
await adapter.syncFramework('fresh');
|
||||
|
||||
const sourceTools = readFileSync(SOURCE_TOOLS_PATH, 'utf8');
|
||||
const installedToolsPath = join(installedHome, 'TOOLS.md');
|
||||
expect(readFileSync(installedToolsPath, 'utf8')).toBe(sourceTools);
|
||||
expect(sourceTools).toContain('fleet-comms-contract: 1');
|
||||
expect(sourceTools).not.toMatch(
|
||||
/<(?:user@host|src_host|src_session|dst_host|dst_session|target-session)>/,
|
||||
);
|
||||
|
||||
const helper = join(installedHome, 'tools', 'tmux', 'agent-send.sh');
|
||||
expect(() => accessSync(helper, constants.X_OK)).not.toThrow();
|
||||
|
||||
mkdirSync(join(installedHome, 'fleet'), { recursive: true });
|
||||
writeFileSync(
|
||||
join(installedHome, 'fleet', 'roster.yaml'),
|
||||
[
|
||||
'version: 1',
|
||||
'transport: tmux',
|
||||
'tmux:',
|
||||
' socket_name: exact-socket',
|
||||
'agents:',
|
||||
' - name: exact-self',
|
||||
' runtime: pi',
|
||||
' class: orchestrator',
|
||||
' host: local-host',
|
||||
' - name: exact-peer',
|
||||
' runtime: claude',
|
||||
' class: implementer',
|
||||
' host: local-host',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
process.env['MOSAIC_AGENT_NAME'] = 'exact-self';
|
||||
|
||||
const composed = composeContract('pi', installedHome);
|
||||
expect(composed).toContain(sourceTools);
|
||||
expect(composed).toContain(`Helper: \`${helper}\``);
|
||||
expect(composed).toContain(`${helper} -L exact-socket -s exact-peer -m "…"`);
|
||||
expect(composed).not.toContain('# Fleet Comms Installation Status');
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env['MOSAIC_AGENT_NAME'];
|
||||
else process.env['MOSAIC_AGENT_NAME'] = previous;
|
||||
rmSync(installRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it.each(['claude', 'codex', 'opencode', 'pi'] as const)(
|
||||
'never injects installed TOOLS.md through a target symlink for %s',
|
||||
(runtime) => {
|
||||
const toolsPath = join(fixture.home, 'TOOLS.md');
|
||||
const external = join(fixture.root, 'attacker-tools.md');
|
||||
writeFileSync(external, 'UNSAFE-TARGET-SYMLINK-CONTENT\n');
|
||||
rmSync(toolsPath);
|
||||
symlinkSync(external, toolsPath);
|
||||
|
||||
const out = composeContract(runtime, fixture.home);
|
||||
|
||||
expect(out).not.toContain('UNSAFE-TARGET-SYMLINK-CONTENT');
|
||||
expect(out).toContain('# Fleet Comms Installation Status');
|
||||
expect(out).toContain('unavailable');
|
||||
expect(readFileSync(external, 'utf8')).toBe('UNSAFE-TARGET-SYMLINK-CONTENT\n');
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['claude', 'codex', 'opencode', 'pi'] as const)(
|
||||
'never injects installed TOOLS.md through an ancestor symlink for %s',
|
||||
(runtime) => {
|
||||
const realHome = join(fixture.root, 'real-mosaic-home');
|
||||
renameSync(fixture.home, realHome);
|
||||
symlinkSync(realHome, fixture.home, 'dir');
|
||||
writeFileSync(join(realHome, 'TOOLS.md'), 'UNSAFE-ANCESTOR-SYMLINK-CONTENT\n');
|
||||
|
||||
const out = composeContract(runtime, fixture.home);
|
||||
|
||||
expect(out).not.toContain('UNSAFE-ANCESTOR-SYMLINK-CONTENT');
|
||||
expect(out).toContain('# Fleet Comms Installation Status');
|
||||
expect(out).toContain('unavailable');
|
||||
expect(readFileSync(join(realHome, 'TOOLS.md'), 'utf8')).toBe(
|
||||
'UNSAFE-ANCESTOR-SYMLINK-CONTENT\n',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('reports stale preserved TOOLS.md without rewriting it', () => {
|
||||
const toolsPath = join(fixture.home, 'TOOLS.md');
|
||||
const stale = '# user-customized tools without fleet contract marker\n';
|
||||
writeFileSync(toolsPath, stale);
|
||||
|
||||
const out = composeContract('pi', fixture.home);
|
||||
|
||||
expect(out).toContain('# Fleet Comms Installation Status');
|
||||
expect(out).toContain('does not byte-match');
|
||||
expect(out).toContain('active context was not rewritten');
|
||||
expect(readFileSync(toolsPath, 'utf8')).toBe(stale);
|
||||
});
|
||||
|
||||
it('does NOT inject fleet comms when MOSAIC_AGENT_NAME is unset (non-fleet launch)', () => {
|
||||
const prev = process.env['MOSAIC_AGENT_NAME'];
|
||||
try {
|
||||
@@ -105,6 +392,24 @@ describe('composeContract — overlay composer', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('fails closed when an explicitly requested fleet identity is unknown', () => {
|
||||
mkdirSync(join(fixture.home, 'fleet'), { recursive: true });
|
||||
writeFileSync(
|
||||
join(fixture.home, 'fleet', 'roster.yaml'),
|
||||
['version: 1', 'transport: tmux', 'agents:', ' - name: exact-agent', ' runtime: pi'].join(
|
||||
'\n',
|
||||
),
|
||||
);
|
||||
const previous = process.env['MOSAIC_AGENT_NAME'];
|
||||
try {
|
||||
process.env['MOSAIC_AGENT_NAME'] = 'invented-agent';
|
||||
expect(() => composeContract('pi', fixture.home)).toThrow(/known exact names: exact-agent/i);
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env['MOSAIC_AGENT_NAME'];
|
||||
else process.env['MOSAIC_AGENT_NAME'] = previous;
|
||||
}
|
||||
});
|
||||
|
||||
it('includes the per-tier anchors and the selected harness runtime', () => {
|
||||
const out = composeContract('claude', fixture.home);
|
||||
expect(out).toContain('GATE-1: the non-negotiable law.'); // L0
|
||||
@@ -240,10 +545,14 @@ describe('composeContract — overlay composer', () => {
|
||||
writeFileSync(
|
||||
join(fixture.home, 'fleet', 'roster.yaml'),
|
||||
[
|
||||
'version: 1',
|
||||
'transport: tmux',
|
||||
'agents:',
|
||||
' - name: orchestrator',
|
||||
' runtime: claude',
|
||||
' class: orchestrator',
|
||||
' - name: enhancer',
|
||||
' runtime: claude',
|
||||
' class: enhancer',
|
||||
'',
|
||||
].join('\n'),
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
257
packages/mosaic/src/commands/fleet-reconciler-command.spec.ts
Normal file
257
packages/mosaic/src/commands/fleet-reconciler-command.spec.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
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 { type FleetReconcileDeps } from '../fleet/fleet-reconciler.js';
|
||||
import { registerFleetCommand, type CommandResult, type FleetCommandDeps } from './fleet.js';
|
||||
|
||||
const roster = `
|
||||
version: 2
|
||||
generation: 7
|
||||
transport: tmux
|
||||
tmux:
|
||||
socket_name: mosaic-fleet
|
||||
holder_session: _holder
|
||||
defaults:
|
||||
working_directory: /srv/mosaic
|
||||
runtime: pi
|
||||
runtimes:
|
||||
pi:
|
||||
reset_command: /new
|
||||
agents:
|
||||
- name: coder0
|
||||
alias: Coder 0
|
||||
class: code
|
||||
runtime: pi
|
||||
provider: openai
|
||||
model: gpt-5.6-sol
|
||||
reasoning: high
|
||||
tool_policy: code
|
||||
working_directory: /srv/mosaic
|
||||
persistent_persona: false
|
||||
reset_between_tasks: true
|
||||
lifecycle:
|
||||
enabled: true
|
||||
desired_state: stopped
|
||||
launch:
|
||||
yolo: true
|
||||
`;
|
||||
|
||||
let cleanup: string | undefined;
|
||||
|
||||
afterEach(async (): Promise<void> => {
|
||||
vi.restoreAllMocks();
|
||||
process.exitCode = undefined;
|
||||
if (cleanup) await rm(cleanup, { recursive: true, force: true });
|
||||
cleanup = undefined;
|
||||
});
|
||||
|
||||
async function fleetHome(): Promise<string> {
|
||||
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-fleet-reconciler-command-'));
|
||||
for (const directory of ['fleet', 'fleet/agents', 'fleet/roles']) {
|
||||
await mkdir(join(cleanup, directory), { recursive: true, mode: 0o700 });
|
||||
await chmod(join(cleanup, directory), 0o700);
|
||||
}
|
||||
await chmod(cleanup, 0o700);
|
||||
await writeFile(join(cleanup, 'fleet', 'roster.yaml'), roster, { mode: 0o600 });
|
||||
await writeFile(join(cleanup, 'fleet', 'roles', 'code.md'), '# code\n\n(`class: code`)\n', {
|
||||
mode: 0o600,
|
||||
});
|
||||
return cleanup;
|
||||
}
|
||||
|
||||
function program(
|
||||
mosaicHome: string,
|
||||
runner: FleetCommandDeps['runner'],
|
||||
reconcileOverrides: Partial<FleetReconcileDeps> = {},
|
||||
): Command {
|
||||
const result = new Command();
|
||||
result.exitOverride();
|
||||
registerFleetCommand(result, {
|
||||
mosaicHome,
|
||||
runner,
|
||||
reconcileDeps: {
|
||||
homeDirectory: '/home/mosaic',
|
||||
readHolderIdentity: async () => '11111111-1111-4111-8111-111111111111',
|
||||
validateRoster: async () => undefined,
|
||||
prepareProjections: async () => [{ agentName: 'coder0' }],
|
||||
applyProjection: async () => undefined,
|
||||
...reconcileOverrides,
|
||||
},
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
function capture(): string[] {
|
||||
const lines: string[] = [];
|
||||
vi.spyOn(console, 'log').mockImplementation((value: string): void => {
|
||||
lines.push(value);
|
||||
});
|
||||
return lines;
|
||||
}
|
||||
|
||||
function ownedRunner(
|
||||
calls: string[][],
|
||||
): (command: string, args: string[]) => Promise<CommandResult> {
|
||||
return async (command: string, args: string[]): Promise<CommandResult> => {
|
||||
calls.push([command, ...args]);
|
||||
if (command === 'tmux' && args.includes('list-sessions')) {
|
||||
return { stdout: '_holder\ncoder0\n', stderr: '', exitCode: 0 };
|
||||
}
|
||||
if (command === '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=mosaic-fleet\nPATH=/usr/bin:/bin\nPWD=/home/mosaic\n',
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
};
|
||||
}
|
||||
return { stdout: '', stderr: '', exitCode: 0 };
|
||||
};
|
||||
}
|
||||
|
||||
describe('mosaic fleet reconciler commands', (): void => {
|
||||
it('plans apply as stable JSON without applying projections or lifecycle effects', async (): Promise<void> => {
|
||||
const home = await fleetHome();
|
||||
const calls: string[][] = [];
|
||||
const lines = capture();
|
||||
|
||||
await program(home, ownedRunner(calls)).parseAsync([
|
||||
'node',
|
||||
'mosaic',
|
||||
'fleet',
|
||||
'apply',
|
||||
'--expected-generation',
|
||||
'7',
|
||||
'--dry-run',
|
||||
]);
|
||||
|
||||
expect(JSON.parse(lines.pop() ?? '')).toMatchObject({
|
||||
applied: false,
|
||||
authoritativeRoster: 'unchanged',
|
||||
projections: 'not-applied',
|
||||
lifecycle: 'not-applied',
|
||||
});
|
||||
expect(calls).not.toContainEqual([
|
||||
'systemctl',
|
||||
'--user',
|
||||
'start',
|
||||
'mosaic-agent@coder0.service',
|
||||
]);
|
||||
});
|
||||
|
||||
it('uses direct fleet status and doctor as observational JSON commands', async (): Promise<void> => {
|
||||
const home = await fleetHome();
|
||||
const lines = capture();
|
||||
const calls: string[][] = [];
|
||||
const cli = program(home, ownedRunner(calls));
|
||||
|
||||
await cli.parseAsync(['node', 'mosaic', 'fleet', 'status', 'coder0']);
|
||||
await cli.parseAsync(['node', 'mosaic', 'fleet', 'doctor']);
|
||||
|
||||
expect(lines.map((line: string): unknown => JSON.parse(line))).toMatchObject([
|
||||
{ applied: false, lifecycle: 'not-applied' },
|
||||
{ applied: false, lifecycle: 'not-applied' },
|
||||
]);
|
||||
expect(
|
||||
calls.every((call: string[]): boolean => call[0] !== 'systemctl' || call[2] === 'show'),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it.each(['start', 'stop', 'restart'] as const)(
|
||||
'uses exact roster-owned systemd targeting for %s',
|
||||
async (operation: 'start' | 'stop' | 'restart'): Promise<void> => {
|
||||
const home = await fleetHome();
|
||||
const calls: string[][] = [];
|
||||
const lines = capture();
|
||||
|
||||
await program(home, ownedRunner(calls)).parseAsync([
|
||||
'node',
|
||||
'mosaic',
|
||||
'fleet',
|
||||
operation,
|
||||
'coder0',
|
||||
'--expected-generation',
|
||||
'7',
|
||||
]);
|
||||
|
||||
expect(JSON.parse(lines.pop() ?? '')).toMatchObject({ applied: true, lifecycle: 'complete' });
|
||||
expect(calls).toContainEqual([
|
||||
'systemctl',
|
||||
'--user',
|
||||
operation,
|
||||
'mosaic-agent@coder0.service',
|
||||
]);
|
||||
expect(calls.some((call: string[]): boolean => call.join(' ').includes('coder0-extra'))).toBe(
|
||||
false,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('makes cleanup-incomplete effect JSON non-zero without losing effect truth', async (): Promise<void> => {
|
||||
const home = await fleetHome();
|
||||
const lines = capture();
|
||||
await program(home, ownedRunner([]), {
|
||||
acquireMutationLock: async () => async () => {
|
||||
throw new Error('cleanup failure');
|
||||
},
|
||||
}).parseAsync(['node', 'mosaic', 'fleet', 'apply', '--expected-generation', '7']);
|
||||
|
||||
expect(JSON.parse(lines.pop() ?? '')).toMatchObject({
|
||||
applied: true,
|
||||
projections: 'complete',
|
||||
lifecycle: 'complete',
|
||||
cleanup: { code: 'lock-cleanup-failed', action: 'inspect-lock-before-retry' },
|
||||
});
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it('keeps primary partial recovery JSON and exits non-zero when cleanup is also incomplete', async (): Promise<void> => {
|
||||
const cleanupFailure = async () => async () => {
|
||||
throw new Error('cleanup failure');
|
||||
};
|
||||
const projectionHome = await fleetHome();
|
||||
const projectionLines = capture();
|
||||
await program(projectionHome, ownedRunner([]), {
|
||||
applyProjection: async () => {
|
||||
throw new Error('projection failure');
|
||||
},
|
||||
acquireMutationLock: cleanupFailure,
|
||||
}).parseAsync(['node', 'mosaic', 'fleet', 'apply', '--expected-generation', '7']);
|
||||
expect(JSON.parse(projectionLines.pop() ?? '')).toMatchObject({
|
||||
projections: 'incomplete',
|
||||
lifecycle: 'not-applied',
|
||||
recovery: { code: 'projection-apply-failed' },
|
||||
cleanup: { code: 'lock-cleanup-failed' },
|
||||
});
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it('keeps clean effect and observational commands at zero exit', async (): Promise<void> => {
|
||||
const home = await fleetHome();
|
||||
const cli = program(home, ownedRunner([]));
|
||||
await cli.parseAsync(['node', 'mosaic', 'fleet', 'apply', '--expected-generation', '7']);
|
||||
expect(process.exitCode).toBe(0);
|
||||
process.exitCode = undefined;
|
||||
await cli.parseAsync(['node', 'mosaic', 'fleet', 'status']);
|
||||
expect(process.exitCode).toBe(0);
|
||||
});
|
||||
|
||||
it('rejects stale apply generations as non-zero redacted JSON', async (): Promise<void> => {
|
||||
const home = await fleetHome();
|
||||
const lines = capture();
|
||||
|
||||
await program(home, ownedRunner([])).parseAsync([
|
||||
'node',
|
||||
'mosaic',
|
||||
'fleet',
|
||||
'apply',
|
||||
'--expected-generation',
|
||||
'6',
|
||||
]);
|
||||
|
||||
expect(JSON.parse(lines.pop() ?? '')).toEqual({ error: { code: 'stale-generation' } });
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
});
|
||||
146
packages/mosaic/src/commands/fleet-reconciler-command.ts
Normal file
146
packages/mosaic/src/commands/fleet-reconciler-command.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { join, resolve } from 'node:path';
|
||||
import type { Command } from 'commander';
|
||||
import type { CommandRunner } from './fleet.js';
|
||||
import {
|
||||
executeFleetReconcile,
|
||||
FleetReconcileError,
|
||||
type FleetReconcileCommand,
|
||||
type FleetReconcileDeps,
|
||||
} from '../fleet/fleet-reconciler.js';
|
||||
import { parseRosterV2 } from '../fleet/roster-v2.js';
|
||||
|
||||
export interface FleetReconcilerCommandDeps {
|
||||
readonly runner: CommandRunner;
|
||||
readonly mosaicHome?: string;
|
||||
readonly reconcileDeps?: Omit<FleetReconcileDeps, 'runner' | 'mosaicHome'>;
|
||||
}
|
||||
|
||||
interface ReconcileOptions {
|
||||
readonly expectedGeneration?: string;
|
||||
readonly dryRun?: boolean;
|
||||
}
|
||||
|
||||
/** Registers roster-v2 reconciliation commands on the canonical fleet control plane. */
|
||||
export function registerFleetReconcilerCommands(
|
||||
fleetCommand: Command,
|
||||
deps: FleetReconcilerCommandDeps,
|
||||
): void {
|
||||
for (const operation of ['apply', 'reconcile'] as const) {
|
||||
fleetCommand
|
||||
.command(operation)
|
||||
.description(`${operation} local roster-owned projections and desired lifecycle`)
|
||||
.requiredOption('--expected-generation <number>', 'Authoritative roster generation')
|
||||
.option('--dry-run', 'Plan and preflight without writing projections or lifecycle state')
|
||||
.action(async (opts: ReconcileOptions): Promise<void> => {
|
||||
await writeOutcome(async (): Promise<void> => {
|
||||
await executeReconcilerCommand(fleetCommand, deps, operation, opts);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fleetCommand
|
||||
.command('doctor')
|
||||
.description('Classify local roster-owned drift without mutation')
|
||||
.action(async (): Promise<void> => {
|
||||
await writeOutcome(async (): Promise<void> => {
|
||||
await executeReconcilerCommand(fleetCommand, deps, 'doctor', {});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function executeReconcilerCommandJson(
|
||||
fleetCommand: Command,
|
||||
deps: FleetReconcilerCommandDeps,
|
||||
operation: FleetReconcileCommand,
|
||||
opts: ReconcileOptions,
|
||||
agentName?: string,
|
||||
): Promise<void> {
|
||||
await writeOutcome(async (): Promise<void> => {
|
||||
await executeReconcilerCommand(fleetCommand, deps, operation, opts, agentName);
|
||||
});
|
||||
}
|
||||
|
||||
export async function executeReconcilerCommand(
|
||||
fleetCommand: Command,
|
||||
deps: FleetReconcilerCommandDeps,
|
||||
operation: FleetReconcileCommand,
|
||||
opts: ReconcileOptions,
|
||||
agentName?: string,
|
||||
): Promise<void> {
|
||||
const mosaicHome = resolveMosaicHome(fleetCommand, deps);
|
||||
const rosterPath = resolveRosterPath(fleetCommand, mosaicHome);
|
||||
const roster = parseRosterV2(await readFile(rosterPath, 'utf8'), 'yaml');
|
||||
const mutating = operation === 'apply' || operation === 'reconcile' || isLifecycle(operation);
|
||||
const expectedGeneration = mutating
|
||||
? parseExpectedGeneration(opts.expectedGeneration)
|
||||
: undefined;
|
||||
const result = await executeFleetReconcile({
|
||||
roster,
|
||||
command: opts.dryRun === true ? 'plan' : operation,
|
||||
...(agentName === undefined ? {} : { agentName }),
|
||||
...(expectedGeneration === undefined ? {} : { expectedGeneration }),
|
||||
deps: {
|
||||
runner: async (command: string, args: readonly string[]) => deps.runner(command, [...args]),
|
||||
mosaicHome,
|
||||
rolesDir: join(mosaicHome, 'fleet', 'roles'),
|
||||
overrideDir: join(mosaicHome, 'fleet', 'roles.local'),
|
||||
readRoster: async (): Promise<typeof roster> =>
|
||||
parseRosterV2(await readFile(rosterPath, 'utf8'), 'yaml'),
|
||||
...(deps.reconcileDeps ?? {}),
|
||||
},
|
||||
});
|
||||
printJson(result);
|
||||
process.exitCode = result.recovery === undefined && result.cleanup === undefined ? 0 : 1;
|
||||
}
|
||||
|
||||
function isLifecycle(operation: FleetReconcileCommand): boolean {
|
||||
return operation === 'start' || operation === 'stop' || operation === 'restart';
|
||||
}
|
||||
|
||||
function parseExpectedGeneration(value: string | undefined): number {
|
||||
const generation = Number(value);
|
||||
if (!Number.isSafeInteger(generation) || generation < 1) {
|
||||
throw new FleetReconcileError(
|
||||
'lifecycle-precondition-failed',
|
||||
'--expected-generation must be a positive safe integer.',
|
||||
);
|
||||
}
|
||||
return generation;
|
||||
}
|
||||
|
||||
function resolveMosaicHome(fleetCommand: Command, deps: FleetReconcilerCommandDeps): string {
|
||||
const options = fleetCommand.optsWithGlobals<{ mosaicHome?: string }>();
|
||||
return (
|
||||
options.mosaicHome ?? deps.mosaicHome ?? join(process.env['HOME'] ?? '', '.config', 'mosaic')
|
||||
);
|
||||
}
|
||||
|
||||
function resolveRosterPath(fleetCommand: Command, mosaicHome: string): string {
|
||||
const options = fleetCommand.optsWithGlobals<{ roster?: string }>();
|
||||
const canonical = join(mosaicHome, 'fleet', 'roster.yaml');
|
||||
if (options.roster !== undefined && resolve(options.roster) !== resolve(canonical)) {
|
||||
throw new FleetReconcileError(
|
||||
'lifecycle-precondition-failed',
|
||||
'Roster-v2 reconciliation requires the canonical roster path.',
|
||||
);
|
||||
}
|
||||
return canonical;
|
||||
}
|
||||
|
||||
async function writeOutcome(action: () => Promise<void>): Promise<void> {
|
||||
try {
|
||||
await action();
|
||||
} catch (error: unknown) {
|
||||
process.exitCode = 1;
|
||||
printJson({
|
||||
error: {
|
||||
code: error instanceof FleetReconcileError ? error.code : 'reconcile-failed',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function printJson(value: object): void {
|
||||
console.log(JSON.stringify(value));
|
||||
}
|
||||
@@ -81,18 +81,22 @@ describe('registerFleetCommand', () => {
|
||||
expect(fleet).toBeDefined();
|
||||
expect(fleet!.commands.map((command) => command.name()).sort()).toEqual([
|
||||
'add',
|
||||
'apply',
|
||||
'backlog',
|
||||
'create',
|
||||
'delete',
|
||||
'doctor',
|
||||
'get',
|
||||
'init',
|
||||
'install',
|
||||
'install-systemd',
|
||||
'migrate-v1',
|
||||
'persona',
|
||||
'plan',
|
||||
'profile',
|
||||
'provision',
|
||||
'ps',
|
||||
'reconcile',
|
||||
'remove',
|
||||
'restart',
|
||||
'start',
|
||||
@@ -194,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');
|
||||
@@ -267,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');
|
||||
@@ -285,8 +335,8 @@ describe('fleet roster parsing', () => {
|
||||
expect(generateAgentEnv(roster, getRosterAgent(roster, 'coder0'))).toBe(
|
||||
[
|
||||
'MOSAIC_AGENT_NAME=coder0',
|
||||
// Reflects the roster's non-default `class: implementer` (A3a).
|
||||
'MOSAIC_AGENT_CLASS=implementer',
|
||||
// Reflects the roster's canonicalized compatibility class (A3a).
|
||||
'MOSAIC_AGENT_CLASS=code',
|
||||
'MOSAIC_AGENT_RUNTIME=codex',
|
||||
'MOSAIC_AGENT_MODEL=',
|
||||
'MOSAIC_AGENT_REASONING=',
|
||||
@@ -490,7 +540,13 @@ describe('fleet command construction', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('runs fleet status through injected runner without touching tmux in tests', async () => {
|
||||
it('runs legacy fleet status through injected runner without touching tmux in tests', async () => {
|
||||
const home = await tempDir();
|
||||
await mkdir(join(home, 'fleet'), { recursive: true });
|
||||
await writeFile(
|
||||
join(home, 'fleet', 'roster.yaml'),
|
||||
'version: 1\ntransport: tmux\nagents: []\n',
|
||||
);
|
||||
const calls: string[][] = [];
|
||||
const runner: CommandRunner = async (command, args) => {
|
||||
calls.push([command, ...args]);
|
||||
@@ -498,11 +554,14 @@ describe('fleet command construction', () => {
|
||||
};
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerFleetCommand(program, { runner });
|
||||
registerFleetCommand(program, { runner, mosaicHome: home });
|
||||
|
||||
await program.parseAsync(['node', 'mosaic', 'fleet', 'status']);
|
||||
|
||||
expect(calls).toEqual([['systemctl', '--user', 'status', 'mosaic-tmux-holder.service']]);
|
||||
try {
|
||||
await program.parseAsync(['node', 'mosaic', 'fleet', 'status']);
|
||||
expect(calls).toEqual([['systemctl', '--user', 'status', 'mosaic-tmux-holder.service']]);
|
||||
} finally {
|
||||
await rm(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('verifies liveness with tmux has-session and does not trust systemd active exited', async () => {
|
||||
@@ -618,13 +677,19 @@ describe('fleet command construction', () => {
|
||||
).rejects.toThrow('Unsupported fleet profile');
|
||||
});
|
||||
|
||||
it('sets process exitCode when status runner fails', async () => {
|
||||
it('sets process exitCode when legacy status runner fails', async () => {
|
||||
const home = await tempDir();
|
||||
await mkdir(join(home, 'fleet'), { recursive: true });
|
||||
await writeFile(
|
||||
join(home, 'fleet', 'roster.yaml'),
|
||||
'version: 1\ntransport: tmux\nagents: []\n',
|
||||
);
|
||||
const originalExitCode = process.exitCode;
|
||||
const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
||||
const runner: CommandRunner = async () => ({ stdout: '', stderr: 'missing\n', exitCode: 3 });
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerFleetCommand(program, { runner });
|
||||
registerFleetCommand(program, { runner, mosaicHome: home });
|
||||
|
||||
try {
|
||||
await program.parseAsync(['node', 'mosaic', 'fleet', 'status']);
|
||||
@@ -632,6 +697,7 @@ describe('fleet command construction', () => {
|
||||
} finally {
|
||||
process.exitCode = originalExitCode;
|
||||
stderrSpy.mockRestore();
|
||||
await rm(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -3486,7 +3552,66 @@ describe('fleet add/remove — pure helpers', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('serializeRosterToYaml round-trips optional fields (modelHint, workingDirectory)', async () => {
|
||||
it.each([
|
||||
['tmux', { kind: 'tmux' }],
|
||||
['discord', { kind: 'discord', discord: { channelId: '1234567890' } }],
|
||||
[
|
||||
'matrix',
|
||||
{
|
||||
kind: 'matrix',
|
||||
matrix: {
|
||||
homeserverUrl: 'https://matrix.example.test',
|
||||
userId: '@mosaic:example.test',
|
||||
roomId: '!fleet:example.test',
|
||||
},
|
||||
},
|
||||
],
|
||||
] as const)('round-trips the supported %s connector through YAML', async (_kind, connector) => {
|
||||
const yaml = serializeRosterToYaml({ ...baseRoster, connector });
|
||||
const dir = await mkdtemp(join(tmpdir(), 'mosaic-fleet-connector-'));
|
||||
const rosterPath = join(dir, 'roster.yaml');
|
||||
try {
|
||||
await writeFile(rosterPath, yaml);
|
||||
expect((await loadFleetRoster(rosterPath)).connector).toEqual(connector);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
['tmux', { kind: 'tmux' }],
|
||||
['discord', { kind: 'discord', discord: { channel_id: '1234567890' } }],
|
||||
[
|
||||
'matrix',
|
||||
{
|
||||
kind: 'matrix',
|
||||
matrix: {
|
||||
homeserver_url: 'https://matrix.example.test',
|
||||
user_id: '@mosaic:example.test',
|
||||
room_id: '!fleet:example.test',
|
||||
},
|
||||
},
|
||||
],
|
||||
] as const)('parses the supported %s connector from JSON', async (_kind, connector) => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'mosaic-fleet-connector-'));
|
||||
const rosterPath = join(dir, 'roster.json');
|
||||
try {
|
||||
await writeFile(
|
||||
rosterPath,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
transport: 'tmux',
|
||||
agents: [{ name: 'orchestrator', runtime: 'claude', class: 'orchestrator' }],
|
||||
connector,
|
||||
}),
|
||||
);
|
||||
expect((await loadFleetRoster(rosterPath)).connector?.kind).toBe(_kind);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('serializeRosterToYaml round-trips optional fields and exact comms targets', async () => {
|
||||
const rosterWithOptionals: FleetRoster = {
|
||||
...baseRoster,
|
||||
agents: [
|
||||
@@ -3498,6 +3623,9 @@ describe('fleet add/remove — pure helpers', () => {
|
||||
workingDirectory: '/tmp/work',
|
||||
persistentPersona: true,
|
||||
resetBetweenTasks: false,
|
||||
host: '10.1.10.37',
|
||||
ssh: 'jwoltje@10.1.10.37',
|
||||
socket: 'mosaic-fleet',
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -3505,6 +3633,9 @@ describe('fleet add/remove — pure helpers', () => {
|
||||
expect(yaml).toContain('model_hint: claude-3-5-sonnet');
|
||||
expect(yaml).toContain('working_directory: /tmp/work');
|
||||
expect(yaml).toContain('persistent_persona: true');
|
||||
expect(yaml).toContain('host: 10.1.10.37');
|
||||
expect(yaml).toContain('ssh: jwoltje@10.1.10.37');
|
||||
expect(yaml).toContain('socket: mosaic-fleet');
|
||||
|
||||
const dir = await mkdtemp(join(tmpdir(), 'mosaic-fleet-'));
|
||||
const rosterPath = join(dir, 'roster.yaml');
|
||||
@@ -3514,6 +3645,9 @@ describe('fleet add/remove — pure helpers', () => {
|
||||
expect(loaded.agents[0]!.modelHint).toBe('claude-3-5-sonnet');
|
||||
expect(loaded.agents[0]!.workingDirectory).toBe('/tmp/work');
|
||||
expect(loaded.agents[0]!.persistentPersona).toBe(true);
|
||||
expect(loaded.agents[0]!.host).toBe('10.1.10.37');
|
||||
expect(loaded.agents[0]!.ssh).toBe('jwoltje@10.1.10.37');
|
||||
expect(loaded.agents[0]!.socket).toBe('mosaic-fleet');
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -18,10 +18,32 @@ import { spawn } from 'node:child_process';
|
||||
import * as readline from 'node:readline';
|
||||
import type { Command } from 'commander';
|
||||
import YAML from 'yaml';
|
||||
import {
|
||||
getRosterAgent,
|
||||
loadFleetRoster,
|
||||
resolveInstalledFleetRosterPath,
|
||||
type FleetAgent,
|
||||
type FleetRoster,
|
||||
} from '../fleet/fleet-roster-v1.js';
|
||||
export {
|
||||
getRosterAgent,
|
||||
loadFleetRoster,
|
||||
resolveInstalledFleetRosterPath,
|
||||
} from '../fleet/fleet-roster-v1.js';
|
||||
export type { FleetAgent, FleetRoster } from '../fleet/fleet-roster-v1.js';
|
||||
import {
|
||||
registerFleetAgentCrudCommands,
|
||||
type FleetAgentCrudCommandDeps,
|
||||
} from './fleet-agent-crud-command.js';
|
||||
import {
|
||||
registerFleetMigrationCommand,
|
||||
type FleetMigrationCommandDeps,
|
||||
} from './fleet-migration-command.js';
|
||||
import {
|
||||
executeReconcilerCommandJson,
|
||||
registerFleetReconcilerCommands,
|
||||
type FleetReconcilerCommandDeps,
|
||||
} from './fleet-reconciler-command.js';
|
||||
import { resolveCommsBlock } from '../fleet/comms-onboarding.js';
|
||||
import {
|
||||
applyPreparedAgentEnvironmentProjection,
|
||||
@@ -78,72 +100,8 @@ export interface FleetCommandDeps {
|
||||
*/
|
||||
isStdinTTY?: boolean;
|
||||
projectionApplier?: FleetAgentCrudCommandDeps['projectionApplier'];
|
||||
}
|
||||
|
||||
interface RawFleetRoster {
|
||||
version?: unknown;
|
||||
transport?: unknown;
|
||||
tmux?: {
|
||||
socket_name?: unknown;
|
||||
socketName?: unknown;
|
||||
holder_session?: unknown;
|
||||
holderSession?: unknown;
|
||||
};
|
||||
defaults?: {
|
||||
working_directory?: unknown;
|
||||
workingDirectory?: unknown;
|
||||
};
|
||||
runtimes?: Record<string, { reset_command?: unknown; resetCommand?: unknown }>;
|
||||
agents?: Array<{
|
||||
name?: unknown;
|
||||
alias?: unknown;
|
||||
provider?: unknown;
|
||||
runtime?: unknown;
|
||||
class?: unknown;
|
||||
working_directory?: unknown;
|
||||
workingDirectory?: unknown;
|
||||
model_hint?: unknown;
|
||||
modelHint?: unknown;
|
||||
reasoning_level?: unknown;
|
||||
reasoningLevel?: unknown;
|
||||
tool_policy?: unknown;
|
||||
toolPolicy?: unknown;
|
||||
persistent_persona?: unknown;
|
||||
persistentPersona?: unknown;
|
||||
reset_between_tasks?: unknown;
|
||||
resetBetweenTasks?: unknown;
|
||||
kickstart_template?: unknown;
|
||||
kickstartTemplate?: unknown;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface FleetAgent {
|
||||
name: string;
|
||||
alias?: string;
|
||||
provider?: string;
|
||||
runtime: string;
|
||||
className: string;
|
||||
workingDirectory?: string;
|
||||
modelHint?: string;
|
||||
reasoningLevel?: string;
|
||||
toolPolicy?: string;
|
||||
persistentPersona?: boolean | string;
|
||||
resetBetweenTasks?: boolean;
|
||||
kickstartTemplate?: string;
|
||||
}
|
||||
|
||||
export interface FleetRoster {
|
||||
version: 1;
|
||||
transport: 'tmux';
|
||||
tmux: {
|
||||
socketName: string;
|
||||
holderSession: string;
|
||||
};
|
||||
defaults: {
|
||||
workingDirectory: string;
|
||||
};
|
||||
runtimes: Record<string, { resetCommand: string }>;
|
||||
agents: FleetAgent[];
|
||||
reconcileDeps?: FleetReconcilerCommandDeps['reconcileDeps'];
|
||||
migrationDeps?: Omit<FleetMigrationCommandDeps, 'mosaicHome'>;
|
||||
}
|
||||
|
||||
export interface FleetPaths {
|
||||
@@ -164,8 +122,6 @@ type FleetServiceAction = 'start' | 'stop' | 'restart' | 'status';
|
||||
* fallback for a socket-less roster (that now resolves to the default socket).
|
||||
*/
|
||||
export const DEFAULT_SOCKET_NAME = 'mosaic-fleet';
|
||||
const DEFAULT_HOLDER_SESSION = '_holder';
|
||||
const DEFAULT_WORKING_DIRECTORY = '~/src';
|
||||
|
||||
/**
|
||||
* tmux `-L` args for a socket name. An empty/absent socket ⇒ the LITERAL default
|
||||
@@ -189,13 +145,6 @@ export const VERIFY_POLL_INTERVAL_MS = 400;
|
||||
* Configurable via `--verify-timeout <ms>` on `agent send`.
|
||||
*/
|
||||
export const VERIFY_DEFAULT_TIMEOUT_MS = 6_000;
|
||||
const DEFAULT_RUNTIME_RESETS: Record<string, { resetCommand: string }> = {
|
||||
claude: { resetCommand: '/clear' },
|
||||
codex: { resetCommand: '/clear' },
|
||||
opencode: { resetCommand: '/clear' },
|
||||
pi: { resetCommand: '/new' },
|
||||
};
|
||||
|
||||
export function resolveFleetPaths(mosaicHome = defaultMosaicHome()): FleetPaths {
|
||||
return {
|
||||
mosaicHome,
|
||||
@@ -223,20 +172,6 @@ function assertDefaultMosaicHomeForSystemd(mosaicHome: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadFleetRoster(path: string): Promise<FleetRoster> {
|
||||
const rawText = await readFile(path, 'utf8');
|
||||
const parsed = parseRosterText(rawText, path);
|
||||
return normalizeRoster(parsed);
|
||||
}
|
||||
|
||||
export function getRosterAgent(roster: FleetRoster, name: string): FleetAgent {
|
||||
const agent = roster.agents.find((candidate) => candidate.name === name);
|
||||
if (!agent) {
|
||||
throw new Error(`Agent "${name}" is not in the fleet roster.`);
|
||||
}
|
||||
return agent;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// NORTH_STAR — machine-readable fleet planning source + Markdown projection
|
||||
//
|
||||
@@ -550,8 +485,8 @@ function generateAgentEnvValues(
|
||||
MOSAIC_AGENT_MODEL: agent.modelHint ?? '',
|
||||
MOSAIC_AGENT_REASONING: agent.reasoningLevel ?? '',
|
||||
MOSAIC_AGENT_TOOL_POLICY: agent.toolPolicy ?? '',
|
||||
MOSAIC_AGENT_WORKDIR: expandHome(workingDirectory),
|
||||
MOSAIC_TMUX_SOCKET: roster.tmux.socketName,
|
||||
MOSAIC_AGENT_WORKDIR: workingDirectory,
|
||||
MOSAIC_TMUX_SOCKET: agent.socket ?? roster.tmux.socketName,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1606,53 +1541,70 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
||||
cmd
|
||||
.command(`${action} [agent]`)
|
||||
.description(`${action} the fleet holder or one agent`)
|
||||
.action(async (agent?: string) => {
|
||||
const commandOpts = cmd.opts<{ mosaicHome: string; roster?: string }>();
|
||||
const activePaths = resolveFleetPaths(commandOpts.mosaicHome);
|
||||
const roster = await loadRosterForCommand(cmd);
|
||||
if (agent) {
|
||||
getRosterAgent(roster, agent);
|
||||
// Single-agent restart is guarded too: it can race a full restart that
|
||||
// is tearing the shared holder down.
|
||||
.option('--expected-generation <number>', 'Authoritative roster generation for roster-v2')
|
||||
.option('--dry-run', 'Plan roster-v2 lifecycle effects without mutation')
|
||||
.action(
|
||||
async (
|
||||
agent: string | undefined,
|
||||
opts: { expectedGeneration?: string; dryRun?: boolean },
|
||||
) => {
|
||||
if (await usesRosterV2ControlPlane(cmd)) {
|
||||
await executeReconcilerCommandJson(
|
||||
cmd,
|
||||
{ runner, mosaicHome: deps.mosaicHome, reconcileDeps: deps.reconcileDeps },
|
||||
action,
|
||||
opts,
|
||||
agent,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const commandOpts = cmd.opts<{ mosaicHome: string; roster?: string }>();
|
||||
const activePaths = resolveFleetPaths(commandOpts.mosaicHome);
|
||||
const roster = await loadRosterForCommand(cmd);
|
||||
if (agent) {
|
||||
getRosterAgent(roster, agent);
|
||||
// Single-agent restart is guarded too: it can race a full restart that
|
||||
// is tearing the shared holder down.
|
||||
if (action === 'restart') {
|
||||
const guard = await acquireRestartLock(activePaths.mosaicHome, sleepFn);
|
||||
try {
|
||||
await runChecked(runner, buildFleetServiceCommand(action, agent));
|
||||
} finally {
|
||||
await guard.release();
|
||||
}
|
||||
return;
|
||||
}
|
||||
await runChecked(runner, buildFleetServiceCommand(action, agent));
|
||||
return;
|
||||
}
|
||||
if (action === 'stop') {
|
||||
await stopFleetBestEffort(
|
||||
runner,
|
||||
roster.agents.map((rosterAgent) => rosterAgent.name),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (action === 'restart') {
|
||||
// Serialize the holder+agents teardown/relaunch behind the restart lock
|
||||
// so a re-entrant restart waits for clean shutdown before relaunching,
|
||||
// instead of racing a half-torn-down holder into a tight loop.
|
||||
const guard = await acquireRestartLock(activePaths.mosaicHome, sleepFn);
|
||||
try {
|
||||
await runChecked(runner, buildFleetServiceCommand(action, agent));
|
||||
await runChecked(runner, buildFleetServiceCommand(action));
|
||||
for (const rosterAgent of roster.agents) {
|
||||
await runChecked(runner, buildFleetServiceCommand(action, rosterAgent.name));
|
||||
}
|
||||
} finally {
|
||||
await guard.release();
|
||||
}
|
||||
return;
|
||||
}
|
||||
await runChecked(runner, buildFleetServiceCommand(action, agent));
|
||||
return;
|
||||
}
|
||||
if (action === 'stop') {
|
||||
await stopFleetBestEffort(
|
||||
runner,
|
||||
roster.agents.map((rosterAgent) => rosterAgent.name),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (action === 'restart') {
|
||||
// Serialize the holder+agents teardown/relaunch behind the restart lock
|
||||
// so a re-entrant restart waits for clean shutdown before relaunching,
|
||||
// instead of racing a half-torn-down holder into a tight loop.
|
||||
const guard = await acquireRestartLock(activePaths.mosaicHome, sleepFn);
|
||||
try {
|
||||
await runChecked(runner, buildFleetServiceCommand(action));
|
||||
for (const rosterAgent of roster.agents) {
|
||||
await runChecked(runner, buildFleetServiceCommand(action, rosterAgent.name));
|
||||
}
|
||||
} finally {
|
||||
await guard.release();
|
||||
await runChecked(runner, buildFleetServiceCommand(action));
|
||||
for (const rosterAgent of roster.agents) {
|
||||
await runChecked(runner, buildFleetServiceCommand(action, rosterAgent.name));
|
||||
}
|
||||
return;
|
||||
}
|
||||
await runChecked(runner, buildFleetServiceCommand(action));
|
||||
for (const rosterAgent of roster.agents) {
|
||||
await runChecked(runner, buildFleetServiceCommand(action, rosterAgent.name));
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
cmd
|
||||
@@ -1660,6 +1612,16 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
||||
.description('Show fleet holder or agent systemd status')
|
||||
.option('--json', 'Print JSON status')
|
||||
.action(async (agent: string | undefined, opts: { json?: boolean }) => {
|
||||
if (await usesRosterV2ControlPlane(cmd)) {
|
||||
await executeReconcilerCommandJson(
|
||||
cmd,
|
||||
{ runner, mosaicHome: deps.mosaicHome, reconcileDeps: deps.reconcileDeps },
|
||||
'status',
|
||||
{},
|
||||
agent,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (agent) {
|
||||
const roster = await loadRosterForCommand(cmd);
|
||||
getRosterAgent(roster, agent);
|
||||
@@ -1683,6 +1645,15 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
||||
.command('verify')
|
||||
.description('Verify the local canary holder and roster sessions on the isolated socket')
|
||||
.action(async () => {
|
||||
if (await usesRosterV2ControlPlane(cmd)) {
|
||||
await executeReconcilerCommandJson(
|
||||
cmd,
|
||||
{ runner, mosaicHome: deps.mosaicHome, reconcileDeps: deps.reconcileDeps },
|
||||
'verify',
|
||||
{},
|
||||
);
|
||||
return;
|
||||
}
|
||||
const roster = await loadRosterForCommand(cmd);
|
||||
const socketName = roster.tmux.socketName;
|
||||
await runChecked(runner, [
|
||||
@@ -2082,6 +2053,15 @@ 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,
|
||||
reconcileDeps: deps.reconcileDeps,
|
||||
});
|
||||
|
||||
return cmd;
|
||||
}
|
||||
@@ -2110,14 +2090,11 @@ export function registerFleetAgentCommands(
|
||||
});
|
||||
|
||||
agentCommand
|
||||
.command('comms-block <role>')
|
||||
.description(
|
||||
"Print the Fleet Comms cheat-sheet for a roster role (preview a peer's peer-reach view)",
|
||||
)
|
||||
.option('--host <host>', 'Override the fleet host (preview a cross-host peer view)')
|
||||
.action((role: string, opts: { host?: string }) => {
|
||||
.command('comms-block <exact-member>')
|
||||
.description('Print the Fleet Comms contract for one exact roster member')
|
||||
.action((exactMember: string) => {
|
||||
const mosaicHome = resolveMosaicHomeFromCommand(agentCommand, deps.mosaicHome);
|
||||
const res = resolveCommsBlock(mosaicHome, role, opts.host);
|
||||
const res = resolveCommsBlock(mosaicHome, exactMember);
|
||||
if (!res.ok) {
|
||||
console.error(`[mosaic] comms-block: ${res.error}`);
|
||||
process.exitCode = 1;
|
||||
@@ -2415,6 +2392,20 @@ async function loadRosterForCommand(cmd: Command): Promise<FleetRoster> {
|
||||
return loadFleetRoster(await resolveRosterPath(opts.mosaicHome, opts.roster));
|
||||
}
|
||||
|
||||
/** Routes only a v2 roster to the M3 desired-state control plane; v1 aliases stay compatible. */
|
||||
async function usesRosterV2ControlPlane(cmd: Command): Promise<boolean> {
|
||||
const opts = cmd.opts<{ mosaicHome: string; roster?: string }>();
|
||||
const path = await resolveRosterPath(opts.mosaicHome, opts.roster);
|
||||
const parsed: unknown = YAML.parse(await readFile(path, 'utf8'));
|
||||
return (
|
||||
typeof parsed === 'object' &&
|
||||
parsed !== null &&
|
||||
!Array.isArray(parsed) &&
|
||||
'version' in parsed &&
|
||||
parsed.version === 2
|
||||
);
|
||||
}
|
||||
|
||||
async function loadRosterFromAgentCommand(
|
||||
command: Command,
|
||||
mosaicHomeOverride?: string,
|
||||
@@ -2429,253 +2420,6 @@ function resolveMosaicHomeFromCommand(command: Command, override?: string): stri
|
||||
return opts.mosaicHome ?? override ?? defaultMosaicHome();
|
||||
}
|
||||
|
||||
function parseRosterText(text: string, path: string): RawFleetRoster {
|
||||
const trimmed = text.trim();
|
||||
if (path.endsWith('.json')) {
|
||||
return JSON.parse(trimmed) as RawFleetRoster;
|
||||
}
|
||||
return YAML.parse(trimmed) as RawFleetRoster;
|
||||
}
|
||||
|
||||
function normalizeRoster(raw: RawFleetRoster): FleetRoster {
|
||||
assertObject(raw, 'Fleet roster');
|
||||
assertKnownKeys(raw, 'Fleet roster', [
|
||||
'version',
|
||||
'transport',
|
||||
'tmux',
|
||||
'defaults',
|
||||
'runtimes',
|
||||
'agents',
|
||||
]);
|
||||
if (raw.tmux !== undefined) {
|
||||
assertObject(raw.tmux, 'Fleet roster tmux');
|
||||
assertKnownKeys(raw.tmux, 'Fleet roster tmux', [
|
||||
'socket_name',
|
||||
'socketName',
|
||||
'holder_session',
|
||||
'holderSession',
|
||||
]);
|
||||
}
|
||||
if (raw.defaults !== undefined) {
|
||||
assertObject(raw.defaults, 'Fleet roster defaults');
|
||||
assertKnownKeys(raw.defaults, 'Fleet roster defaults', [
|
||||
'working_directory',
|
||||
'workingDirectory',
|
||||
]);
|
||||
}
|
||||
if (raw.runtimes !== undefined) {
|
||||
assertObject(raw.runtimes, 'Fleet roster runtimes');
|
||||
for (const [runtime, config] of Object.entries(raw.runtimes)) {
|
||||
assertObject(config, `Fleet roster runtime "${runtime}"`);
|
||||
assertKnownKeys(config, `Fleet roster runtime "${runtime}"`, [
|
||||
'reset_command',
|
||||
'resetCommand',
|
||||
]);
|
||||
}
|
||||
}
|
||||
if (raw.version !== 1) {
|
||||
throw new Error('Fleet roster version must be 1.');
|
||||
}
|
||||
if (raw.transport !== 'tmux') {
|
||||
throw new Error('Fleet roster transport must be "tmux".');
|
||||
}
|
||||
if (!Array.isArray(raw.agents) || raw.agents.length === 0) {
|
||||
throw new Error('Fleet roster must define at least one agent.');
|
||||
}
|
||||
|
||||
const agents = raw.agents.map(normalizeAgent);
|
||||
assertUniqueAgentNames(agents);
|
||||
|
||||
return {
|
||||
version: 1,
|
||||
transport: 'tmux',
|
||||
tmux: {
|
||||
// Absent socket_name ⇒ '' (the literal default tmux socket, no -L) — NOT
|
||||
// mosaic-fleet. Shipped presets set socket_name explicitly, so they are
|
||||
// unaffected; only socket-less rosters get default-socket behavior.
|
||||
socketName: stringValue(
|
||||
raw.tmux?.socket_name ?? raw.tmux?.socketName,
|
||||
'',
|
||||
'Fleet roster tmux socket_name',
|
||||
),
|
||||
holderSession: stringValue(
|
||||
raw.tmux?.holder_session ?? raw.tmux?.holderSession,
|
||||
DEFAULT_HOLDER_SESSION,
|
||||
'Fleet roster tmux holder_session',
|
||||
),
|
||||
},
|
||||
defaults: {
|
||||
workingDirectory: stringValue(
|
||||
raw.defaults?.working_directory ?? raw.defaults?.workingDirectory,
|
||||
DEFAULT_WORKING_DIRECTORY,
|
||||
'Fleet roster defaults working_directory',
|
||||
),
|
||||
},
|
||||
runtimes: normalizeRuntimes(raw.runtimes as RawFleetRoster['runtimes']),
|
||||
agents,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeAgent(raw: NonNullable<RawFleetRoster['agents']>[number]): FleetAgent {
|
||||
assertObject(raw, 'Fleet roster agent');
|
||||
assertKnownKeys(raw, 'Fleet roster agent', [
|
||||
'name',
|
||||
'alias',
|
||||
'provider',
|
||||
'runtime',
|
||||
'class',
|
||||
'working_directory',
|
||||
'workingDirectory',
|
||||
'model_hint',
|
||||
'modelHint',
|
||||
'reasoning_level',
|
||||
'reasoningLevel',
|
||||
'tool_policy',
|
||||
'toolPolicy',
|
||||
'persistent_persona',
|
||||
'persistentPersona',
|
||||
'reset_between_tasks',
|
||||
'resetBetweenTasks',
|
||||
'kickstart_template',
|
||||
'kickstartTemplate',
|
||||
]);
|
||||
const name = stringValue(raw.name, '', 'Fleet roster agent name');
|
||||
const runtime = stringValue(
|
||||
raw.runtime,
|
||||
'',
|
||||
`Fleet roster agent "${name || '<unknown>'}" runtime`,
|
||||
);
|
||||
if (!name || !/^[A-Za-z0-9_.-]+$/.test(name)) {
|
||||
throw new Error(`Invalid fleet agent name: ${name || '<empty>'}`);
|
||||
}
|
||||
if (!runtime) {
|
||||
throw new Error(`Fleet agent "${name}" must define a runtime.`);
|
||||
}
|
||||
return {
|
||||
name,
|
||||
alias: optionalString(raw.alias, `Fleet roster agent "${name}" alias`),
|
||||
provider: optionalString(raw.provider, `Fleet roster agent "${name}" provider`),
|
||||
runtime,
|
||||
className: stringValue(raw.class, 'worker', `Fleet roster agent "${name}" class`),
|
||||
workingDirectory: optionalString(
|
||||
raw.working_directory ?? raw.workingDirectory,
|
||||
`Fleet roster agent "${name}" working_directory`,
|
||||
),
|
||||
modelHint: optionalString(
|
||||
raw.model_hint ?? raw.modelHint,
|
||||
`Fleet roster agent "${name}" model_hint`,
|
||||
),
|
||||
reasoningLevel: optionalString(
|
||||
raw.reasoning_level ?? raw.reasoningLevel,
|
||||
`Fleet roster agent "${name}" reasoning_level`,
|
||||
),
|
||||
toolPolicy: optionalString(
|
||||
raw.tool_policy ?? raw.toolPolicy,
|
||||
`Fleet roster agent "${name}" tool_policy`,
|
||||
),
|
||||
persistentPersona: optionalBooleanOrString(
|
||||
raw.persistent_persona ?? raw.persistentPersona,
|
||||
`Fleet roster agent "${name}" persistent_persona`,
|
||||
),
|
||||
resetBetweenTasks: optionalBoolean(
|
||||
raw.reset_between_tasks ?? raw.resetBetweenTasks,
|
||||
`Fleet roster agent "${name}" reset_between_tasks`,
|
||||
),
|
||||
kickstartTemplate: optionalString(
|
||||
raw.kickstart_template ?? raw.kickstartTemplate,
|
||||
`Fleet roster agent "${name}" kickstart_template`,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeRuntimes(
|
||||
raw: RawFleetRoster['runtimes'] | undefined,
|
||||
): Record<string, { resetCommand: string }> {
|
||||
const result: Record<string, { resetCommand: string }> = { ...DEFAULT_RUNTIME_RESETS };
|
||||
for (const [runtime, config] of Object.entries(raw ?? {})) {
|
||||
result[runtime] = {
|
||||
resetCommand: stringValue(
|
||||
config.reset_command ?? config.resetCommand,
|
||||
'/clear',
|
||||
`Fleet roster runtime "${runtime}" reset_command`,
|
||||
),
|
||||
};
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function assertObject(value: unknown, label: string): asserts value is Record<string, unknown> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new Error(`${label} must be an object.`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertKnownKeys(
|
||||
value: Record<string, unknown>,
|
||||
label: string,
|
||||
allowedKeys: readonly string[],
|
||||
): void {
|
||||
const allowed = new Set(allowedKeys);
|
||||
const unknownKeys = Object.keys(value).filter((key) => !allowed.has(key));
|
||||
if (unknownKeys.length > 0) {
|
||||
throw new Error(`${label} has unknown field(s): ${unknownKeys.join(', ')}.`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertUniqueAgentNames(agents: FleetAgent[]): void {
|
||||
const seen = new Set<string>();
|
||||
for (const agent of agents) {
|
||||
if (seen.has(agent.name)) {
|
||||
throw new Error(`Fleet roster has duplicate agent name: ${agent.name}.`);
|
||||
}
|
||||
seen.add(agent.name);
|
||||
}
|
||||
}
|
||||
|
||||
function stringValue(value: unknown, fallback = '', label = 'Value'): string {
|
||||
if (value === undefined) {
|
||||
return fallback;
|
||||
}
|
||||
if (typeof value !== 'string') {
|
||||
throw new Error(`${label} must be a string.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalString(value: unknown, label = 'Value'): string | undefined {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof value !== 'string') {
|
||||
throw new Error(`${label} must be a string.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalBoolean(value: unknown, label = 'Value'): boolean | undefined {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof value !== 'boolean') {
|
||||
throw new Error(`${label} must be a boolean.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalBooleanOrString(value: unknown, label = 'Value'): boolean | string | undefined {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof value !== 'boolean' && typeof value !== 'string') {
|
||||
throw new Error(`${label} must be a boolean or string.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -2828,6 +2572,23 @@ export function removeAgentFromRoster(roster: FleetRoster, name: string): FleetR
|
||||
};
|
||||
}
|
||||
|
||||
function serializeConnector(
|
||||
connector: NonNullable<FleetRoster['connector']>,
|
||||
): Record<string, unknown> {
|
||||
if (connector.kind === 'tmux') return { kind: 'tmux' };
|
||||
if (connector.kind === 'discord') {
|
||||
return { kind: 'discord', discord: { channel_id: connector.discord.channelId } };
|
||||
}
|
||||
return {
|
||||
kind: 'matrix',
|
||||
matrix: {
|
||||
homeserver_url: connector.matrix.homeserverUrl,
|
||||
user_id: connector.matrix.userId,
|
||||
room_id: connector.matrix.roomId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize a FleetRoster to YAML text (snake_case keys).
|
||||
* The output is parseable by loadFleetRoster.
|
||||
@@ -2845,6 +2606,15 @@ export function serializeRosterToYaml(roster: FleetRoster): string {
|
||||
if (agent.provider !== undefined) {
|
||||
raw['provider'] = agent.provider;
|
||||
}
|
||||
if (agent.host !== undefined) {
|
||||
raw['host'] = agent.host;
|
||||
}
|
||||
if (agent.ssh !== undefined) {
|
||||
raw['ssh'] = agent.ssh;
|
||||
}
|
||||
if (agent.socket !== undefined) {
|
||||
raw['socket'] = agent.socket;
|
||||
}
|
||||
if (agent.workingDirectory !== undefined) {
|
||||
raw['working_directory'] = agent.workingDirectory;
|
||||
}
|
||||
@@ -2886,6 +2656,7 @@ export function serializeRosterToYaml(roster: FleetRoster): string {
|
||||
},
|
||||
runtimes,
|
||||
agents,
|
||||
...(roster.connector ? { connector: serializeConnector(roster.connector) } : {}),
|
||||
};
|
||||
|
||||
return YAML.stringify(raw);
|
||||
@@ -3032,6 +2803,5 @@ export async function resolveRosterPath(
|
||||
if (await canRead(yamlPath)) {
|
||||
return yamlPath;
|
||||
}
|
||||
const jsonPath = join(mosaicHome, 'fleet', 'roster.json');
|
||||
return jsonPath;
|
||||
return resolveInstalledFleetRosterPath(mosaicHome);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,10 +19,18 @@ import { createRequire } from 'node:module';
|
||||
import { homedir } from 'node:os';
|
||||
import { join, dirname } from 'node:path';
|
||||
import type { Command } from 'commander';
|
||||
import { readFleetCommsBlock } from '../fleet/comms-onboarding.js';
|
||||
import {
|
||||
buildResolvedFleetCommsBlock,
|
||||
renderToolsContractStatus,
|
||||
resolveFleetIdentity,
|
||||
} from '../fleet/comms-onboarding.js';
|
||||
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;
|
||||
|
||||
type RuntimeName = 'claude' | 'codex' | 'opencode' | 'pi';
|
||||
|
||||
@@ -185,6 +193,17 @@ function readOptional(path: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
function readInstalledToolsSecure(mosaicHome: string): string {
|
||||
try {
|
||||
return readRegularFileSecure(join(mosaicHome, 'TOOLS.md'), {
|
||||
root: mosaicHome,
|
||||
maxBytes: MAX_INSTALLED_TOOLS_BYTES,
|
||||
}).content.toString('utf8');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function readJson(path: string): Record<string, unknown> | null {
|
||||
try {
|
||||
return JSON.parse(readFileSync(path, 'utf-8')) as Record<string, unknown>;
|
||||
@@ -361,9 +380,25 @@ For required push/merge/issue-close/release actions, execute without routine con
|
||||
parts.push('\n\n## Operator Overlay (USER.local.md)\n\n' + userLocal);
|
||||
}
|
||||
|
||||
const fleetIdentity = resolveFleetIdentity(mosaicHome, process.env['MOSAIC_AGENT_NAME']);
|
||||
if (!fleetIdentity.ok) {
|
||||
throw new Error(`Fleet communications contract unavailable: ${fleetIdentity.error}`);
|
||||
}
|
||||
const canonicalMember = fleetIdentity.identity?.member;
|
||||
if (canonicalMember && process.env['MOSAIC_AGENT_CLASS']?.trim()) {
|
||||
const ambientClass = canonicalizeRoleClass(process.env['MOSAIC_AGENT_CLASS']).canonicalClass;
|
||||
if (ambientClass !== canonicalMember.className) {
|
||||
throw new Error(
|
||||
`Ambient MOSAIC_AGENT_CLASS resolves to "${ambientClass}" but canonical roster member "${canonicalMember.name}" resolves to "${canonicalMember.className}". Refusing split identity authority.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// TOOLS.md
|
||||
const tools = readOptional(join(mosaicHome, 'TOOLS.md'));
|
||||
const tools = readInstalledToolsSecure(mosaicHome);
|
||||
if (tools) parts.push('\n\n# Machine Tools\n\n' + tools);
|
||||
const toolsContractStatus = renderToolsContractStatus(mosaicHome);
|
||||
if (toolsContractStatus) parts.push('\n\n' + toolsContractStatus);
|
||||
|
||||
// Operator overlays whose base layers are load-on-demand (SOUL, STANDARDS):
|
||||
// inject only the small `.local` delta by value so the customization reaches
|
||||
@@ -385,24 +420,23 @@ For required push/merge/issue-close/release actions, execute without routine con
|
||||
// Runtime-specific contract
|
||||
parts.push('\n\n# Runtime-Specific Contract\n\n' + readFileSync(runtimeFile, 'utf-8'));
|
||||
|
||||
// Persona contract (A3b): when this agent was spawned with a class
|
||||
// (MOSAIC_AGENT_CLASS, exported into the pane env by A3a), inject its resolved
|
||||
// role contract so its identity (mandate + boundaries) is resident from the
|
||||
// first turn. Override-aware via the persona resolver: a user-customized
|
||||
// persona in fleet/roles.local/ wins over the baseline (AC-NS-7 launch proof).
|
||||
// Placed BEFORE fleet comms: identity first, then how-to-reach-peers. No-ops
|
||||
// silently when the class is unset/unknown (mirrors the comms block).
|
||||
const persona = readPersonaContractBlock(mosaicHome, process.env['MOSAIC_AGENT_CLASS']);
|
||||
// Fleet launches derive every identity projection from the one canonical roster
|
||||
// member resolved above. Non-fleet launches retain the legacy ambient persona
|
||||
// and tool-policy behavior.
|
||||
const personaClass = canonicalMember?.className ?? process.env['MOSAIC_AGENT_CLASS'];
|
||||
const persona = readPersonaContractBlock(mosaicHome, personaClass);
|
||||
if (persona) parts.push('\n\n' + persona);
|
||||
|
||||
const toolPolicy = readFleetToolPolicyBlock(process.env['MOSAIC_AGENT_TOOL_POLICY']);
|
||||
const toolPolicyName = canonicalMember
|
||||
? canonicalMember.toolPolicy
|
||||
: process.env['MOSAIC_AGENT_TOOL_POLICY'];
|
||||
const toolPolicy = readFleetToolPolicyBlock(toolPolicyName);
|
||||
if (toolPolicy) parts.push('\n\n' + toolPolicy);
|
||||
|
||||
// Fleet onboarding: when this is a spawned fleet agent (MOSAIC_AGENT_NAME set
|
||||
// and present in the roster), inject a comms cheat-sheet + peer roster so it
|
||||
// knows how to reach the orchestrator and its peers from its first turn.
|
||||
const fleetComms = readFleetCommsBlock(mosaicHome, process.env['MOSAIC_AGENT_NAME']);
|
||||
if (fleetComms) parts.push('\n\n' + fleetComms);
|
||||
if (fleetIdentity.identity) {
|
||||
const fleetComms = buildResolvedFleetCommsBlock(fleetIdentity.identity);
|
||||
if (fleetComms) parts.push('\n\n' + fleetComms);
|
||||
}
|
||||
|
||||
return parts.join('\n');
|
||||
}
|
||||
@@ -773,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) {
|
||||
@@ -787,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 {
|
||||
@@ -1001,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)
|
||||
@@ -1018,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);
|
||||
}
|
||||
|
||||
@@ -38,7 +38,10 @@ function makeFixture(): { sourceDir: string; mosaicHome: string; defaultsDir: st
|
||||
writeFileSync(join(defaultsDir, 'CONSTITUTION.md'), '# CONSTITUTION default\n');
|
||||
writeFileSync(join(defaultsDir, 'AGENTS.md'), '# AGENTS default\n');
|
||||
writeFileSync(join(defaultsDir, 'STANDARDS.md'), '# STANDARDS default\n');
|
||||
writeFileSync(join(defaultsDir, 'TOOLS.md'), '# TOOLS default\n');
|
||||
writeFileSync(
|
||||
join(defaultsDir, 'TOOLS.md'),
|
||||
'# TOOLS default\n\n<!-- fleet-comms-contract: 1 -->\n',
|
||||
);
|
||||
|
||||
// Non-contract files we must NOT seed on first install.
|
||||
writeFileSync(join(defaultsDir, 'SOUL.md'), '# SOUL default (should not be seeded)\n');
|
||||
@@ -71,9 +74,8 @@ describe('FileConfigAdapter.syncFramework — defaults seeding', () => {
|
||||
for (const name of DEFAULT_SEED_FILES) {
|
||||
expect(existsSync(join(fixture.mosaicHome, name))).toBe(true);
|
||||
}
|
||||
expect(readFileSync(join(fixture.mosaicHome, 'TOOLS.md'), 'utf-8')).toContain(
|
||||
'# TOOLS default',
|
||||
);
|
||||
const sourceTools = readFileSync(join(fixture.defaultsDir, 'TOOLS.md'), 'utf-8');
|
||||
expect(readFileSync(join(fixture.mosaicHome, 'TOOLS.md'), 'utf-8')).toBe(sourceTools);
|
||||
});
|
||||
|
||||
it('does NOT seed SOUL.md or USER.md from defaults/ (wizard stages own those)', async () => {
|
||||
@@ -153,17 +155,20 @@ describe('FileConfigAdapter.syncFramework — defaults seeding', () => {
|
||||
expect(readFileSync(join(fixture.mosaicHome, 'AGENTS.md'), 'utf-8')).toBe('# AGENTS default\n');
|
||||
});
|
||||
|
||||
it('preserves user fleet data (roster.yaml, agents/, run/) through a keep-mode sync', async () => {
|
||||
// Regression for the roster-loss bug (#631): user-authored fleet files must
|
||||
it('preserves user fleet data (YAML/JSON rosters, agents/, run/) through a keep-mode sync', async () => {
|
||||
// Regression for roster loss (#631/#766): user-authored fleet files must
|
||||
// survive the framework re-seed that `mosaic update` runs.
|
||||
mkdirSync(join(fixture.mosaicHome, 'fleet', 'run'), { recursive: true });
|
||||
mkdirSync(join(fixture.mosaicHome, 'fleet', 'agents'), { recursive: true });
|
||||
writeFileSync(join(fixture.mosaicHome, 'fleet', 'roster.yaml'), 'version: 1\nMINE\n');
|
||||
writeFileSync(join(fixture.mosaicHome, 'fleet', 'roster.json'), '{"mine":true}\n');
|
||||
writeFileSync(join(fixture.mosaicHome, 'fleet', 'run', 'a.hb'), 'ts=x\n');
|
||||
writeFileSync(join(fixture.mosaicHome, 'fleet', 'agents', 'a.env'), 'X=1\n');
|
||||
// The framework ships fleet/examples — it should still seed/refresh.
|
||||
writeFileSync(join(fixture.mosaicHome, 'fleet', 'roster.schema.json'), '{"stale":true}\n');
|
||||
// The framework ships fleet/examples and roster.schema.json — both refresh.
|
||||
mkdirSync(join(fixture.sourceDir, 'fleet', 'examples'), { recursive: true });
|
||||
writeFileSync(join(fixture.sourceDir, 'fleet', 'examples', 'general.yaml'), '# preset\n');
|
||||
writeFileSync(join(fixture.sourceDir, 'fleet', 'roster.schema.json'), '{"fresh":true}\n');
|
||||
|
||||
const adapter = new FileConfigAdapter(fixture.mosaicHome, fixture.sourceDir);
|
||||
await adapter.syncFramework('keep');
|
||||
@@ -171,10 +176,16 @@ describe('FileConfigAdapter.syncFramework — defaults seeding', () => {
|
||||
expect(readFileSync(join(fixture.mosaicHome, 'fleet', 'roster.yaml'), 'utf-8')).toBe(
|
||||
'version: 1\nMINE\n',
|
||||
);
|
||||
expect(readFileSync(join(fixture.mosaicHome, 'fleet', 'roster.json'), 'utf-8')).toBe(
|
||||
'{"mine":true}\n',
|
||||
);
|
||||
expect(existsSync(join(fixture.mosaicHome, 'fleet', 'run', 'a.hb'))).toBe(true);
|
||||
expect(existsSync(join(fixture.mosaicHome, 'fleet', 'agents', 'a.env'))).toBe(true);
|
||||
// framework-owned fleet/examples is seeded
|
||||
// Framework-owned fleet assets are refreshed; unrelated user YAML is not preserved.
|
||||
expect(existsSync(join(fixture.mosaicHome, 'fleet', 'examples', 'general.yaml'))).toBe(true);
|
||||
expect(readFileSync(join(fixture.mosaicHome, 'fleet', 'roster.schema.json'), 'utf-8')).toBe(
|
||||
'{"fresh":true}\n',
|
||||
);
|
||||
});
|
||||
|
||||
it('is a no-op for seeding when defaults/ dir does not exist', async () => {
|
||||
|
||||
@@ -177,7 +177,8 @@ export class FileConfigAdapter implements ConfigService {
|
||||
// The framework seeds only fleet/examples + fleet/roles +
|
||||
// fleet/roster.schema.json; the operator's roster, per-agent env, and
|
||||
// heartbeat run dir stay user-owned. (Mirror of install.sh PRESERVE_PATHS.)
|
||||
'fleet/*.yaml',
|
||||
'fleet/roster.yaml',
|
||||
'fleet/roster.json',
|
||||
'fleet/agents',
|
||||
'fleet/run',
|
||||
]
|
||||
|
||||
@@ -1,30 +1,43 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import {
|
||||
chmodSync,
|
||||
mkdtempSync,
|
||||
mkdirSync,
|
||||
writeFileSync,
|
||||
rmSync,
|
||||
readFileSync,
|
||||
symlinkSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { parseFleetRosterV1, type FleetRoster, type FleetAgent } from './fleet-roster-v1.js';
|
||||
import {
|
||||
parseRosterAgents,
|
||||
buildFleetCommsBlock,
|
||||
renderPeerReach,
|
||||
readFleetCommsBlock,
|
||||
resolveCommsBlock,
|
||||
type CommsPeer,
|
||||
resolvePeerCommand,
|
||||
renderToolsContractStatus,
|
||||
} from './comms-onboarding.js';
|
||||
|
||||
const ROSTER = [
|
||||
'version: 1',
|
||||
'transport: tmux',
|
||||
'tmux:',
|
||||
' socket_name: mosaic-fleet',
|
||||
'agents:',
|
||||
' - name: orchestrator',
|
||||
' runtime: claude',
|
||||
' class: orchestrator',
|
||||
' host: w-jarvis',
|
||||
' - name: enhancer',
|
||||
' runtime: claude',
|
||||
' class: enhancer',
|
||||
' host: w-jarvis',
|
||||
' - name: coder0',
|
||||
' runtime: pi',
|
||||
' class: implementer',
|
||||
' # a manually-listed cross-host peer (pre-federation stopgap)',
|
||||
' host: w-jarvis',
|
||||
' - name: coder0-0',
|
||||
' runtime: claude',
|
||||
' class: implementer',
|
||||
@@ -33,206 +46,687 @@ const ROSTER = [
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
describe('parseRosterAgents', () => {
|
||||
it('parses name + class + optional host/ssh', () => {
|
||||
const peers = parseRosterAgents(ROSTER);
|
||||
expect(peers.map((p) => p.name)).toEqual(['orchestrator', 'enhancer', 'coder0', 'coder0-0']);
|
||||
expect(peers.find((p) => p.name === 'coder0')).toMatchObject({ className: 'implementer' });
|
||||
expect(peers.find((p) => p.name === 'coder0-0')).toMatchObject({
|
||||
className: 'implementer',
|
||||
function roster(source = ROSTER): FleetRoster {
|
||||
return parseFleetRosterV1(source, 'yaml');
|
||||
}
|
||||
|
||||
describe('shared fleet roster v1 resolver', () => {
|
||||
it('resolves comms fields and the global socket through the canonical roster contract', () => {
|
||||
const resolved = roster();
|
||||
expect(resolved.tmux.socketName).toBe('mosaic-fleet');
|
||||
expect(resolved.agents.find((agent) => agent.name === 'coder0-0')).toMatchObject({
|
||||
className: 'code',
|
||||
host: '10.1.10.37',
|
||||
ssh: 'jwoltje@10.1.10.37',
|
||||
});
|
||||
// local agents have no host/ssh
|
||||
expect(peers.find((p) => p.name === 'orchestrator')!.host).toBeUndefined();
|
||||
});
|
||||
|
||||
it('parses an optional per-agent socket', () => {
|
||||
const peers = parseRosterAgents(
|
||||
['agents:', ' - name: a', ' class: worker', ' socket: mosaic-fleet'].join('\n'),
|
||||
it('rejects unknown fields instead of leniently constructing a second roster view', () => {
|
||||
expect(() => parseFleetRosterV1(`${ROSTER}\nunknown: value\n`, 'yaml')).toThrow(
|
||||
/unknown field/i,
|
||||
);
|
||||
expect(peers[0]).toMatchObject({ name: 'a', socket: 'mosaic-fleet' });
|
||||
});
|
||||
|
||||
it('stops at the next top-level key', () => {
|
||||
const peers = parseRosterAgents(
|
||||
['agents:', ' - name: a', ' class: worker', 'defaults:', ' working_directory: ~'].join(
|
||||
'\n',
|
||||
it('rejects an unsupported independent per-agent socket instead of targeting a nonexistent session', () => {
|
||||
expect(() =>
|
||||
roster(
|
||||
ROSTER.replace(
|
||||
' host: w-jarvis\n - name: coder0',
|
||||
' host: w-jarvis\n socket: other-socket\n - name: coder0',
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(peers.map((p) => p.name)).toEqual(['a']);
|
||||
).toThrow(/independent per-agent sockets are not supported/i);
|
||||
});
|
||||
|
||||
it('rejects unsafe operational targeting values', () => {
|
||||
expect(() =>
|
||||
roster(ROSTER.replace(' ssh: jwoltje@10.1.10.37', ' ssh: host;touch-owned')),
|
||||
).toThrow(/unsupported targeting characters/i);
|
||||
});
|
||||
|
||||
it('normalizes matching connector settings for YAML and JSON rosters', () => {
|
||||
const yamlSource = `${ROSTER}connector:\n kind: discord\n discord:\n channel_id: "123"\n`;
|
||||
const jsonSource = JSON.stringify({
|
||||
version: 1,
|
||||
transport: 'tmux',
|
||||
agents: [{ name: 'a', runtime: 'claude', class: 'worker' }],
|
||||
connector: {
|
||||
kind: 'matrix',
|
||||
matrix: {
|
||||
homeserver_url: 'https://matrix.example',
|
||||
user_id: '@a:example',
|
||||
room_id: '!room:example',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(parseFleetRosterV1(yamlSource, 'yaml').connector).toEqual({
|
||||
kind: 'discord',
|
||||
discord: { channelId: '123' },
|
||||
});
|
||||
expect(parseFleetRosterV1(jsonSource, 'json').connector).toEqual({
|
||||
kind: 'matrix',
|
||||
matrix: {
|
||||
homeserverUrl: 'https://matrix.example',
|
||||
userId: '@a:example',
|
||||
roomId: '!room:example',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
['discord channel_id', { kind: 'discord', discord: { channel_id: '' } }],
|
||||
['discord channel_id whitespace', { kind: 'discord', discord: { channel_id: ' ' } }],
|
||||
[
|
||||
'matrix homeserver_url',
|
||||
{
|
||||
kind: 'matrix',
|
||||
matrix: { homeserver_url: '', user_id: '@a:example', room_id: '!room:example' },
|
||||
},
|
||||
],
|
||||
[
|
||||
'matrix homeserver_url whitespace',
|
||||
{
|
||||
kind: 'matrix',
|
||||
matrix: { homeserver_url: '\t', user_id: '@a:example', room_id: '!room:example' },
|
||||
},
|
||||
],
|
||||
[
|
||||
'matrix user_id',
|
||||
{
|
||||
kind: 'matrix',
|
||||
matrix: {
|
||||
homeserver_url: 'https://matrix.example',
|
||||
user_id: '',
|
||||
room_id: '!room:example',
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
'matrix user_id whitespace',
|
||||
{
|
||||
kind: 'matrix',
|
||||
matrix: {
|
||||
homeserver_url: 'https://matrix.example',
|
||||
user_id: ' ',
|
||||
room_id: '!room:example',
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
'matrix room_id',
|
||||
{
|
||||
kind: 'matrix',
|
||||
matrix: {
|
||||
homeserver_url: 'https://matrix.example',
|
||||
user_id: '@a:example',
|
||||
room_id: '',
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
'matrix room_id whitespace',
|
||||
{
|
||||
kind: 'matrix',
|
||||
matrix: {
|
||||
homeserver_url: 'https://matrix.example',
|
||||
user_id: '@a:example',
|
||||
room_id: '\n',
|
||||
},
|
||||
},
|
||||
],
|
||||
])(
|
||||
'rejects empty or whitespace-only parser-required connector string: %s',
|
||||
(_label, connector) => {
|
||||
expect(() =>
|
||||
parseFleetRosterV1(
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
transport: 'tmux',
|
||||
agents: [{ name: 'a', runtime: 'claude', class: 'worker' }],
|
||||
connector,
|
||||
}),
|
||||
'json',
|
||||
),
|
||||
).toThrow(/required/i);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
['tmux with discord settings', { kind: 'tmux', discord: { channel_id: '123' } }],
|
||||
['discord without discord settings', { kind: 'discord' }],
|
||||
[
|
||||
'discord with matrix settings',
|
||||
{ kind: 'discord', discord: { channel_id: '123' }, matrix: {} },
|
||||
],
|
||||
['matrix without matrix settings', { kind: 'matrix' }],
|
||||
[
|
||||
'matrix with discord settings',
|
||||
{
|
||||
kind: 'matrix',
|
||||
matrix: {
|
||||
homeserver_url: 'https://matrix.example',
|
||||
user_id: '@a:example',
|
||||
room_id: '!room:example',
|
||||
},
|
||||
discord: { channel_id: '123' },
|
||||
},
|
||||
],
|
||||
])('rejects connector kind/settings mismatch: %s', (_label, connector) => {
|
||||
expect(() =>
|
||||
parseFleetRosterV1(
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
transport: 'tmux',
|
||||
agents: [{ name: 'a', runtime: 'claude', class: 'worker' }],
|
||||
connector,
|
||||
}),
|
||||
'json',
|
||||
),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['tmux socket', ['tmux', 'socket_name'], ['tmux', 'socketName'], 'same', 'different'],
|
||||
['tmux holder', ['tmux', 'holder_session'], ['tmux', 'holderSession'], 'same', 'different'],
|
||||
[
|
||||
'defaults working directory',
|
||||
['defaults', 'working_directory'],
|
||||
['defaults', 'workingDirectory'],
|
||||
'same',
|
||||
'different',
|
||||
],
|
||||
[
|
||||
'runtime reset command',
|
||||
['runtimes', 'claude', 'reset_command'],
|
||||
['runtimes', 'claude', 'resetCommand'],
|
||||
'same',
|
||||
'different',
|
||||
],
|
||||
[
|
||||
'agent working directory',
|
||||
['agents', 0, 'working_directory'],
|
||||
['agents', 0, 'workingDirectory'],
|
||||
'same',
|
||||
'different',
|
||||
],
|
||||
[
|
||||
'agent model hint',
|
||||
['agents', 0, 'model_hint'],
|
||||
['agents', 0, 'modelHint'],
|
||||
'same',
|
||||
'different',
|
||||
],
|
||||
[
|
||||
'agent reasoning level',
|
||||
['agents', 0, 'reasoning_level'],
|
||||
['agents', 0, 'reasoningLevel'],
|
||||
'same',
|
||||
'different',
|
||||
],
|
||||
[
|
||||
'agent tool policy',
|
||||
['agents', 0, 'tool_policy'],
|
||||
['agents', 0, 'toolPolicy'],
|
||||
'same',
|
||||
'different',
|
||||
],
|
||||
[
|
||||
'agent persistent persona',
|
||||
['agents', 0, 'persistent_persona'],
|
||||
['agents', 0, 'persistentPersona'],
|
||||
true,
|
||||
false,
|
||||
],
|
||||
[
|
||||
'agent reset between tasks',
|
||||
['agents', 0, 'reset_between_tasks'],
|
||||
['agents', 0, 'resetBetweenTasks'],
|
||||
true,
|
||||
false,
|
||||
],
|
||||
[
|
||||
'agent kickstart template',
|
||||
['agents', 0, 'kickstart_template'],
|
||||
['agents', 0, 'kickstartTemplate'],
|
||||
'same',
|
||||
'different',
|
||||
],
|
||||
] as const)(
|
||||
'rejects conflicting %s aliases and accepts identical aliases',
|
||||
(_label, snake, camel, same, different) => {
|
||||
const base: Record<string, unknown> = {
|
||||
version: 1,
|
||||
transport: 'tmux',
|
||||
tmux: {},
|
||||
defaults: {},
|
||||
runtimes: { claude: {} },
|
||||
agents: [{ name: 'a', runtime: 'claude', class: 'worker' }],
|
||||
};
|
||||
const assign = (
|
||||
root: Record<string, unknown>,
|
||||
path: readonly (string | number)[],
|
||||
value: unknown,
|
||||
) => {
|
||||
let cursor: unknown = root;
|
||||
for (const segment of path.slice(0, -1)) {
|
||||
cursor = (cursor as Record<string | number, unknown>)[segment];
|
||||
}
|
||||
(cursor as Record<string | number, unknown>)[path.at(-1)!] = value;
|
||||
};
|
||||
assign(base, snake, same);
|
||||
assign(base, camel, different);
|
||||
expect(() => parseFleetRosterV1(JSON.stringify(base), 'json')).toThrow(
|
||||
/aliases .* conflict/i,
|
||||
);
|
||||
assign(base, camel, same);
|
||||
expect(() => parseFleetRosterV1(JSON.stringify(base), 'json')).not.toThrow();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('renderPeerReach — same-host vs cross-host', () => {
|
||||
describe('renderPeerReach — exact same-host/cross-host/socket targeting', () => {
|
||||
const send = '/home/u/.config/mosaic/tools/tmux/agent-send.sh';
|
||||
const base: FleetAgent = {
|
||||
name: 'peer',
|
||||
runtime: 'claude',
|
||||
className: 'worker',
|
||||
};
|
||||
|
||||
it('renders the short form for a same-host peer', () => {
|
||||
const peer: CommsPeer = { name: 'enhancer', className: 'enhancer' };
|
||||
expect(renderPeerReach(peer, 'w-jarvis', send)).toBe(`${send} -s enhancer -m "…"`);
|
||||
});
|
||||
|
||||
it('renders the -H form for a cross-host peer using ssh', () => {
|
||||
const peer: CommsPeer = {
|
||||
name: 'coder0-0',
|
||||
className: 'implementer',
|
||||
host: '10.1.10.37',
|
||||
ssh: 'jwoltje@10.1.10.37',
|
||||
};
|
||||
expect(renderPeerReach(peer, 'w-jarvis', send)).toBe(
|
||||
`${send} -H jwoltje@10.1.10.37 -s coder0-0 -m "…"`,
|
||||
it('renders the global named socket and omits -H for a same-host peer', () => {
|
||||
expect(renderPeerReach(base, 'w-jarvis', 'w-jarvis', 'mosaic-fleet', send)).toBe(
|
||||
`${send} -L mosaic-fleet -s peer -m "…"`,
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to host when a cross-host peer has no ssh', () => {
|
||||
const peer: CommsPeer = { name: 'x', className: 'worker', host: '10.0.0.9' };
|
||||
expect(renderPeerReach(peer, 'w-jarvis', send)).toBe(`${send} -H 10.0.0.9 -s x -m "…"`);
|
||||
});
|
||||
|
||||
it('treats a peer whose host equals the fleet host as same-host', () => {
|
||||
const peer: CommsPeer = { name: 'y', className: 'worker', host: 'w-jarvis' };
|
||||
expect(renderPeerReach(peer, 'w-jarvis', send)).toBe(`${send} -s y -m "…"`);
|
||||
});
|
||||
|
||||
it('emits NO -L for an unset/default socket', () => {
|
||||
const peer: CommsPeer = { name: 'lead', className: 'orchestrator' };
|
||||
expect(renderPeerReach(peer, 'w-jarvis', send)).toBe(`${send} -s lead -m "…"`);
|
||||
});
|
||||
|
||||
it('emits -L <socket> for a named socket', () => {
|
||||
const peer: CommsPeer = { name: 'coder0', className: 'implementer', socket: 'mosaic-fleet' };
|
||||
expect(renderPeerReach(peer, 'w-jarvis', send)).toBe(
|
||||
`${send} -L mosaic-fleet -s coder0 -m "…"`,
|
||||
);
|
||||
});
|
||||
|
||||
it('combines -L (named socket) and -H (cross-host) in order', () => {
|
||||
const peer: CommsPeer = {
|
||||
it('uses only the explicit roster ssh target for a cross-host peer', () => {
|
||||
const peer: FleetAgent = {
|
||||
...base,
|
||||
name: 'coder0-0',
|
||||
className: 'implementer',
|
||||
host: '10.1.10.37',
|
||||
ssh: 'jwoltje@10.1.10.37',
|
||||
socket: 'mosaic-fleet',
|
||||
};
|
||||
expect(renderPeerReach(peer, 'w-jarvis', send)).toBe(
|
||||
expect(renderPeerReach(peer, 'w-jarvis', 'w-jarvis', 'mosaic-fleet', send)).toBe(
|
||||
`${send} -L mosaic-fleet -H jwoltje@10.1.10.37 -s coder0-0 -m "…"`,
|
||||
);
|
||||
});
|
||||
|
||||
it('fails closed when a cross-host peer has no explicit roster ssh target', () => {
|
||||
const peer: FleetAgent = { ...base, name: 'x', host: '10.0.0.9' };
|
||||
expect(() => renderPeerReach(peer, 'w-jarvis', 'w-jarvis', 'mosaic-fleet', send)).toThrow(
|
||||
/explicit roster ssh target/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('renders only the fleet-wide supported socket', () => {
|
||||
const peer: FleetAgent = { ...base, socket: 'mosaic-fleet' };
|
||||
expect(renderPeerReach(peer, 'w-jarvis', 'w-jarvis', 'mosaic-fleet', send)).toBe(
|
||||
`${send} -L mosaic-fleet -s peer -m "…"`,
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves hostless peers against the stable fleet-host baseline, not the viewer host', () => {
|
||||
const peer: FleetAgent = { ...base, ssh: 'fleet-user@w-jarvis' };
|
||||
expect(renderPeerReach(peer, 'remote-host', 'w-jarvis', 'mosaic-fleet', send)).toBe(
|
||||
`${send} -L mosaic-fleet -H fleet-user@w-jarvis -s peer -m "…"`,
|
||||
);
|
||||
});
|
||||
|
||||
it('shell-quotes an exact helper path that contains spaces', () => {
|
||||
expect(
|
||||
renderPeerReach(base, 'w-jarvis', 'w-jarvis', 'mosaic-fleet', '/home/test user/send.sh'),
|
||||
).toBe(`'/home/test user/send.sh' -L mosaic-fleet -s peer -m "…"`);
|
||||
});
|
||||
|
||||
it('omits -L only for the literal default socket', () => {
|
||||
expect(renderPeerReach(base, 'w-jarvis', 'w-jarvis', '', send)).toBe(`${send} -s peer -m "…"`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildFleetCommsBlock', () => {
|
||||
const send = '/h/.config/mosaic/tools/tmux/agent-send.sh';
|
||||
const agents = parseRosterAgents(ROSTER);
|
||||
|
||||
it('excludes self, lists peers, flags the orchestrator, and emits both address forms', () => {
|
||||
it('renders authoritative identity, exact rows, generation, and no operational metavariables', () => {
|
||||
const block = buildFleetCommsBlock({
|
||||
selfName: 'enhancer',
|
||||
agents,
|
||||
fleetHost: 'w-jarvis',
|
||||
roster: roster(),
|
||||
localHost: 'ignored-process-host',
|
||||
agentSendPath: send,
|
||||
});
|
||||
|
||||
expect(block).toContain('# Fleet Comms');
|
||||
expect(block).toContain('You are **enhancer**');
|
||||
// criterion 1: agent's own [host:session] identity
|
||||
expect(block).toContain('`[w-jarvis:enhancer]`');
|
||||
// self excluded
|
||||
expect(block).toContain('Host: `w-jarvis`');
|
||||
expect(block).toContain('Agent/session: `enhancer`');
|
||||
expect(block).toContain('tmux socket: `mosaic-fleet`');
|
||||
expect(block).toContain(`Helper: \`${send}\``);
|
||||
expect(block).toMatch(/Comms generation: `[a-f0-9]{64}`/);
|
||||
expect(block).not.toMatch(/\|\s*enhancer\s*\|/);
|
||||
// peers present
|
||||
expect(block).toContain('| orchestrator |');
|
||||
expect(block).toContain('point of contact');
|
||||
// same-host peer short form
|
||||
expect(block).toContain(`${send} -s coder0 -m "…"`);
|
||||
// cross-host peer -H form + host annotation
|
||||
expect(block).toContain(`${send} -H jwoltje@10.1.10.37 -s coder0-0 -m "…"`);
|
||||
expect(block).toContain('host `10.1.10.37`');
|
||||
// conventions
|
||||
expect(block).toContain('FLIP the preamble');
|
||||
expect(block).toContain('ACCEPTED');
|
||||
expect(block).toContain(`${send} -L mosaic-fleet -s orchestrator -m "…"`);
|
||||
expect(block).toContain(`${send} -L mosaic-fleet -H jwoltje@10.1.10.37 -s coder0-0 -m "…"`);
|
||||
expect(block).toContain(`mosaic agent comms-block enhancer`);
|
||||
expect(block).toMatch(/Never invent, substitute, or fuzzy-match/i);
|
||||
expect(block).not.toMatch(
|
||||
/<(?:user@host|src_host|src_session|dst_host|dst_session|target-session)>/,
|
||||
);
|
||||
expect(block).not.toContain('FLIP the preamble');
|
||||
});
|
||||
|
||||
it('returns empty when the agent has no peers', () => {
|
||||
expect(
|
||||
it('changes the generation when a rendered peer role changes', () => {
|
||||
const generation = (block: string) => block.match(/Comms generation: `([a-f0-9]{64})`/)?.[1];
|
||||
const before = buildFleetCommsBlock({
|
||||
selfName: 'enhancer',
|
||||
roster: roster(),
|
||||
localHost: 'w-jarvis',
|
||||
agentSendPath: send,
|
||||
});
|
||||
const changedRoster = roster(ROSTER.replace('class: implementer', 'class: reviewer'));
|
||||
const after = buildFleetCommsBlock({
|
||||
selfName: 'enhancer',
|
||||
roster: changedRoster,
|
||||
localHost: 'w-jarvis',
|
||||
agentSendPath: send,
|
||||
});
|
||||
expect(generation(before)).toMatch(/^[a-f0-9]{64}$/);
|
||||
expect(generation(after)).not.toBe(generation(before));
|
||||
});
|
||||
|
||||
it('fails closed when any rendered cross-host row lacks ssh', () => {
|
||||
const bad = roster(ROSTER.replace(' ssh: jwoltje@10.1.10.37\n', ''));
|
||||
expect(() =>
|
||||
buildFleetCommsBlock({
|
||||
selfName: 'solo',
|
||||
agents: [{ name: 'solo', className: 'orchestrator' }],
|
||||
fleetHost: 'h',
|
||||
selfName: 'enhancer',
|
||||
roster: bad,
|
||||
localHost: 'w-jarvis',
|
||||
agentSendPath: send,
|
||||
}),
|
||||
).toBe('');
|
||||
).toThrow(/explicit roster ssh target/i);
|
||||
});
|
||||
|
||||
it('still renders authoritative local identity when the agent has no peers', () => {
|
||||
const solo = roster(
|
||||
[
|
||||
'version: 1',
|
||||
'transport: tmux',
|
||||
'agents:',
|
||||
' - name: solo',
|
||||
' runtime: claude',
|
||||
' class: orchestrator',
|
||||
].join('\n'),
|
||||
);
|
||||
const block = buildFleetCommsBlock({
|
||||
selfName: 'solo',
|
||||
roster: solo,
|
||||
localHost: 'h',
|
||||
agentSendPath: send,
|
||||
});
|
||||
expect(block).toContain('Host: `h`');
|
||||
expect(block).toContain('Agent/session: `solo`');
|
||||
expect(block).toContain('Role/class: `orchestrator`');
|
||||
expect(block).toMatch(/Comms generation: `[a-f0-9]{64}`/);
|
||||
expect(block).toContain('This roster has no peers');
|
||||
expect(block).toContain('## Solo authority boundaries');
|
||||
expect(block).toContain('no peer, orchestrator, or remote communication authority');
|
||||
expect(block).toContain('Do not send, infer a target, or claim fleet coordination');
|
||||
});
|
||||
});
|
||||
|
||||
describe('readFleetCommsBlock — situational (the context a spawned agent gets)', () => {
|
||||
describe('resolvePeerCommand', () => {
|
||||
const send = '/h/.config/mosaic/tools/tmux/agent-send.sh';
|
||||
|
||||
it('returns one exact known-peer row', () => {
|
||||
const result = resolvePeerCommand(roster(), 'enhancer', 'coder0-0', 'w-jarvis', send);
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.command).toContain('-H jwoltje@10.1.10.37 -s coder0-0');
|
||||
});
|
||||
|
||||
it('fails closed for an unknown peer with exact-name discovery guidance', () => {
|
||||
const result = resolvePeerCommand(roster(), 'enhancer', 'invented-host', 'w-jarvis', send);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.command).toBe('');
|
||||
expect(result.error).toContain('invented-host');
|
||||
expect(result.error).toContain('orchestrator, coder0, coder0-0');
|
||||
expect(result.error).toContain('mosaic agent comms-block enhancer');
|
||||
expect(result.error).not.toContain('tmux ls');
|
||||
});
|
||||
});
|
||||
|
||||
describe('readFleetCommsBlock — spawned-agent context', () => {
|
||||
let home: string;
|
||||
beforeEach(() => {
|
||||
home = mkdtempSync(join(tmpdir(), 'mosaic-comms-'));
|
||||
mkdirSync(join(home, 'fleet'), { recursive: true });
|
||||
mkdirSync(join(home, 'tools', 'tmux'), { recursive: true });
|
||||
writeFileSync(join(home, 'fleet', 'roster.yaml'), ROSTER);
|
||||
const helper = join(home, 'tools', 'tmux', 'agent-send.sh');
|
||||
writeFileSync(helper, '#!/bin/sh\n');
|
||||
chmodSync(helper, 0o755);
|
||||
});
|
||||
afterEach(() => rmSync(home, { recursive: true, force: true }));
|
||||
|
||||
it('builds the cheat-sheet with correct peer addresses for a fleet member', () => {
|
||||
const block = readFleetCommsBlock(home, 'orchestrator', 'w-jarvis');
|
||||
expect(block).toContain('# Fleet Comms');
|
||||
expect(block).toContain('| enhancer |');
|
||||
expect(block).toContain(`${join(home, 'tools', 'tmux', 'agent-send.sh')} -s coder0 -m "…"`);
|
||||
expect(block).toContain('-H jwoltje@10.1.10.37 -s coder0-0');
|
||||
expect(block).not.toMatch(/\|\s*orchestrator\s*\|/); // self excluded
|
||||
it('uses the authoritative self host and global socket from the shared roster resolver', () => {
|
||||
const result = readFleetCommsBlock(home, 'enhancer', 'process-host-must-not-win');
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.output).toContain('Host: `w-jarvis`');
|
||||
expect(result.output).toContain('tmux socket: `mosaic-fleet`');
|
||||
expect(result.output).toContain('-L mosaic-fleet -s orchestrator');
|
||||
});
|
||||
|
||||
it('returns empty when MOSAIC_AGENT_NAME is unset, no roster, or agent not a member', () => {
|
||||
expect(readFleetCommsBlock(home, undefined, 'w-jarvis')).toBe('');
|
||||
expect(readFleetCommsBlock(home, 'stranger', 'w-jarvis')).toBe('');
|
||||
expect(readFleetCommsBlock(mkdtempSync(join(tmpdir(), 'noroster-')), 'orchestrator')).toBe('');
|
||||
it('fails closed for a requested fleet identity that is absent', () => {
|
||||
const result = readFleetCommsBlock(home, 'stranger', 'w-jarvis');
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.output).toBe('');
|
||||
expect(result.error).toContain('Known exact names');
|
||||
});
|
||||
|
||||
it('resolves a supported JSON-only installed roster', () => {
|
||||
rmSync(join(home, 'fleet', 'roster.yaml'));
|
||||
writeFileSync(
|
||||
join(home, 'fleet', 'roster.json'),
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
transport: 'tmux',
|
||||
tmux: { socket_name: 'mosaic-fleet' },
|
||||
agents: [
|
||||
{ name: 'enhancer', runtime: 'claude', class: 'enhancer', host: 'w-jarvis' },
|
||||
{ name: 'orchestrator', runtime: 'claude', class: 'orchestrator', host: 'w-jarvis' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
const result = readFleetCommsBlock(home, 'enhancer', 'process-host-must-not-win');
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.output).toContain('-L mosaic-fleet -s orchestrator');
|
||||
});
|
||||
|
||||
it('fails closed on a YAML I/O error instead of falling back to JSON', () => {
|
||||
rmSync(join(home, 'fleet', 'roster.yaml'));
|
||||
mkdirSync(join(home, 'fleet', 'roster.yaml'));
|
||||
writeFileSync(
|
||||
join(home, 'fleet', 'roster.json'),
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
transport: 'tmux',
|
||||
agents: [{ name: 'enhancer', runtime: 'claude', class: 'enhancer' }],
|
||||
}),
|
||||
);
|
||||
const result = readFleetCommsBlock(home, 'enhancer', 'w-jarvis');
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toContain('invalid fleet roster at');
|
||||
expect(result.error).toContain('roster.yaml');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['missing', () => rmSync(join(home, 'tools', 'tmux', 'agent-send.sh'))],
|
||||
[
|
||||
'directory',
|
||||
() => {
|
||||
rmSync(join(home, 'tools', 'tmux', 'agent-send.sh'));
|
||||
mkdirSync(join(home, 'tools', 'tmux', 'agent-send.sh'));
|
||||
},
|
||||
],
|
||||
[
|
||||
'symlink',
|
||||
() => {
|
||||
const helper = join(home, 'tools', 'tmux', 'agent-send.sh');
|
||||
rmSync(helper);
|
||||
writeFileSync(join(home, 'real-send.sh'), '#!/bin/sh\n');
|
||||
symlinkSync(join(home, 'real-send.sh'), helper);
|
||||
},
|
||||
],
|
||||
['non-executable', () => chmodSync(join(home, 'tools', 'tmux', 'agent-send.sh'), 0o644)],
|
||||
])('fails closed for a %s helper with deterministic repair guidance', (_case, mutate) => {
|
||||
mutate();
|
||||
const result = readFleetCommsBlock(home, 'enhancer', 'w-jarvis');
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.output).toBe('');
|
||||
expect(result.error).toContain('mosaic update --repair-tools');
|
||||
expect(result.error).toContain('no active context or session was rewritten');
|
||||
});
|
||||
|
||||
it('does not rewrite the roster while resolving context', () => {
|
||||
const path = join(home, 'fleet', 'roster.yaml');
|
||||
const before = readFileSync(path, 'utf8');
|
||||
readFleetCommsBlock(home, 'enhancer', 'w-jarvis');
|
||||
expect(readFileSync(path, 'utf8')).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveCommsBlock — `mosaic fleet comms-block <role>` emitter semantics', () => {
|
||||
// The emitter wraps readFleetCommsBlock but must NEVER print an empty string silently:
|
||||
// an unknown role / missing roster has to fail loud (caller maps !ok → stderr + exit 1)
|
||||
// so `mosaic fleet comms-block bogus` is a visible error, not a confusing no-op. The
|
||||
// success path returns the block verbatim for `mosaic fleet comms-block <peer>` previews.
|
||||
describe('renderToolsContractStatus — non-mutating install drift', () => {
|
||||
let home: string;
|
||||
|
||||
beforeEach(() => {
|
||||
home = mkdtempSync(join(tmpdir(), 'mosaic-tools-status-'));
|
||||
mkdirSync(join(home, 'defaults'), { recursive: true });
|
||||
writeFileSync(
|
||||
join(home, 'defaults', 'TOOLS.md'),
|
||||
'# authoritative tools\n<!-- fleet-comms-contract: 1 -->\n',
|
||||
);
|
||||
});
|
||||
afterEach(() => rmSync(home, { recursive: true, force: true }));
|
||||
|
||||
it('uses the supported repair command when installed TOOLS.md is missing', () => {
|
||||
const status = renderToolsContractStatus(home);
|
||||
expect(status).toContain('mosaic update --repair-tools');
|
||||
expect(status).not.toContain('--reseed');
|
||||
expect(status).toContain('authorized operator');
|
||||
});
|
||||
|
||||
it('reports stale preserved content without rewriting it', () => {
|
||||
const path = join(home, 'TOOLS.md');
|
||||
const stale = '# customized tools\n';
|
||||
writeFileSync(path, stale);
|
||||
const status = renderToolsContractStatus(home);
|
||||
expect(status).toContain('fleet-comms-contract: 1');
|
||||
expect(status).toContain('digest-qualified backup');
|
||||
expect(status).toContain('mosaic update --repair-tools');
|
||||
expect(status).toContain('active context was not rewritten');
|
||||
expect(readFileSync(path, 'utf8')).toBe(stale);
|
||||
});
|
||||
|
||||
it('does not accept marker-only customized content as current', () => {
|
||||
const path = join(home, 'TOOLS.md');
|
||||
writeFileSync(path, '<!-- fleet-comms-contract: 1 -->\ncorrupt\n');
|
||||
expect(renderToolsContractStatus(home)).toContain('does not byte-match');
|
||||
});
|
||||
|
||||
it('rejects markerless byte-equal source and installed content', () => {
|
||||
const content = '# markerless but equal\n';
|
||||
writeFileSync(join(home, 'defaults', 'TOOLS.md'), content);
|
||||
writeFileSync(join(home, 'TOOLS.md'), content);
|
||||
const status = renderToolsContractStatus(home);
|
||||
expect(status).toContain('source contract');
|
||||
expect(status).toContain('does not declare the expected');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['source', join('defaults', 'TOOLS.md')],
|
||||
['installed', 'TOOLS.md'],
|
||||
])('rejects a wrong contract version in %s content', (_case, relativePath) => {
|
||||
const current = '# authoritative tools\n<!-- fleet-comms-contract: 1 -->\n';
|
||||
writeFileSync(join(home, 'TOOLS.md'), current);
|
||||
writeFileSync(join(home, 'defaults', 'TOOLS.md'), current);
|
||||
writeFileSync(join(home, relativePath), current.replace('contract: 1', 'contract: 2'));
|
||||
expect(renderToolsContractStatus(home)).not.toBe('');
|
||||
});
|
||||
|
||||
it('treats installed TOOLS.md symlinks as stale without following or rewriting them', () => {
|
||||
const external = join(home, 'external-tools.md');
|
||||
const externalContent = '# external\n<!-- fleet-comms-contract: 1 -->\n';
|
||||
writeFileSync(external, externalContent);
|
||||
symlinkSync(external, join(home, 'TOOLS.md'));
|
||||
|
||||
const status = renderToolsContractStatus(home);
|
||||
|
||||
expect(status).toContain('unavailable');
|
||||
expect(status).toContain('mosaic update --repair-tools');
|
||||
expect(readFileSync(external, 'utf8')).toBe(externalContent);
|
||||
});
|
||||
|
||||
it('treats source TOOLS.md symlinks as unavailable without following them', () => {
|
||||
const external = join(home, 'external-source.md');
|
||||
const content = '# authoritative tools\n<!-- fleet-comms-contract: 1 -->\n';
|
||||
writeFileSync(external, content);
|
||||
rmSync(join(home, 'defaults', 'TOOLS.md'));
|
||||
symlinkSync(external, join(home, 'defaults', 'TOOLS.md'));
|
||||
writeFileSync(join(home, 'TOOLS.md'), content);
|
||||
|
||||
const status = renderToolsContractStatus(home);
|
||||
|
||||
expect(status).toContain('source contract');
|
||||
expect(status).toContain('unavailable');
|
||||
expect(readFileSync(external, 'utf8')).toBe(content);
|
||||
});
|
||||
|
||||
it('accepts byte-equal bounded source and installed contracts', () => {
|
||||
const source = readFileSync(join(home, 'defaults', 'TOOLS.md'), 'utf8');
|
||||
writeFileSync(join(home, 'TOOLS.md'), source);
|
||||
expect(renderToolsContractStatus(home)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveCommsBlock — mosaic agent comms-block', () => {
|
||||
let home: string;
|
||||
beforeEach(() => {
|
||||
home = mkdtempSync(join(tmpdir(), 'mosaic-commsblk-'));
|
||||
mkdirSync(join(home, 'fleet'), { recursive: true });
|
||||
mkdirSync(join(home, 'tools', 'tmux'), { recursive: true });
|
||||
writeFileSync(join(home, 'fleet', 'roster.yaml'), ROSTER);
|
||||
const helper = join(home, 'tools', 'tmux', 'agent-send.sh');
|
||||
writeFileSync(helper, '#!/bin/sh\n');
|
||||
chmodSync(helper, 0o755);
|
||||
});
|
||||
afterEach(() => rmSync(home, { recursive: true, force: true }));
|
||||
|
||||
it('returns ok + the cheat-sheet for a roster member', () => {
|
||||
const res = resolveCommsBlock(home, 'orchestrator', 'w-jarvis');
|
||||
expect(res.ok).toBe(true);
|
||||
expect(res.output).toContain('# Fleet Comms');
|
||||
expect(res.output).toContain('| enhancer |');
|
||||
expect(res.error).toBeUndefined();
|
||||
it('returns the exact contract for a roster member', () => {
|
||||
const result = resolveCommsBlock(home, 'enhancer');
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.output).toContain('Host: `w-jarvis`');
|
||||
expect(result.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it('fails loud (not ok + error naming the role) for a non-member — never silently empty', () => {
|
||||
const res = resolveCommsBlock(home, 'stranger', 'w-jarvis');
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.output).toBe('');
|
||||
expect(res.error).toContain('stranger');
|
||||
it('fails loud and lists known exact names for a non-member', () => {
|
||||
const result = resolveCommsBlock(home, 'stranger');
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.output).toBe('');
|
||||
expect(result.error).toContain('stranger');
|
||||
expect(result.error).toContain('orchestrator');
|
||||
expect(result.error).toContain('enhancer');
|
||||
});
|
||||
|
||||
it('fails loud when no roster exists at the mosaic home', () => {
|
||||
it('fails loud when no roster exists', () => {
|
||||
const noRoster = mkdtempSync(join(tmpdir(), 'mosaic-noroster-'));
|
||||
const res = resolveCommsBlock(noRoster, 'orchestrator', 'w-jarvis');
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.error).toBeTruthy();
|
||||
mkdirSync(join(noRoster, 'tools', 'tmux'), { recursive: true });
|
||||
const helper = join(noRoster, 'tools', 'tmux', 'agent-send.sh');
|
||||
writeFileSync(helper, '#!/bin/sh\n');
|
||||
chmodSync(helper, 0o755);
|
||||
const result = resolveCommsBlock(noRoster, 'orchestrator');
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toContain('no fleet roster');
|
||||
rmSync(noRoster, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('fails loud for a missing role argument', () => {
|
||||
const res = resolveCommsBlock(home, undefined, 'w-jarvis');
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.error).toBeTruthy();
|
||||
});
|
||||
|
||||
it('honors a host override so a peer can preview its own cross-host view', () => {
|
||||
// coder0-0 viewing with its own host → its self-identity line uses that host.
|
||||
const res = resolveCommsBlock(home, 'coder0-0', '10.1.10.37');
|
||||
expect(res.ok).toBe(true);
|
||||
expect(res.output).toContain('`[10.1.10.37:coder0-0]`');
|
||||
const result = resolveCommsBlock(home, undefined);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toContain('requires');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,226 +1,423 @@
|
||||
/**
|
||||
* Fleet onboarding-injection (#620).
|
||||
* Exact roster-resolved fleet communications contract (#766).
|
||||
*
|
||||
* Fleet agents are born not knowing how to reach their peers — the root cause of
|
||||
* a spawned agent's failed first send. When an agent boots via `mosaic yolo
|
||||
* <runtime>` (→ composeContract → system prompt), we append a comms cheat-sheet
|
||||
* + peer roster so it can talk to the orchestrator and other agents immediately.
|
||||
*
|
||||
* Cross-host aware: a peer may carry `host`/`ssh` (a deliberate pre-federation
|
||||
* stopgap — manual cross-host listing; federation/W1 auto-discovers later), so a
|
||||
* w-jarvis agent is born knowing the exact `-H` command to reach a dragon-lin
|
||||
* peer. Same-host peers render the short form.
|
||||
*
|
||||
* Standalone (no fleet.ts import) to keep launch.ts's prompt path free of the
|
||||
* heavy fleet command module. The roster is parsed leniently — the cheat-sheet
|
||||
* is best-effort onboarding, never a hard dependency.
|
||||
* The runtime composer and `mosaic fleet` command surface share the canonical
|
||||
* v1 roster resolver. This module never probes tmux, guesses an SSH target, or
|
||||
* mutates an active session.
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { homedir, hostname } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
export interface CommsPeer {
|
||||
name: string;
|
||||
/** Roster `class` (orchestrator | enhancer | implementer | worker | …). */
|
||||
className: string;
|
||||
/** Host the peer runs on; absent ⇒ the fleet host (same host). */
|
||||
host?: string;
|
||||
/** SSH target (user@host) for a cross-host peer; renders the `-H` form. */
|
||||
ssh?: string;
|
||||
/** tmux socket the peer's session lives on; absent ⇒ default socket (no `-L`). */
|
||||
socket?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lenient parse of a fleet `roster.yaml` for agent name/class/host/ssh. Avoids a
|
||||
* dependency on the full fleet roster parser; the format is `- name:` list items
|
||||
* with `class:`/`host:`/`ssh:` siblings under `agents:`.
|
||||
*/
|
||||
export function parseRosterAgents(yamlText: string): CommsPeer[] {
|
||||
const peers: CommsPeer[] = [];
|
||||
let current: CommsPeer | null = null;
|
||||
let inAgents = false;
|
||||
const scalar = (line: string, key: string): string | null => {
|
||||
const m = line.match(new RegExp(`^\\s*${key}:\\s*["']?([^"'#]+?)["']?\\s*$`));
|
||||
return m ? (m[1] as string).trim() : null;
|
||||
};
|
||||
for (const rawLine of yamlText.split('\n')) {
|
||||
const line = rawLine.replace(/\s+$/, '');
|
||||
if (/^agents:\s*$/.test(line)) {
|
||||
inAgents = true;
|
||||
continue;
|
||||
}
|
||||
if (!inAgents) continue;
|
||||
// A new top-level key (no leading space) ends the agents block.
|
||||
if (/^\S/.test(line)) break;
|
||||
|
||||
const nameMatch = line.match(/^\s*-\s*name:\s*["']?([A-Za-z0-9._-]+)["']?\s*$/);
|
||||
if (nameMatch) {
|
||||
if (current) peers.push(current);
|
||||
current = { name: nameMatch[1] as string, className: 'worker' };
|
||||
continue;
|
||||
}
|
||||
if (!current) continue;
|
||||
const cls = scalar(line, 'class');
|
||||
if (cls) current.className = cls;
|
||||
const host = scalar(line, 'host');
|
||||
if (host) current.host = host;
|
||||
const ssh = scalar(line, 'ssh');
|
||||
if (ssh) current.ssh = ssh;
|
||||
const socket = scalar(line, 'socket');
|
||||
if (socket) current.socket = socket;
|
||||
}
|
||||
if (current) peers.push(current);
|
||||
return peers;
|
||||
}
|
||||
import { readRegularFileSecure } from './secure-file.js';
|
||||
import {
|
||||
parseFleetRosterV1,
|
||||
resolveInstalledFleetRosterPath,
|
||||
getRosterAgent,
|
||||
type FleetAgent,
|
||||
type FleetRoster,
|
||||
} from './fleet-roster-v1.js';
|
||||
|
||||
export interface FleetCommsOptions {
|
||||
/** This agent's name (it is excluded from its own peer list). */
|
||||
/** Exact current roster member. */
|
||||
selfName: string;
|
||||
/** All roster agents (including self; filtered out internally). */
|
||||
agents: CommsPeer[];
|
||||
/** Host the fleet runs on (short hostname) — the same-host baseline. */
|
||||
fleetHost: string;
|
||||
/** Absolute path to agent-send.sh in this install. */
|
||||
/** Canonically resolved roster. */
|
||||
roster: FleetRoster;
|
||||
/** Stable fleet-host baseline for members whose roster host is absent. */
|
||||
localHost: string;
|
||||
/** Absolute helper path in this installation. */
|
||||
agentSendPath: string;
|
||||
}
|
||||
|
||||
/** Is this peer on a different host than the fleet baseline? */
|
||||
function isRemote(peer: CommsPeer, fleetHost: string): boolean {
|
||||
return peer.host !== undefined && peer.host !== fleetHost;
|
||||
export interface CommsBlockResult {
|
||||
ok: boolean;
|
||||
output: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the exact agent-send command to reach a peer (session = agent name).
|
||||
* Data-driven per peer: a named `socket` → `-L <socket>`; an unset socket → the
|
||||
* default tmux socket (no `-L`). A cross-host peer adds `-H <ssh|host>`.
|
||||
*/
|
||||
export function renderPeerReach(peer: CommsPeer, fleetHost: string, agentSendPath: string): string {
|
||||
const parts = [agentSendPath];
|
||||
if (peer.socket) parts.push('-L', peer.socket); // unset ⇒ default socket, no -L
|
||||
if (isRemote(peer, fleetHost)) parts.push('-H', peer.ssh ?? (peer.host as string));
|
||||
parts.push('-s', peer.name, '-m', '"…"');
|
||||
export interface ResolvedFleetIdentity {
|
||||
readonly roster: FleetRoster;
|
||||
readonly member: FleetAgent;
|
||||
readonly requestedName: string;
|
||||
readonly agentSendPath: string;
|
||||
readonly localHost: string;
|
||||
}
|
||||
|
||||
export interface FleetIdentityResult {
|
||||
ok: boolean;
|
||||
identity?: ResolvedFleetIdentity;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface PeerCommandResult {
|
||||
ok: boolean;
|
||||
command: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export const FLEET_COMMS_TOOLS_CONTRACT = 'fleet-comms-contract: 1';
|
||||
const MAX_TOOLS_CONTRACT_BYTES = 256 * 1024;
|
||||
|
||||
function shortHostname(): string {
|
||||
return hostname().split('.')[0] || 'localhost';
|
||||
}
|
||||
|
||||
function resolvedHost(agent: FleetAgent, fleetHost: string): string {
|
||||
return agent.host ?? fleetHost;
|
||||
}
|
||||
|
||||
function displaySocket(socket: string): string {
|
||||
return socket || '(default)';
|
||||
}
|
||||
|
||||
function knownNames(roster: FleetRoster, except?: string): string {
|
||||
return roster.agents
|
||||
.filter((agent) => agent.name !== except)
|
||||
.map((agent) => agent.name)
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
function missingMemberError(roster: FleetRoster, selfName: string): string {
|
||||
return `Agent "${selfName}" is not in the fleet roster. Known exact names: ${knownNames(roster)}. Select an exact roster name; do not infer or fuzzy-match a tmux session.`;
|
||||
}
|
||||
|
||||
/** Render one shell argument without changing already-safe exact values. */
|
||||
function shellArg(value: string): string {
|
||||
if (/^[A-Za-z0-9_./:@=+-]+$/.test(value)) return value;
|
||||
return `'${value.replaceAll("'", `'"'"'`)}'`;
|
||||
}
|
||||
|
||||
/** Render the exact command for one peer. Throws rather than guessing. */
|
||||
export function renderPeerReach(
|
||||
peer: FleetAgent,
|
||||
selfHost: string,
|
||||
fleetHost: string,
|
||||
rosterSocket: string,
|
||||
agentSendPath: string,
|
||||
): string {
|
||||
const parts = [shellArg(agentSendPath)];
|
||||
if (rosterSocket) parts.push('-L', shellArg(rosterSocket));
|
||||
|
||||
const peerHost = resolvedHost(peer, fleetHost);
|
||||
if (peerHost !== selfHost) {
|
||||
if (!peer.ssh) {
|
||||
throw new Error(
|
||||
`Cross-host peer "${peer.name}" (${peerHost}) requires an explicit roster ssh target; refusing to substitute its host value.`,
|
||||
);
|
||||
}
|
||||
parts.push('-H', shellArg(peer.ssh));
|
||||
}
|
||||
parts.push('-s', shellArg(peer.name), '-m', '"…"');
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the `# Fleet Comms` onboarding block (pure markdown). Returns '' when
|
||||
* the agent has no peers (a single-agent roster has no one to talk to).
|
||||
*/
|
||||
/** Resolve one requested peer without fuzzy lookup. */
|
||||
export function resolvePeerCommand(
|
||||
roster: FleetRoster,
|
||||
selfName: string,
|
||||
peerName: string,
|
||||
localHost: string,
|
||||
agentSendPath: string,
|
||||
): PeerCommandResult {
|
||||
const self = roster.agents.find((agent) => agent.name === selfName);
|
||||
if (!self) return { ok: false, command: '', error: missingMemberError(roster, selfName) };
|
||||
const peer = roster.agents.find((agent) => agent.name === peerName && agent.name !== selfName);
|
||||
if (!peer) {
|
||||
return {
|
||||
ok: false,
|
||||
command: '',
|
||||
error:
|
||||
`Peer "${peerName}" is absent from the fleet roster. Known exact peer names: ${knownNames(roster, selfName)}. ` +
|
||||
`Run \`mosaic agent comms-block ${selfName}\` to rediscover exact rendered rows; do not infer or fuzzy-match a tmux session.`,
|
||||
};
|
||||
}
|
||||
try {
|
||||
return {
|
||||
ok: true,
|
||||
command: renderPeerReach(
|
||||
peer,
|
||||
resolvedHost(self, localHost),
|
||||
localHost,
|
||||
roster.tmux.socketName,
|
||||
agentSendPath,
|
||||
),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
command: '',
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
interface ResolvedRow {
|
||||
readonly peer: FleetAgent;
|
||||
readonly host: string;
|
||||
readonly socket: string;
|
||||
readonly command: string;
|
||||
}
|
||||
|
||||
function resolveRows(opts: FleetCommsOptions, self: FleetAgent): readonly ResolvedRow[] {
|
||||
const selfHost = resolvedHost(self, opts.localHost);
|
||||
return opts.roster.agents
|
||||
.filter((agent) => agent.name !== opts.selfName)
|
||||
.map(
|
||||
(peer): ResolvedRow => ({
|
||||
peer,
|
||||
host: resolvedHost(peer, opts.localHost),
|
||||
socket: opts.roster.tmux.socketName,
|
||||
command: renderPeerReach(
|
||||
peer,
|
||||
selfHost,
|
||||
opts.localHost,
|
||||
opts.roster.tmux.socketName,
|
||||
opts.agentSendPath,
|
||||
),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function commsGeneration(
|
||||
self: FleetAgent,
|
||||
selfHost: string,
|
||||
selfSocket: string,
|
||||
helper: string,
|
||||
rows: readonly ResolvedRow[],
|
||||
): string {
|
||||
const canonical = JSON.stringify({
|
||||
self: { ...self, resolvedHost: selfHost, resolvedSocket: selfSocket, helper },
|
||||
peers: rows.map((row) => ({
|
||||
...row.peer,
|
||||
resolvedHost: row.host,
|
||||
resolvedSocket: row.socket,
|
||||
exactCommand: row.command,
|
||||
})),
|
||||
});
|
||||
return createHash('sha256').update(canonical).digest('hex');
|
||||
}
|
||||
|
||||
/** Build the authoritative Markdown contract for one exact roster member. */
|
||||
export function buildFleetCommsBlock(opts: FleetCommsOptions): string {
|
||||
const peers = opts.agents.filter((a) => a.name !== opts.selfName);
|
||||
if (peers.length === 0) return '';
|
||||
const self = opts.roster.agents.find((agent) => agent.name === opts.selfName);
|
||||
if (!self) throw new Error(missingMemberError(opts.roster, opts.selfName));
|
||||
const rows = resolveRows(opts, self);
|
||||
const selfHost = resolvedHost(self, opts.localHost);
|
||||
const selfSocket = opts.roster.tmux.socketName;
|
||||
const generation = commsGeneration(self, selfHost, selfSocket, opts.agentSendPath, rows);
|
||||
const orchestrator = rows.find((row) => row.peer.className === 'orchestrator');
|
||||
const peerSection =
|
||||
rows.length === 0
|
||||
? 'This roster has no peers. Do not invent a target.'
|
||||
: `| Agent | Role | Host | Socket | Exact command |
|
||||
| ----- | ---- | ---- | ------ | ------------- |
|
||||
${rows
|
||||
.map((row) => {
|
||||
const pointOfContact = row.peer.className === 'orchestrator' ? ' ← point of contact' : '';
|
||||
return `| ${row.peer.name} | ${row.peer.className}${pointOfContact} | ${row.host} | ${displaySocket(row.socket)} | \`${row.command}\` |`;
|
||||
})
|
||||
.join('\n')}`;
|
||||
const contact = orchestrator
|
||||
? `Your point of contact is **${orchestrator.peer.name}**. Select that exact peer row for status, questions, and decisions.`
|
||||
: rows.length === 0
|
||||
? 'No peer coordination target exists in this roster.'
|
||||
: 'This roster has no orchestrator. Select an exact peer row for coordination.';
|
||||
const soloAuthority =
|
||||
rows.length === 0
|
||||
? `\n## Solo authority boundaries\n\nThis member is normalized as role/class **${self.className}**. The roster grants no peer, orchestrator, or remote communication authority. Do not send, infer a target, or claim fleet coordination until an exact peer is added to the canonical roster and this block is recomposed.\n`
|
||||
: '';
|
||||
|
||||
const orchestrator = peers.find((p) => p.className === 'orchestrator');
|
||||
const rows = peers
|
||||
.map((p) => {
|
||||
const where = isRemote(p, opts.fleetHost)
|
||||
? `${p.className} · host \`${p.host}\``
|
||||
: p.className;
|
||||
const role = p.className === 'orchestrator' ? `${where} ← point of contact` : where;
|
||||
return `| ${p.name} | ${role} | \`${renderPeerReach(p, opts.fleetHost, opts.agentSendPath)}\` |`;
|
||||
})
|
||||
.join('\n');
|
||||
return `# Fleet Comms — authoritative exact targets
|
||||
|
||||
const orchLine = orchestrator
|
||||
? `Your point of contact is **${orchestrator.name}** (the orchestrator) — route questions, ` +
|
||||
`status, and decisions there.`
|
||||
: `This fleet has no orchestrator in its roster; coordinate with your peers directly.`;
|
||||
## Local identity
|
||||
|
||||
return `# Fleet Comms — reach your peers
|
||||
- Host: \`${selfHost}\`
|
||||
- Agent/session: \`${self.name}\`
|
||||
- Role/class: \`${self.className}\`
|
||||
- tmux socket: \`${displaySocket(selfSocket)}\`
|
||||
- Helper: \`${opts.agentSendPath}\`
|
||||
- Comms generation: \`${generation}\`
|
||||
|
||||
You are **${opts.selfName}** in this fleet. Your comms identity is \`[${opts.fleetHost}:${opts.selfName}]\` —
|
||||
that is the \`<src>\` other agents see and reply to. Reach other agents (durable tmux sessions) with the
|
||||
Mosaic comms tool at \`${opts.agentSendPath}\`. The **Reach** column below is the exact command per peer:
|
||||
same-host peers use the short form (no \`-H\`); cross-host peers include \`-H <user@host>\`.
|
||||
The roster-resolved rows below are the only valid operational targets. Select the row whose Agent value
|
||||
exactly matches the requested peer. Never invent, substitute, or fuzzy-match host, session, socket, SSH,
|
||||
or helper-path values. If the peer is absent, stop and run \`mosaic agent comms-block ${self.name}\` to
|
||||
rediscover this exact member's rows; if it is still absent, report the unknown peer.
|
||||
|
||||
## Peers
|
||||
|
||||
| Agent | Role | Reach (session = agent name) |
|
||||
| ----- | ---- | ---------------------------- |
|
||||
${rows}
|
||||
${peerSection}
|
||||
|
||||
${orchLine}
|
||||
${contact}
|
||||
${soloAuthority}
|
||||
## Context freshness
|
||||
|
||||
## Conventions
|
||||
This block is a snapshot; Mosaic does not rewrite an active agent's context. Compare its Comms generation
|
||||
with fresh output from \`mosaic agent comms-block ${self.name}\`. If they differ, report stale composed
|
||||
context and have an authorized operator relaunch only this exact roster member with
|
||||
\`mosaic fleet restart ${self.name}\`. Do not restart or mutate a session automatically.`;
|
||||
}
|
||||
|
||||
- Every message carries a self-identifying preamble \`[<src_host>:<src_session> -> <dst_host>:<dst_session>]\` — \`agent-send.sh\` adds it automatically.
|
||||
- **To reply, FLIP the preamble:** address your reply to the sender's \`src\` (their host:session becomes your \`-s\`/\`-H\`).
|
||||
- \`agent-send.sh\` (a.k.a. \`agent send --verify\`) confirms the message was **ACCEPTED** at the destination prompt — not merely injected. Prefer it for anything that matters.`;
|
||||
function validateAgentSendHelper(path: string, mosaicHome: string): string | undefined {
|
||||
try {
|
||||
readRegularFileSecure(path, { root: mosaicHome, executable: true });
|
||||
return undefined;
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
return `helper is unavailable or unsafe: ${path} (${reason})`;
|
||||
}
|
||||
}
|
||||
|
||||
function helperFailureGuidance(reason: string): string {
|
||||
return `${reason}. Run \`mosaic update --repair-tools\` to restore the supported current-version helper and TOOLS contract, then retry exact-member composition; no active context or session was rewritten.`;
|
||||
}
|
||||
|
||||
export function resolveFleetIdentity(
|
||||
mosaicHome: string,
|
||||
requestedName: string | undefined,
|
||||
localHost: string = shortHostname(),
|
||||
): FleetIdentityResult {
|
||||
if (!requestedName) return { ok: true };
|
||||
const agentSendPath = join(mosaicHome, 'tools', 'tmux', 'agent-send.sh');
|
||||
const helperError = validateAgentSendHelper(agentSendPath, mosaicHome);
|
||||
if (helperError) return { ok: false, error: helperFailureGuidance(helperError) };
|
||||
|
||||
let rosterPath: string;
|
||||
try {
|
||||
rosterPath = resolveInstalledFleetRosterPath(mosaicHome);
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `cannot inspect fleet roster.yaml: ${error instanceof Error ? error.message : String(error)}; refusing JSON fallback because fallback is allowed only when YAML is absent`,
|
||||
};
|
||||
}
|
||||
if (!existsSync(rosterPath)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `no fleet roster at ${join(mosaicHome, 'fleet', 'roster.yaml')} or ${join(mosaicHome, 'fleet', 'roster.json')}`,
|
||||
};
|
||||
}
|
||||
|
||||
let roster: FleetRoster;
|
||||
try {
|
||||
roster = parseFleetRosterV1(
|
||||
readRegularFileSecure(rosterPath, { root: mosaicHome }).content.toString('utf8'),
|
||||
rosterPath.endsWith('.json') ? 'json' : 'yaml',
|
||||
);
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `invalid fleet roster at ${rosterPath}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
return {
|
||||
ok: true,
|
||||
identity: {
|
||||
roster,
|
||||
member: getRosterAgent(roster, requestedName),
|
||||
requestedName,
|
||||
agentSendPath,
|
||||
localHost,
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
return { ok: false, error: missingMemberError(roster, requestedName) };
|
||||
}
|
||||
}
|
||||
|
||||
/** Render Fleet Comms from one already-resolved canonical member identity. */
|
||||
export function buildResolvedFleetCommsBlock(identity: ResolvedFleetIdentity): string {
|
||||
return buildFleetCommsBlock({
|
||||
selfName: identity.member.name,
|
||||
roster: identity.roster,
|
||||
localHost: identity.localHost,
|
||||
agentSendPath: identity.agentSendPath,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the fleet roster from `mosaicHome` and build the comms block for
|
||||
* `selfName`. Returns '' when there is no roster, the agent is not in it, or
|
||||
* there are no peers — onboarding is best-effort and never throws.
|
||||
* Read and resolve the installed roster for runtime composition. A requested
|
||||
* fleet identity fails closed; only a genuinely non-fleet launch (no selfName)
|
||||
* is a quiet no-op.
|
||||
*/
|
||||
export function readFleetCommsBlock(
|
||||
mosaicHome: string,
|
||||
selfName: string | undefined,
|
||||
fleetHost: string = hostname().split('.')[0] || 'localhost',
|
||||
): string {
|
||||
if (!selfName) return '';
|
||||
const rosterPath = join(mosaicHome, 'fleet', 'roster.yaml');
|
||||
if (!existsSync(rosterPath)) return '';
|
||||
let text: string;
|
||||
try {
|
||||
text = readFileSync(rosterPath, 'utf-8');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
const agents = parseRosterAgents(text);
|
||||
if (!agents.some((a) => a.name === selfName)) return ''; // not a member of this fleet
|
||||
return buildFleetCommsBlock({
|
||||
selfName,
|
||||
agents,
|
||||
fleetHost,
|
||||
agentSendPath: join(mosaicHome, 'tools', 'tmux', 'agent-send.sh'),
|
||||
});
|
||||
}
|
||||
|
||||
/** Result of resolving a comms-block emit request — see `mosaic fleet comms-block`. */
|
||||
export interface CommsBlockResult {
|
||||
/** True when a cheat-sheet was produced; false maps to stderr + non-zero exit. */
|
||||
ok: boolean;
|
||||
/** The Fleet-Comms cheat-sheet (empty unless ok). */
|
||||
output: string;
|
||||
/** Operator-facing reason when !ok. */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the Fleet-Comms cheat-sheet for an explicit <role>, backing the
|
||||
* `mosaic fleet comms-block <role>` command. Unlike readFleetCommsBlock — which
|
||||
* returns '' on any miss so composeContract can no-op silently during a launch —
|
||||
* this NEVER silently emits empty: an unknown role or missing roster yields
|
||||
* ok:false + an operator-facing reason, so the CLI surfaces it (stderr + exit 1)
|
||||
* rather than printing nothing. That makes it safe to preview any peer's view,
|
||||
* e.g. `mosaic fleet comms-block coder0-0`.
|
||||
*/
|
||||
export function resolveCommsBlock(
|
||||
mosaicHome: string,
|
||||
role: string | undefined,
|
||||
fleetHost?: string,
|
||||
localHost: string = shortHostname(),
|
||||
): CommsBlockResult {
|
||||
if (!role) {
|
||||
return { ok: false, output: '', error: 'comms-block requires a <role> argument' };
|
||||
}
|
||||
const block = fleetHost
|
||||
? readFleetCommsBlock(mosaicHome, role, fleetHost)
|
||||
: readFleetCommsBlock(mosaicHome, role);
|
||||
if (!block) {
|
||||
const rosterPath = join(mosaicHome, 'fleet', 'roster.yaml');
|
||||
const resolved = resolveFleetIdentity(mosaicHome, selfName, localHost);
|
||||
if (!resolved.ok) return { ok: false, output: '', error: resolved.error };
|
||||
if (!resolved.identity) return { ok: true, output: '' };
|
||||
try {
|
||||
return { ok: true, output: buildResolvedFleetCommsBlock(resolved.identity) };
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
output: '',
|
||||
error: existsSync(rosterPath)
|
||||
? `role "${role}" is not a member of the fleet roster at ${rosterPath}`
|
||||
: `no fleet roster at ${rosterPath}`,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
return { ok: true, output: block };
|
||||
}
|
||||
|
||||
/** Default mosaic home (mirrors launch.ts), for callers that don't pass one. */
|
||||
/** Backing resolver for `mosaic agent comms-block <exact-member>`. */
|
||||
export function resolveCommsBlock(
|
||||
mosaicHome: string,
|
||||
exactMember: string | undefined,
|
||||
): CommsBlockResult {
|
||||
if (!exactMember) {
|
||||
return {
|
||||
ok: false,
|
||||
output: '',
|
||||
error: 'comms-block requires an exact <exact-member> argument',
|
||||
};
|
||||
}
|
||||
return readFleetCommsBlock(mosaicHome, exactMember);
|
||||
}
|
||||
|
||||
function expectedContractVersion(content: Buffer | string): boolean {
|
||||
return content.toString().includes(`<!-- ${FLEET_COMMS_TOOLS_CONTRACT} -->`);
|
||||
}
|
||||
|
||||
function boundedContractDigest(
|
||||
path: string,
|
||||
mosaicHome: string,
|
||||
): { digest?: string; versionOk: boolean } {
|
||||
try {
|
||||
const content = readRegularFileSecure(path, {
|
||||
root: mosaicHome,
|
||||
maxBytes: MAX_TOOLS_CONTRACT_BYTES,
|
||||
}).content;
|
||||
return {
|
||||
digest: createHash('sha256').update(content).digest('hex'),
|
||||
versionOk: expectedContractVersion(content),
|
||||
};
|
||||
} catch {
|
||||
return { versionOk: false };
|
||||
}
|
||||
}
|
||||
|
||||
function replacementGuidance(): string {
|
||||
return `Run \`mosaic update --repair-tools\` to make a digest-qualified backup and restore the supported current-version TOOLS contract, then have an authorized operator explicitly relaunch the exact roster member. The active context was not rewritten.`;
|
||||
}
|
||||
|
||||
/** Detect preserved installed TOOLS.md drift without changing it. */
|
||||
export function renderToolsContractStatus(mosaicHome: string): string {
|
||||
const installedPath = join(mosaicHome, 'TOOLS.md');
|
||||
const sourcePath = join(mosaicHome, 'defaults', 'TOOLS.md');
|
||||
if (!existsSync(installedPath)) {
|
||||
return `# Fleet Comms Installation Status\n\nInstalled TOOLS.md is missing at \`${installedPath}\`. ${replacementGuidance()}`;
|
||||
}
|
||||
|
||||
const installed = boundedContractDigest(installedPath, mosaicHome);
|
||||
const source = boundedContractDigest(sourcePath, mosaicHome);
|
||||
if (!source.digest || !source.versionOk) {
|
||||
return `# Fleet Comms Installation Status\n\nThe bounded framework source contract at \`${sourcePath}\` is unavailable or does not declare the expected \`${FLEET_COMMS_TOOLS_CONTRACT}\` version. Run \`mosaic update\` to restore framework source data, verify again, then have an authorized operator explicitly relaunch the exact roster member. The installed file and active context were not rewritten.`;
|
||||
}
|
||||
if (installed.versionOk && installed.digest === source.digest) return '';
|
||||
|
||||
return `# Fleet Comms Installation Status\n\nInstalled TOOLS.md is unavailable, has the wrong contract version, or does not byte-match the bounded framework source contract \`${FLEET_COMMS_TOOLS_CONTRACT}\`. ${replacementGuidance()}`;
|
||||
}
|
||||
|
||||
export const DEFAULT_MOSAIC_HOME_FOR_COMMS = join(homedir(), '.config', 'mosaic');
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
529
packages/mosaic/src/fleet/fleet-reconciler.spec.ts
Normal file
529
packages/mosaic/src/fleet/fleet-reconciler.spec.ts
Normal file
@@ -0,0 +1,529 @@
|
||||
import { chmod, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
acquirePrivateReconcileLock,
|
||||
FleetReconcileError,
|
||||
executeFleetReconcile,
|
||||
type FleetReconcileCommand,
|
||||
type FleetReconcileDeps,
|
||||
} from './fleet-reconciler.js';
|
||||
import type { FleetRosterV2 } from './roster-v2.js';
|
||||
|
||||
let cleanup: string | undefined;
|
||||
|
||||
afterEach(async (): Promise<void> => {
|
||||
if (cleanup) await rm(cleanup, { recursive: true, force: true });
|
||||
cleanup = undefined;
|
||||
});
|
||||
|
||||
async function lockHome(): Promise<string> {
|
||||
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-reconcile-lock-'));
|
||||
const fleet = join(cleanup, 'fleet');
|
||||
await mkdir(fleet, { mode: 0o700 });
|
||||
await chmod(cleanup, 0o700);
|
||||
await chmod(fleet, 0o700);
|
||||
return cleanup;
|
||||
}
|
||||
|
||||
const roster: FleetRosterV2 = {
|
||||
version: 2,
|
||||
generation: 7,
|
||||
transport: 'tmux',
|
||||
tmux: { socketName: 'mosaic-fleet', holderSession: '_holder' },
|
||||
defaults: { workingDirectory: '/srv/mosaic', runtime: 'pi' },
|
||||
runtimes: { pi: { resetCommand: '/new' } },
|
||||
agents: [
|
||||
{
|
||||
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 },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function deps(overrides: Partial<FleetReconcileDeps> = {}): FleetReconcileDeps {
|
||||
return {
|
||||
runner: async (command, args) => {
|
||||
if (command === 'tmux' && args.includes('list-sessions')) {
|
||||
return { stdout: '_holder\ncoder0\n', stderr: '', exitCode: 0 };
|
||||
}
|
||||
if (command === '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=mosaic-fleet\nPATH=/usr/bin:/bin\nPWD=/home/mosaic\n',
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
};
|
||||
}
|
||||
return { stdout: '', stderr: '', exitCode: 0 };
|
||||
},
|
||||
homeDirectory: '/home/mosaic',
|
||||
readHolderIdentity: async () => '11111111-1111-4111-8111-111111111111',
|
||||
validateRoster: async () => undefined,
|
||||
prepareProjections: async () => [{ agentName: 'coder0' }],
|
||||
applyProjection: async () => undefined,
|
||||
readRoster: async () => roster,
|
||||
acquireMutationLock: async () => async () => undefined,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function run(command: FleetReconcileCommand, overrides: Partial<FleetReconcileDeps> = {}) {
|
||||
return executeFleetReconcile({
|
||||
roster,
|
||||
command,
|
||||
...(command === 'status' || command === 'verify' || command === 'doctor'
|
||||
? {}
|
||||
: { expectedGeneration: 7 }),
|
||||
deps: deps({ readRoster: async () => roster, ...overrides }),
|
||||
});
|
||||
}
|
||||
|
||||
describe('fleet roster-owned reconciler', (): void => {
|
||||
it('fails closed on a symlinked fleet ancestor without touching its target', async (): Promise<void> => {
|
||||
const home = await lockHome();
|
||||
const fleet = join(home, 'fleet');
|
||||
const attacker = await mkdtemp(join(tmpdir(), 'mosaic-reconcile-attacker-'));
|
||||
await writeFile(join(attacker, 'sentinel'), 'unchanged\n', { mode: 0o600 });
|
||||
try {
|
||||
await rm(fleet, { recursive: true });
|
||||
await symlink(attacker, fleet, 'dir');
|
||||
await expect(acquirePrivateReconcileLock(home)()).rejects.toMatchObject({
|
||||
code: 'unsafe-managed-path',
|
||||
});
|
||||
expect(await readFile(join(attacker, 'sentinel'), 'utf8')).toBe('unchanged\n');
|
||||
} finally {
|
||||
await rm(attacker, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects unsafe ancestors, symlink leaves, EEXIST, and non-EEXIST lock creation failures', async (): Promise<void> => {
|
||||
const home = await lockHome();
|
||||
const fleet = join(home, 'fleet');
|
||||
const lockPath = join(fleet, 'roster.yaml.reconcile.lock');
|
||||
|
||||
await chmod(fleet, 0o770);
|
||||
await expect(acquirePrivateReconcileLock(home)()).rejects.toMatchObject({
|
||||
code: 'unsafe-managed-path',
|
||||
});
|
||||
await chmod(fleet, 0o700);
|
||||
|
||||
const target = join(home, 'target');
|
||||
await writeFile(target, 'target\n', { mode: 0o600 });
|
||||
await symlink(target, lockPath);
|
||||
await expect(acquirePrivateReconcileLock(home)()).rejects.toMatchObject({
|
||||
code: 'unsafe-lock',
|
||||
});
|
||||
await rm(lockPath);
|
||||
|
||||
await writeFile(lockPath, 'other\n', { mode: 0o600 });
|
||||
await expect(acquirePrivateReconcileLock(home)()).rejects.toMatchObject({
|
||||
code: 'concurrent-mutation',
|
||||
});
|
||||
await rm(lockPath);
|
||||
|
||||
const ioFailure = Object.assign(new Error('injected I/O failure'), { code: 'EIO' });
|
||||
await expect(
|
||||
acquirePrivateReconcileLock(home, async () => Promise.reject(ioFailure))(),
|
||||
).rejects.toMatchObject({ code: 'lock-io-failed' });
|
||||
});
|
||||
|
||||
it('does not unlink a replacement lock and normally releases its own lock', async (): Promise<void> => {
|
||||
const home = await lockHome();
|
||||
const lockPath = join(home, 'fleet', 'roster.yaml.reconcile.lock');
|
||||
const release = await acquirePrivateReconcileLock(home)();
|
||||
await rm(lockPath);
|
||||
await writeFile(lockPath, 'replacement\n', { mode: 0o600 });
|
||||
await expect(release()).rejects.toMatchObject({ code: 'lock-cleanup-failed' });
|
||||
expect(await readFile(lockPath, 'utf8')).toBe('replacement\n');
|
||||
|
||||
await rm(lockPath);
|
||||
const normalRelease = await acquirePrivateReconcileLock(home)();
|
||||
await normalRelease();
|
||||
await expect(readFile(lockPath, 'utf8')).rejects.toThrow();
|
||||
});
|
||||
it('requires a generation before any mutating preflight or effect', async (): Promise<void> => {
|
||||
let effects = 0;
|
||||
await expect(
|
||||
executeFleetReconcile({
|
||||
roster,
|
||||
command: 'apply',
|
||||
deps: deps({
|
||||
validateRoster: async () => {
|
||||
effects += 1;
|
||||
},
|
||||
}),
|
||||
}),
|
||||
).rejects.toMatchObject({ code: 'missing-generation' });
|
||||
expect(effects).toBe(0);
|
||||
});
|
||||
|
||||
it('fences mutation against the canonical roster reread under lock', async (): Promise<void> => {
|
||||
let effects = 0;
|
||||
await expect(
|
||||
executeFleetReconcile({
|
||||
roster,
|
||||
command: 'apply',
|
||||
expectedGeneration: 7,
|
||||
deps: deps({
|
||||
readRoster: async () => ({ ...roster, generation: 8 }),
|
||||
runner: async () => {
|
||||
effects += 1;
|
||||
return { stdout: '', stderr: '', exitCode: 0 };
|
||||
},
|
||||
applyProjection: async () => {
|
||||
effects += 1;
|
||||
},
|
||||
}),
|
||||
}),
|
||||
).rejects.toMatchObject({ code: 'stale-generation' });
|
||||
expect(effects).toBe(0);
|
||||
});
|
||||
|
||||
it('rejects stale generation before projection or lifecycle effects', async (): Promise<void> => {
|
||||
let effects = 0;
|
||||
await expect(
|
||||
executeFleetReconcile({
|
||||
roster,
|
||||
command: 'apply',
|
||||
expectedGeneration: 6,
|
||||
deps: deps({
|
||||
validateRoster: async () => {
|
||||
effects += 1;
|
||||
},
|
||||
}),
|
||||
}),
|
||||
).rejects.toMatchObject({ code: 'stale-generation' });
|
||||
expect(effects).toBe(0);
|
||||
});
|
||||
|
||||
it('denies a concurrent mutation lock without effects and leaves observations lock-free', async (): Promise<void> => {
|
||||
let effects = 0;
|
||||
const busy = async (): Promise<() => Promise<void>> => {
|
||||
throw new FleetReconcileError('concurrent-mutation', 'busy');
|
||||
};
|
||||
await expect(
|
||||
run('apply', {
|
||||
acquireMutationLock: busy,
|
||||
applyProjection: async () => {
|
||||
effects += 1;
|
||||
},
|
||||
}),
|
||||
).rejects.toMatchObject({ code: 'concurrent-mutation' });
|
||||
expect(effects).toBe(0);
|
||||
|
||||
await expect(run('doctor', { acquireMutationLock: busy })).resolves.toMatchObject({
|
||||
applied: false,
|
||||
lifecycle: 'not-applied',
|
||||
});
|
||||
});
|
||||
|
||||
it('always releases the mutation lock after success and partial failure', async (): Promise<void> => {
|
||||
let releases = 0;
|
||||
const lock = async (): Promise<() => Promise<void>> => async (): Promise<void> => {
|
||||
releases += 1;
|
||||
};
|
||||
await run('apply', { acquireMutationLock: lock });
|
||||
await run('apply', {
|
||||
acquireMutationLock: lock,
|
||||
applyProjection: async () => {
|
||||
throw new Error('injected failure');
|
||||
},
|
||||
});
|
||||
expect(releases).toBe(2);
|
||||
});
|
||||
|
||||
it('preserves stopped desired state during apply', async (): Promise<void> => {
|
||||
const calls: string[][] = [];
|
||||
const result = await run('apply', {
|
||||
runner: async (command, args) => {
|
||||
calls.push([command, ...args]);
|
||||
if (command === 'tmux' && args.includes('list-sessions')) {
|
||||
return { stdout: '_holder\ncoder0\n', stderr: '', exitCode: 0 };
|
||||
}
|
||||
if (command === '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=mosaic-fleet\nPATH=/usr/bin:/bin\nPWD=/home/mosaic\n',
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
};
|
||||
}
|
||||
return { stdout: '', stderr: '', exitCode: 0 };
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.lifecycle).toBe('complete');
|
||||
expect(calls).not.toContainEqual([
|
||||
'systemctl',
|
||||
'--user',
|
||||
'start',
|
||||
'mosaic-agent@coder0.service',
|
||||
]);
|
||||
});
|
||||
|
||||
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 = {
|
||||
...roster,
|
||||
agents: [{ ...roster.agents[0]!, lifecycle: { enabled: true, desiredState: 'running' } }],
|
||||
};
|
||||
const result = await executeFleetReconcile({
|
||||
roster: runningRoster,
|
||||
command: 'apply',
|
||||
expectedGeneration: 7,
|
||||
deps: deps({
|
||||
readRoster: async () => runningRoster,
|
||||
runner: async (command, args) => {
|
||||
calls.push([command, ...args]);
|
||||
if (command === 'tmux' && args.includes('list-sessions')) {
|
||||
return { stdout: '', stderr: '', exitCode: 1 };
|
||||
}
|
||||
return { stdout: '', stderr: '', exitCode: 0 };
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
expect(result.lifecycle).toBe('complete');
|
||||
expect(calls).toContainEqual(['systemctl', '--user', 'start', 'mosaic-tmux-holder.service']);
|
||||
expect(calls).toContainEqual(['systemctl', '--user', 'start', 'mosaic-agent@coder0.service']);
|
||||
});
|
||||
|
||||
it('rejects a fake holder with contaminated global state before projection application', async (): Promise<void> => {
|
||||
let projectionApplied = false;
|
||||
await expect(
|
||||
run('apply', {
|
||||
runner: async (command, args) => {
|
||||
if (command === 'tmux' && args.includes('list-sessions')) {
|
||||
return { stdout: '_holder\ncoder0\n', stderr: '', exitCode: 0 };
|
||||
}
|
||||
if (command === 'tmux' && args.includes('show-environment')) {
|
||||
return { stdout: 'MOSAIC_FLEET_OWNER=forged\n', stderr: '', exitCode: 0 };
|
||||
}
|
||||
return { stdout: '', stderr: '', exitCode: 0 };
|
||||
},
|
||||
applyProjection: async () => {
|
||||
projectionApplied = true;
|
||||
},
|
||||
}),
|
||||
).rejects.toMatchObject({ code: 'ownership-mismatch' });
|
||||
expect(projectionApplied).toBe(false);
|
||||
});
|
||||
|
||||
it('reports but never adopts unmanaged sessions', async (): Promise<void> => {
|
||||
await expect(
|
||||
run('apply', {
|
||||
runner: async (command, args) => {
|
||||
if (command === 'tmux' && args.includes('list-sessions')) {
|
||||
return { stdout: '_holder\ncoder0\nother\n', stderr: '', exitCode: 0 };
|
||||
}
|
||||
if (command === '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=mosaic-fleet\nPATH=/usr/bin:/bin\nPWD=/home/mosaic\n',
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
};
|
||||
}
|
||||
return { stdout: '', stderr: '', exitCode: 0 };
|
||||
},
|
||||
}),
|
||||
).rejects.toMatchObject({ code: 'unmanaged-session' } satisfies Partial<FleetReconcileError>);
|
||||
});
|
||||
|
||||
it('rejects remote inventory before a local lifecycle command is constructed', async (): Promise<void> => {
|
||||
const calls: string[][] = [];
|
||||
const remoteRoster = {
|
||||
...roster,
|
||||
agents: [{ ...roster.agents[0]!, remote: { host: 'inventory-only' } }],
|
||||
} as unknown as FleetRosterV2;
|
||||
|
||||
await expect(
|
||||
executeFleetReconcile({
|
||||
roster: remoteRoster,
|
||||
command: 'apply',
|
||||
expectedGeneration: 7,
|
||||
deps: deps({
|
||||
readRoster: async () => remoteRoster,
|
||||
runner: async (command, args) => {
|
||||
calls.push([command, ...args]);
|
||||
return { stdout: '', stderr: '', exitCode: 0 };
|
||||
},
|
||||
}),
|
||||
}),
|
||||
).rejects.toMatchObject({ code: 'lifecycle-precondition-failed' });
|
||||
expect(calls).toEqual([]);
|
||||
});
|
||||
|
||||
it('plans without applying projections or lifecycle effects', async (): Promise<void> => {
|
||||
let applied = false;
|
||||
const result = await run('plan', {
|
||||
applyProjection: async () => {
|
||||
applied = true;
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.applied).toBe(false);
|
||||
expect(result.lifecycle).toBe('not-applied');
|
||||
expect(applied).toBe(false);
|
||||
});
|
||||
|
||||
it('reports a truthful partial result if projection application fails', async (): Promise<void> => {
|
||||
const result = await run('apply', {
|
||||
applyProjection: async () => {
|
||||
throw new Error('injected failure');
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
applied: false,
|
||||
projections: 'incomplete',
|
||||
lifecycle: 'not-applied',
|
||||
recovery: { code: 'projection-apply-failed', action: 'regenerate-projections-from-roster' },
|
||||
});
|
||||
});
|
||||
|
||||
it('adds cleanup diagnostics without masking projection or lifecycle partial truth', async (): Promise<void> => {
|
||||
const failingRelease = async (): Promise<never> => {
|
||||
throw new FleetReconcileError('lock-cleanup-failed', 'injected');
|
||||
};
|
||||
const projectionPartial = await run('apply', {
|
||||
applyProjection: async () => {
|
||||
throw new Error('injected projection failure');
|
||||
},
|
||||
acquireMutationLock: async () => failingRelease,
|
||||
});
|
||||
expect(projectionPartial).toMatchObject({
|
||||
projections: 'incomplete',
|
||||
lifecycle: 'not-applied',
|
||||
recovery: { code: 'projection-apply-failed' },
|
||||
cleanup: { code: 'lock-cleanup-failed', action: 'inspect-lock-before-retry' },
|
||||
});
|
||||
|
||||
const lifecyclePartial = await run('apply', {
|
||||
runner: async () => ({ stdout: '', stderr: '', exitCode: 1 }),
|
||||
acquireMutationLock: async () => failingRelease,
|
||||
});
|
||||
expect(lifecyclePartial).toMatchObject({
|
||||
projections: 'complete',
|
||||
lifecycle: 'incomplete',
|
||||
recovery: { code: 'lifecycle-apply-failed' },
|
||||
cleanup: { code: 'lock-cleanup-failed', action: 'inspect-lock-before-retry' },
|
||||
});
|
||||
});
|
||||
|
||||
it('adds cleanup diagnostics after successful effects without changing effect completion', async (): Promise<void> => {
|
||||
const result = await run('apply', {
|
||||
acquireMutationLock: async () => async () => {
|
||||
throw new FleetReconcileError('lock-cleanup-failed', 'injected');
|
||||
},
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
applied: true,
|
||||
projections: 'complete',
|
||||
lifecycle: 'complete',
|
||||
cleanup: { code: 'lock-cleanup-failed', action: 'inspect-lock-before-retry' },
|
||||
});
|
||||
});
|
||||
});
|
||||
799
packages/mosaic/src/fleet/fleet-reconciler.ts
Normal file
799
packages/mosaic/src/fleet/fleet-reconciler.ts
Normal file
@@ -0,0 +1,799 @@
|
||||
import { constants } from 'node:fs';
|
||||
import { lstat, open, readFile, unlink, type FileHandle } from 'node:fs/promises';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
applyPreparedAgentEnvironmentProjection,
|
||||
prepareAgentEnvironmentProjection,
|
||||
type PreparedAgentEnvironmentProjection,
|
||||
} from './generated-env-boundary.js';
|
||||
import {
|
||||
validateRosterV2Semantics,
|
||||
type FleetRosterV2,
|
||||
type FleetRosterV2Agent,
|
||||
} from './roster-v2.js';
|
||||
|
||||
export type FleetReconcileCommand =
|
||||
| 'plan'
|
||||
| 'apply'
|
||||
| 'reconcile'
|
||||
| 'start'
|
||||
| 'stop'
|
||||
| 'restart'
|
||||
| 'status'
|
||||
| 'verify'
|
||||
| 'doctor';
|
||||
|
||||
export interface FleetReconcileCommandResult {
|
||||
readonly stdout: string;
|
||||
readonly stderr: string;
|
||||
readonly exitCode: number;
|
||||
}
|
||||
|
||||
export type FleetReconcileRunner = (
|
||||
command: string,
|
||||
args: readonly string[],
|
||||
) => Promise<FleetReconcileCommandResult>;
|
||||
|
||||
export interface FleetReconcileDeps {
|
||||
readonly runner: FleetReconcileRunner;
|
||||
readonly mosaicHome?: string;
|
||||
readonly rolesDir?: string;
|
||||
readonly overrideDir?: string;
|
||||
readonly homeDirectory?: string;
|
||||
readonly readHolderIdentity?: () => Promise<string>;
|
||||
readonly validateRoster?: (roster: FleetRosterV2) => Promise<void>;
|
||||
readonly prepareProjections?: (roster: FleetRosterV2) => Promise<readonly unknown[]>;
|
||||
readonly applyProjection?: (prepared: unknown) => Promise<unknown>;
|
||||
/** Canonical roster reader; mutation authority is reread under the private lock. */
|
||||
readonly readRoster?: () => Promise<FleetRosterV2>;
|
||||
/** Test seam; production uses a private exclusive roster-adjacent lock. */
|
||||
readonly acquireMutationLock?: () => Promise<() => Promise<void>>;
|
||||
/** Internal recursion guard for the under-lock canonical roster read. */
|
||||
readonly lockAlreadyHeld?: boolean;
|
||||
}
|
||||
|
||||
export interface FleetReconcileRequest {
|
||||
readonly roster: FleetRosterV2;
|
||||
readonly command: FleetReconcileCommand;
|
||||
readonly agentName?: string;
|
||||
readonly expectedGeneration?: number;
|
||||
readonly deps: FleetReconcileDeps;
|
||||
}
|
||||
|
||||
export interface FleetReconcileObservedAgent {
|
||||
readonly name: string;
|
||||
readonly desiredState: 'running' | 'stopped';
|
||||
readonly enabled: boolean;
|
||||
readonly systemd: 'active' | 'inactive' | 'unknown';
|
||||
readonly tmux: 'present' | 'missing';
|
||||
readonly drift: readonly ('missing-session' | 'unexpected-session' | 'disabled-running')[];
|
||||
}
|
||||
|
||||
export interface FleetReconcilePlan {
|
||||
readonly generation: number;
|
||||
readonly holder: 'owned' | 'missing' | 'ownership-mismatch';
|
||||
readonly agents: readonly FleetReconcileObservedAgent[];
|
||||
readonly unmanagedSessions: readonly string[];
|
||||
}
|
||||
|
||||
export type FleetReconcileProjectionState = 'not-applied' | 'complete' | 'incomplete';
|
||||
export type FleetReconcileLifecycleState = 'not-applied' | 'complete' | 'incomplete';
|
||||
|
||||
export interface FleetReconcileResult {
|
||||
readonly applied: boolean;
|
||||
readonly authoritativeRoster: 'unchanged';
|
||||
readonly projections: FleetReconcileProjectionState;
|
||||
readonly lifecycle: FleetReconcileLifecycleState;
|
||||
readonly plan: FleetReconcilePlan;
|
||||
readonly recovery?: {
|
||||
readonly code: 'projection-apply-failed' | 'lifecycle-apply-failed';
|
||||
readonly action:
|
||||
| 'regenerate-projections-from-roster'
|
||||
| 'rerun-after-inspecting-owned-resources';
|
||||
};
|
||||
/** Additive: effects remain truthful when private lock cleanup cannot be proven. */
|
||||
readonly cleanup?: {
|
||||
readonly code: 'lock-cleanup-failed';
|
||||
readonly action: 'inspect-lock-before-retry';
|
||||
};
|
||||
}
|
||||
|
||||
export class FleetReconcileError extends Error {
|
||||
constructor(
|
||||
readonly code:
|
||||
| 'missing-generation'
|
||||
| 'stale-generation'
|
||||
| 'concurrent-mutation'
|
||||
| 'unsafe-managed-path'
|
||||
| 'unsafe-lock'
|
||||
| 'lock-io-failed'
|
||||
| 'lock-cleanup-failed'
|
||||
| 'agent-not-found'
|
||||
| 'disabled-agent'
|
||||
| 'ownership-mismatch'
|
||||
| 'unmanaged-session'
|
||||
| 'lifecycle-precondition-failed',
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = FleetReconcileError.name;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconciles only local roster-owned projections and exact service targets.
|
||||
* The roster is read-only desired state: no observed runtime result ever writes it.
|
||||
*/
|
||||
export async function executeFleetReconcile(
|
||||
request: FleetReconcileRequest,
|
||||
): Promise<FleetReconcileResult> {
|
||||
const mutating =
|
||||
request.command === 'apply' ||
|
||||
request.command === 'reconcile' ||
|
||||
isLifecycleCommand(request.command);
|
||||
if (mutating && !request.deps.lockAlreadyHeld) {
|
||||
if (request.expectedGeneration === undefined) assertExpectedGeneration(request);
|
||||
const acquire =
|
||||
request.deps.acquireMutationLock ?? acquirePrivateReconcileLock(mosaicHomeFor(request.deps));
|
||||
const release = await acquire();
|
||||
let result: FleetReconcileResult | undefined;
|
||||
let primaryError: unknown;
|
||||
try {
|
||||
if (!request.deps.readRoster) {
|
||||
throw new FleetReconcileError(
|
||||
'lifecycle-precondition-failed',
|
||||
'Canonical roster state cannot be read for mutation.',
|
||||
);
|
||||
}
|
||||
const canonicalRoster = await request.deps.readRoster();
|
||||
result = await executeFleetReconcile({
|
||||
...request,
|
||||
roster: canonicalRoster,
|
||||
deps: { ...request.deps, lockAlreadyHeld: true },
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
primaryError = error;
|
||||
}
|
||||
try {
|
||||
await release();
|
||||
} catch (cleanupError: unknown) {
|
||||
if (result) {
|
||||
return {
|
||||
...result,
|
||||
cleanup: { code: 'lock-cleanup-failed', action: 'inspect-lock-before-retry' },
|
||||
};
|
||||
}
|
||||
if (primaryError) throw primaryError;
|
||||
throw cleanupError;
|
||||
}
|
||||
if (primaryError) throw primaryError;
|
||||
return result as FleetReconcileResult;
|
||||
}
|
||||
|
||||
assertExpectedGeneration(request);
|
||||
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') {
|
||||
return {
|
||||
applied: false,
|
||||
authoritativeRoster: 'unchanged',
|
||||
projections: 'not-applied',
|
||||
lifecycle: 'not-applied',
|
||||
plan,
|
||||
};
|
||||
}
|
||||
if (request.command === 'verify') {
|
||||
assertVerificationSafe(plan);
|
||||
return {
|
||||
applied: false,
|
||||
authoritativeRoster: 'unchanged',
|
||||
projections: 'not-applied',
|
||||
lifecycle: 'not-applied',
|
||||
plan,
|
||||
};
|
||||
}
|
||||
|
||||
assertMutationSafe(plan, request.command);
|
||||
const prepareProjections = request.deps.prepareProjections ?? defaultPrepareProjections(request);
|
||||
const prepared = await prepareProjections(request.roster);
|
||||
if (request.command === 'plan') {
|
||||
return {
|
||||
applied: false,
|
||||
authoritativeRoster: 'unchanged',
|
||||
projections: 'not-applied',
|
||||
lifecycle: 'not-applied',
|
||||
plan,
|
||||
};
|
||||
}
|
||||
const targetAgents = targetAgentsFor(request.roster, request.agentName);
|
||||
const release = async (): Promise<void> => undefined;
|
||||
let result: FleetReconcileResult | undefined;
|
||||
let primaryError: unknown;
|
||||
try {
|
||||
if (request.command !== 'apply' && request.command !== 'reconcile') {
|
||||
result = await executeExplicitLifecycle(request, plan, targetAgents);
|
||||
} else {
|
||||
const applyProjection = request.deps.applyProjection ?? defaultApplyProjection;
|
||||
try {
|
||||
for (const projection of prepared) await applyProjection(projection);
|
||||
} catch {
|
||||
result = {
|
||||
applied: false,
|
||||
authoritativeRoster: 'unchanged',
|
||||
projections: 'incomplete',
|
||||
lifecycle: 'not-applied',
|
||||
plan,
|
||||
recovery: {
|
||||
code: 'projection-apply-failed',
|
||||
action: 'regenerate-projections-from-roster',
|
||||
},
|
||||
};
|
||||
}
|
||||
if (!result) {
|
||||
try {
|
||||
await applyDesiredLifecycle(request.roster, plan, request.deps);
|
||||
result = {
|
||||
applied: true,
|
||||
authoritativeRoster: 'unchanged',
|
||||
projections: 'complete',
|
||||
lifecycle: 'complete',
|
||||
plan,
|
||||
};
|
||||
} catch {
|
||||
result = {
|
||||
applied: false,
|
||||
authoritativeRoster: 'unchanged',
|
||||
projections: 'complete',
|
||||
lifecycle: 'incomplete',
|
||||
plan,
|
||||
recovery: {
|
||||
code: 'lifecycle-apply-failed',
|
||||
action: 'rerun-after-inspecting-owned-resources',
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
primaryError = error;
|
||||
}
|
||||
|
||||
try {
|
||||
await release();
|
||||
} catch (cleanupError: unknown) {
|
||||
if (result) {
|
||||
return {
|
||||
...result,
|
||||
cleanup: { code: 'lock-cleanup-failed', action: 'inspect-lock-before-retry' },
|
||||
};
|
||||
}
|
||||
if (primaryError) throw primaryError;
|
||||
throw cleanupError;
|
||||
}
|
||||
if (primaryError) throw primaryError;
|
||||
return result as FleetReconcileResult;
|
||||
}
|
||||
|
||||
function assertLocalRosterOnly(roster: FleetRosterV2): void {
|
||||
for (const agent of roster.agents) {
|
||||
const untypedAgent = agent as unknown as Record<string, unknown>;
|
||||
if (
|
||||
Object.hasOwn(untypedAgent, 'remote') ||
|
||||
Object.hasOwn(untypedAgent, 'ssh') ||
|
||||
Object.hasOwn(untypedAgent, 'connector') ||
|
||||
Object.hasOwn(untypedAgent, 'host')
|
||||
) {
|
||||
throw new FleetReconcileError(
|
||||
'lifecycle-precondition-failed',
|
||||
'Remote or connector inventory cannot receive local lifecycle actions.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function assertExpectedGeneration(request: FleetReconcileRequest): void {
|
||||
if (isObservational(request.command)) return;
|
||||
if (request.expectedGeneration === undefined) {
|
||||
throw new FleetReconcileError(
|
||||
'missing-generation',
|
||||
'A roster generation is required for mutation.',
|
||||
);
|
||||
}
|
||||
if (request.expectedGeneration !== request.roster.generation) {
|
||||
throw new FleetReconcileError('stale-generation', 'The roster generation is stale.');
|
||||
}
|
||||
}
|
||||
|
||||
function isObservational(command: FleetReconcileCommand): boolean {
|
||||
return command === 'plan' || command === 'status' || command === 'verify' || command === 'doctor';
|
||||
}
|
||||
|
||||
async function observeFleet(
|
||||
roster: FleetRosterV2,
|
||||
deps: FleetReconcileDeps,
|
||||
): Promise<FleetReconcilePlan> {
|
||||
const sessionsResult = await run(deps, 'tmux', [
|
||||
...tmuxSocketArgs(roster.tmux.socketName),
|
||||
'list-sessions',
|
||||
'-F',
|
||||
'#{session_name}',
|
||||
]);
|
||||
if (sessionsResult.exitCode !== 0) {
|
||||
return {
|
||||
generation: roster.generation,
|
||||
holder: 'missing',
|
||||
agents: await observeAgents(roster, deps, new Set<string>()),
|
||||
unmanagedSessions: [],
|
||||
};
|
||||
}
|
||||
|
||||
const sessions = new Set(
|
||||
sessionsResult.stdout
|
||||
.split('\n')
|
||||
.map((value: string): string => value.trim())
|
||||
.filter((value: string): boolean => value.length > 0),
|
||||
);
|
||||
const knownSessions = new Set([
|
||||
roster.tmux.holderSession,
|
||||
...roster.agents.map((agent) => agent.name),
|
||||
]);
|
||||
const unmanagedSessions = [...sessions].filter(
|
||||
(session: string): boolean => !knownSessions.has(session),
|
||||
);
|
||||
const holder = await observeHolder(roster, deps, sessions);
|
||||
return {
|
||||
generation: roster.generation,
|
||||
holder,
|
||||
agents: await observeAgents(roster, deps, sessions),
|
||||
unmanagedSessions: Object.freeze(unmanagedSessions.sort()),
|
||||
};
|
||||
}
|
||||
|
||||
async function observeHolder(
|
||||
roster: FleetRosterV2,
|
||||
deps: FleetReconcileDeps,
|
||||
sessions: ReadonlySet<string>,
|
||||
): Promise<FleetReconcilePlan['holder']> {
|
||||
if (!sessions.has(roster.tmux.holderSession)) return 'ownership-mismatch';
|
||||
let owner: string;
|
||||
try {
|
||||
owner = await (deps.readHolderIdentity ?? defaultReadHolderIdentity(mosaicHomeFor(deps)))();
|
||||
} catch {
|
||||
return 'ownership-mismatch';
|
||||
}
|
||||
const environment = await run(deps, 'tmux', [
|
||||
...tmuxSocketArgs(roster.tmux.socketName),
|
||||
'show-environment',
|
||||
'-g',
|
||||
]);
|
||||
if (environment.exitCode !== 0) return 'ownership-mismatch';
|
||||
const homeDirectory = deps.homeDirectory ?? homedir();
|
||||
const expected = [
|
||||
`HOME=${homeDirectory}`,
|
||||
`MOSAIC_FLEET_OWNER=${owner}`,
|
||||
`MOSAIC_TMUX_HOLDER=${roster.tmux.holderSession}`,
|
||||
`MOSAIC_TMUX_SOCKET=${roster.tmux.socketName}`,
|
||||
'PATH=/usr/bin:/bin',
|
||||
`PWD=${homeDirectory}`,
|
||||
].sort();
|
||||
const actual = environment.stdout
|
||||
.split('\n')
|
||||
.filter((line: string): boolean => line.length > 0)
|
||||
.sort();
|
||||
return sameStringArray(actual, expected) ? 'owned' : 'ownership-mismatch';
|
||||
}
|
||||
|
||||
async function observeAgents(
|
||||
roster: FleetRosterV2,
|
||||
deps: FleetReconcileDeps,
|
||||
sessions: ReadonlySet<string>,
|
||||
): Promise<readonly FleetReconcileObservedAgent[]> {
|
||||
return Promise.all(
|
||||
roster.agents.map(async (agent: FleetRosterV2Agent): Promise<FleetReconcileObservedAgent> => {
|
||||
const service = await run(deps, 'systemctl', [
|
||||
'--user',
|
||||
'show',
|
||||
`mosaic-agent@${agent.name}.service`,
|
||||
'-p',
|
||||
'ActiveState',
|
||||
]);
|
||||
const active = /^ActiveState=active$/m.test(service.stdout)
|
||||
? 'active'
|
||||
: service.exitCode === 0
|
||||
? 'inactive'
|
||||
: 'unknown';
|
||||
const tmux = sessions.has(agent.name) ? 'present' : 'missing';
|
||||
const drift: Array<'missing-session' | 'unexpected-session' | 'disabled-running'> = [];
|
||||
if (
|
||||
agent.lifecycle.enabled &&
|
||||
agent.lifecycle.desiredState === 'running' &&
|
||||
tmux === 'missing'
|
||||
) {
|
||||
drift.push('missing-session');
|
||||
}
|
||||
if (agent.lifecycle.desiredState === 'stopped' && tmux === 'present')
|
||||
drift.push('unexpected-session');
|
||||
if (!agent.lifecycle.enabled && tmux === 'present') drift.push('disabled-running');
|
||||
return {
|
||||
name: agent.name,
|
||||
desiredState: agent.lifecycle.desiredState,
|
||||
enabled: agent.lifecycle.enabled,
|
||||
systemd: active,
|
||||
tmux,
|
||||
drift: Object.freeze(drift),
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function assertMutationSafe(plan: FleetReconcilePlan, command: FleetReconcileCommand): void {
|
||||
if (plan.holder === 'ownership-mismatch') {
|
||||
throw new FleetReconcileError(
|
||||
'ownership-mismatch',
|
||||
'The named tmux server ownership cannot be proven.',
|
||||
);
|
||||
}
|
||||
if (plan.unmanagedSessions.length > 0 && affectsHolder(command)) {
|
||||
throw new FleetReconcileError(
|
||||
'unmanaged-session',
|
||||
'Unmanaged sessions are present on the named socket.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function affectsHolder(command: FleetReconcileCommand): boolean {
|
||||
return (
|
||||
command === 'apply' || command === 'reconcile' || command === 'start' || command === 'restart'
|
||||
);
|
||||
}
|
||||
|
||||
function scopePlan(plan: FleetReconcilePlan, agentName?: string): FleetReconcilePlan {
|
||||
if (agentName === undefined) return plan;
|
||||
const agent = plan.agents.find(
|
||||
(candidate: FleetReconcileObservedAgent): boolean => candidate.name === agentName,
|
||||
);
|
||||
if (!agent)
|
||||
throw new FleetReconcileError('agent-not-found', 'The lifecycle target is not roster-owned.');
|
||||
return { ...plan, agents: [agent] };
|
||||
}
|
||||
|
||||
function assertVerificationSafe(plan: FleetReconcilePlan): void {
|
||||
if (plan.holder !== 'owned' || plan.unmanagedSessions.length > 0) {
|
||||
throw new FleetReconcileError(
|
||||
'lifecycle-precondition-failed',
|
||||
'Fleet ownership cannot be verified.',
|
||||
);
|
||||
}
|
||||
if (plan.agents.some((agent: FleetReconcileObservedAgent): boolean => agent.drift.length > 0)) {
|
||||
throw new FleetReconcileError(
|
||||
'lifecycle-precondition-failed',
|
||||
'Fleet drift prevents verification.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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(
|
||||
(candidate: FleetRosterV2Agent): boolean => candidate.name === agentName,
|
||||
);
|
||||
if (!agent)
|
||||
throw new FleetReconcileError('agent-not-found', 'The lifecycle target is not roster-owned.');
|
||||
return [agent];
|
||||
}
|
||||
|
||||
async function executeExplicitLifecycle(
|
||||
request: FleetReconcileRequest,
|
||||
plan: FleetReconcilePlan,
|
||||
agents: readonly FleetRosterV2Agent[],
|
||||
): Promise<FleetReconcileResult> {
|
||||
if (request.command === 'start') {
|
||||
for (const agent of agents) {
|
||||
if (!agent.lifecycle.enabled) {
|
||||
throw new FleetReconcileError(
|
||||
'disabled-agent',
|
||||
'A disabled roster agent cannot be started.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
if (request.command === 'start' && plan.holder === 'missing') {
|
||||
await runChecked(request.deps, 'systemctl', [
|
||||
'--user',
|
||||
'start',
|
||||
'mosaic-tmux-holder.service',
|
||||
]);
|
||||
}
|
||||
for (const agent of agents) {
|
||||
await runChecked(request.deps, 'systemctl', [
|
||||
'--user',
|
||||
request.command,
|
||||
`mosaic-agent@${agent.name}.service`,
|
||||
]);
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
applied: false,
|
||||
authoritativeRoster: 'unchanged',
|
||||
projections: 'not-applied',
|
||||
lifecycle: 'incomplete',
|
||||
plan,
|
||||
recovery: {
|
||||
code: 'lifecycle-apply-failed',
|
||||
action: 'rerun-after-inspecting-owned-resources',
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
applied: true,
|
||||
authoritativeRoster: 'unchanged',
|
||||
projections: 'not-applied',
|
||||
lifecycle: 'complete',
|
||||
plan,
|
||||
};
|
||||
}
|
||||
|
||||
async function applyDesiredLifecycle(
|
||||
roster: FleetRosterV2,
|
||||
plan: FleetReconcilePlan,
|
||||
deps: FleetReconcileDeps,
|
||||
): Promise<void> {
|
||||
const needsRunningAgent = roster.agents.some(
|
||||
(agent: FleetRosterV2Agent): boolean =>
|
||||
agent.lifecycle.enabled && agent.lifecycle.desiredState === 'running',
|
||||
);
|
||||
if (needsRunningAgent && plan.holder === 'missing') {
|
||||
await runChecked(deps, 'systemctl', ['--user', 'start', 'mosaic-tmux-holder.service']);
|
||||
}
|
||||
for (const agent of roster.agents) {
|
||||
const action =
|
||||
agent.lifecycle.enabled && agent.lifecycle.desiredState === 'running' ? 'start' : 'stop';
|
||||
await runChecked(deps, 'systemctl', ['--user', action, `mosaic-agent@${agent.name}.service`]);
|
||||
}
|
||||
}
|
||||
|
||||
function defaultValidateRoster(
|
||||
request: FleetReconcileRequest,
|
||||
): (roster: FleetRosterV2) => Promise<void> {
|
||||
return async (roster: FleetRosterV2): Promise<void> => {
|
||||
const mosaicHome = mosaicHomeFor(request.deps);
|
||||
await validateRosterV2Semantics(roster, {
|
||||
rolesDir: request.deps.rolesDir ?? join(mosaicHome, 'fleet', 'roles'),
|
||||
overrideDir: request.deps.overrideDir ?? join(mosaicHome, 'fleet', 'roles.local'),
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
function defaultPrepareProjections(
|
||||
request: FleetReconcileRequest,
|
||||
): (roster: FleetRosterV2) => Promise<readonly PreparedAgentEnvironmentProjection[]> {
|
||||
return async (roster: FleetRosterV2): Promise<readonly PreparedAgentEnvironmentProjection[]> => {
|
||||
const mosaicHome = mosaicHomeFor(request.deps);
|
||||
return Promise.all(
|
||||
roster.agents.map(
|
||||
(agent: FleetRosterV2Agent): Promise<PreparedAgentEnvironmentProjection> =>
|
||||
prepareAgentEnvironmentProjection({
|
||||
mosaicHome,
|
||||
agentEnvDir: join(mosaicHome, 'fleet', 'agents'),
|
||||
agentName: agent.name,
|
||||
generated: {
|
||||
MOSAIC_AGENT_NAME: agent.name,
|
||||
MOSAIC_AGENT_CLASS: agent.className,
|
||||
MOSAIC_AGENT_RUNTIME: agent.runtime,
|
||||
MOSAIC_AGENT_MODEL: agent.model,
|
||||
MOSAIC_AGENT_REASONING: agent.reasoning,
|
||||
MOSAIC_AGENT_TOOL_POLICY: agent.toolPolicy,
|
||||
MOSAIC_AGENT_WORKDIR: agent.workingDirectory,
|
||||
MOSAIC_TMUX_SOCKET: roster.tmux.socketName,
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
async function defaultApplyProjection(prepared: unknown): Promise<unknown> {
|
||||
return applyPreparedAgentEnvironmentProjection(prepared as PreparedAgentEnvironmentProjection);
|
||||
}
|
||||
|
||||
function mosaicHomeFor(deps: FleetReconcileDeps): string {
|
||||
return deps.mosaicHome ?? join(homedir(), '.config', 'mosaic');
|
||||
}
|
||||
|
||||
/** Acquires a private lock only after proving the canonical managed path. */
|
||||
export function acquirePrivateReconcileLock(
|
||||
mosaicHome: string,
|
||||
openLock: typeof open = open,
|
||||
): () => Promise<() => Promise<void>> {
|
||||
const fleetDir = join(mosaicHome, 'fleet');
|
||||
const lockPath = join(fleetDir, 'roster.yaml.reconcile.lock');
|
||||
return async (): Promise<() => Promise<void>> => {
|
||||
await assertPrivateManagedDirectory(mosaicHome);
|
||||
await assertPrivateManagedDirectory(fleetDir);
|
||||
await assertSafeLockLeafIfPresent(lockPath);
|
||||
|
||||
let handle: FileHandle;
|
||||
try {
|
||||
handle = await openLock(lockPath, 'wx', 0o600);
|
||||
} catch (error: unknown) {
|
||||
if (isCode(error, 'EEXIST')) {
|
||||
await assertSafeLockLeafIfPresent(lockPath);
|
||||
throw new FleetReconcileError(
|
||||
'concurrent-mutation',
|
||||
'Another roster reconciliation is in progress.',
|
||||
);
|
||||
}
|
||||
throw new FleetReconcileError('lock-io-failed', 'The reconciliation lock cannot be created.');
|
||||
}
|
||||
|
||||
const token = randomUUID();
|
||||
try {
|
||||
await handle.writeFile(`${token}\n`, 'utf8');
|
||||
const opened = await handle.stat();
|
||||
await handle.close();
|
||||
await assertLockOwnership(lockPath, opened.dev, opened.ino, token, 'unsafe-lock');
|
||||
return async (): Promise<void> => {
|
||||
try {
|
||||
await assertLockOwnership(lockPath, opened.dev, opened.ino, token, 'lock-cleanup-failed');
|
||||
await assertLockOwnership(lockPath, opened.dev, opened.ino, token, 'lock-cleanup-failed');
|
||||
await unlink(lockPath);
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof FleetReconcileError) throw error;
|
||||
throw new FleetReconcileError(
|
||||
'lock-cleanup-failed',
|
||||
'The reconciliation lock cleanup failed.',
|
||||
);
|
||||
}
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
await handle.close().catch((): void => {});
|
||||
if (error instanceof FleetReconcileError) throw error;
|
||||
throw new FleetReconcileError(
|
||||
'lock-io-failed',
|
||||
'The reconciliation lock cannot be initialized.',
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function assertPrivateManagedDirectory(path: string): Promise<void> {
|
||||
try {
|
||||
const metadata = await lstat(path);
|
||||
if (!metadata.isDirectory() || metadata.isSymbolicLink() || (metadata.mode & 0o077) !== 0) {
|
||||
throw new FleetReconcileError('unsafe-managed-path', 'The managed lock ancestor is unsafe.');
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof FleetReconcileError) throw error;
|
||||
throw new FleetReconcileError(
|
||||
'unsafe-managed-path',
|
||||
'The managed lock ancestor is unavailable.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function assertSafeLockLeafIfPresent(lockPath: string): Promise<void> {
|
||||
try {
|
||||
const metadata = await lstat(lockPath);
|
||||
if (!metadata.isFile() || metadata.isSymbolicLink() || (metadata.mode & 0o077) !== 0) {
|
||||
throw new FleetReconcileError('unsafe-lock', 'The reconciliation lock path is unsafe.');
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (isCode(error, 'ENOENT')) return;
|
||||
if (error instanceof FleetReconcileError) throw error;
|
||||
throw new FleetReconcileError('unsafe-lock', 'The reconciliation lock path is unavailable.');
|
||||
}
|
||||
}
|
||||
|
||||
async function assertLockOwnership(
|
||||
lockPath: string,
|
||||
device: number,
|
||||
inode: number,
|
||||
token: string,
|
||||
failureCode: 'unsafe-lock' | 'lock-cleanup-failed',
|
||||
): Promise<void> {
|
||||
try {
|
||||
const metadata = await lstat(lockPath);
|
||||
if (
|
||||
!metadata.isFile() ||
|
||||
metadata.isSymbolicLink() ||
|
||||
(metadata.mode & 0o077) !== 0 ||
|
||||
metadata.dev !== device ||
|
||||
metadata.ino !== inode
|
||||
) {
|
||||
throw new FleetReconcileError(failureCode, 'The reconciliation lock ownership changed.');
|
||||
}
|
||||
const handle = await open(lockPath, constants.O_RDONLY | constants.O_NOFOLLOW);
|
||||
try {
|
||||
const opened = await handle.stat();
|
||||
const contents = await handle.readFile({ encoding: 'utf8' });
|
||||
if (opened.dev !== device || opened.ino !== inode || contents !== `${token}\n`) {
|
||||
throw new FleetReconcileError(failureCode, 'The reconciliation lock ownership changed.');
|
||||
}
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof FleetReconcileError) throw error;
|
||||
throw new FleetReconcileError(
|
||||
failureCode,
|
||||
'The reconciliation lock ownership cannot be proven.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function isLifecycleCommand(command: FleetReconcileCommand): boolean {
|
||||
return command === 'start' || command === 'stop' || command === 'restart';
|
||||
}
|
||||
|
||||
function isCode(error: unknown, code: string): boolean {
|
||||
return typeof error === 'object' && error !== null && 'code' in error && error.code === code;
|
||||
}
|
||||
|
||||
function defaultReadHolderIdentity(mosaicHome: string): () => Promise<string> {
|
||||
return async (): Promise<string> => {
|
||||
const fleetDir = join(mosaicHome, 'fleet');
|
||||
const runDir = join(fleetDir, 'run');
|
||||
for (const directory of [mosaicHome, fleetDir, runDir]) {
|
||||
const metadata = await lstat(directory);
|
||||
if (!metadata.isDirectory() || metadata.isSymbolicLink() || (metadata.mode & 0o077) !== 0) {
|
||||
throw new Error('Unsafe holder identity ancestor.');
|
||||
}
|
||||
}
|
||||
const identityPath = join(runDir, 'holder-owner');
|
||||
const metadata = await lstat(identityPath);
|
||||
if (!metadata.isFile() || metadata.isSymbolicLink() || (metadata.mode & 0o077) !== 0) {
|
||||
throw new Error('Unsafe holder identity.');
|
||||
}
|
||||
const value = (await readFile(identityPath, 'utf8')).trim();
|
||||
if (!/^[a-f0-9-]{36}$/.test(value)) throw new Error('Malformed holder identity.');
|
||||
return value;
|
||||
};
|
||||
}
|
||||
|
||||
async function run(
|
||||
deps: FleetReconcileDeps,
|
||||
command: string,
|
||||
args: readonly string[],
|
||||
): Promise<FleetReconcileCommandResult> {
|
||||
return deps.runner(command, args);
|
||||
}
|
||||
|
||||
async function runChecked(
|
||||
deps: FleetReconcileDeps,
|
||||
command: string,
|
||||
args: readonly string[],
|
||||
): Promise<void> {
|
||||
const result = await run(deps, command, args);
|
||||
if (result.exitCode !== 0) throw new Error('Lifecycle action failed.');
|
||||
}
|
||||
|
||||
function tmuxSocketArgs(socketName: string): readonly string[] {
|
||||
return socketName === '' ? [] : ['-L', socketName];
|
||||
}
|
||||
|
||||
function sameStringArray(left: readonly string[], right: readonly string[]): boolean {
|
||||
return (
|
||||
left.length === right.length &&
|
||||
left.every((value: string, index: number): boolean => value === right[index])
|
||||
);
|
||||
}
|
||||
522
packages/mosaic/src/fleet/fleet-roster-v1.ts
Normal file
522
packages/mosaic/src/fleet/fleet-roster-v1.ts
Normal file
@@ -0,0 +1,522 @@
|
||||
import { lstatSync } from 'node:fs';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import YAML from 'yaml';
|
||||
import { canonicalizeRoleClass } from '../commands/fleet-personas.js';
|
||||
|
||||
interface RawFleetRoster {
|
||||
version?: unknown;
|
||||
transport?: unknown;
|
||||
tmux?: {
|
||||
socket_name?: unknown;
|
||||
socketName?: unknown;
|
||||
holder_session?: unknown;
|
||||
holderSession?: unknown;
|
||||
};
|
||||
defaults?: {
|
||||
working_directory?: unknown;
|
||||
workingDirectory?: unknown;
|
||||
};
|
||||
runtimes?: Record<string, { reset_command?: unknown; resetCommand?: unknown }>;
|
||||
agents?: Array<{
|
||||
name?: unknown;
|
||||
alias?: unknown;
|
||||
provider?: unknown;
|
||||
runtime?: unknown;
|
||||
class?: unknown;
|
||||
host?: unknown;
|
||||
ssh?: unknown;
|
||||
socket?: unknown;
|
||||
working_directory?: unknown;
|
||||
workingDirectory?: unknown;
|
||||
model_hint?: unknown;
|
||||
modelHint?: unknown;
|
||||
reasoning_level?: unknown;
|
||||
reasoningLevel?: unknown;
|
||||
tool_policy?: unknown;
|
||||
toolPolicy?: unknown;
|
||||
persistent_persona?: unknown;
|
||||
persistentPersona?: unknown;
|
||||
reset_between_tasks?: unknown;
|
||||
resetBetweenTasks?: unknown;
|
||||
kickstart_template?: unknown;
|
||||
kickstartTemplate?: unknown;
|
||||
}>;
|
||||
connector?: {
|
||||
kind?: unknown;
|
||||
matrix?: {
|
||||
homeserver_url?: unknown;
|
||||
user_id?: unknown;
|
||||
room_id?: unknown;
|
||||
};
|
||||
discord?: {
|
||||
channel_id?: unknown;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface FleetAgent {
|
||||
name: string;
|
||||
alias?: string;
|
||||
provider?: string;
|
||||
runtime: string;
|
||||
className: string;
|
||||
/** Resolved host identity. Absent means the caller's authoritative local host. */
|
||||
host?: string;
|
||||
/** Explicit SSH destination for a cross-host inventory peer. */
|
||||
ssh?: string;
|
||||
/** Compatibility declaration; when set it must equal fleet-wide tmux.socketName. */
|
||||
socket?: string;
|
||||
workingDirectory?: string;
|
||||
modelHint?: string;
|
||||
reasoningLevel?: string;
|
||||
toolPolicy?: string;
|
||||
persistentPersona?: boolean | string;
|
||||
resetBetweenTasks?: boolean;
|
||||
kickstartTemplate?: string;
|
||||
}
|
||||
|
||||
export type FleetConnector =
|
||||
| { kind: 'tmux' }
|
||||
| {
|
||||
kind: 'discord';
|
||||
discord: { channelId: string };
|
||||
}
|
||||
| {
|
||||
kind: 'matrix';
|
||||
matrix: { homeserverUrl: string; userId: string; roomId: string };
|
||||
};
|
||||
|
||||
export interface FleetRoster {
|
||||
version: 1;
|
||||
transport: 'tmux';
|
||||
tmux: {
|
||||
socketName: string;
|
||||
holderSession: string;
|
||||
};
|
||||
defaults: {
|
||||
workingDirectory: string;
|
||||
};
|
||||
runtimes: Record<string, { resetCommand: string }>;
|
||||
agents: FleetAgent[];
|
||||
connector?: FleetConnector;
|
||||
}
|
||||
|
||||
export type FleetRosterInputFormat = 'yaml' | 'json';
|
||||
|
||||
export function resolveInstalledFleetRosterPath(mosaicHome: string): string {
|
||||
const yamlPath = join(mosaicHome, 'fleet', 'roster.yaml');
|
||||
try {
|
||||
lstatSync(yamlPath);
|
||||
return yamlPath;
|
||||
} catch (error) {
|
||||
if (!isNodeErrorCode(error, 'ENOENT')) throw error;
|
||||
return join(mosaicHome, 'fleet', 'roster.json');
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_HOLDER_SESSION = '_holder';
|
||||
const DEFAULT_WORKING_DIRECTORY = '~/src';
|
||||
const DEFAULT_RUNTIME_RESETS: Record<string, { resetCommand: string }> = {
|
||||
claude: { resetCommand: '/clear' },
|
||||
codex: { resetCommand: '/clear' },
|
||||
opencode: { resetCommand: '/clear' },
|
||||
pi: { resetCommand: '/new' },
|
||||
};
|
||||
|
||||
/** One structural v1 resolver used by fleet commands and runtime comms composition. */
|
||||
export function parseFleetRosterV1(
|
||||
source: string,
|
||||
format: FleetRosterInputFormat = 'yaml',
|
||||
): FleetRoster {
|
||||
const trimmed = source.trim();
|
||||
const parsed =
|
||||
format === 'json'
|
||||
? (JSON.parse(trimmed) as RawFleetRoster)
|
||||
: (YAML.parse(trimmed) as RawFleetRoster);
|
||||
return normalizeFleetRosterV1(parsed);
|
||||
}
|
||||
|
||||
export async function loadFleetRoster(path: string): Promise<FleetRoster> {
|
||||
const source = await readFile(path, 'utf8');
|
||||
return parseFleetRosterV1(source, path.endsWith('.json') ? 'json' : 'yaml');
|
||||
}
|
||||
|
||||
export function getRosterAgent(roster: FleetRoster, name: string): FleetAgent {
|
||||
const agent = roster.agents.find((candidate) => candidate.name === name);
|
||||
if (!agent) throw new Error(`Agent "${name}" is not in the fleet roster.`);
|
||||
return agent;
|
||||
}
|
||||
|
||||
export function normalizeFleetRosterV1(raw: RawFleetRoster): FleetRoster {
|
||||
assertObject(raw, 'Fleet roster');
|
||||
assertKnownKeys(raw, 'Fleet roster', [
|
||||
'version',
|
||||
'transport',
|
||||
'tmux',
|
||||
'defaults',
|
||||
'runtimes',
|
||||
'agents',
|
||||
'connector',
|
||||
]);
|
||||
if (raw.tmux !== undefined) {
|
||||
assertObject(raw.tmux, 'Fleet roster tmux');
|
||||
assertKnownKeys(raw.tmux, 'Fleet roster tmux', [
|
||||
'socket_name',
|
||||
'socketName',
|
||||
'holder_session',
|
||||
'holderSession',
|
||||
]);
|
||||
}
|
||||
if (raw.defaults !== undefined) {
|
||||
assertObject(raw.defaults, 'Fleet roster defaults');
|
||||
assertKnownKeys(raw.defaults, 'Fleet roster defaults', [
|
||||
'working_directory',
|
||||
'workingDirectory',
|
||||
]);
|
||||
}
|
||||
if (raw.runtimes !== undefined) {
|
||||
assertObject(raw.runtimes, 'Fleet roster runtimes');
|
||||
for (const [runtime, config] of Object.entries(raw.runtimes)) {
|
||||
assertObject(config, `Fleet roster runtime "${runtime}"`);
|
||||
assertKnownKeys(config, `Fleet roster runtime "${runtime}"`, [
|
||||
'reset_command',
|
||||
'resetCommand',
|
||||
]);
|
||||
}
|
||||
}
|
||||
if (raw.version !== 1) throw new Error('Fleet roster version must be 1.');
|
||||
if (raw.transport !== 'tmux') throw new Error('Fleet roster transport must be "tmux".');
|
||||
if (!Array.isArray(raw.agents) || raw.agents.length === 0) {
|
||||
throw new Error('Fleet roster must define at least one agent.');
|
||||
}
|
||||
|
||||
const socketName = targetingString(
|
||||
aliasValue(raw.tmux, 'socket_name', 'socketName', 'Fleet roster tmux socket'),
|
||||
'',
|
||||
'Fleet roster tmux socket_name',
|
||||
/^[A-Za-z0-9_.-]+$/,
|
||||
);
|
||||
const agents = raw.agents.map(normalizeAgent);
|
||||
assertUniqueAgentNames(agents);
|
||||
for (const agent of agents) {
|
||||
if (agent.socket !== undefined && agent.socket !== socketName) {
|
||||
throw new Error(
|
||||
`Fleet agent "${agent.name}" socket must equal the fleet-wide tmux socket_name; independent per-agent sockets are not supported.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return {
|
||||
version: 1,
|
||||
transport: 'tmux',
|
||||
tmux: {
|
||||
socketName,
|
||||
holderSession: stringValue(
|
||||
aliasValue(raw.tmux, 'holder_session', 'holderSession', 'Fleet roster tmux holder'),
|
||||
DEFAULT_HOLDER_SESSION,
|
||||
'Fleet roster tmux holder_session',
|
||||
),
|
||||
},
|
||||
defaults: {
|
||||
workingDirectory: stringValue(
|
||||
aliasValue(
|
||||
raw.defaults,
|
||||
'working_directory',
|
||||
'workingDirectory',
|
||||
'Fleet roster defaults working directory',
|
||||
),
|
||||
DEFAULT_WORKING_DIRECTORY,
|
||||
'Fleet roster defaults working_directory',
|
||||
),
|
||||
},
|
||||
runtimes: normalizeRuntimes(raw.runtimes as RawFleetRoster['runtimes']),
|
||||
agents,
|
||||
connector: normalizeConnector(raw.connector as RawFleetRoster['connector']),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeAgent(raw: NonNullable<RawFleetRoster['agents']>[number]): FleetAgent {
|
||||
assertObject(raw, 'Fleet roster agent');
|
||||
assertKnownKeys(raw, 'Fleet roster agent', [
|
||||
'name',
|
||||
'alias',
|
||||
'provider',
|
||||
'runtime',
|
||||
'class',
|
||||
'host',
|
||||
'ssh',
|
||||
'socket',
|
||||
'working_directory',
|
||||
'workingDirectory',
|
||||
'model_hint',
|
||||
'modelHint',
|
||||
'reasoning_level',
|
||||
'reasoningLevel',
|
||||
'tool_policy',
|
||||
'toolPolicy',
|
||||
'persistent_persona',
|
||||
'persistentPersona',
|
||||
'reset_between_tasks',
|
||||
'resetBetweenTasks',
|
||||
'kickstart_template',
|
||||
'kickstartTemplate',
|
||||
]);
|
||||
const name = stringValue(raw.name, '', 'Fleet roster agent name');
|
||||
const runtime = stringValue(
|
||||
raw.runtime,
|
||||
'',
|
||||
`Fleet roster agent "${name || '<unknown>'}" runtime`,
|
||||
);
|
||||
if (!name || !/^[A-Za-z0-9_.-]+$/.test(name)) {
|
||||
throw new Error(`Invalid fleet agent name: ${name || '<empty>'}`);
|
||||
}
|
||||
if (!runtime) throw new Error(`Fleet agent "${name}" must define a runtime.`);
|
||||
return {
|
||||
name,
|
||||
alias: optionalString(raw.alias, `Fleet roster agent "${name}" alias`),
|
||||
provider: optionalString(raw.provider, `Fleet roster agent "${name}" provider`),
|
||||
runtime,
|
||||
className: canonicalizeRoleClass(
|
||||
stringValue(raw.class, 'worker', `Fleet roster agent "${name}" class`),
|
||||
).canonicalClass,
|
||||
host: optionalTargetingString(
|
||||
raw.host,
|
||||
`Fleet roster agent "${name}" host`,
|
||||
/^[A-Za-z0-9_.:[\]-]+$/,
|
||||
),
|
||||
ssh: optionalTargetingString(
|
||||
raw.ssh,
|
||||
`Fleet roster agent "${name}" ssh`,
|
||||
/^(?:[A-Za-z0-9._-]+@)?[A-Za-z0-9_.:[\]-]+$/,
|
||||
),
|
||||
socket: optionalTargetingString(
|
||||
raw.socket,
|
||||
`Fleet roster agent "${name}" socket`,
|
||||
/^[A-Za-z0-9_.-]+$/,
|
||||
),
|
||||
workingDirectory: optionalString(
|
||||
aliasValue(
|
||||
raw,
|
||||
'working_directory',
|
||||
'workingDirectory',
|
||||
`Fleet roster agent "${name}" working directory`,
|
||||
),
|
||||
`Fleet roster agent "${name}" working_directory`,
|
||||
),
|
||||
modelHint: optionalString(
|
||||
aliasValue(raw, 'model_hint', 'modelHint', `Fleet roster agent "${name}" model hint`),
|
||||
`Fleet roster agent "${name}" model_hint`,
|
||||
),
|
||||
reasoningLevel: optionalString(
|
||||
aliasValue(
|
||||
raw,
|
||||
'reasoning_level',
|
||||
'reasoningLevel',
|
||||
`Fleet roster agent "${name}" reasoning level`,
|
||||
),
|
||||
`Fleet roster agent "${name}" reasoning_level`,
|
||||
),
|
||||
toolPolicy: optionalString(
|
||||
aliasValue(raw, 'tool_policy', 'toolPolicy', `Fleet roster agent "${name}" tool policy`),
|
||||
`Fleet roster agent "${name}" tool_policy`,
|
||||
),
|
||||
persistentPersona: optionalBooleanOrString(
|
||||
aliasValue(
|
||||
raw,
|
||||
'persistent_persona',
|
||||
'persistentPersona',
|
||||
`Fleet roster agent "${name}" persistent persona`,
|
||||
),
|
||||
`Fleet roster agent "${name}" persistent_persona`,
|
||||
),
|
||||
resetBetweenTasks: optionalBoolean(
|
||||
aliasValue(
|
||||
raw,
|
||||
'reset_between_tasks',
|
||||
'resetBetweenTasks',
|
||||
`Fleet roster agent "${name}" reset between tasks`,
|
||||
),
|
||||
`Fleet roster agent "${name}" reset_between_tasks`,
|
||||
),
|
||||
kickstartTemplate: optionalString(
|
||||
aliasValue(
|
||||
raw,
|
||||
'kickstart_template',
|
||||
'kickstartTemplate',
|
||||
`Fleet roster agent "${name}" kickstart template`,
|
||||
),
|
||||
`Fleet roster agent "${name}" kickstart_template`,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeRuntimes(
|
||||
raw: RawFleetRoster['runtimes'] | undefined,
|
||||
): Record<string, { resetCommand: string }> {
|
||||
const result: Record<string, { resetCommand: string }> = { ...DEFAULT_RUNTIME_RESETS };
|
||||
for (const [runtime, config] of Object.entries(raw ?? {})) {
|
||||
result[runtime] = {
|
||||
resetCommand: stringValue(
|
||||
aliasValue(
|
||||
config,
|
||||
'reset_command',
|
||||
'resetCommand',
|
||||
`Fleet roster runtime "${runtime}" reset command`,
|
||||
),
|
||||
'/clear',
|
||||
`Fleet roster runtime "${runtime}" reset_command`,
|
||||
),
|
||||
};
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function normalizeConnector(raw: RawFleetRoster['connector']): FleetConnector | undefined {
|
||||
if (raw === undefined) return undefined;
|
||||
assertObject(raw, 'Fleet roster connector');
|
||||
assertKnownKeys(raw, 'Fleet roster connector', ['kind', 'matrix', 'discord']);
|
||||
const kind = stringValue(raw.kind, '', 'Fleet roster connector kind');
|
||||
if (kind === 'tmux') {
|
||||
if (raw.matrix !== undefined || raw.discord !== undefined) {
|
||||
throw new Error('Fleet roster tmux connector must not define matrix or discord settings.');
|
||||
}
|
||||
return { kind };
|
||||
}
|
||||
if (kind === 'discord') {
|
||||
if (raw.matrix !== undefined) {
|
||||
throw new Error('Fleet roster discord connector must not define matrix settings.');
|
||||
}
|
||||
assertObject(raw.discord, 'Fleet roster connector discord');
|
||||
assertKnownKeys(raw.discord, 'Fleet roster connector discord', ['channel_id']);
|
||||
return {
|
||||
kind,
|
||||
discord: {
|
||||
channelId: requiredString(
|
||||
raw.discord.channel_id,
|
||||
'Fleet roster connector discord channel_id',
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
if (kind === 'matrix') {
|
||||
if (raw.discord !== undefined) {
|
||||
throw new Error('Fleet roster matrix connector must not define discord settings.');
|
||||
}
|
||||
assertObject(raw.matrix, 'Fleet roster connector matrix');
|
||||
assertKnownKeys(raw.matrix, 'Fleet roster connector matrix', [
|
||||
'homeserver_url',
|
||||
'user_id',
|
||||
'room_id',
|
||||
]);
|
||||
return {
|
||||
kind,
|
||||
matrix: {
|
||||
homeserverUrl: requiredString(
|
||||
raw.matrix.homeserver_url,
|
||||
'Fleet roster connector matrix homeserver_url',
|
||||
),
|
||||
userId: requiredString(raw.matrix.user_id, 'Fleet roster connector matrix user_id'),
|
||||
roomId: requiredString(raw.matrix.room_id, 'Fleet roster connector matrix room_id'),
|
||||
},
|
||||
};
|
||||
}
|
||||
throw new Error('Fleet roster connector kind must be one of: tmux, discord, matrix.');
|
||||
}
|
||||
|
||||
function aliasValue<T extends Record<string, unknown>>(
|
||||
source: T | undefined,
|
||||
snake: keyof T,
|
||||
camel: keyof T,
|
||||
label: string,
|
||||
): unknown {
|
||||
const snakeValue = source?.[snake];
|
||||
const camelValue = source?.[camel];
|
||||
if (snakeValue !== undefined && camelValue !== undefined && snakeValue !== camelValue) {
|
||||
throw new Error(`${label} aliases ${String(snake)} and ${String(camel)} conflict.`);
|
||||
}
|
||||
return snakeValue ?? camelValue;
|
||||
}
|
||||
|
||||
function isNodeErrorCode(error: unknown, code: string): boolean {
|
||||
return error instanceof Error && 'code' in error && error.code === code;
|
||||
}
|
||||
|
||||
function requiredString(value: unknown, label: string): string {
|
||||
const resolved = stringValue(value, '', label).trim();
|
||||
if (!resolved) throw new Error(`${label} is required.`);
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function assertObject(value: unknown, label: string): asserts value is Record<string, unknown> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new Error(`${label} must be an object.`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertKnownKeys(
|
||||
value: Record<string, unknown>,
|
||||
label: string,
|
||||
allowedKeys: readonly string[],
|
||||
): void {
|
||||
const allowed = new Set(allowedKeys);
|
||||
const unknownKeys = Object.keys(value).filter((key) => !allowed.has(key));
|
||||
if (unknownKeys.length > 0) {
|
||||
throw new Error(`${label} has unknown field(s): ${unknownKeys.join(', ')}.`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertUniqueAgentNames(agents: FleetAgent[]): void {
|
||||
const seen = new Set<string>();
|
||||
for (const agent of agents) {
|
||||
if (seen.has(agent.name)) {
|
||||
throw new Error(`Fleet roster has duplicate agent name: ${agent.name}.`);
|
||||
}
|
||||
seen.add(agent.name);
|
||||
}
|
||||
}
|
||||
|
||||
function stringValue(value: unknown, fallback = '', label = 'Value'): string {
|
||||
if (value === undefined) return fallback;
|
||||
if (typeof value !== 'string') throw new Error(`${label} must be a string.`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function targetingString(value: unknown, fallback: string, label: string, pattern: RegExp): string {
|
||||
const resolved = stringValue(value, fallback, label);
|
||||
if (resolved && !pattern.test(resolved)) {
|
||||
throw new Error(`${label} contains unsupported targeting characters.`);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function optionalTargetingString(
|
||||
value: unknown,
|
||||
label: string,
|
||||
pattern: RegExp,
|
||||
): string | undefined {
|
||||
const resolved = optionalString(value, label);
|
||||
if (resolved !== undefined && (!resolved || !pattern.test(resolved))) {
|
||||
throw new Error(`${label} contains unsupported targeting characters.`);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function optionalString(value: unknown, label = 'Value'): string | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (typeof value !== 'string') throw new Error(`${label} must be a string.`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalBoolean(value: unknown, label = 'Value'): boolean | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (typeof value !== 'boolean') throw new Error(`${label} must be a boolean.`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalBooleanOrString(value: unknown, label = 'Value'): boolean | string | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (typeof value !== 'boolean' && typeof value !== 'string') {
|
||||
throw new Error(`${label} must be a boolean or string.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
@@ -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> => ({
|
||||
|
||||
179
packages/mosaic/src/fleet/secure-file.spec.ts
Normal file
179
packages/mosaic/src/fleet/secure-file.spec.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
chmodSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
symlinkSync,
|
||||
writeFileSync,
|
||||
type PathLike,
|
||||
} from 'node:fs';
|
||||
import type * as NodeFs from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
interface FilesystemRaceState {
|
||||
afterLstat?: (path: string) => void;
|
||||
afterOpen?: (path: string) => void;
|
||||
}
|
||||
|
||||
const filesystemRaceState = vi.hoisted<FilesystemRaceState>(() => ({}));
|
||||
|
||||
vi.mock('node:fs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof NodeFs>();
|
||||
return {
|
||||
...actual,
|
||||
lstatSync: (path: PathLike) => {
|
||||
const result = actual.lstatSync(path);
|
||||
filesystemRaceState.afterLstat?.(String(path));
|
||||
return result;
|
||||
},
|
||||
openSync: (path: PathLike, flags: string | number, mode?: number) => {
|
||||
const fd = actual.openSync(path, flags, mode);
|
||||
filesystemRaceState.afterOpen?.(String(path));
|
||||
return fd;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { assertCanonicalContainment, readRegularFileSecure } from './secure-file.js';
|
||||
|
||||
describe('secure file reads', () => {
|
||||
let root: string;
|
||||
|
||||
beforeEach(() => {
|
||||
root = mkdtempSync(join(tmpdir(), 'mosaic-secure-file-'));
|
||||
filesystemRaceState.afterLstat = undefined;
|
||||
filesystemRaceState.afterOpen = undefined;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
filesystemRaceState.afterLstat = undefined;
|
||||
filesystemRaceState.afterOpen = undefined;
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('rejects canonical path escape', () => {
|
||||
expect(() => assertCanonicalContainment(root, join(root, '..', 'outside'))).toThrow(
|
||||
'path escapes managed root',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a symlink in a file ancestor', () => {
|
||||
const external = join(root, 'external');
|
||||
mkdirSync(external);
|
||||
writeFileSync(join(external, 'file'), 'external\n');
|
||||
symlinkSync(external, join(root, 'linked'));
|
||||
|
||||
expect(() => readRegularFileSecure(join(root, 'linked', 'file'), { root })).toThrow(
|
||||
'path ancestor is a symbolic link',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a symlink target', () => {
|
||||
const external = join(root, 'external');
|
||||
writeFileSync(external, 'external\n');
|
||||
symlinkSync(external, join(root, 'linked-file'));
|
||||
|
||||
expect(() => readRegularFileSecure(join(root, 'linked-file'), { root })).toThrow(
|
||||
'file is a symbolic link',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps ancestor traversal bound when an opened directory is substituted', () => {
|
||||
const tools = join(root, 'tools');
|
||||
const displacedTools = join(root, 'tools.displaced');
|
||||
const external = join(root, 'external');
|
||||
const helper = join(tools, 'helper.sh');
|
||||
mkdirSync(tools);
|
||||
mkdirSync(external);
|
||||
writeFileSync(helper, 'trusted\n', { mode: 0o755 });
|
||||
writeFileSync(join(external, 'helper.sh'), 'external marker\n', { mode: 0o755 });
|
||||
|
||||
let substituted = false;
|
||||
filesystemRaceState.afterOpen = (openedPath: string): void => {
|
||||
if (substituted || !openedPath.startsWith('/proc/self/fd/')) return;
|
||||
if (openedPath.split('/').at(-1) !== 'tools') return;
|
||||
substituted = true;
|
||||
renameSync(tools, displacedTools);
|
||||
symlinkSync(external, tools);
|
||||
};
|
||||
|
||||
const result = readRegularFileSecure(helper, { root, executable: true });
|
||||
expect(substituted).toBe(true);
|
||||
expect(result.content.toString('utf8')).toBe('trusted\n');
|
||||
});
|
||||
|
||||
it('keeps root selection bound when the opened root is substituted', () => {
|
||||
const displacedRoot = `${root}.displaced`;
|
||||
const externalRoot = `${root}.external`;
|
||||
const helper = join(root, 'helper.sh');
|
||||
mkdirSync(externalRoot);
|
||||
writeFileSync(helper, 'trusted root\n', { mode: 0o755 });
|
||||
writeFileSync(join(externalRoot, 'helper.sh'), 'external root marker\n', { mode: 0o755 });
|
||||
|
||||
let substituted = false;
|
||||
filesystemRaceState.afterOpen = (openedPath: string): void => {
|
||||
if (substituted || !openedPath.startsWith('/proc/self/fd/')) return;
|
||||
const match = openedPath.match(/\/([^/]+)$/);
|
||||
if (match?.[1] !== root.split('/').filter(Boolean).at(-1)) return;
|
||||
substituted = true;
|
||||
renameSync(root, displacedRoot);
|
||||
symlinkSync(externalRoot, root);
|
||||
};
|
||||
|
||||
const result = readRegularFileSecure(helper, { root, executable: true });
|
||||
expect(substituted).toBe(true);
|
||||
expect(result.content.toString('utf8')).toBe('trusted root\n');
|
||||
filesystemRaceState.afterOpen = undefined;
|
||||
rmSync(root);
|
||||
renameSync(displacedRoot, root);
|
||||
});
|
||||
|
||||
it('keeps target read and execute validation bound to the opened file', () => {
|
||||
const file = join(root, 'helper.sh');
|
||||
const displaced = join(root, 'helper.displaced.sh');
|
||||
const external = join(root, 'external-helper.sh');
|
||||
writeFileSync(file, 'trusted target\n', { mode: 0o755 });
|
||||
writeFileSync(external, 'external target marker\n', { mode: 0o755 });
|
||||
|
||||
let substituted = false;
|
||||
filesystemRaceState.afterOpen = (openedPath: string): void => {
|
||||
if (substituted || !openedPath.startsWith('/proc/self/fd/')) return;
|
||||
if (openedPath.split('/').at(-1) !== 'helper.sh') return;
|
||||
substituted = true;
|
||||
renameSync(file, displaced);
|
||||
symlinkSync(external, file);
|
||||
};
|
||||
|
||||
const result = readRegularFileSecure(file, { root, executable: true });
|
||||
expect(substituted).toBe(true);
|
||||
expect(result.content.toString('utf8')).toBe('trusted target\n');
|
||||
});
|
||||
|
||||
it('uses a stable redacted executable error while retaining the error code', () => {
|
||||
const file = join(root, 'helper.sh');
|
||||
writeFileSync(file, '#!/bin/sh\n', { mode: 0o644 });
|
||||
|
||||
try {
|
||||
readRegularFileSecure(file, { root, executable: true });
|
||||
throw new Error('expected executable validation to fail');
|
||||
} catch (error) {
|
||||
expect(error).toMatchObject({ message: 'managed file is not executable', code: 'EACCES' });
|
||||
expect(String(error)).not.toContain('/proc/self/fd/');
|
||||
expect(String(error)).not.toContain(root);
|
||||
}
|
||||
});
|
||||
|
||||
it('uses effective-identity execute access after regular-file validation', () => {
|
||||
const file = join(root, 'helper.sh');
|
||||
writeFileSync(file, '#!/bin/sh\n', { mode: 0o644 });
|
||||
expect(() => readRegularFileSecure(file, { root, executable: true })).toThrow();
|
||||
|
||||
chmodSync(file, 0o755);
|
||||
expect(readRegularFileSecure(file, { root, executable: true }).content.toString()).toBe(
|
||||
'#!/bin/sh\n',
|
||||
);
|
||||
});
|
||||
});
|
||||
242
packages/mosaic/src/fleet/secure-file.ts
Normal file
242
packages/mosaic/src/fleet/secure-file.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
import {
|
||||
accessSync,
|
||||
closeSync,
|
||||
constants,
|
||||
fstatSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
openSync,
|
||||
readFileSync,
|
||||
} from 'node:fs';
|
||||
import { platform } from 'node:os';
|
||||
import { dirname, isAbsolute, relative, resolve, sep } from 'node:path';
|
||||
|
||||
export interface SecureFileReadOptions {
|
||||
root: string;
|
||||
maxBytes?: number;
|
||||
executable?: boolean;
|
||||
}
|
||||
|
||||
export interface SecureFileSnapshot {
|
||||
content: Buffer;
|
||||
mode: number;
|
||||
dev: number | bigint;
|
||||
ino: number | bigint;
|
||||
}
|
||||
|
||||
function sameIdentity(
|
||||
left: { dev: number | bigint; ino: number | bigint },
|
||||
right: { dev: number | bigint; ino: number | bigint },
|
||||
): boolean {
|
||||
return left.dev === right.dev && left.ino === right.ino;
|
||||
}
|
||||
|
||||
function secureFilesystemError(message: string, cause: unknown): Error {
|
||||
const error = new Error(message);
|
||||
if (cause instanceof Error && 'code' in cause && typeof cause.code === 'string') {
|
||||
Object.defineProperty(error, 'code', { value: cause.code, enumerable: true });
|
||||
}
|
||||
return error;
|
||||
}
|
||||
|
||||
function closeDescriptors(descriptors: number[]): void {
|
||||
for (const fd of descriptors.reverse()) {
|
||||
try {
|
||||
closeSync(fd);
|
||||
} catch {
|
||||
// Best-effort cleanup must not replace the security decision already made.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function procDescriptorPath(fd: number, component?: string): string {
|
||||
const descriptor = `/proc/self/fd/${fd}`;
|
||||
return component === undefined ? descriptor : `${descriptor}/${component}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hold each directory while opening its child through Linux proc-fd. The only
|
||||
* symlink followed is the kernel-owned descriptor link; O_NOFOLLOW protects
|
||||
* every appended filesystem component from substitution.
|
||||
*/
|
||||
function openDirectoryChain(absoluteDirectory: string): { fd: number; descriptors: number[] } {
|
||||
if (platform() !== 'linux') {
|
||||
throw new Error('secure descriptor traversal is unsupported on this platform');
|
||||
}
|
||||
|
||||
const descriptors: number[] = [];
|
||||
try {
|
||||
let fd = openSync(sep, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
|
||||
descriptors.push(fd);
|
||||
for (const component of absoluteDirectory.split(sep).filter(Boolean)) {
|
||||
fd = openSync(
|
||||
procDescriptorPath(fd, component),
|
||||
constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW,
|
||||
);
|
||||
descriptors.push(fd);
|
||||
if (!fstatSync(fd).isDirectory()) {
|
||||
throw new Error('secure descriptor traversal encountered a non-directory component');
|
||||
}
|
||||
}
|
||||
return { fd, descriptors };
|
||||
} catch (error) {
|
||||
closeDescriptors(descriptors);
|
||||
throw secureFilesystemError(
|
||||
'secure descriptor traversal failed: symbolic link, unavailable, or not a directory',
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function openFileBeneathRoot(root: string, target: string): { fd: number; descriptors: number[] } {
|
||||
const canonicalRoot = resolve(root);
|
||||
const canonicalTarget = resolve(target);
|
||||
assertCanonicalContainment(canonicalRoot, canonicalTarget);
|
||||
const components = relative(canonicalRoot, canonicalTarget).split(sep).filter(Boolean);
|
||||
const fileName = components.pop();
|
||||
if (fileName === undefined) throw new Error('managed file path names the managed root');
|
||||
|
||||
const rootChain = openDirectoryChain(canonicalRoot);
|
||||
try {
|
||||
let parentFd = rootChain.fd;
|
||||
for (const component of components) {
|
||||
try {
|
||||
parentFd = openSync(
|
||||
procDescriptorPath(parentFd, component),
|
||||
constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW,
|
||||
);
|
||||
} catch (error) {
|
||||
throw secureFilesystemError(
|
||||
'path ancestor is a symbolic link, unavailable, or not a directory',
|
||||
error,
|
||||
);
|
||||
}
|
||||
rootChain.descriptors.push(parentFd);
|
||||
if (!fstatSync(parentFd).isDirectory()) {
|
||||
throw new Error('path ancestor is a symbolic link or not a directory');
|
||||
}
|
||||
}
|
||||
let fd: number;
|
||||
try {
|
||||
fd = openSync(
|
||||
procDescriptorPath(parentFd, fileName),
|
||||
constants.O_RDONLY | constants.O_NONBLOCK | constants.O_NOFOLLOW,
|
||||
);
|
||||
} catch (error) {
|
||||
throw secureFilesystemError('file is a symbolic link or unavailable', error);
|
||||
}
|
||||
rootChain.descriptors.push(fd);
|
||||
return { fd, descriptors: rootChain.descriptors };
|
||||
} catch (error) {
|
||||
closeDescriptors(rootChain.descriptors);
|
||||
if (error instanceof Error) throw error;
|
||||
throw new Error('secure managed file open failed');
|
||||
}
|
||||
}
|
||||
|
||||
export function assertCanonicalContainment(root: string, target: string): void {
|
||||
const canonicalRoot = resolve(root);
|
||||
const canonicalTarget = resolve(target);
|
||||
const rel = relative(canonicalRoot, canonicalTarget);
|
||||
if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
|
||||
throw new Error(`path escapes managed root ${canonicalRoot}: ${canonicalTarget}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Reject every symlink from the filesystem root through the target's parent. */
|
||||
export function assertNoSymlinkAncestors(target: string): void {
|
||||
const absolute = resolve(target);
|
||||
const parent = dirname(absolute);
|
||||
const pieces = parent.split(sep).filter(Boolean);
|
||||
let cursor: string = sep;
|
||||
for (const piece of pieces) {
|
||||
cursor = resolve(cursor, piece);
|
||||
const stat = lstatSync(cursor);
|
||||
if (stat.isSymbolicLink()) throw new Error(`path ancestor is a symbolic link: ${cursor}`);
|
||||
if (!stat.isDirectory()) throw new Error(`path ancestor is not a directory: ${cursor}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function ensureManagedDirectory(root: string, directory: string): void {
|
||||
assertCanonicalContainment(root, directory);
|
||||
const canonicalRoot = resolve(root);
|
||||
const canonicalDirectory = resolve(directory);
|
||||
assertNoSymlinkAncestors(canonicalRoot);
|
||||
try {
|
||||
const rootStat = lstatSync(canonicalRoot);
|
||||
if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) {
|
||||
throw new Error(`managed root is not a real directory: ${canonicalRoot}`);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT')) throw error;
|
||||
mkdirSync(canonicalRoot, { mode: 0o700 });
|
||||
const rootStat = lstatSync(canonicalRoot);
|
||||
if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) {
|
||||
throw new Error(`managed root creation was redirected: ${canonicalRoot}`);
|
||||
}
|
||||
}
|
||||
const rel = relative(canonicalRoot, canonicalDirectory);
|
||||
let cursor = canonicalRoot;
|
||||
for (const piece of rel.split(sep).filter(Boolean)) {
|
||||
cursor = resolve(cursor, piece);
|
||||
try {
|
||||
const stat = lstatSync(cursor);
|
||||
if (stat.isSymbolicLink()) throw new Error(`path ancestor is a symbolic link: ${cursor}`);
|
||||
if (!stat.isDirectory()) throw new Error(`path ancestor is not a directory: ${cursor}`);
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT')) throw error;
|
||||
mkdirSync(cursor, { mode: 0o700 });
|
||||
const created = lstatSync(cursor);
|
||||
if (!created.isDirectory() || created.isSymbolicLink()) {
|
||||
throw new Error(`managed directory creation was redirected: ${cursor}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a regular file through an O_NOFOLLOW descriptor. The inode is checked
|
||||
* before and after access/read, and executable access is tested against the
|
||||
* already-open descriptor so path replacement cannot redirect the check.
|
||||
*/
|
||||
export function readRegularFileSecure(
|
||||
path: string,
|
||||
options: SecureFileReadOptions,
|
||||
): SecureFileSnapshot {
|
||||
const openedFile = openFileBeneathRoot(options.root, path);
|
||||
try {
|
||||
const opened = fstatSync(openedFile.fd);
|
||||
if (!opened.isFile()) throw new Error('managed file is not a regular file');
|
||||
if (options.maxBytes !== undefined && opened.size > options.maxBytes) {
|
||||
throw new Error(`managed file exceeds ${options.maxBytes} bytes`);
|
||||
}
|
||||
if (options.executable) {
|
||||
try {
|
||||
accessSync(procDescriptorPath(openedFile.fd), constants.X_OK);
|
||||
} catch (error) {
|
||||
throw secureFilesystemError('managed file is not executable', error);
|
||||
}
|
||||
const afterAccess = fstatSync(openedFile.fd);
|
||||
if (!afterAccess.isFile() || !sameIdentity(opened, afterAccess)) {
|
||||
throw new Error('managed file changed during executable access check');
|
||||
}
|
||||
}
|
||||
|
||||
const content = readFileSync(openedFile.fd);
|
||||
const after = fstatSync(openedFile.fd);
|
||||
if (!after.isFile() || !sameIdentity(opened, after)) {
|
||||
throw new Error('managed file changed during secure read');
|
||||
}
|
||||
if (options.maxBytes !== undefined && content.byteLength > options.maxBytes) {
|
||||
throw new Error(`managed file exceeds ${options.maxBytes} bytes`);
|
||||
}
|
||||
return {
|
||||
content,
|
||||
mode: Number(opened.mode),
|
||||
dev: opened.dev,
|
||||
ino: opened.ino,
|
||||
};
|
||||
} finally {
|
||||
closeDescriptors(openedFile.descriptors);
|
||||
}
|
||||
}
|
||||
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
@@ -1,5 +1,18 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import {
|
||||
chmodSync,
|
||||
existsSync,
|
||||
lstatSync,
|
||||
mkdtempSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
symlinkSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
@@ -11,8 +24,8 @@ import {
|
||||
readInstalledFrameworkVersion,
|
||||
readBundledFrameworkVersion,
|
||||
checkFrameworkDrift,
|
||||
repairFleetCommsTools,
|
||||
} from './update-checker.js';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
|
||||
/**
|
||||
* F3-m3 / R13: `mosaic update` re-seeds the framework + (opt-in) relaunches
|
||||
@@ -66,6 +79,7 @@ describe('readRosterAgentNames', () => {
|
||||
join(home, 'fleet', 'roster.yaml'),
|
||||
[
|
||||
'version: 1',
|
||||
'transport: tmux',
|
||||
'agents:',
|
||||
' - name: orchestrator',
|
||||
' runtime: pi',
|
||||
@@ -77,6 +91,212 @@ describe('readRosterAgentNames', () => {
|
||||
);
|
||||
expect(readRosterAgentNames(home)).toEqual(['orchestrator', 'coder0', 'reviewer-1']);
|
||||
});
|
||||
|
||||
it('extracts agent names from a JSON-only roster', () => {
|
||||
mkdirSync(join(home, 'fleet'), { recursive: true });
|
||||
writeFileSync(
|
||||
join(home, 'fleet', 'roster.json'),
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
transport: 'tmux',
|
||||
agents: [
|
||||
{ name: 'orchestrator', runtime: 'pi', class: 'orchestrator' },
|
||||
{ name: 'coder0', runtime: 'claude', class: 'worker' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(readRosterAgentNames(home)).toEqual(['orchestrator', 'coder0']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('repairFleetCommsTools', () => {
|
||||
let root: string;
|
||||
let framework: string;
|
||||
let home: string;
|
||||
const toolsContent = '# tools\n<!-- fleet-comms-contract: 1 -->\n';
|
||||
const helperContent = '#!/bin/sh\nexit 0\n';
|
||||
|
||||
beforeEach(() => {
|
||||
root = mkdtempSync(join(tmpdir(), 'mosaic-tools-repair-'));
|
||||
framework = join(root, 'framework');
|
||||
home = join(root, 'home');
|
||||
mkdirSync(join(framework, 'defaults'), { recursive: true });
|
||||
mkdirSync(join(framework, 'tools', 'tmux'), { recursive: true });
|
||||
writeFileSync(join(framework, 'defaults', 'TOOLS.md'), toolsContent);
|
||||
const helper = join(framework, 'tools', 'tmux', 'agent-send.sh');
|
||||
writeFileSync(helper, helperContent);
|
||||
chmodSync(helper, 0o755);
|
||||
});
|
||||
|
||||
afterEach(() => rmSync(root, { recursive: true, force: true }));
|
||||
|
||||
it('restores a partially deleted current-version installation without package updates', () => {
|
||||
mkdirSync(join(home, 'tools', 'tmux'), { recursive: true });
|
||||
writeFileSync(join(home, 'TOOLS.md'), toolsContent);
|
||||
|
||||
const result = repairFleetCommsTools(framework, home);
|
||||
|
||||
expect(result).toMatchObject({ ok: true, changed: true });
|
||||
expect(readFileSync(join(home, 'TOOLS.md'), 'utf8')).toBe(toolsContent);
|
||||
expect(readFileSync(join(home, 'tools', 'tmux', 'agent-send.sh'), 'utf8')).toBe(helperContent);
|
||||
expect(lstatSync(join(home, 'tools', 'tmux', 'agent-send.sh')).mode & 0o111).not.toBe(0);
|
||||
});
|
||||
|
||||
it('creates a digest-qualified no-clobber backup and is idempotent', () => {
|
||||
mkdirSync(home, { recursive: true });
|
||||
const stale = '# user tools\n';
|
||||
writeFileSync(join(home, 'TOOLS.md'), stale);
|
||||
|
||||
const first = repairFleetCommsTools(framework, home);
|
||||
expect(first).toMatchObject({ ok: true, changed: true });
|
||||
expect(first.backupPath).toMatch(/\.pre-fleet-comms-[a-f0-9]{16}\.bak$/);
|
||||
expect(readFileSync(first.backupPath!, 'utf8')).toBe(stale);
|
||||
|
||||
const second = repairFleetCommsTools(framework, home);
|
||||
expect(second).toEqual({ ok: true, changed: false, backupPath: undefined });
|
||||
expect(readFileSync(first.backupPath!, 'utf8')).toBe(stale);
|
||||
});
|
||||
|
||||
it('rejects an installed helper symlink without modifying its target', () => {
|
||||
mkdirSync(join(home, 'tools', 'tmux'), { recursive: true });
|
||||
writeFileSync(join(home, 'TOOLS.md'), toolsContent);
|
||||
const target = join(root, 'external-helper');
|
||||
writeFileSync(target, 'do not touch\n');
|
||||
symlinkSync(target, join(home, 'tools', 'tmux', 'agent-send.sh'));
|
||||
|
||||
const result = repairFleetCommsTools(framework, home);
|
||||
|
||||
expect(result).toMatchObject({ ok: false, changed: false });
|
||||
expect(result.reason).toContain('symbolic link');
|
||||
expect(readFileSync(target, 'utf8')).toBe('do not touch\n');
|
||||
expect(lstatSync(join(home, 'tools', 'tmux', 'agent-send.sh')).isSymbolicLink()).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses a helper directory before replacing stale TOOLS content', () => {
|
||||
mkdirSync(join(home, 'tools', 'tmux', 'agent-send.sh'), { recursive: true });
|
||||
const stale = '# user tools\n';
|
||||
writeFileSync(join(home, 'TOOLS.md'), stale);
|
||||
|
||||
const result = repairFleetCommsTools(framework, home);
|
||||
|
||||
expect(result).toMatchObject({ ok: false, changed: false });
|
||||
expect(result.reason).toContain('not a regular file');
|
||||
expect(readFileSync(join(home, 'TOOLS.md'), 'utf8')).toBe(stale);
|
||||
});
|
||||
|
||||
it('refuses a pre-existing digest backup whose bytes do not match', () => {
|
||||
mkdirSync(home, { recursive: true });
|
||||
const stale = '# user tools\n';
|
||||
writeFileSync(join(home, 'TOOLS.md'), stale);
|
||||
const digest = createHash('sha256').update(stale).digest('hex').slice(0, 16);
|
||||
writeFileSync(join(home, `TOOLS.md.pre-fleet-comms-${digest}.bak`), 'collision\n');
|
||||
|
||||
const result = repairFleetCommsTools(framework, home);
|
||||
|
||||
expect(result).toMatchObject({ ok: false, changed: false });
|
||||
expect(result.reason).toContain('backup collision');
|
||||
expect(readFileSync(join(home, 'TOOLS.md'), 'utf8')).toBe(stale);
|
||||
});
|
||||
|
||||
it('rejects a symlink in each installed destination ancestor without external writes', () => {
|
||||
const cases = [
|
||||
{ name: 'home', prefix: join(root, 'linked-home'), suffix: '' },
|
||||
{ name: 'tools', prefix: join(root, 'real-home'), suffix: 'tools' },
|
||||
{ name: 'tmux', prefix: join(root, 'real-home'), suffix: join('tools', 'tmux') },
|
||||
];
|
||||
for (const testCase of cases) {
|
||||
const external = join(root, `external-${testCase.name}`);
|
||||
mkdirSync(external, { recursive: true });
|
||||
const targetHome =
|
||||
testCase.name === 'home' ? testCase.prefix : join(root, `installed-${testCase.name}`);
|
||||
if (testCase.name === 'home') {
|
||||
symlinkSync(external, targetHome);
|
||||
} else {
|
||||
mkdirSync(targetHome, { recursive: true });
|
||||
const linkPath = join(targetHome, testCase.suffix);
|
||||
mkdirSync(join(linkPath, '..'), { recursive: true });
|
||||
symlinkSync(external, linkPath);
|
||||
}
|
||||
|
||||
const result = repairFleetCommsTools(framework, targetHome);
|
||||
|
||||
expect(result, testCase.name).toMatchObject({ ok: false, changed: false });
|
||||
expect(result.reason, testCase.name).toContain('symbolic link');
|
||||
expect(readdirSync(external), testCase.name).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it('rolls back the backup and exact TOOLS bytes/mode when helper commit fails', () => {
|
||||
mkdirSync(join(home, 'tools', 'tmux'), { recursive: true });
|
||||
const staleTools = '# user tools\n';
|
||||
const staleHelper = '#!/bin/sh\nexit 17\n';
|
||||
writeFileSync(join(home, 'TOOLS.md'), staleTools, { mode: 0o640 });
|
||||
writeFileSync(join(home, 'tools', 'tmux', 'agent-send.sh'), staleHelper, { mode: 0o710 });
|
||||
|
||||
const result = repairFleetCommsTools(framework, home, {
|
||||
beforeCommit(which) {
|
||||
if (which === 'helper') throw new Error('injected helper commit failure');
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ ok: false, changed: false });
|
||||
expect(result.backupPath).toBeUndefined();
|
||||
expect(result.reason).toContain('injected helper commit failure');
|
||||
expect(readFileSync(join(home, 'TOOLS.md'), 'utf8')).toBe(staleTools);
|
||||
expect(statSync(join(home, 'TOOLS.md')).mode & 0o777).toBe(0o640);
|
||||
expect(readFileSync(join(home, 'tools', 'tmux', 'agent-send.sh'), 'utf8')).toBe(staleHelper);
|
||||
expect(statSync(join(home, 'tools', 'tmux', 'agent-send.sh')).mode & 0o777).toBe(0o710);
|
||||
expect(readdirSync(home).filter((name) => name.includes('pre-fleet-comms'))).toEqual([]);
|
||||
expect(
|
||||
readdirSync(home).some((name) => name.includes('.repair-')) ||
|
||||
readdirSync(join(home, 'tools', 'tmux')).some((name) => name.includes('.repair-')),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('rolls back initially absent destinations and created directories on commit failure', () => {
|
||||
const result = repairFleetCommsTools(framework, home, {
|
||||
beforeCommit(which) {
|
||||
if (which === 'helper') throw new Error('injected absent helper failure');
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ ok: false, changed: false });
|
||||
expect(result.reason).toContain('injected absent helper failure');
|
||||
expect(existsSync(home)).toBe(false);
|
||||
});
|
||||
|
||||
it('does not persist a backup or replacement when backup commit fails', () => {
|
||||
mkdirSync(home, { recursive: true });
|
||||
const stale = '# user tools\n';
|
||||
writeFileSync(join(home, 'TOOLS.md'), stale, { mode: 0o640 });
|
||||
|
||||
const result = repairFleetCommsTools(framework, home, {
|
||||
beforeCommit(which) {
|
||||
if (which === 'backup') throw new Error('injected backup commit failure');
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ ok: false, changed: false });
|
||||
expect(readFileSync(join(home, 'TOOLS.md'), 'utf8')).toBe(stale);
|
||||
expect(statSync(join(home, 'TOOLS.md')).mode & 0o777).toBe(0o640);
|
||||
expect(readdirSync(home).filter((name) => name.includes('pre-fleet-comms'))).toEqual([]);
|
||||
});
|
||||
|
||||
it('fails before writes when bundled source paths traverse a symlink ancestor', () => {
|
||||
const external = join(root, 'external-source');
|
||||
mkdirSync(join(external, 'defaults'), { recursive: true });
|
||||
mkdirSync(join(external, 'tools', 'tmux'), { recursive: true });
|
||||
writeFileSync(join(external, 'defaults', 'TOOLS.md'), toolsContent);
|
||||
writeFileSync(join(external, 'tools', 'tmux', 'agent-send.sh'), helperContent, { mode: 0o755 });
|
||||
const linkedFramework = join(root, 'linked-framework');
|
||||
symlinkSync(external, linkedFramework);
|
||||
|
||||
const result = repairFleetCommsTools(linkedFramework, home);
|
||||
|
||||
expect(result).toMatchObject({ ok: false, changed: false });
|
||||
expect(result.reason).toContain('symbolic link');
|
||||
expect(existsSync(home)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runFrameworkReseed', () => {
|
||||
|
||||
@@ -15,16 +15,34 @@
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
readdirSync,
|
||||
closeSync,
|
||||
constants,
|
||||
copyFileSync,
|
||||
existsSync,
|
||||
fchmodSync,
|
||||
fsyncSync,
|
||||
linkSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
openSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
renameSync,
|
||||
rmdirSync,
|
||||
unlinkSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import { homedir } from 'node:os';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { basename, dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parseFleetRosterV1, resolveInstalledFleetRosterPath } from '../fleet/fleet-roster-v1.js';
|
||||
import {
|
||||
assertCanonicalContainment,
|
||||
assertNoSymlinkAncestors,
|
||||
ensureManagedDirectory,
|
||||
readRegularFileSecure,
|
||||
} from '../fleet/secure-file.js';
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -54,6 +72,10 @@ const CACHE_FILE = join(CACHE_DIR, 'update-check.json');
|
||||
const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
|
||||
const NETWORK_TIMEOUT_MS = 5_000;
|
||||
|
||||
function isNodeErrorCode(error: unknown, code: string): boolean {
|
||||
return error instanceof Error && 'code' in error && error.code === code;
|
||||
}
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function npmExec(args: string, timeoutMs = NETWORK_TIMEOUT_MS): string {
|
||||
@@ -500,6 +522,346 @@ export function buildReseedCommand(
|
||||
};
|
||||
}
|
||||
|
||||
export interface ToolsRepairResult {
|
||||
ok: boolean;
|
||||
changed: boolean;
|
||||
backupPath?: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface ToolsRepairHooks {
|
||||
beforeCommit?: (which: 'backup' | 'tools' | 'helper') => void;
|
||||
}
|
||||
|
||||
function optionalSecureFile(
|
||||
path: string,
|
||||
root: string,
|
||||
): ReturnType<typeof readRegularFileSecure> | undefined {
|
||||
try {
|
||||
return readRegularFileSecure(path, { root });
|
||||
} catch (error) {
|
||||
if (isNodeErrorCode(error, 'ENOENT')) return undefined;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function stageManagedFile(
|
||||
root: string,
|
||||
directory: string,
|
||||
target: string,
|
||||
content: Buffer,
|
||||
mode: number,
|
||||
): string {
|
||||
assertCanonicalContainment(root, target);
|
||||
ensureManagedDirectory(root, directory);
|
||||
assertNoSymlinkAncestors(target);
|
||||
const staged = join(directory, `.${basename(target)}.repair-${process.pid}-${cryptoRandom()}`);
|
||||
assertCanonicalContainment(root, staged);
|
||||
const fd = openSync(
|
||||
staged,
|
||||
constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW,
|
||||
0o600,
|
||||
);
|
||||
try {
|
||||
writeFileSync(fd, content);
|
||||
fchmodSync(fd, mode);
|
||||
fsyncSync(fd);
|
||||
} finally {
|
||||
closeSync(fd);
|
||||
}
|
||||
return staged;
|
||||
}
|
||||
|
||||
function cryptoRandom(): string {
|
||||
return randomBytes(8).toString('hex');
|
||||
}
|
||||
|
||||
interface ManagedOriginal {
|
||||
path: string;
|
||||
snapshot?: ReturnType<typeof readRegularFileSecure>;
|
||||
}
|
||||
|
||||
function assertManagedOriginalUnchanged(original: ManagedOriginal, root: string): void {
|
||||
if (!original.snapshot) {
|
||||
try {
|
||||
lstatSync(original.path);
|
||||
throw new Error(`repair destination appeared during staging: ${original.path}`);
|
||||
} catch (error) {
|
||||
if (isNodeErrorCode(error, 'ENOENT')) return;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const current = readRegularFileSecure(original.path, { root });
|
||||
if (
|
||||
current.dev !== original.snapshot.dev ||
|
||||
current.ino !== original.snapshot.ino ||
|
||||
current.mode !== original.snapshot.mode ||
|
||||
!current.content.equals(original.snapshot.content)
|
||||
) {
|
||||
throw new Error(`repair destination changed during staging: ${original.path}`);
|
||||
}
|
||||
}
|
||||
|
||||
function installBackupNoClobber(staged: string, target: string, root: string): void {
|
||||
assertCanonicalContainment(root, target);
|
||||
assertNoSymlinkAncestors(target);
|
||||
try {
|
||||
lstatSync(target);
|
||||
throw new Error(`digest-qualified backup collision at ${target}`);
|
||||
} catch (error) {
|
||||
if (!isNodeErrorCode(error, 'ENOENT')) throw error;
|
||||
}
|
||||
linkSync(staged, target);
|
||||
unlinkSync(staged);
|
||||
}
|
||||
|
||||
function atomicInstall(staged: string, target: string, root: string): void {
|
||||
assertCanonicalContainment(root, target);
|
||||
assertNoSymlinkAncestors(target);
|
||||
try {
|
||||
const current = lstatSync(target);
|
||||
if (current.isSymbolicLink() || !current.isFile()) {
|
||||
throw new Error(`repair destination is not a regular file: ${target}`);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!isNodeErrorCode(error, 'ENOENT')) throw error;
|
||||
}
|
||||
renameSync(staged, target);
|
||||
}
|
||||
|
||||
/**
|
||||
* Explicitly repair the user-owned TOOLS contract and required helper from the
|
||||
* bundled current framework. Existing divergent TOOLS content is preserved in
|
||||
* a digest-qualified no-clobber backup; repeated repairs are idempotent.
|
||||
*/
|
||||
export function repairFleetCommsTools(
|
||||
frameworkRoot = resolveBundledFrameworkRoot(),
|
||||
mosaicHome = join(homedir(), '.config', 'mosaic'),
|
||||
hooks: ToolsRepairHooks = {},
|
||||
): ToolsRepairResult {
|
||||
const sourceTools = join(frameworkRoot, 'defaults', 'TOOLS.md');
|
||||
const sourceHelper = join(frameworkRoot, 'tools', 'tmux', 'agent-send.sh');
|
||||
const installedTools = join(mosaicHome, 'TOOLS.md');
|
||||
const helperDirectory = join(mosaicHome, 'tools', 'tmux');
|
||||
const installedHelper = join(helperDirectory, 'agent-send.sh');
|
||||
let stagedBackup: string | undefined;
|
||||
let stagedTools: string | undefined;
|
||||
let stagedHelper: string | undefined;
|
||||
let rollbackTools: string | undefined;
|
||||
let rollbackHelper: string | undefined;
|
||||
let committedBackup = false;
|
||||
let committedTools = false;
|
||||
let committedHelper = false;
|
||||
let createdHome = false;
|
||||
let createdToolsDirectory = false;
|
||||
let createdHelperDirectory = false;
|
||||
let backupPath: string | undefined;
|
||||
let toolsOriginal: ManagedOriginal | undefined;
|
||||
let helperOriginal: ManagedOriginal | undefined;
|
||||
try {
|
||||
const sourceToolsSnapshot = readRegularFileSecure(sourceTools, { root: frameworkRoot });
|
||||
const sourceHelperSnapshot = readRegularFileSecure(sourceHelper, {
|
||||
root: frameworkRoot,
|
||||
executable: true,
|
||||
});
|
||||
if (!sourceToolsSnapshot.content.includes('<!-- fleet-comms-contract: 1 -->')) {
|
||||
return { ok: false, changed: false, reason: 'bundled TOOLS contract has wrong version' };
|
||||
}
|
||||
|
||||
assertCanonicalContainment(mosaicHome, installedTools);
|
||||
assertCanonicalContainment(mosaicHome, installedHelper);
|
||||
assertNoSymlinkAncestors(mosaicHome);
|
||||
const homeExisted = existsSync(mosaicHome);
|
||||
const toolsDirectory = dirname(helperDirectory);
|
||||
const toolsDirectoryExisted = existsSync(toolsDirectory);
|
||||
const helperDirectoryExisted = existsSync(helperDirectory);
|
||||
if (homeExisted) {
|
||||
const homeStat = lstatSync(mosaicHome);
|
||||
if (homeStat.isSymbolicLink()) {
|
||||
throw new Error(`managed root is a symbolic link: ${mosaicHome}`);
|
||||
}
|
||||
if (!homeStat.isDirectory()) {
|
||||
throw new Error(`managed root is not a real directory: ${mosaicHome}`);
|
||||
}
|
||||
}
|
||||
|
||||
const installedToolsSnapshot = homeExisted
|
||||
? optionalSecureFile(installedTools, mosaicHome)
|
||||
: undefined;
|
||||
let installedHelperSnapshot: ReturnType<typeof readRegularFileSecure> | undefined;
|
||||
let installedHelperExecutable = false;
|
||||
if (homeExisted) {
|
||||
try {
|
||||
installedHelperSnapshot = readRegularFileSecure(installedHelper, {
|
||||
root: mosaicHome,
|
||||
executable: true,
|
||||
});
|
||||
installedHelperExecutable = true;
|
||||
} catch (error) {
|
||||
if (!isNodeErrorCode(error, 'ENOENT') && !isNodeErrorCode(error, 'EACCES')) throw error;
|
||||
if (isNodeErrorCode(error, 'EACCES')) {
|
||||
installedHelperSnapshot = optionalSecureFile(installedHelper, mosaicHome);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const toolsChanged = !installedToolsSnapshot?.content.equals(sourceToolsSnapshot.content);
|
||||
const helperChanged =
|
||||
!installedHelperExecutable ||
|
||||
!installedHelperSnapshot?.content.equals(sourceHelperSnapshot.content);
|
||||
if (!toolsChanged && !helperChanged) return { ok: true, changed: false };
|
||||
|
||||
toolsOriginal = { path: installedTools, snapshot: installedToolsSnapshot };
|
||||
helperOriginal = { path: installedHelper, snapshot: installedHelperSnapshot };
|
||||
|
||||
ensureManagedDirectory(dirname(mosaicHome), mosaicHome);
|
||||
createdHome = !homeExisted;
|
||||
ensureManagedDirectory(mosaicHome, helperDirectory);
|
||||
createdToolsDirectory = !toolsDirectoryExisted;
|
||||
createdHelperDirectory = !helperDirectoryExisted;
|
||||
|
||||
if (toolsChanged) {
|
||||
stagedTools = stageManagedFile(
|
||||
mosaicHome,
|
||||
mosaicHome,
|
||||
installedTools,
|
||||
sourceToolsSnapshot.content,
|
||||
sourceToolsSnapshot.mode & 0o777,
|
||||
);
|
||||
if (installedToolsSnapshot) {
|
||||
const digest = createHash('sha256')
|
||||
.update(installedToolsSnapshot.content)
|
||||
.digest('hex')
|
||||
.slice(0, 16);
|
||||
backupPath = `${installedTools}.pre-fleet-comms-${digest}.bak`;
|
||||
const existingBackup = optionalSecureFile(backupPath, mosaicHome);
|
||||
if (existingBackup && !existingBackup.content.equals(installedToolsSnapshot.content)) {
|
||||
throw new Error(`digest-qualified backup collision at ${backupPath}`);
|
||||
}
|
||||
if (!existingBackup) {
|
||||
stagedBackup = stageManagedFile(
|
||||
mosaicHome,
|
||||
mosaicHome,
|
||||
backupPath,
|
||||
installedToolsSnapshot.content,
|
||||
installedToolsSnapshot.mode & 0o777,
|
||||
);
|
||||
}
|
||||
rollbackTools = stageManagedFile(
|
||||
mosaicHome,
|
||||
mosaicHome,
|
||||
installedTools,
|
||||
installedToolsSnapshot.content,
|
||||
installedToolsSnapshot.mode & 0o777,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (helperChanged) {
|
||||
stagedHelper = stageManagedFile(
|
||||
mosaicHome,
|
||||
helperDirectory,
|
||||
installedHelper,
|
||||
sourceHelperSnapshot.content,
|
||||
sourceHelperSnapshot.mode & 0o777,
|
||||
);
|
||||
if (installedHelperSnapshot) {
|
||||
rollbackHelper = stageManagedFile(
|
||||
mosaicHome,
|
||||
helperDirectory,
|
||||
installedHelper,
|
||||
installedHelperSnapshot.content,
|
||||
installedHelperSnapshot.mode & 0o777,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
assertManagedOriginalUnchanged(toolsOriginal, mosaicHome);
|
||||
assertManagedOriginalUnchanged(helperOriginal, mosaicHome);
|
||||
if (stagedBackup && backupPath) {
|
||||
hooks.beforeCommit?.('backup');
|
||||
assertManagedOriginalUnchanged(toolsOriginal, mosaicHome);
|
||||
assertManagedOriginalUnchanged(helperOriginal, mosaicHome);
|
||||
installBackupNoClobber(stagedBackup, backupPath, mosaicHome);
|
||||
stagedBackup = undefined;
|
||||
committedBackup = true;
|
||||
}
|
||||
if (stagedTools) {
|
||||
hooks.beforeCommit?.('tools');
|
||||
assertManagedOriginalUnchanged(toolsOriginal, mosaicHome);
|
||||
atomicInstall(stagedTools, installedTools, mosaicHome);
|
||||
stagedTools = undefined;
|
||||
committedTools = true;
|
||||
}
|
||||
if (stagedHelper) {
|
||||
hooks.beforeCommit?.('helper');
|
||||
assertManagedOriginalUnchanged(helperOriginal, mosaicHome);
|
||||
atomicInstall(stagedHelper, installedHelper, mosaicHome);
|
||||
stagedHelper = undefined;
|
||||
committedHelper = true;
|
||||
}
|
||||
if (rollbackTools) unlinkSync(rollbackTools);
|
||||
if (rollbackHelper) unlinkSync(rollbackHelper);
|
||||
return { ok: true, changed: true, backupPath };
|
||||
} catch (error) {
|
||||
const failures: string[] = [];
|
||||
try {
|
||||
if (committedHelper) {
|
||||
if (rollbackHelper) atomicInstall(rollbackHelper, installedHelper, mosaicHome);
|
||||
else unlinkSync(installedHelper);
|
||||
rollbackHelper = undefined;
|
||||
}
|
||||
} catch (rollbackError) {
|
||||
failures.push(`helper rollback failed: ${String(rollbackError)}`);
|
||||
}
|
||||
try {
|
||||
if (committedTools) {
|
||||
if (rollbackTools) atomicInstall(rollbackTools, installedTools, mosaicHome);
|
||||
else unlinkSync(installedTools);
|
||||
rollbackTools = undefined;
|
||||
}
|
||||
} catch (rollbackError) {
|
||||
failures.push(`TOOLS rollback failed: ${String(rollbackError)}`);
|
||||
}
|
||||
try {
|
||||
if (committedBackup && backupPath) {
|
||||
unlinkSync(backupPath);
|
||||
committedBackup = false;
|
||||
}
|
||||
} catch (rollbackError) {
|
||||
failures.push(`backup rollback failed: ${String(rollbackError)}`);
|
||||
}
|
||||
for (const staged of [stagedBackup, stagedTools, stagedHelper, rollbackTools, rollbackHelper]) {
|
||||
if (!staged) continue;
|
||||
try {
|
||||
unlinkSync(staged);
|
||||
} catch {
|
||||
failures.push(`staging cleanup failed: ${staged}`);
|
||||
}
|
||||
}
|
||||
for (const [created, directory] of [
|
||||
[createdHelperDirectory, helperDirectory],
|
||||
[createdToolsDirectory, dirname(helperDirectory)],
|
||||
[createdHome, mosaicHome],
|
||||
] as const) {
|
||||
if (!created) continue;
|
||||
try {
|
||||
rmdirSync(directory);
|
||||
} catch (cleanupError) {
|
||||
if (!isNodeErrorCode(cleanupError, 'ENOENT')) {
|
||||
failures.push(`directory cleanup failed: ${directory}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
ok: false,
|
||||
changed: failures.length > 0,
|
||||
backupPath: committedBackup ? backupPath : undefined,
|
||||
reason: failures.length > 0 ? `${reason}; ${failures.join('; ')}` : reason,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-seed the framework from the freshly-installed package. Returns a result
|
||||
* describing what happened (so callers can message + decide on relaunch).
|
||||
@@ -591,25 +953,20 @@ export function checkFrameworkDrift(
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort parse of the fleet roster for agent names (used to relaunch
|
||||
* durable agents after a re-seed). Returns [] when no roster exists.
|
||||
* Canonically parse the installed fleet roster for relaunch targets. JSON is
|
||||
* considered only when roster.yaml is genuinely absent; all other failures
|
||||
* return no targets rather than guessing.
|
||||
*/
|
||||
export function readRosterAgentNames(mosaicHome = join(homedir(), '.config', 'mosaic')): string[] {
|
||||
const rosterPath = join(mosaicHome, 'fleet', 'roster.yaml');
|
||||
if (!existsSync(rosterPath)) return [];
|
||||
let text: string;
|
||||
try {
|
||||
text = readFileSync(rosterPath, 'utf-8');
|
||||
const rosterPath = resolveInstalledFleetRosterPath(mosaicHome);
|
||||
const source = readFileSync(rosterPath, 'utf8');
|
||||
return parseFleetRosterV1(source, rosterPath.endsWith('.json') ? 'json' : 'yaml').agents.map(
|
||||
(agent) => agent.name,
|
||||
);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
// Roster agents are listed as `- name: <id>` entries under `agents:`.
|
||||
const names: string[] = [];
|
||||
for (const line of text.split('\n')) {
|
||||
const m = line.match(/^\s*-?\s*name:\s*["']?([A-Za-z0-9._-]+)["']?\s*$/);
|
||||
if (m && m[1]) names.push(m[1]);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
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