Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
133c3b67f7 | ||
|
|
476db12b92 | ||
|
|
5198c3f198 | ||
|
|
19ac0a02d7 | ||
|
|
6d9387c857 | ||
|
|
14cb9c6a1e | ||
|
|
b5b322f80d | ||
|
|
e4674709be | ||
|
|
c56483eb1b | ||
|
|
5c35a250de | ||
|
|
10a1f82031 | ||
|
|
b61789fe26 | ||
|
|
61a907a12f | ||
|
|
6f5b4c3dc1 | ||
|
|
67f5014cc0 | ||
|
|
463745e314 | ||
|
|
03eda02c20 | ||
|
|
3b4055017e | ||
|
|
07373ede4d | ||
|
|
fb5bb98a32 | ||
|
|
47e90767b7 | ||
|
|
00bc602f93 | ||
|
|
d0c223bdf9 | ||
|
|
cc0d24d5c4 | ||
|
|
40fecd4d38 |
+14
-12
@@ -115,18 +115,19 @@ gateway-backed agent catalog.
|
|||||||
|
|
||||||
### Normative requirements
|
### Normative requirements
|
||||||
|
|
||||||
| ID | Requirement |
|
| ID | Requirement |
|
||||||
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `FCM-REQ-01` | The roster SHALL be the sole writable desired-state source for local fleet membership, launch policy, and persisted lifecycle target. Generated environment files, systemd enablement, tmux sessions, and heartbeat state SHALL be non-authoritative projections. |
|
| `FCM-REQ-01` | The roster SHALL be the sole writable desired-state source for local fleet membership, launch policy, and persisted lifecycle target. Generated environment files, systemd enablement, tmux sessions, and heartbeat state SHALL be non-authoritative projections. |
|
||||||
| `FCM-REQ-02` | The implementation SHALL provide one executable structural contract for YAML/JSON input and one shared semantic validator. Roster load, profile validation, provision, migration, and apply SHALL reuse the existing baseline-plus-`roles.local` profile/persona resolver; a parallel role resolver is forbidden. |
|
| `FCM-REQ-02` | The implementation SHALL provide one executable structural contract for YAML/JSON input and one shared semantic validator. Roster load, profile validation, provision, migration, and apply SHALL reuse the existing baseline-plus-`roles.local` profile/persona resolver; a parallel role resolver is forbidden. |
|
||||||
| `FCM-REQ-03` | The local fleet CLI SHALL expose documented programmatic validate, show, plan, apply/reconcile, create, inspect, update, delete, start, stop, restart, status, verify, and doctor operations with stable JSON and exit-code behavior. Existing `fleet add/remove` compatibility aliases may remain during the stated deprecation window. |
|
| `FCM-REQ-03` | The local fleet CLI SHALL expose documented programmatic validate, show, plan, apply/reconcile, create, inspect, update, delete, start, stop, restart, status, verify, and doctor operations with stable JSON and exit-code behavior. Existing `fleet add/remove` compatibility aliases may remain during the stated deprecation window. |
|
||||||
| `FCM-REQ-04` | A fresh create SHALL persist `enabled:true` and `desired_state:stopped` unless an explicit persisted start is requested. The model SHALL distinguish enabled state, persisted desired state, and observed state. Migration, apply, reboot, and rollback SHALL not start an agent that was observed stopped before cutover. |
|
| `FCM-REQ-04` | A fresh create SHALL persist `enabled:true` and `desired_state:stopped` unless an explicit persisted start is requested. The model SHALL distinguish enabled state, persisted desired state, and observed state. Migration, apply, reboot, and rollback SHALL not start an agent that was observed stopped before cutover. |
|
||||||
| `FCM-REQ-05` | The launch chain SHALL consume deterministic, digest-stamped generated input only. Optional local overrides SHALL be parsed as strict data, may not shadow authoritative generated keys, and may not contain arbitrary commands, credential values, channels, or unknown `MOSAIC_AGENT_*` keys. Forbidden legacy keys, including `MOSAIC_AGENT_COMMAND`, SHALL be privately quarantined before launch and reported only by key name and content hash. |
|
| `FCM-REQ-05` | The launch chain SHALL consume deterministic, digest-stamped generated input only. Optional local overrides SHALL be parsed as strict data, may not shadow authoritative generated keys, and may not contain arbitrary commands, credential values, channels, or unknown `MOSAIC_AGENT_*` keys. Forbidden legacy keys, including `MOSAIC_AGENT_COMMAND`, SHALL be privately quarantined before launch and reported only by key name and content hash. |
|
||||||
| `FCM-REQ-06` | Mutations and apply SHALL validate before mutation, use an expected generation/lock, write projections atomically, produce a deterministic plan, and emit recovery information on partial failure. Reconciliation SHALL act only on local, enabled, roster-owned projections and SHALL not kill unmanaged tmux sessions by fuzzy name. |
|
| `FCM-REQ-06` | Mutations and apply SHALL validate before mutation, use an expected generation/lock, write projections atomically, produce a deterministic plan, and emit recovery information on partial failure. Reconciliation SHALL act only on local, enabled, roster-owned projections and SHALL not kill unmanaged tmux sessions by fuzzy name. |
|
||||||
| `FCM-REQ-07` | Canonical required classes are `code`, `review`, `validator`, `orchestrator`, `team-leader`, `enhancer`, and `interaction`. `validator` issues an independent final certificate but has no merge authority; `merge-gate` remains sole approve-to-land/merge authority. Team-leader capacity is bounded by an orchestrator-issued lease, and interaction is request/status only. Tess and Ultron are configurable instance/display names, not required machine identities. |
|
| `FCM-REQ-07` | Canonical required classes are `code`, `review`, `validator`, `orchestrator`, `team-leader`, `enhancer`, and `interaction`. `validator` issues an independent final certificate but has no merge authority; `merge-gate` remains sole approve-to-land/merge authority. Team-leader capacity is bounded by an orchestrator-issued lease, and interaction is request/status only. Tess and Ultron are configurable instance/display names, not required machine identities. |
|
||||||
| `FCM-REQ-08` | v1 migration SHALL be field-complete, reversible, and explicit about aliases, unresolved classes, lifecycle inference, generated-file regeneration, local override quarantine, schema-only remote/connector fields, and rollback. Every shipped example, profile, and service preset SHALL be migrated and executable, retained as an explicitly versioned v1 fixture, or retired with a replacement and deprecation note. |
|
| `FCM-REQ-08` | v1 migration SHALL be field-complete, reversible, and explicit about aliases, unresolved classes, lifecycle inference, generated-file regeneration, local override quarantine, schema-only remote/connector fields, and rollback. Every shipped example, profile, and service preset SHALL be migrated and executable, retained as an explicitly versioned v1 fixture, or retired with a replacement and deprecation note. |
|
||||||
| `FCM-REQ-09` | M1–M5 SHALL remain local tmux/systemd control-plane work. Remote/SSH reconciliation, connector mutation, secret references, arbitrary command/channel overrides, gateway/API convergence, and UI configuration storage are excluded and require a separate PRD/threat model. |
|
| `FCM-REQ-09` | M1–M5 SHALL remain local tmux/systemd control-plane work. Remote/SSH reconciliation, connector mutation, secret references, arbitrary command/channel overrides, gateway/API convergence, and UI configuration storage are excluded and require a separate PRD/threat model. |
|
||||||
| `FCM-REQ-10` | Documentation and examples are delivery gates. The M0 checklist at [docs/fleet/FLEET-CONFIG-DOCS-IA-CHECKLIST.md](./fleet/FLEET-CONFIG-DOCS-IA-CHECKLIST.md) and the baseline disposition inventory at [docs/fleet/LEGACY-EXAMPLE-PROFILE-DISPOSITION-INVENTORY.md](./fleet/LEGACY-EXAMPLE-PROFILE-DISPOSITION-INVENTORY.md) SHALL be maintained as acceptance evidence. |
|
| `FCM-REQ-10` | Documentation and examples are delivery gates. The M0 checklist at [docs/fleet/FLEET-CONFIG-DOCS-IA-CHECKLIST.md](./fleet/FLEET-CONFIG-DOCS-IA-CHECKLIST.md) and the baseline disposition inventory at [docs/fleet/LEGACY-EXAMPLE-PROFILE-DISPOSITION-INVENTORY.md](./fleet/LEGACY-EXAMPLE-PROFILE-DISPOSITION-INVENTORY.md) SHALL be maintained as acceptance evidence. |
|
||||||
|
| `FCM-REQ-11` | Fleet provisioning SHALL validate the fleet CLI and distinct runtimes requested by the roster against the exact PATH construction used by the runtime pane, through one shared implementation rather than the operator PATH or a parallel PATH model. Name resolution alone is insufficient: a resolved script's shebang interpreter SHALL also be reachable, and Node SHALL execute a side-effect-free version probe when it is that interpreter. `fleet install` and `install-systemd` SHALL fail before installation effects when a required executable is absent or unreachable. `fleet doctor` SHALL emit the same named checks as non-green evidence. Every runtime failure SHALL name the runtime, all requesting roster rows, the pane PATH searched, and an exact install command. |
|
||||||
|
|
||||||
### Acceptance criteria
|
### Acceptance criteria
|
||||||
|
|
||||||
@@ -138,6 +139,7 @@ gateway-backed agent catalog.
|
|||||||
6. `AC-FCM-06`: A v1 roster migration previews field-by-field disposition, preserves observed stopped/running state, inventories rather than reconciles remote/schema-only entries, supports a canary and rollback, and classifies every shipped example, profile, and service preset according to the M0 inventory.
|
6. `AC-FCM-06`: A v1 roster migration previews field-by-field disposition, preserves observed stopped/running state, inventories rather than reconciles remote/schema-only entries, supports a canary and rollback, and classifies every shipped example, profile, and service preset according to the M0 inventory.
|
||||||
7. `AC-FCM-07`: Required role authority is validated: validator certificate is consumed but does not merge, merge-gate is the sole merge authority, team-leader leases do not change roster/credentials/authority, and interaction/Tess cannot claim orchestration or merge powers.
|
7. `AC-FCM-07`: Required role authority is validated: validator certificate is consumed but does not merge, merge-gate is the sole merge authority, team-leader leases do not change roster/credentials/authority, and interaction/Tess cannot claim orchestration or merge powers.
|
||||||
8. `AC-FCM-08`: Documentation, examples, migration, troubleshooting, operational recovery, package/update asset drift, schema/example/profile validation, independent code/security review, validator certificate, and terminal-green CI are complete before #758 closes.
|
8. `AC-FCM-08`: Documentation, examples, migration, troubleshooting, operational recovery, package/update asset drift, schema/example/profile validation, independent code/security review, validator certificate, and terminal-green CI are complete before #758 closes.
|
||||||
|
9. `AC-FCM-09`: Red-first isolated tests create (a) a roster whose runtime exists on the operator PATH but is absent from the constructed pane PATH and (b) a greenfield pane where `mosaic` and a runtime resolve as Node-shebang scripts while Node is absent. They prove `fleet install` fails before effects, the launcher creates no doomed session, and `fleet doctor` reports named non-green checks. Diagnostics include the executable or runtime, all requesting rows, searched pane PATH, shebang dependency when present, and exact runtime install command; repeated rows are checked once per distinct runtime/effective pane path. Tests use temporary `--mosaic-home` state and fixture binaries, never host runtime mutation.
|
||||||
|
|
||||||
### M0 implementation gate
|
### M0 implementation gate
|
||||||
|
|
||||||
|
|||||||
@@ -59,6 +59,28 @@ valid allowed local data can move to `.env.local`; invalid legacy input is priva
|
|||||||
Diagnostics expose only rule code, key name, and a SHA-256 content hash. They do not reveal command
|
Diagnostics expose only rule code, key name, and a SHA-256 content hash. They do not reveal command
|
||||||
text, credentials, or other values.
|
text, credentials, or other values.
|
||||||
|
|
||||||
|
## Pane executable preflight
|
||||||
|
|
||||||
|
The fleet install, install-systemd, and doctor commands plus the session launcher use
|
||||||
|
**pane-runtime-path.sh** as the single pane-PATH implementation. Install inspects every distinct
|
||||||
|
roster runtime and effective MOSAIC_RUNTIME_BIN pair before creating holder identity, tool,
|
||||||
|
projection, or unit files. Doctor reports the same checks as JSON.
|
||||||
|
|
||||||
|
A resolved command is not automatically executable. The helper reads a script shebang, unwraps the
|
||||||
|
common “/usr/bin/env node” and “/usr/bin/env -S node …” forms, then resolves the declared command
|
||||||
|
against the pane PATH. When Node is the declared interpreter, the helper runs the side-effect-free
|
||||||
|
“node --version” probe. It does not run “mosaic --version”, whose startup update check can write cache
|
||||||
|
state. Native binaries have no PATH-resolved shebang dependency and retain their normal executable
|
||||||
|
check. Failures name the executable or runtime, requesting roster rows, searched pane PATH,
|
||||||
|
dependency, and runtime install command.
|
||||||
|
|
||||||
|
Supported runtime install commands are:
|
||||||
|
|
||||||
|
- **Claude:** curl -fsSL https://claude.ai/install.sh | bash
|
||||||
|
- **Codex:** npm install -g @openai/codex
|
||||||
|
- **OpenCode:** npm install -g opencode-ai
|
||||||
|
- **Pi:** npm install -g @earendil-works/pi-coding-agent
|
||||||
|
|
||||||
## Launch and stop behavior
|
## Launch and stop behavior
|
||||||
|
|
||||||
The launcher obtains the agent's socket only from the validated generated projection. It creates or
|
The launcher obtains the agent's socket only from the validated generated projection. It creates or
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# #1256 — Fleet runtime preflight
|
||||||
|
|
||||||
|
**Agent:** tiny
|
||||||
|
|
||||||
|
**Branch:** `fix/1256-fleet-runtime-preflight` from `origin/next@476db12b92971634b67fd2057b7577ee5894e449`
|
||||||
|
|
||||||
|
**Issue:** `mosaicstack/stack#1256` blocker 1
|
||||||
|
|
||||||
|
**Adjacent PR:** `#1258` (`fix/1256-fleet-pane-path-node`) owns the bootstrapped-Node candidate and must remain a separate change
|
||||||
|
|
||||||
|
**Budget:** 30K-token soft cap; one bounded implementation lane
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Make fleet provisioning fail before installation effects when the roster names a runtime binary absent from the exact PATH the tmux pane will receive. Make `mosaic fleet doctor` report the same named runtime check. Diagnostics must name the runtime, every requesting roster row, the pane PATH searched, and an exact install command.
|
||||||
|
|
||||||
|
For Pi the exact command is:
|
||||||
|
|
||||||
|
```text
|
||||||
|
npm install -g @earendil-works/pi-coding-agent
|
||||||
|
```
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- TDD: add the failing behavior test and capture RED before implementation.
|
||||||
|
- Runtime resolution uses the launcher's pane-PATH construction; a second PATH model is forbidden.
|
||||||
|
- Operator PATH is non-authoritative and must not cause a false pass.
|
||||||
|
- Tests use an isolated `--mosaic-home`/temporary HOME and never mutate host runtime binaries.
|
||||||
|
- No install, removal, or binary-resolution changes on sb-it-1-dt.
|
||||||
|
- PR targets `next` and requires a reviewer other than fred.
|
||||||
|
- Commit identity is `tiny <[email protected]>`.
|
||||||
|
- #1258's Node candidate is a dependency/adjacent change, never reimplemented here.
|
||||||
|
|
||||||
|
## Planned seam
|
||||||
|
|
||||||
|
1. Factor the shell pane-path builder/resolver into one sourceable and executable fleet helper.
|
||||||
|
2. Have `start-agent-session.sh` source that helper, preserving one definition of the pane PATH.
|
||||||
|
3. Have the TypeScript fleet command invoke the same helper under the unit-equivalent clean launcher environment.
|
||||||
|
4. Group roster rows by distinct runtime and effective pane PATH, then report requesting row names.
|
||||||
|
5. Run the preflight before `installFleet` performs any write.
|
||||||
|
6. Add the named result to roster-v2 `fleet doctor` JSON and set a failing exit when a runtime is absent.
|
||||||
|
7. Install/copy the helper alongside `start-agent-session.sh` and update framework manifest/docs as required.
|
||||||
|
|
||||||
|
This seam overlaps #1258 only at the location of the existing shell function. Development may use #1258 as a local dependency, but the final PR diff must exclude #1258's separately owned Node change after that PR lands or after an agreed rebase order.
|
||||||
|
|
||||||
|
## Acceptance evidence
|
||||||
|
|
||||||
|
| Requirement | Evidence |
|
||||||
|
|---|---|
|
||||||
|
| Missing Pi blocks install before effects | isolated CLI test: nonzero + no installed files/runner effects |
|
||||||
|
| Operator PATH cannot create false green | test puts Pi only on operator PATH and omits it from constructed pane PATH |
|
||||||
|
| Exact pane PATH reused | launcher and CLI call one shared shell helper; contract test exercises both |
|
||||||
|
| Actionable diagnosis | runtime + roster rows + searched PATH + exact install command assertions |
|
||||||
|
| Distinct runtimes | repeated rows produce one check with all row names |
|
||||||
|
| Doctor reports named check | JSON assertion + nonzero exit for missing runtime |
|
||||||
|
| Present runtime passes | isolated pane-path fixture with executable binary |
|
||||||
|
| No host mutation | tests use temporary HOME/Mosaic home and fixture binaries only |
|
||||||
|
| Baseline safety | focused tests, package typecheck/lint/format, full relevant suite, CI |
|
||||||
|
|
||||||
|
## Progress log
|
||||||
|
|
||||||
|
- 2026-08-16: Dispatch received from fred; issue #1256 and PR #1258 measured.
|
||||||
|
- 2026-08-16: Fresh clone created under `~/agent-work/tiny-fleet-runtime-preflight`; local Git identity pinned to tiny so retired global `mos-dt-0` identity cannot win.
|
||||||
|
- 2026-08-16: Design inspection found the pane PATH exists only inside `start-agent-session.sh`; the right seam is a shared shell helper rather than a parallel TypeScript reconstruction.
|
||||||
|
- 2026-08-16: RED measured on `origin/next@476db12b`: focused `fleet-roster-v2-dispatch.spec.ts` ran 11 tests; the new case failed because install returned success, wrote units for two agents, and emitted no `runtime=pi` diagnosis while Pi existed only on operator PATH.
|
||||||
|
- 2026-08-16: Factored pane home/PATH/resolution into sourceable and executable `pane-runtime-path.sh`; install invokes it before the first effect, doctor emits the same named checks, and the launcher sources it.
|
||||||
|
- 2026-08-16: Fred/rhodey review exposed the #1241 name-resolution blind spot: `mosaic` can resolve while its `#!/usr/bin/env node` interpreter cannot. Measurement confirmed every supported current Mosaic package shape is a Node-shebang script, but executing `mosaic --version` is not observational because CLI startup runs the cache-writing/network update checker before Commander handles the flag.
|
||||||
|
- 2026-08-16: Final executable check reads and unwraps direct and `/usr/bin/env` shebangs (including `env -S`), resolves the declared dependency against pane PATH, and runs only side-effect-free `node --version` when Node is declared. Native binaries do not inherit a permanent Node requirement. Install, doctor, and launcher share this implementation.
|
||||||
|
- 2026-08-16: Isolated greenfield fixture places resolved Mosaic and Pi Node-shebang scripts in pane-visible npm-global bin while using an empty system suffix; both checks become `unexecutable` with `dependency=node`, and install leaves holder/tools/units absent. No host binary or HOME is changed.
|
||||||
|
- 2026-08-16: GREEN evidence before #1258 rebase: focused install/doctor/preflight suites pass; `fleet.spec.ts` 209/209; full Vitest 87 files / 1,557 tests; launcher shell suite, typecheck, lint, build, and focused format check pass. Full framework-shell reaches an unrelated host-measurement drift in unchanged `invariant_r_unittest.py` (expected Pi 0.84.1, host resolves 0.84.2); no invariant was changed in this lane.
|
||||||
|
- 2026-08-16: Merge-order gate remains: `origin/next` is still `476db12b`; #1258 is unmerged at `6dc35e5`. Rebase after it lands, relocate its Node candidate into the helper with explicit provenance, rerun gates, then open the PR to `next` for an independent non-fred review.
|
||||||
+1
-1
@@ -11,7 +11,7 @@
|
|||||||
"typecheck": "pnpm preflight && turbo run typecheck",
|
"typecheck": "pnpm preflight && turbo run typecheck",
|
||||||
"test:checkout": "node --test scripts/*.test.mjs",
|
"test:checkout": "node --test scripts/*.test.mjs",
|
||||||
"test": "pnpm test:checkout && turbo run test && pnpm run test:installer",
|
"test": "pnpm test:checkout && turbo run test && pnpm run test:installer",
|
||||||
"test:installer": "bash tools/install-next-lane.test.sh && bash tools/install-node-provisioning.test.sh && bash tools/install-newest-matching-file.test.sh",
|
"test:installer": "bash tools/install-next-lane.test.sh",
|
||||||
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"",
|
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"",
|
||||||
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,md}\"",
|
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,md}\"",
|
||||||
"prepare": "node scripts/install-hooks.mjs"
|
"prepare": "node scripts/install-hooks.mjs"
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
|
||||||
|
// homedir/platform are read at call time, so they can be stubbed per case.
|
||||||
|
vi.mock('node:os', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('node:os')>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
homedir: () => '/home/tester',
|
||||||
|
platform: () => mockPlatform,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
let mockPlatform: NodeJS.Platform = 'linux';
|
||||||
|
|
||||||
|
const { getShellProfilePath, detectShell } = await import('../../src/platform/detect.js');
|
||||||
|
|
||||||
|
describe('getShellProfilePath', () => {
|
||||||
|
const originalShell = process.env['SHELL'];
|
||||||
|
const originalZdotdir = process.env['ZDOTDIR'];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockPlatform = 'linux';
|
||||||
|
delete process.env['ZDOTDIR'];
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (originalShell === undefined) delete process.env['SHELL'];
|
||||||
|
else process.env['SHELL'] = originalShell;
|
||||||
|
if (originalZdotdir === undefined) delete process.env['ZDOTDIR'];
|
||||||
|
else process.env['ZDOTDIR'] = originalZdotdir;
|
||||||
|
});
|
||||||
|
|
||||||
|
// The regression this guards: setupPath() in stages/finalize.ts appends the
|
||||||
|
// PATH export to whatever this returns. A line written to ~/.bashrc is
|
||||||
|
// unreachable to `bash -lc`, systemd units and agent seats, because Debian's
|
||||||
|
// default .bashrc returns early for non-interactive shells — so an install
|
||||||
|
// reported success and left `mosaic: command not found`. Same for .zshrc,
|
||||||
|
// which zsh only reads for interactive shells.
|
||||||
|
it('never targets an interactive-only rc file', () => {
|
||||||
|
for (const shell of ['/bin/bash', '/usr/bin/zsh']) {
|
||||||
|
process.env['SHELL'] = shell;
|
||||||
|
const profile = getShellProfilePath();
|
||||||
|
expect(profile).not.toMatch(/\.bashrc$/);
|
||||||
|
expect(profile).not.toMatch(/\.zshrc$/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses ~/.profile for bash', () => {
|
||||||
|
process.env['SHELL'] = '/bin/bash';
|
||||||
|
expect(getShellProfilePath()).toBe('/home/tester/.profile');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses ~/.zshenv for zsh', () => {
|
||||||
|
process.env['SHELL'] = '/usr/bin/zsh';
|
||||||
|
expect(getShellProfilePath()).toBe('/home/tester/.zshenv');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('honours ZDOTDIR for zsh', () => {
|
||||||
|
process.env['SHELL'] = '/usr/bin/zsh';
|
||||||
|
process.env['ZDOTDIR'] = '/custom/zdot';
|
||||||
|
expect(getShellProfilePath()).toBe('/custom/zdot/.zshenv');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to ~/.profile for an unknown shell', () => {
|
||||||
|
process.env['SHELL'] = '/bin/somethingelse';
|
||||||
|
expect(detectShell()).toBe('unknown');
|
||||||
|
expect(getShellProfilePath()).toBe('/home/tester/.profile');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still routes fish to its own config', () => {
|
||||||
|
process.env['SHELL'] = '/usr/bin/fish';
|
||||||
|
expect(getShellProfilePath()).toBe('/home/tester/.config/fish/config.fish');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -35,6 +35,18 @@ SOURCE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|||||||
TARGET_DIR="${MOSAIC_HOME:-$HOME/.config/mosaic}"
|
TARGET_DIR="${MOSAIC_HOME:-$HOME/.config/mosaic}"
|
||||||
INSTALL_MODE="${MOSAIC_INSTALL_MODE:-prompt}"
|
INSTALL_MODE="${MOSAIC_INSTALL_MODE:-prompt}"
|
||||||
|
|
||||||
|
# Normalize the ambient umask so directory modes are a property of the installer
|
||||||
|
# and not of whatever shell invoked it (#1236). Debian/Ubuntu ship umask 002, so
|
||||||
|
# every `mkdir -p` below yielded 0775 — and the fleet env boundary rejects any
|
||||||
|
# managed directory with `mode & 0o022`, which made `mosaic fleet init --write`
|
||||||
|
# impossible on a stock install of those distros. Fedora/RHEL ship 022 and did
|
||||||
|
# not trip it, so the product worked or did not depending on the operator's
|
||||||
|
# login shell. 022 is what this script already assumes it produces: see the
|
||||||
|
# umask note in make_durable_snapshot, which restores to the ambient value
|
||||||
|
# precisely so "every later sync copy and new framework dir" gets 0644/0755.
|
||||||
|
# Now that value is 022 rather than whatever was inherited.
|
||||||
|
umask 022
|
||||||
|
|
||||||
# Deliberately parsed from "$@" (a real, explicit, per-invocation argument) —
|
# Deliberately parsed from "$@" (a real, explicit, per-invocation argument) —
|
||||||
# never an environment variable — so this opt-out can never sit silently
|
# never an environment variable — so this opt-out can never sit silently
|
||||||
# inherited in a shell profile. See #869 Point-1 C2.
|
# inherited in a shell profile. See #869 Point-1 C2.
|
||||||
@@ -696,6 +708,52 @@ sync_framework
|
|||||||
mkdir -p "$TARGET_DIR/memory"
|
mkdir -p "$TARGET_DIR/memory"
|
||||||
mkdir -p "$TARGET_DIR/credentials"
|
mkdir -p "$TARGET_DIR/credentials"
|
||||||
|
|
||||||
|
# Three directories must be 0700, not merely not-group-writable (#1236).
|
||||||
|
# The fleet code guards them with two different masks in two different
|
||||||
|
# languages, and the strict one wins:
|
||||||
|
#
|
||||||
|
# assertPrivateManagedDirectory (fleet-reconciler.js, `mode & 0o077`)
|
||||||
|
# -> MOSAIC_HOME and MOSAIC_HOME/fleet, checked before the roster lock is
|
||||||
|
# taken, so every mutating `mosaic fleet` command dies at 0755.
|
||||||
|
# assert_private_directory (tools/fleet/start-agent-session.sh, `mode & 077`)
|
||||||
|
# -> MOSAIC_HOME/fleet/agents, checked before a pane is ever spawned.
|
||||||
|
#
|
||||||
|
# Their laxer siblings (`mode & 0o022`) accept 0755, which is why normalizing
|
||||||
|
# the umask above is necessary and not sufficient — a correct umask-022 install
|
||||||
|
# still produces 0755 and still cannot run `mosaic fleet init --write`. Say the
|
||||||
|
# strict modes outright rather than inferring them from a umask.
|
||||||
|
#
|
||||||
|
# Only these. The rest of the tree is content, stays 0755, and is only ever
|
||||||
|
# reached by the 0o022 checks, which 0755 satisfies.
|
||||||
|
chmod 700 "$TARGET_DIR" 2>/dev/null || \
|
||||||
|
warn "Could not set 0700 on $TARGET_DIR — 'mosaic fleet' mutations will fail as unsafe-permissions."
|
||||||
|
if [[ -d "$TARGET_DIR/fleet" ]]; then
|
||||||
|
chmod 700 "$TARGET_DIR/fleet" 2>/dev/null || \
|
||||||
|
warn "Could not set 0700 on $TARGET_DIR/fleet — 'mosaic fleet' mutations will fail as unsafe-permissions."
|
||||||
|
fi
|
||||||
|
# fleet/agents does not exist on a first install — the CLI creates it 0700 on
|
||||||
|
# demand. It is chmod'd here for the UPGRADE case: a tree built under umask 002
|
||||||
|
# has it at 0775, and the repair sweep below cannot rescue it, because stripping
|
||||||
|
# group/other write from 0755 leaves 0750 and `mode & 077` is still non-zero.
|
||||||
|
if [[ -d "$TARGET_DIR/fleet/agents" ]]; then
|
||||||
|
chmod 700 "$TARGET_DIR/fleet/agents" 2>/dev/null || \
|
||||||
|
warn "Could not set 0700 on $TARGET_DIR/fleet/agents — agent sessions will fail to start as unsafe-permissions."
|
||||||
|
fi
|
||||||
|
# credentials/ holds secrets and was never meant to be group-readable either.
|
||||||
|
# It is not on the fleet boundary, so a failure here breaks nothing — but it is
|
||||||
|
# the one directory where a silently-failed chmod leaves secrets group-readable,
|
||||||
|
# which is precisely the failure worth a line in the output.
|
||||||
|
chmod 700 "$TARGET_DIR/credentials" 2>/dev/null || \
|
||||||
|
warn "Could not set 0700 on $TARGET_DIR/credentials — stored secrets may be readable by other users on this host."
|
||||||
|
|
||||||
|
# Repair an existing tree. The umask above only governs directories this run
|
||||||
|
# creates, so a host installed under umask 002 before this fix keeps its 0775
|
||||||
|
# dirs through every upgrade and stays broken. Strips group/other WRITE only —
|
||||||
|
# never read or execute — so it can repair the boundary violation without
|
||||||
|
# changing who can traverse or read anything. Scoped to directories: file modes
|
||||||
|
# are the manifest's business, not this fix's.
|
||||||
|
find "$TARGET_DIR" -type d -perm /022 -exec chmod go-w {} + 2>/dev/null || true
|
||||||
|
|
||||||
# Reconcile contract files from defaults/ into the framework root: framework-owned
|
# Reconcile contract files from defaults/ into the framework root: framework-owned
|
||||||
# files (CONSTITUTION/AGENTS/STANDARDS) are overwritten every upgrade (a divergent
|
# files (CONSTITUTION/AGENTS/STANDARDS) are overwritten every upgrade (a divergent
|
||||||
# copy is backed up once); user-seeded files (TOOLS) are written on first install only.
|
# copy is backed up once); user-seeded files (TOOLS) are written on first install only.
|
||||||
|
|||||||
@@ -51,8 +51,12 @@ See `docs/fleet/reference/generated-env-boundary.md` for the full contract.
|
|||||||
## Manual canary sequence
|
## Manual canary sequence
|
||||||
|
|
||||||
Use the roster and the supported installer; do not pre-create the agent environment directory or
|
Use the roster and the supported installer; do not pre-create the agent environment directory or
|
||||||
edit a generated projection. `mosaic fleet install` validates the roster, installs the units and
|
edit a generated projection. Before it writes any holder identity, tool, projection, or unit file,
|
||||||
helpers, and writes private roster-derived projections before any service is started.
|
`mosaic fleet install` validates the fleet CLI and every distinct roster runtime through the exact
|
||||||
|
pane PATH. The shared helper also unwraps `/usr/bin/env` shebangs, so a resolved Node script with no
|
||||||
|
pane-visible Node fails before effects. `mosaic fleet doctor` reports the same named executable
|
||||||
|
checks without mutation. After that preflight, install places the units and helpers and writes private
|
||||||
|
roster-derived projections before any service starts.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Create a site-owned canary roster. Inspect an existing roster before using --force.
|
# Create a site-owned canary roster. Inspect an existing roster before using --force.
|
||||||
|
|||||||
@@ -4,6 +4,14 @@ Documentation=https://git.mosaicstack.dev/mosaicstack/stack
|
|||||||
Requires=mosaic-tmux-holder.service
|
Requires=mosaic-tmux-holder.service
|
||||||
After=mosaic-tmux-holder.service
|
After=mosaic-tmux-holder.service
|
||||||
PartOf=mosaic-tmux-holder.service
|
PartOf=mosaic-tmux-holder.service
|
||||||
|
# Do not attempt a seat before its generated env exists. `install` enables this
|
||||||
|
# unit (WantedBy=default.target) but on a roster-v2 fleet the reconciler owns the
|
||||||
|
# generated env, so between `install` and the first `apply`/`regen --write` there
|
||||||
|
# is a boot window where ExecStart would run against an absent env file and the
|
||||||
|
# launcher would fail the unit. A skipped unit is the honest state for "enabled
|
||||||
|
# but not yet configured"; systemd re-evaluates the condition on every start, so
|
||||||
|
# the seat comes up on the next start once the reconciler has written env.
|
||||||
|
ConditionPathExists=%h/.config/mosaic/fleet/agents/%i.env.generated
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=oneshot
|
Type=oneshot
|
||||||
|
|||||||
@@ -128,6 +128,14 @@ EOF
|
|||||||
sleep 30
|
sleep 30
|
||||||
EOF
|
EOF
|
||||||
chmod 700 "$AGENT_BIN/mosaic"
|
chmod 700 "$AGENT_BIN/mosaic"
|
||||||
|
# The launcher resolves the roster's runtime against PANE_PATH before it
|
||||||
|
# spawns anything (#1241), so the runtime this projection names has to be
|
||||||
|
# present here even though the fake `mosaic` above never execs it.
|
||||||
|
cat > "$AGENT_BIN/pi" <<'EOF'
|
||||||
|
#!/bin/sh
|
||||||
|
sleep 30
|
||||||
|
EOF
|
||||||
|
chmod 700 "$AGENT_BIN/pi"
|
||||||
server_environment_before=$(tmux -L "$TEST_SOCKET" show-environment -g | sort)
|
server_environment_before=$(tmux -L "$TEST_SOCKET" show-environment -g | sort)
|
||||||
server_sessions_before=$(tmux -L "$TEST_SOCKET" list-sessions | sort)
|
server_sessions_before=$(tmux -L "$TEST_SOCKET" list-sessions | sort)
|
||||||
if /usr/bin/env -i HOME="$HOLDER_HOME" PATH=/usr/bin:/bin MOSAIC_HOME="$AGENT_HOME" \
|
if /usr/bin/env -i HOME="$HOLDER_HOME" PATH=/usr/bin:/bin MOSAIC_HOME="$AGENT_HOME" \
|
||||||
|
|||||||
@@ -225,6 +225,54 @@ else
|
|||||||
warn "mosaic-ensure-sequential-thinking helper missing"
|
warn "mosaic-ensure-sequential-thinking helper missing"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Fleet transport binary (#1240).
|
||||||
|
#
|
||||||
|
# `mosaic fleet --help` reads "Manage the local Mosaic tmux fleet" and every
|
||||||
|
# roster the CLI scaffolds sets `transport: tmux`, but nothing in the install
|
||||||
|
# path provides tmux and, until now, nothing here noticed it was absent. On a
|
||||||
|
# greenfield host that produced a fleet which installed clean, started clean,
|
||||||
|
# and had no live seat; `mosaic fleet ps` was the operator's first and only
|
||||||
|
# signal that anything was wrong.
|
||||||
|
#
|
||||||
|
# The roster's own `transport:` is read rather than assumed, so a host that
|
||||||
|
# declares something other than tmux is told about the binary it actually
|
||||||
|
# needs. Absent a roster the check still runs — `mosaic fleet init` will
|
||||||
|
# scaffold a tmux fleet on this host, and finding out beforehand is the point.
|
||||||
|
#
|
||||||
|
# `tools/install.sh` carries a deliberately parallel check at the end of its
|
||||||
|
# summary. The two are separate because the installer must be able to say this
|
||||||
|
# before the framework's own scripts are guaranteed to be on disk; keep their
|
||||||
|
# wording in step.
|
||||||
|
fleet_declared_transport() {
|
||||||
|
local roster="$MOSAIC_HOME/fleet/roster.yaml"
|
||||||
|
local declared=""
|
||||||
|
|
||||||
|
if [[ -f "$roster" ]]; then
|
||||||
|
declared="$(sed -n 's/^[[:space:]]*transport:[[:space:]]*//p' "$roster" | head -1 |
|
||||||
|
tr -d '"'\''' | tr -d '\r' | awk '{print $1}')"
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '%s\n' "${declared:-tmux}"
|
||||||
|
}
|
||||||
|
|
||||||
|
check_fleet_transport() {
|
||||||
|
local transport
|
||||||
|
transport="$(fleet_declared_transport)"
|
||||||
|
|
||||||
|
if command -v "$transport" >/dev/null 2>&1; then
|
||||||
|
pass "Fleet transport available: $transport"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -f "$MOSAIC_HOME/fleet/roster.yaml" ]]; then
|
||||||
|
warn "Fleet transport '$transport' is not installed — this host has a roster and no seat can launch. Install it (e.g. sudo apt-get install -y $transport), then 'mosaic fleet start'."
|
||||||
|
else
|
||||||
|
warn "Fleet transport '$transport' is not installed — 'mosaic fleet' cannot run seats here. Install it (e.g. sudo apt-get install -y $transport) before 'mosaic fleet init'."
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
check_fleet_transport
|
||||||
|
|
||||||
# Legacy migration surfaces should no longer contain symlink trees.
|
# Legacy migration surfaces should no longer contain symlink trees.
|
||||||
legacy_paths=(
|
legacy_paths=(
|
||||||
"$HOME/.claude/agent-guides"
|
"$HOME/.claude/agent-guides"
|
||||||
|
|||||||
@@ -0,0 +1,215 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Covers the #1240 fleet-transport checks in `mosaic-doctor` and in
|
||||||
|
# `tools/install.sh`.
|
||||||
|
#
|
||||||
|
# Both checks answer the same question — "can a seat actually launch on this
|
||||||
|
# host?" — from two different places, because the installer has to be able to
|
||||||
|
# answer it before the framework's own scripts are guaranteed to be on disk.
|
||||||
|
# Two implementations of one rule is exactly the shape that drifts, so this
|
||||||
|
# harness drives BOTH, in one file, from the same table of cases.
|
||||||
|
#
|
||||||
|
# The functions are extracted from the shipped scripts rather than copied here.
|
||||||
|
# A test that carries its own copy of the logic is a test that keeps passing
|
||||||
|
# after the shipped copy changes — the failure mode this whole change is about.
|
||||||
|
# Extraction is by exact function header and a closing brace in column one; if
|
||||||
|
# either script is reshaped so that stops matching, the extraction yields
|
||||||
|
# nothing and this fails loudly instead of silently measuring an empty string.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR=$(cd -- "$(dirname -- "$0")" && pwd)
|
||||||
|
DOCTOR="$SCRIPT_DIR/mosaic-doctor"
|
||||||
|
# framework/tools/_scripts -> framework/tools -> framework -> mosaic -> packages -> repo
|
||||||
|
INSTALLER=$(cd -- "$SCRIPT_DIR/../../../../.." && pwd)/tools/install.sh
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
echo "FAIL: $*" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
[ -f "$DOCTOR" ] || fail "missing mosaic-doctor at $DOCTOR"
|
||||||
|
[ -f "$INSTALLER" ] || fail "missing install.sh at $INSTALLER"
|
||||||
|
|
||||||
|
ROOT=$(mktemp -d)
|
||||||
|
trap 'rm -rf "$ROOT"' EXIT
|
||||||
|
|
||||||
|
# The cases below run with PATH set to a directory that deliberately does not
|
||||||
|
# contain a shell, and a PATH assignment on a command also governs how that
|
||||||
|
# command is looked up — so bash has to be named absolutely or it becomes the
|
||||||
|
# thing that is missing.
|
||||||
|
BASH_BIN=$(command -v bash) || fail "host is missing 'bash'"
|
||||||
|
|
||||||
|
# A PATH containing exactly the utilities these functions use and nothing else.
|
||||||
|
# The absent-transport cases are only meaningful on a PATH where the transport
|
||||||
|
# is genuinely unresolvable, and this host (like most) has tmux in /usr/bin —
|
||||||
|
# so the system path cannot be part of the path under test.
|
||||||
|
FAKE_BIN="$ROOT/bin"
|
||||||
|
mkdir -p "$FAKE_BIN"
|
||||||
|
for utility in sed head tr awk; do
|
||||||
|
utility_path=$(command -v "$utility") || fail "host is missing '$utility'"
|
||||||
|
ln -s "$utility_path" "$FAKE_BIN/$utility"
|
||||||
|
done
|
||||||
|
|
||||||
|
if PATH="$FAKE_BIN" command -v tmux >/dev/null 2>&1; then
|
||||||
|
fail "'tmux' is resolvable on the minimal test path; absent-transport cases are not measurable"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Extract a function by its exact header, up to a closing brace in column one.
|
||||||
|
extract_function() {
|
||||||
|
local source_file="$1"
|
||||||
|
local function_name="$2"
|
||||||
|
local destination="$3"
|
||||||
|
|
||||||
|
awk -v name="$function_name" '
|
||||||
|
$0 == name "() {" { collecting = 1 }
|
||||||
|
collecting { print }
|
||||||
|
collecting && $0 == "}" { exit }
|
||||||
|
' "$source_file" > "$destination"
|
||||||
|
|
||||||
|
grep -qF "$function_name() {" "$destination" ||
|
||||||
|
fail "could not extract '$function_name' from $source_file — has it been renamed or reshaped?"
|
||||||
|
# An unterminated extraction would be a syntax error the moment it is sourced,
|
||||||
|
# but saying so here names the cause instead of leaving a bash parse error.
|
||||||
|
bash -n "$destination" ||
|
||||||
|
fail "extracted '$function_name' does not parse; the closing brace was probably not found"
|
||||||
|
}
|
||||||
|
|
||||||
|
extract_function "$DOCTOR" fleet_declared_transport "$ROOT/doctor-declared.sh"
|
||||||
|
extract_function "$DOCTOR" check_fleet_transport "$ROOT/doctor-check.sh"
|
||||||
|
extract_function "$INSTALLER" check_fleet_transport "$ROOT/installer-check.sh"
|
||||||
|
|
||||||
|
# Build a MOSAIC_HOME, optionally with a roster declaring a transport.
|
||||||
|
make_home() {
|
||||||
|
local home="$ROOT/$1"
|
||||||
|
local declared="${2-}"
|
||||||
|
|
||||||
|
rm -rf "$home"
|
||||||
|
mkdir -p "$home"
|
||||||
|
if [ -n "$declared" ]; then
|
||||||
|
mkdir -p "$home/fleet"
|
||||||
|
cat > "$home/fleet/roster.yaml" <<EOF
|
||||||
|
version: 2
|
||||||
|
generation: 1
|
||||||
|
transport: $declared
|
||||||
|
agents: []
|
||||||
|
EOF
|
||||||
|
fi
|
||||||
|
printf '%s\n' "$home"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Run the doctor's check against a given home and path, capturing which
|
||||||
|
# reporter the check chose. The real `pass` prints only under `--verbose` and
|
||||||
|
# the real `warn` always prints; these stubs make both unconditional on
|
||||||
|
# purpose, because what is under test is the severity the check selects, not
|
||||||
|
# whether the default verbosity happens to show it. A check that warned where
|
||||||
|
# it should pass would otherwise be invisible here.
|
||||||
|
run_doctor_check() {
|
||||||
|
local home="$1"
|
||||||
|
local path="$2"
|
||||||
|
|
||||||
|
MOSAIC_HOME="$home" PATH="$path" "$BASH_BIN" --noprofile --norc -c '
|
||||||
|
set -euo pipefail
|
||||||
|
warn() { echo "[WARN] $*"; }
|
||||||
|
pass() { echo "[OK] $*"; }
|
||||||
|
MOSAIC_HOME="$1"
|
||||||
|
source "$2"
|
||||||
|
source "$3"
|
||||||
|
check_fleet_transport
|
||||||
|
' _ "$home" "$ROOT/doctor-declared.sh" "$ROOT/doctor-check.sh" 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
|
run_installer_check() {
|
||||||
|
local home="$1"
|
||||||
|
local path="$2"
|
||||||
|
|
||||||
|
MOSAIC_HOME="$home" PATH="$path" "$BASH_BIN" --noprofile --norc -c '
|
||||||
|
set -euo pipefail
|
||||||
|
warn() { echo "[WARN] $*"; }
|
||||||
|
C="" RESET=""
|
||||||
|
MOSAIC_HOME="$1"
|
||||||
|
source "$2"
|
||||||
|
check_fleet_transport
|
||||||
|
' _ "$home" "$ROOT/installer-check.sh" 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
|
# A transport that exists. Named tmux because that is what the default roster
|
||||||
|
# declares; the binary never runs, it only has to resolve.
|
||||||
|
PRESENT_BIN="$ROOT/present-bin"
|
||||||
|
mkdir -p "$PRESENT_BIN"
|
||||||
|
printf '#!/usr/bin/env bash\nexit 0\n' > "$PRESENT_BIN/tmux"
|
||||||
|
chmod +x "$PRESENT_BIN/tmux"
|
||||||
|
PATH_WITH_TMUX="$PRESENT_BIN:$FAKE_BIN"
|
||||||
|
|
||||||
|
# ── absent, no roster ────────────────────────────────────────────────────────
|
||||||
|
# Nothing has been configured yet, so the honest thing to point at is `init`.
|
||||||
|
home=$(make_home no-roster)
|
||||||
|
|
||||||
|
output=$(run_doctor_check "$home" "$FAKE_BIN")
|
||||||
|
echo "$output" | grep -qF '[WARN]' || fail "doctor did not warn when tmux was absent"
|
||||||
|
echo "$output" | grep -qF 'tmux' || fail "doctor warning did not name the transport"
|
||||||
|
echo "$output" | grep -qF 'mosaic fleet init' || fail "doctor did not point a rosterless host at init"
|
||||||
|
|
||||||
|
output=$(run_installer_check "$home" "$FAKE_BIN")
|
||||||
|
echo "$output" | grep -qF '[WARN]' || fail "installer did not warn when tmux was absent"
|
||||||
|
echo "$output" | grep -qF 'reports success and no seat comes up' ||
|
||||||
|
fail "installer warning did not say what the missing transport actually breaks"
|
||||||
|
|
||||||
|
# ── absent, roster present ───────────────────────────────────────────────────
|
||||||
|
# A configured fleet that cannot launch is a stronger statement than a
|
||||||
|
# hypothetical one, and the message says so.
|
||||||
|
home=$(make_home with-roster tmux)
|
||||||
|
|
||||||
|
output=$(run_doctor_check "$home" "$FAKE_BIN")
|
||||||
|
echo "$output" | grep -qF '[WARN]' || fail "doctor did not warn with a roster present and tmux absent"
|
||||||
|
echo "$output" | grep -qF 'roster' || fail "doctor did not mention the roster it found"
|
||||||
|
echo "$output" | grep -qF 'mosaic fleet start' || fail "doctor did not point a configured host at start"
|
||||||
|
|
||||||
|
# ── present ──────────────────────────────────────────────────────────────────
|
||||||
|
# Silence from the installer, and a pass (not a warning) from the audit.
|
||||||
|
for home_name in no-roster with-roster; do
|
||||||
|
home="$ROOT/$home_name"
|
||||||
|
|
||||||
|
output=$(run_doctor_check "$home" "$PATH_WITH_TMUX")
|
||||||
|
if echo "$output" | grep -qF '[WARN]'; then
|
||||||
|
fail "doctor warned about the transport while tmux was present ($home_name)"
|
||||||
|
fi
|
||||||
|
echo "$output" | grep -qF '[OK]' || fail "doctor did not record a pass with tmux present ($home_name)"
|
||||||
|
|
||||||
|
output=$(run_installer_check "$home" "$PATH_WITH_TMUX")
|
||||||
|
if [ -n "$output" ]; then
|
||||||
|
fail "installer was not silent with tmux present ($home_name): $output"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# ── the roster declares something other than tmux ────────────────────────────
|
||||||
|
# The roster is read, not assumed. A host that declares a different transport
|
||||||
|
# is told about the binary it actually needs, and never about tmux — being sent
|
||||||
|
# to install the wrong package is worse than no advice at all.
|
||||||
|
home=$(make_home other-transport zellij)
|
||||||
|
|
||||||
|
output=$(run_doctor_check "$home" "$PATH_WITH_TMUX")
|
||||||
|
echo "$output" | grep -qF 'zellij' || fail "doctor ignored the roster's declared transport"
|
||||||
|
if echo "$output" | grep -qF 'tmux'; then
|
||||||
|
fail "doctor named tmux for a host whose roster declares zellij"
|
||||||
|
fi
|
||||||
|
|
||||||
|
output=$(run_installer_check "$home" "$PATH_WITH_TMUX")
|
||||||
|
echo "$output" | grep -qF 'zellij' || fail "installer ignored the roster's declared transport"
|
||||||
|
if echo "$output" | grep -qF 'tmux'; then
|
||||||
|
fail "installer named tmux for a host whose roster declares zellij"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── a quoted or trailing-comment transport value ─────────────────────────────
|
||||||
|
# YAML permits both and neither is exotic; a check that installs `tmux"` or
|
||||||
|
# reads `tmux # default` as a binary name would send the operator nowhere.
|
||||||
|
home=$(make_home quoted-transport '"tmux" # the only transport today')
|
||||||
|
|
||||||
|
output=$(run_doctor_check "$home" "$PATH_WITH_TMUX")
|
||||||
|
echo "$output" | grep -qF '[OK] Fleet transport available: tmux' ||
|
||||||
|
fail "doctor did not parse a quoted/commented transport value: $output"
|
||||||
|
|
||||||
|
output=$(run_installer_check "$home" "$PATH_WITH_TMUX")
|
||||||
|
if [ -n "$output" ]; then
|
||||||
|
fail "installer did not parse a quoted/commented transport value: $output"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "ok - fleet transport checks (mosaic-doctor + install.sh)"
|
||||||
+199
@@ -0,0 +1,199 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Canonical fleet-pane PATH construction and executable reachability checks.
|
||||||
|
#
|
||||||
|
# This file is both sourceable by start-agent-session.sh and executable by the
|
||||||
|
# fleet CLI preflight. Keep the pane PATH in one implementation: provisioning
|
||||||
|
# checks and the eventual pane must answer the same question.
|
||||||
|
|
||||||
|
mosaic_fleet_pane_home() {
|
||||||
|
local mosaic_home="$1"
|
||||||
|
local fallback_home="$2"
|
||||||
|
case "$mosaic_home" in
|
||||||
|
*/.config/mosaic) printf '%s' "${mosaic_home%/.config/mosaic}" ;;
|
||||||
|
*) printf '%s' "$fallback_home" ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
mosaic_fleet_build_runtime_bin_prefix() {
|
||||||
|
local pane_home="$1"
|
||||||
|
local runtime_bin="${2:-}"
|
||||||
|
local candidates=()
|
||||||
|
if [ -n "$runtime_bin" ]; then candidates+=("$runtime_bin"); fi
|
||||||
|
if command -v npm >/dev/null 2>&1; then
|
||||||
|
local npm_prefix
|
||||||
|
npm_prefix=$(npm config get prefix 2>/dev/null) || true
|
||||||
|
if [ -n "$npm_prefix" ]; then candidates+=("${npm_prefix}/bin"); fi
|
||||||
|
fi
|
||||||
|
candidates+=("$pane_home/.npm-global/bin" "$pane_home/.local/bin")
|
||||||
|
|
||||||
|
local prefix="" dir
|
||||||
|
for dir in "${candidates[@]}"; do
|
||||||
|
[ -d "$dir" ] || continue
|
||||||
|
case ":${prefix}:" in *":${dir}:"*) ;; *) prefix="${prefix:+$prefix:}$dir" ;; esac
|
||||||
|
done
|
||||||
|
printf '%s' "$prefix"
|
||||||
|
}
|
||||||
|
|
||||||
|
mosaic_fleet_build_pane_path() {
|
||||||
|
local pane_home="$1"
|
||||||
|
local runtime_bin="${2:-}"
|
||||||
|
local system_path="${3:-/usr/local/bin:/usr/bin:/bin}"
|
||||||
|
local prefix
|
||||||
|
prefix=$(mosaic_fleet_build_runtime_bin_prefix "$pane_home" "$runtime_bin")
|
||||||
|
printf '%s' "${prefix:+${prefix}:}${system_path}"
|
||||||
|
}
|
||||||
|
|
||||||
|
mosaic_fleet_resolve_in_pane_path() {
|
||||||
|
local pane_path="$1"
|
||||||
|
local binary="$2"
|
||||||
|
PATH="$pane_path" command -v -- "$binary" 2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
# Sets executable evidence in MOSAIC_FLEET_EXECUTABLE_* and returns nonzero when
|
||||||
|
# a resolved script's shebang interpreter cannot run in the pane. Native/ELF
|
||||||
|
# binaries have no PATH-resolved interpreter dependency and pass the executable
|
||||||
|
# bit check. Node receives an additional side-effect-free `node --version`
|
||||||
|
# execution check; invoking `mosaic --version` itself is intentionally avoided
|
||||||
|
# because Mosaic performs a cache-writing/network update check at CLI startup.
|
||||||
|
mosaic_fleet_check_resolved_executable() {
|
||||||
|
local pane_path="$1"
|
||||||
|
local resolved="$2"
|
||||||
|
MOSAIC_FLEET_EXECUTABLE_DEPENDENCY=""
|
||||||
|
MOSAIC_FLEET_EXECUTABLE_PROBE=""
|
||||||
|
MOSAIC_FLEET_EXECUTABLE_EXIT=""
|
||||||
|
MOSAIC_FLEET_EXECUTABLE_OUTPUT=""
|
||||||
|
|
||||||
|
[ -x "$resolved" ] || {
|
||||||
|
MOSAIC_FLEET_EXECUTABLE_OUTPUT="resolved path is not executable"
|
||||||
|
return 70
|
||||||
|
}
|
||||||
|
|
||||||
|
local magic=""
|
||||||
|
IFS= read -r -n 2 magic < "$resolved" || true
|
||||||
|
[ "$magic" = '#!' ] || return 0
|
||||||
|
|
||||||
|
local shebang
|
||||||
|
IFS= read -r shebang < "$resolved" || true
|
||||||
|
shebang=${shebang%$'\r'}
|
||||||
|
shebang=${shebang#\#!}
|
||||||
|
local parts=()
|
||||||
|
read -r -a parts <<< "$shebang"
|
||||||
|
local interpreter="${parts[0]:-}"
|
||||||
|
[[ "$interpreter" = /* ]] && [ -x "$interpreter" ] || {
|
||||||
|
MOSAIC_FLEET_EXECUTABLE_DEPENDENCY="$interpreter"
|
||||||
|
MOSAIC_FLEET_EXECUTABLE_OUTPUT="shebang interpreter is absent or not executable"
|
||||||
|
return 70
|
||||||
|
}
|
||||||
|
|
||||||
|
local dependency="$interpreter"
|
||||||
|
local dependency_path="$interpreter"
|
||||||
|
if [ "${interpreter##*/}" = env ]; then
|
||||||
|
local index=1
|
||||||
|
if [ "${parts[$index]:-}" = -S ]; then index=$((index + 1)); fi
|
||||||
|
dependency="${parts[$index]:-}"
|
||||||
|
if [ -z "$dependency" ] || [[ "$dependency" = -* ]]; then
|
||||||
|
MOSAIC_FLEET_EXECUTABLE_DEPENDENCY="$dependency"
|
||||||
|
MOSAIC_FLEET_EXECUTABLE_OUTPUT="unsupported env shebang"
|
||||||
|
return 70
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
MOSAIC_FLEET_EXECUTABLE_DEPENDENCY="$dependency"
|
||||||
|
if [ "${dependency##*/}" = node ]; then
|
||||||
|
MOSAIC_FLEET_EXECUTABLE_PROBE="node --version"
|
||||||
|
fi
|
||||||
|
if [ "${interpreter##*/}" = env ]; then
|
||||||
|
if ! dependency_path=$(mosaic_fleet_resolve_in_pane_path "$pane_path" "$dependency"); then
|
||||||
|
MOSAIC_FLEET_EXECUTABLE_OUTPUT="shebang command is not on the pane PATH"
|
||||||
|
return 70
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "${dependency##*/}" = node ]; then
|
||||||
|
if MOSAIC_FLEET_EXECUTABLE_OUTPUT=$(PATH="$pane_path" "$dependency_path" --version 2>&1); then
|
||||||
|
MOSAIC_FLEET_EXECUTABLE_EXIT=0
|
||||||
|
else
|
||||||
|
MOSAIC_FLEET_EXECUTABLE_EXIT=$?
|
||||||
|
return 70
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
mosaic_fleet_runtime_path_main() {
|
||||||
|
local mosaic_home=""
|
||||||
|
local runtime_bin=""
|
||||||
|
local system_path="/usr/local/bin:/usr/bin:/bin"
|
||||||
|
local binary=""
|
||||||
|
local check_executable=0
|
||||||
|
|
||||||
|
while [ "$#" -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--mosaic-home)
|
||||||
|
[ "$#" -ge 2 ] || return 64
|
||||||
|
mosaic_home="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--runtime-bin)
|
||||||
|
[ "$#" -ge 2 ] || return 64
|
||||||
|
runtime_bin="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--binary)
|
||||||
|
[ "$#" -ge 2 ] || return 64
|
||||||
|
binary="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--check-executable)
|
||||||
|
check_executable=1
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
# Test seam for measuring a greenfield host with no system Node. The
|
||||||
|
# launcher and production CLI omit it and retain the fixed system suffix.
|
||||||
|
--system-path)
|
||||||
|
[ "$#" -ge 2 ] || return 64
|
||||||
|
system_path="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
*) return 64 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
[ -n "$mosaic_home" ] && [ -n "$binary" ] || return 64
|
||||||
|
local pane_home pane_path resolved
|
||||||
|
pane_home=$(mosaic_fleet_pane_home "$mosaic_home" "${HOME:-}")
|
||||||
|
# npm config is HOME-sensitive. Pin it to the derived pane home before asking
|
||||||
|
# for its prefix so an operator's unrelated npmrc cannot influence preflight.
|
||||||
|
HOME=$pane_home
|
||||||
|
export HOME
|
||||||
|
pane_path=$(mosaic_fleet_build_pane_path "$pane_home" "$runtime_bin" "$system_path")
|
||||||
|
if ! resolved=$(mosaic_fleet_resolve_in_pane_path "$pane_path" "$binary"); then
|
||||||
|
printf 'pane_path\0%s\0status\0missing\0binary_path\0\0dependency\0\0probe_command\0\0probe_exit\0\0probe_output\0\0' \
|
||||||
|
"$pane_path"
|
||||||
|
return 69
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$check_executable" -eq 1 ]; then
|
||||||
|
if mosaic_fleet_check_resolved_executable "$pane_path" "$resolved"; then
|
||||||
|
printf 'pane_path\0%s\0status\0present\0binary_path\0%s\0dependency\0%s\0probe_command\0%s\0probe_exit\0%s\0probe_output\0%s\0' \
|
||||||
|
"$pane_path" "$resolved" "$MOSAIC_FLEET_EXECUTABLE_DEPENDENCY" \
|
||||||
|
"$MOSAIC_FLEET_EXECUTABLE_PROBE" "$MOSAIC_FLEET_EXECUTABLE_EXIT" \
|
||||||
|
"$MOSAIC_FLEET_EXECUTABLE_OUTPUT"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
printf 'pane_path\0%s\0status\0unexecutable\0binary_path\0%s\0dependency\0%s\0probe_command\0%s\0probe_exit\0%s\0probe_output\0%s\0' \
|
||||||
|
"$pane_path" "$resolved" "$MOSAIC_FLEET_EXECUTABLE_DEPENDENCY" \
|
||||||
|
"$MOSAIC_FLEET_EXECUTABLE_PROBE" "$MOSAIC_FLEET_EXECUTABLE_EXIT" \
|
||||||
|
"$MOSAIC_FLEET_EXECUTABLE_OUTPUT"
|
||||||
|
return 70
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf 'pane_path\0%s\0status\0present\0binary_path\0%s\0dependency\0\0probe_command\0\0probe_exit\0\0probe_output\0\0' \
|
||||||
|
"$pane_path" "$resolved"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
|
||||||
|
set -euo pipefail
|
||||||
|
mosaic_fleet_runtime_path_main "$@"
|
||||||
|
fi
|
||||||
@@ -258,33 +258,46 @@ if _tmux has-session -t "=${AGENT_NAME}:0.0" 2>/dev/null; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Systemd passes HOME as %h, and the installed service fixes MOSAIC_HOME under
|
# Systemd passes HOME as %h, and the installed service fixes MOSAIC_HOME under
|
||||||
# that home. Derive the pane home from the canonical path when available so an
|
# that home. The provisioning preflight executes this same helper under the
|
||||||
# inherited pane/session HOME cannot become runtime authority.
|
# unit's clean launcher environment, so operator PATH cannot produce a false
|
||||||
PANE_HOME=$HOME
|
# green result for a binary the pane will never see.
|
||||||
case "$MOSAIC_HOME" in
|
SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
|
||||||
*/.config/mosaic) PANE_HOME=${MOSAIC_HOME%/.config/mosaic} ;;
|
# shellcheck source=pane-runtime-path.sh
|
||||||
esac
|
. "$SCRIPT_DIR/pane-runtime-path.sh"
|
||||||
|
PANE_HOME=$(mosaic_fleet_pane_home "$MOSAIC_HOME" "$HOME")
|
||||||
|
PANE_PATH=$(mosaic_fleet_build_pane_path "$PANE_HOME" "$MOSAIC_RUNTIME_BIN")
|
||||||
|
|
||||||
_build_runtime_bin_prefix() {
|
# #1241/#1256. The pane runs `mosaic yolo <runtime>` under PANE_PATH with a
|
||||||
local candidates=()
|
# cleared environment. Resolve both names and validate any shebang interpreter
|
||||||
if [ -n "$MOSAIC_RUNTIME_BIN" ]; then candidates+=("$MOSAIC_RUNTIME_BIN"); fi
|
# here, before an effect, where the failure remains attributable. Name
|
||||||
if command -v npm >/dev/null 2>&1; then
|
# resolution alone is insufficient: an `#!/usr/bin/env node` script resolves
|
||||||
local npm_prefix
|
# even when the pane cannot execute it because Node is absent.
|
||||||
npm_prefix=$(npm config get prefix 2>/dev/null) || true
|
_resolve_in_pane_path() {
|
||||||
if [ -n "$npm_prefix" ]; then candidates+=("${npm_prefix}/bin"); fi
|
mosaic_fleet_resolve_in_pane_path "$PANE_PATH" "$1"
|
||||||
fi
|
|
||||||
candidates+=("$PANE_HOME/.npm-global/bin" "$PANE_HOME/.local/bin")
|
|
||||||
|
|
||||||
local prefix="" dir
|
|
||||||
for dir in "${candidates[@]}"; do
|
|
||||||
[ -d "$dir" ] || continue
|
|
||||||
case ":${prefix}:" in *":${dir}:"*) ;; *) prefix="${prefix:+$prefix:}$dir" ;; esac
|
|
||||||
done
|
|
||||||
printf '%s' "$prefix"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
MOSAIC_RUNTIME_BIN_PREFIX=$(_build_runtime_bin_prefix)
|
# Exit 69 (EX_UNAVAILABLE): the seat cannot be provided. Distinguished from the
|
||||||
PANE_PATH=${MOSAIC_RUNTIME_BIN_PREFIX:+${MOSAIC_RUNTIME_BIN_PREFIX}:}/usr/local/bin:/usr/bin:/bin
|
# 64 (EX_USAGE) rejections above, which mean the projection itself was bad —
|
||||||
|
# here the data is fine and the host is not ready. Callers tell the individual
|
||||||
|
# cases apart by `code=`, the same way fail_env's many codes share exit 64.
|
||||||
|
fail_launch() {
|
||||||
|
local code="$1"
|
||||||
|
shift
|
||||||
|
echo "ERROR: agent launch aborted: code=${code} agent=${AGENT_NAME} $*" >&2
|
||||||
|
exit 69
|
||||||
|
}
|
||||||
|
|
||||||
|
for required_binary in mosaic "$MOSAIC_AGENT_RUNTIME"; do
|
||||||
|
resolved_binary=$(_resolve_in_pane_path "$required_binary") ||
|
||||||
|
fail_launch missing-binary "'${required_binary}' is not on the pane PATH (${PANE_PATH})"
|
||||||
|
if mosaic_fleet_check_resolved_executable "$PANE_PATH" "$resolved_binary"; then
|
||||||
|
continue
|
||||||
|
else
|
||||||
|
executable_exit=$?
|
||||||
|
fi
|
||||||
|
fail_launch unexecutable-binary \
|
||||||
|
"'${required_binary}' resolves to '${resolved_binary}' but dependency '${MOSAIC_FLEET_EXECUTABLE_DEPENDENCY:-unknown}' is not executable on the pane PATH (${PANE_PATH}); check_exit=${executable_exit} detail=${MOSAIC_FLEET_EXECUTABLE_OUTPUT:-unavailable}"
|
||||||
|
done
|
||||||
|
|
||||||
_ensure_claude_workdir_trusted() {
|
_ensure_claude_workdir_trusted() {
|
||||||
local workdir="$1"
|
local workdir="$1"
|
||||||
@@ -384,6 +397,19 @@ if [ -n "$PANE_PID" ]; then
|
|||||||
_start_heartbeat_sidecar "$AGENT_NAME" "$PANE_PID" \
|
_start_heartbeat_sidecar "$AGENT_NAME" "$PANE_PID" \
|
||||||
"$MOSAIC_HEARTBEAT_RUN_DIR" "$MOSAIC_HEARTBEAT_INTERVAL" || \
|
"$MOSAIC_HEARTBEAT_RUN_DIR" "$MOSAIC_HEARTBEAT_INTERVAL" || \
|
||||||
echo "WARNING: heartbeat sidecar could not be started for $AGENT_NAME" >&2
|
echo "WARNING: heartbeat sidecar could not be started for $AGENT_NAME" >&2
|
||||||
|
elif _tmux has-session -t "=${AGENT_NAME}:0.0" 2>/dev/null; then
|
||||||
|
# #1241. Session present, no pane PID after a second of retries. Whatever this
|
||||||
|
# is, it is not a seat an operator can use, so it is not a success either.
|
||||||
|
fail_launch pane-pid-unresolved \
|
||||||
|
"tmux reports the session but no pane PID after 5 attempts"
|
||||||
else
|
else
|
||||||
echo "WARNING: could not resolve pane PID for $AGENT_NAME — heartbeat sidecar not started" >&2
|
# #1241. This branch used to print a WARNING about the heartbeat sidecar and
|
||||||
|
# exit 0. It is not a heartbeat problem: tmux destroys a session when its pane
|
||||||
|
# command exits, so an absent session one second after new-session means the
|
||||||
|
# runtime died on startup. Reporting it as success is what let `fleet start`
|
||||||
|
# return 0 over three dead panes — the launcher knew, and said the wrong thing
|
||||||
|
# at the wrong severity to the wrong layer.
|
||||||
|
fail_launch pane-did-not-survive \
|
||||||
|
"the pane exited immediately and tmux destroyed the session;" \
|
||||||
|
"run 'mosaic yolo ${MOSAIC_AGENT_RUNTIME}' in ${MOSAIC_AGENT_WORKDIR} to see why"
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -23,8 +23,26 @@ index=0
|
|||||||
if [ "${args[0]:-}" = -L ]; then index=2; fi
|
if [ "${args[0]:-}" = -L ]; then index=2; fi
|
||||||
case "${args[$index]:-}" in
|
case "${args[$index]:-}" in
|
||||||
has-session)
|
has-session)
|
||||||
|
# The holder always answers. MOSAIC_TEST_HELD_SESSIONS lets a case add
|
||||||
|
# other targets that should answer too — without it there is no way to
|
||||||
|
# model "tmux still reports the session" for a non-holder agent, and the
|
||||||
|
# launcher's pane-pid-unresolved branch is unreachable from this harness.
|
||||||
|
#
|
||||||
|
# A listed target answers only AFTER new-session, because the launcher asks
|
||||||
|
# this question twice about the same name: once before launching, where a
|
||||||
|
# yes means "already running, nothing to do, exit 0", and once after, where
|
||||||
|
# a yes means "the session survived". A shim that answered yes to both
|
||||||
|
# would short-circuit at the first and never reach the branch under test —
|
||||||
|
# it would look like coverage and measure the idempotency path instead.
|
||||||
for argument in "${args[@]}"; do
|
for argument in "${args[@]}"; do
|
||||||
[ "$argument" = '=_holder:0.0' ] && exit 0
|
[ "$argument" = '=_holder:0.0' ] && exit 0
|
||||||
|
case " ${MOSAIC_TEST_HELD_SESSIONS:-} " in
|
||||||
|
*" $argument "*)
|
||||||
|
if tr '\0' '\n' < "${MOSAIC_TEST_TMUX_CALLS:?}" | grep -qxF new-session; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
done
|
done
|
||||||
exit 1
|
exit 1
|
||||||
;;
|
;;
|
||||||
@@ -62,6 +80,30 @@ env -0 > "${MOSAIC_HOME:?}/fleet/pane-environment"
|
|||||||
SHIM
|
SHIM
|
||||||
chmod +x "$FAKE_BIN/mosaic"
|
chmod +x "$FAKE_BIN/mosaic"
|
||||||
|
|
||||||
|
# The runtime the rosters below name. The launcher resolves it against PANE_PATH
|
||||||
|
# before spawning (#1241), so it has to exist somewhere the pane would find it —
|
||||||
|
# not merely on the launcher's own PATH.
|
||||||
|
printf '#!/usr/bin/env bash\nexit 0\n' > "$FAKE_BIN/pi"
|
||||||
|
chmod +x "$FAKE_BIN/pi"
|
||||||
|
|
||||||
|
# PANE_PATH is derived partly from `npm config get prefix`. Left to the real npm
|
||||||
|
# it would splice whatever the host has installed into the path under test, and
|
||||||
|
# the missing-binary cases below would pass or fail by accident of the machine.
|
||||||
|
cat > "$FAKE_BIN/npm" <<'SHIM'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
printf '%s\n' "${MOSAIC_TEST_NPM_PREFIX:-/nonexistent}"
|
||||||
|
SHIM
|
||||||
|
chmod +x "$FAKE_BIN/npm"
|
||||||
|
|
||||||
|
# PANE_PATH always ends in the system path. A host that installs these there can
|
||||||
|
# not measure the missing-binary cases at all, and a green run would mean
|
||||||
|
# nothing — so say so instead of passing.
|
||||||
|
for host_binary in mosaic pi; do
|
||||||
|
if PATH=/usr/local/bin:/usr/bin:/bin command -v "$host_binary" >/dev/null 2>&1; then
|
||||||
|
fail "host provides '$host_binary' in the system path; missing-binary cases are not measurable here"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
write_generated() {
|
write_generated() {
|
||||||
local home="$1"
|
local home="$1"
|
||||||
local agent="$2"
|
local agent="$2"
|
||||||
@@ -81,6 +123,19 @@ MOSAIC_TMUX_SOCKET=mosaic-test
|
|||||||
EOF
|
EOF
|
||||||
chmod 600 "$home/fleet/agents/$agent.env.generated"
|
chmod 600 "$home/fleet/agents/$agent.env.generated"
|
||||||
mkdir -p "$home/work"
|
mkdir -p "$home/work"
|
||||||
|
install_pane_binaries "$home"
|
||||||
|
}
|
||||||
|
|
||||||
|
# `$PANE_HOME/.npm-global/bin` is one of the prefixes the launcher folds into
|
||||||
|
# PANE_PATH, so this is the pane's own view of "installed", distinct from the
|
||||||
|
# launcher's PATH. Tests that need a binary *absent* remove it from here.
|
||||||
|
install_pane_binaries() {
|
||||||
|
local pane_home="$1"
|
||||||
|
mkdir -p "$pane_home/.npm-global/bin"
|
||||||
|
local binary
|
||||||
|
for binary in mosaic pi; do
|
||||||
|
ln -sf "$FAKE_BIN/$binary" "$pane_home/.npm-global/bin/$binary"
|
||||||
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
run_start() {
|
run_start() {
|
||||||
@@ -88,6 +143,7 @@ run_start() {
|
|||||||
local agent="$2"
|
local agent="$2"
|
||||||
HOME="$home" PATH="$FAKE_BIN:$PATH" MOSAIC_TEST_TMUX_CALLS="$TMUX_CALLS" \
|
HOME="$home" PATH="$FAKE_BIN:$PATH" MOSAIC_TEST_TMUX_CALLS="$TMUX_CALLS" \
|
||||||
MOSAIC_TEST_PANE_PID="${MOSAIC_TEST_PANE_PID:-}" \
|
MOSAIC_TEST_PANE_PID="${MOSAIC_TEST_PANE_PID:-}" \
|
||||||
|
MOSAIC_TEST_HELD_SESSIONS="${MOSAIC_TEST_HELD_SESSIONS:-}" \
|
||||||
MOSAIC_TEST_HOME="$home" \
|
MOSAIC_TEST_HOME="$home" \
|
||||||
MOSAIC_TEST_FLEET_OWNER=123e4567-e89b-12d3-a456-426614174000 \
|
MOSAIC_TEST_FLEET_OWNER=123e4567-e89b-12d3-a456-426614174000 \
|
||||||
MOSAIC_HOME="$home" "$START" "$agent"
|
MOSAIC_HOME="$home" "$START" "$agent"
|
||||||
@@ -98,7 +154,10 @@ run_start() {
|
|||||||
HOME_VALID="$ROOT/valid"
|
HOME_VALID="$ROOT/valid"
|
||||||
AGENT_VALID="coder0"
|
AGENT_VALID="coder0"
|
||||||
write_generated "$HOME_VALID" "$AGENT_VALID"
|
write_generated "$HOME_VALID" "$AGENT_VALID"
|
||||||
run_start "$HOME_VALID" "$AGENT_VALID"
|
# A live pane PID is part of what "valid launch" means. Until #1241 this case
|
||||||
|
# ran with none, so the suite's one success path was itself a dead pane the
|
||||||
|
# launcher reported as fine.
|
||||||
|
MOSAIC_TEST_PANE_PID=$$ run_start "$HOME_VALID" "$AGENT_VALID"
|
||||||
valid_args=$(tr '\0' '\n' < "$TMUX_CALLS")
|
valid_args=$(tr '\0' '\n' < "$TMUX_CALLS")
|
||||||
echo "$valid_args" | grep -qF new-session || fail "valid generated projection did not reach tmux"
|
echo "$valid_args" | grep -qF new-session || fail "valid generated projection did not reach tmux"
|
||||||
echo "$valid_args" | grep -qF 'mosaic' || fail "fixed mosaic launcher command missing"
|
echo "$valid_args" | grep -qF 'mosaic' || fail "fixed mosaic launcher command missing"
|
||||||
@@ -245,6 +304,13 @@ PANE_BASH_ENV="$ROOT/pane-boundary.bash-env"
|
|||||||
printf 'MOSAIC_RUNTIME_BIN=%s\n' "$FAKE_BIN" > \
|
printf 'MOSAIC_RUNTIME_BIN=%s\n' "$FAKE_BIN" > \
|
||||||
"$HOME_PANE_BOUNDARY/fleet/agents/coder-pane-boundary.env.local"
|
"$HOME_PANE_BOUNDARY/fleet/agents/coder-pane-boundary.env.local"
|
||||||
chmod 600 "$HOME_PANE_BOUNDARY/fleet/agents/coder-pane-boundary.env.local"
|
chmod 600 "$HOME_PANE_BOUNDARY/fleet/agents/coder-pane-boundary.env.local"
|
||||||
|
# This case does not go through run_start, so its pane binaries come from
|
||||||
|
# MOSAIC_RUNTIME_BIN=$FAKE_BIN in the env.local written above — not from the
|
||||||
|
# symlinks install_pane_binaries planted under the generated home, which this
|
||||||
|
# launcher never consults because HOME here is the trusted parent. That is a
|
||||||
|
# legitimate resolution path, but it means dropping MOSAIC_RUNTIME_BIN from
|
||||||
|
# this case on the belief that the symlinks cover it would break the #1241
|
||||||
|
# binary check rather than exercise it.
|
||||||
LD_PRELOAD='/not/loaded/by-clean-bootstrap.so' \
|
LD_PRELOAD='/not/loaded/by-clean-bootstrap.so' \
|
||||||
BASH_ENV="$PANE_BASH_ENV" \
|
BASH_ENV="$PANE_BASH_ENV" \
|
||||||
MOSAIC_UNTRUSTED_SENTINEL='must-not-reach-pane' \
|
MOSAIC_UNTRUSTED_SENTINEL='must-not-reach-pane' \
|
||||||
@@ -258,6 +324,7 @@ PATH="$PANE_STALE_PATH" \
|
|||||||
"MOSAIC_TEST_HOME=$PANE_TRUSTED_HOME" \
|
"MOSAIC_TEST_HOME=$PANE_TRUSTED_HOME" \
|
||||||
MOSAIC_TEST_FLEET_OWNER=123e4567-e89b-12d3-a456-426614174000 \
|
MOSAIC_TEST_FLEET_OWNER=123e4567-e89b-12d3-a456-426614174000 \
|
||||||
MOSAIC_TEST_EXECUTE_PANE=1 \
|
MOSAIC_TEST_EXECUTE_PANE=1 \
|
||||||
|
"MOSAIC_TEST_PANE_PID=$$" \
|
||||||
"$START" coder-pane-boundary
|
"$START" coder-pane-boundary
|
||||||
pane_args=$(tr '\0' '\n' < "$TMUX_CALLS")
|
pane_args=$(tr '\0' '\n' < "$TMUX_CALLS")
|
||||||
echo "$pane_args" | grep -qxF "HOME=$PANE_TRUSTED_HOME" || \
|
echo "$pane_args" | grep -qxF "HOME=$PANE_TRUSTED_HOME" || \
|
||||||
@@ -392,6 +459,96 @@ echo "$interaction_policy_args" | grep -qF 'new-session' && \
|
|||||||
echo "$output" | grep -qF 'operator interaction service requires runtime pi' || \
|
echo "$output" | grep -qF 'operator interaction service requires runtime pi' || \
|
||||||
fail "interaction pinned-policy check did not follow strict parsing"
|
fail "interaction pinned-policy check did not follow strict parsing"
|
||||||
|
|
||||||
|
# #1241. The pane runs `mosaic yolo <runtime>` against PANE_PATH. A binary
|
||||||
|
# missing from that path is a launch failure, and it has to be named before the
|
||||||
|
# session is created — after it, the diagnostic dies with the pane.
|
||||||
|
assert_missing_pane_binary_rejected() {
|
||||||
|
local binary="$1"
|
||||||
|
local home="$ROOT/missing-$binary"
|
||||||
|
local agent="coder-missing-$binary"
|
||||||
|
write_generated "$home" "$agent"
|
||||||
|
rm -f "$home/.npm-global/bin/$binary"
|
||||||
|
|
||||||
|
: > "$TMUX_CALLS"
|
||||||
|
local output
|
||||||
|
if output=$(MOSAIC_TEST_PANE_PID=$$ run_start "$home" "$agent" 2>&1); then
|
||||||
|
fail "launch succeeded with '$binary' absent from the pane PATH"
|
||||||
|
fi
|
||||||
|
echo "$output" | grep -qF 'code=missing-binary' || fail "missing '$binary' diagnostic missing"
|
||||||
|
echo "$output" | grep -qF "'$binary'" || fail "missing-binary diagnostic did not name $binary"
|
||||||
|
if tr '\0' '\n' < "$TMUX_CALLS" | grep -qF new-session; then
|
||||||
|
fail "launcher created a session it knew would die ($binary absent)"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_missing_pane_binary_rejected mosaic
|
||||||
|
assert_missing_pane_binary_rejected pi
|
||||||
|
|
||||||
|
# #1256. Name resolution is not executable reachability. A script can resolve
|
||||||
|
# while its /usr/bin/env shebang command is absent from PANE_PATH; reject that
|
||||||
|
# before tmux creates the doomed session.
|
||||||
|
: > "$TMUX_CALLS"
|
||||||
|
HOME_UNEXECUTABLE="$ROOT/unexecutable-shebang"
|
||||||
|
write_generated "$HOME_UNEXECUTABLE" "coder-unexecutable"
|
||||||
|
rm -f "$HOME_UNEXECUTABLE/.npm-global/bin/mosaic"
|
||||||
|
printf '#!/usr/bin/env mosaic-test-absent-interpreter\n' > \
|
||||||
|
"$HOME_UNEXECUTABLE/.npm-global/bin/mosaic"
|
||||||
|
chmod +x "$HOME_UNEXECUTABLE/.npm-global/bin/mosaic"
|
||||||
|
if output=$(MOSAIC_TEST_PANE_PID=$$ run_start "$HOME_UNEXECUTABLE" coder-unexecutable 2>&1); then
|
||||||
|
fail "launcher accepted a resolved mosaic script with an absent shebang command"
|
||||||
|
fi
|
||||||
|
echo "$output" | grep -qF 'code=unexecutable-binary' || \
|
||||||
|
fail "unexecutable shebang diagnostic missing: $output"
|
||||||
|
echo "$output" | grep -qF 'mosaic-test-absent-interpreter' || \
|
||||||
|
fail "unexecutable shebang diagnostic did not name the missing dependency"
|
||||||
|
if tr '\0' '\n' < "$TMUX_CALLS" | grep -qF new-session; then
|
||||||
|
fail "launcher created a session after its shebang dependency check failed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# #1241. tmux destroys a session when its pane command exits, so no pane PID a
|
||||||
|
# second after new-session means the runtime died on startup. This used to be a
|
||||||
|
# WARNING about the heartbeat sidecar followed by exit 0 — three layers above it
|
||||||
|
# then reported a fleet that was not running.
|
||||||
|
: > "$TMUX_CALLS"
|
||||||
|
HOME_DEAD_PANE="$ROOT/dead-pane"
|
||||||
|
write_generated "$HOME_DEAD_PANE" "coder-dead-pane"
|
||||||
|
if output=$(MOSAIC_TEST_PANE_PID='' run_start "$HOME_DEAD_PANE" coder-dead-pane 2>&1); then
|
||||||
|
fail "launcher reported success over a pane that did not survive"
|
||||||
|
fi
|
||||||
|
echo "$output" | grep -qF 'code=pane-did-not-survive' || fail "dead-pane diagnostic missing"
|
||||||
|
if echo "$output" | grep -qiF 'heartbeat'; then
|
||||||
|
fail "dead pane is still being reported as a heartbeat-sidecar problem"
|
||||||
|
fi
|
||||||
|
tr '\0' '\n' < "$TMUX_CALLS" | grep -qF new-session || \
|
||||||
|
fail "dead-pane case did not reach the launch it is measuring"
|
||||||
|
|
||||||
|
# #1241, the other way a pane fails. Above, tmux destroyed the session and
|
||||||
|
# has-session said so. Here the session is still there and no PID comes back
|
||||||
|
# after the retries — a different fault (the pane is alive but unusable, or
|
||||||
|
# tmux is answering inconsistently) that an operator has to be told apart from
|
||||||
|
# a runtime that died on startup.
|
||||||
|
#
|
||||||
|
# This case exists because the branch that handles it shipped with nothing able
|
||||||
|
# to reach it: the shim answered has-session only for the holder, so every
|
||||||
|
# non-holder agent landed in the session-is-gone branch no matter what. A
|
||||||
|
# defensive branch nothing exercises is the same shape as the bug this whole
|
||||||
|
# change is about, one layer down.
|
||||||
|
: > "$TMUX_CALLS"
|
||||||
|
HOME_NO_PID="$ROOT/pane-no-pid"
|
||||||
|
write_generated "$HOME_NO_PID" "coder-no-pid"
|
||||||
|
if output=$(MOSAIC_TEST_PANE_PID='' MOSAIC_TEST_HELD_SESSIONS='=coder-no-pid:0.0' \
|
||||||
|
run_start "$HOME_NO_PID" coder-no-pid 2>&1); then
|
||||||
|
fail "launcher reported success over a session with no resolvable pane PID"
|
||||||
|
fi
|
||||||
|
echo "$output" | grep -qF 'code=pane-pid-unresolved' || \
|
||||||
|
fail "session-present/no-PID was not reported as pane-pid-unresolved: $output"
|
||||||
|
if echo "$output" | grep -qF 'code=pane-did-not-survive'; then
|
||||||
|
fail "a session tmux still reports was diagnosed as a destroyed session"
|
||||||
|
fi
|
||||||
|
if echo "$output" | grep -qiF 'heartbeat'; then
|
||||||
|
fail "an unresolvable pane PID is still being reported as a heartbeat-sidecar problem"
|
||||||
|
fi
|
||||||
|
|
||||||
# Exact stop derives the socket exclusively from the validated generated
|
# Exact stop derives the socket exclusively from the validated generated
|
||||||
# projection and ignores an ambient socket supplied by the caller.
|
# projection and ignores an ambient socket supplied by the caller.
|
||||||
: > "$TMUX_CALLS"
|
: > "$TMUX_CALLS"
|
||||||
|
|||||||
@@ -32,10 +32,6 @@ packages/mosaic/framework/tools/tmux/test-send-message-socket.sh | requires a re
|
|||||||
packages/mosaic/framework/tools/tmux/test-send-message-verdict.sh | requires real tmux-pane fixtures on a throwaway socket; CI image ships no tmux; #1017 burndown (same condition as its sibling)
|
packages/mosaic/framework/tools/tmux/test-send-message-verdict.sh | requires real tmux-pane fixtures on a throwaway socket; CI image ships no tmux; #1017 burndown (same condition as its sibling)
|
||||||
|
|
||||||
# --- single-suite directories: unmeasured in CI ---
|
# --- single-suite directories: unmeasured in CI ---
|
||||||
packages/mosaic/framework/tools/fleet/test-start-agent-session.sh | unmeasured in CI image; stubs tmux via a fake bin dir, likely CI-fit; #1017 burndown
|
|
||||||
packages/mosaic/framework/tools/glpi/test-list-http-status.sh | unmeasured in CI image; stub-based (#807 regression harness), likely CI-fit; #1017 burndown
|
|
||||||
packages/mosaic/framework/tools/orchestrator/test-board-roll.sh | unmeasured in CI image; file-fixture based, likely CI-fit; #1017 burndown
|
|
||||||
packages/mosaic/framework/tools/woodpecker/test-ci-wait-exit-matrix.sh | unmeasured in CI image; drives ci-wait.sh against a stub pipeline-status.sh, likely CI-fit; #1017 burndown
|
|
||||||
|
|
||||||
# --- naming-boundary files the strict test-*.sh prefix cannot even name ---
|
# --- naming-boundary files the strict test-*.sh prefix cannot even name ---
|
||||||
# (#1017: three independent censuses handled the microtest file three different
|
# (#1017: three independent censuses handled the microtest file three different
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
"lint": "eslint src",
|
"lint": "eslint src",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "vitest run --passWithNoTests && pnpm run test:framework-shell",
|
"test": "vitest run --passWithNoTests && pnpm run test:framework-shell",
|
||||||
"test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/promotion_binding_unittest.py && python3 src/lease-broker/promotion_trigger_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/receipt_observer_client_unittest.py && python3 src/lease-broker/invariant_r_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-tristate.sh && bash framework/tools/git/test-ci-queue-wait-github-checks.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-pr-merge-message-field.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/_scripts/test-mosaic-init-rce.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh"
|
"test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/promotion_binding_unittest.py && python3 src/lease-broker/promotion_trigger_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/receipt_observer_client_unittest.py && python3 src/lease-broker/invariant_r_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-tristate.sh && bash framework/tools/git/test-ci-queue-wait-github-checks.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-pr-merge-message-field.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/_scripts/test-mosaic-init-rce.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh && bash framework/tools/fleet/test-start-agent-session.sh && bash framework/tools/glpi/test-list-http-status.sh && bash framework/tools/orchestrator/test-board-roll.sh && bash framework/tools/woodpecker/test-ci-wait-exit-matrix.sh && bash framework/tools/_scripts/test-fleet-transport-check.sh"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mosaicstack/brain": "workspace:*",
|
"@mosaicstack/brain": "workspace:*",
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { join } from 'node:path';
|
import { join, resolve } from 'node:path';
|
||||||
import { Command } from 'commander';
|
import { Command } from 'commander';
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { type FleetReconcileDeps } from '../fleet/fleet-reconciler.js';
|
import { type FleetReconcileDeps } from '../fleet/fleet-reconciler.js';
|
||||||
|
import {
|
||||||
|
type FleetRuntimeProbeResult,
|
||||||
|
type FleetRuntimeProbeRunner,
|
||||||
|
} from '../fleet/fleet-runtime-preflight.js';
|
||||||
import { registerFleetCommand, type CommandResult, type FleetCommandDeps } from './fleet.js';
|
import { registerFleetCommand, type CommandResult, type FleetCommandDeps } from './fleet.js';
|
||||||
|
|
||||||
const roster = `
|
const roster = `
|
||||||
@@ -65,12 +69,15 @@ function program(
|
|||||||
mosaicHome: string,
|
mosaicHome: string,
|
||||||
runner: FleetCommandDeps['runner'],
|
runner: FleetCommandDeps['runner'],
|
||||||
reconcileOverrides: Partial<FleetReconcileDeps> = {},
|
reconcileOverrides: Partial<FleetReconcileDeps> = {},
|
||||||
|
runtimeProbeRunner: FleetRuntimeProbeRunner = runtimeProbe('present'),
|
||||||
): Command {
|
): Command {
|
||||||
const result = new Command();
|
const result = new Command();
|
||||||
result.exitOverride();
|
result.exitOverride();
|
||||||
registerFleetCommand(result, {
|
registerFleetCommand(result, {
|
||||||
mosaicHome,
|
mosaicHome,
|
||||||
runner,
|
runner,
|
||||||
|
frameworkRoot: resolve(process.cwd(), 'framework'),
|
||||||
|
runtimeProbeRunner,
|
||||||
reconcileDeps: {
|
reconcileDeps: {
|
||||||
homeDirectory: '/home/mosaic',
|
homeDirectory: '/home/mosaic',
|
||||||
readHolderIdentity: async () => '11111111-1111-4111-8111-111111111111',
|
readHolderIdentity: async () => '11111111-1111-4111-8111-111111111111',
|
||||||
@@ -83,6 +90,24 @@ function program(
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function runtimeProbe(status: 'present' | 'missing'): FleetRuntimeProbeRunner {
|
||||||
|
return async (_command, args): Promise<FleetRuntimeProbeResult> => {
|
||||||
|
const binaryFlag = args.indexOf('--binary');
|
||||||
|
const binary = binaryFlag >= 0 ? args[binaryFlag + 1] : undefined;
|
||||||
|
const effectiveStatus = binary === 'mosaic' ? 'present' : status;
|
||||||
|
return {
|
||||||
|
stdout:
|
||||||
|
`pane_path\u0000/fixture/runtime-bin:/usr/bin:/bin\u0000status\u0000${effectiveStatus}\u0000` +
|
||||||
|
`binary_path\u0000${effectiveStatus === 'present' ? `/fixture/runtime-bin/${binary ?? 'unknown'}` : ''}\u0000` +
|
||||||
|
`dependency\u0000${effectiveStatus === 'present' ? 'node' : ''}\u0000` +
|
||||||
|
`probe_command\u0000${effectiveStatus === 'present' ? 'node --version' : ''}\u0000` +
|
||||||
|
`probe_exit\u0000${effectiveStatus === 'present' ? '0' : ''}\u0000probe_output\u0000\u0000`,
|
||||||
|
stderr: '',
|
||||||
|
exitCode: effectiveStatus === 'present' ? 0 : 69,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function capture(): string[] {
|
function capture(): string[] {
|
||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
vi.spyOn(console, 'log').mockImplementation((value: string): void => {
|
vi.spyOn(console, 'log').mockImplementation((value: string): void => {
|
||||||
@@ -152,13 +177,64 @@ describe('mosaic fleet reconciler commands', (): void => {
|
|||||||
|
|
||||||
expect(lines.map((line: string): unknown => JSON.parse(line))).toMatchObject([
|
expect(lines.map((line: string): unknown => JSON.parse(line))).toMatchObject([
|
||||||
{ applied: false, lifecycle: 'not-applied' },
|
{ applied: false, lifecycle: 'not-applied' },
|
||||||
{ applied: false, lifecycle: 'not-applied' },
|
{
|
||||||
|
applied: false,
|
||||||
|
lifecycle: 'not-applied',
|
||||||
|
checks: {
|
||||||
|
fleetCliExecutable: [
|
||||||
|
{
|
||||||
|
check: 'fleet-cli-executable',
|
||||||
|
status: 'ok',
|
||||||
|
requestedBy: ['coder0'],
|
||||||
|
dependency: 'node',
|
||||||
|
probeCommand: 'node --version',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
fleetRuntimeAvailability: [
|
||||||
|
{
|
||||||
|
check: 'fleet-runtime-available',
|
||||||
|
runtime: 'pi',
|
||||||
|
status: 'ok',
|
||||||
|
requestedBy: ['coder0'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
expect(
|
expect(
|
||||||
calls.every((call: string[]): boolean => call[0] !== 'systemctl' || call[2] === 'show'),
|
calls.every((call: string[]): boolean => call[0] !== 'systemctl' || call[2] === 'show'),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('reports a missing roster runtime as a named non-green doctor check', async (): Promise<void> => {
|
||||||
|
const home = await fleetHome();
|
||||||
|
const lines = capture();
|
||||||
|
|
||||||
|
await program(home, ownedRunner([]), {}, runtimeProbe('missing')).parseAsync([
|
||||||
|
'node',
|
||||||
|
'mosaic',
|
||||||
|
'fleet',
|
||||||
|
'doctor',
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(JSON.parse(lines.pop() ?? '')).toMatchObject({
|
||||||
|
applied: false,
|
||||||
|
checks: {
|
||||||
|
fleetRuntimeAvailability: [
|
||||||
|
{
|
||||||
|
check: 'fleet-runtime-available',
|
||||||
|
runtime: 'pi',
|
||||||
|
status: 'missing',
|
||||||
|
requestedBy: ['coder0'],
|
||||||
|
panePath: '/fixture/runtime-bin:/usr/bin:/bin',
|
||||||
|
installCommand: 'npm install -g @earendil-works/pi-coding-agent',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(process.exitCode).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
it.each(['start', 'stop', 'restart'] as const)(
|
it.each(['start', 'stop', 'restart'] as const)(
|
||||||
'uses exact roster-owned systemd targeting for %s',
|
'uses exact roster-owned systemd targeting for %s',
|
||||||
async (operation: 'start' | 'stop' | 'restart'): Promise<void> => {
|
async (operation: 'start' | 'stop' | 'restart'): Promise<void> => {
|
||||||
|
|||||||
@@ -8,10 +8,18 @@ import {
|
|||||||
type FleetReconcileCommand,
|
type FleetReconcileCommand,
|
||||||
type FleetReconcileDeps,
|
type FleetReconcileDeps,
|
||||||
} from '../fleet/fleet-reconciler.js';
|
} from '../fleet/fleet-reconciler.js';
|
||||||
|
import {
|
||||||
|
inspectFleetRuntimeAvailability,
|
||||||
|
type FleetRuntimeInspection,
|
||||||
|
type FleetRuntimePreflightCheck,
|
||||||
|
type FleetRuntimeProbeRunner,
|
||||||
|
} from '../fleet/fleet-runtime-preflight.js';
|
||||||
import { parseRosterV2 } from '../fleet/roster-v2.js';
|
import { parseRosterV2 } from '../fleet/roster-v2.js';
|
||||||
|
|
||||||
export interface FleetReconcilerCommandDeps {
|
export interface FleetReconcilerCommandDeps {
|
||||||
readonly runner: CommandRunner;
|
readonly runner: CommandRunner;
|
||||||
|
readonly runtimeProbeRunner?: FleetRuntimeProbeRunner;
|
||||||
|
readonly frameworkRoot?: string;
|
||||||
readonly mosaicHome?: string;
|
readonly mosaicHome?: string;
|
||||||
readonly reconcileDeps?: Omit<FleetReconcileDeps, 'runner' | 'mosaicHome'>;
|
readonly reconcileDeps?: Omit<FleetReconcileDeps, 'runner' | 'mosaicHome'>;
|
||||||
}
|
}
|
||||||
@@ -71,6 +79,10 @@ export async function executeReconcilerCommand(
|
|||||||
const mosaicHome = resolveMosaicHome(fleetCommand, deps);
|
const mosaicHome = resolveMosaicHome(fleetCommand, deps);
|
||||||
const rosterPath = resolveRosterPath(fleetCommand, mosaicHome);
|
const rosterPath = resolveRosterPath(fleetCommand, mosaicHome);
|
||||||
const roster = parseRosterV2(await readFile(rosterPath, 'utf8'), 'yaml');
|
const roster = parseRosterV2(await readFile(rosterPath, 'utf8'), 'yaml');
|
||||||
|
const runtimeInspection =
|
||||||
|
operation === 'doctor'
|
||||||
|
? await inspectRuntimeAvailability(roster.agents, mosaicHome, deps)
|
||||||
|
: undefined;
|
||||||
const mutating = operation === 'apply' || operation === 'reconcile' || isLifecycle(operation);
|
const mutating = operation === 'apply' || operation === 'reconcile' || isLifecycle(operation);
|
||||||
const expectedGeneration = mutating
|
const expectedGeneration = mutating
|
||||||
? parseExpectedGeneration(opts.expectedGeneration)
|
? parseExpectedGeneration(opts.expectedGeneration)
|
||||||
@@ -90,8 +102,31 @@ export async function executeReconcilerCommand(
|
|||||||
...(deps.reconcileDeps ?? {}),
|
...(deps.reconcileDeps ?? {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
printJson(result);
|
printJson(operation === 'doctor' ? { ...result, checks: runtimeInspection } : result);
|
||||||
process.exitCode = result.recovery === undefined && result.cleanup === undefined ? 0 : 1;
|
const executableFailure =
|
||||||
|
runtimeInspection !== undefined &&
|
||||||
|
[...runtimeInspection.fleetCliExecutable, ...runtimeInspection.fleetRuntimeAvailability].some(
|
||||||
|
(check: FleetRuntimePreflightCheck): boolean => check.status !== 'ok',
|
||||||
|
);
|
||||||
|
process.exitCode =
|
||||||
|
result.recovery === undefined && result.cleanup === undefined && !executableFailure ? 0 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function inspectRuntimeAvailability(
|
||||||
|
agents: readonly { readonly name: string; readonly runtime: string }[],
|
||||||
|
mosaicHome: string,
|
||||||
|
deps: FleetReconcilerCommandDeps,
|
||||||
|
): Promise<FleetRuntimeInspection> {
|
||||||
|
if (deps.frameworkRoot === undefined || deps.runtimeProbeRunner === undefined) {
|
||||||
|
throw new Error('Fleet doctor runtime preflight dependencies are unavailable.');
|
||||||
|
}
|
||||||
|
return inspectFleetRuntimeAvailability({
|
||||||
|
mosaicHome,
|
||||||
|
agentEnvDir: join(mosaicHome, 'fleet', 'agents'),
|
||||||
|
helperPath: join(deps.frameworkRoot, 'tools', 'fleet', 'pane-runtime-path.sh'),
|
||||||
|
agents,
|
||||||
|
runner: deps.runtimeProbeRunner,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function isLifecycle(operation: FleetReconcileCommand): boolean {
|
function isLifecycle(operation: FleetReconcileCommand): boolean {
|
||||||
|
|||||||
@@ -0,0 +1,429 @@
|
|||||||
|
import { execFile } from 'node:child_process';
|
||||||
|
import { mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join, resolve } from 'node:path';
|
||||||
|
import { Command } from 'commander';
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import type { FleetRuntimeProbeRunner } from '../fleet/fleet-runtime-preflight.js';
|
||||||
|
import { registerFleetCommand, type CommandResult, type CommandRunner } from './fleet.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #1237: the v1-only commands (`ps`, `install`, `install-systemd`, `add`,
|
||||||
|
* `remove`) rejected a roster-v2 fleet outright, so a greenfield v2 box could
|
||||||
|
* never get its units placed. These tests pin the three behaviours that fix
|
||||||
|
* gives it, and the two it deliberately does NOT give it.
|
||||||
|
*
|
||||||
|
* The load-bearing negative is that `install` on v2 writes no generated env:
|
||||||
|
* the reconciler owns that file through projectRosterV2AgentGeneratedEnv, and a
|
||||||
|
* second writer here — necessarily through the v1 mapping — is exactly the
|
||||||
|
* drift the #791 single-SSOT invariant exists to prevent.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const rosterV2 = `
|
||||||
|
version: 2
|
||||||
|
generation: 4
|
||||||
|
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
|
||||||
|
- name: coder1
|
||||||
|
alias: Coder 1
|
||||||
|
class: code
|
||||||
|
runtime: pi
|
||||||
|
provider: openai
|
||||||
|
model: gpt-5.6-sol
|
||||||
|
reasoning: medium
|
||||||
|
tool_policy: code
|
||||||
|
working_directory: /srv/other
|
||||||
|
persistent_persona: false
|
||||||
|
reset_between_tasks: true
|
||||||
|
lifecycle:
|
||||||
|
enabled: true
|
||||||
|
desired_state: stopped
|
||||||
|
launch:
|
||||||
|
yolo: true
|
||||||
|
`;
|
||||||
|
|
||||||
|
let tempHome: string | undefined;
|
||||||
|
const savedHome = process.env.HOME;
|
||||||
|
const savedMosaicHome = process.env.MOSAIC_HOME;
|
||||||
|
const savedPath = process.env.PATH;
|
||||||
|
|
||||||
|
afterEach(async (): Promise<void> => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
process.exitCode = undefined;
|
||||||
|
if (savedHome === undefined) delete process.env.HOME;
|
||||||
|
else process.env.HOME = savedHome;
|
||||||
|
if (savedMosaicHome === undefined) delete process.env.MOSAIC_HOME;
|
||||||
|
else process.env.MOSAIC_HOME = savedMosaicHome;
|
||||||
|
if (savedPath === undefined) delete process.env.PATH;
|
||||||
|
else process.env.PATH = savedPath;
|
||||||
|
if (tempHome) await rm(tempHome, { recursive: true, force: true });
|
||||||
|
tempHome = undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A HOME with a roster-v2 fleet and nothing else — the greenfield shape, before
|
||||||
|
* anything has been installed, applied or started.
|
||||||
|
*/
|
||||||
|
async function v2Home(options: { withPaneRuntime?: boolean } = {}): Promise<string> {
|
||||||
|
tempHome = await mkdtemp(join(tmpdir(), 'mosaic-fleet-v2-dispatch-'));
|
||||||
|
process.env.HOME = tempHome;
|
||||||
|
delete process.env.MOSAIC_HOME;
|
||||||
|
const mosaicHome = join(tempHome, '.config', 'mosaic');
|
||||||
|
for (const directory of ['fleet', 'fleet/agents', 'fleet/roles']) {
|
||||||
|
await mkdir(join(mosaicHome, directory), { recursive: true, mode: 0o700 });
|
||||||
|
}
|
||||||
|
await writeFile(join(mosaicHome, 'fleet', 'roster.yaml'), rosterV2, { mode: 0o600 });
|
||||||
|
await writeFile(join(mosaicHome, 'fleet', 'roles', 'code.md'), '`class: code`\n\n# code\n', {
|
||||||
|
mode: 0o600,
|
||||||
|
});
|
||||||
|
const runtimeDir = join(tempHome, '.npm-global', 'bin');
|
||||||
|
await mkdir(runtimeDir, { recursive: true });
|
||||||
|
await writeFile(join(runtimeDir, 'mosaic'), '#!/bin/sh\nexit 0\n', { mode: 0o755 });
|
||||||
|
if (options.withPaneRuntime !== false) {
|
||||||
|
await writeFile(join(runtimeDir, 'pi'), '#!/bin/sh\nexit 0\n', { mode: 0o755 });
|
||||||
|
}
|
||||||
|
return mosaicHome;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stands in for a box where nothing is running: every systemctl and tmux probe
|
||||||
|
* fails the way it does before the holder has ever started. `ps` must survive
|
||||||
|
* this — it is the command an operator reaches for to find out *why* there is
|
||||||
|
* no seat, so it has to report the emptiness rather than fail on it.
|
||||||
|
*/
|
||||||
|
const greenfieldRunner: CommandRunner = async (command): Promise<CommandResult> => {
|
||||||
|
if (command === 'tmux') {
|
||||||
|
return { stdout: '', stderr: 'no server running on /tmp/tmux-1000/mosaic-fleet', exitCode: 1 };
|
||||||
|
}
|
||||||
|
return { stdout: '', stderr: '', exitCode: 1 };
|
||||||
|
};
|
||||||
|
|
||||||
|
function program(
|
||||||
|
runner: CommandRunner = greenfieldRunner,
|
||||||
|
runtimeProbeRunner?: FleetRuntimeProbeRunner,
|
||||||
|
): Command {
|
||||||
|
const result = new Command();
|
||||||
|
result.exitOverride();
|
||||||
|
registerFleetCommand(result, {
|
||||||
|
runner,
|
||||||
|
frameworkRoot: resolve(process.cwd(), 'framework'),
|
||||||
|
...(runtimeProbeRunner === undefined ? {} : { runtimeProbeRunner }),
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function capture(): string[] {
|
||||||
|
const lines: string[] = [];
|
||||||
|
vi.spyOn(console, 'log').mockImplementation((value: string): void => {
|
||||||
|
lines.push(value);
|
||||||
|
});
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exists(path: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await stat(path);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('mosaic fleet ps — roster v2', (): void => {
|
||||||
|
it('lists every v2 agent on a greenfield box with nothing running, and does not throw', async (): Promise<void> => {
|
||||||
|
await v2Home();
|
||||||
|
const lines = capture();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
program().parseAsync(['node', 'mosaic', 'fleet', 'ps', '--json']),
|
||||||
|
).resolves.toBeDefined();
|
||||||
|
|
||||||
|
const rows = JSON.parse(lines.join('\n')) as {
|
||||||
|
name: string;
|
||||||
|
runtime: string;
|
||||||
|
alias?: string;
|
||||||
|
paneAlive: boolean;
|
||||||
|
source: string;
|
||||||
|
}[];
|
||||||
|
expect(rows.map((row) => row.name).sort()).toEqual(['coder0', 'coder1']);
|
||||||
|
// The v2 roster's per-agent fields must survive the read model, not be
|
||||||
|
// flattened into defaults.
|
||||||
|
expect(rows.every((row) => row.runtime === 'pi')).toBe(true);
|
||||||
|
expect(rows.find((row) => row.name === 'coder0')?.alias).toBe('Coder 0');
|
||||||
|
// Nothing is running, and that is a report, not an error.
|
||||||
|
expect(rows.every((row) => row.paneAlive === false)).toBe(true);
|
||||||
|
expect(rows.every((row) => row.source === 'roster')).toBe(true);
|
||||||
|
expect(process.exitCode ?? 0).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mosaic fleet install — roster v2', (): void => {
|
||||||
|
it('rejects a roster runtime missing from the pane PATH before installing any files', async (): Promise<void> => {
|
||||||
|
const mosaicHome = await v2Home({ withPaneRuntime: false });
|
||||||
|
const operatorBin = join(tempHome!, 'operator-bin');
|
||||||
|
await mkdir(operatorBin, { recursive: true });
|
||||||
|
await writeFile(join(operatorBin, 'pi'), '#!/bin/sh\nexit 0\n', { mode: 0o755 });
|
||||||
|
process.env.PATH = `${operatorBin}:${savedPath ?? '/usr/bin:/bin'}`;
|
||||||
|
|
||||||
|
let message = '';
|
||||||
|
try {
|
||||||
|
await program().parseAsync([
|
||||||
|
'node',
|
||||||
|
'mosaic',
|
||||||
|
'fleet',
|
||||||
|
'--mosaic-home',
|
||||||
|
mosaicHome,
|
||||||
|
'install',
|
||||||
|
'--no-enable',
|
||||||
|
]);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
message = error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(message).toContain('runtime=pi');
|
||||||
|
expect(message).toContain('requested_by=coder0,coder1');
|
||||||
|
expect(message).toContain('pane_path=');
|
||||||
|
expect(message).toContain('npm install -g @earendil-works/pi-coding-agent');
|
||||||
|
expect(message).not.toContain(operatorBin);
|
||||||
|
expect(
|
||||||
|
await exists(join(tempHome!, '.config', 'systemd', 'user', '[email protected]')),
|
||||||
|
).toBe(false);
|
||||||
|
expect(await exists(join(mosaicHome, 'fleet', 'holder.id'))).toBe(false);
|
||||||
|
expect(await exists(join(mosaicHome, 'tools'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects resolved Node-shebang commands when Node is absent from the pane PATH', async (): Promise<void> => {
|
||||||
|
const mosaicHome = await v2Home();
|
||||||
|
const runtimeDir = join(tempHome!, '.npm-global', 'bin');
|
||||||
|
const nodeScript = '#!/usr/bin/env node\nconsole.log("should-not-run");\n';
|
||||||
|
await writeFile(join(runtimeDir, 'mosaic'), nodeScript, { mode: 0o755 });
|
||||||
|
await writeFile(join(runtimeDir, 'pi'), nodeScript, { mode: 0o755 });
|
||||||
|
await writeFile(join(tempHome!, '.npmrc'), `prefix=${join(tempHome!, 'absent-prefix')}\n`);
|
||||||
|
const isolatedSystemPath = join(tempHome!, 'system-bin');
|
||||||
|
await mkdir(isolatedSystemPath, { recursive: true });
|
||||||
|
const isolatedProbeRunner: FleetRuntimeProbeRunner = async (
|
||||||
|
command,
|
||||||
|
args,
|
||||||
|
): Promise<CommandResult> =>
|
||||||
|
new Promise((settle) => {
|
||||||
|
const child = execFile(
|
||||||
|
command,
|
||||||
|
[...args, '--system-path', isolatedSystemPath],
|
||||||
|
{ encoding: 'utf8' },
|
||||||
|
(error, stdout, stderr) => {
|
||||||
|
settle({
|
||||||
|
stdout,
|
||||||
|
stderr,
|
||||||
|
exitCode: child.exitCode ?? (error === null ? 0 : 1),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
let message = '';
|
||||||
|
try {
|
||||||
|
await program(greenfieldRunner, isolatedProbeRunner).parseAsync([
|
||||||
|
'node',
|
||||||
|
'mosaic',
|
||||||
|
'fleet',
|
||||||
|
'--mosaic-home',
|
||||||
|
mosaicHome,
|
||||||
|
'install',
|
||||||
|
'--no-enable',
|
||||||
|
]);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
message = error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(message).toContain('check=fleet-cli-executable');
|
||||||
|
expect(message).toContain('binary=mosaic');
|
||||||
|
expect(message).toContain('dependency=node');
|
||||||
|
expect(message).toContain('check=fleet-runtime-available');
|
||||||
|
expect(message).toContain('runtime=pi');
|
||||||
|
expect(message).not.toContain('/usr/bin');
|
||||||
|
expect(await exists(join(mosaicHome, 'fleet', 'holder.id'))).toBe(false);
|
||||||
|
expect(await exists(join(mosaicHome, 'tools'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('places the tool files and unit templates', async (): Promise<void> => {
|
||||||
|
const mosaicHome = await v2Home();
|
||||||
|
capture();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
program().parseAsync(['node', 'mosaic', 'fleet', 'install', '--no-enable']),
|
||||||
|
).resolves.toBeDefined();
|
||||||
|
|
||||||
|
// Units live in the systemd user dir, not under the Mosaic home.
|
||||||
|
const systemdUserDir = join(tempHome!, '.config', 'systemd', 'user');
|
||||||
|
for (const unit of [
|
||||||
|
'mosaic-tmux-holder.service',
|
||||||
|
'[email protected]',
|
||||||
|
'[email protected]',
|
||||||
|
]) {
|
||||||
|
expect(await exists(join(systemdUserDir, unit))).toBe(true);
|
||||||
|
}
|
||||||
|
for (const tool of ['start-agent-session.sh', 'pane-runtime-path.sh']) {
|
||||||
|
const toolPath = join(mosaicHome, 'tools', 'fleet', tool);
|
||||||
|
expect(await exists(toolPath)).toBe(true);
|
||||||
|
expect((await stat(toolPath)).mode & 0o777).toBe(0o755);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes NO generated env — that file belongs to the reconciler (#791)', async (): Promise<void> => {
|
||||||
|
const mosaicHome = await v2Home();
|
||||||
|
capture();
|
||||||
|
|
||||||
|
await program().parseAsync(['node', 'mosaic', 'fleet', 'install', '--no-enable']);
|
||||||
|
|
||||||
|
const agentDir = join(mosaicHome, 'fleet', 'agents');
|
||||||
|
expect(await readdir(agentDir)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tells the operator which command does own the env', async (): Promise<void> => {
|
||||||
|
await v2Home();
|
||||||
|
const lines = capture();
|
||||||
|
|
||||||
|
await program().parseAsync(['node', 'mosaic', 'fleet', 'install', '--no-enable']);
|
||||||
|
|
||||||
|
expect(lines.join('\n')).toContain('mosaic fleet apply');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('[email protected]', (): void => {
|
||||||
|
const unitPath = resolve(process.cwd(), 'framework', 'systemd', 'user', '[email protected]');
|
||||||
|
|
||||||
|
/** The single `ConditionPathExists=` value declared by the unit template. */
|
||||||
|
async function conditionPath(): Promise<string> {
|
||||||
|
const unit = await readFile(unitPath, 'utf8');
|
||||||
|
const matches = unit.match(/^ConditionPathExists=(.+)$/gm) ?? [];
|
||||||
|
expect(matches).toHaveLength(1);
|
||||||
|
return matches[0]!.slice('ConditionPathExists='.length).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
it('will not attempt a seat before the reconciler has written its env', async (): Promise<void> => {
|
||||||
|
// The pairing that makes "install writes no env" safe: install enables the
|
||||||
|
// unit (WantedBy=default.target) but does not start it, so without this
|
||||||
|
// condition a reboot between `install` and the first `apply` would run
|
||||||
|
// ExecStart against an absent env file and fail every seat unit.
|
||||||
|
expect(await conditionPath()).toBe('%h/.config/mosaic/fleet/agents/%i.env.generated');
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The two halves of the guard's *effect*, which no assertion on the literal
|
||||||
|
* string can cover on its own.
|
||||||
|
*
|
||||||
|
* Measured end to end on a real box (canary, 2026-08-16) rather than inferred:
|
||||||
|
* with the condition, `systemctl --user start mosaic-agent@<name>` on an agent
|
||||||
|
* with no generated env returns rc=0, `Result=success`, `ConditionResult=no`,
|
||||||
|
* and journals "skipped, unmet condition check". With the condition removed by
|
||||||
|
* drop-in and nothing else changed, the same start returns rc=1,
|
||||||
|
* `Result=exit-code`, `ExecMainStatus=64`, and the unit enters `failed`.
|
||||||
|
*
|
||||||
|
* systemd is not available in this suite, so these two tests pin the parts
|
||||||
|
* that can drift in code: the condition naming a *different* file than the one
|
||||||
|
* the fleet actually writes, and the launcher quietly becoming tolerant of an
|
||||||
|
* absent env — either of which turns the condition into decoration while the
|
||||||
|
* literal-string assertion above still passes.
|
||||||
|
*/
|
||||||
|
it('guards exactly the file the fleet writes, so the two cannot drift apart', async (): Promise<void> => {
|
||||||
|
const mosaicHome = await v2Home();
|
||||||
|
const rendered = (await conditionPath()).replace('%h', tempHome!).replace('%i', 'coder0');
|
||||||
|
|
||||||
|
// The path an installed fleet actually places for this agent.
|
||||||
|
expect(rendered).toBe(join(mosaicHome, 'fleet', 'agents', 'coder0.env.generated'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('guards a real failure — the launcher rejects an absent generated env', async (): Promise<void> => {
|
||||||
|
await v2Home();
|
||||||
|
await program().parseAsync(['node', 'mosaic', 'fleet', 'install', '--no-enable']);
|
||||||
|
|
||||||
|
// Exactly what ExecStart runs, against the state the condition exists to
|
||||||
|
// catch: unit enabled, reconciler has not written env yet.
|
||||||
|
const launched = await new Promise<{ code: number | null; stderr: string }>((settle) => {
|
||||||
|
const child = execFile(
|
||||||
|
'/bin/bash',
|
||||||
|
[
|
||||||
|
'--noprofile',
|
||||||
|
'--norc',
|
||||||
|
join(tempHome!, '.config', 'mosaic', 'tools', 'fleet', 'start-agent-session.sh'),
|
||||||
|
'coder0',
|
||||||
|
],
|
||||||
|
{ env: { HOME: tempHome!, MOSAIC_AGENT_NAME: 'coder0', PATH: '/usr/bin:/bin' } },
|
||||||
|
(_error, _stdout, stderr) => {
|
||||||
|
settle({ code: child.exitCode, stderr });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(launched.code).not.toBe(0);
|
||||||
|
expect(launched.stderr).toContain('missing-file');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mosaic fleet add / remove — roster v2', (): void => {
|
||||||
|
it('add refuses, and names the two-step v2 sequence instead of inventing defaults', async (): Promise<void> => {
|
||||||
|
await v2Home();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
program().parseAsync([
|
||||||
|
'node',
|
||||||
|
'mosaic',
|
||||||
|
'fleet',
|
||||||
|
'add',
|
||||||
|
'coder2',
|
||||||
|
'--runtime',
|
||||||
|
'pi',
|
||||||
|
'--class',
|
||||||
|
'code',
|
||||||
|
]),
|
||||||
|
).rejects.toThrow(/mosaic fleet create[\s\S]*mosaic fleet apply/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('remove refuses, and names delete plus apply', async (): Promise<void> => {
|
||||||
|
await v2Home();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
program().parseAsync(['node', 'mosaic', 'fleet', 'remove', 'coder1']),
|
||||||
|
).rejects.toThrow(/mosaic fleet delete coder1[\s\S]*mosaic fleet apply/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Note: this one passes on the unmodified tree too — there `remove` throws in
|
||||||
|
// the v1 parser, before it can touch anything. It is a regression guard on the
|
||||||
|
// ordering of the new guard clause, not evidence that the fix works.
|
||||||
|
it('refuses BEFORE mutating the roster', async (): Promise<void> => {
|
||||||
|
const mosaicHome = await v2Home();
|
||||||
|
const rosterPath = join(mosaicHome, 'fleet', 'roster.yaml');
|
||||||
|
const before = await readFile(rosterPath, 'utf8');
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
program().parseAsync(['node', 'mosaic', 'fleet', 'remove', 'coder1']),
|
||||||
|
).rejects.toThrow();
|
||||||
|
|
||||||
|
expect(await readFile(rosterPath, 'utf8')).toBe(before);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1277,6 +1277,10 @@ describe('fleet command construction', () => {
|
|||||||
const home = await tempDir();
|
const home = await tempDir();
|
||||||
process.env.HOME = home;
|
process.env.HOME = home;
|
||||||
delete process.env.MOSAIC_HOME;
|
delete process.env.MOSAIC_HOME;
|
||||||
|
const runtimeDir = join(home, '.npm-global', 'bin');
|
||||||
|
await mkdir(runtimeDir, { recursive: true });
|
||||||
|
await writeFile(join(runtimeDir, 'pi'), '#!/bin/sh\nexit 0\n', { mode: 0o755 });
|
||||||
|
await writeFile(join(runtimeDir, 'mosaic'), '#!/bin/sh\nexit 0\n', { mode: 0o755 });
|
||||||
const mosaicHome = join(home, '.config', 'mosaic');
|
const mosaicHome = join(home, '.config', 'mosaic');
|
||||||
const program = new Command();
|
const program = new Command();
|
||||||
program.exitOverride();
|
program.exitOverride();
|
||||||
@@ -1315,6 +1319,10 @@ describe('fleet command construction', () => {
|
|||||||
const originalHome = process.env.HOME;
|
const originalHome = process.env.HOME;
|
||||||
const home = await tempDir();
|
const home = await tempDir();
|
||||||
process.env.HOME = home;
|
process.env.HOME = home;
|
||||||
|
const runtimeDir = join(home, '.npm-global', 'bin');
|
||||||
|
await mkdir(runtimeDir, { recursive: true });
|
||||||
|
await writeFile(join(runtimeDir, 'pi'), '#!/bin/sh\nexit 0\n', { mode: 0o755 });
|
||||||
|
await writeFile(join(runtimeDir, 'mosaic'), '#!/bin/sh\nexit 0\n', { mode: 0o755 });
|
||||||
const mosaicHome = join(home, '.config', 'mosaic');
|
const mosaicHome = join(home, '.config', 'mosaic');
|
||||||
const agentEnvDir = join(mosaicHome, 'fleet', 'agents');
|
const agentEnvDir = join(mosaicHome, 'fleet', 'agents');
|
||||||
const fleetDir = join(mosaicHome, 'fleet');
|
const fleetDir = join(mosaicHome, 'fleet');
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export {
|
|||||||
resolveInstalledFleetRosterPath,
|
resolveInstalledFleetRosterPath,
|
||||||
} from '../fleet/fleet-roster-v1.js';
|
} from '../fleet/fleet-roster-v1.js';
|
||||||
export type { FleetAgent, FleetRoster } from '../fleet/fleet-roster-v1.js';
|
export type { FleetAgent, FleetRoster } from '../fleet/fleet-roster-v1.js';
|
||||||
|
import { parseRosterV2 } from '../fleet/roster-v2.js';
|
||||||
import {
|
import {
|
||||||
registerFleetAgentCrudCommands,
|
registerFleetAgentCrudCommands,
|
||||||
type FleetAgentCrudCommandDeps,
|
type FleetAgentCrudCommandDeps,
|
||||||
@@ -59,6 +60,12 @@ import {
|
|||||||
writeAgentEnvironmentProjection,
|
writeAgentEnvironmentProjection,
|
||||||
writeManagedFleetRoster,
|
writeManagedFleetRoster,
|
||||||
} from '../fleet/generated-env-boundary.js';
|
} from '../fleet/generated-env-boundary.js';
|
||||||
|
import {
|
||||||
|
assertFleetRuntimeAvailability,
|
||||||
|
FleetRuntimePreflightError,
|
||||||
|
inspectFleetRuntimeAvailability,
|
||||||
|
type FleetRuntimeProbeRunner,
|
||||||
|
} from '../fleet/fleet-runtime-preflight.js';
|
||||||
import { registerFleetBacklogCommand } from './fleet-backlog.js';
|
import { registerFleetBacklogCommand } from './fleet-backlog.js';
|
||||||
import { registerFleetPersonaCommand } from './fleet-personas.js';
|
import { registerFleetPersonaCommand } from './fleet-personas.js';
|
||||||
import { registerFleetProfileCommand } from './fleet-profiles.js';
|
import { registerFleetProfileCommand } from './fleet-profiles.js';
|
||||||
@@ -88,6 +95,8 @@ export type SleepFn = (ms: number) => Promise<void>;
|
|||||||
|
|
||||||
export interface FleetCommandDeps {
|
export interface FleetCommandDeps {
|
||||||
runner?: CommandRunner;
|
runner?: CommandRunner;
|
||||||
|
/** Executes the pane-PATH helper under a clean launcher environment. */
|
||||||
|
runtimeProbeRunner?: FleetRuntimeProbeRunner;
|
||||||
/** Injectable interactive runner for commands needing inherited TTY (e.g., `tmux attach`). */
|
/** Injectable interactive runner for commands needing inherited TTY (e.g., `tmux attach`). */
|
||||||
interactiveRunner?: InteractiveRunner;
|
interactiveRunner?: InteractiveRunner;
|
||||||
/**
|
/**
|
||||||
@@ -820,7 +829,7 @@ export function buildEnableLingerCommand(user: string): string[] {
|
|||||||
*/
|
*/
|
||||||
export async function enableFleetUnits(
|
export async function enableFleetUnits(
|
||||||
runner: CommandRunner,
|
runner: CommandRunner,
|
||||||
roster: FleetRoster,
|
roster: { readonly agents: readonly { readonly name: string }[] },
|
||||||
opts: { enable?: boolean },
|
opts: { enable?: boolean },
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (opts.enable === false) {
|
if (opts.enable === false) {
|
||||||
@@ -1428,6 +1437,10 @@ export function isSendAccepted(capturedOutput: string): SendVerifyResult {
|
|||||||
|
|
||||||
export function registerFleetCommand(program: Command, deps: FleetCommandDeps = {}): Command {
|
export function registerFleetCommand(program: Command, deps: FleetCommandDeps = {}): Command {
|
||||||
const runner = deps.runner ?? runCommand;
|
const runner = deps.runner ?? runCommand;
|
||||||
|
const runtimeProbeRunner: FleetRuntimeProbeRunner =
|
||||||
|
deps.runtimeProbeRunner ??
|
||||||
|
(async (command: string, args: readonly string[]): Promise<CommandResult> =>
|
||||||
|
runCommand(command, [...args]));
|
||||||
const sleepFn = deps.sleepFn ?? defaultSleep;
|
const sleepFn = deps.sleepFn ?? defaultSleep;
|
||||||
const paths = resolveFleetPaths(deps.mosaicHome);
|
const paths = resolveFleetPaths(deps.mosaicHome);
|
||||||
const frameworkRoot = deps.frameworkRoot ?? resolveFrameworkRoot();
|
const frameworkRoot = deps.frameworkRoot ?? resolveFrameworkRoot();
|
||||||
@@ -1526,8 +1539,9 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
|||||||
.description('Install local fleet tools and user systemd units')
|
.description('Install local fleet tools and user systemd units')
|
||||||
.option('--no-enable', 'Skip enabling units for boot-survival')
|
.option('--no-enable', 'Skip enabling units for boot-survival')
|
||||||
.action(async (opts: { enable?: boolean }) => {
|
.action(async (opts: { enable?: boolean }) => {
|
||||||
await installFleet(cmd, frameworkRoot);
|
await installFleet(cmd, frameworkRoot, runtimeProbeRunner);
|
||||||
const roster = await loadRosterForCommand(cmd);
|
// Unit enablement needs agent names only, so it reads either version.
|
||||||
|
const roster = await loadRosterReadModel(cmd);
|
||||||
await enableFleetUnits(runner, roster, opts);
|
await enableFleetUnits(runner, roster, opts);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1536,8 +1550,9 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
|||||||
.description('Install local fleet tools and user systemd units')
|
.description('Install local fleet tools and user systemd units')
|
||||||
.option('--no-enable', 'Skip enabling units for boot-survival')
|
.option('--no-enable', 'Skip enabling units for boot-survival')
|
||||||
.action(async (opts: { enable?: boolean }) => {
|
.action(async (opts: { enable?: boolean }) => {
|
||||||
await installFleet(cmd, frameworkRoot);
|
await installFleet(cmd, frameworkRoot, runtimeProbeRunner);
|
||||||
const roster = await loadRosterForCommand(cmd);
|
// Unit enablement needs agent names only, so it reads either version.
|
||||||
|
const roster = await loadRosterReadModel(cmd);
|
||||||
await enableFleetUnits(runner, roster, opts);
|
await enableFleetUnits(runner, roster, opts);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1688,7 +1703,9 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
|||||||
.action(async (opts: { json?: boolean }) => {
|
.action(async (opts: { json?: boolean }) => {
|
||||||
const commandOpts = cmd.opts<{ mosaicHome: string; roster?: string }>();
|
const commandOpts = cmd.opts<{ mosaicHome: string; roster?: string }>();
|
||||||
const activePaths = resolveFleetPaths(commandOpts.mosaicHome);
|
const activePaths = resolveFleetPaths(commandOpts.mosaicHome);
|
||||||
const roster = await loadRosterForCommand(cmd);
|
// ps only reads, so it takes the version-agnostic read model rather than
|
||||||
|
// the v1 parser, which rejects a v2 roster outright.
|
||||||
|
const roster = await loadRosterReadModel(cmd);
|
||||||
const { tenant_id, host } = getDefaultTenantAndHost();
|
const { tenant_id, host } = getDefaultTenantAndHost();
|
||||||
const nowMs = Date.now();
|
const nowMs = Date.now();
|
||||||
|
|
||||||
@@ -1908,6 +1925,16 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
|||||||
start: boolean;
|
start: boolean;
|
||||||
},
|
},
|
||||||
) => {
|
) => {
|
||||||
|
if (await usesRosterV2ControlPlane(cmd)) {
|
||||||
|
// command.error, not a bare throw: this is operator guidance, and a
|
||||||
|
// bare throw reaches the top level uncaught and prints it under a Node
|
||||||
|
// stack trace. Measured on canary — the message is the whole point of
|
||||||
|
// the refusal, so it has to arrive readable.
|
||||||
|
cmd.error(rosterV2MutationGuidance('add', 'create', name), {
|
||||||
|
code: 'fleet.roster-v2',
|
||||||
|
exitCode: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
if (!VALID_FLEET_RUNTIMES.includes(opts.runtime)) {
|
if (!VALID_FLEET_RUNTIMES.includes(opts.runtime)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Invalid runtime "${opts.runtime}". Valid runtimes: ${VALID_FLEET_RUNTIMES.join(', ')}.`,
|
`Invalid runtime "${opts.runtime}". Valid runtimes: ${VALID_FLEET_RUNTIMES.join(', ')}.`,
|
||||||
@@ -1973,6 +2000,12 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
|||||||
.description('Remove an agent from the fleet roster')
|
.description('Remove an agent from the fleet roster')
|
||||||
.option('--keep-files', 'Skip deleting env and heartbeat files')
|
.option('--keep-files', 'Skip deleting env and heartbeat files')
|
||||||
.action(async (name: string, opts: { keepFiles?: boolean }) => {
|
.action(async (name: string, opts: { keepFiles?: boolean }) => {
|
||||||
|
if (await usesRosterV2ControlPlane(cmd)) {
|
||||||
|
cmd.error(rosterV2MutationGuidance('remove', 'delete', name), {
|
||||||
|
code: 'fleet.roster-v2',
|
||||||
|
exitCode: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
const commandOpts = cmd.opts<{ mosaicHome: string; roster?: string }>();
|
const commandOpts = cmd.opts<{ mosaicHome: string; roster?: string }>();
|
||||||
const activePaths = resolveFleetPaths(commandOpts.mosaicHome);
|
const activePaths = resolveFleetPaths(commandOpts.mosaicHome);
|
||||||
const rosterPath = await resolveRosterPath(commandOpts.mosaicHome, commandOpts.roster);
|
const rosterPath = await resolveRosterPath(commandOpts.mosaicHome, commandOpts.roster);
|
||||||
@@ -2063,6 +2096,8 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
|||||||
});
|
});
|
||||||
registerFleetReconcilerCommands(cmd, {
|
registerFleetReconcilerCommands(cmd, {
|
||||||
runner,
|
runner,
|
||||||
|
runtimeProbeRunner,
|
||||||
|
frameworkRoot,
|
||||||
mosaicHome: deps.mosaicHome,
|
mosaicHome: deps.mosaicHome,
|
||||||
reconcileDeps: deps.reconcileDeps,
|
reconcileDeps: deps.reconcileDeps,
|
||||||
});
|
});
|
||||||
@@ -2328,16 +2363,68 @@ export function registerFleetAgentCommands(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function installFleet(cmd: Command, frameworkRoot: string): Promise<void> {
|
async function installFleet(
|
||||||
|
cmd: Command,
|
||||||
|
frameworkRoot: string,
|
||||||
|
runtimeProbeRunner: FleetRuntimeProbeRunner,
|
||||||
|
): Promise<void> {
|
||||||
const activePaths = resolveFleetPaths(cmd.opts<{ mosaicHome: string }>().mosaicHome);
|
const activePaths = resolveFleetPaths(cmd.opts<{ mosaicHome: string }>().mosaicHome);
|
||||||
assertDefaultMosaicHomeForSystemd(activePaths.mosaicHome);
|
assertDefaultMosaicHomeForSystemd(activePaths.mosaicHome);
|
||||||
const roster = await loadRosterForCommand(cmd);
|
// Read and preflight before the first mkdir/copy/chmod/write. A successful
|
||||||
|
// install must mean every roster runtime is executable in the eventual pane,
|
||||||
|
// not merely visible to the operator who invoked this command.
|
||||||
|
const roster = await loadRosterReadModel(cmd);
|
||||||
|
const v1Roster = roster.version === 1 ? await loadRosterForCommand(cmd) : undefined;
|
||||||
|
const preflightV1Projections =
|
||||||
|
v1Roster === undefined
|
||||||
|
? []
|
||||||
|
: await Promise.all(
|
||||||
|
v1Roster.agents.map((agent: FleetAgent) =>
|
||||||
|
prepareAgentEnvironmentProjection({
|
||||||
|
mosaicHome: activePaths.mosaicHome,
|
||||||
|
agentEnvDir: activePaths.agentEnvDir,
|
||||||
|
agentName: agent.name,
|
||||||
|
generated: generateAgentEnvValues(v1Roster, agent),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const preflightAgents =
|
||||||
|
v1Roster === undefined
|
||||||
|
? roster.agents
|
||||||
|
: v1Roster.agents.map((agent: FleetAgent, index: number) => {
|
||||||
|
const prepared = preflightV1Projections[index];
|
||||||
|
if (prepared === undefined) {
|
||||||
|
throw new Error(`Missing prepared environment projection for ${agent.name}.`);
|
||||||
|
}
|
||||||
|
const local = parseAgentEnvironment(prepared.local, 'local');
|
||||||
|
return {
|
||||||
|
name: agent.name,
|
||||||
|
runtime: agent.runtime,
|
||||||
|
runtimeBin: local['MOSAIC_RUNTIME_BIN'] ?? '',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const runtimeInspection = await inspectFleetRuntimeAvailability({
|
||||||
|
mosaicHome: activePaths.mosaicHome,
|
||||||
|
agentEnvDir: activePaths.agentEnvDir,
|
||||||
|
helperPath: join(frameworkRoot, 'tools', 'fleet', 'pane-runtime-path.sh'),
|
||||||
|
agents: preflightAgents,
|
||||||
|
runner: runtimeProbeRunner,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
assertFleetRuntimeAvailability(runtimeInspection);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (error instanceof FleetRuntimePreflightError) {
|
||||||
|
cmd.error(error.message, { code: 'fleet.runtime-preflight', exitCode: 1 });
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
await ensureFleetHolderIdentity(activePaths.mosaicHome);
|
await ensureFleetHolderIdentity(activePaths.mosaicHome);
|
||||||
await mkdir(activePaths.fleetToolsDir, { recursive: true });
|
await mkdir(activePaths.fleetToolsDir, { recursive: true });
|
||||||
await mkdir(activePaths.tmuxToolsDir, { recursive: true });
|
await mkdir(activePaths.tmuxToolsDir, { recursive: true });
|
||||||
await mkdir(activePaths.systemdUserDir, { recursive: true });
|
await mkdir(activePaths.systemdUserDir, { recursive: true });
|
||||||
|
|
||||||
const startAgentSessionPath = join(activePaths.fleetToolsDir, 'start-agent-session.sh');
|
const startAgentSessionPath = join(activePaths.fleetToolsDir, 'start-agent-session.sh');
|
||||||
|
const paneRuntimePath = join(activePaths.fleetToolsDir, 'pane-runtime-path.sh');
|
||||||
const startInteractionServicePath = join(
|
const startInteractionServicePath = join(
|
||||||
activePaths.fleetToolsDir,
|
activePaths.fleetToolsDir,
|
||||||
'start-interaction-service.sh',
|
'start-interaction-service.sh',
|
||||||
@@ -2351,6 +2438,7 @@ async function installFleet(cmd: Command, frameworkRoot: string): Promise<void>
|
|||||||
const agentSendPath = join(activePaths.tmuxToolsDir, 'agent-send.sh');
|
const agentSendPath = join(activePaths.tmuxToolsDir, 'agent-send.sh');
|
||||||
const executableToolPaths = [
|
const executableToolPaths = [
|
||||||
startAgentSessionPath,
|
startAgentSessionPath,
|
||||||
|
paneRuntimePath,
|
||||||
startInteractionServicePath,
|
startInteractionServicePath,
|
||||||
startTmuxHolderPath,
|
startTmuxHolderPath,
|
||||||
printInteractionPolicyPath,
|
printInteractionPolicyPath,
|
||||||
@@ -2361,6 +2449,7 @@ async function installFleet(cmd: Command, frameworkRoot: string): Promise<void>
|
|||||||
join(frameworkRoot, 'tools', 'fleet', 'start-agent-session.sh'),
|
join(frameworkRoot, 'tools', 'fleet', 'start-agent-session.sh'),
|
||||||
startAgentSessionPath,
|
startAgentSessionPath,
|
||||||
);
|
);
|
||||||
|
await copyFile(join(frameworkRoot, 'tools', 'fleet', 'pane-runtime-path.sh'), paneRuntimePath);
|
||||||
await copyFile(
|
await copyFile(
|
||||||
join(frameworkRoot, 'tools', 'fleet', 'start-interaction-service.sh'),
|
join(frameworkRoot, 'tools', 'fleet', 'start-interaction-service.sh'),
|
||||||
startInteractionServicePath,
|
startInteractionServicePath,
|
||||||
@@ -2391,16 +2480,32 @@ async function installFleet(cmd: Command, frameworkRoot: string): Promise<void>
|
|||||||
join(activePaths.systemdUserDir, '[email protected]'),
|
join(activePaths.systemdUserDir, '[email protected]'),
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const agent of roster.agents) {
|
// On roster v2 the reconciler owns the generated env: `apply` writes it and
|
||||||
|
// `regen` rebuilds it, both from projectRosterV2AgentGeneratedEnv. Writing it
|
||||||
|
// here too — necessarily through the v1 mapping — would be the third writer of
|
||||||
|
// one file and would break the #791 single-SSOT invariant. So v2 gets the tool
|
||||||
|
// files and the units, and nothing else.
|
||||||
|
if (roster.version === 2) {
|
||||||
|
console.log(
|
||||||
|
`Installed fleet tools and systemd units for ${roster.agents.length} agent(s). ` +
|
||||||
|
`Generated env is owned by the reconciler on roster v2 — run: mosaic fleet apply --expected-generation <n>`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (v1Roster === undefined) {
|
||||||
|
throw new Error('Roster version changed while installing fleet files.');
|
||||||
|
}
|
||||||
|
for (const agent of v1Roster.agents) {
|
||||||
await writeAgentEnvironmentProjection({
|
await writeAgentEnvironmentProjection({
|
||||||
mosaicHome: activePaths.mosaicHome,
|
mosaicHome: activePaths.mosaicHome,
|
||||||
agentEnvDir: activePaths.agentEnvDir,
|
agentEnvDir: activePaths.agentEnvDir,
|
||||||
agentName: agent.name,
|
agentName: agent.name,
|
||||||
generated: generateAgentEnvValues(roster, agent),
|
generated: generateAgentEnvValues(v1Roster, agent),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Installed fleet files for ${roster.agents.length} agent(s).`);
|
console.log(`Installed fleet files for ${v1Roster.agents.length} agent(s).`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadRosterForCommand(cmd: Command): Promise<FleetRoster> {
|
async function loadRosterForCommand(cmd: Command): Promise<FleetRoster> {
|
||||||
@@ -2427,6 +2532,77 @@ async function usesRosterV2ControlPlane(cmd: Command): Promise<boolean> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `add`/`remove` and `create`/`delete` are not two spellings of one operation.
|
||||||
|
* The v1 pair edits the roster *and* drives systemd; the v2 pair is documented
|
||||||
|
* as changing desired state "without runtime actions", leaving convergence to
|
||||||
|
* `apply`. `add` also collects four fields where a v2 agent requires eleven, so
|
||||||
|
* routing it to `create` would mean inventing provider, alias, reasoning and
|
||||||
|
* tool-policy defaults on the operator's behalf. Refusing with the real command
|
||||||
|
* is honest; silently guessing an agent's provider is not.
|
||||||
|
*/
|
||||||
|
function rosterV2MutationGuidance(
|
||||||
|
v1Command: 'add' | 'remove',
|
||||||
|
v2Command: 'create' | 'delete',
|
||||||
|
name: string,
|
||||||
|
): string {
|
||||||
|
const target = v2Command === 'delete' ? ` ${name}` : '';
|
||||||
|
return (
|
||||||
|
`mosaic fleet ${v1Command} does not operate on a roster-v2 fleet. ` +
|
||||||
|
`Roster v2 separates desired state from convergence:\n` +
|
||||||
|
` 1. mosaic fleet ${v2Command}${target} --expected-generation <current> ` +
|
||||||
|
`${v2Command === 'create' ? "--agent '<json>' " : ''}` +
|
||||||
|
`(edits the roster only)\n` +
|
||||||
|
` 2. mosaic fleet apply --expected-generation <new> (converges systemd and tmux)\n` +
|
||||||
|
`Read the current generation with: mosaic fleet status`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The read-only fields shared by roster v1 and v2, for the commands that only
|
||||||
|
* ever *read* the roster (`ps`, and unit enablement inside `install`).
|
||||||
|
*
|
||||||
|
* This is deliberately NOT a v2→v1 downshift. A downshifted `FleetRoster` would
|
||||||
|
* be accepted by `generateAgentEnvValues`, and that would make a third writer of
|
||||||
|
* `fleet/agents/<name>.env.generated` — through the v1 mapping — breaking the
|
||||||
|
* #791 single-SSOT invariant that {@link projectRosterV2AgentGeneratedEnv} is
|
||||||
|
* documented to hold. Keeping the read model this small makes that misuse
|
||||||
|
* impossible: there is nothing here to write a roster or an env file back from.
|
||||||
|
*/
|
||||||
|
interface FleetRosterReadModel {
|
||||||
|
readonly version: 1 | 2;
|
||||||
|
readonly tmux: { readonly socketName: string; readonly holderSession: string };
|
||||||
|
readonly agents: readonly {
|
||||||
|
readonly name: string;
|
||||||
|
readonly alias?: string;
|
||||||
|
readonly runtime: string;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reads either roster version into the shared read-only view. */
|
||||||
|
async function loadRosterReadModel(cmd: Command): Promise<FleetRosterReadModel> {
|
||||||
|
const opts = cmd.opts<{ mosaicHome: string; roster?: string }>();
|
||||||
|
const path = await resolveRosterPath(opts.mosaicHome, opts.roster);
|
||||||
|
if (!(await usesRosterV2ControlPlane(cmd))) {
|
||||||
|
const v1 = await loadRosterAtPath(cmd, path);
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
tmux: { socketName: v1.tmux.socketName, holderSession: v1.tmux.holderSession },
|
||||||
|
agents: v1.agents,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const v2 = parseRosterV2(await readFleetRosterText(path), 'yaml');
|
||||||
|
return {
|
||||||
|
version: 2,
|
||||||
|
tmux: { socketName: v2.tmux.socketName, holderSession: v2.tmux.holderSession },
|
||||||
|
agents: v2.agents,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
reportFleetRosterConfigurationError(cmd, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadRosterFromAgentCommand(
|
async function loadRosterFromAgentCommand(
|
||||||
command: Command,
|
command: Command,
|
||||||
mosaicHomeOverride?: string,
|
mosaicHomeOverride?: string,
|
||||||
|
|||||||
@@ -0,0 +1,328 @@
|
|||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join, resolve } from 'node:path';
|
||||||
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
inspectFleetRuntimeAvailability,
|
||||||
|
type FleetRuntimeProbeResult,
|
||||||
|
type FleetRuntimeProbeRunner,
|
||||||
|
} from './fleet-runtime-preflight.js';
|
||||||
|
|
||||||
|
const helperPath = resolve(process.cwd(), 'framework', 'tools', 'fleet', 'pane-runtime-path.sh');
|
||||||
|
let cleanup: string | undefined;
|
||||||
|
|
||||||
|
afterEach(async (): Promise<void> => {
|
||||||
|
if (cleanup !== undefined) await rm(cleanup, { recursive: true, force: true });
|
||||||
|
cleanup = undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
interface FleetFixture {
|
||||||
|
readonly root: string;
|
||||||
|
readonly mosaicHome: string;
|
||||||
|
readonly agentEnvDir: string;
|
||||||
|
readonly runtimeDir: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fleetHome(): Promise<FleetFixture> {
|
||||||
|
const root = await mkdtemp(join(tmpdir(), 'mosaic-fleet-runtime-preflight-'));
|
||||||
|
cleanup = root;
|
||||||
|
const mosaicHome = join(root, '.config', 'mosaic');
|
||||||
|
const agentEnvDir = join(mosaicHome, 'fleet', 'agents');
|
||||||
|
const runtimeDir = join(root, '.npm-global', 'bin');
|
||||||
|
await mkdir(agentEnvDir, { recursive: true, mode: 0o700 });
|
||||||
|
await mkdir(runtimeDir, { recursive: true });
|
||||||
|
for (const directory of [mosaicHome, join(mosaicHome, 'fleet'), agentEnvDir]) {
|
||||||
|
await chmod(directory, 0o700);
|
||||||
|
}
|
||||||
|
await writeExecutable(runtimeDir, 'mosaic', '#!/bin/sh\nexit 0\n');
|
||||||
|
return { root, mosaicHome, agentEnvDir, runtimeDir };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeExecutable(directory: string, name: string, content: string): Promise<void> {
|
||||||
|
await mkdir(directory, { recursive: true });
|
||||||
|
await writeFile(join(directory, name), content, { mode: 0o755 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const processRunner: FleetRuntimeProbeRunner = async (
|
||||||
|
command: string,
|
||||||
|
args: readonly string[],
|
||||||
|
): Promise<FleetRuntimeProbeResult> =>
|
||||||
|
new Promise((settle) => {
|
||||||
|
const child = spawn(command, [...args], { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||||
|
let stdout = '';
|
||||||
|
let stderr = '';
|
||||||
|
child.stdout.setEncoding('utf8');
|
||||||
|
child.stderr.setEncoding('utf8');
|
||||||
|
child.stdout.on('data', (chunk: string): void => {
|
||||||
|
stdout += chunk;
|
||||||
|
});
|
||||||
|
child.stderr.on('data', (chunk: string): void => {
|
||||||
|
stderr += chunk;
|
||||||
|
});
|
||||||
|
child.on('error', (error: Error): void => {
|
||||||
|
settle({ stdout, stderr: `${stderr}${error.message}`, exitCode: 127 });
|
||||||
|
});
|
||||||
|
child.on('close', (code: number | null): void => {
|
||||||
|
settle({ stdout, stderr, exitCode: code ?? 1 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fleet runtime preflight', (): void => {
|
||||||
|
it('executes one distinct pane runtime and aggregates every requesting roster row', async (): Promise<void> => {
|
||||||
|
const fixture = await fleetHome();
|
||||||
|
await writeExecutable(fixture.runtimeDir, 'pi', '#!/bin/sh\nexit 0\n');
|
||||||
|
let probes = 0;
|
||||||
|
|
||||||
|
const inspection = await inspectFleetRuntimeAvailability({
|
||||||
|
mosaicHome: fixture.mosaicHome,
|
||||||
|
agentEnvDir: fixture.agentEnvDir,
|
||||||
|
helperPath,
|
||||||
|
agents: [
|
||||||
|
{ name: 'coder1', runtime: 'pi' },
|
||||||
|
{ name: 'coder0', runtime: 'pi' },
|
||||||
|
],
|
||||||
|
runner: async (command, args): Promise<FleetRuntimeProbeResult> => {
|
||||||
|
probes += 1;
|
||||||
|
return processRunner(command, args);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(probes).toBe(2);
|
||||||
|
expect(inspection.fleetCliExecutable).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
check: 'fleet-cli-executable',
|
||||||
|
status: 'ok',
|
||||||
|
requestedBy: ['coder0', 'coder1'],
|
||||||
|
binaryPath: join(fixture.runtimeDir, 'mosaic'),
|
||||||
|
dependency: '/bin/sh',
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
expect(inspection.fleetRuntimeAvailability).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
check: 'fleet-runtime-available',
|
||||||
|
runtime: 'pi',
|
||||||
|
status: 'ok',
|
||||||
|
requestedBy: ['coder0', 'coder1'],
|
||||||
|
binaryPath: join(fixture.runtimeDir, 'pi'),
|
||||||
|
dependency: '/bin/sh',
|
||||||
|
installCommand: 'npm install -g @earendil-works/pi-coding-agent',
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
expect(inspection.fleetRuntimeAvailability[0]?.panePath).toContain(fixture.runtimeDir);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an actionable non-green check when the pane PATH lacks the runtime', async (): Promise<void> => {
|
||||||
|
const fixture = await fleetHome();
|
||||||
|
|
||||||
|
const inspection = await inspectFleetRuntimeAvailability({
|
||||||
|
mosaicHome: fixture.mosaicHome,
|
||||||
|
agentEnvDir: fixture.agentEnvDir,
|
||||||
|
helperPath,
|
||||||
|
agents: [{ name: 'coder0', runtime: 'pi' }],
|
||||||
|
runner: processRunner,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(inspection.fleetCliExecutable[0]?.status).toBe('ok');
|
||||||
|
expect(inspection.fleetRuntimeAvailability).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
check: 'fleet-runtime-available',
|
||||||
|
runtime: 'pi',
|
||||||
|
status: 'missing',
|
||||||
|
requestedBy: ['coder0'],
|
||||||
|
installCommand: 'npm install -g @earendil-works/pi-coding-agent',
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
expect(inspection.fleetRuntimeAvailability[0]?.panePath).not.toContain(
|
||||||
|
process.env['PATH'] ?? 'operator-path-absent',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('executes the side-effect-free Node version probe for Node-shebang commands', async (): Promise<void> => {
|
||||||
|
const fixture = await fleetHome();
|
||||||
|
const nodeScript = '#!/usr/bin/env node\nconsole.log("should-not-run");\n';
|
||||||
|
await writeExecutable(fixture.runtimeDir, 'mosaic', nodeScript);
|
||||||
|
await writeExecutable(fixture.runtimeDir, 'pi', nodeScript);
|
||||||
|
await writeExecutable(
|
||||||
|
fixture.runtimeDir,
|
||||||
|
'node',
|
||||||
|
'#!/bin/sh\n[ "$1" = --version ] || exit 9\nprintf "v-fixture-node\\n"\n',
|
||||||
|
);
|
||||||
|
|
||||||
|
const inspection = await inspectFleetRuntimeAvailability({
|
||||||
|
mosaicHome: fixture.mosaicHome,
|
||||||
|
agentEnvDir: fixture.agentEnvDir,
|
||||||
|
helperPath,
|
||||||
|
agents: [{ name: 'coder0', runtime: 'pi' }],
|
||||||
|
runner: processRunner,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const check of [
|
||||||
|
...inspection.fleetCliExecutable,
|
||||||
|
...inspection.fleetRuntimeAvailability,
|
||||||
|
]) {
|
||||||
|
expect(check).toMatchObject({
|
||||||
|
status: 'ok',
|
||||||
|
dependency: 'node',
|
||||||
|
probeCommand: 'node --version',
|
||||||
|
probeExit: 0,
|
||||||
|
probeOutput: 'v-fixture-node',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reddens when resolved Node-shebang commands cannot execute without pane Node', async (): Promise<void> => {
|
||||||
|
const fixture = await fleetHome();
|
||||||
|
const isolatedSystemPath = join(fixture.root, 'system-bin');
|
||||||
|
await mkdir(isolatedSystemPath, { recursive: true });
|
||||||
|
await writeFile(
|
||||||
|
join(fixture.root, '.npmrc'),
|
||||||
|
`prefix=${join(fixture.root, 'absent-prefix')}\n`,
|
||||||
|
);
|
||||||
|
const nodeScript = '#!/usr/bin/env node\nconsole.log("should-not-run");\n';
|
||||||
|
await writeExecutable(fixture.runtimeDir, 'mosaic', nodeScript);
|
||||||
|
await writeExecutable(fixture.runtimeDir, 'pi', nodeScript);
|
||||||
|
|
||||||
|
const inspection = await inspectFleetRuntimeAvailability({
|
||||||
|
mosaicHome: fixture.mosaicHome,
|
||||||
|
agentEnvDir: fixture.agentEnvDir,
|
||||||
|
helperPath,
|
||||||
|
agents: [{ name: 'coder0', runtime: 'pi' }],
|
||||||
|
runner: processRunner,
|
||||||
|
systemPath: isolatedSystemPath,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(inspection.fleetCliExecutable).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
check: 'fleet-cli-executable',
|
||||||
|
status: 'unexecutable',
|
||||||
|
binaryPath: join(fixture.runtimeDir, 'mosaic'),
|
||||||
|
dependency: 'node',
|
||||||
|
probeCommand: 'node --version',
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
expect(inspection.fleetRuntimeAvailability).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
check: 'fleet-runtime-available',
|
||||||
|
runtime: 'pi',
|
||||||
|
status: 'unexecutable',
|
||||||
|
binaryPath: join(fixture.runtimeDir, 'pi'),
|
||||||
|
dependency: 'node',
|
||||||
|
probeCommand: 'node --version',
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
expect(inspection.fleetCliExecutable[0]?.probeOutput).toBe(
|
||||||
|
'shebang command is not on the pane PATH',
|
||||||
|
);
|
||||||
|
expect(inspection.fleetCliExecutable[0]?.panePath).not.toContain('/usr/bin');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps distinct effective local runtime-bin paths as distinct checks', async (): Promise<void> => {
|
||||||
|
const fixture = await fleetHome();
|
||||||
|
const firstBin = join(fixture.root, 'first-bin');
|
||||||
|
const secondBin = join(fixture.root, 'second-bin');
|
||||||
|
for (const override of [
|
||||||
|
{ agent: 'coder0', runtimeBin: firstBin },
|
||||||
|
{ agent: 'coder1', runtimeBin: secondBin },
|
||||||
|
]) {
|
||||||
|
await writeExecutable(override.runtimeBin, 'pi', '#!/bin/sh\nexit 0\n');
|
||||||
|
await writeFile(
|
||||||
|
join(fixture.agentEnvDir, `${override.agent}.env.local`),
|
||||||
|
`MOSAIC_RUNTIME_BIN=${override.runtimeBin}\n`,
|
||||||
|
{ mode: 0o600 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const inspection = await inspectFleetRuntimeAvailability({
|
||||||
|
mosaicHome: fixture.mosaicHome,
|
||||||
|
agentEnvDir: fixture.agentEnvDir,
|
||||||
|
helperPath,
|
||||||
|
agents: [
|
||||||
|
{ name: 'coder0', runtime: 'pi' },
|
||||||
|
{ name: 'coder1', runtime: 'pi' },
|
||||||
|
],
|
||||||
|
runner: processRunner,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(inspection.fleetCliExecutable).toHaveLength(2);
|
||||||
|
expect(inspection.fleetRuntimeAvailability).toHaveLength(2);
|
||||||
|
expect(inspection.fleetRuntimeAvailability.map((check) => check.requestedBy)).toEqual([
|
||||||
|
['coder0'],
|
||||||
|
['coder1'],
|
||||||
|
]);
|
||||||
|
expect(inspection.fleetRuntimeAvailability.map((check) => check.binaryPath)).toEqual([
|
||||||
|
join(firstBin, 'pi'),
|
||||||
|
join(secondBin, 'pi'),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports each distinct roster runtime with its exact install command', async (): Promise<void> => {
|
||||||
|
const fixture = await fleetHome();
|
||||||
|
let probes = 0;
|
||||||
|
|
||||||
|
const inspection = await inspectFleetRuntimeAvailability({
|
||||||
|
mosaicHome: fixture.mosaicHome,
|
||||||
|
agentEnvDir: fixture.agentEnvDir,
|
||||||
|
helperPath,
|
||||||
|
agents: [
|
||||||
|
{ name: 'pi-seat', runtime: 'pi' },
|
||||||
|
{ name: 'claude-seat', runtime: 'claude' },
|
||||||
|
{ name: 'codex-seat', runtime: 'codex' },
|
||||||
|
{ name: 'opencode-seat', runtime: 'opencode' },
|
||||||
|
],
|
||||||
|
runner: async (): Promise<FleetRuntimeProbeResult> => {
|
||||||
|
probes += 1;
|
||||||
|
return {
|
||||||
|
stdout:
|
||||||
|
'pane_path\u0000/fixture/bin:/usr/bin:/bin\u0000status\u0000missing\u0000' +
|
||||||
|
'binary_path\u0000\u0000probe_exit\u0000\u0000probe_output\u0000\u0000',
|
||||||
|
stderr: '',
|
||||||
|
exitCode: 69,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(probes).toBe(5);
|
||||||
|
expect(
|
||||||
|
inspection.fleetRuntimeAvailability.map((check) => ({
|
||||||
|
runtime: check.runtime,
|
||||||
|
installCommand: check.installCommand,
|
||||||
|
})),
|
||||||
|
).toEqual([
|
||||||
|
{
|
||||||
|
runtime: 'claude',
|
||||||
|
installCommand: 'curl -fsSL https://claude.ai/install.sh | bash',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
runtime: 'codex',
|
||||||
|
installCommand: 'npm install -g @openai/codex',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
runtime: 'opencode',
|
||||||
|
installCommand: 'npm install -g opencode-ai',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
runtime: 'pi',
|
||||||
|
installCommand: 'npm install -g @earendil-works/pi-coding-agent',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails closed when the shared helper returns malformed evidence', async (): Promise<void> => {
|
||||||
|
const fixture = await fleetHome();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
inspectFleetRuntimeAvailability({
|
||||||
|
mosaicHome: fixture.mosaicHome,
|
||||||
|
agentEnvDir: fixture.agentEnvDir,
|
||||||
|
helperPath,
|
||||||
|
agents: [{ name: 'coder0', runtime: 'pi' }],
|
||||||
|
runner: async (): Promise<FleetRuntimeProbeResult> => ({
|
||||||
|
stdout: 'not-a-field-protocol',
|
||||||
|
stderr: '',
|
||||||
|
exitCode: 0,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
).rejects.toThrow('malformed field output');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,362 @@
|
|||||||
|
import { homedir } from 'node:os';
|
||||||
|
import { getInstallInstructions } from '../runtime/detector.js';
|
||||||
|
import type { RuntimeName } from '../types.js';
|
||||||
|
import { compareCodePoints } from './deterministic-order.js';
|
||||||
|
import {
|
||||||
|
GENERATED_AGENT_ENV_SUPPORTED_RUNTIMES,
|
||||||
|
readAgentLocalEnvironment,
|
||||||
|
} from './generated-env-boundary.js';
|
||||||
|
|
||||||
|
const RUNTIME_SET = new Set<string>(GENERATED_AGENT_ENV_SUPPORTED_RUNTIMES);
|
||||||
|
|
||||||
|
export interface FleetRuntimeRequestedAgent {
|
||||||
|
readonly name: string;
|
||||||
|
readonly runtime: string;
|
||||||
|
/** Planned effective local override, when provisioning has already prepared it. */
|
||||||
|
readonly runtimeBin?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FleetRuntimeProbeResult {
|
||||||
|
readonly stdout: string;
|
||||||
|
readonly stderr: string;
|
||||||
|
readonly exitCode: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FleetRuntimeProbeRunner = (
|
||||||
|
command: string,
|
||||||
|
args: readonly string[],
|
||||||
|
) => Promise<FleetRuntimeProbeResult>;
|
||||||
|
|
||||||
|
export interface FleetRuntimePreflightOptions {
|
||||||
|
readonly mosaicHome: string;
|
||||||
|
readonly agentEnvDir: string;
|
||||||
|
readonly helperPath: string;
|
||||||
|
readonly agents: readonly FleetRuntimeRequestedAgent[];
|
||||||
|
readonly runner: FleetRuntimeProbeRunner;
|
||||||
|
/** Test-only system suffix; production and the launcher use the helper default. */
|
||||||
|
readonly systemPath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FleetExecutableStatus = 'ok' | 'missing' | 'unexecutable';
|
||||||
|
|
||||||
|
interface FleetExecutableEvidence {
|
||||||
|
readonly status: FleetExecutableStatus;
|
||||||
|
readonly panePath: string;
|
||||||
|
readonly binaryPath?: string;
|
||||||
|
readonly dependency?: string;
|
||||||
|
readonly probeCommand?: string;
|
||||||
|
readonly probeExit?: number;
|
||||||
|
readonly probeOutput?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FleetCliExecutableCheck extends FleetExecutableEvidence {
|
||||||
|
readonly check: 'fleet-cli-executable';
|
||||||
|
readonly binary: 'mosaic';
|
||||||
|
readonly requestedBy: readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FleetRuntimeCheck extends FleetExecutableEvidence {
|
||||||
|
readonly check: 'fleet-runtime-available';
|
||||||
|
readonly runtime: RuntimeName;
|
||||||
|
readonly requestedBy: readonly string[];
|
||||||
|
readonly installCommand: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FleetRuntimePreflightCheck = FleetCliExecutableCheck | FleetRuntimeCheck;
|
||||||
|
|
||||||
|
export interface FleetRuntimeInspection {
|
||||||
|
readonly fleetCliExecutable: readonly FleetCliExecutableCheck[];
|
||||||
|
readonly fleetRuntimeAvailability: readonly FleetRuntimeCheck[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EffectiveAgent {
|
||||||
|
readonly name: string;
|
||||||
|
readonly runtime: RuntimeName;
|
||||||
|
readonly runtimeBin: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PaneProbeGroup {
|
||||||
|
readonly runtimeBin: string;
|
||||||
|
readonly requestedBy: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RuntimeProbeGroup extends PaneProbeGroup {
|
||||||
|
readonly runtime: RuntimeName;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BinaryProbeRequest {
|
||||||
|
readonly binary: string;
|
||||||
|
readonly runtimeBin: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FleetRuntimePreflightError extends Error {
|
||||||
|
readonly checks: readonly FleetRuntimePreflightCheck[];
|
||||||
|
|
||||||
|
constructor(checks: readonly FleetRuntimePreflightCheck[]) {
|
||||||
|
super(formatFleetRuntimePreflightError(checks));
|
||||||
|
this.name = FleetRuntimePreflightError.name;
|
||||||
|
this.checks = checks;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FleetRuntimeProbeError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = FleetRuntimeProbeError.name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proves the fleet CLI and every distinct runtime/effective-bin pair resolve
|
||||||
|
* with an executable shebang interpreter through the eventual pane PATH. The
|
||||||
|
* helper runs under the unit's clean launcher environment, so operator PATH can
|
||||||
|
* neither create a false green nor provide a hidden interpreter.
|
||||||
|
*/
|
||||||
|
export async function inspectFleetRuntimeAvailability(
|
||||||
|
options: FleetRuntimePreflightOptions,
|
||||||
|
): Promise<FleetRuntimeInspection> {
|
||||||
|
const agents = await resolveEffectiveAgents(options);
|
||||||
|
const paneGroups = groupPaneRequests(agents);
|
||||||
|
const runtimeGroups = groupRuntimeRequests(agents);
|
||||||
|
|
||||||
|
const fleetCliExecutable: FleetCliExecutableCheck[] = [];
|
||||||
|
for (const group of paneGroups) {
|
||||||
|
const evidence = await probeBinary(options, {
|
||||||
|
binary: 'mosaic',
|
||||||
|
runtimeBin: group.runtimeBin,
|
||||||
|
});
|
||||||
|
fleetCliExecutable.push({
|
||||||
|
check: 'fleet-cli-executable',
|
||||||
|
binary: 'mosaic',
|
||||||
|
requestedBy: sortedRequestedBy(group.requestedBy),
|
||||||
|
...evidence,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const fleetRuntimeAvailability: FleetRuntimeCheck[] = [];
|
||||||
|
for (const group of runtimeGroups) {
|
||||||
|
const evidence = await probeBinary(options, {
|
||||||
|
binary: group.runtime,
|
||||||
|
runtimeBin: group.runtimeBin,
|
||||||
|
});
|
||||||
|
fleetRuntimeAvailability.push({
|
||||||
|
check: 'fleet-runtime-available',
|
||||||
|
runtime: group.runtime,
|
||||||
|
requestedBy: sortedRequestedBy(group.requestedBy),
|
||||||
|
installCommand: getInstallInstructions(group.runtime),
|
||||||
|
...evidence,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.freeze({
|
||||||
|
fleetCliExecutable: Object.freeze(fleetCliExecutable),
|
||||||
|
fleetRuntimeAvailability: Object.freeze(fleetRuntimeAvailability),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertFleetRuntimeAvailability(inspection: FleetRuntimeInspection): void {
|
||||||
|
const checks: FleetRuntimePreflightCheck[] = [
|
||||||
|
...inspection.fleetCliExecutable,
|
||||||
|
...inspection.fleetRuntimeAvailability,
|
||||||
|
];
|
||||||
|
const failures = checks.filter(
|
||||||
|
(check: FleetRuntimePreflightCheck): boolean => check.status !== 'ok',
|
||||||
|
);
|
||||||
|
if (failures.length > 0) throw new FleetRuntimePreflightError(failures);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatFleetRuntimePreflightError(
|
||||||
|
checks: readonly FleetRuntimePreflightCheck[],
|
||||||
|
): string {
|
||||||
|
const lines = ['Fleet runtime preflight failed:'];
|
||||||
|
for (const check of checks) {
|
||||||
|
const dependency = check.dependency === undefined ? '' : ` dependency=${check.dependency}`;
|
||||||
|
const probe = check.probeCommand === undefined ? '' : ` dependency_probe=${check.probeCommand}`;
|
||||||
|
const execution =
|
||||||
|
check.status === 'unexecutable'
|
||||||
|
? ` probe_exit=${check.probeExit?.toString() ?? 'not-run'} ` +
|
||||||
|
`probe_output=${JSON.stringify(check.probeOutput ?? '')}`
|
||||||
|
: '';
|
||||||
|
if (check.check === 'fleet-cli-executable') {
|
||||||
|
lines.push(
|
||||||
|
`check=${check.check} binary=${check.binary} ` +
|
||||||
|
`requested_by=${check.requestedBy.join(',')} pane_path=${check.panePath}` +
|
||||||
|
`${dependency}${probe}${execution} ` +
|
||||||
|
'action=repair the Mosaic installation until its pane dependencies resolve',
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
lines.push(
|
||||||
|
`check=${check.check} runtime=${check.runtime} ` +
|
||||||
|
`requested_by=${check.requestedBy.join(',')} pane_path=${check.panePath}` +
|
||||||
|
`${dependency}${probe}${execution} install_command=${check.installCommand}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveEffectiveAgents(
|
||||||
|
options: FleetRuntimePreflightOptions,
|
||||||
|
): Promise<readonly EffectiveAgent[]> {
|
||||||
|
const agents: EffectiveAgent[] = [];
|
||||||
|
for (const agent of options.agents) {
|
||||||
|
if (!isRuntimeName(agent.runtime)) {
|
||||||
|
throw new FleetRuntimeProbeError(`Unsupported fleet runtime: ${agent.runtime}`);
|
||||||
|
}
|
||||||
|
const runtimeBin =
|
||||||
|
agent.runtimeBin ??
|
||||||
|
(
|
||||||
|
await readAgentLocalEnvironment({
|
||||||
|
mosaicHome: options.mosaicHome,
|
||||||
|
agentEnvDir: options.agentEnvDir,
|
||||||
|
agentName: agent.name,
|
||||||
|
})
|
||||||
|
)['MOSAIC_RUNTIME_BIN'] ??
|
||||||
|
'';
|
||||||
|
agents.push({ name: agent.name, runtime: agent.runtime, runtimeBin });
|
||||||
|
}
|
||||||
|
return agents;
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupPaneRequests(agents: readonly EffectiveAgent[]): readonly PaneProbeGroup[] {
|
||||||
|
const groups = new Map<string, PaneProbeGroup>();
|
||||||
|
for (const agent of agents) {
|
||||||
|
const current = groups.get(agent.runtimeBin);
|
||||||
|
if (current === undefined) {
|
||||||
|
groups.set(agent.runtimeBin, { runtimeBin: agent.runtimeBin, requestedBy: [agent.name] });
|
||||||
|
} else {
|
||||||
|
current.requestedBy.push(agent.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...groups.values()].sort((left, right): number =>
|
||||||
|
compareCodePoints(left.runtimeBin, right.runtimeBin),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupRuntimeRequests(agents: readonly EffectiveAgent[]): readonly RuntimeProbeGroup[] {
|
||||||
|
const groups = new Map<string, RuntimeProbeGroup>();
|
||||||
|
for (const agent of agents) {
|
||||||
|
const key = JSON.stringify([agent.runtime, agent.runtimeBin]);
|
||||||
|
const current = groups.get(key);
|
||||||
|
if (current === undefined) {
|
||||||
|
groups.set(key, {
|
||||||
|
runtime: agent.runtime,
|
||||||
|
runtimeBin: agent.runtimeBin,
|
||||||
|
requestedBy: [agent.name],
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
current.requestedBy.push(agent.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...groups.values()].sort((left, right): number =>
|
||||||
|
compareCodePoints(
|
||||||
|
`${left.runtime}\u0000${left.runtimeBin}`,
|
||||||
|
`${right.runtime}\u0000${right.runtimeBin}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function probeBinary(
|
||||||
|
options: FleetRuntimePreflightOptions,
|
||||||
|
probe: BinaryProbeRequest,
|
||||||
|
): Promise<FleetExecutableEvidence> {
|
||||||
|
const args = [
|
||||||
|
'-i',
|
||||||
|
`HOME=${process.env['HOME'] ?? homedir()}`,
|
||||||
|
'PATH=/usr/bin:/bin',
|
||||||
|
`MOSAIC_HOME=${options.mosaicHome}`,
|
||||||
|
'/bin/bash',
|
||||||
|
'--noprofile',
|
||||||
|
'--norc',
|
||||||
|
options.helperPath,
|
||||||
|
'--mosaic-home',
|
||||||
|
options.mosaicHome,
|
||||||
|
'--binary',
|
||||||
|
probe.binary,
|
||||||
|
'--check-executable',
|
||||||
|
];
|
||||||
|
if (probe.runtimeBin !== '') args.push('--runtime-bin', probe.runtimeBin);
|
||||||
|
if (options.systemPath !== undefined) args.push('--system-path', options.systemPath);
|
||||||
|
|
||||||
|
const result = await options.runner('/usr/bin/env', args);
|
||||||
|
const fields = parseNulFields(result.stdout);
|
||||||
|
const panePath = requiredField(fields, 'pane_path');
|
||||||
|
const status = requiredField(fields, 'status');
|
||||||
|
if (result.exitCode === 0 && status === 'present') {
|
||||||
|
return executableEvidence('ok', panePath, fields);
|
||||||
|
}
|
||||||
|
if (result.exitCode === 69 && status === 'missing') {
|
||||||
|
return { status: 'missing', panePath };
|
||||||
|
}
|
||||||
|
if (result.exitCode === 70 && status === 'unexecutable') {
|
||||||
|
return executableEvidence('unexecutable', panePath, fields);
|
||||||
|
}
|
||||||
|
throw new FleetRuntimeProbeError(
|
||||||
|
`Fleet executable probe failed: binary=${probe.binary} exit=${result.exitCode.toString()} ` +
|
||||||
|
`stderr=${JSON.stringify(result.stderr.trim())}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function executableEvidence(
|
||||||
|
status: 'ok' | 'unexecutable',
|
||||||
|
panePath: string,
|
||||||
|
fields: ReadonlyMap<string, string>,
|
||||||
|
): FleetExecutableEvidence {
|
||||||
|
const dependency = requiredField(fields, 'dependency');
|
||||||
|
const probeCommand = requiredField(fields, 'probe_command');
|
||||||
|
const probeExit = requiredField(fields, 'probe_exit');
|
||||||
|
const probeOutput = requiredField(fields, 'probe_output');
|
||||||
|
return {
|
||||||
|
status,
|
||||||
|
panePath,
|
||||||
|
binaryPath: requiredField(fields, 'binary_path'),
|
||||||
|
...(dependency === '' ? {} : { dependency }),
|
||||||
|
...(probeCommand === '' ? {} : { probeCommand }),
|
||||||
|
...(probeExit === '' ? {} : { probeExit: parseProbeExit(probeExit) }),
|
||||||
|
...(probeOutput === '' ? {} : { probeOutput }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortedRequestedBy(requestedBy: readonly string[]): readonly string[] {
|
||||||
|
return Object.freeze(
|
||||||
|
[...requestedBy].sort((left: string, right: string): number => compareCodePoints(left, right)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseNulFields(source: string): ReadonlyMap<string, string> {
|
||||||
|
const parts = source.split('\u0000');
|
||||||
|
if (parts.at(-1) === '') parts.pop();
|
||||||
|
if (parts.length % 2 !== 0) {
|
||||||
|
throw new FleetRuntimeProbeError('Fleet runtime probe returned malformed field output.');
|
||||||
|
}
|
||||||
|
const fields = new Map<string, string>();
|
||||||
|
for (let index = 0; index < parts.length; index += 2) {
|
||||||
|
const key = parts[index];
|
||||||
|
const value = parts[index + 1];
|
||||||
|
if (key === undefined || value === undefined || key === '' || fields.has(key)) {
|
||||||
|
throw new FleetRuntimeProbeError('Fleet runtime probe returned malformed field output.');
|
||||||
|
}
|
||||||
|
fields.set(key, value);
|
||||||
|
}
|
||||||
|
return fields;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requiredField(fields: ReadonlyMap<string, string>, key: string): string {
|
||||||
|
const value = fields.get(key);
|
||||||
|
if (value === undefined) {
|
||||||
|
throw new FleetRuntimeProbeError(`Fleet runtime probe omitted ${key}.`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseProbeExit(value: string): number {
|
||||||
|
const exitCode = Number(value);
|
||||||
|
if (!Number.isSafeInteger(exitCode) || exitCode < 0) {
|
||||||
|
throw new FleetRuntimeProbeError('Fleet runtime probe returned an invalid execution status.');
|
||||||
|
}
|
||||||
|
return exitCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRuntimeName(value: string): value is RuntimeName {
|
||||||
|
return RUNTIME_SET.has(value);
|
||||||
|
}
|
||||||
@@ -25,6 +25,12 @@ export interface AgentGeneratedProjectionDeletionOptions {
|
|||||||
readonly agentName: string;
|
readonly agentName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AgentLocalEnvironmentReadOptions {
|
||||||
|
readonly mosaicHome: string;
|
||||||
|
readonly agentEnvDir: string;
|
||||||
|
readonly agentName: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AgentEnvironmentProjectionResult {
|
export interface AgentEnvironmentProjectionResult {
|
||||||
readonly generatedPath: string;
|
readonly generatedPath: string;
|
||||||
readonly localPath: string;
|
readonly localPath: string;
|
||||||
@@ -145,6 +151,23 @@ export function parseAgentEnvironment(
|
|||||||
return Object.freeze(values);
|
return Object.freeze(values);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads one agent's optional local overrides through the same path, file-type,
|
||||||
|
* permission, key, and value boundary used by projection/launch handling.
|
||||||
|
*/
|
||||||
|
export async function readAgentLocalEnvironment(
|
||||||
|
options: AgentLocalEnvironmentReadOptions,
|
||||||
|
): Promise<Readonly<Record<string, string>>> {
|
||||||
|
if (!AGENT_NAME.test(options.agentName)) {
|
||||||
|
throw new AgentEnvBoundaryError('unsafe-agent-name', 'MOSAIC_AGENT_NAME', options.agentName);
|
||||||
|
}
|
||||||
|
await validatePrivateProjectionDirectory(options.mosaicHome, options.agentEnvDir);
|
||||||
|
const source = await readOptionalPrivateFile(
|
||||||
|
join(options.agentEnvDir, `${options.agentName}.env.local`),
|
||||||
|
);
|
||||||
|
return source === undefined ? Object.freeze({}) : parseAgentEnvironment(source, 'local');
|
||||||
|
}
|
||||||
|
|
||||||
/** Renders the roster-derived generated projection in a stable, complete key order. */
|
/** Renders the roster-derived generated projection in a stable, complete key order. */
|
||||||
export function renderGeneratedAgentEnvironment(values: Readonly<Record<string, string>>): string {
|
export function renderGeneratedAgentEnvironment(values: Readonly<Record<string, string>>): string {
|
||||||
const normalized = normalizeGeneratedValues(values);
|
const normalized = normalizeGeneratedValues(values);
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { existsSync } from 'node:fs';
|
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { homedir, platform } from 'node:os';
|
import { homedir, platform } from 'node:os';
|
||||||
|
|
||||||
@@ -22,15 +21,18 @@ export function getShellProfilePath(): string | null {
|
|||||||
|
|
||||||
const shell = detectShell();
|
const shell = detectShell();
|
||||||
switch (shell) {
|
switch (shell) {
|
||||||
|
// Both of these deliberately avoid the interactive-only rc files.
|
||||||
|
// Debian's default .bashrc returns early for non-interactive shells, so a
|
||||||
|
// PATH line appended to it never runs for `bash -lc`, systemd units, or
|
||||||
|
// agent seats — an install could report success and still leave `mosaic`
|
||||||
|
// unreachable. .profile is read by login shells and sources .bashrc for
|
||||||
|
// interactive ones, so one line covers both; .zshenv is zsh's equivalent.
|
||||||
case 'zsh': {
|
case 'zsh': {
|
||||||
const zdotdir = process.env['ZDOTDIR'] ?? home;
|
const zdotdir = process.env['ZDOTDIR'] ?? home;
|
||||||
return join(zdotdir, '.zshrc');
|
return join(zdotdir, '.zshenv');
|
||||||
}
|
}
|
||||||
case 'bash': {
|
case 'bash':
|
||||||
const bashrc = join(home, '.bashrc');
|
|
||||||
if (existsSync(bashrc)) return bashrc;
|
|
||||||
return join(home, '.profile');
|
return join(home, '.profile');
|
||||||
}
|
|
||||||
case 'fish':
|
case 'fish':
|
||||||
return join(home, '.config', 'fish', 'config.fish');
|
return join(home, '.config', 'fish', 'config.fish');
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ const RUNTIME_DEFS: Record<
|
|||||||
label: 'Claude Code',
|
label: 'Claude Code',
|
||||||
command: 'claude',
|
command: 'claude',
|
||||||
versionFlag: '--version',
|
versionFlag: '--version',
|
||||||
installHint: 'npm install -g @anthropic-ai/claude-code',
|
installHint: 'curl -fsSL https://claude.ai/install.sh | bash',
|
||||||
},
|
},
|
||||||
codex: {
|
codex: {
|
||||||
label: 'Codex',
|
label: 'Codex',
|
||||||
@@ -31,13 +31,13 @@ const RUNTIME_DEFS: Record<
|
|||||||
label: 'OpenCode',
|
label: 'OpenCode',
|
||||||
command: 'opencode',
|
command: 'opencode',
|
||||||
versionFlag: 'version',
|
versionFlag: 'version',
|
||||||
installHint: 'See https://opencode.ai for install instructions',
|
installHint: 'npm install -g opencode-ai',
|
||||||
},
|
},
|
||||||
pi: {
|
pi: {
|
||||||
label: 'Pi',
|
label: 'Pi',
|
||||||
command: 'pi',
|
command: 'pi',
|
||||||
versionFlag: '--version',
|
versionFlag: '--version',
|
||||||
installHint: 'curl -fsSL https://pi.dev/install.sh | sh',
|
installHint: 'npm install -g @earendil-works/pi-coding-agent',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,137 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Tests for newest_matching_file() in tools/install.sh.
|
|
||||||
#
|
|
||||||
# The function answers one question -- "which is the most recent backup / tarball
|
|
||||||
# here?" -- and its callers act destructively on the answer. Three ways of getting it
|
|
||||||
# wrong have already been found, and each has a case below:
|
|
||||||
#
|
|
||||||
# * `ls -1t | head -1` returns 141 under `set -o pipefail` once the listing fills a
|
|
||||||
# pipe buffer (~1600 names), because head closes the pipe and ls takes SIGPIPE.
|
|
||||||
# Callers assign it at top level under `set -e`, so a 141 aborts the run.
|
|
||||||
# * `mapfile` is a Bash 4 builtin. macOS ships Bash 3.2 and the installer supports
|
|
||||||
# Darwin, so the whole lookup was unavailable there -- and an empty answer is what
|
|
||||||
# sends the uninstaller down its delete-the-destination branch.
|
|
||||||
# * Any line-based parse of `ls` splits a filename containing a newline into two
|
|
||||||
# wrong answers.
|
|
||||||
#
|
|
||||||
# The large-population and newline cases are the point: with two or three ordinary
|
|
||||||
# names every version of this function passes, which is why the first two went
|
|
||||||
# unnoticed.
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
||||||
TMP="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-newest-match-test-XXXXXX")"
|
|
||||||
trap 'rm -rf "$TMP"' EXIT
|
|
||||||
|
|
||||||
# Load the function under test and the mtime helper it depends on, with the same
|
|
||||||
# shell options install.sh runs under.
|
|
||||||
eval "$(sed -n '/^_MTIME_STYLE=/,/^}/p' "$ROOT/tools/install.sh")"
|
|
||||||
eval "$(sed -n '/^newest_matching_file()/,/^}/p' "$ROOT/tools/install.sh")"
|
|
||||||
|
|
||||||
POPULATED="$TMP/many"
|
|
||||||
mkdir -p "$POPULATED"
|
|
||||||
# Enough names to overflow a 64 KiB pipe buffer several times over.
|
|
||||||
for i in $(seq 1 5000); do
|
|
||||||
: > "$POPULATED/mosaicstack-mosaic-0.0.${i}.tgz"
|
|
||||||
done
|
|
||||||
sleep 1
|
|
||||||
: > "$POPULATED/mosaicstack-mosaic-9.9.9.tgz"
|
|
||||||
|
|
||||||
echo "[test] the newest match is returned from a directory large enough to fill a pipe"
|
|
||||||
GOT="$(newest_matching_file "$POPULATED" 'mosaicstack-mosaic-*.tgz')"
|
|
||||||
[[ "$(basename "$GOT")" == "mosaicstack-mosaic-9.9.9.tgz" ]] || {
|
|
||||||
echo "expected the newest tarball, got '${GOT}'" >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
echo "[test] a large population does not make the lookup fail"
|
|
||||||
set +e
|
|
||||||
newest_matching_file "$POPULATED" 'mosaicstack-mosaic-*.tgz' >/dev/null
|
|
||||||
RC=$?
|
|
||||||
set -e
|
|
||||||
[[ "$RC" -eq 0 ]] || { echo "expected rc=0, got ${RC} (141 means the SIGPIPE regression is back)" >&2; exit 1; }
|
|
||||||
|
|
||||||
echo "[test] a small population still works"
|
|
||||||
SMALL="$TMP/few"
|
|
||||||
mkdir -p "$SMALL"
|
|
||||||
: > "$SMALL/mosaicstack-gateway-0.0.1.tgz"
|
|
||||||
sleep 1
|
|
||||||
: > "$SMALL/mosaicstack-gateway-0.0.2.tgz"
|
|
||||||
GOT="$(newest_matching_file "$SMALL" 'mosaicstack-gateway-*.tgz')"
|
|
||||||
[[ "$(basename "$GOT")" == "mosaicstack-gateway-0.0.2.tgz" ]] || {
|
|
||||||
echo "expected the newer gateway tarball, got '${GOT}'" >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
echo "[test] a name containing a space is returned whole"
|
|
||||||
SPACED="$TMP/spaced"
|
|
||||||
mkdir -p "$SPACED"
|
|
||||||
: > "$SPACED/agents.md.mosaic-bak-one two"
|
|
||||||
GOT="$(newest_matching_file "$SPACED" 'agents.md.mosaic-bak-*')"
|
|
||||||
[[ "$GOT" == "$SPACED/agents.md.mosaic-bak-one two" ]] || {
|
|
||||||
echo "expected the spaced name intact, got '${GOT}'" >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
echo "[test] a name containing a newline is returned whole, not split"
|
|
||||||
# The old `ls -1t` parse reported this file as two separate shorter names, neither of
|
|
||||||
# which exists -- so the caller saw a backup path that could not be restored.
|
|
||||||
NEWLINE="$TMP/newline"
|
|
||||||
mkdir -p "$NEWLINE"
|
|
||||||
WEIRD="$NEWLINE/agents.md.mosaic-bak-$(printf 'a\nb')"
|
|
||||||
: > "$WEIRD"
|
|
||||||
GOT="$(newest_matching_file "$NEWLINE" 'agents.md.mosaic-bak-*')"
|
|
||||||
[[ "$GOT" == "$WEIRD" ]] || {
|
|
||||||
echo "expected the newline-containing name intact, got '${GOT}'" >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
[[ -f "$GOT" ]] || { echo "the returned path does not name a real file" >&2; exit 1; }
|
|
||||||
|
|
||||||
echo "[test] no match is an empty answer, not an error"
|
|
||||||
EMPTY="$TMP/none"
|
|
||||||
mkdir -p "$EMPTY"
|
|
||||||
set +e
|
|
||||||
GOT="$(newest_matching_file "$EMPTY" 'nothing-*.tgz')"
|
|
||||||
RC=$?
|
|
||||||
set -e
|
|
||||||
[[ "$RC" -eq 0 && -z "$GOT" ]] || { echo "expected empty output and rc=0, got '${GOT}' rc=${RC}" >&2; exit 1; }
|
|
||||||
|
|
||||||
echo "[test] a directory that does not exist is an empty answer, not an error"
|
|
||||||
set +e
|
|
||||||
GOT="$(newest_matching_file "$TMP/absent" 'nothing-*.tgz')"
|
|
||||||
RC=$?
|
|
||||||
set -e
|
|
||||||
[[ "$RC" -eq 0 && -z "$GOT" ]] || { echo "expected empty output and rc=0, got '${GOT}' rc=${RC}" >&2; exit 1; }
|
|
||||||
|
|
||||||
echo "[test] an unanswerable lookup fails loudly instead of reporting no match"
|
|
||||||
# This is the distinction the uninstaller depends on. "No backup exists" is licence to
|
|
||||||
# delete the destination; "I could not tell" must never reach that branch.
|
|
||||||
_MTIME_STYLE=none
|
|
||||||
set +e
|
|
||||||
GOT="$(newest_matching_file "$SMALL" 'mosaicstack-gateway-*.tgz')"
|
|
||||||
RC=$?
|
|
||||||
set -e
|
|
||||||
_MTIME_STYLE=""
|
|
||||||
[[ "$RC" -ne 0 ]] || {
|
|
||||||
echo "expected a non-zero rc when no mtime source is usable, got rc=0 output '${GOT}'" >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
echo "[test] the installer uses no Bash 4 syntax"
|
|
||||||
# A lint, not an execution test: this host has no Bash 3.2 to run under. It is still
|
|
||||||
# the thing that stops the regression, because every Bash 4 construct that has broken
|
|
||||||
# macOS here was introduced by someone who never ran the script there either.
|
|
||||||
# Comments are stripped first -- the ones above name these constructs on purpose.
|
|
||||||
BASH4_HITS="$(
|
|
||||||
sed 's/#.*$//' "$ROOT/tools/install.sh" \
|
|
||||||
| grep -nE '(^|[^[:alnum:]_])(mapfile|readarray)([^[:alnum:]_]|$)|declare[[:space:]]+-[a-zA-Z]*A|local[[:space:]]+-[a-zA-Z]*A|\$\{[A-Za-z_][A-Za-z0-9_]*(\^\^|,,)' \
|
|
||||||
|| true
|
|
||||||
)"
|
|
||||||
[[ -z "$BASH4_HITS" ]] || {
|
|
||||||
echo "tools/install.sh uses Bash 4+ syntax, which macOS's Bash 3.2 cannot run:" >&2
|
|
||||||
echo "$BASH4_HITS" >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
echo "[test] newest_matching_file tests passed"
|
|
||||||
@@ -153,21 +153,17 @@ reset_state() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
reset_state
|
reset_state
|
||||||
# The installer now provisions Node itself, so Node 20 no longer stops a --next
|
|
||||||
# install -- it gets replaced. What still has to hold is that the >= 22 gate fires
|
|
||||||
# before anything is installed, so this asserts it on the one lane where refusing is
|
|
||||||
# still the outcome. The replacement path is covered by install-node-provisioning.test.sh.
|
|
||||||
echo "[test] --next rejects Node 20 before any install action"
|
echo "[test] --next rejects Node 20 before any install action"
|
||||||
if OUTPUT="$(
|
if OUTPUT="$(
|
||||||
HOME="$HOME_DIR" MOSAIC_HOME="$MOSAIC_HOME" MOSAIC_PREFIX="$PREFIX" MOSAIC_NO_COLOR=1 \
|
HOME="$HOME_DIR" MOSAIC_HOME="$MOSAIC_HOME" MOSAIC_PREFIX="$PREFIX" MOSAIC_NO_COLOR=1 \
|
||||||
MOSAIC_TEST_NPM_LOG="$LOG" MOSAIC_TEST_STATE="$STATE" MOSAIC_TEST_REAL_NODE="$REAL_NODE" \
|
MOSAIC_TEST_NPM_LOG="$LOG" MOSAIC_TEST_STATE="$STATE" MOSAIC_TEST_REAL_NODE="$REAL_NODE" \
|
||||||
MOSAIC_TEST_NODE_MAJOR=20 PATH="$FAKE_BIN:$PATH" \
|
MOSAIC_TEST_NODE_MAJOR=20 PATH="$FAKE_BIN:$PATH" \
|
||||||
bash "$ROOT/tools/install.sh" --cli --next --yes --no-auto-launch --no-node-install 2>&1
|
bash "$ROOT/tools/install.sh" --cli --next --yes --no-auto-launch 2>&1
|
||||||
)"; then
|
)"; then
|
||||||
echo "expected Node 20 next-lane install to fail" >&2
|
echo "expected Node 20 next-lane install to fail" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
grep -qF 'Node >= 22 required and --no-node-install was given.' <<<"$OUTPUT"
|
grep -qF 'Node.js >= 22 required for the --next lane' <<<"$OUTPUT"
|
||||||
[[ ! -s "$LOG" ]] || { echo "Node 20 gate ran npm actions" >&2; exit 1; }
|
[[ ! -s "$LOG" ]] || { echo "Node 20 gate ran npm actions" >&2; exit 1; }
|
||||||
|
|
||||||
reset_state
|
reset_state
|
||||||
|
|||||||
@@ -1,460 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Tests for the installer's Node provisioning.
|
|
||||||
#
|
|
||||||
# The installer's whole promise is that one command turns a bare host into a working
|
|
||||||
# one. Node was the exception: it was a hard prerequisite the installer checked and
|
|
||||||
# refused, so on a greenfield host the documented one-command install failed first.
|
|
||||||
# These tests pin the fixed behaviour, including the refusals.
|
|
||||||
#
|
|
||||||
# Everything runs offline. MOSAIC_NODE_DIST points at a local directory laid out like
|
|
||||||
# nodejs.org/dist, served over file:// -- so the download, the checksum gate, and the
|
|
||||||
# unpack are the real code paths, with no network and no real Node download.
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
||||||
TMP="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-node-provision-test-XXXXXX")"
|
|
||||||
trap 'rm -rf "$TMP"' EXIT
|
|
||||||
|
|
||||||
DIST="$TMP/dist"
|
|
||||||
FAKE_BIN="$TMP/bin"
|
|
||||||
HOME_DIR="$TMP/home"
|
|
||||||
PREFIX="$TMP/prefix"
|
|
||||||
MOSAIC_HOME_DIR="$TMP/mosaic"
|
|
||||||
STATE="$TMP/state"
|
|
||||||
LOG="$TMP/npm.log"
|
|
||||||
NODE_HOME="$TMP/nodehome"
|
|
||||||
mkdir -p "$DIST" "$FAKE_BIN" "$HOME_DIR" "$STATE"
|
|
||||||
|
|
||||||
REAL_NODE="$(command -v node)"
|
|
||||||
|
|
||||||
# The platform triple, derived the same way the installer derives it.
|
|
||||||
case "$(uname -s)" in
|
|
||||||
Linux) TEST_OS=linux ;;
|
|
||||||
Darwin) TEST_OS=darwin ;;
|
|
||||||
*) echo "[skip] no Node build for $(uname -s)"; exit 0 ;;
|
|
||||||
esac
|
|
||||||
case "$(uname -m)" in
|
|
||||||
x86_64|amd64) TEST_ARCH=x64 ;;
|
|
||||||
aarch64|arm64) TEST_ARCH=arm64 ;;
|
|
||||||
armv7l) TEST_ARCH=armv7l ;;
|
|
||||||
*) echo "[skip] no Node build for $(uname -m)"; exit 0 ;;
|
|
||||||
esac
|
|
||||||
PLATFORM="${TEST_OS}-${TEST_ARCH}"
|
|
||||||
|
|
||||||
VERSION=v22.99.0 # the one that must be chosen
|
|
||||||
MID_VERSION=v22.50.0 # same major, older -- catches "take the last match"
|
|
||||||
OLD_VERSION=v20.99.0 # wrong major
|
|
||||||
NEWER_MAJOR=v24.99.0 # listed first -- catches "take the first entry"
|
|
||||||
|
|
||||||
# ─── fixtures ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
# A node stub that answers the installer's version probe and defers everything else
|
|
||||||
# to the real interpreter, so the rest of the install still runs.
|
|
||||||
#
|
|
||||||
# The major is baked in per stub rather than read from the environment. A shared env
|
|
||||||
# var would be read by the downloaded Node too, so the "system Node is too old" case
|
|
||||||
# would install a replacement that also claimed to be too old.
|
|
||||||
write_node_stub() {
|
|
||||||
local path="$1" major="${2:-22}"
|
|
||||||
cat > "$path" <<STUB
|
|
||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
if [[ "\$*" == *'process.versions.node.split'* ]]; then
|
|
||||||
printf '%s' "${major}"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
if [[ "\${1:-}" == "--version" ]]; then
|
|
||||||
printf 'v%s.99.0\n' "${major}"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
exec "\${MOSAIC_TEST_REAL_NODE:?}" "\$@"
|
|
||||||
STUB
|
|
||||||
chmod +x "$path"
|
|
||||||
}
|
|
||||||
|
|
||||||
write_npm_stub() {
|
|
||||||
cat > "$1" <<'STUB'
|
|
||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
echo "$*" >> "${MOSAIC_TEST_NPM_LOG:?}"
|
|
||||||
STATE="${MOSAIC_TEST_STATE:?}"
|
|
||||||
if [[ "${1:-}" == "view" ]]; then
|
|
||||||
case "$2 $3" in
|
|
||||||
"@mosaicstack/mosaic@next version") echo "0.0.50-next.999" ;;
|
|
||||||
"@mosaicstack/gateway@next version") echo "0.0.7-next.999" ;;
|
|
||||||
"@mosaicstack/mosaic version") echo "0.0.49" ;;
|
|
||||||
*) echo "unexpected npm view: $*" >&2; exit 1 ;;
|
|
||||||
esac
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
if [[ "${1:-}" == "install" ]]; then
|
|
||||||
case "$*" in
|
|
||||||
*"@mosaicstack/mosaic@"*) echo "0.0.50-next.999" > "$STATE/mosaic" ;;
|
|
||||||
*"@mosaicstack/gateway@"*) echo "0.0.7-next.999" > "$STATE/gateway" ;;
|
|
||||||
esac
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
if [[ "${1:-}" == "ls" ]]; then
|
|
||||||
printf '{"dependencies":{"@mosaicstack/mosaic":{"version":"%s"},"@mosaicstack/gateway":{"version":"%s"}}}\n' \
|
|
||||||
"$(cat "$STATE/mosaic" 2>/dev/null || echo '')" \
|
|
||||||
"$(cat "$STATE/gateway" 2>/dev/null || echo '')"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
exit 0
|
|
||||||
STUB
|
|
||||||
chmod +x "$1"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Build a nodejs.org-shaped release: the tarball, and a SHASUMS256.txt over it.
|
|
||||||
publish_release() {
|
|
||||||
local version="$1" corrupt_checksum="${2:-false}"
|
|
||||||
local base="node-${version}-${PLATFORM}"
|
|
||||||
local stage="$TMP/stage-${version}"
|
|
||||||
rm -rf "$stage"
|
|
||||||
mkdir -p "$stage/${base}/bin"
|
|
||||||
write_node_stub "$stage/${base}/bin/node" "$(sed 's/^v//; s/\..*//' <<<"$version")"
|
|
||||||
write_npm_stub "$stage/${base}/bin/npm"
|
|
||||||
|
|
||||||
mkdir -p "${DIST}/${version}"
|
|
||||||
tar -czf "${DIST}/${version}/${base}.tar.gz" -C "$stage" "$base"
|
|
||||||
|
|
||||||
local sum
|
|
||||||
if command -v sha256sum &>/dev/null; then
|
|
||||||
sum="$(sha256sum "${DIST}/${version}/${base}.tar.gz" | awk '{print $1}')"
|
|
||||||
else
|
|
||||||
sum="$(shasum -a 256 "${DIST}/${version}/${base}.tar.gz" | awk '{print $1}')"
|
|
||||||
fi
|
|
||||||
if [[ "$corrupt_checksum" == "true" ]]; then
|
|
||||||
sum="0000000000000000000000000000000000000000000000000000000000000000"
|
|
||||||
fi
|
|
||||||
printf '%s %s.tar.gz\n' "$sum" "$base" > "${DIST}/${version}/SHASUMS256.txt"
|
|
||||||
}
|
|
||||||
|
|
||||||
publish_release "$VERSION"
|
|
||||||
publish_release "$MID_VERSION"
|
|
||||||
publish_release "$OLD_VERSION"
|
|
||||||
publish_release "$NEWER_MAJOR"
|
|
||||||
# Newest-first, as nodejs.org publishes it. Every wrong entry is genuinely installable,
|
|
||||||
# so a resolver that picks one fails on the assertion rather than on a 404 -- the
|
|
||||||
# assertion is then about version selection and not about the fixture.
|
|
||||||
printf '[{"version":"%s"},{"version":"%s"},{"version":"%s"},{"version":"%s"}]\n' \
|
|
||||||
"$NEWER_MAJOR" "$VERSION" "$MID_VERSION" "$OLD_VERSION" > "$DIST/index.json"
|
|
||||||
|
|
||||||
# A PATH with the usual tools but no Node toolchain, so "a host with no Node" is
|
|
||||||
# actually true on a developer machine and in CI, both of which have one installed.
|
|
||||||
NONODE_BIN="$TMP/nonode-bin"
|
|
||||||
mkdir -p "$NONODE_BIN"
|
|
||||||
for candidate in /usr/bin/* /bin/*; do
|
|
||||||
[[ -e "$candidate" ]] || continue
|
|
||||||
case "$(basename "$candidate")" in
|
|
||||||
node|npm|npx|corepack|nodejs) continue ;;
|
|
||||||
esac
|
|
||||||
ln -sf "$candidate" "$NONODE_BIN/$(basename "$candidate")" 2>/dev/null || true
|
|
||||||
done
|
|
||||||
if PATH="$NONODE_BIN" command -v node &>/dev/null; then
|
|
||||||
echo "[skip] could not build a Node-free PATH on this host" >&2
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
reset_home() {
|
|
||||||
rm -rf "$HOME_DIR" "$PREFIX" "$MOSAIC_HOME_DIR" "$NODE_HOME" "$LOG" "$STATE"
|
|
||||||
mkdir -p "$HOME_DIR" "$STATE"
|
|
||||||
: > "$LOG"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Run the installer with no Node anywhere on PATH.
|
|
||||||
run_bare() {
|
|
||||||
env -u npm_config_prefix \
|
|
||||||
HOME="$HOME_DIR" \
|
|
||||||
MOSAIC_HOME="$MOSAIC_HOME_DIR" \
|
|
||||||
MOSAIC_PREFIX="$PREFIX" \
|
|
||||||
MOSAIC_NO_COLOR=1 \
|
|
||||||
MOSAIC_NODE_HOME="$NODE_HOME" \
|
|
||||||
MOSAIC_NODE_DIST="file://${DIST}" \
|
|
||||||
MOSAIC_TEST_REAL_NODE="$REAL_NODE" \
|
|
||||||
MOSAIC_TEST_NPM_LOG="$LOG" \
|
|
||||||
MOSAIC_TEST_STATE="$STATE" \
|
|
||||||
PATH="$NONODE_BIN" \
|
|
||||||
bash "$ROOT/tools/install.sh" "$@"
|
|
||||||
}
|
|
||||||
|
|
||||||
# ─── tests ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
reset_home
|
|
||||||
echo "[test] a host with no Node gets one, and the CLI install proceeds"
|
|
||||||
OUTPUT="$(run_bare --cli --next --yes --no-auto-launch 2>&1)"
|
|
||||||
grep -qF -- "Node is not installed" <<<"$OUTPUT"
|
|
||||||
grep -qF -- "Installed Node ${VERSION}" <<<"$OUTPUT"
|
|
||||||
[[ -x "${NODE_HOME}/${VERSION}/bin/node" ]]
|
|
||||||
grep -qF -- "install -g @mosaicstack/[email protected]" "$LOG"
|
|
||||||
|
|
||||||
echo "[test] the newest release of the required major is chosen"
|
|
||||||
# The index lists a higher major first and an older release of the right major after
|
|
||||||
# the right answer, so "first entry" and "last match" both produce a wrong directory.
|
|
||||||
[[ -d "${NODE_HOME}/${VERSION}" ]]
|
|
||||||
[[ ! -d "${NODE_HOME}/${NEWER_MAJOR}" ]]
|
|
||||||
[[ ! -d "${NODE_HOME}/${MID_VERSION}" ]]
|
|
||||||
[[ ! -d "${NODE_HOME}/${OLD_VERSION}" ]]
|
|
||||||
|
|
||||||
echo "[test] future shells can find both Node and the CLI"
|
|
||||||
grep -qF -- "export PATH=\"${NODE_HOME}/${VERSION}/bin:\$PATH\"" "$HOME_DIR/.profile"
|
|
||||||
grep -qF -- "export PATH=\"${PREFIX}/bin:\$PATH\"" "$HOME_DIR/.profile"
|
|
||||||
# Debian's .bashrc returns early when non-interactive, so the login profile is the
|
|
||||||
# one that matters -- but an interactive non-login shell only reads .bashrc.
|
|
||||||
grep -qF -- "export PATH=\"${NODE_HOME}/${VERSION}/bin:\$PATH\"" "$HOME_DIR/.bashrc"
|
|
||||||
grep -qF -- "export PATH=\"${PREFIX}/bin:\$PATH\"" "$HOME_DIR/.bashrc"
|
|
||||||
|
|
||||||
echo "[test] a real login shell resolves node, not just the text of a profile line"
|
|
||||||
# Grepping the file only proves the installer wrote something. This starts an actual
|
|
||||||
# login shell against that HOME and asks it to find the binary.
|
|
||||||
RESOLVED="$(env -i HOME="$HOME_DIR" PATH="$NONODE_BIN" TERM=dumb bash -lc 'command -v node')"
|
|
||||||
[[ "$RESOLVED" == "${NODE_HOME}/${VERSION}/bin/node" ]] || {
|
|
||||||
echo "a login shell resolved node to '${RESOLVED}'" >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
echo "[test] a systemd --user unit gets the same PATH, via environment.d"
|
|
||||||
# Units read no shell file at all, which is how a Mosaic agent seat starts.
|
|
||||||
ENVD="$HOME_DIR/.config/environment.d/50-mosaic-path.conf"
|
|
||||||
[[ -f "$ENVD" ]] || { echo "no environment.d drop-in was written" >&2; exit 1; }
|
|
||||||
grep -qF -- "PATH=${NODE_HOME}/${VERSION}/bin:\${PATH}" "$ENVD"
|
|
||||||
grep -qF -- "PATH=${PREFIX}/bin:\${PATH}" "$ENVD"
|
|
||||||
|
|
||||||
echo "[test] re-running reuses the Node it installed and does not duplicate PATH lines"
|
|
||||||
OUTPUT="$(run_bare --cli --next --yes --no-auto-launch 2>&1)"
|
|
||||||
grep -qF -- "from ${NODE_HOME}" <<<"$OUTPUT"
|
|
||||||
[[ "$(grep -c 'export PATH=' "$HOME_DIR/.profile")" -eq 2 ]]
|
|
||||||
[[ "$(grep -c 'export PATH=' "$HOME_DIR/.bashrc")" -eq 2 ]]
|
|
||||||
[[ "$(grep -c '^PATH=' "$ENVD")" -eq 2 ]]
|
|
||||||
|
|
||||||
reset_home
|
|
||||||
echo "[test] a ~/.bash_profile does not silently swallow the PATH entry"
|
|
||||||
# A bash login shell reads the first of .bash_profile / .bash_login / .profile that
|
|
||||||
# exists and never looks at the rest. Writing only .profile is a no-op on such a host,
|
|
||||||
# and the failure is invisible until something cannot find node.
|
|
||||||
: > "$HOME_DIR/.bash_profile"
|
|
||||||
run_bare --cli --next --yes --no-auto-launch >/dev/null 2>&1
|
|
||||||
RESOLVED="$(env -i HOME="$HOME_DIR" PATH="$NONODE_BIN" TERM=dumb bash -lc 'command -v node')"
|
|
||||||
[[ "$RESOLVED" == "${NODE_HOME}/${VERSION}/bin/node" ]] || {
|
|
||||||
echo "with a .bash_profile present, a login shell resolved node to '${RESOLVED}'" >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
reset_home
|
|
||||||
echo "[test] a commented-out example does not count as the PATH entry already existing"
|
|
||||||
# The idempotence check used to be an unanchored substring match, so a line like this
|
|
||||||
# in a user's profile made the installer skip the real entry.
|
|
||||||
mkdir -p "$HOME_DIR"
|
|
||||||
printf '# export PATH="%s/%s/bin:$PATH"\n' "$NODE_HOME" "$VERSION" > "$HOME_DIR/.profile"
|
|
||||||
run_bare --cli --next --yes --no-auto-launch >/dev/null 2>&1
|
|
||||||
[[ "$(grep -c '^export PATH=' "$HOME_DIR/.profile")" -eq 2 ]] || {
|
|
||||||
echo "expected two real export lines, found:" >&2
|
|
||||||
cat "$HOME_DIR/.profile" >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
reset_home
|
|
||||||
echo "[test] --no-node-install refuses instead of installing"
|
|
||||||
set +e
|
|
||||||
OUTPUT="$(run_bare --cli --next --yes --no-node-install 2>&1)"
|
|
||||||
RC=$?
|
|
||||||
set -e
|
|
||||||
[[ "$RC" -ne 0 ]]
|
|
||||||
grep -qF -- "--no-node-install was given" <<<"$OUTPUT"
|
|
||||||
[[ ! -d "$NODE_HOME" ]]
|
|
||||||
|
|
||||||
reset_home
|
|
||||||
echo "[test] --check never provisions Node"
|
|
||||||
set +e
|
|
||||||
OUTPUT="$(run_bare --check --cli --next 2>&1)"
|
|
||||||
RC=$?
|
|
||||||
set -e
|
|
||||||
[[ "$RC" -ne 0 ]]
|
|
||||||
grep -qF -- "Required command not found: node" <<<"$OUTPUT"
|
|
||||||
[[ ! -d "$NODE_HOME" ]]
|
|
||||||
|
|
||||||
reset_home
|
|
||||||
echo "[test] a tampered download is rejected and nothing is installed"
|
|
||||||
publish_release "$VERSION" true
|
|
||||||
set +e
|
|
||||||
OUTPUT="$(run_bare --cli --next --yes --no-auto-launch 2>&1)"
|
|
||||||
RC=$?
|
|
||||||
set -e
|
|
||||||
[[ "$RC" -ne 0 ]]
|
|
||||||
grep -qF -- "failed checksum verification" <<<"$OUTPUT"
|
|
||||||
# Not just "no usable node": nothing at all may survive. An unpack that ran before
|
|
||||||
# verification, or a staging directory left behind, would still satisfy the weaker
|
|
||||||
# check while leaving unverified bytes on disk for the next run to adopt.
|
|
||||||
[[ ! -x "${NODE_HOME}/${VERSION}/bin/node" ]]
|
|
||||||
[[ ! -e "${NODE_HOME}/${VERSION}" ]]
|
|
||||||
[[ ! -e "${NODE_HOME}/${VERSION}.partial" ]]
|
|
||||||
[[ ! -d "$NODE_HOME" ]] || [[ -z "$(ls -A "$NODE_HOME")" ]]
|
|
||||||
publish_release "$VERSION"
|
|
||||||
|
|
||||||
reset_home
|
|
||||||
echo "[test] a system Node that is new enough is used as-is and left alone"
|
|
||||||
write_node_stub "$FAKE_BIN/node" 22
|
|
||||||
write_npm_stub "$FAKE_BIN/npm"
|
|
||||||
OUTPUT="$(
|
|
||||||
env -u npm_config_prefix \
|
|
||||||
HOME="$HOME_DIR" \
|
|
||||||
MOSAIC_HOME="$MOSAIC_HOME_DIR" \
|
|
||||||
MOSAIC_PREFIX="$PREFIX" \
|
|
||||||
MOSAIC_NO_COLOR=1 \
|
|
||||||
MOSAIC_NODE_HOME="$NODE_HOME" \
|
|
||||||
MOSAIC_NODE_DIST="file://${DIST}" \
|
|
||||||
MOSAIC_TEST_REAL_NODE="$REAL_NODE" \
|
|
||||||
MOSAIC_TEST_NPM_LOG="$LOG" \
|
|
||||||
MOSAIC_TEST_STATE="$STATE" \
|
|
||||||
PATH="$FAKE_BIN:$NONODE_BIN" \
|
|
||||||
bash "$ROOT/tools/install.sh" --cli --next --yes --no-auto-launch 2>&1
|
|
||||||
)"
|
|
||||||
grep -qF -- "satisfies the >= 22 requirement" <<<"$OUTPUT"
|
|
||||||
[[ ! -d "$NODE_HOME" ]]
|
|
||||||
|
|
||||||
reset_home
|
|
||||||
echo "[test] a system Node that is too old is replaced rather than accepted"
|
|
||||||
write_node_stub "$FAKE_BIN/node" 18
|
|
||||||
OUTPUT="$(
|
|
||||||
env -u npm_config_prefix \
|
|
||||||
HOME="$HOME_DIR" \
|
|
||||||
MOSAIC_HOME="$MOSAIC_HOME_DIR" \
|
|
||||||
MOSAIC_PREFIX="$PREFIX" \
|
|
||||||
MOSAIC_NO_COLOR=1 \
|
|
||||||
MOSAIC_NODE_HOME="$NODE_HOME" \
|
|
||||||
MOSAIC_NODE_DIST="file://${DIST}" \
|
|
||||||
MOSAIC_TEST_REAL_NODE="$REAL_NODE" \
|
|
||||||
MOSAIC_TEST_NPM_LOG="$LOG" \
|
|
||||||
MOSAIC_TEST_STATE="$STATE" \
|
|
||||||
PATH="$FAKE_BIN:$NONODE_BIN" \
|
|
||||||
bash "$ROOT/tools/install.sh" --cli --next --yes --no-auto-launch 2>&1
|
|
||||||
)"
|
|
||||||
grep -qF -- "older than the required >= 22" <<<"$OUTPUT"
|
|
||||||
[[ -x "${NODE_HOME}/${VERSION}/bin/node" ]]
|
|
||||||
|
|
||||||
# ─── refusals: untrusted input that reaches a path or an exec ─────────────────
|
|
||||||
|
|
||||||
reset_home
|
|
||||||
echo "[test] an empty checksum manifest is refused, not read as an empty digest"
|
|
||||||
: > "${DIST}/${VERSION}/SHASUMS256.txt"
|
|
||||||
set +e
|
|
||||||
OUTPUT="$(run_bare --cli --next --yes --no-auto-launch 2>&1)"
|
|
||||||
RC=$?
|
|
||||||
set -e
|
|
||||||
[[ "$RC" -ne 0 ]]
|
|
||||||
grep -qF -- "No checksum published" <<<"$OUTPUT"
|
|
||||||
[[ ! -e "${NODE_HOME}/${VERSION}" ]]
|
|
||||||
publish_release "$VERSION"
|
|
||||||
|
|
||||||
reset_home
|
|
||||||
echo "[test] a manifest naming a regex-equivalent file does not vouch for this one"
|
|
||||||
# The lookup used to interpolate the filename into a grep pattern. A Node tarball name
|
|
||||||
# is mostly dots, and a dot matches any character, so this line -- which names a
|
|
||||||
# different file -- was accepted as this file's checksum.
|
|
||||||
DECOY="node-${VERSION}-${PLATFORM}Xtar.gz"
|
|
||||||
printf '%s %s\n' "$(printf '0%.0s' $(seq 1 64))" "$DECOY" > "${DIST}/${VERSION}/SHASUMS256.txt"
|
|
||||||
set +e
|
|
||||||
OUTPUT="$(run_bare --cli --next --yes --no-auto-launch 2>&1)"
|
|
||||||
RC=$?
|
|
||||||
set -e
|
|
||||||
[[ "$RC" -ne 0 ]]
|
|
||||||
grep -qF -- "No checksum published" <<<"$OUTPUT"
|
|
||||||
[[ ! -e "${NODE_HOME}/${VERSION}" ]]
|
|
||||||
publish_release "$VERSION"
|
|
||||||
|
|
||||||
reset_home
|
|
||||||
echo "[test] a manifest listing the same file twice is refused rather than guessed at"
|
|
||||||
BASE="node-${VERSION}-${PLATFORM}.tar.gz"
|
|
||||||
GOOD="$(awk '{print $1}' "${DIST}/${VERSION}/SHASUMS256.txt")"
|
|
||||||
{
|
|
||||||
printf '%s %s\n' "$GOOD" "$BASE"
|
|
||||||
printf '%s %s\n' "$(printf '0%.0s' $(seq 1 64))" "$BASE"
|
|
||||||
} > "${DIST}/${VERSION}/SHASUMS256.txt"
|
|
||||||
set +e
|
|
||||||
OUTPUT="$(run_bare --cli --next --yes --no-auto-launch 2>&1)"
|
|
||||||
RC=$?
|
|
||||||
set -e
|
|
||||||
[[ "$RC" -ne 0 ]]
|
|
||||||
grep -qF -- "refusing to guess" <<<"$OUTPUT"
|
|
||||||
[[ ! -e "${NODE_HOME}/${VERSION}" ]]
|
|
||||||
publish_release "$VERSION"
|
|
||||||
|
|
||||||
echo "[test] a version string is checked before it becomes a path"
|
|
||||||
# MOSAIC_NODE_VERSION becomes a directory name under NODE_HOME, and that directory is
|
|
||||||
# later handed to `rm -rf`. This is defence in depth, and the honest scope should be
|
|
||||||
# recorded: the plain 'v..' case is separately refused by rm itself, and a traversal
|
|
||||||
# value breaks the download URL before the removal is reached. Measured, not assumed.
|
|
||||||
# What the check buys is that neither of those accidents is what is protecting us, and
|
|
||||||
# that a typo is refused with its own name on it rather than a curl error.
|
|
||||||
eval "$(sed -n '/^node_valid_version()/,/^}/p' "$ROOT/tools/install.sh")"
|
|
||||||
for good in v22.99.0 v0.0.0 v22.11.0 v100.0.1; do
|
|
||||||
node_valid_version "$good" || { echo "rejected a real version: ${good}" >&2; exit 1; }
|
|
||||||
done
|
|
||||||
for bad in 'v..' '..' 'v9.9.9/../../elsewhere' '/etc' 'v22' 'v22.1' '22.1.0' 'v22.1.0-rc1' '' 'v1.0.0 ' '$(id)'; do
|
|
||||||
! node_valid_version "$bad" || { echo "accepted a bad version: '${bad}'" >&2; exit 1; }
|
|
||||||
done
|
|
||||||
|
|
||||||
reset_home
|
|
||||||
echo "[test] a bad MOSAIC_NODE_VERSION is refused by name, before any download"
|
|
||||||
set +e
|
|
||||||
OUTPUT="$(
|
|
||||||
env -u npm_config_prefix \
|
|
||||||
HOME="$HOME_DIR" MOSAIC_HOME="$MOSAIC_HOME_DIR" MOSAIC_PREFIX="$PREFIX" \
|
|
||||||
MOSAIC_NO_COLOR=1 MOSAIC_NODE_HOME="$NODE_HOME" \
|
|
||||||
MOSAIC_NODE_DIST="file://${DIST}" MOSAIC_NODE_VERSION="v9.9.9/../../elsewhere" \
|
|
||||||
MOSAIC_TEST_REAL_NODE="$REAL_NODE" MOSAIC_TEST_NPM_LOG="$LOG" \
|
|
||||||
MOSAIC_TEST_STATE="$STATE" PATH="$NONODE_BIN" \
|
|
||||||
bash "$ROOT/tools/install.sh" --cli --next --yes --no-auto-launch 2>&1
|
|
||||||
)"
|
|
||||||
RC=$?
|
|
||||||
set -e
|
|
||||||
[[ "$RC" -ne 0 ]]
|
|
||||||
grep -qF -- "MOSAIC_NODE_VERSION" <<<"$OUTPUT"
|
|
||||||
grep -qF -- "Downloading Node" <<<"$OUTPUT" && {
|
|
||||||
echo "the download started despite an invalid version" >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
[[ ! -d "$NODE_HOME" ]]
|
|
||||||
|
|
||||||
reset_home
|
|
||||||
echo "[test] a download location with no transport integrity is refused"
|
|
||||||
set +e
|
|
||||||
OUTPUT="$(
|
|
||||||
env -u npm_config_prefix \
|
|
||||||
HOME="$HOME_DIR" MOSAIC_HOME="$MOSAIC_HOME_DIR" MOSAIC_PREFIX="$PREFIX" \
|
|
||||||
MOSAIC_NO_COLOR=1 MOSAIC_NODE_HOME="$NODE_HOME" \
|
|
||||||
MOSAIC_NODE_DIST="http://example.invalid/dist" \
|
|
||||||
MOSAIC_TEST_REAL_NODE="$REAL_NODE" MOSAIC_TEST_NPM_LOG="$LOG" \
|
|
||||||
MOSAIC_TEST_STATE="$STATE" PATH="$NONODE_BIN" \
|
|
||||||
bash "$ROOT/tools/install.sh" --cli --next --yes --no-auto-launch 2>&1
|
|
||||||
)"
|
|
||||||
RC=$?
|
|
||||||
set -e
|
|
||||||
[[ "$RC" -ne 0 ]]
|
|
||||||
grep -qF -- "MOSAIC_NODE_DIST must be" <<<"$OUTPUT"
|
|
||||||
[[ ! -d "$NODE_HOME" ]]
|
|
||||||
|
|
||||||
reset_home
|
|
||||||
echo "[test] a path containing shell syntax is not written into a profile"
|
|
||||||
# The PATH line is executed by every future shell that reads the file, so a directory
|
|
||||||
# holding $() or a quote would run there as code.
|
|
||||||
EVIL="$TMP/ev\$(touch $TMP/pwned)il"
|
|
||||||
set +e
|
|
||||||
env -u npm_config_prefix \
|
|
||||||
HOME="$HOME_DIR" MOSAIC_HOME="$MOSAIC_HOME_DIR" MOSAIC_PREFIX="$EVIL" \
|
|
||||||
MOSAIC_NO_COLOR=1 MOSAIC_NODE_HOME="$NODE_HOME" \
|
|
||||||
MOSAIC_NODE_DIST="file://${DIST}" \
|
|
||||||
MOSAIC_TEST_REAL_NODE="$REAL_NODE" MOSAIC_TEST_NPM_LOG="$LOG" \
|
|
||||||
MOSAIC_TEST_STATE="$STATE" PATH="$NONODE_BIN" \
|
|
||||||
bash "$ROOT/tools/install.sh" --cli --next --yes --no-auto-launch >/dev/null 2>&1
|
|
||||||
set -e
|
|
||||||
if [[ -f "$HOME_DIR/.profile" ]]; then
|
|
||||||
grep -qF -- 'touch' "$HOME_DIR/.profile" && {
|
|
||||||
echo "a command substitution was written into .profile" >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
fi
|
|
||||||
[[ ! -e "$TMP/pwned" ]] || { echo "the embedded command ran" >&2; exit 1; }
|
|
||||||
|
|
||||||
echo "[test] installer node provisioning tests passed"
|
|
||||||
+294
-453
@@ -25,9 +25,6 @@
|
|||||||
# tarballs and installs them globally. Use to test a branch
|
# tarballs and installs them globally. Use to test a branch
|
||||||
# end-to-end before cutting a release.
|
# end-to-end before cutting a release.
|
||||||
# --yes Accept all defaults; headless/non-interactive install
|
# --yes Accept all defaults; headless/non-interactive install
|
||||||
# --no-node-install Do not provision Node; fail if Node >= 20 (>= 22 with
|
|
||||||
# --next) is not already present. Default is to install a
|
|
||||||
# user-local Node under ~/.mosaic/node when it is missing.
|
|
||||||
# --no-auto-launch Skip automatic mosaic wizard + gateway install on first install
|
# --no-auto-launch Skip automatic mosaic wizard + gateway install on first install
|
||||||
# --uninstall Reverse the install: remove framework dir, CLI package, and npmrc line
|
# --uninstall Reverse the install: remove framework dir, CLI package, and npmrc line
|
||||||
#
|
#
|
||||||
@@ -41,11 +38,6 @@
|
|||||||
# MOSAIC_NEXT — equivalent to --next (set to 1)
|
# MOSAIC_NEXT — equivalent to --next (set to 1)
|
||||||
# MOSAIC_DEV — equivalent to --dev (set to 1)
|
# MOSAIC_DEV — equivalent to --dev (set to 1)
|
||||||
# MOSAIC_ASSUME_YES — equivalent to --yes (set to 1)
|
# MOSAIC_ASSUME_YES — equivalent to --yes (set to 1)
|
||||||
# MOSAIC_NODE_HOME — user-local Node install dir (default: ~/.mosaic/node)
|
|
||||||
# MOSAIC_NODE_VERSION — pin the Node release (default: latest of the
|
|
||||||
# required major, e.g. v22.23.2)
|
|
||||||
# MOSAIC_NODE_DIST — Node download mirror (default: nodejs.org/dist)
|
|
||||||
# MOSAIC_NO_NODE_INSTALL — equivalent to --no-node-install (set to 1)
|
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
#
|
#
|
||||||
# Wrapped in main() for safe curl-pipe usage.
|
# Wrapped in main() for safe curl-pipe usage.
|
||||||
@@ -90,7 +82,7 @@ if [[ "${MOSAIC_NEXT:-0}" == "1" ]]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
installer_usage() {
|
installer_usage() {
|
||||||
printf 'Usage: install.sh [--check] [--framework] [--cli] [--ref <branch>] [--next] [--dev] [--yes|-y] [--no-auto-launch] [--no-node-install] [--uninstall]\n' >&2
|
printf 'Usage: install.sh [--check] [--framework] [--cli] [--ref <branch>] [--next] [--dev] [--yes|-y] [--no-auto-launch] [--uninstall]\n' >&2
|
||||||
}
|
}
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
@@ -117,7 +109,6 @@ while [[ $# -gt 0 ]]; do
|
|||||||
--next) FLAG_NEXT=true; if [[ "$GIT_REF_EXPLICIT" == "false" ]]; then GIT_REF="next"; fi; shift ;;
|
--next) FLAG_NEXT=true; if [[ "$GIT_REF_EXPLICIT" == "false" ]]; then GIT_REF="next"; fi; shift ;;
|
||||||
--yes|-y) FLAG_YES=true; shift ;;
|
--yes|-y) FLAG_YES=true; shift ;;
|
||||||
--no-auto-launch) FLAG_NO_AUTO_LAUNCH=true; shift ;;
|
--no-auto-launch) FLAG_NO_AUTO_LAUNCH=true; shift ;;
|
||||||
--no-node-install) MOSAIC_NO_NODE_INSTALL=1; shift ;;
|
|
||||||
--uninstall) FLAG_UNINSTALL=true; shift ;;
|
--uninstall) FLAG_UNINSTALL=true; shift ;;
|
||||||
*)
|
*)
|
||||||
printf 'Error: Unknown argument: %s\n' "$1" >&2
|
printf 'Error: Unknown argument: %s\n' "$1" >&2
|
||||||
@@ -159,43 +150,6 @@ fi
|
|||||||
WORK_DIR=""
|
WORK_DIR=""
|
||||||
EXTRACTED_DIR=""
|
EXTRACTED_DIR=""
|
||||||
|
|
||||||
# Modification time of one file, as an integer. GNU/BusyBox stat takes -c, BSD/macOS
|
|
||||||
# stat takes -f, and there is no flag both accept -- so probe once and remember.
|
|
||||||
_MTIME_STYLE=""
|
|
||||||
file_mtime() {
|
|
||||||
if [[ -z "$_MTIME_STYLE" ]]; then
|
|
||||||
if stat -c %Y . >/dev/null 2>&1; then
|
|
||||||
_MTIME_STYLE=gnu
|
|
||||||
elif stat -f %m . >/dev/null 2>&1; then
|
|
||||||
_MTIME_STYLE=bsd
|
|
||||||
else
|
|
||||||
_MTIME_STYLE=none
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
case "$_MTIME_STYLE" in
|
|
||||||
gnu) stat -c %Y -- "$1" 2>/dev/null ;;
|
|
||||||
bsd) stat -f %m -- "$1" 2>/dev/null ;;
|
|
||||||
*) return 1 ;;
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
|
|
||||||
# The most recently modified file in "$dir" matching "$pattern".
|
|
||||||
#
|
|
||||||
# Three separate contracts, and callers must tell them apart:
|
|
||||||
# rc=0 with output — this is the newest match
|
|
||||||
# rc=0, no output — the directory or the pattern matched nothing
|
|
||||||
# rc=1 — the answer could not be determined
|
|
||||||
#
|
|
||||||
# The third one exists because the uninstall path treats "no backup" as licence to
|
|
||||||
# delete the destination. A lookup that fails must never be mistaken for a lookup
|
|
||||||
# that succeeded and found nothing.
|
|
||||||
#
|
|
||||||
# The candidates come from a glob and are compared in-shell, never rendered as text.
|
|
||||||
# That is deliberate, and it closes three bugs at once: `mapfile` is a Bash 4 builtin
|
|
||||||
# and macOS ships Bash 3.2, which this installer supports (see node_platform); piping
|
|
||||||
# `ls` into `head` dies on SIGPIPE under `set -o pipefail` once the listing fills a
|
|
||||||
# pipe buffer, returning 141 with no output; and any line-based parse of `ls` splits a
|
|
||||||
# filename that contains a newline into two wrong answers.
|
|
||||||
newest_matching_file() {
|
newest_matching_file() {
|
||||||
local dir="$1"
|
local dir="$1"
|
||||||
local pattern="$2"
|
local pattern="$2"
|
||||||
@@ -206,17 +160,8 @@ newest_matching_file() {
|
|||||||
matches=("$dir"/$pattern)
|
matches=("$dir"/$pattern)
|
||||||
shopt -u nullglob
|
shopt -u nullglob
|
||||||
[[ "${#matches[@]}" -gt 0 ]] || return 0
|
[[ "${#matches[@]}" -gt 0 ]] || return 0
|
||||||
|
# shellcheck disable=SC2012 # Need portable mtime sorting across Linux/macOS.
|
||||||
local newest="" newest_t="" candidate t
|
ls -1t "${matches[@]}" 2>/dev/null | head -1
|
||||||
for candidate in "${matches[@]}"; do
|
|
||||||
t="$(file_mtime "$candidate")" || return 1
|
|
||||||
[[ -n "$t" ]] || return 1
|
|
||||||
if [[ -z "$newest_t" ]] || [[ "$t" -gt "$newest_t" ]]; then
|
|
||||||
newest="$candidate"
|
|
||||||
newest_t="$t"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
printf '%s\n' "$newest"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# ─── uninstall path ───────────────────────────────────────────────────────────
|
# ─── uninstall path ───────────────────────────────────────────────────────────
|
||||||
@@ -279,17 +224,12 @@ if [[ "$FLAG_UNINSTALL" == "true" ]]; then
|
|||||||
for dest in "${RUNTIME_DESTS[@]}"; do
|
for dest in "${RUNTIME_DESTS[@]}"; do
|
||||||
base="$(basename "$dest")"
|
base="$(basename "$dest")"
|
||||||
dir="$(dirname "$dest")"
|
dir="$(dirname "$dest")"
|
||||||
# Find most recent backup. A lookup that could not answer is not the same as
|
# Find most recent backup
|
||||||
# "there is no backup": removing the destination on a failed lookup would destroy
|
|
||||||
# the file the backup exists to restore.
|
|
||||||
backup=""
|
backup=""
|
||||||
backup_lookup_ok=true
|
|
||||||
if [[ -d "$dir" ]]; then
|
if [[ -d "$dir" ]]; then
|
||||||
backup="$(newest_matching_file "$dir" "${base}.mosaic-bak-*")" || backup_lookup_ok=false
|
backup="$(newest_matching_file "$dir" "${base}.mosaic-bak-*")"
|
||||||
fi
|
fi
|
||||||
if [[ "$backup_lookup_ok" != "true" ]]; then
|
if [[ -n "$backup" ]] && [[ -f "$backup" ]]; then
|
||||||
echo " Skipped: $dest (could not check for a backup; left in place)"
|
|
||||||
elif [[ -n "$backup" ]] && [[ -f "$backup" ]]; then
|
|
||||||
cp "$backup" "$dest"
|
cp "$backup" "$dest"
|
||||||
rm -f "$backup"
|
rm -f "$backup"
|
||||||
echo " Restored: $dest"
|
echo " Restored: $dest"
|
||||||
@@ -369,376 +309,122 @@ require_cmd() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# ─── node provisioning ────────────────────────────────────────────────────────
|
# True if any shell rc file already puts $1 on PATH.
|
||||||
#
|
#
|
||||||
# Node is a hard prerequisite for everything below, and a greenfield host does not
|
# Each file is tested for existence first and grepped one at a time, rather than
|
||||||
# have it. Treating that as the operator's problem made the documented one-command
|
# handed to a single `grep -qs ... "${rc_files[@]}"`. Handing grep a missing file
|
||||||
# install a two-command install that fails first — so the installer provisions Node
|
# makes the exit status implementation-defined: GNU grep 3.11 returns 0 when -q
|
||||||
# itself.
|
# matched an earlier file, ugrep 7.5 returns 2 for the missing one regardless.
|
||||||
#
|
# On the 2 path the caller reads "not present yet" and appends a duplicate PATH
|
||||||
# It installs into the user's own tree rather than through apt/dnf/brew on purpose:
|
# line on every single install.
|
||||||
# no root, one code path on every distro, and it works on an immutable host where
|
path_entry_exists() {
|
||||||
# there is no system package manager to reach for. A system Node that is already
|
local dir="$1" rc_file
|
||||||
# new enough is always preferred and left untouched.
|
for rc_file in "$HOME/.profile" "$HOME/.zshenv" "$HOME/.zshrc" "$HOME/.bashrc"; do
|
||||||
|
if [[ -f "$rc_file" ]] && grep -qF "$dir" "$rc_file"; then
|
||||||
NODE_HOME="${MOSAIC_NODE_HOME:-$HOME/.mosaic/node}"
|
return 0
|
||||||
NODE_DIST="${MOSAIC_NODE_DIST:-https://nodejs.org/dist}"
|
|
||||||
FLAG_NO_NODE_INSTALL=false
|
|
||||||
if [[ "${MOSAIC_NO_NODE_INSTALL:-0}" == "1" ]]; then
|
|
||||||
FLAG_NO_NODE_INSTALL=true
|
|
||||||
fi
|
|
||||||
|
|
||||||
# A Node version string is about to become a directory name under NODE_HOME, and that
|
|
||||||
# directory is passed to `rm -rf`. Nothing reaches a filesystem operation until it has
|
|
||||||
# matched this. `v..` is the case that matters: it resolves to NODE_HOME's parent.
|
|
||||||
node_valid_version() {
|
|
||||||
[[ "$1" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]
|
|
||||||
}
|
|
||||||
|
|
||||||
# The download location is executable code. Refuse a scheme that carries no transport
|
|
||||||
# integrity at all, and say plainly what an override does and does not buy, since the
|
|
||||||
# tarball and the checksum that vouches for it then come from the same place.
|
|
||||||
case "$NODE_DIST" in
|
|
||||||
https://*) ;;
|
|
||||||
file://*) ;;
|
|
||||||
*)
|
|
||||||
if [[ -n "${MOSAIC_NODE_DIST:-}" ]]; then
|
|
||||||
fail "MOSAIC_NODE_DIST must be an https:// or file:// URL; got '${NODE_DIST}'"
|
|
||||||
exit 1
|
|
||||||
fi
|
fi
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
node_major_of() {
|
|
||||||
# Read the major from the binary rather than parsing `node --version` text, so a
|
|
||||||
# build with a suffix (v22.1.0-nightly…) does not read as a different major.
|
|
||||||
"$1" -e 'process.stdout.write(String(process.versions.node.split(".")[0]))' 2>/dev/null || echo 0
|
|
||||||
}
|
|
||||||
|
|
||||||
# The platform triple in a nodejs.org tarball name, or empty where nodejs.org
|
|
||||||
# publishes no build we can use.
|
|
||||||
node_platform() {
|
|
||||||
local os arch
|
|
||||||
case "$(uname -s)" in
|
|
||||||
Linux) os=linux ;;
|
|
||||||
Darwin) os=darwin ;;
|
|
||||||
*) return 1 ;;
|
|
||||||
esac
|
|
||||||
# Official Linux builds are glibc-linked; on musl they install and then fail to run.
|
|
||||||
if [[ "$os" == "linux" ]] && ldd --version 2>&1 | grep -qi musl; then
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
case "$(uname -m)" in
|
|
||||||
x86_64|amd64) arch=x64 ;;
|
|
||||||
aarch64|arm64) arch=arm64 ;;
|
|
||||||
armv7l) arch=armv7l ;;
|
|
||||||
*) return 1 ;;
|
|
||||||
esac
|
|
||||||
printf '%s-%s' "$os" "$arch"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Newest release of the wanted major. Resolved rather than pinned so a fresh install
|
|
||||||
# picks up security releases; MOSAIC_NODE_VERSION pins it when reproducibility matters.
|
|
||||||
node_resolve_version() {
|
|
||||||
local want="$1" index resolved
|
|
||||||
if [[ -n "${MOSAIC_NODE_VERSION:-}" ]]; then
|
|
||||||
if ! node_valid_version "$MOSAIC_NODE_VERSION"; then
|
|
||||||
fail "MOSAIC_NODE_VERSION must look like v22.11.0; got '${MOSAIC_NODE_VERSION}'"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
printf '%s' "$MOSAIC_NODE_VERSION"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
index="$(curl -fsSL --retry 3 "${NODE_DIST}/index.json" 2>/dev/null)" || return 1
|
|
||||||
# index.json is newest-first, so the first match is the latest of that major.
|
|
||||||
# grep/sed rather than a JSON parser because node is the thing we do not have yet.
|
|
||||||
# No `| head -1` here: head closes the pipe, grep takes SIGPIPE, and under
|
|
||||||
# `set -o pipefail` the whole substitution returns 141 -- the bug already fixed in
|
|
||||||
# newest_matching_file. Take the first line in the shell instead.
|
|
||||||
local found
|
|
||||||
found="$(printf '%s' "$index" | grep -o "\"version\":\"v${want}\.[0-9]\+\.[0-9]\+\"")" || return 1
|
|
||||||
found="${found%%$'\n'*}"
|
|
||||||
resolved="${found#\"version\":\"}"
|
|
||||||
resolved="${resolved%\"}"
|
|
||||||
# The index is remote input, and what comes out of it becomes a path.
|
|
||||||
[[ -n "$resolved" ]] || return 1
|
|
||||||
node_valid_version "$resolved" || return 1
|
|
||||||
printf '%s' "$resolved"
|
|
||||||
}
|
|
||||||
|
|
||||||
node_verify_checksum() {
|
|
||||||
local dir="$1" file="$2" expected="" line name matched=0
|
|
||||||
local manifest="${dir}/SHASUMS256.txt"
|
|
||||||
|
|
||||||
if [[ ! -f "$manifest" ]]; then
|
|
||||||
fail "No checksum manifest was downloaded for ${file}"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Compare filenames exactly rather than `grep " ${file}$"`. A Node tarball name is
|
|
||||||
# mostly dots, and in a regex a dot matches any character -- so a manifest line for
|
|
||||||
# a name that merely looks like this one would be accepted as this one's checksum.
|
|
||||||
#
|
|
||||||
# Every line is read, not just the first match: two entries for the same file mean
|
|
||||||
# the manifest is not trustworthy, and picking either one is a decision this code
|
|
||||||
# has no basis to make.
|
|
||||||
while IFS= read -r line || [[ -n "$line" ]]; do
|
|
||||||
name="${line#* }"
|
|
||||||
[[ "$name" == "$file" ]] || continue
|
|
||||||
expected="${line%% *}"
|
|
||||||
matched=$(( matched + 1 ))
|
|
||||||
done < "$manifest"
|
|
||||||
|
|
||||||
if [[ "$matched" -eq 0 ]]; then
|
|
||||||
fail "No checksum published for ${file}"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
if [[ "$matched" -gt 1 ]]; then
|
|
||||||
fail "Checksum manifest lists ${file} ${matched} times; refusing to guess."
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
if [[ ! "$expected" =~ ^[0-9a-fA-F]{64}$ ]]; then
|
|
||||||
fail "Checksum for ${file} is not a SHA-256 digest: '${expected}'"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
local actual
|
|
||||||
if command -v sha256sum &>/dev/null; then
|
|
||||||
actual="$(sha256sum "${dir}/${file}" | awk '{print $1}')"
|
|
||||||
elif command -v shasum &>/dev/null; then
|
|
||||||
actual="$(shasum -a 256 "${dir}/${file}" | awk '{print $1}')"
|
|
||||||
else
|
|
||||||
fail "Cannot verify the Node download: neither sha256sum nor shasum is present."
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
if [[ "$actual" != "$expected" ]]; then
|
|
||||||
fail "Node download failed checksum verification (${file})"
|
|
||||||
dim " expected ${expected}"
|
|
||||||
dim " got ${actual}"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# Download, verify and unpack one Node release into a scratch dir, then move it into
|
|
||||||
# place. Staging first means a failed or interrupted download never leaves a half-tree
|
|
||||||
# that the next run would mistake for an installed Node.
|
|
||||||
node_fetch_and_unpack() {
|
|
||||||
local version="$1" platform="$2" work="$3"
|
|
||||||
local base="node-${version}-${platform}"
|
|
||||||
local tarball="${base}.tar.gz"
|
|
||||||
local dest="${NODE_HOME}/${version}"
|
|
||||||
|
|
||||||
info "Downloading Node ${version} (${platform})…"
|
|
||||||
curl -fsSL --retry 3 -o "${work}/${tarball}" "${NODE_DIST}/${version}/${tarball}" || {
|
|
||||||
fail "Could not download ${NODE_DIST}/${version}/${tarball}"
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
curl -fsSL --retry 3 -o "${work}/SHASUMS256.txt" "${NODE_DIST}/${version}/SHASUMS256.txt" || {
|
|
||||||
fail "Could not download the Node checksum file"
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
node_verify_checksum "$work" "$tarball" || return 1
|
|
||||||
|
|
||||||
mkdir -p "$NODE_HOME"
|
|
||||||
tar -xzf "${work}/${tarball}" -C "$work" || { fail "Could not unpack ${tarball}"; return 1; }
|
|
||||||
rm -rf "${dest}.partial"
|
|
||||||
mv "${work}/${base}" "${dest}.partial" || { fail "Could not stage Node into ${NODE_HOME}"; return 1; }
|
|
||||||
rm -rf "$dest"
|
|
||||||
mv "${dest}.partial" "$dest" || { fail "Could not install Node into ${dest}"; return 1; }
|
|
||||||
ok "Installed Node ${version} → ${dest}"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Install one Node release, reusing it if this installer already put it there.
|
|
||||||
#
|
|
||||||
# The scratch dir is removed here rather than by a RETURN trap inside the worker: a
|
|
||||||
# RETURN trap set inside a function stays installed after that function returns, so it
|
|
||||||
# fires again on the next unrelated function return, where its variables are gone.
|
|
||||||
node_install() {
|
|
||||||
local version="$1" platform="$2"
|
|
||||||
local dest="${NODE_HOME}/${version}"
|
|
||||||
|
|
||||||
# Re-checked here, not only where the version was resolved: `dest` is about to be
|
|
||||||
# handed to `rm -rf`, and this is the last place before that happens. A version of
|
|
||||||
# `..` would point the removal at NODE_HOME's parent.
|
|
||||||
if ! node_valid_version "$version"; then
|
|
||||||
fail "Refusing to install Node from an unexpected version string: '${version}'"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -x "${dest}/bin/node" ]]; then
|
|
||||||
info "Reusing Node ${version} already at ${dest}"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
local work rc=0
|
|
||||||
work="$(mktemp -d)" || return 1
|
|
||||||
node_fetch_and_unpack "$version" "$platform" "$work" || rc=$?
|
|
||||||
rm -rf "$work"
|
|
||||||
return "$rc"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Put a directory on PATH for future processes, once. A user-local Node and a
|
|
||||||
# user-local npm prefix are only useful if the next process can still find them, and
|
|
||||||
# the installer used to do no more than warn about it.
|
|
||||||
#
|
|
||||||
# There is no one file that covers this. Each target below is the only thing that
|
|
||||||
# works for some way a user -- or an agent seat -- actually starts a process:
|
|
||||||
#
|
|
||||||
# ~/.profile POSIX login shells, and `bash -lc` when no bash-specific
|
|
||||||
# profile exists.
|
|
||||||
# ~/.bash_profile A bash login shell reads the first of these that exists and
|
|
||||||
# ~/.bash_login then never reads ~/.profile. On a host with one of them,
|
|
||||||
# writing only ~/.profile is a silent no-op. Appended to when
|
|
||||||
# present, never created -- creating one would itself start
|
|
||||||
# shadowing ~/.profile for everything else the user has there.
|
|
||||||
# ~/.bashrc Interactive non-login shells. Debian's returns early when the
|
|
||||||
# shell is not interactive, so it cannot stand in for a profile.
|
|
||||||
# ~/.zshenv Every zsh invocation, including `ssh host cmd`. A remote
|
|
||||||
# non-interactive zsh reads neither ~/.zprofile nor ~/.zshrc,
|
|
||||||
# which is what the previous version of this function wrote.
|
|
||||||
# environment.d systemd --user units, which read no shell file at all. A
|
|
||||||
# Mosaic agent seat starts as a unit, so this one is the point.
|
|
||||||
persist_path_line() {
|
|
||||||
local dir="$1" line rc wrote=""
|
|
||||||
|
|
||||||
# This text is written into files that a future shell will execute, so a directory
|
|
||||||
# containing shell syntax would run there as code. Refuse rather than escape: such
|
|
||||||
# a path can only arrive through MOSAIC_NODE_HOME or MOSAIC_PREFIX, and a real
|
|
||||||
# install directory never needs these characters.
|
|
||||||
if [[ "$dir" =~ [\"\$\`\\] ]] || [[ "$dir" == *"'"* ]] || [[ "$dir" == *$'\n'* ]]; then
|
|
||||||
warn "Not adding ${dir} to PATH automatically: the path contains shell syntax."
|
|
||||||
dim " Put it on PATH by hand, or reinstall to a path without those characters."
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
line="export PATH=\"${dir}:\$PATH\""
|
|
||||||
|
|
||||||
local files=("$HOME/.profile")
|
|
||||||
case "$(basename "${SHELL:-/bin/bash}")" in
|
|
||||||
zsh)
|
|
||||||
files+=("$HOME/.zshenv")
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
files+=("$HOME/.bashrc")
|
|
||||||
if [[ -f "$HOME/.bash_profile" ]]; then files+=("$HOME/.bash_profile"); fi
|
|
||||||
if [[ -f "$HOME/.bash_login" ]]; then files+=("$HOME/.bash_login"); fi
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
for rc in "${files[@]}"; do
|
|
||||||
# -x anchors the match to a whole line. Without it, a commented-out example of
|
|
||||||
# this same export counts as already present and the real entry never gets
|
|
||||||
# written -- the failure then looks like the installer simply did nothing.
|
|
||||||
if [[ -f "$rc" ]] && grep -Fqx "$line" "$rc"; then
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
{
|
|
||||||
printf '\n# Added by the Mosaic Stack installer\n'
|
|
||||||
printf '%s\n' "$line"
|
|
||||||
} >> "$rc"
|
|
||||||
wrote+="${wrote:+, }${rc}"
|
|
||||||
done
|
done
|
||||||
|
return 1
|
||||||
# systemd --user units inherit from the user manager, not from any shell.
|
|
||||||
local envd="$HOME/.config/environment.d"
|
|
||||||
local envd_file="$envd/50-mosaic-path.conf"
|
|
||||||
local envd_line="PATH=${dir}:\${PATH}"
|
|
||||||
if mkdir -p "$envd" 2>/dev/null; then
|
|
||||||
if [[ ! -f "$envd_file" ]] || ! grep -Fqx "$envd_line" "$envd_file"; then
|
|
||||||
printf '%s\n' "$envd_line" >> "$envd_file"
|
|
||||||
wrote+="${wrote:+, }${envd_file}"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -n "$wrote" ]]; then
|
|
||||||
ok "Added ${dir} to PATH in ${wrote}"
|
|
||||||
dim " This shell: export PATH=\"${dir}:\$PATH\""
|
|
||||||
dim " systemd --user: systemctl --user daemon-reload (or log in again)"
|
|
||||||
fi
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Make the installed `mosaic` reachable, now and in the next shell. Warning about
|
# Append `export PATH="$1:$PATH"` to the shell profile so $1 survives this
|
||||||
# this and moving on left a completed install whose CLI could not be found, which
|
# process. An `export` here reaches only the installer; every directory the
|
||||||
# reads to an operator as a failed install.
|
# install leaves behind has to be written down somewhere a later shell reads.
|
||||||
|
#
|
||||||
|
# Deliberately NOT ~/.bashrc: Debian's default .bashrc returns early for
|
||||||
|
# non-interactive shells, so a PATH line appended to the bottom of it is
|
||||||
|
# unreachable to `bash -lc`, to systemd units, and to every agent seat — the
|
||||||
|
# exact consumers that need these binaries. ~/.profile is read by login shells
|
||||||
|
# and Debian's .profile sources .bashrc for interactive ones, so a single line
|
||||||
|
# there reaches both. For zsh the always-sourced file is .zshenv, not .zshrc.
|
||||||
|
#
|
||||||
|
# $1 = directory to add, $2 = label for the comment line.
|
||||||
|
# Returns 1 (having warned) if the profile could not be written.
|
||||||
|
persist_on_path() {
|
||||||
|
local dir="$1" label="$2" profile
|
||||||
|
|
||||||
|
if path_entry_exists "$dir"; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -n "${ZSH_VERSION:-}" ]] || [[ "$(basename "${SHELL:-}")" == "zsh" ]]; then
|
||||||
|
profile="$HOME/.zshenv"
|
||||||
|
else
|
||||||
|
profile="$HOME/.profile"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Probe writability in a subshell. A redirection failure on a special built-in
|
||||||
|
# aborts the shell it runs in, so it has to be a child; and the redirection on
|
||||||
|
# the subshell is what silences the "Permission denied" the shell would
|
||||||
|
# otherwise print ahead of our own message.
|
||||||
|
if ! ( : >>"$profile" ) 2>/dev/null; then
|
||||||
|
warn "$dir is not on your PATH and $profile could not be written"
|
||||||
|
dim " Add to your shell rc: export PATH=\"$dir:\$PATH\""
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
{
|
||||||
|
echo ""
|
||||||
|
echo "# $label"
|
||||||
|
echo "export PATH=\"$dir:\$PATH\""
|
||||||
|
} >>"$profile"
|
||||||
|
ok "Added $dir to PATH in $profile"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# Persist $PREFIX/bin on PATH instead of only warning about it.
|
||||||
|
#
|
||||||
|
# The warning it replaces was the last step of an otherwise successful install,
|
||||||
|
# so the installer reported success and left `mosaic: command not found` — an
|
||||||
|
# unattended install had no operator to read the advice and act on it.
|
||||||
ensure_prefix_on_path() {
|
ensure_prefix_on_path() {
|
||||||
persist_path_line "$PREFIX/bin"
|
if [[ ":$PATH:" == *":$PREFIX/bin:"* ]]; then
|
||||||
if [[ ":$PATH:" != *":$PREFIX/bin:"* ]]; then
|
return
|
||||||
PATH="$PREFIX/bin:$PATH"
|
|
||||||
export PATH
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if path_entry_exists "$PREFIX/bin"; then
|
||||||
|
warn "$PREFIX/bin is in your shell profile but not in this shell"
|
||||||
|
elif ! persist_on_path "$PREFIX/bin" "Mosaic CLI"; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
dim " Run: export PATH=\"$PREFIX/bin:\$PATH\" (or start a new login shell)"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Guarantee a Node of at least $1 on PATH for the rest of this run.
|
# Fleet transport binary (#1240).
|
||||||
ensure_node() {
|
#
|
||||||
local want="$1" current=0
|
# `mosaic fleet --help` reads "Manage the local Mosaic tmux fleet" and every
|
||||||
if command -v node &>/dev/null; then
|
# roster the CLI scaffolds sets `transport: tmux`, but nothing in this script
|
||||||
current="$(node_major_of node)"
|
# provides tmux and, until now, nothing in it mentioned tmux at all. A
|
||||||
if [[ "$current" -ge "$want" ]]; then
|
# greenfield host came out of this installer able to install a fleet, start a
|
||||||
ok "Node $(node --version) satisfies the >= ${want} requirement"
|
# fleet, and run no seat — the operator's first signal was `mosaic fleet ps`.
|
||||||
return 0
|
#
|
||||||
fi
|
# Not a `require_cmd`: tmux is required by the fleet, not by mosaic. Plenty of
|
||||||
|
# hosts install this to run `mosaic claude` and will never scaffold a roster,
|
||||||
|
# and failing their install over a binary they do not need would be wrong. It
|
||||||
|
# is a warning that names precisely what it blocks.
|
||||||
|
#
|
||||||
|
# `tools/_scripts/mosaic-doctor` carries a deliberately parallel check, so the
|
||||||
|
# same host state gets the same answer from an audit as from an install. They
|
||||||
|
# are separate implementations because this one has to work before the
|
||||||
|
# framework's scripts are guaranteed to be on disk; keep their wording in step.
|
||||||
|
check_fleet_transport() {
|
||||||
|
local transport=tmux
|
||||||
|
local roster="$MOSAIC_HOME/fleet/roster.yaml"
|
||||||
|
local declared=""
|
||||||
|
|
||||||
|
if [[ -f "$roster" ]]; then
|
||||||
|
declared="$(sed -n 's/^[[:space:]]*transport:[[:space:]]*//p' "$roster" | head -1 |
|
||||||
|
tr -d '"'\''' | tr -d '\r' | awk '{print $1}')"
|
||||||
|
[[ -n "$declared" ]] && transport="$declared"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# A Node this installer put there previously, from an earlier run or another lane.
|
command -v "$transport" &>/dev/null && return 0
|
||||||
local candidate
|
|
||||||
for candidate in "$NODE_HOME"/*/bin/node; do
|
|
||||||
[[ -x "$candidate" ]] || continue
|
|
||||||
if [[ "$(node_major_of "$candidate")" -ge "$want" ]]; then
|
|
||||||
PATH="$(dirname "$candidate"):$PATH"
|
|
||||||
export PATH
|
|
||||||
ok "Using Node $(node --version) from ${NODE_HOME}"
|
|
||||||
persist_path_line "$(dirname "$candidate")"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
if [[ "$current" == "0" ]]; then
|
warn "Fleet transport '$transport' is not installed."
|
||||||
info "Node is not installed; the Mosaic CLI needs Node >= ${want}."
|
echo " The Mosaic fleet runs its agent seats inside $transport. Without it,"
|
||||||
else
|
echo " ${C}mosaic fleet start${RESET} reports success and no seat comes up."
|
||||||
info "Node v${current} is older than the required >= ${want}."
|
echo " Install it before using the fleet, e.g. ${C}sudo apt-get install -y $transport${RESET}"
|
||||||
fi
|
echo " (this does not affect ${C}mosaic claude${RESET} or the other single-runtime commands)."
|
||||||
|
|
||||||
if [[ "$FLAG_NO_NODE_INSTALL" == "true" ]]; then
|
|
||||||
fail "Node >= ${want} required and --no-node-install was given."
|
|
||||||
echo " Install Node >= ${want} and re-run, or drop --no-node-install."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
local platform
|
|
||||||
if ! platform="$(node_platform)"; then
|
|
||||||
fail "No official Node build for $(uname -s)/$(uname -m)."
|
|
||||||
echo " Install Node >= ${want} with your system package manager and re-run."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
require_cmd curl
|
|
||||||
require_cmd tar
|
|
||||||
|
|
||||||
local version
|
|
||||||
version="$(node_resolve_version "$want")" || true
|
|
||||||
if [[ -z "$version" ]]; then
|
|
||||||
fail "Could not resolve a Node ${want}.x release from ${NODE_DIST}."
|
|
||||||
echo " Check network access, or pin one: MOSAIC_NODE_VERSION=v${want}.0.0"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
info "Installing Node ${version} into ${NODE_HOME} (no root required)…"
|
|
||||||
if ! node_install "$version" "$platform"; then
|
|
||||||
fail "Node installation failed."
|
|
||||||
echo " Install Node >= ${want} manually and re-run, or re-run with --no-node-install"
|
|
||||||
echo " once it is present."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
PATH="${NODE_HOME}/${version}/bin:$PATH"
|
|
||||||
export PATH
|
|
||||||
persist_path_line "${NODE_HOME}/${version}/bin"
|
|
||||||
|
|
||||||
# Prove it, rather than assuming the unpack produced a working binary.
|
|
||||||
if ! command -v node &>/dev/null || [[ "$(node_major_of node)" -lt "$want" ]]; then
|
|
||||||
fail "Node ${version} was installed but is not usable on PATH."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
ok "Node $(node --version) ready"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
installed_cli_version() {
|
installed_cli_version() {
|
||||||
@@ -882,10 +568,8 @@ install_cli_from_source() {
|
|||||||
( cd "$src/apps/gateway" && pnpm pack --pack-destination "$out_dir" ) 2>&1 | sed 's/^/ /'
|
( cd "$src/apps/gateway" && pnpm pack --pack-destination "$out_dir" ) 2>&1 | sed 's/^/ /'
|
||||||
|
|
||||||
local cli_tgz gw_tgz
|
local cli_tgz gw_tgz
|
||||||
# An unanswerable lookup becomes an empty path, which the -f guards below report
|
cli_tgz="$(newest_matching_file "$out_dir" 'mosaicstack-mosaic-*.tgz')"
|
||||||
# properly. Nothing destructive happens on this path, so failing soft is safe here.
|
gw_tgz="$(newest_matching_file "$out_dir" 'mosaicstack-gateway-*.tgz')"
|
||||||
cli_tgz="$(newest_matching_file "$out_dir" 'mosaicstack-mosaic-*.tgz')" || cli_tgz=""
|
|
||||||
gw_tgz="$(newest_matching_file "$out_dir" 'mosaicstack-gateway-*.tgz')" || gw_tgz=""
|
|
||||||
|
|
||||||
if [[ ! -f "$cli_tgz" ]]; then
|
if [[ ! -f "$cli_tgz" ]]; then
|
||||||
fail "CLI tarball was not produced by pnpm pack."
|
fail "CLI tarball was not produced by pnpm pack."
|
||||||
@@ -950,28 +634,186 @@ install_next_cli_from_registry() {
|
|||||||
ok "Installed @next packages: CLI ${installed_cli}, gateway ${installed_gateway}"
|
ok "Installed @next packages: CLI ${installed_cli}, gateway ${installed_gateway}"
|
||||||
}
|
}
|
||||||
|
|
||||||
# ─── preflight ────────────────────────────────────────────────────────────────
|
# ─── node bootstrap ───────────────────────────────────────────────────────────
|
||||||
|
#
|
||||||
|
# Nothing on a greenfield host installs Node.js, yet this installer and the CLI
|
||||||
|
# it installs both hard-require it. Measured on a clean Debian 13 image: the
|
||||||
|
# installer stopped at `require_cmd node` with "Required command not found" and
|
||||||
|
# nothing was installed, with no hint of how to proceed.
|
||||||
|
#
|
||||||
|
# Inlined rather than factored into a sibling file on purpose: this script is
|
||||||
|
# fetched standalone by curl and has nothing to source.
|
||||||
|
#
|
||||||
|
# No-op when a suitable node is already on PATH, so it never fights an
|
||||||
|
# operator's nvm/fnm/distro node.
|
||||||
|
|
||||||
NODE_REQUIRED=20
|
NODE_ROOT="${MOSAIC_NODE_ROOT:-$HOME/.mosaic/node}"
|
||||||
if [[ "$FLAG_NEXT" == "true" ]]; then
|
NODE_BOOTSTRAP_VERSION="${MOSAIC_NODE_VERSION:-v22.23.2}"
|
||||||
NODE_REQUIRED=22
|
NODE_MIN_MAJOR="${MOSAIC_NODE_MIN_MAJOR:-20}"
|
||||||
fi
|
NODE_DIST_BASE="${MOSAIC_NODE_DIST_BASE:-https://nodejs.org/dist}"
|
||||||
|
|
||||||
if [[ "$FLAG_CHECK" == "true" || "$FLAG_UNINSTALL" == "true" ]]; then
|
# Major version of the node at $1, or empty if it will not run.
|
||||||
# Neither lane installs anything, so neither one may install Node.
|
node_major_of() {
|
||||||
require_cmd node
|
local candidate="$1" version
|
||||||
require_cmd npm
|
version="$("$candidate" -e 'process.stdout.write(process.versions.node)' 2>/dev/null)" || return 0
|
||||||
NODE_MAJOR="$(node_major_of node)"
|
printf '%s' "${version%%.*}"
|
||||||
if [[ "$NODE_MAJOR" -lt "$NODE_REQUIRED" ]]; then
|
}
|
||||||
fail "Node.js >= ${NODE_REQUIRED} required (found $(node --version))"
|
|
||||||
|
node_is_suitable() {
|
||||||
|
local major
|
||||||
|
major="$(node_major_of "$1")"
|
||||||
|
[[ -n "$major" ]] && [[ "$major" -ge "$NODE_MIN_MAJOR" ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
install_node() {
|
||||||
|
local node_os node_arch tarball release_url work_dir extracted target node_bin
|
||||||
|
|
||||||
|
case "$(uname -s)" in
|
||||||
|
Linux) node_os="linux" ;;
|
||||||
|
Darwin) node_os="darwin" ;;
|
||||||
|
*) fail "Unsupported OS '$(uname -s)'. Install Node.js >= $NODE_MIN_MAJOR manually."; return 1 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Linux here means glibc. Node's official linux-x64 build is dynamically
|
||||||
|
# linked against glibc, so on musl (Alpine) the binary will not exec — but it
|
||||||
|
# fails visibly: node_is_suitable rejects it and ensure_node exits with
|
||||||
|
# "install Node.js manually". No silent breakage, just a wasted download.
|
||||||
|
# A musl host needs the unofficial build, which is out of scope here.
|
||||||
|
case "$(uname -m)" in
|
||||||
|
x86_64|amd64) node_arch="x64" ;;
|
||||||
|
aarch64|arm64) node_arch="arm64" ;;
|
||||||
|
armv7l) node_arch="armv7l" ;;
|
||||||
|
*) fail "Unsupported architecture '$(uname -m)'. Install Node.js >= $NODE_MIN_MAJOR manually."; return 1 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# .tar.gz rather than the smaller .tar.xz: gzip is universally present, xz is
|
||||||
|
# not, and a minimal image is exactly the case this exists to handle.
|
||||||
|
tarball="node-${NODE_BOOTSTRAP_VERSION}-${node_os}-${node_arch}.tar.gz"
|
||||||
|
release_url="${NODE_DIST_BASE}/${NODE_BOOTSTRAP_VERSION}"
|
||||||
|
|
||||||
|
work_dir="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-node-XXXXXX")"
|
||||||
|
|
||||||
|
info "Installing Node.js $NODE_BOOTSTRAP_VERSION ($node_os-$node_arch) to $NODE_ROOT…"
|
||||||
|
|
||||||
|
if ! curl -fsSL "${release_url}/${tarball}" -o "$work_dir/$tarball"; then
|
||||||
|
fail "Download failed: ${release_url}/${tarball}"
|
||||||
|
rm -rf "$work_dir"; return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Trust assumption, stated so nobody has to infer it: this verifies INTEGRITY
|
||||||
|
# (the tarball matches the manifest), not AUTHENTICITY (the manifest is
|
||||||
|
# genuinely Node's). The only thing establishing that is TLS to
|
||||||
|
# $NODE_DIST_BASE. Node publishes SHASUMS256.txt.sig signed by its release
|
||||||
|
# keys and we do not check it, which is on par with nvm but means pointing
|
||||||
|
# MOSAIC_NODE_DIST_BASE at an untrusted mirror has no signature backstop.
|
||||||
|
# Tracked as a hardening follow-up (raised by scooby in the #1229 review).
|
||||||
|
if ! curl -fsSL "${release_url}/SHASUMS256.txt" -o "$work_dir/SHASUMS256.txt"; then
|
||||||
|
fail "Could not fetch SHASUMS256.txt; refusing to install an unverified runtime."
|
||||||
|
rm -rf "$work_dir"; return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Keep only our artifact's line, so a missing entry is an error not a pass.
|
||||||
|
if ! grep " ${tarball}\$" "$work_dir/SHASUMS256.txt" >"$work_dir/expected.sha256"; then
|
||||||
|
fail "$tarball has no entry in SHASUMS256.txt; refusing to install."
|
||||||
|
rm -rf "$work_dir"; return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! (cd "$work_dir" && verify_sha256 expected.sha256); then
|
||||||
|
fail "Checksum mismatch for $tarball; refusing to install."
|
||||||
|
rm -rf "$work_dir"; return 1
|
||||||
|
fi
|
||||||
|
ok "Checksum verified"
|
||||||
|
|
||||||
|
tar xzf "$work_dir/$tarball" -C "$work_dir"
|
||||||
|
extracted="$work_dir/node-${NODE_BOOTSTRAP_VERSION}-${node_os}-${node_arch}"
|
||||||
|
if [[ ! -x "$extracted/bin/node" ]]; then
|
||||||
|
fail "Extracted archive has no bin/node"
|
||||||
|
rm -rf "$work_dir"; return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$NODE_ROOT"
|
||||||
|
target="$NODE_ROOT/$NODE_BOOTSTRAP_VERSION"
|
||||||
|
rm -rf "$target.incoming"
|
||||||
|
mv "$extracted" "$target.incoming"
|
||||||
|
rm -rf "$target"
|
||||||
|
mv "$target.incoming" "$target"
|
||||||
|
ln -sfn "$NODE_BOOTSTRAP_VERSION" "$NODE_ROOT/current"
|
||||||
|
rm -rf "$work_dir"
|
||||||
|
|
||||||
|
node_bin="$NODE_ROOT/current/bin"
|
||||||
|
if ! node_is_suitable "$node_bin/node"; then
|
||||||
|
fail "Installed node at $node_bin/node did not run"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
export PATH="$node_bin:$PATH"
|
||||||
|
ok "Node.js $(node -v) installed with npm $(npm -v 2>/dev/null || echo '?')"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# Make the Mosaic-managed Node reachable from the next shell as well as this
|
||||||
|
# one. Measured on a greenfield canary run: without this the install finished
|
||||||
|
# rc=0, wrote $PREFIX/bin to ~/.profile, and the next login shell found `mosaic`
|
||||||
|
# and then died on `env: 'node': No such file or directory` — the CLI is a Node
|
||||||
|
# script, so a CLI on PATH without its runtime is a successful install that
|
||||||
|
# produces a broken command.
|
||||||
|
persist_node_on_path() {
|
||||||
|
persist_on_path "$NODE_ROOT/current/bin" "Mosaic-managed Node.js" || true
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_node() {
|
||||||
|
if command -v node &>/dev/null && node_is_suitable node; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# A previous run may have installed one that is not on this shell's PATH.
|
||||||
|
if node_is_suitable "$NODE_ROOT/current/bin/node"; then
|
||||||
|
export PATH="$NODE_ROOT/current/bin:$PATH"
|
||||||
|
persist_node_on_path
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "${MOSAIC_SKIP_NODE_BOOTSTRAP:-0}" == "1" ]]; then
|
||||||
|
fail "No suitable Node.js and MOSAIC_SKIP_NODE_BOOTSTRAP=1; refusing to download."
|
||||||
|
echo " Install Node.js >= $NODE_MIN_MAJOR yourself, then re-run this script."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
else
|
|
||||||
ensure_node "$NODE_REQUIRED"
|
require_cmd curl
|
||||||
# npm ships inside the Node tarball, so this only fails on a system Node that
|
require_cmd tar
|
||||||
# was packaged without it — which is worth saying out loud rather than dying later.
|
|
||||||
require_cmd npm
|
# sha256sum on Linux, shasum on macOS. Verification is not optional: without a
|
||||||
NODE_MAJOR="$(node_major_of node)"
|
# checksum this would install an unauthenticated runtime.
|
||||||
|
if command -v sha256sum &>/dev/null; then
|
||||||
|
verify_sha256() { sha256sum -c --status "$1"; }
|
||||||
|
elif command -v shasum &>/dev/null; then
|
||||||
|
verify_sha256() { shasum -a 256 -c --status "$1"; }
|
||||||
|
else
|
||||||
|
fail "sha256sum or shasum required to verify the Node.js download"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! install_node; then
|
||||||
|
fail "Could not bootstrap Node.js. Install Node.js >= $NODE_MIN_MAJOR and re-run."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
persist_node_on_path
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── preflight ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
ensure_node
|
||||||
|
require_cmd node
|
||||||
|
require_cmd npm
|
||||||
|
|
||||||
|
NODE_MAJOR="$(node -e 'process.stdout.write(String(process.versions.node.split(".")[0]))')"
|
||||||
|
if [[ "$NODE_MAJOR" -lt 20 ]]; then
|
||||||
|
fail "Node.js >= 20 required (found v$(node --version))"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ "$FLAG_NEXT" == "true" && "$NODE_MAJOR" -lt 22 ]]; then
|
||||||
|
fail "Node.js >= 22 required for the --next lane (found v$(node --version))"
|
||||||
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
@@ -1241,13 +1083,7 @@ if [[ "$FLAG_CHECK" == "false" ]]; then
|
|||||||
local base dir backup_path backup_val
|
local base dir backup_path backup_val
|
||||||
base="$(basename "$dest")"
|
base="$(basename "$dest")"
|
||||||
dir="$(dirname "$dest")"
|
dir="$(dirname "$dest")"
|
||||||
# Recording null here would tell a later uninstall that no backup exists, and
|
backup_path="$(newest_matching_file "$dir" "${base}.mosaic-bak-*")"
|
||||||
# it would then delete the destination instead of restoring it. An unanswerable
|
|
||||||
# lookup must stop the manifest, not guess at it.
|
|
||||||
if ! backup_path="$(newest_matching_file "$dir" "${base}.mosaic-bak-*")"; then
|
|
||||||
fail "Could not determine the backup state of ${dest}; refusing to write a manifest."
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
if [[ -n "$backup_path" ]]; then
|
if [[ -n "$backup_path" ]]; then
|
||||||
backup_val="\"$backup_path\""
|
backup_val="\"$backup_path\""
|
||||||
else
|
else
|
||||||
@@ -1307,6 +1143,11 @@ if [[ "$FLAG_CHECK" == "false" ]]; then
|
|||||||
ok "Done."
|
ok "Done."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Fleet readiness (#1240). Runs in both normal and --check mode: "what is the
|
||||||
|
# state of this host" is exactly the question --check is asked, and a host that
|
||||||
|
# cannot run a seat should not have to discover it from `fleet ps`.
|
||||||
|
check_fleet_transport
|
||||||
|
|
||||||
} # end main
|
} # end main
|
||||||
|
|
||||||
main "$@"
|
main "$@"
|
||||||
|
|||||||
Reference in New Issue
Block a user