Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8eb8e7cfce | ||
|
|
8199261caa | ||
|
|
d789a43cae | ||
|
|
19ad93999f | ||
|
|
57a2f2b40e | ||
|
|
93c1de51e1 |
+1
-3
@@ -116,7 +116,7 @@ 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. |
|
||||||
@@ -127,7 +127,6 @@ gateway-backed agent catalog.
|
|||||||
| `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
|
||||||
|
|
||||||
@@ -139,7 +138,6 @@ 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,28 +59,6 @@ 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
|
||||||
|
|||||||
@@ -1,71 +0,0 @@
|
|||||||
# #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.
|
|
||||||
@@ -51,12 +51,8 @@ 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. Before it writes any holder identity, tool, projection, or unit file,
|
edit a generated projection. `mosaic fleet install` validates the roster, installs the units and
|
||||||
`mosaic fleet install` validates the fleet CLI and every distinct roster runtime through the exact
|
helpers, and writes private roster-derived projections before any service is started.
|
||||||
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.
|
||||||
|
|||||||
@@ -1,199 +0,0 @@
|
|||||||
#!/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,22 +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. The provisioning preflight executes this same helper under the
|
# that home. Derive the pane home from the canonical path when available so an
|
||||||
# unit's clean launcher environment, so operator PATH cannot produce a false
|
# inherited pane/session HOME cannot become runtime authority.
|
||||||
# green result for a binary the pane will never see.
|
PANE_HOME=$HOME
|
||||||
SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
|
case "$MOSAIC_HOME" in
|
||||||
# shellcheck source=pane-runtime-path.sh
|
*/.config/mosaic) PANE_HOME=${MOSAIC_HOME%/.config/mosaic} ;;
|
||||||
. "$SCRIPT_DIR/pane-runtime-path.sh"
|
esac
|
||||||
PANE_HOME=$(mosaic_fleet_pane_home "$MOSAIC_HOME" "$HOME")
|
|
||||||
PANE_PATH=$(mosaic_fleet_build_pane_path "$PANE_HOME" "$MOSAIC_RUNTIME_BIN")
|
|
||||||
|
|
||||||
# #1241/#1256. The pane runs `mosaic yolo <runtime>` under PANE_PATH with a
|
_build_runtime_bin_prefix() {
|
||||||
# cleared environment. Resolve both names and validate any shebang interpreter
|
local candidates=()
|
||||||
# here, before an effect, where the failure remains attributable. Name
|
if [ -n "$MOSAIC_RUNTIME_BIN" ]; then candidates+=("$MOSAIC_RUNTIME_BIN"); fi
|
||||||
# resolution alone is insufficient: an `#!/usr/bin/env node` script resolves
|
if command -v npm >/dev/null 2>&1; then
|
||||||
# even when the pane cannot execute it because Node is absent.
|
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_RUNTIME_BIN_PREFIX=$(_build_runtime_bin_prefix)
|
||||||
|
PANE_PATH=${MOSAIC_RUNTIME_BIN_PREFIX:+${MOSAIC_RUNTIME_BIN_PREFIX}:}/usr/local/bin:/usr/bin:/bin
|
||||||
|
|
||||||
|
# #1241. The pane runs `mosaic yolo <runtime>` under PANE_PATH with a cleared
|
||||||
|
# environment. A binary missing from *that* path is a pane that dies in under a
|
||||||
|
# second, inside a session nobody is attached to, with its diagnostic scrolled
|
||||||
|
# into a pane tmux then destroys. Resolve both here, before any effect, where
|
||||||
|
# the failure is still attributable to the thing that caused it.
|
||||||
|
#
|
||||||
|
# `mosaic yolo <runtime>` runs checkRuntime(runtime) and the binary it looks for
|
||||||
|
# is named exactly like the runtime, so resolving the runtime name is the same
|
||||||
|
# question the pane will ask a moment later — asked while an operator can still
|
||||||
|
# see the answer.
|
||||||
_resolve_in_pane_path() {
|
_resolve_in_pane_path() {
|
||||||
mosaic_fleet_resolve_in_pane_path "$PANE_PATH" "$1"
|
PATH="$PANE_PATH" command -v -- "$1" 2>/dev/null
|
||||||
}
|
}
|
||||||
|
|
||||||
# Exit 69 (EX_UNAVAILABLE): the seat cannot be provided. Distinguished from the
|
# Exit 69 (EX_UNAVAILABLE): the seat cannot be provided. Distinguished from the
|
||||||
@@ -288,15 +312,8 @@ fail_launch() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for required_binary in mosaic "$MOSAIC_AGENT_RUNTIME"; do
|
for required_binary in mosaic "$MOSAIC_AGENT_RUNTIME"; do
|
||||||
resolved_binary=$(_resolve_in_pane_path "$required_binary") ||
|
_resolve_in_pane_path "$required_binary" >/dev/null ||
|
||||||
fail_launch missing-binary "'${required_binary}' is not on the pane PATH (${PANE_PATH})"
|
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
|
done
|
||||||
|
|
||||||
_ensure_claude_workdir_trusted() {
|
_ensure_claude_workdir_trusted() {
|
||||||
|
|||||||
@@ -484,27 +484,6 @@ assert_missing_pane_binary_rejected() {
|
|||||||
assert_missing_pane_binary_rejected mosaic
|
assert_missing_pane_binary_rejected mosaic
|
||||||
assert_missing_pane_binary_rejected pi
|
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
|
# #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
|
# 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
|
# WARNING about the heartbeat sidecar followed by exit 0 — three layers above it
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ The Gitea API token is **never passed on a curl command line.** An `Authorizatio
|
|||||||
|
|
||||||
### `--login` override
|
### `--login` override
|
||||||
|
|
||||||
Both `pr-review.sh` and `issue-comment.sh` accept an optional `--login <name>` flag that overrides the automatically detected Gitea login for that single invocation. The override selects **which credential the REST write, the `/user` identity lookup, and the read-back all use** — its token is resolved from the tea config for that login name (`get_gitea_token_for_login`), falling back to the repo host's credential when no login is named. The resolved login is **host- and port-bound**: the login's configured URL host **and effective port** (the scheme's default port — 80 for `http`, 443 for `https` — applies when a port is omitted, symmetrically on both sides) must match the repo remote's, so a login name shared across hosts (or an override configured for a different Gitea, including one on a different port of the same host) can never send one host's credential to another — a host or port mismatch fails closed rather than leaking a cross-host token. Resolving the acting identity and the read-back from the _same_ login that performs the write is essential: a write performed under an overridden login must be verified against that login's identity, not the host default's. Callers who need a different login than the host default should pass `--login <reviewer-login>`.
|
Both `pr-review.sh` and `issue-comment.sh` accept an optional `--login <name>` flag that overrides the automatically detected Gitea login for that single invocation (as of #1280, `pr-create.sh`, `pr-merge.sh` and `issue-create.sh` accept it too, and it wins over `MOSAIC_GIT_IDENTITY` everywhere). The override selects **which credential the REST write, the `/user` identity lookup, and the read-back all use** — its token is resolved from the tea config for that login name (`get_gitea_token_for_login`), falling back to the repo host's credential when no login is named. The resolved login is **host- and port-bound**: the login's configured URL host **and effective port** (the scheme's default port — 80 for `http`, 443 for `https` — applies when a port is omitted, symmetrically on both sides) must match the repo remote's, so a login name shared across hosts (or an override configured for a different Gitea, including one on a different port of the same host) can never send one host's credential to another — a host or port mismatch fails closed rather than leaking a cross-host token. Resolving the acting identity and the read-back from the _same_ login that performs the write is essential: a write performed under an overridden login must be verified against that login's identity, not the host default's. Callers who need a different login than the host default should pass `--login <reviewer-login>`.
|
||||||
|
|
||||||
As a durable successor to this mechanism, consider giving each reviewer/approver slot its own dedicated Gitea login credential, so that author≠reviewer holds at the credential level rather than relying on wrapper-level `--login` bookkeeping. This is a recommendation for future hardening, not something implemented by this flag.
|
As a durable successor to this mechanism, consider giving each reviewer/approver slot its own dedicated Gitea login credential, so that author≠reviewer holds at the credential level rather than relying on wrapper-level `--login` bookkeeping. This is a recommendation for future hardening, not something implemented by this flag.
|
||||||
|
|
||||||
@@ -58,6 +58,36 @@ token file present, both tools fall through to the existing shared-account path
|
|||||||
unchanged, so this feature is a no-op on any host that hasn't provisioned per-slot
|
unchanged, so this feature is a no-op on any host that hasn't provisioned per-slot
|
||||||
tokens.
|
tokens.
|
||||||
|
|
||||||
|
### Identity-first principal resolution in the wrappers (#1280)
|
||||||
|
|
||||||
|
`resolve_gitea_principal()` (detect-platform.sh) gives the write wrappers —
|
||||||
|
`pr-create.sh`, `pr-merge.sh`, `pr-review.sh`, `issue-create.sh`, `issue-comment.sh` —
|
||||||
|
ONE precedence for choosing the acting principal:
|
||||||
|
|
||||||
|
1. an explicit `--login <name>` (now accepted by all five; operator intent beats
|
||||||
|
environment), then
|
||||||
|
2. the per-agent identity above (`MOSAIC_GIT_IDENTITY` env / worktree
|
||||||
|
`mosaic.gitIdentity`) when a per-slot token exists — the wrapper then writes via the
|
||||||
|
REST API with that identity's token and never consults `tea`, so the tea login list
|
||||||
|
cannot shadow the requested principal, then
|
||||||
|
3. the tea login list — the LAST resort, never the first, because it enumerates
|
||||||
|
whatever logins the host happens to hold and knows nothing about which seat is
|
||||||
|
calling.
|
||||||
|
|
||||||
|
A requested identity whose per-slot token is absent, or a `--login` whose token cannot
|
||||||
|
resolve host-bound, **fails loud** (nonzero, naming the identity/login and the expected
|
||||||
|
slot) instead of silently writing under whatever account `tea` has configured — that
|
||||||
|
silent fallthrough is defect #1280 (reviews, comments, merges, PRs and issues filed
|
||||||
|
under the wrong account). `pr-merge.sh --dry-run` reports the principal the merge would
|
||||||
|
act as, resolved exactly as the real merge resolves it. ⚠ A **workstation-global**
|
||||||
|
`mosaic.gitIdentity` shadows every seat on that host (a fresh clone with no local value
|
||||||
|
resolves the global one) — set it per-worktree, not with `--global`.
|
||||||
|
|
||||||
|
The resolver is covered by `test-gitea-principal-resolution.sh`; the happy-path
|
||||||
|
ordering (identity arm REACHED, not sitting behind a tea failure) by
|
||||||
|
`test-pr-create-identity-first.sh`; merge credential binding by
|
||||||
|
`test-pr-merge-principal-resolution.sh`.
|
||||||
|
|
||||||
### Enabling it for a clone
|
### Enabling it for a clone
|
||||||
|
|
||||||
The framework installer syncs `git-credential-mosaic` to
|
The framework installer syncs `git-credential-mosaic` to
|
||||||
|
|||||||
@@ -497,6 +497,32 @@ get_gitea_url_for_host() {
|
|||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Map a Gitea host to the per-agent identity-token slot PREFIX ("gitea-usc" /
|
||||||
|
# "gitea-mosaicstack") used by identity-first principal resolution
|
||||||
|
# (MOSAIC_GIT_IDENTITY / git config mosaic.gitIdentity; #1280). Returns 1 for
|
||||||
|
# hosts with no per-slot scheme — callers treat that as "identity does not
|
||||||
|
# bind here" and fall through to existing behavior, never as an error. This is
|
||||||
|
# the single source of truth for the slot layout: get_gitea_token and
|
||||||
|
# resolve_gitea_principal both derive their slot paths from here, so the two
|
||||||
|
# resolutions can never disagree about where an identity's credential lives.
|
||||||
|
gitea_identity_slot_prefix() {
|
||||||
|
case "$1" in
|
||||||
|
git.uscllc.com) echo "gitea-usc" ;;
|
||||||
|
git.mosaicstack.dev) echo "gitea-mosaicstack" ;;
|
||||||
|
*) return 1 ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# Resolve the per-slot token FILE PATH for an identity on a host. Prints the
|
||||||
|
# absolute path on success; returns 1 (no output) when the host has no per-slot
|
||||||
|
# scheme. Prints a PATH only — never a token value.
|
||||||
|
gitea_identity_token_slot() {
|
||||||
|
local identity="$1" host="$2" prefix
|
||||||
|
[[ -n "$identity" ]] || return 1
|
||||||
|
prefix=$(gitea_identity_slot_prefix "$host") || return 1
|
||||||
|
printf '%s\n' "$HOME/.config/mosaic/secrets/gitea-tokens/${prefix}-${identity}.token"
|
||||||
|
}
|
||||||
|
|
||||||
# Resolve a Gitea API token for the given host.
|
# Resolve a Gitea API token for the given host.
|
||||||
# Priority: Mosaic credential loader → GITEA_TOKEN env → ~/.git-credentials
|
# Priority: Mosaic credential loader → GITEA_TOKEN env → ~/.git-credentials
|
||||||
get_gitea_token() {
|
get_gitea_token() {
|
||||||
@@ -517,13 +543,8 @@ get_gitea_token() {
|
|||||||
_ident_src="git config mosaic.gitIdentity"
|
_ident_src="git config mosaic.gitIdentity"
|
||||||
fi
|
fi
|
||||||
if [[ -n "$_ident" ]]; then
|
if [[ -n "$_ident" ]]; then
|
||||||
local _idpfx=""
|
local _idtok=""
|
||||||
case "$host" in
|
if _idtok="$(gitea_identity_token_slot "$_ident" "$host" 2>/dev/null)"; then
|
||||||
git.uscllc.com) _idpfx=gitea-usc ;;
|
|
||||||
git.mosaicstack.dev) _idpfx=gitea-mosaicstack ;;
|
|
||||||
esac
|
|
||||||
if [[ -n "$_idpfx" ]]; then
|
|
||||||
local _idtok="$HOME/.config/mosaic/secrets/gitea-tokens/${_idpfx}-${_ident}.token"
|
|
||||||
if [[ -r "$_idtok" ]]; then
|
if [[ -r "$_idtok" ]]; then
|
||||||
cat "$_idtok"
|
cat "$_idtok"
|
||||||
return 0
|
return 0
|
||||||
@@ -1465,6 +1486,81 @@ raise SystemExit(1)
|
|||||||
PY
|
PY
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# resolve_gitea_principal — identity-first acting-principal resolution shared by
|
||||||
|
# the git wrappers (#1280). The defect this fixes: wrappers resolved their
|
||||||
|
# acting principal from tea's login list FIRST, and that list enumerates
|
||||||
|
# whatever logins happen to be configured on the host — it knows nothing about
|
||||||
|
# which seat is calling — so a wrapper invoked with MOSAIC_GIT_IDENTITY=fargo
|
||||||
|
# still wrote under whichever account tea held (mos-dt-0), and the correct
|
||||||
|
# identity-aware code sat behind arms that only ran when the tea path failed.
|
||||||
|
# Precedence here is the contract:
|
||||||
|
# 1. an explicit login override ($1, the wrapper's --login) — operator intent
|
||||||
|
# beats environment;
|
||||||
|
# 2. MOSAIC_GIT_IDENTITY env, else per-worktree `git config mosaic.gitIdentity`
|
||||||
|
# (mirroring get_gitea_token exactly, so resolver and token resolution can
|
||||||
|
# never disagree) — binds only on hosts with a per-slot token scheme;
|
||||||
|
# 3. the tea login list — LAST resort, never the first.
|
||||||
|
#
|
||||||
|
# Prints exactly one line, three tab-separated fields (machine-readable for
|
||||||
|
# wrapper dispatch and tests):
|
||||||
|
# mode "login" | "identity" | "default"
|
||||||
|
# principal login name (login) | identity name (identity) | tea login or "" (default)
|
||||||
|
# source "tea-login:<name>" | "identity-slot:<path>" | "tea-default" | "host-credential"
|
||||||
|
#
|
||||||
|
# Fails LOUD (nonzero, empty stdout, stderr diagnostic) when an explicit
|
||||||
|
# override cannot be honored — a refusal is a good day; silently falling
|
||||||
|
# through to whoever tea has configured is the exact defect this resolves:
|
||||||
|
# - login mode: no host-bound token for that tea login. The existence check
|
||||||
|
# runs the same tea-config lookup tea itself uses; the token VALUE is
|
||||||
|
# discarded (never printed, never used).
|
||||||
|
# - identity mode: no per-slot token file for that identity on a recognized
|
||||||
|
# host — the diagnostic names the identity, its source, and the expected
|
||||||
|
# slot path. An identity requested on a host with NO per-slot scheme does
|
||||||
|
# not bind (matching get_gitea_token's containment) and falls to default.
|
||||||
|
#
|
||||||
|
# NEVER prints a token value — principal names and slot paths only.
|
||||||
|
# $1 = explicit login override ("" when absent), $2 = host (default: the
|
||||||
|
# origin remote's host).
|
||||||
|
resolve_gitea_principal() {
|
||||||
|
local login_override="${1:-}" host="${2:-}" ident ident_src slot login
|
||||||
|
[[ -n "$host" ]] || { host=$(get_remote_host) || return 1; }
|
||||||
|
|
||||||
|
if [[ -n "$login_override" ]]; then
|
||||||
|
get_gitea_token_for_login "$login_override" "$host" >/dev/null || {
|
||||||
|
echo "Error: --login '$login_override' has no host-matched token on host '$host' (tea config lookup); refusing to fall back to any other principal (#1280 identity-first resolution)." >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
printf 'login\t%s\ttea-login:%s\n' "$login_override" "$login_override"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
ident="${MOSAIC_GIT_IDENTITY:-}"
|
||||||
|
ident_src="MOSAIC_GIT_IDENTITY"
|
||||||
|
if [[ -z "$ident" ]]; then
|
||||||
|
ident="$(git config --get mosaic.gitIdentity 2>/dev/null || true)"
|
||||||
|
ident_src="git config mosaic.gitIdentity"
|
||||||
|
fi
|
||||||
|
if [[ -n "$ident" ]] && slot="$(gitea_identity_token_slot "$ident" "$host" 2>/dev/null)"; then
|
||||||
|
if [[ -r "$slot" ]]; then
|
||||||
|
printf 'identity\t%s\tidentity-slot:%s\n' "$ident" "$slot"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
echo "Error: git identity '$ident' requested (via $ident_src) for host '$host', but no per-slot token at $slot (#1280 identity-first resolution)." >&2
|
||||||
|
echo " Refusing to fall back to the tea login list or shared credentials. Provision the per-slot token, or unset the identity." >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# No override requested: tea's login list is the LAST resort. Absence is
|
||||||
|
# not an error here — callers fall back to the host credential, exactly as
|
||||||
|
# they did before this resolver existed (preserved behavior).
|
||||||
|
if login=$(get_gitea_login_for_host "$host" 2>/dev/null); then
|
||||||
|
printf 'default\t%s\ttea-default\n' "$login"
|
||||||
|
else
|
||||||
|
printf 'default\t\thost-credential\n'
|
||||||
|
fi
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
# Resolve HTTPS basic auth credentials for a Gitea host from ~/.git-credentials.
|
# Resolve HTTPS basic auth credentials for a Gitea host from ~/.git-credentials.
|
||||||
# Prints "username:password" for direct curl -u consumption. Callers must not log it.
|
# Prints "username:password" for direct curl -u consumption. Callers must not log it.
|
||||||
get_gitea_basic_auth() {
|
get_gitea_basic_auth() {
|
||||||
|
|||||||
@@ -76,27 +76,36 @@ fi
|
|||||||
detect_platform >/dev/null
|
detect_platform >/dev/null
|
||||||
|
|
||||||
# Resolve and cache the Gitea REST endpoint + token for the current remote,
|
# Resolve and cache the Gitea REST endpoint + token for the current remote,
|
||||||
# bound to a SPECIFIC login identity ($1). Populates GITEA_API_ROOT (…/api/v1),
|
# bound to a SPECIFIC acting principal ($1) selected identity-first (#1280):
|
||||||
# GITEA_API_BASE (…/api/v1/repos/<slug>), and GITEA_API_TOKEN.
|
# an explicit --login wins, else MOSAIC_GIT_IDENTITY / git config
|
||||||
|
# mosaic.gitIdentity binds the per-slot credential, else the tea login list
|
||||||
|
# (last resort). Populates GITEA_API_ROOT (…/api/v1), GITEA_API_BASE
|
||||||
|
# (…/api/v1/repos/<slug>), and GITEA_API_TOKEN.
|
||||||
#
|
#
|
||||||
# The token is resolved for the EFFECTIVE login (the --login override when
|
# The token is resolved for the EFFECTIVE principal so that the single
|
||||||
# given, otherwise the detected default) so that the single credential used for
|
# credential used for the write ALSO drives the /user identity read and the
|
||||||
# the write ALSO drives the /user identity read and the read-back — write token
|
# read-back — write token and read-back token are the same identity by
|
||||||
# and read-back token are the same identity by construction (this is the
|
# construction (this is the credential-ordering fix: a --login override is no
|
||||||
# credential-ordering fix: a --login override is no longer written under one
|
# longer written under one credential and verified under a different default
|
||||||
# credential and verified under a different default one). Falls back to the
|
# one). When $2 is "identity" the principal ($1) is a requested git identity:
|
||||||
# host-scoped credential ONLY when NO --login override was supplied (the
|
# the token MUST resolve from that identity's per-slot token (get_gitea_token's
|
||||||
# best-effort default path). When $2 is "explicit" the login came from a
|
# identity arm), failing closed rather than borrowing the tea default login —
|
||||||
# caller-supplied --login: that exact login's token MUST resolve, and we FAIL
|
# the tea login list must never shadow a requested identity (#1280). When $2
|
||||||
# CLOSED rather than silently downgrading the write to the host default
|
# is "explicit" the principal came from a caller-supplied --login: that exact
|
||||||
# identity — otherwise a caller relying on a dedicated per-role credential would
|
# login's token MUST resolve, and we FAIL CLOSED rather than silently
|
||||||
# be told the write succeeded as requested while it was attributed to the shared
|
# downgrading the write to the host default identity. Otherwise the best-effort
|
||||||
# default. Returns non-zero (clear stderr) on any resolution failure.
|
# default path applies (per-login token, else the host-scoped credential).
|
||||||
|
# Returns non-zero (clear stderr) on any resolution failure.
|
||||||
gitea_resolve_api_for_login() {
|
gitea_resolve_api_for_login() {
|
||||||
local effective_login="$1" override_explicit="${2:-}" host configured_url repo
|
local effective_login="$1" override_explicit="${2:-}" host configured_url repo
|
||||||
|
|
||||||
host=$(get_remote_host)
|
host=$(get_remote_host)
|
||||||
if [[ -n "$override_explicit" ]]; then
|
if [[ "$override_explicit" == "identity" ]]; then
|
||||||
|
GITEA_API_TOKEN=$(get_gitea_token "$host") || {
|
||||||
|
echo "Error: could not resolve the per-slot token for requested git identity '$effective_login' on host '$host'; refusing to fall back to the tea login list or shared credentials (comment write/read-back, #1280)." >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
elif [[ -n "$override_explicit" ]]; then
|
||||||
GITEA_API_TOKEN=$(get_gitea_token_for_login "$effective_login" "$host") || {
|
GITEA_API_TOKEN=$(get_gitea_token_for_login "$effective_login" "$host") || {
|
||||||
echo "Error: could not resolve a host-matched Gitea token for --login '$effective_login' on host '$host'; refusing to fall back to the host default identity or a cross-host credential (comment write/read-back)" >&2
|
echo "Error: could not resolve a host-matched Gitea token for --login '$effective_login' on host '$host'; refusing to fall back to the host default identity or a cross-host credential (comment write/read-back)" >&2
|
||||||
return 1
|
return 1
|
||||||
@@ -318,23 +327,31 @@ if [[ "$PLATFORM" == "github" ]]; then
|
|||||||
gh issue comment "$ISSUE_NUMBER" --body "$COMMENT"
|
gh issue comment "$ISSUE_NUMBER" --body "$COMMENT"
|
||||||
echo "Added comment to GitHub issue #$ISSUE_NUMBER"
|
echo "Added comment to GitHub issue #$ISSUE_NUMBER"
|
||||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||||
# Resolve the login this comment should be attributed to: the --login
|
# Resolve the acting principal identity-first (#1280): an explicit --login
|
||||||
# override when given, otherwise the detected default for this repo's host.
|
# wins; otherwise MOSAIC_GIT_IDENTITY / git config mosaic.gitIdentity
|
||||||
# A --login override always wins. Otherwise name this repo host's login only
|
# selects the principal when a per-slot token exists (fail-loud when it
|
||||||
# as a best effort: the login name merely selects a per-login token, and
|
# does not); the tea login list is the LAST resort — it knows nothing about
|
||||||
# gitea_resolve_api_for_login falls back to the host credential
|
# which seat is calling, so resolving from it first wrote under whichever
|
||||||
# (get_gitea_token) when no tea login is named, so the default credential
|
# account tea had configured (the #1280 family).
|
||||||
# still resolves even when the host tea has no matching login entry.
|
principal_host=$(get_remote_host)
|
||||||
EFFECTIVE_LOGIN="$LOGIN_OVERRIDE"
|
if ! principal_resolved="$(resolve_gitea_principal "$LOGIN_OVERRIDE" "$principal_host")"; then
|
||||||
[[ -n "$EFFECTIVE_LOGIN" ]] || EFFECTIVE_LOGIN=$(get_gitea_login 2>/dev/null || true)
|
# resolve_gitea_principal already printed the fail-loud diagnostic.
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
PRINCIPAL_MODE="$(printf '%s' "$principal_resolved" | cut -f1)"
|
||||||
|
PRINCIPAL_NAME="$(printf '%s' "$principal_resolved" | cut -f2)"
|
||||||
|
|
||||||
# Bind the REST endpoint + token to the effective login, then derive the
|
# Bind the REST endpoint + token to the resolved principal, then derive the
|
||||||
# acting identity from that SAME credential (GET /user). The write below and
|
# acting identity from that SAME credential (GET /user). The write below and
|
||||||
# its read-back both use this credential, so the write is verified against
|
# its read-back both use this credential, so the write is verified against
|
||||||
# the identity that actually performed it. Passing "explicit" when --login
|
# the identity that actually performed it.
|
||||||
# was supplied forbids the host-default fallback: an unresolvable explicit
|
if [[ "$PRINCIPAL_MODE" == "identity" ]]; then
|
||||||
# override fails closed instead of writing under the default identity.
|
gitea_resolve_api_for_login "$PRINCIPAL_NAME" identity || exit 1
|
||||||
gitea_resolve_api_for_login "$EFFECTIVE_LOGIN" "${LOGIN_OVERRIDE:+explicit}" || exit 1
|
elif [[ "$PRINCIPAL_MODE" == "login" ]]; then
|
||||||
|
gitea_resolve_api_for_login "$PRINCIPAL_NAME" explicit || exit 1
|
||||||
|
else
|
||||||
|
gitea_resolve_api_for_login "$PRINCIPAL_NAME" "" || exit 1
|
||||||
|
fi
|
||||||
ACTING_LOGIN=$(gitea_authenticated_login) || exit 1
|
ACTING_LOGIN=$(gitea_authenticated_login) || exit 1
|
||||||
|
|
||||||
comment_id=$(gitea_create_comment_verified "$ISSUE_NUMBER" "$COMMENT" "$ACTING_LOGIN") || {
|
comment_id=$(gitea_create_comment_verified "$ISSUE_NUMBER" "$COMMENT" "$ACTING_LOGIN") || {
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# issue-create.sh - Create issues on Gitea or GitHub
|
# issue-create.sh - Create issues on Gitea or GitHub
|
||||||
# Usage: issue-create.sh -t "Title" [-b "Body"] [-l "label1,label2"] [-m "milestone"]
|
# Usage: issue-create.sh -t "Title" [-b "Body"] [-l "label1,label2"] [-m "milestone"] [--login <name>]
|
||||||
|
#
|
||||||
|
# Acting principal is resolved identity-first (#1280): an explicit --login
|
||||||
|
# wins; otherwise MOSAIC_GIT_IDENTITY / per-worktree git config
|
||||||
|
# mosaic.gitIdentity selects the principal when a per-slot token exists (and
|
||||||
|
# the wrapper then creates the issue through the REST API with that identity's
|
||||||
|
# token — tea is never invoked, so the tea login list cannot shadow the
|
||||||
|
# requested principal); the tea login list is the LAST resort. A requested
|
||||||
|
# identity with no per-slot token fails LOUD rather than writing under
|
||||||
|
# whichever account tea happens to hold.
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
@@ -16,6 +25,14 @@ INTERACTIVE=false
|
|||||||
|
|
||||||
# get_remote_host and get_gitea_token are provided by detect-platform.sh
|
# get_remote_host and get_gitea_token are provided by detect-platform.sh
|
||||||
|
|
||||||
|
# Acting-principal mode set in the Gitea branch below (from
|
||||||
|
# resolve_gitea_principal): "login" when --login was given, "identity" when a
|
||||||
|
# git identity bound, "default" otherwise. PRINCIPAL_MODE=login makes the API
|
||||||
|
# arm resolve the --login principal's token too, so an explicit --login keeps
|
||||||
|
# winning even on the tea-FAILURE fallback arm.
|
||||||
|
PRINCIPAL_MODE=""
|
||||||
|
PRINCIPAL_NAME=""
|
||||||
|
|
||||||
gitea_issue_create_api() {
|
gitea_issue_create_api() {
|
||||||
local host repo token url payload
|
local host repo token url payload
|
||||||
host=$(get_remote_host) || {
|
host=$(get_remote_host) || {
|
||||||
@@ -26,10 +43,19 @@ gitea_issue_create_api() {
|
|||||||
echo "Error: could not determine repo owner/name for API fallback" >&2
|
echo "Error: could not determine repo owner/name for API fallback" >&2
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
if [[ "$PRINCIPAL_MODE" == "login" ]]; then
|
||||||
|
token=$(get_gitea_token_for_login "$PRINCIPAL_NAME" "$host") || {
|
||||||
|
echo "Error: could not resolve a host-matched Gitea token for --login '$PRINCIPAL_NAME' on host '$host' (API path)" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
else
|
||||||
|
# Identity-first when MOSAIC_GIT_IDENTITY / git config mosaic.gitIdentity
|
||||||
|
# is set (per-slot token, fail-loud on absence); shared default otherwise.
|
||||||
token=$(get_gitea_token "$host") || {
|
token=$(get_gitea_token "$host") || {
|
||||||
echo "Error: Gitea token not found for API fallback (set GITEA_TOKEN or configure ~/.git-credentials)" >&2
|
echo "Error: Gitea token not found for API fallback (set GITEA_TOKEN or configure ~/.git-credentials)" >&2
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
fi
|
||||||
|
|
||||||
if [[ -n "$LABELS" || -n "$MILESTONE" ]]; then
|
if [[ -n "$LABELS" || -n "$MILESTONE" ]]; then
|
||||||
echo "Warning: API fallback currently applies title/body only; labels/milestone require authenticated tea setup." >&2
|
echo "Warning: API fallback currently applies title/body only; labels/milestone require authenticated tea setup." >&2
|
||||||
@@ -67,6 +93,7 @@ Options:
|
|||||||
-b, --body BODY Issue body/description
|
-b, --body BODY Issue body/description
|
||||||
-l, --labels LABELS Comma-separated labels (e.g., "bug,feature")
|
-l, --labels LABELS Comma-separated labels (e.g., "bug,feature")
|
||||||
-m, --milestone NAME Milestone name to assign
|
-m, --milestone NAME Milestone name to assign
|
||||||
|
--login NAME Act as this Gitea tea login (wins over MOSAIC_GIT_IDENTITY)
|
||||||
-i, --interactive Prompt for missing issue fields
|
-i, --interactive Prompt for missing issue fields
|
||||||
-h, --help Show this help message
|
-h, --help Show this help message
|
||||||
|
|
||||||
@@ -97,6 +124,10 @@ while [[ $# -gt 0 ]]; do
|
|||||||
MILESTONE="$2"
|
MILESTONE="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
|
--login)
|
||||||
|
LOGIN_OVERRIDE="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
-i|--interactive)
|
-i|--interactive)
|
||||||
INTERACTIVE=true
|
INTERACTIVE=true
|
||||||
shift
|
shift
|
||||||
@@ -134,13 +165,37 @@ case "$PLATFORM" in
|
|||||||
"${CMD[@]}"
|
"${CMD[@]}"
|
||||||
;;
|
;;
|
||||||
gitea)
|
gitea)
|
||||||
|
# Resolve the acting principal identity-first (#1280). The tea login
|
||||||
|
# list is the LAST resort: it knows nothing about which seat is calling,
|
||||||
|
# and a login resolved from it first is what attributed issues to the
|
||||||
|
# wrong account even when MOSAIC_GIT_IDENTITY was set.
|
||||||
|
principal_host=$(get_remote_host 2>/dev/null || true)
|
||||||
|
if ! principal_resolved="$(resolve_gitea_principal "${LOGIN_OVERRIDE:-}" "$principal_host")"; then
|
||||||
|
# resolve_gitea_principal already printed the fail-loud diagnostic.
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
PRINCIPAL_MODE="$(printf '%s' "$principal_resolved" | cut -f1)"
|
||||||
|
PRINCIPAL_NAME="$(printf '%s' "$principal_resolved" | cut -f2)"
|
||||||
|
|
||||||
|
if [[ "$PRINCIPAL_MODE" == "identity" ]]; then
|
||||||
|
# HAPPY PATH for a requested identity: create through the REST API
|
||||||
|
# with the per-slot token and never invoke tea — the identity arm
|
||||||
|
# must be REACHED, not sit behind a tea failure (#1280).
|
||||||
|
gitea_issue_create_api
|
||||||
|
exit $?
|
||||||
|
fi
|
||||||
|
|
||||||
if command -v tea >/dev/null 2>&1; then
|
if command -v tea >/dev/null 2>&1; then
|
||||||
REPO_SLUG=$(get_repo_slug)
|
REPO_SLUG=$(get_repo_slug)
|
||||||
|
if [[ "$PRINCIPAL_MODE" == "login" ]]; then
|
||||||
|
GITEA_LOGIN_NAME="$PRINCIPAL_NAME"
|
||||||
|
else
|
||||||
GITEA_LOGIN_NAME=$(get_gitea_login) || {
|
GITEA_LOGIN_NAME=$(get_gitea_login) || {
|
||||||
echo "Warning: could not resolve Gitea login for tea; trying Gitea API fallback..." >&2
|
echo "Warning: could not resolve Gitea login for tea; trying Gitea API fallback..." >&2
|
||||||
gitea_issue_create_api
|
gitea_issue_create_api
|
||||||
exit $?
|
exit $?
|
||||||
}
|
}
|
||||||
|
fi
|
||||||
if ! get_gitea_authenticated_user "$GITEA_LOGIN_NAME" >/dev/null; then
|
if ! get_gitea_authenticated_user "$GITEA_LOGIN_NAME" >/dev/null; then
|
||||||
echo "Warning: Tea authenticated-user validation failed (possible stale user/login); trying Gitea API fallback..." >&2
|
echo "Warning: Tea authenticated-user validation failed (possible stale user/login); trying Gitea API fallback..." >&2
|
||||||
gitea_issue_create_api
|
gitea_issue_create_api
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# pr-create.sh - Create pull requests on Gitea or GitHub
|
# pr-create.sh - Create pull requests on Gitea or GitHub
|
||||||
# Usage: pr-create.sh -t "Title" [-b "Body"] [-B base] [-H head] [-l "labels"] [-m "milestone"]
|
# Usage: pr-create.sh -t "Title" [-b "Body"] [-B base] [-H head] [-l "labels"] [-m "milestone"] [--login <name>]
|
||||||
|
#
|
||||||
|
# Acting principal is resolved identity-first (#1280): an explicit --login
|
||||||
|
# wins; otherwise MOSAIC_GIT_IDENTITY / per-worktree git config
|
||||||
|
# mosaic.gitIdentity selects the principal when a per-slot token exists (and
|
||||||
|
# the wrapper then creates the PR through the REST API with that identity's
|
||||||
|
# token — tea is never invoked, so the tea login list cannot shadow the
|
||||||
|
# requested principal); the tea login list is the LAST resort. A requested
|
||||||
|
# identity with no per-slot token fails LOUD rather than writing under
|
||||||
|
# whichever account tea happens to hold.
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
@@ -19,6 +28,15 @@ ISSUE=""
|
|||||||
|
|
||||||
# get_remote_host, get_gitea_token, get_repo_info, and get_gitea_repo_args are provided by detect-platform.sh
|
# get_remote_host, get_gitea_token, get_repo_info, and get_gitea_repo_args are provided by detect-platform.sh
|
||||||
|
|
||||||
|
# Acting-principal mode set in the Gitea branch below (from
|
||||||
|
# resolve_gitea_principal): "login" when --login was given, "identity" when a
|
||||||
|
# git identity bound, "default" otherwise. PRINCIPAL_MODE=login makes the API
|
||||||
|
# arm resolve the --login principal's token too, so an explicit --login keeps
|
||||||
|
# winning even on the tea-FAILURE fallback arm (otherwise the fallback would
|
||||||
|
# silently re-resolve to the environment identity or shared credential).
|
||||||
|
PRINCIPAL_MODE=""
|
||||||
|
PRINCIPAL_NAME=""
|
||||||
|
|
||||||
gitea_pr_create_api() {
|
gitea_pr_create_api() {
|
||||||
local host repo token url payload
|
local host repo token url payload
|
||||||
host=$(get_remote_host) || {
|
host=$(get_remote_host) || {
|
||||||
@@ -29,10 +47,19 @@ gitea_pr_create_api() {
|
|||||||
echo "Error: could not determine repo owner/name for API fallback" >&2
|
echo "Error: could not determine repo owner/name for API fallback" >&2
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
if [[ "$PRINCIPAL_MODE" == "login" ]]; then
|
||||||
|
token=$(get_gitea_token_for_login "$PRINCIPAL_NAME" "$host") || {
|
||||||
|
echo "Error: could not resolve a host-matched Gitea token for --login '$PRINCIPAL_NAME' on host '$host' (API path)" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
else
|
||||||
|
# Identity-first when MOSAIC_GIT_IDENTITY / git config mosaic.gitIdentity
|
||||||
|
# is set (per-slot token, fail-loud on absence); shared default otherwise.
|
||||||
token=$(get_gitea_token "$host") || {
|
token=$(get_gitea_token "$host") || {
|
||||||
echo "Error: Gitea token not found for API fallback (set GITEA_TOKEN or configure ~/.git-credentials)" >&2
|
echo "Error: Gitea token not found for API fallback (set GITEA_TOKEN or configure ~/.git-credentials)" >&2
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
fi
|
||||||
|
|
||||||
if [[ -n "$LABELS" || -n "$MILESTONE" || "$DRAFT" == true ]]; then
|
if [[ -n "$LABELS" || -n "$MILESTONE" || "$DRAFT" == true ]]; then
|
||||||
echo "Warning: API fallback applies title/body/head/base only; labels/milestone/draft require authenticated tea setup." >&2
|
echo "Warning: API fallback applies title/body/head/base only; labels/milestone/draft require authenticated tea setup." >&2
|
||||||
@@ -76,6 +103,7 @@ Options:
|
|||||||
-H, --head BRANCH Head branch with changes (default: current branch)
|
-H, --head BRANCH Head branch with changes (default: current branch)
|
||||||
-l, --labels LABELS Comma-separated labels
|
-l, --labels LABELS Comma-separated labels
|
||||||
-m, --milestone NAME Milestone name
|
-m, --milestone NAME Milestone name
|
||||||
|
--login NAME Act as this Gitea tea login (wins over MOSAIC_GIT_IDENTITY)
|
||||||
-i, --issue NUMBER Link to issue (auto-generates title if not provided)
|
-i, --issue NUMBER Link to issue (auto-generates title if not provided)
|
||||||
-d, --draft Create as draft PR
|
-d, --draft Create as draft PR
|
||||||
-h, --help Show this help message
|
-h, --help Show this help message
|
||||||
@@ -116,6 +144,10 @@ while [[ $# -gt 0 ]]; do
|
|||||||
MILESTONE="$2"
|
MILESTONE="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
|
--login)
|
||||||
|
LOGIN_OVERRIDE="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
-i|--issue)
|
-i|--issue)
|
||||||
ISSUE="$2"
|
ISSUE="$2"
|
||||||
shift 2
|
shift 2
|
||||||
@@ -174,15 +206,41 @@ case "$PLATFORM" in
|
|||||||
"${CMD[@]}"
|
"${CMD[@]}"
|
||||||
;;
|
;;
|
||||||
gitea)
|
gitea)
|
||||||
|
# Resolve the acting principal identity-first (#1280). The tea login
|
||||||
|
# list is the LAST resort: it knows nothing about which seat is calling,
|
||||||
|
# and a login resolved from it first is what attributed PRs to the wrong
|
||||||
|
# account even when MOSAIC_GIT_IDENTITY was set.
|
||||||
|
principal_host=$(get_remote_host 2>/dev/null || true)
|
||||||
|
if ! principal_resolved="$(resolve_gitea_principal "${LOGIN_OVERRIDE:-}" "$principal_host")"; then
|
||||||
|
# resolve_gitea_principal already printed the fail-loud diagnostic.
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
PRINCIPAL_MODE="$(printf '%s' "$principal_resolved" | cut -f1)"
|
||||||
|
PRINCIPAL_NAME="$(printf '%s' "$principal_resolved" | cut -f2)"
|
||||||
|
|
||||||
|
if [[ "$PRINCIPAL_MODE" == "identity" ]]; then
|
||||||
|
# HAPPY PATH for a requested identity: the per-slot token IS the
|
||||||
|
# credential, so create through the REST API directly and never
|
||||||
|
# invoke tea — the identity arm must be REACHED, not sit behind a
|
||||||
|
# tea failure (#1280). Fail-loud on a missing slot already happened
|
||||||
|
# in resolve_gitea_principal.
|
||||||
|
gitea_pr_create_api
|
||||||
|
exit $?
|
||||||
|
fi
|
||||||
|
|
||||||
# tea pull create syntax. Always pass --repo because tea repo inference
|
# tea pull create syntax. Always pass --repo because tea repo inference
|
||||||
# is unreliable in Mosaic worktrees/profile shells. Use arrays instead
|
# is unreliable in Mosaic worktrees/profile shells. Use arrays instead
|
||||||
# of eval so markdown backticks/body content are not shell-executed.
|
# of eval so markdown backticks/body content are not shell-executed.
|
||||||
REPO_SLUG=$(get_repo_slug)
|
REPO_SLUG=$(get_repo_slug)
|
||||||
|
if [[ "$PRINCIPAL_MODE" == "login" ]]; then
|
||||||
|
GITEA_LOGIN_NAME="$PRINCIPAL_NAME"
|
||||||
|
else
|
||||||
GITEA_LOGIN_NAME=$(get_gitea_login) || {
|
GITEA_LOGIN_NAME=$(get_gitea_login) || {
|
||||||
echo "Warning: could not resolve Gitea login for tea; trying Gitea API fallback..." >&2
|
echo "Warning: could not resolve Gitea login for tea; trying Gitea API fallback..." >&2
|
||||||
gitea_pr_create_api
|
gitea_pr_create_api
|
||||||
exit $?
|
exit $?
|
||||||
}
|
}
|
||||||
|
fi
|
||||||
if ! get_gitea_authenticated_user "$GITEA_LOGIN_NAME" >/dev/null; then
|
if ! get_gitea_authenticated_user "$GITEA_LOGIN_NAME" >/dev/null; then
|
||||||
echo "Warning: Tea authenticated-user validation failed (possible stale user/login); trying Gitea API fallback..." >&2
|
echo "Warning: Tea authenticated-user validation failed (possible stale user/login); trying Gitea API fallback..." >&2
|
||||||
gitea_pr_create_api
|
gitea_pr_create_api
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# pr-merge.sh - Merge pull requests on Gitea or GitHub
|
# pr-merge.sh - Merge pull requests on Gitea or GitHub
|
||||||
# Usage: pr-merge.sh -n PR_NUMBER [-m squash] [-d] [--expect-head SHA] [--co-author-trailers --escalate-to PRINCIPAL]
|
# Usage: pr-merge.sh -n PR_NUMBER [-m squash] [-d] [--expect-head SHA] [--co-author-trailers --escalate-to PRINCIPAL] [--login <name>]
|
||||||
|
#
|
||||||
|
# Acting principal is resolved identity-first (#1280): an explicit --login
|
||||||
|
# wins; otherwise MOSAIC_GIT_IDENTITY / per-worktree git config
|
||||||
|
# mosaic.gitIdentity selects the credential (per-slot token, fail-loud when
|
||||||
|
# absent); the shared host credential is the last resort. The merge is
|
||||||
|
# performed with the resolved credential only — never a cross-principal
|
||||||
|
# fallback (an HTTP 401 from the identity-bound token is a hard stop).
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -16,6 +23,7 @@ DRY_RUN=false
|
|||||||
EXPECT_HEAD=""
|
EXPECT_HEAD=""
|
||||||
CO_AUTHOR_TRAILERS=false
|
CO_AUTHOR_TRAILERS=false
|
||||||
ESCALATE_TO=""
|
ESCALATE_TO=""
|
||||||
|
LOGIN_OVERRIDE=""
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
cat <<EOF
|
cat <<EOF
|
||||||
@@ -31,6 +39,7 @@ Options:
|
|||||||
--expect-head SHA Refuse unless the PR head matches this full commit SHA
|
--expect-head SHA Refuse unless the PR head matches this full commit SHA
|
||||||
--co-author-trailers Build verified trailers from linked PR commit authors
|
--co-author-trailers Build verified trailers from linked PR commit authors
|
||||||
--escalate-to NAME Named principal for an unresolved-author BLOCK
|
--escalate-to NAME Named principal for an unresolved-author BLOCK
|
||||||
|
--login NAME Act as this Gitea tea login (wins over MOSAIC_GIT_IDENTITY)
|
||||||
-h, --help Show this help message
|
-h, --help Show this help message
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
@@ -39,6 +48,7 @@ Examples:
|
|||||||
$(basename "$0") -n 42 -d # Squash merge and delete branch
|
$(basename "$0") -n 42 -d # Squash merge and delete branch
|
||||||
$(basename "$0") -n 42 --expect-head 0123456789abcdef0123456789abcdef01234567
|
$(basename "$0") -n 42 --expect-head 0123456789abcdef0123456789abcdef01234567
|
||||||
$(basename "$0") -n 42 --co-author-trailers --escalate-to tl-mosaic
|
$(basename "$0") -n 42 --co-author-trailers --escalate-to tl-mosaic
|
||||||
|
$(basename "$0") -n 42 --login fred-ms # Merge under the fred-ms tea login
|
||||||
EOF
|
EOF
|
||||||
exit "${1:-1}"
|
exit "${1:-1}"
|
||||||
}
|
}
|
||||||
@@ -82,6 +92,14 @@ while [[ $# -gt 0 ]]; do
|
|||||||
ESCALATE_TO="$2"
|
ESCALATE_TO="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
|
--login|-l)
|
||||||
|
if [[ $# -lt 2 ]]; then
|
||||||
|
echo "Error: --login requires one tea login name." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
LOGIN_OVERRIDE="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
-h|--help)
|
-h|--help)
|
||||||
usage 0
|
usage 0
|
||||||
;;
|
;;
|
||||||
@@ -572,10 +590,23 @@ PY
|
|||||||
merge_gitea_with_api() {
|
merge_gitea_with_api() {
|
||||||
local host="$1" token attempt_rc
|
local host="$1" token attempt_rc
|
||||||
|
|
||||||
|
# Identity-first principal resolution (#1280): an explicit --login wins
|
||||||
|
# over MOSAIC_GIT_IDENTITY (operator intent beats environment); otherwise
|
||||||
|
# get_gitea_token resolves the identity's per-slot token when an identity
|
||||||
|
# is requested (fail-loud when absent) and the shared host credential only
|
||||||
|
# when no identity is set. No cross-principal fallback: whatever resolves
|
||||||
|
# here is the ONLY credential the merge is attempted with.
|
||||||
|
if [[ -n "$LOGIN_OVERRIDE" ]]; then
|
||||||
|
if ! token=$(get_gitea_token_for_login "$LOGIN_OVERRIDE" "$host"); then
|
||||||
|
echo "Error: --login '$LOGIN_OVERRIDE' has no host-matched token on host '$host'; refusing to merge under any other principal (#1280 identity-first resolution)." >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
if ! token=$(get_gitea_token "$host"); then
|
if ! token=$(get_gitea_token "$host"); then
|
||||||
echo "Error: Could not resolve the required Gitea token; refusing merge without changing principals." >&2
|
echo "Error: Could not resolve the required Gitea token; refusing merge without changing principals." >&2
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
fi
|
||||||
if [[ -z "$token" ]]; then
|
if [[ -z "$token" ]]; then
|
||||||
echo "Error: Required Gitea token resolved empty; refusing merge without changing principals." >&2
|
echo "Error: Required Gitea token resolved empty; refusing merge without changing principals." >&2
|
||||||
return 1
|
return 1
|
||||||
@@ -602,10 +633,25 @@ if [[ "$DRY_RUN" == true ]]; then
|
|||||||
echo "Error: Cannot determine host from origin remote URL" >&2
|
echo "Error: Cannot determine host from origin remote URL" >&2
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
# Report the acting principal the merge WOULD use, resolved the same
|
||||||
|
# way the real merge resolves it (#1280) — a dry run that names a
|
||||||
|
# different principal than the merge would act as is a lie.
|
||||||
|
if ! principal_resolved="$(resolve_gitea_principal "$LOGIN_OVERRIDE" "$HOST")"; then
|
||||||
|
# Fail-loud diagnostic already printed (unresolvable --login or a
|
||||||
|
# requested identity with no per-slot token).
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
DRY_PRINCIPAL_MODE="$(printf '%s' "$principal_resolved" | cut -f1)"
|
||||||
|
DRY_PRINCIPAL_NAME="$(printf '%s' "$principal_resolved" | cut -f2)"
|
||||||
|
case "$DRY_PRINCIPAL_MODE" in
|
||||||
|
login) DRY_PRINCIPAL_DESC="tea login '$DRY_PRINCIPAL_NAME'" ;;
|
||||||
|
identity) DRY_PRINCIPAL_DESC="git identity '$DRY_PRINCIPAL_NAME' (per-slot credential)" ;;
|
||||||
|
*) DRY_PRINCIPAL_DESC="default host credential" ;;
|
||||||
|
esac
|
||||||
if [[ "$CO_AUTHOR_TRAILERS" == true ]]; then
|
if [[ "$CO_AUTHOR_TRAILERS" == true ]]; then
|
||||||
echo "Dry run: would verify PR commit authors and merge PR #$PR_NUMBER on $HOST with authenticated Gitea API message fields (base=$BASE_BRANCH, method=squash)."
|
echo "Dry run: would verify PR commit authors and merge PR #$PR_NUMBER on $HOST as $DRY_PRINCIPAL_DESC with authenticated Gitea API message fields (base=$BASE_BRANCH, method=squash)."
|
||||||
else
|
else
|
||||||
echo "Dry run: would merge PR #$PR_NUMBER on $HOST with the authenticated exact-head Gitea API path (base=$BASE_BRANCH, method=squash)."
|
echo "Dry run: would merge PR #$PR_NUMBER on $HOST as $DRY_PRINCIPAL_DESC with the authenticated exact-head Gitea API path (base=$BASE_BRANCH, method=squash)."
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
echo "Dry run: would merge PR #$PR_NUMBER on $PLATFORM (base=$BASE_BRANCH, method=squash)."
|
echo "Dry run: would merge PR #$PR_NUMBER on $PLATFORM (base=$BASE_BRANCH, method=squash)."
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ while [[ $# -gt 0 ]]; do
|
|||||||
echo " -n, --number PR number (required)"
|
echo " -n, --number PR number (required)"
|
||||||
echo " -a, --action Review action: approve, request-changes, comment (required)"
|
echo " -a, --action Review action: approve, request-changes, comment (required)"
|
||||||
echo " -c, --comment Review comment (required for request-changes)"
|
echo " -c, --comment Review comment (required for request-changes)"
|
||||||
echo " -l, --login Override the detected Gitea tea login (approve/request-changes only)"
|
echo " -l, --login Override the detected Gitea tea login (all actions; wins over MOSAIC_GIT_IDENTITY)"
|
||||||
echo " -r, --repo Explicit owner/repo slug (skips git-remote slug inference)"
|
echo " -r, --repo Explicit owner/repo slug (skips git-remote slug inference)"
|
||||||
echo " -H, --host Explicit Gitea host (skips remote-host inference)"
|
echo " -H, --host Explicit Gitea host (skips remote-host inference)"
|
||||||
echo " -h, --help Show this help"
|
echo " -h, --help Show this help"
|
||||||
@@ -346,7 +346,14 @@ gitea_resolve_api_for_login() {
|
|||||||
else
|
else
|
||||||
host=$(get_remote_host)
|
host=$(get_remote_host)
|
||||||
fi
|
fi
|
||||||
if [[ -n "$override_explicit" ]]; then
|
if [[ "$override_explicit" == "identity" ]]; then
|
||||||
|
# Requested git identity (#1280): the per-slot token MUST resolve via
|
||||||
|
# get_gitea_token's identity arm; never borrow the tea default login.
|
||||||
|
GITEA_API_TOKEN=$(get_gitea_token "$host") || {
|
||||||
|
echo "Error: could not resolve the per-slot token for requested git identity '$effective_login' on host '$host'; refusing to fall back to the tea login list or shared credentials (review write/read-back, #1280)." >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
elif [[ -n "$override_explicit" ]]; then
|
||||||
GITEA_API_TOKEN=$(get_gitea_token_for_login "$effective_login" "$host") || {
|
GITEA_API_TOKEN=$(get_gitea_token_for_login "$effective_login" "$host") || {
|
||||||
echo "Error: could not resolve a host-matched Gitea token for --login '$effective_login' on host '$host'; refusing to fall back to the host default identity or a cross-host credential (review write/read-back)" >&2
|
echo "Error: could not resolve a host-matched Gitea token for --login '$effective_login' on host '$host'; refusing to fall back to the host default identity or a cross-host credential (review write/read-back)" >&2
|
||||||
return 1
|
return 1
|
||||||
@@ -676,29 +683,32 @@ if [[ "$PLATFORM" == "github" ]]; then
|
|||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||||
|
# Resolve the acting principal ONCE for every action, identity-first
|
||||||
|
# (#1280): an explicit --login wins; otherwise MOSAIC_GIT_IDENTITY /
|
||||||
|
# per-worktree git config mosaic.gitIdentity selects the principal when a
|
||||||
|
# per-slot token exists (fail-loud when it does not); the tea login list is
|
||||||
|
# the LAST resort — it enumerates whatever logins this host happens to hold
|
||||||
|
# and knows nothing about which seat is calling, so resolving from it first
|
||||||
|
# wrote under whichever account tea had configured (the #1280 family).
|
||||||
|
principal_host="${HOST_OVERRIDE:-$(get_remote_host 2>/dev/null || true)}"
|
||||||
|
if ! principal_resolved="$(resolve_gitea_principal "$LOGIN_OVERRIDE" "$principal_host")"; then
|
||||||
|
# resolve_gitea_principal already printed the fail-loud diagnostic.
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
PRINCIPAL_MODE="$(printf '%s' "$principal_resolved" | cut -f1)"
|
||||||
|
PRINCIPAL_NAME="$(printf '%s' "$principal_resolved" | cut -f2)"
|
||||||
case $ACTION in
|
case $ACTION in
|
||||||
approve)
|
approve)
|
||||||
# Best-effort host for the tea-login GUESS only (gitea_resolve_api_for_login
|
# Identity-first principal resolution (#1280): PRINCIPAL_MODE /
|
||||||
# below re-derives the real host from HOST_OVERRIDE/remote independently and
|
# PRINCIPAL_NAME were resolved once above from --login >
|
||||||
# is authoritative). Prefer an explicit -H/--host; otherwise best-effort
|
# MOSAIC_GIT_IDENTITY / git config > tea login list (last resort).
|
||||||
# git-remote inference, tolerating its ABSENCE (a bare `get_remote_host` here
|
if [[ "$PRINCIPAL_MODE" == "identity" ]]; then
|
||||||
# under `set -e`, with no origin and no -H, previously killed the script
|
gitea_resolve_api_for_login "$PRINCIPAL_NAME" identity || exit 1
|
||||||
# SILENTLY — exit 1, zero output — even though -r/-H are exactly the flags
|
elif [[ "$PRINCIPAL_MODE" == "login" ]]; then
|
||||||
# that support running with no usable origin at all).
|
gitea_resolve_api_for_login "$PRINCIPAL_NAME" explicit || exit 1
|
||||||
host="${HOST_OVERRIDE:-$(get_remote_host 2>/dev/null || true)}"
|
else
|
||||||
# A --login override always wins. Otherwise name this host's login
|
gitea_resolve_api_for_login "$PRINCIPAL_NAME" "" || exit 1
|
||||||
# only as a best effort: the login name merely selects a per-login
|
fi
|
||||||
# token, and gitea_resolve_api_for_login falls back to the host
|
|
||||||
# credential (get_gitea_token) when no tea login is named — so a host
|
|
||||||
# tea's login list need not enumerate exotic (e.g. ported) hosts for
|
|
||||||
# the default credential to resolve. The single resolved token is
|
|
||||||
# then used for the write, the /user identity, and the read-back.
|
|
||||||
EFFECTIVE_LOGIN="$LOGIN_OVERRIDE"
|
|
||||||
[[ -n "$EFFECTIVE_LOGIN" ]] || EFFECTIVE_LOGIN=$(get_gitea_login_for_host "$host" 2>/dev/null || true)
|
|
||||||
# Bind the REST endpoint + token to the effective login, then derive
|
|
||||||
# the acting identity from that SAME credential so the review submit
|
|
||||||
# and its read-back verify against the identity that performed them.
|
|
||||||
gitea_resolve_api_for_login "$EFFECTIVE_LOGIN" "${LOGIN_OVERRIDE:+explicit}" || exit 1
|
|
||||||
ACTING_LOGIN=$(gitea_authenticated_login) || exit 1
|
ACTING_LOGIN=$(gitea_authenticated_login) || exit 1
|
||||||
head_sha=$(gitea_pr_head_sha "$PR_NUMBER") || exit 1
|
head_sha=$(gitea_pr_head_sha "$PR_NUMBER") || exit 1
|
||||||
# The review body (if any) travels with the review itself in the REST
|
# The review body (if any) travels with the review itself in the REST
|
||||||
@@ -715,24 +725,16 @@ elif [[ "$PLATFORM" == "gitea" ]]; then
|
|||||||
echo "Error: Comment required for request-changes"
|
echo "Error: Comment required for request-changes"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
# Best-effort host for the tea-login GUESS only (gitea_resolve_api_for_login
|
# Identity-first principal resolution (#1280): PRINCIPAL_MODE /
|
||||||
# below re-derives the real host from HOST_OVERRIDE/remote independently and
|
# PRINCIPAL_NAME were resolved once above from --login >
|
||||||
# is authoritative). Prefer an explicit -H/--host; otherwise best-effort
|
# MOSAIC_GIT_IDENTITY / git config > tea login list (last resort).
|
||||||
# git-remote inference, tolerating its ABSENCE (a bare `get_remote_host` here
|
if [[ "$PRINCIPAL_MODE" == "identity" ]]; then
|
||||||
# under `set -e`, with no origin and no -H, previously killed the script
|
gitea_resolve_api_for_login "$PRINCIPAL_NAME" identity || exit 1
|
||||||
# SILENTLY — exit 1, zero output — even though -r/-H are exactly the flags
|
elif [[ "$PRINCIPAL_MODE" == "login" ]]; then
|
||||||
# that support running with no usable origin at all).
|
gitea_resolve_api_for_login "$PRINCIPAL_NAME" explicit || exit 1
|
||||||
host="${HOST_OVERRIDE:-$(get_remote_host 2>/dev/null || true)}"
|
else
|
||||||
# A --login override always wins. Otherwise name this host's login
|
gitea_resolve_api_for_login "$PRINCIPAL_NAME" "" || exit 1
|
||||||
# only as a best effort: the login name merely selects a per-login
|
fi
|
||||||
# token, and gitea_resolve_api_for_login falls back to the host
|
|
||||||
# credential (get_gitea_token) when no tea login is named — so a host
|
|
||||||
# tea's login list need not enumerate exotic (e.g. ported) hosts for
|
|
||||||
# the default credential to resolve. The single resolved token is
|
|
||||||
# then used for the write, the /user identity, and the read-back.
|
|
||||||
EFFECTIVE_LOGIN="$LOGIN_OVERRIDE"
|
|
||||||
[[ -n "$EFFECTIVE_LOGIN" ]] || EFFECTIVE_LOGIN=$(get_gitea_login_for_host "$host" 2>/dev/null || true)
|
|
||||||
gitea_resolve_api_for_login "$EFFECTIVE_LOGIN" "${LOGIN_OVERRIDE:+explicit}" || exit 1
|
|
||||||
ACTING_LOGIN=$(gitea_authenticated_login) || exit 1
|
ACTING_LOGIN=$(gitea_authenticated_login) || exit 1
|
||||||
head_sha=$(gitea_pr_head_sha "$PR_NUMBER") || exit 1
|
head_sha=$(gitea_pr_head_sha "$PR_NUMBER") || exit 1
|
||||||
review_id=$(gitea_submit_review_verified "$PR_NUMBER" "REQUEST_CHANGES" "$COMMENT" "$ACTING_LOGIN" "$head_sha") || {
|
review_id=$(gitea_submit_review_verified "$PR_NUMBER" "REQUEST_CHANGES" "$COMMENT" "$ACTING_LOGIN" "$head_sha") || {
|
||||||
@@ -746,24 +748,16 @@ elif [[ "$PLATFORM" == "gitea" ]]; then
|
|||||||
echo "Error: Comment required"
|
echo "Error: Comment required"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
# Best-effort host for the tea-login GUESS only (gitea_resolve_api_for_login
|
# Identity-first principal resolution (#1280): PRINCIPAL_MODE /
|
||||||
# below re-derives the real host from HOST_OVERRIDE/remote independently and
|
# PRINCIPAL_NAME were resolved once above from --login >
|
||||||
# is authoritative). Prefer an explicit -H/--host; otherwise best-effort
|
# MOSAIC_GIT_IDENTITY / git config > tea login list (last resort).
|
||||||
# git-remote inference, tolerating its ABSENCE (a bare `get_remote_host` here
|
if [[ "$PRINCIPAL_MODE" == "identity" ]]; then
|
||||||
# under `set -e`, with no origin and no -H, previously killed the script
|
gitea_resolve_api_for_login "$PRINCIPAL_NAME" identity || exit 1
|
||||||
# SILENTLY — exit 1, zero output — even though -r/-H are exactly the flags
|
elif [[ "$PRINCIPAL_MODE" == "login" ]]; then
|
||||||
# that support running with no usable origin at all).
|
gitea_resolve_api_for_login "$PRINCIPAL_NAME" explicit || exit 1
|
||||||
host="${HOST_OVERRIDE:-$(get_remote_host 2>/dev/null || true)}"
|
else
|
||||||
# A --login override always wins. Otherwise name this host's login
|
gitea_resolve_api_for_login "$PRINCIPAL_NAME" "" || exit 1
|
||||||
# only as a best effort: the login name merely selects a per-login
|
fi
|
||||||
# token, and gitea_resolve_api_for_login falls back to the host
|
|
||||||
# credential (get_gitea_token) when no tea login is named — so a host
|
|
||||||
# tea's login list need not enumerate exotic (e.g. ported) hosts for
|
|
||||||
# the default credential to resolve. The single resolved token is
|
|
||||||
# then used for the write, the /user identity, and the read-back.
|
|
||||||
EFFECTIVE_LOGIN="$LOGIN_OVERRIDE"
|
|
||||||
[[ -n "$EFFECTIVE_LOGIN" ]] || EFFECTIVE_LOGIN=$(get_gitea_login_for_host "$host" 2>/dev/null || true)
|
|
||||||
gitea_resolve_api_for_login "$EFFECTIVE_LOGIN" "${LOGIN_OVERRIDE:+explicit}" || exit 1
|
|
||||||
ACTING_LOGIN=$(gitea_authenticated_login) || exit 1
|
ACTING_LOGIN=$(gitea_authenticated_login) || exit 1
|
||||||
comment_id=$(gitea_create_comment_verified "$PR_NUMBER" "$COMMENT" "$ACTING_LOGIN") || {
|
comment_id=$(gitea_create_comment_verified "$PR_NUMBER" "$COMMENT" "$ACTING_LOGIN") || {
|
||||||
echo "Error: could not create and verify a comment on Gitea PR #$PR_NUMBER via a provider-returned created id (#865)." >&2
|
echo "Error: could not create and verify a comment on Gitea PR #$PR_NUMBER via a provider-returned created id (#865)." >&2
|
||||||
|
|||||||
@@ -0,0 +1,255 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Regression harness for detect-platform.sh's resolve_gitea_principal() — the
|
||||||
|
# identity-first acting-principal resolution shared by the git wrappers
|
||||||
|
# (mosaicstack/stack #1280).
|
||||||
|
#
|
||||||
|
# The contract under test (precedence: --login > MOSAIC_GIT_IDENTITY /
|
||||||
|
# git config mosaic.gitIdentity > tea login list, which is the LAST resort):
|
||||||
|
# 1. identity env + per-slot token present -> mode=identity, principal=
|
||||||
|
# identity name, source names the identity's slot PATH (never a token
|
||||||
|
# value).
|
||||||
|
# 2. identity env + per-slot token ABSENT -> FAIL LOUD: nonzero, empty
|
||||||
|
# stdout, stderr naming the identity and the expected slot path.
|
||||||
|
# 3. identity env + --login -> --login wins (login mode resolves even when
|
||||||
|
# the identity has no slot — operator intent beats environment).
|
||||||
|
# 4. identity unset + no --login -> default mode: the tea login list
|
||||||
|
# resolves the principal exactly as before (preserved behavior).
|
||||||
|
# 5. no identity + no host-matching tea login -> default/host-credential
|
||||||
|
# (preserved behavior; absence is not an error on the default path).
|
||||||
|
# 6. identity on an UNRECOGNIZED host (no per-slot scheme) -> does not bind;
|
||||||
|
# default mode (containment, mirroring get_gitea_token).
|
||||||
|
# 7. --login with no host-bound token for that login -> FAIL LOUD, stderr
|
||||||
|
# naming the login and the host.
|
||||||
|
# 8. git config mosaic.gitIdentity is honored when the env var is unset.
|
||||||
|
# 9. The resolver NEVER emits a token value — stdout/stderr of every
|
||||||
|
# successful resolution must not contain the slot file's contents.
|
||||||
|
#
|
||||||
|
# Uses a stubbed tea binary, stubbed tea config.yml, stubbed credentials.json
|
||||||
|
# and stubbed per-slot token files under a fake HOME. NEVER reads real secrets.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/gitea-principal-resolution}"
|
||||||
|
FAKE_HOME="$WORK_DIR/home"
|
||||||
|
REPO_DIR="$WORK_DIR/repo"
|
||||||
|
BIN_DIR="$WORK_DIR/bin"
|
||||||
|
CREDENTIALS_FILE="$FAKE_HOME/.config/mosaic/credentials.json"
|
||||||
|
|
||||||
|
rm -rf "$WORK_DIR"
|
||||||
|
mkdir -p "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens" "$FAKE_HOME/.config/tea" "$REPO_DIR" "$BIN_DIR"
|
||||||
|
|
||||||
|
git -C "$REPO_DIR" init -q
|
||||||
|
git -C "$REPO_DIR" remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git
|
||||||
|
|
||||||
|
cat > "$CREDENTIALS_FILE" <<'JSON'
|
||||||
|
{
|
||||||
|
"gitea": {
|
||||||
|
"mosaicstack": {
|
||||||
|
"url": "https://git.mosaicstack.dev",
|
||||||
|
"token": "shared-mosaicstack-token"
|
||||||
|
},
|
||||||
|
"usc": {
|
||||||
|
"url": "https://git.uscllc.com",
|
||||||
|
"token": "shared-usc-token"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
JSON
|
||||||
|
|
||||||
|
# tea's own config store: the source get_gitea_token_for_login reads. Logins
|
||||||
|
# "alice" (mosaicstack) and "bob-usc" (usc) carry sentinel token values that
|
||||||
|
# the assertions prove are NEVER emitted by the resolver.
|
||||||
|
cat > "$FAKE_HOME/.config/tea/config.yml" <<'YAML'
|
||||||
|
logins:
|
||||||
|
- name: alice
|
||||||
|
url: https://git.mosaicstack.dev
|
||||||
|
token: SECRET-alice-tea-token
|
||||||
|
- name: bob-usc
|
||||||
|
url: https://git.uscllc.com
|
||||||
|
token: SECRET-bob-usc-tea-token
|
||||||
|
YAML
|
||||||
|
|
||||||
|
# Stubbed tea: only what login resolution needs (`login list --output json`).
|
||||||
|
cat > "$BIN_DIR/tea" <<'SH'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
if [[ "$*" == "login list --output json" ]]; then
|
||||||
|
cat <<'JSON'
|
||||||
|
[
|
||||||
|
{"name":"alice","url":"https://git.mosaicstack.dev","default":true},
|
||||||
|
{"name":"bob-usc","url":"https://git.uscllc.com"}
|
||||||
|
]
|
||||||
|
JSON
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
|
SH
|
||||||
|
chmod +x "$BIN_DIR/tea"
|
||||||
|
|
||||||
|
# Per-slot identity token with a sentinel value the assertions prove is never
|
||||||
|
# emitted (proving "token came from the identity's slot BY PATH, not by value").
|
||||||
|
echo -n "SECRET-agentX-slot-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentX.token"
|
||||||
|
|
||||||
|
fail=0
|
||||||
|
assert_eq() {
|
||||||
|
local desc="$1" expected="$2" actual="$3"
|
||||||
|
if [[ "$expected" != "$actual" ]]; then
|
||||||
|
echo "FAIL: $desc — expected '$expected', got '$actual'" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
assert_contains() {
|
||||||
|
local desc="$1" haystack="$2" needle="$3"
|
||||||
|
if [[ "$haystack" != *"$needle"* ]]; then
|
||||||
|
echo "FAIL: $desc — missing '$needle' in: $haystack" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
assert_not_contains() {
|
||||||
|
local desc="$1" haystack="$2" needle="$3"
|
||||||
|
if [[ "$haystack" == *"$needle"* ]]; then
|
||||||
|
echo "FAIL: $desc — must not contain '$needle', got: $haystack" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Runs resolve_gitea_principal for $1=login_override $2=host inside REPO_DIR
|
||||||
|
# (per-worktree git config resolves there) under a fake HOME, stubbed tea, and
|
||||||
|
# stubbed credentials. Extra env (e.g. MOSAIC_GIT_IDENTITY) via $@.
|
||||||
|
call_resolver() {
|
||||||
|
local login="$1" host="$2"; shift 2
|
||||||
|
(
|
||||||
|
cd "$REPO_DIR"
|
||||||
|
env -i HOME="$FAKE_HOME" PATH="$BIN_DIR:$PATH" \
|
||||||
|
GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null \
|
||||||
|
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
|
||||||
|
DETECT_PLATFORM_SH="$SCRIPT_DIR/detect-platform.sh" "$@" \
|
||||||
|
bash -c 'source "$DETECT_PLATFORM_SH"; resolve_gitea_principal "$1" "$2"' _ "$login" "$host"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
field() { printf '%s' "$1" | cut -f"$2"; }
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 1. Identity env + slot present -> identity mode, slot named BY PATH, and no
|
||||||
|
# token value ever emitted.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
git -C "$REPO_DIR" config --unset mosaic.gitIdentity 2>/dev/null || true
|
||||||
|
out=$(call_resolver "" "git.mosaicstack.dev" MOSAIC_GIT_IDENTITY=agentX)
|
||||||
|
assert_eq "identity mode" "identity" "$(field "$out" 1)"
|
||||||
|
assert_eq "identity principal" "agentX" "$(field "$out" 2)"
|
||||||
|
assert_eq "identity slot source" \
|
||||||
|
"identity-slot:$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentX.token" \
|
||||||
|
"$(field "$out" 3)"
|
||||||
|
assert_not_contains "identity stdout leaks token" "$out" "SECRET"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 2. Identity env + slot ABSENT -> fail loud: nonzero, empty stdout, stderr
|
||||||
|
# naming the identity and the expected slot path.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
stderr_file="$WORK_DIR/stderr.tmp"
|
||||||
|
set +e
|
||||||
|
out=$(call_resolver "" "git.mosaicstack.dev" MOSAIC_GIT_IDENTITY=agentNoSlot 2>"$stderr_file")
|
||||||
|
rc=$?
|
||||||
|
set -e
|
||||||
|
if [[ "$rc" -eq 0 ]]; then
|
||||||
|
echo "FAIL: missing slot — expected nonzero return, got 0 (stdout='$out')" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
if [[ -n "$out" ]]; then
|
||||||
|
echo "FAIL: missing slot — expected empty stdout, got '$out'" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
err=$(cat "$stderr_file")
|
||||||
|
assert_contains "missing slot names identity" "$err" "agentNoSlot"
|
||||||
|
assert_contains "missing slot names slot path" "$err" \
|
||||||
|
"$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentNoSlot.token"
|
||||||
|
assert_not_contains "missing-slot stderr leaks token" "$err" "SECRET"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 3. Identity + --login -> --login wins. Also wins when the identity has NO
|
||||||
|
# slot (no identity check may veto an explicit login).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
out=$(call_resolver "alice" "git.mosaicstack.dev" MOSAIC_GIT_IDENTITY=agentX)
|
||||||
|
assert_eq "login beats identity (mode)" "login" "$(field "$out" 1)"
|
||||||
|
assert_eq "login beats identity (principal)" "alice" "$(field "$out" 2)"
|
||||||
|
assert_eq "login source" "tea-login:alice" "$(field "$out" 3)"
|
||||||
|
out=$(call_resolver "alice" "git.mosaicstack.dev" MOSAIC_GIT_IDENTITY=agentNoSlot)
|
||||||
|
assert_eq "login beats slot-less identity" "login" "$(field "$out" 1)"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 4. No identity, no --login -> default mode via the tea login list
|
||||||
|
# (preserved behavior).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
out=$(call_resolver "" "git.mosaicstack.dev")
|
||||||
|
assert_eq "default mode" "default" "$(field "$out" 1)"
|
||||||
|
assert_eq "default principal" "alice" "$(field "$out" 2)"
|
||||||
|
assert_eq "default source" "tea-default" "$(field "$out" 3)"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 5. No identity, no --login, no host-matching tea login -> default with the
|
||||||
|
# host credential (absence is not an error on the default path).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
out=$(call_resolver "" "git.unknown.test")
|
||||||
|
assert_eq "no-match default mode" "default" "$(field "$out" 1)"
|
||||||
|
assert_eq "no-match default principal" "" "$(field "$out" 2)"
|
||||||
|
assert_eq "no-match default source" "host-credential" "$(field "$out" 3)"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 6. Identity on an UNRECOGNIZED host -> does not bind; default mode
|
||||||
|
# (containment, mirroring get_gitea_token's scope).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
out=$(call_resolver "" "github.com" MOSAIC_GIT_IDENTITY=agentX)
|
||||||
|
assert_eq "unrecognized host falls to default" "default" "$(field "$out" 1)"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 7. --login with no host-bound token for that login -> fail loud, stderr
|
||||||
|
# naming the login and the host.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
: > "$stderr_file"
|
||||||
|
set +e
|
||||||
|
out=$(call_resolver "ghost-login" "git.mosaicstack.dev" 2>"$stderr_file")
|
||||||
|
rc=$?
|
||||||
|
set -e
|
||||||
|
if [[ "$rc" -eq 0 ]]; then
|
||||||
|
echo "FAIL: unknown --login — expected nonzero return, got 0 (stdout='$out')" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
err=$(cat "$stderr_file")
|
||||||
|
assert_contains "unknown login names login" "$err" "ghost-login"
|
||||||
|
assert_contains "unknown login names host" "$err" "git.mosaicstack.dev"
|
||||||
|
# A cross-host login (exists, but for usc) must ALSO fail loud for mosaicstack.
|
||||||
|
set +e
|
||||||
|
out=$(call_resolver "bob-usc" "git.mosaicstack.dev" 2>"$stderr_file")
|
||||||
|
rc=$?
|
||||||
|
set -e
|
||||||
|
if [[ "$rc" -eq 0 ]]; then
|
||||||
|
echo "FAIL: cross-host --login — expected nonzero return, got 0" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 8. git config mosaic.gitIdentity honored when env is unset.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
git -C "$REPO_DIR" config mosaic.gitIdentity agentX
|
||||||
|
out=$(call_resolver "" "git.mosaicstack.dev")
|
||||||
|
assert_eq "git-config identity mode" "identity" "$(field "$out" 1)"
|
||||||
|
assert_eq "git-config identity principal" "agentX" "$(field "$out" 2)"
|
||||||
|
git -C "$REPO_DIR" config --unset mosaic.gitIdentity
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 9. Cross-host slot layout: the usc slot path is chosen for the usc host.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
echo -n "SECRET-agentX-usc-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-usc-agentX.token"
|
||||||
|
out=$(call_resolver "" "git.uscllc.com" MOSAIC_GIT_IDENTITY=agentX)
|
||||||
|
assert_eq "usc identity mode" "identity" "$(field "$out" 1)"
|
||||||
|
assert_eq "usc slot source" \
|
||||||
|
"identity-slot:$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-usc-agentX.token" \
|
||||||
|
"$(field "$out" 3)"
|
||||||
|
|
||||||
|
if [[ "$fail" -eq 0 ]]; then
|
||||||
|
echo "resolve_gitea_principal identity-first resolution regression passed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit "$fail"
|
||||||
@@ -77,12 +77,30 @@ exit 0
|
|||||||
SH
|
SH
|
||||||
chmod +x "$BIN_DIR/tea"
|
chmod +x "$BIN_DIR/tea"
|
||||||
|
|
||||||
|
# TRIPWIRE provider stub: this harness tests argv construction, so ANY curl
|
||||||
|
# call is a failure of that contract (and, before this stub existed, a LIVE
|
||||||
|
# write — the #1282–#1287 incident: the seat's real HOME leaked a global
|
||||||
|
# mosaic.gitIdentity, flipping the wrapper into identity mode whose real
|
||||||
|
# per-slot token created real issues on the forge). Fail loudly instead.
|
||||||
|
cat > "$BIN_DIR/curl" <<'SH'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
echo "FAIL: body-safety harness reached a provider request — this test must never curl" >&2
|
||||||
|
exit 99
|
||||||
|
SH
|
||||||
|
chmod +x "$BIN_DIR/curl"
|
||||||
|
|
||||||
|
# Hermetic invocation: fake HOME (no credentials, no tea config, no token
|
||||||
|
# slots) and GIT_CONFIG_GLOBAL severed — `git config --get mosaic.gitIdentity`
|
||||||
|
# otherwise resolves the WORKSTATION's global identity (mos-dt-0 on the seat
|
||||||
|
# that wrote this) and reroutes the wrapper into identity mode (#1280 family).
|
||||||
(
|
(
|
||||||
cd "$REPO_DIR"
|
cd "$REPO_DIR"
|
||||||
PATH="$BIN_DIR:$PATH" \
|
env -i HOME="$WORK_DIR/home" PATH="$BIN_DIR:$PATH" \
|
||||||
|
GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null \
|
||||||
MOSAIC_TEST_RECEIVED="$RECEIVED_FILE" \
|
MOSAIC_TEST_RECEIVED="$RECEIVED_FILE" \
|
||||||
"$SCRIPT_DIR/issue-create.sh" -t "Body safety test" -b "$BODY"
|
"$SCRIPT_DIR/issue-create.sh" -t "Body safety test" -b "$BODY"
|
||||||
) >/dev/null
|
) >/dev/null
|
||||||
|
mkdir -p "$WORK_DIR/home"
|
||||||
|
|
||||||
# 1. No command substitution executed anywhere in the pipeline.
|
# 1. No command substitution executed anywhere in the pipeline.
|
||||||
if [[ -e "$SENTINEL" ]]; then
|
if [[ -e "$SENTINEL" ]]; then
|
||||||
|
|||||||
@@ -47,14 +47,31 @@ SH
|
|||||||
chmod +x "$BIN_DIR/tea" "$BIN_DIR/curl"
|
chmod +x "$BIN_DIR/tea" "$BIN_DIR/curl"
|
||||||
|
|
||||||
run_wrapper() {
|
run_wrapper() {
|
||||||
|
# Hermetic: fake HOME (fixture credentials only, no token slots, no tea
|
||||||
|
# config) and GIT_CONFIG_GLOBAL severed — `git config --get
|
||||||
|
# mosaic.gitIdentity` otherwise resolves the WORKSTATION's global identity
|
||||||
|
# and reroutes the wrapper into identity mode before the tea paths this
|
||||||
|
# harness exercises (#1280 family; see test-issue-create-body-safety.sh).
|
||||||
|
# An `env …` prefix (used for MOSAIC_TEA_STALE_USER) is re-wrapped, not
|
||||||
|
# doubled: arguments beginning with "env" are shifted past.
|
||||||
|
local env_pairs=()
|
||||||
|
if [[ "${1:-}" == "env" ]]; then
|
||||||
|
shift
|
||||||
|
while [[ "$#" -gt 0 && "$1" == *=* ]]; do
|
||||||
|
env_pairs+=("$1")
|
||||||
|
shift
|
||||||
|
done
|
||||||
|
fi
|
||||||
(
|
(
|
||||||
cd "$REPO_DIR"
|
cd "$REPO_DIR"
|
||||||
PATH="$BIN_DIR:$PATH" \
|
env -i HOME="$WORK_DIR/home" PATH="$BIN_DIR:$PATH" \
|
||||||
|
GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null \
|
||||||
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
|
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
|
||||||
MOSAIC_TEST_LOG="$LOG_FILE" \
|
MOSAIC_TEST_LOG="$LOG_FILE" "${env_pairs[@]}" \
|
||||||
"$@"
|
"$@"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
mkdir -p "$WORK_DIR/home"
|
||||||
|
|
||||||
: > "$LOG_FILE"
|
: > "$LOG_FILE"
|
||||||
printf 'Interactive title\nInteractive body\nlabel-a,label-b\nM1\n' | run_wrapper "$SCRIPT_DIR/issue-create.sh" -i >/dev/null
|
printf 'Interactive title\nInteractive body\nlabel-a,label-b\nM1\n' | run_wrapper "$SCRIPT_DIR/issue-create.sh" -i >/dev/null
|
||||||
|
|||||||
@@ -0,0 +1,244 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Load-bearing regression harness for pr-create.sh identity-first principal
|
||||||
|
# resolution (mosaicstack/stack #1280).
|
||||||
|
#
|
||||||
|
# The failure this harness is written down to catch: `MOSAIC_GIT_IDENTITY=fargo
|
||||||
|
# pr-create.sh …` produces a PR attributed to `mos-dt-0` (whichever account the
|
||||||
|
# tea login list happens to hold). Before #1280 the identity-aware code existed
|
||||||
|
# but sat on the API arm that only ran when the tea path FAILED — tea succeeded,
|
||||||
|
# so the identity arm never executed, and every test that did not check ORDERING
|
||||||
|
# passed. This harness checks ordering directly:
|
||||||
|
#
|
||||||
|
# 1. identity set + slot present -> the PR is created via the REST API with
|
||||||
|
# the identity's per-slot token (asserted by sentinel value AT the fake
|
||||||
|
# provider), and tea's `pr create` is NEVER invoked.
|
||||||
|
# 2. identity set + slot ABSENT -> nonzero, stderr naming the identity and
|
||||||
|
# the expected slot path; neither tea `pr create` nor any API request
|
||||||
|
# fires. No silent fallback to the tea login list.
|
||||||
|
# 3. identity set + --login -> --login wins: tea runs WITH the explicit
|
||||||
|
# --login, no API request.
|
||||||
|
# 4. nothing set -> preserved behavior: tea path with the tea-list login.
|
||||||
|
#
|
||||||
|
# Uses a stubbed tea, a stubbed curl provider, stubbed credentials.json and
|
||||||
|
# per-slot token under a fake HOME. NEVER reads real secrets or hits a live
|
||||||
|
# forge — all assertions are against the stubs' logs.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-create-identity-first}"
|
||||||
|
FAKE_HOME="$WORK_DIR/home"
|
||||||
|
REPO_DIR="$WORK_DIR/repo"
|
||||||
|
TOOLS_DIR="$WORK_DIR/tools"
|
||||||
|
BIN_DIR="$WORK_DIR/bin"
|
||||||
|
LOG_FILE="$WORK_DIR/calls.log"
|
||||||
|
CREDENTIALS_FILE="$FAKE_HOME/.config/mosaic/credentials.json"
|
||||||
|
|
||||||
|
rm -rf "$WORK_DIR"
|
||||||
|
mkdir -p "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens" "$FAKE_HOME/.config/tea" \
|
||||||
|
"$REPO_DIR" "$TOOLS_DIR/git" "$TOOLS_DIR/_lib" "$BIN_DIR"
|
||||||
|
|
||||||
|
# Fixture: the real scripts under test, copied so sibling stubs (and the
|
||||||
|
# ../_lib credential loader) resolve inside the fixture tree.
|
||||||
|
cp "$SCRIPT_DIR/pr-create.sh" "$TOOLS_DIR/git/pr-create.sh"
|
||||||
|
cp "$SCRIPT_DIR/detect-platform.sh" "$TOOLS_DIR/git/detect-platform.sh"
|
||||||
|
cp "$SCRIPT_DIR/../_lib/credentials.sh" "$TOOLS_DIR/_lib/credentials.sh"
|
||||||
|
chmod +x "$TOOLS_DIR/git/pr-create.sh"
|
||||||
|
|
||||||
|
git -C "$REPO_DIR" init -q
|
||||||
|
git -C "$REPO_DIR" remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git
|
||||||
|
|
||||||
|
cat > "$CREDENTIALS_FILE" <<'JSON'
|
||||||
|
{
|
||||||
|
"gitea": {
|
||||||
|
"mosaicstack": {
|
||||||
|
"url": "https://git.mosaicstack.dev",
|
||||||
|
"token": "shared-mosaicstack-token"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
JSON
|
||||||
|
|
||||||
|
cat > "$FAKE_HOME/.config/tea/config.yml" <<'YAML'
|
||||||
|
logins:
|
||||||
|
- name: alice
|
||||||
|
url: https://git.mosaicstack.dev
|
||||||
|
token: SECRET-alice-tea-token
|
||||||
|
YAML
|
||||||
|
|
||||||
|
echo -n "SECRET-agentX-slot-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentX.token"
|
||||||
|
|
||||||
|
: > "$LOG_FILE"
|
||||||
|
|
||||||
|
# Stubbed tea: records every invocation; `login list` feeds login resolution;
|
||||||
|
# `api --login <n> /user` feeds get_gitea_authenticated_user; `pr create` marks
|
||||||
|
# the marker file (its presence fails the identity-mode assertions).
|
||||||
|
cat > "$BIN_DIR/tea" <<SH
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
printf 'TEA: %s\n' "\$*" >> "$LOG_FILE"
|
||||||
|
if [[ "\$*" == "login list --output json" ]]; then
|
||||||
|
cat <<'JSON'
|
||||||
|
[
|
||||||
|
{"name":"alice","url":"https://git.mosaicstack.dev","default":true}
|
||||||
|
]
|
||||||
|
JSON
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if [[ "\${1:-}" == "api" ]]; then
|
||||||
|
printf '%s\n' '{"login":"alice"}'
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if [[ "\$*" == pr\ create* ]]; then
|
||||||
|
echo "TEA-PR-CREATE-INVOKED" >> "$LOG_FILE"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
|
SH
|
||||||
|
chmod +x "$BIN_DIR/tea"
|
||||||
|
|
||||||
|
# Stubbed provider: records the URL and the Authorization header VALUE it
|
||||||
|
# received, answers 201 with a created-PR object. The sentinel token values are
|
||||||
|
# synthetic fixtures — asserting them at the provider proves WHICH slot's
|
||||||
|
# credential carried the write.
|
||||||
|
cat > "$BIN_DIR/curl" <<SH
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
url=""
|
||||||
|
auth=""
|
||||||
|
while [[ \$# -gt 0 ]]; do
|
||||||
|
case "\$1" in
|
||||||
|
-H)
|
||||||
|
case "\$2" in
|
||||||
|
Authorization*) auth="\$2" ;;
|
||||||
|
esac
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
*) [[ -n "\$1" && "\$1" != -* ]] && url="\$1"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
printf 'CURL-URL: %s\nCURL-AUTH: %s\n' "\$url" "\$auth" >> "$LOG_FILE"
|
||||||
|
cat <<'JSON'
|
||||||
|
{"number": 1299, "html_url": "https://git.mosaicstack.dev/mosaicstack/stack/pulls/1299"}
|
||||||
|
JSON
|
||||||
|
exit 0
|
||||||
|
SH
|
||||||
|
chmod +x "$BIN_DIR/curl"
|
||||||
|
|
||||||
|
fail=0
|
||||||
|
assert_contains() {
|
||||||
|
local desc="$1" needle="$2"
|
||||||
|
if ! grep -qF -- "$needle" "$LOG_FILE"; then
|
||||||
|
echo "FAIL: $desc — log does not contain '$needle':" >&2
|
||||||
|
cat "$LOG_FILE" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
assert_not_contains() {
|
||||||
|
local desc="$1" needle="$2"
|
||||||
|
if grep -qF -- "$needle" "$LOG_FILE"; then
|
||||||
|
echo "FAIL: $desc — log must not contain '$needle':" >&2
|
||||||
|
cat "$LOG_FILE" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
EXTRA_ARGS=""
|
||||||
|
run_pr_create() {
|
||||||
|
# "$@" carries ONLY environment assignments (VAR=value); EXTRA_ARGS (if
|
||||||
|
# set) carries wrapper arguments, so `env` never mistakes a wrapper flag
|
||||||
|
# like --login for one of its own.
|
||||||
|
(
|
||||||
|
cd "$REPO_DIR"
|
||||||
|
# shellcheck disable=SC2086 # EXTRA_ARGS is deliberately word-split wrapper args
|
||||||
|
env -i HOME="$FAKE_HOME" PATH="$BIN_DIR:$PATH" \
|
||||||
|
GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null \
|
||||||
|
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" "$@" \
|
||||||
|
bash "$TOOLS_DIR/git/pr-create.sh" -t "Test PR" -B next -H fix/test $EXTRA_ARGS
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 1. HAPPY PATH (the load-bearing ordering test): identity set + slot present
|
||||||
|
# -> REST API with the per-slot token; tea `pr create` NEVER invoked.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
set +e
|
||||||
|
out=$(run_pr_create MOSAIC_GIT_IDENTITY=agentX 2>"$WORK_DIR/stderr-1.tmp")
|
||||||
|
rc=$?
|
||||||
|
set -e
|
||||||
|
if [[ "$rc" -ne 0 ]]; then
|
||||||
|
echo "FAIL: identity happy path — expected rc=0, got $rc" >&2
|
||||||
|
cat "$WORK_DIR/stderr-1.tmp" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
assert_contains "identity happy path reaches the API" "CURL-URL: https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/pulls"
|
||||||
|
assert_contains "identity happy path carries the slot token" "CURL-AUTH: Authorization: token SECRET-agentX-slot-token"
|
||||||
|
assert_not_contains "identity happy path must NOT invoke tea pr create" "TEA-PR-CREATE-INVOKED"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 2. Identity set + slot ABSENT -> fail loud BEFORE any write: nonzero, stderr
|
||||||
|
# naming identity + slot path, no tea pr create, no API request.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
: > "$LOG_FILE"
|
||||||
|
set +e
|
||||||
|
out=$(run_pr_create MOSAIC_GIT_IDENTITY=agentNoSlot 2>"$WORK_DIR/stderr-2.tmp")
|
||||||
|
rc=$?
|
||||||
|
set -e
|
||||||
|
if [[ "$rc" -eq 0 ]]; then
|
||||||
|
echo "FAIL: missing slot — expected nonzero return, got 0 (stdout='$out')" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
err=$(cat "$WORK_DIR/stderr-2.tmp")
|
||||||
|
if [[ "$err" != *"agentNoSlot"* ]]; then
|
||||||
|
echo "FAIL: missing slot — stderr does not name the identity:" >&2
|
||||||
|
echo "$err" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
if [[ "$err" != *"$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentNoSlot.token"* ]]; then
|
||||||
|
echo "FAIL: missing slot — stderr does not name the expected slot path:" >&2
|
||||||
|
echo "$err" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
assert_not_contains "missing slot must not reach tea pr create" "TEA-PR-CREATE-INVOKED"
|
||||||
|
assert_not_contains "missing slot must not reach the API" "CURL-URL"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 3. Identity set + --login -> --login wins: tea runs WITH the explicit login.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
: > "$LOG_FILE"
|
||||||
|
EXTRA_ARGS="--login alice"
|
||||||
|
set +e
|
||||||
|
out=$(run_pr_create MOSAIC_GIT_IDENTITY=agentX 2>"$WORK_DIR/stderr-3.tmp")
|
||||||
|
rc=$?
|
||||||
|
set -e
|
||||||
|
EXTRA_ARGS=""
|
||||||
|
if [[ "$rc" -ne 0 ]]; then
|
||||||
|
echo "FAIL: login override — expected rc=0, got $rc" >&2
|
||||||
|
cat "$WORK_DIR/stderr-3.tmp" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
assert_contains "login override drives tea with the explicit login" "TEA: pr create --repo mosaicstack/stack --login alice"
|
||||||
|
assert_not_contains "login override must not hit the API" "CURL-URL"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 4. Nothing set -> preserved behavior: tea path with the tea-list login.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
: > "$LOG_FILE"
|
||||||
|
set +e
|
||||||
|
out=$(run_pr_create 2>"$WORK_DIR/stderr-4.tmp")
|
||||||
|
rc=$?
|
||||||
|
set -e
|
||||||
|
if [[ "$rc" -ne 0 ]]; then
|
||||||
|
echo "FAIL: default path — expected rc=0, got $rc" >&2
|
||||||
|
cat "$WORK_DIR/stderr-4.tmp" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
assert_contains "default path still uses the tea-list login" "TEA: pr create --repo mosaicstack/stack --login alice"
|
||||||
|
|
||||||
|
if [[ "$fail" -eq 0 ]]; then
|
||||||
|
echo "pr-create identity-first happy-path regression passed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit "$fail"
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Regression harness for pr-merge.sh identity-first principal resolution
|
||||||
|
# (mosaicstack/stack #1280).
|
||||||
|
#
|
||||||
|
# Covers:
|
||||||
|
# 1. --dry-run reports the acting principal the merge WOULD use, resolved the
|
||||||
|
# same way the real merge resolves it: --login > MOSAIC_GIT_IDENTITY /
|
||||||
|
# git config mosaic.gitIdentity > shared host credential. (The pre-#1280
|
||||||
|
# deployed copy reported a tea login that the merge would not act as.)
|
||||||
|
# 2. --dry-run fails closed when the requested principal has no credential:
|
||||||
|
# unknown --login, or an identity with no per-slot token (stderr names
|
||||||
|
# the login / the identity and its slot path).
|
||||||
|
# 3. The real merge POST carries the resolved principal's credential and no
|
||||||
|
# other: --login merges with that login's tea-config token; an identity
|
||||||
|
# merges with the per-slot token; an unresolvable --login never reaches
|
||||||
|
# the provider.
|
||||||
|
#
|
||||||
|
# Fixture pattern from test-pr-merge-head-pin.sh: the scripts under test are
|
||||||
|
# copied into a fixture tree with stubbed pr-metadata.sh / ci-queue-wait.sh
|
||||||
|
# siblings; the provider is a stubbed curl that records the credential it
|
||||||
|
# received. NEVER reads real secrets or hits a live forge.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-merge-principal-resolution}"
|
||||||
|
FAKE_HOME="$WORK_DIR/home"
|
||||||
|
REPO_DIR="$WORK_DIR/repo"
|
||||||
|
TOOLS_DIR="$WORK_DIR/tools"
|
||||||
|
BIN_DIR="$WORK_DIR/bin"
|
||||||
|
LOG_FILE="$WORK_DIR/calls.log"
|
||||||
|
CREDENTIALS_FILE="$FAKE_HOME/.config/mosaic/credentials.json"
|
||||||
|
SHA=0123456789abcdef0123456789abcdef01234567
|
||||||
|
|
||||||
|
rm -rf "$WORK_DIR"
|
||||||
|
mkdir -p "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens" "$FAKE_HOME/.config/tea" \
|
||||||
|
"$REPO_DIR" "$TOOLS_DIR/git" "$TOOLS_DIR/_lib" "$BIN_DIR"
|
||||||
|
|
||||||
|
cp "$SCRIPT_DIR/pr-merge.sh" "$TOOLS_DIR/git/pr-merge.sh"
|
||||||
|
cp "$SCRIPT_DIR/detect-platform.sh" "$TOOLS_DIR/git/detect-platform.sh"
|
||||||
|
cp "$SCRIPT_DIR/../_lib/credentials.sh" "$TOOLS_DIR/_lib/credentials.sh"
|
||||||
|
chmod +x "$TOOLS_DIR/git/pr-merge.sh"
|
||||||
|
|
||||||
|
git -C "$REPO_DIR" init -q
|
||||||
|
git -C "$REPO_DIR" remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git
|
||||||
|
|
||||||
|
# Stubbed siblings pr-merge.sh resolves relative to its own SCRIPT_DIR.
|
||||||
|
cat > "$TOOLS_DIR/git/pr-metadata.sh" <<SH
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
printf '%s\n' '{"baseRefName":"next","headRefName":"fix/pinned","headRefOid":"$SHA","headRepository":"mosaicstack/stack","title":"Test PR","author":{"login":"contributor"}}'
|
||||||
|
SH
|
||||||
|
cat > "$TOOLS_DIR/git/ci-queue-wait.sh" <<'SH'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
exit 0
|
||||||
|
SH
|
||||||
|
chmod +x "$TOOLS_DIR/git/pr-metadata.sh" "$TOOLS_DIR/git/ci-queue-wait.sh"
|
||||||
|
|
||||||
|
cat > "$CREDENTIALS_FILE" <<'JSON'
|
||||||
|
{
|
||||||
|
"gitea": {
|
||||||
|
"mosaicstack": {
|
||||||
|
"url": "https://git.mosaicstack.dev",
|
||||||
|
"token": "shared-mosaicstack-token"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
JSON
|
||||||
|
|
||||||
|
cat > "$FAKE_HOME/.config/tea/config.yml" <<'YAML'
|
||||||
|
logins:
|
||||||
|
- name: fred-ms
|
||||||
|
url: https://git.mosaicstack.dev
|
||||||
|
token: SECRET-fred-ms-tea-token
|
||||||
|
YAML
|
||||||
|
|
||||||
|
echo -n "SECRET-agentX-slot-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentX.token"
|
||||||
|
|
||||||
|
: > "$LOG_FILE"
|
||||||
|
|
||||||
|
# Stubbed tea for login-list resolution only.
|
||||||
|
cat > "$BIN_DIR/tea" <<'SH'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
if [[ "$*" == "login list --output json" ]]; then
|
||||||
|
cat <<'JSON'
|
||||||
|
[
|
||||||
|
{"name":"fred-ms","url":"https://git.mosaicstack.dev","default":true}
|
||||||
|
]
|
||||||
|
JSON
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
|
SH
|
||||||
|
chmod +x "$BIN_DIR/tea"
|
||||||
|
|
||||||
|
# Stubbed provider. pr-merge passes curl config on STDIN with -K -; the stub
|
||||||
|
# reads stdin, records the Authorization header it received, answers 200.
|
||||||
|
cat > "$BIN_DIR/curl" <<SH
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
url=""
|
||||||
|
out_file=""
|
||||||
|
stdin_config=""
|
||||||
|
if [[ ! -t 0 ]]; then
|
||||||
|
stdin_config="\$(cat || true)"
|
||||||
|
fi
|
||||||
|
while [[ \$# -gt 0 ]]; do
|
||||||
|
case "\$1" in
|
||||||
|
-o) out_file="\$2"; shift 2 ;;
|
||||||
|
-K|-w|--max-filesize|--max-time|--connect-timeout|-sS) shift 2 ;;
|
||||||
|
*) [[ -n "\$1" && "\$1" != -* && -z "\$url" ]] && url="\$1"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
auth="\$(printf '%s' "\$stdin_config" | grep -o 'Authorization: token [^"]*' || true)"
|
||||||
|
printf 'CURL-URL: %s\nCURL-AUTH: %s\n' "\$url" "\$auth" >> "$LOG_FILE"
|
||||||
|
[[ -n "\$out_file" ]] && printf '{}' > "\$out_file"
|
||||||
|
printf '200\n'
|
||||||
|
exit 0
|
||||||
|
SH
|
||||||
|
chmod +x "$BIN_DIR/curl"
|
||||||
|
|
||||||
|
fail=0
|
||||||
|
assert_contains_log() {
|
||||||
|
local desc="$1" needle="$2"
|
||||||
|
if ! grep -qF -- "$needle" "$LOG_FILE"; then
|
||||||
|
echo "FAIL: $desc — log does not contain '$needle':" >&2
|
||||||
|
cat "$LOG_FILE" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
assert_not_contains_log() {
|
||||||
|
local desc="$1" needle="$2"
|
||||||
|
if grep -qF -- "$needle" "$LOG_FILE"; then
|
||||||
|
echo "FAIL: $desc — log must not contain '$needle':" >&2
|
||||||
|
cat "$LOG_FILE" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
run_pr_merge() {
|
||||||
|
local extra_args="$1"; shift
|
||||||
|
(
|
||||||
|
cd "$REPO_DIR"
|
||||||
|
# shellcheck disable=SC2086 # extra_args is deliberately word-split wrapper args
|
||||||
|
env -i HOME="$FAKE_HOME" PATH="$BIN_DIR:$PATH" \
|
||||||
|
GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null \
|
||||||
|
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" "$@" \
|
||||||
|
bash "$TOOLS_DIR/git/pr-merge.sh" -n 42 $extra_args
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 1. --dry-run reports the resolved acting principal truthfully.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
out=$(run_pr_merge "--dry-run" MOSAIC_GIT_IDENTITY=agentX)
|
||||||
|
if [[ "$out" != *"as git identity 'agentX' (per-slot credential)"* ]]; then
|
||||||
|
echo "FAIL: dry-run identity — principal not reported: $out" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
out=$(run_pr_merge "--dry-run --login fred-ms" MOSAIC_GIT_IDENTITY=agentX)
|
||||||
|
if [[ "$out" != *"as tea login 'fred-ms'"* ]]; then
|
||||||
|
echo "FAIL: dry-run login override — login not reported (must beat env identity): $out" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
out=$(run_pr_merge "--dry-run")
|
||||||
|
if [[ "$out" != *"as default host credential"* ]]; then
|
||||||
|
echo "FAIL: dry-run default — not reported: $out" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 2. --dry-run fails closed when the requested principal has no credential.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
stderr_file="$WORK_DIR/stderr.tmp"
|
||||||
|
set +e
|
||||||
|
out=$(run_pr_merge "--dry-run --login ghost" 2>"$stderr_file")
|
||||||
|
rc=$?
|
||||||
|
set -e
|
||||||
|
if [[ "$rc" -eq 0 ]] || [[ "$(cat "$stderr_file")" != *"ghost"* ]]; then
|
||||||
|
echo "FAIL: dry-run unknown --login — expected fail-loud naming 'ghost', rc=$rc" >&2
|
||||||
|
cat "$stderr_file" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
: > "$stderr_file"
|
||||||
|
set +e
|
||||||
|
out=$(run_pr_merge "--dry-run" MOSAIC_GIT_IDENTITY=agentNoSlot 2>"$stderr_file")
|
||||||
|
rc=$?
|
||||||
|
set -e
|
||||||
|
err=$(cat "$stderr_file")
|
||||||
|
if [[ "$rc" -eq 0 ]] || [[ "$err" != *"agentNoSlot"* ]] \
|
||||||
|
|| [[ "$err" != *"$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentNoSlot.token"* ]]; then
|
||||||
|
echo "FAIL: dry-run identity without slot — expected fail-loud naming identity + slot path, rc=$rc" >&2
|
||||||
|
echo "$err" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 3. The real merge POST carries the resolved principal's credential ONLY.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
: > "$LOG_FILE"
|
||||||
|
set +e
|
||||||
|
out=$(run_pr_merge "--login fred-ms" MOSAIC_GIT_IDENTITY=agentX 2>"$stderr_file")
|
||||||
|
rc=$?
|
||||||
|
set -e
|
||||||
|
if [[ "$rc" -ne 0 ]]; then
|
||||||
|
echo "FAIL: merge with --login — expected rc=0, got $rc" >&2
|
||||||
|
cat "$stderr_file" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
assert_contains_log "merge --login uses the login token" "CURL-AUTH: Authorization: token SECRET-fred-ms-tea-token"
|
||||||
|
assert_not_contains_log "merge --login must not use the identity slot token" "SECRET-agentX-slot-token"
|
||||||
|
assert_not_contains_log "merge --login must not use the shared token" "shared-mosaicstack-token"
|
||||||
|
|
||||||
|
: > "$LOG_FILE"
|
||||||
|
set +e
|
||||||
|
out=$(run_pr_merge "" MOSAIC_GIT_IDENTITY=agentX 2>"$stderr_file")
|
||||||
|
rc=$?
|
||||||
|
set -e
|
||||||
|
if [[ "$rc" -ne 0 ]]; then
|
||||||
|
echo "FAIL: merge with identity — expected rc=0, got $rc" >&2
|
||||||
|
cat "$stderr_file" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
assert_contains_log "merge identity uses the per-slot token" "CURL-AUTH: Authorization: token SECRET-agentX-slot-token"
|
||||||
|
assert_not_contains_log "merge identity must not use the shared token" "shared-mosaicstack-token"
|
||||||
|
|
||||||
|
: > "$LOG_FILE"
|
||||||
|
set +e
|
||||||
|
out=$(run_pr_merge "--login ghost" 2>"$stderr_file")
|
||||||
|
rc=$?
|
||||||
|
set -e
|
||||||
|
if [[ "$rc" -eq 0 ]]; then
|
||||||
|
echo "FAIL: merge with unknown --login — expected nonzero, got 0" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
assert_not_contains_log "merge with unknown --login must not reach the provider" "CURL-URL"
|
||||||
|
|
||||||
|
if [[ "$fail" -eq 0 ]]; then
|
||||||
|
echo "pr-merge identity-first principal resolution regression passed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit "$fail"
|
||||||
@@ -39,3 +39,20 @@ packages/mosaic/framework/tools/tmux/test-send-message-verdict.sh | requires rea
|
|||||||
# recorded judgement. These lines ARE that judgement, signed.)
|
# recorded judgement. These lines ARE that judgement, signed.)
|
||||||
packages/mosaic/framework/tools/orchestrator/smoke-test.sh | behavior smoke checks for coord continue/run workflows, run manually by orchestrator seats; unmeasured in CI; #1017 burndown
|
packages/mosaic/framework/tools/orchestrator/smoke-test.sh | behavior smoke checks for coord continue/run workflows, run manually by orchestrator seats; unmeasured in CI; #1017 burndown
|
||||||
packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh | #973 instrument self-test, run as a precondition of the validate-973 evidence procedure rather than as a standing CI suite; #1017 burndown candidate
|
packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh | #973 instrument self-test, run as a precondition of the validate-973 evidence procedure rather than as a standing CI suite; #1017 burndown candidate
|
||||||
|
|
||||||
|
# --- tools/fleet: precondition is unsatisfiable in the CI image (#1271) ---
|
||||||
|
# Signed by fred (sb-it-1-dt, 2026-08-16) at origin/next 476db12.
|
||||||
|
# This suite asserts the launcher's behaviour when `mosaic` and `pi` are MISSING.
|
||||||
|
# It shims fakes into $FAKE_BIN, but the constructed PANE_PATH always ends in the
|
||||||
|
# real system path, so on a host that installs those binaries the missing-binary
|
||||||
|
# cases cannot be measured at all. The suite's own guard (line 103) says so and
|
||||||
|
# fails rather than reporting a pass it cannot back. That guard is correct.
|
||||||
|
# The error was wiring the suite into CI: #1017 (c56483eb) enumerated it and
|
||||||
|
# dropped this exclusion, and the CI image provides `pi` in the system path, so
|
||||||
|
# it has failed on every pipeline since. Measured 2026-08-16 across pipelines
|
||||||
|
# 2444 (#1256), 2438 (#1240) and 2441 (#1017-quality): exactly one FAIL line in
|
||||||
|
# each full log, identical, this assertion; control `zzz-not-present-zzz` -> 0.
|
||||||
|
# Burn-down and the full measurement are tracked in #1271; unwired by PR #1270.
|
||||||
|
# Because test:framework-shell is one && chain and this sat at position 44 of 48,
|
||||||
|
# the four suites after it had not run at all since the merge.
|
||||||
|
packages/mosaic/framework/tools/fleet/test-start-agent-session.sh | precondition unsatisfiable in the CI image: asserts missing-binary behaviour, but PANE_PATH always ends in the system path and the image provides `pi` there; guard at line 103 fails by design rather than passing unmeasured. Burn down by controlling the tail of PANE_PATH inside the test. NOT by removing `pi` from the image: the CI image installs @earendil-works/[email protected] deliberately (measured in pipeline 2444's test-step log), and other suites depend on that pin. Burn-down tracked in #1271
|
||||||
|
|||||||
@@ -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 && 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"
|
"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/git/test-gitea-principal-resolution.sh && bash framework/tools/git/test-pr-create-identity-first.sh && bash framework/tools/git/test-pr-merge-principal-resolution.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/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,13 +1,9 @@
|
|||||||
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, resolve } from 'node:path';
|
import { join } 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 = `
|
||||||
@@ -69,15 +65,12 @@ 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',
|
||||||
@@ -90,24 +83,6 @@ 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 => {
|
||||||
@@ -177,64 +152,13 @@ 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,18 +8,10 @@ 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'>;
|
||||||
}
|
}
|
||||||
@@ -79,10 +71,6 @@ 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)
|
||||||
@@ -102,31 +90,8 @@ export async function executeReconcilerCommand(
|
|||||||
...(deps.reconcileDeps ?? {}),
|
...(deps.reconcileDeps ?? {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
printJson(operation === 'doctor' ? { ...result, checks: runtimeInspection } : result);
|
printJson(result);
|
||||||
const executableFailure =
|
process.exitCode = result.recovery === undefined && result.cleanup === undefined ? 0 : 1;
|
||||||
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 {
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { tmpdir } from 'node:os';
|
|||||||
import { join, resolve } 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 { FleetRuntimeProbeRunner } from '../fleet/fleet-runtime-preflight.js';
|
|
||||||
import { registerFleetCommand, type CommandResult, type CommandRunner } from './fleet.js';
|
import { registerFleetCommand, type CommandResult, type CommandRunner } from './fleet.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -70,7 +69,6 @@ agents:
|
|||||||
let tempHome: string | undefined;
|
let tempHome: string | undefined;
|
||||||
const savedHome = process.env.HOME;
|
const savedHome = process.env.HOME;
|
||||||
const savedMosaicHome = process.env.MOSAIC_HOME;
|
const savedMosaicHome = process.env.MOSAIC_HOME;
|
||||||
const savedPath = process.env.PATH;
|
|
||||||
|
|
||||||
afterEach(async (): Promise<void> => {
|
afterEach(async (): Promise<void> => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
@@ -79,8 +77,6 @@ afterEach(async (): Promise<void> => {
|
|||||||
else process.env.HOME = savedHome;
|
else process.env.HOME = savedHome;
|
||||||
if (savedMosaicHome === undefined) delete process.env.MOSAIC_HOME;
|
if (savedMosaicHome === undefined) delete process.env.MOSAIC_HOME;
|
||||||
else process.env.MOSAIC_HOME = savedMosaicHome;
|
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 });
|
if (tempHome) await rm(tempHome, { recursive: true, force: true });
|
||||||
tempHome = undefined;
|
tempHome = undefined;
|
||||||
});
|
});
|
||||||
@@ -89,7 +85,7 @@ afterEach(async (): Promise<void> => {
|
|||||||
* A HOME with a roster-v2 fleet and nothing else — the greenfield shape, before
|
* A HOME with a roster-v2 fleet and nothing else — the greenfield shape, before
|
||||||
* anything has been installed, applied or started.
|
* anything has been installed, applied or started.
|
||||||
*/
|
*/
|
||||||
async function v2Home(options: { withPaneRuntime?: boolean } = {}): Promise<string> {
|
async function v2Home(): Promise<string> {
|
||||||
tempHome = await mkdtemp(join(tmpdir(), 'mosaic-fleet-v2-dispatch-'));
|
tempHome = await mkdtemp(join(tmpdir(), 'mosaic-fleet-v2-dispatch-'));
|
||||||
process.env.HOME = tempHome;
|
process.env.HOME = tempHome;
|
||||||
delete process.env.MOSAIC_HOME;
|
delete process.env.MOSAIC_HOME;
|
||||||
@@ -101,12 +97,6 @@ async function v2Home(options: { withPaneRuntime?: boolean } = {}): Promise<stri
|
|||||||
await writeFile(join(mosaicHome, 'fleet', 'roles', 'code.md'), '`class: code`\n\n# code\n', {
|
await writeFile(join(mosaicHome, 'fleet', 'roles', 'code.md'), '`class: code`\n\n# code\n', {
|
||||||
mode: 0o600,
|
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;
|
return mosaicHome;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,17 +113,10 @@ const greenfieldRunner: CommandRunner = async (command): Promise<CommandResult>
|
|||||||
return { stdout: '', stderr: '', exitCode: 1 };
|
return { stdout: '', stderr: '', exitCode: 1 };
|
||||||
};
|
};
|
||||||
|
|
||||||
function program(
|
function program(runner: CommandRunner = greenfieldRunner): Command {
|
||||||
runner: CommandRunner = greenfieldRunner,
|
|
||||||
runtimeProbeRunner?: FleetRuntimeProbeRunner,
|
|
||||||
): Command {
|
|
||||||
const result = new Command();
|
const result = new Command();
|
||||||
result.exitOverride();
|
result.exitOverride();
|
||||||
registerFleetCommand(result, {
|
registerFleetCommand(result, { runner, frameworkRoot: resolve(process.cwd(), 'framework') });
|
||||||
runner,
|
|
||||||
frameworkRoot: resolve(process.cwd(), 'framework'),
|
|
||||||
...(runtimeProbeRunner === undefined ? {} : { runtimeProbeRunner }),
|
|
||||||
});
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,93 +166,6 @@ describe('mosaic fleet ps — roster v2', (): void => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('mosaic fleet install — roster v2', (): void => {
|
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> => {
|
it('places the tool files and unit templates', async (): Promise<void> => {
|
||||||
const mosaicHome = await v2Home();
|
const mosaicHome = await v2Home();
|
||||||
capture();
|
capture();
|
||||||
@@ -287,11 +183,9 @@ describe('mosaic fleet install — roster v2', (): void => {
|
|||||||
]) {
|
]) {
|
||||||
expect(await exists(join(systemdUserDir, unit))).toBe(true);
|
expect(await exists(join(systemdUserDir, unit))).toBe(true);
|
||||||
}
|
}
|
||||||
for (const tool of ['start-agent-session.sh', 'pane-runtime-path.sh']) {
|
const launcher = join(mosaicHome, 'tools', 'fleet', 'start-agent-session.sh');
|
||||||
const toolPath = join(mosaicHome, 'tools', 'fleet', tool);
|
expect(await exists(launcher)).toBe(true);
|
||||||
expect(await exists(toolPath)).toBe(true);
|
expect((await stat(launcher)).mode & 0o777).toBe(0o755);
|
||||||
expect((await stat(toolPath)).mode & 0o777).toBe(0o755);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('writes NO generated env — that file belongs to the reconciler (#791)', async (): Promise<void> => {
|
it('writes NO generated env — that file belongs to the reconciler (#791)', async (): Promise<void> => {
|
||||||
|
|||||||
@@ -1277,10 +1277,6 @@ 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();
|
||||||
@@ -1319,10 +1315,6 @@ 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');
|
||||||
|
|||||||
@@ -60,12 +60,6 @@ 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';
|
||||||
@@ -95,8 +89,6 @@ 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;
|
||||||
/**
|
/**
|
||||||
@@ -1437,10 +1429,6 @@ 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();
|
||||||
@@ -1539,7 +1527,7 @@ 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, runtimeProbeRunner);
|
await installFleet(cmd, frameworkRoot);
|
||||||
// Unit enablement needs agent names only, so it reads either version.
|
// Unit enablement needs agent names only, so it reads either version.
|
||||||
const roster = await loadRosterReadModel(cmd);
|
const roster = await loadRosterReadModel(cmd);
|
||||||
await enableFleetUnits(runner, roster, opts);
|
await enableFleetUnits(runner, roster, opts);
|
||||||
@@ -1550,7 +1538,7 @@ 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, runtimeProbeRunner);
|
await installFleet(cmd, frameworkRoot);
|
||||||
// Unit enablement needs agent names only, so it reads either version.
|
// Unit enablement needs agent names only, so it reads either version.
|
||||||
const roster = await loadRosterReadModel(cmd);
|
const roster = await loadRosterReadModel(cmd);
|
||||||
await enableFleetUnits(runner, roster, opts);
|
await enableFleetUnits(runner, roster, opts);
|
||||||
@@ -2096,8 +2084,6 @@ 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,
|
||||||
});
|
});
|
||||||
@@ -2363,68 +2349,18 @@ export function registerFleetAgentCommands(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function installFleet(
|
async function installFleet(cmd: Command, frameworkRoot: string): Promise<void> {
|
||||||
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);
|
||||||
// Read and preflight before the first mkdir/copy/chmod/write. A successful
|
// Read model first: every file this function places is roster-independent, and
|
||||||
// install must mean every roster runtime is executable in the eventual pane,
|
// the v1 parser would reject a v2 roster before any of them were written.
|
||||||
// not merely visible to the operator who invoked this command.
|
|
||||||
const roster = await loadRosterReadModel(cmd);
|
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',
|
||||||
@@ -2438,7 +2374,6 @@ async function installFleet(
|
|||||||
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,
|
||||||
@@ -2449,7 +2384,6 @@ async function installFleet(
|
|||||||
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,
|
||||||
@@ -2493,9 +2427,7 @@ async function installFleet(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (v1Roster === undefined) {
|
const v1Roster = await loadRosterForCommand(cmd);
|
||||||
throw new Error('Roster version changed while installing fleet files.');
|
|
||||||
}
|
|
||||||
for (const agent of v1Roster.agents) {
|
for (const agent of v1Roster.agents) {
|
||||||
await writeAgentEnvironmentProjection({
|
await writeAgentEnvironmentProjection({
|
||||||
mosaicHome: activePaths.mosaicHome,
|
mosaicHome: activePaths.mosaicHome,
|
||||||
|
|||||||
@@ -1,328 +0,0 @@
|
|||||||
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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,362 +0,0 @@
|
|||||||
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,12 +25,6 @@ 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;
|
||||||
@@ -151,23 +145,6 @@ 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);
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ const RUNTIME_DEFS: Record<
|
|||||||
label: 'Claude Code',
|
label: 'Claude Code',
|
||||||
command: 'claude',
|
command: 'claude',
|
||||||
versionFlag: '--version',
|
versionFlag: '--version',
|
||||||
installHint: 'curl -fsSL https://claude.ai/install.sh | bash',
|
installHint: 'npm install -g @anthropic-ai/claude-code',
|
||||||
},
|
},
|
||||||
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: 'npm install -g opencode-ai',
|
installHint: 'See https://opencode.ai for install instructions',
|
||||||
},
|
},
|
||||||
pi: {
|
pi: {
|
||||||
label: 'Pi',
|
label: 'Pi',
|
||||||
command: 'pi',
|
command: 'pi',
|
||||||
versionFlag: '--version',
|
versionFlag: '--version',
|
||||||
installHint: 'npm install -g @earendil-works/pi-coding-agent',
|
installHint: 'curl -fsSL https://pi.dev/install.sh | sh',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user