v1.91.5.0 feat: balanced free-suite shards, test:ubicloud, and faster PR eval lane (#2989)

* v1.91.5.0 feat: balanced free-suite shards, 16-way Linux runs, and bun run test:ubicloud

* chore: regenerate agents digest for v1.91.5.0

* ci: serial flaky retry and flake ledger for the Windows free lane

* ci: cancel superseded eval runs; relax LLM-judge clarity bar to 3
This commit is contained in:
Garry Tan authored and GitHub committed 2026-09-28 16:00:12 -07:00
1 parent d2a0bbcf4c
commit 080a655791
30 files changed
+2474 -1784

No files matched your search

+4 -3
View File
@@ -275,9 +275,10 @@ jobs:
report: report:
runs-on: ubicloud-standard-2 runs-on: ubicloud-standard-2
needs: [plan-slices, eval-slices, gate-census] needs: [plan-slices, eval-slices, gate-census]
# always(): the report must run (and FAIL) when an executor died — a # !cancelled(): the report must run (and FAIL) when an executor died — a
# missing slice artifact reading as green is the class this lane kills. # missing slice artifact reading as green is the class this lane kills —
if: always() && needs.plan-slices.result == 'success' # but a cancelled run stops here.
if: ${{ !cancelled() && needs.plan-slices.result == 'success' }}
timeout-minutes: 10 timeout-minutes: 10
permissions: permissions:
contents: read contents: read
+9 -5
View File
@@ -178,7 +178,10 @@ jobs:
eval-slices: eval-slices:
runs-on: ubicloud-standard-8 runs-on: ubicloud-standard-8
needs: [build-image, plan-slices] needs: [build-image, plan-slices]
if: always() && needs.build-image.result == 'success' && needs.plan-slices.result == 'success' # !cancelled(), not always(): still runs when build-image was skipped
# (image already published), but a newer push's cancel-in-progress stops
# it instead of letting a superseded run finish its paid slices first.
if: ${{ !cancelled() && needs.build-image.result == 'success' && needs.plan-slices.result == 'success' }}
# Aggregate spawn-concurrency budget: 6 slices x EVALS_JOBS=2 x # Aggregate spawn-concurrency budget: 6 slices x EVALS_JOBS=2 x
# EVALS_CONCURRENCY=2 = 24 concurrent tests lane-wide (the old matrix's # EVALS_CONCURRENCY=2 = 24 concurrent tests lane-wide (the old matrix's
# 40-way per row queued claude session STARTUP behind 39 siblings and ate # 40-way per row queued claude session STARTUP behind 39 siblings and ate
@@ -314,9 +317,10 @@ jobs:
slices-report: slices-report:
runs-on: ubicloud-standard-2 runs-on: ubicloud-standard-2
needs: [plan-slices, eval-slices] needs: [plan-slices, eval-slices]
# always(): the report must run (and FAIL) when an executor died — a # !cancelled(): the report must run (and FAIL) when an executor died — a
# missing slice artifact reading as green is the class this lane kills. # missing slice artifact reading as green is the class this lane kills —
if: always() && needs.plan-slices.result == 'success' # but a run superseded by a newer push stops here.
if: ${{ !cancelled() && needs.plan-slices.result == 'success' }}
timeout-minutes: 5 timeout-minutes: 5
# contents:read ONLY — this job executes the PR-authored reconcile # contents:read ONLY — this job executes the PR-authored reconcile
# runner from the PR checkout, so it # runner from the PR checkout, so it
@@ -382,7 +386,7 @@ jobs:
slices-comment: slices-comment:
runs-on: ubicloud-standard-2 runs-on: ubicloud-standard-2
needs: slices-report needs: slices-report
if: always() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && needs.slices-report.result != 'skipped' if: ${{ !cancelled() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && needs.slices-report.result != 'skipped' }}
timeout-minutes: 5 timeout-minutes: 5
permissions: permissions:
pull-requests: write pull-requests: write
+3
View File
@@ -93,3 +93,6 @@ jobs:
scripts/build-app.sh scripts/build-app.sh
scripts/write-version-files.sh scripts/write-version-files.sh
browse/scripts/build-node-server.sh browse/scripts/build-node-server.sh
scripts/ubicloud/ubi-runner.sh
scripts/ubicloud/setup-free-suite.sh
scripts/ubicloud/test-free.sh
+16
View File
@@ -146,6 +146,13 @@ jobs:
# exclusions — don't resurrect a hand list in this file. # exclusions — don't resurrect a hand list in this file.
env: env:
GSTACK_FREE_JOBS: '2' GSTACK_FREE_JOBS: '2'
# Same policy as the required Linux lane: process-supervision timing
# tests pass alone on this 4-vCPU runner but can stall under the
# two-shard load, so attributed failures get one serial retry
# (cap 5 files, truncation veto). Every flaky pass is appended to
# the ledger uploaded below, so repeats stay visible and ranked.
GSTACK_FREE_RETRY_FLAKY: '1'
GSTACK_FLAKE_LEDGER: ${{ runner.temp }}/flake-ledger.jsonl
# Point os.tmpdir() at the runner temp so the shard logs land # Point os.tmpdir() at the runner temp so the shard logs land
# somewhere the artifact step below can glob. # somewhere the artifact step below can glob.
TEMP: ${{ runner.temp }} TEMP: ${{ runner.temp }}
@@ -176,6 +183,15 @@ jobs:
path: ${{ runner.temp }}/gstack-free-test-*.log path: ${{ runner.temp }}/gstack-free-test-*.log
if-no-files-found: ignore if-no-files-found: ignore
- name: Upload flake ledger
if: always()
uses: actions/upload-artifact@v7
with:
name: flake-ledger-windows
path: ${{ runner.temp }}/flake-ledger.jsonl
if-no-files-found: ignore
retention-days: 90
cookie-native-qualification: cookie-native-qualification:
if: github.event_name == 'workflow_dispatch' && !inputs.dia_native_only && !inputs.native_diagnostics_only && !inputs.dia_launch_comparison && !inputs.dia_gui_readiness if: github.event_name == 'workflow_dispatch' && !inputs.dia_native_only && !inputs.native_diagnostics_only && !inputs.dia_launch_comparison && !inputs.dia_gui_readiness
runs-on: windows-latest runs-on: windows-latest
+1
View File
@@ -236,6 +236,7 @@ When fixing failures or preparing `/ship`, follow this order:
bun install # install dependencies bun install # install dependencies
bun run test:quick # fast measured free subset for edit feedback (not acceptance) bun run test:quick # fast measured free subset for edit feedback (not acceptance)
bun run test # complete free suite via the strict shard runner (no API spend) bun run test # complete free suite via the strict shard runner (no API spend)
bun run test:ubicloud # same suite on an ephemeral 16-vCPU Ubicloud VM (needs UBICLOUD_API_KEY)
bun run eval:bg:pr # changed fast live probes + selected judges, with explicit deferrals bun run eval:bg:pr # changed fast live probes + selected judges, with explicit deferrals
bun run eval:bg:release # fresh complete gate + periodic live coverage bun run eval:bg:release # fresh complete gate + periodic live coverage
bun run test:windows # curated Windows-safe subset (runs on windows-latest) bun run test:windows # curated Windows-safe subset (runs on windows-latest)
+18
View File
@@ -1,5 +1,23 @@
# Changelog # Changelog
## [1.91.5.0] - 2026-09-28
The free suite now finishes in about half the time on a 16-core Linux machine, `bun run test:ubicloud` runs it on a fresh 16-vCPU Ubicloud VM from any dev box, container, or cloud sandbox, and re-pushing a PR no longer waits behind the previous commit's eval run.
### Added
- `bun run test:ubicloud [test:free args]` runs the complete free suite on an ephemeral Ubicloud VM (`UBICLOUD_API_KEY` required; `UBI_SIZE` and `UBI_LOCATION` choose the machine). The VM gets the required CI lane's environment and strictness settings, uncommitted edits are included, shard logs are copied to `.context/ubicloud/`, and the VM is always destroyed afterwards. A full run takes about four and a half minutes end to end, compared with seven and a half minutes of suite time alone on a 4-vCPU machine.
- `bun run test:ubicloud --record-durations` refreshes `scripts/free-test-durations.json` in that same environment and copies it back.
- `scripts/ubicloud/ubi-runner.sh` runs any command on a Ubicloud VM (`run`, or `up` / `ssh` / `sync` / `pull` / `down`). It needs only bash, curl, python3, ssh, and tar locally, and it removes its own stale VMs after 12 hours.
### Changed
- On Linux, `bun run test` now starts one shard per available CPU, up to 16; macOS and Windows keep the limit of six. On a 16-vCPU VM, 16 shards finished the suite in 137 seconds and six shards took 327 seconds.
- The duration seed is re-recorded with browser, display, and CSO tests actually running, and the slowest test file is split into four files. On a 16-vCPU run, all 16 shards now finish within about 15 seconds of each other, where one shard used to take twice as long as the rest. The 20 CI shards are packed with the same seed.
- Full-suite and CI planning runs name any test file missing from the duration seed, so a slow new file cannot quietly become the long pole.
- The Windows CI lane gets the same single serial retry for attributed failures as the required Linux lane, and uploads its flaky passes as a `flake-ledger-windows` artifact. Its process-supervision timing tests pass when run alone but can stall under the lane's two-shard load.
- A new push to a pull request now cancels the previous commit's paid eval run. The eval slice, report, and comment jobs run unless the workflow is cancelled (`!cancelled()`) instead of unconditionally (`always()`), so they still run when the image build is skipped or a slice fails, but a superseded run no longer finishes (and bills) its slices while the new commit's run waits behind it. A workflow test fails if any eval job goes back to a job-level `always()`.
- LLM-judge skill quality evals require a clarity score of 3 instead of 4; completeness and actionability bars are unchanged. The cookie setup judge keeps its manually approved thresholds.
- Two timing-sensitive tests allow for a heavily loaded machine: the terminal-agent startup race waits up to 8 seconds for a cold agent start, and the invalid Retry-After cases accept timer delays below the next 4-second backoff step.
## [1.91.4.0] - 2026-09-28 ## [1.91.4.0] - 2026-09-28
Windows users can copy signed-in cookies from Opera and Opera GX into gstack's browser. On Windows, where Chrome, Edge and Brave increasingly store App-Bound Encryption cookies that gstack cannot decrypt, Opera and Opera GX still use DPAPI-protected cookies, so they may be the browsers where import keeps working. Windows users can copy signed-in cookies from Opera and Opera GX into gstack's browser. On Windows, where Chrome, Edge and Brave increasingly store App-Bound Encryption cookies that gstack cannot decrypt, Opera and Opera GX still use DPAPI-protected cookies, so they may be the browsers where import keeps working.
+1
View File
@@ -6,6 +6,7 @@
bun install # install dependencies bun install # install dependencies
bun run test:quick # measured fast deterministic subset for edit feedback bun run test:quick # measured fast deterministic subset for edit feedback
bun run test # complete free suite via the strict parallel runner bun run test # complete free suite via the strict parallel runner
bun run test:ubicloud # complete free suite on an ephemeral 16-vCPU Ubicloud VM (needs UBICLOUD_API_KEY)
bun run test:pr # changed fast live probes + selected judges (CI PR default) bun run test:pr # changed fast live probes + selected judges (CI PR default)
bun run test:evals # run paid evals: LLM judge + E2E (diff-based, ~$4.35/run max) bun run test:evals # run paid evals: LLM judge + E2E (diff-based, ~$4.35/run max)
bun run test:evals:all # run ALL paid evals regardless of diff bun run test:evals:all # run ALL paid evals regardless of diff
+10 -4
View File
@@ -182,6 +182,7 @@ Bun auto-loads `.env` — no extra config. Conductor workspaces inherit `.env` f
bun run test:quick # Measured fast free subset for ordinary edits; not full acceptance bun run test:quick # Measured fast free subset for ordinary edits; not full acceptance
bun run eval:bg:pr # Changed fast live probes + selected quality judges, detached bun run eval:bg:pr # Changed fast live probes + selected quality judges, detached
bun run test # Final full free acceptance after focused repairs and source freeze bun run test # Final full free acceptance after focused repairs and source freeze
bun run test:ubicloud # Same suite on an ephemeral 16-vCPU Ubicloud VM; needs UBICLOUD_API_KEY
bun run test:e2e # Tier 2: E2E only (needs EVALS=1, can't run inside Claude Code) bun run test:e2e # Tier 2: E2E only (needs EVALS=1, can't run inside Claude Code)
bun run test:evals # Tier 2 + 3 combined (~$4.35/run) bun run test:evals # Tier 2 + 3 combined (~$4.35/run)
``` ```
@@ -208,11 +209,15 @@ unknown-input results cannot be reused.
Timing goals are under one minute for edit feedback, 3–5 minutes for typical PR Timing goals are under one minute for edit feedback, 3–5 minutes for typical PR
checks, and 60–90 seconds for complete free test execution across isolated CI checks, and 60–90 seconds for complete free test execution across isolated CI
machines. They are targets, not timeout reductions or guarantees. The complete machines. They are targets, not timeout reductions or guarantees. The complete
local suite uses available CPU affinity, up to six workers; use `test:quick` for local suite uses available CPU affinity, up to 16 workers on Linux and six on
the shorter edit loop. The historical six-worker result below and the macOS and Windows; use `test:quick` for the shorter edit loop. On a small dev
box, container, or cloud sandbox, `bun run test:ubicloud` runs the complete suite
on a fresh 16-vCPU Ubicloud VM with the CI lane's environment instead (about
four and a half minutes end to end, including VM boot and setup). The
historical six-worker result below and the
[four-CPU portfolio comparison](docs/TEST_PORTFOLIO.md#measurement-contract) [four-CPU portfolio comparison](docs/TEST_PORTFOLIO.md#measurement-contract)
are machine-specific measurements. CI setup, build and queue time are reported are machine-specific measurements. CI setup, build and queue time are reported
separately. Refresh measurements with `bun run test:free --record-durations`; separately. Refresh measurements with `bun run test:ubicloud --record-durations`;
the required free CI lane packs the complete inventory across isolated runners, the required free CI lane packs the complete inventory across isolated runners,
then checks every shard's receipt before reporting success. Local worker counts then checks every shard's receipt before reporting success. Local worker counts
remain bounded to avoid browser/process contention. remain bounded to avoid browser/process contention.
@@ -254,7 +259,8 @@ flakes; the required CI free lane turns it on and uploads every flaky pass
in a JSONL ledger artifact that `bun run eval:flake-rank` folds in). in a JSONL ledger artifact that `bun run eval:flake-rank` folds in).
Working in a cloud sandbox? Run `scripts/sandbox-doctor.sh` once per boot to Working in a cloud sandbox? Run `scripts/sandbox-doctor.sh` once per boot to
make the suite run green (details in make the suite run green (details in
[docs/TESTING_INTERNALS.md](docs/TESTING_INTERNALS.md)). [docs/TESTING_INTERNALS.md](docs/TESTING_INTERNALS.md)), or skip the sandbox's
limits entirely with `bun run test:ubicloud`.
Don't type bare `bun test` for the suite: it walks the whole repo, loads paid Don't type bare `bun test` for the suite: it walks the whole repo, loads paid
eval files, and misses the strict classifier. No API keys needed. eval files, and misses the strict classifier. No API keys needed.
+1 -1
View File
@@ -1 +1 @@
1.91.4.0 1.91.5.0
+1 -1
View File
@@ -1,4 +1,4 @@
# gstack digest v1.91.4.0 — regenerate/re-copy after upgrading gstack # gstack digest v1.91.5.0 — regenerate/re-copy after upgrading gstack
Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed
for agent hosts without a full skill install. The full skills add workflows, for agent hosts without a full skill install. The full skills add workflows,
+7 -4
View File
@@ -311,15 +311,18 @@ describe('terminal-agent owned lifecycle regression', () => {
try { try {
writeAgentRecord(stateDir, { pid: old.pid, gen: 'synthetic-old-generation', startedAt: Date.now(), writeAgentRecord(stateDir, { pid: old.pid, gen: 'synthetic-old-generation', startedAt: Date.now(),
startTime: readAgentStartTime(old.pid), ownerPid: process.pid, ownerStartTime }); startTime: readAgentStartTime(old.pid), ownerPid: process.pid, ownerStartTime });
expect(await waitFor(() => fs.existsSync(`${barrier}.ready`))).toBe(true); // Startup waits cover a cold `bun run` of the agent; under a fully
// loaded 16-shard run that alone can exceed 3s. They return as soon as
// the file appears, so the ordering contract below is unchanged.
expect(await waitFor(() => fs.existsSync(`${barrier}.ready`), 8000)).toBe(true);
winner = rawAgent('synthetic-new-generation', false); winner = rawAgent('synthetic-new-generation', false);
writeAgentRecord(stateDir, { pid: winner.pid, gen: 'synthetic-new-generation', startedAt: Date.now(), writeAgentRecord(stateDir, { pid: winner.pid, gen: 'synthetic-new-generation', startedAt: Date.now(),
startTime: readAgentStartTime(winner.pid), ownerPid: process.pid, ownerStartTime }); startTime: readAgentStartTime(winner.pid), ownerPid: process.pid, ownerStartTime });
expect(await waitFor(() => fs.existsSync(path.join(stateDir, 'terminal-port')))).toBe(true); expect(await waitFor(() => fs.existsSync(path.join(stateDir, 'terminal-port')), 8000)).toBe(true);
const port = fs.readFileSync(path.join(stateDir, 'terminal-port'), 'utf8'); const port = fs.readFileSync(path.join(stateDir, 'terminal-port'), 'utf8');
const token = fs.readFileSync(path.join(stateDir, 'terminal-internal-token'), 'utf8'); const token = fs.readFileSync(path.join(stateDir, 'terminal-internal-token'), 'utf8');
fs.writeFileSync(barrier, 'continue'); fs.writeFileSync(barrier, 'continue');
expect(await Promise.race([old.exited.then(() => true), Bun.sleep(3000).then(() => false)])).toBe(true); expect(await Promise.race([old.exited.then(() => true), Bun.sleep(8000).then(() => false)])).toBe(true);
expect(readAgentRecord(stateDir)?.gen).toBe('synthetic-new-generation'); expect(readAgentRecord(stateDir)?.gen).toBe('synthetic-new-generation');
expect(fs.readFileSync(path.join(stateDir, 'terminal-port'), 'utf8')).toBe(port); expect(fs.readFileSync(path.join(stateDir, 'terminal-port'), 'utf8')).toBe(port);
expect(fs.readFileSync(path.join(stateDir, 'terminal-internal-token'), 'utf8')).toBe(token); expect(fs.readFileSync(path.join(stateDir, 'terminal-internal-token'), 'utf8')).toBe(token);
@@ -330,7 +333,7 @@ describe('terminal-agent owned lifecycle regression', () => {
await old.exited; await old.exited;
if (winner) await winner.exited; if (winner) await winner.exited;
} }
}, 10000); }, 30000);
test('daemon respawns after agent crash, then exits without deleting a successor state', async () => { test('daemon respawns after agent crash, then exits without deleting a successor state', async () => {
const stateDir = dir(); const stateDir = dir();
+5 -3
View File
@@ -108,9 +108,11 @@ describe("generateVariant Retry-After handling", () => {
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(calls.length).toBe(2); expect(calls.length).toBe(2);
const gap = calls[1].ts - calls[0].ts; const gap = calls[1].ts - calls[0].ts;
// Falls through to existing 2s exponential leading delay // Falls through to existing 2s exponential leading delay. The ceiling
// only has to reject the next backoff step (4s) or a double wait; timers
// on a loaded scheduler fire over a second late.
expect(gap).toBeGreaterThanOrEqual(1800); expect(gap).toBeGreaterThanOrEqual(1800);
expect(gap).toBeLessThan(3000); expect(gap).toBeLessThan(3900);
}); });
test("no Retry-After header: falls through to exponential", async () => { test("no Retry-After header: falls through to exponential", async () => {
@@ -125,7 +127,7 @@ describe("generateVariant Retry-After handling", () => {
expect(calls.length).toBe(2); expect(calls.length).toBe(2);
const gap = calls[1].ts - calls[0].ts; const gap = calls[1].ts - calls[0].ts;
expect(gap).toBeGreaterThanOrEqual(1800); expect(gap).toBeGreaterThanOrEqual(1800);
expect(gap).toBeLessThan(3000); expect(gap).toBeLessThan(3900);
}); });
test("Retry-After: 0 retries immediately, skips leading exponential", async () => { test("Retry-After: 0 retries immediately, skips leading exponential", async () => {
+48 -9
View File
@@ -161,14 +161,20 @@ load-sensitive on a busy dev box, runs only in CI or on explicit opt-in
**Free suite (`bun run test:free`).** `scripts/test-free-shards.ts` runs N **Free suite (`bun run test:free`).** `scripts/test-free-shards.ts` runs N
concurrent shard processes (serial within each) with strict-output concurrent shard processes (serial within each) with strict-output
classification per shard. Local defaults use the available CPU affinity, classification per shard. Local defaults use the available CPU affinity
floored at one and capped at six; `GSTACK_FREE_JOBS` remains an explicit override. (which Bun also bounds by a container's cgroup CPU quota), floored at one and
capped at 16 on Linux and six on macOS and Windows; `GSTACK_FREE_JOBS` remains
an explicit override.
This does not change the separate CI machine count. Full-suite shards are packed by RECORDED PER-FILE This does not change the separate CI machine count. Full-suite shards are packed by RECORDED PER-FILE
DURATIONS (LPT, `packShardsByDuration`) when the committed seed DURATIONS (LPT, `packShardsByDuration`) when the committed seed
`scripts/free-test-durations.json` exists — refresh it occasionally with `scripts/free-test-durations.json` exists — refresh it with
`bun run test:free --record-durations` (each file timed in its own child; `bun run test:ubicloud --record-durations`, which times each file in its own
CI never records). Missing seed → silent hash-shard fallback; corrupt seed → child on a VM with the CI lane's environment and copies the seed back (CI never
one warning + fallback; unknown files get 75th-percentile pessimism. Packed records; a seed recorded where browser or display tests skip underestimates
them). Missing seed → silent hash-shard fallback; corrupt seed → one warning +
fallback; unknown files get 75th-percentile pessimism, and both full-suite and
`--ci-plan` runs name them on stderr so a new slow file cannot silently become
the long pole. Packed
shards get duration-aware walls (`max(base, predicted × 3, files × 5s)`). The shards get duration-aware walls (`max(base, predicted × 3, files × 5s)`). The
legacy `--shards N --shard i` path keeps stable hash indices. Required CI uses legacy `--shards N --shard i` path keeps stable hash indices. Required CI uses
one duration-packed `--ci-plan`, 20 isolated `--ci-run` machines, and a one duration-packed `--ci-plan`, 20 isolated `--ci-run` machines, and a
@@ -256,7 +262,7 @@ serially. Plans and receipts bind the source revision, complete inventory and
strict outcomes; missing, duplicate or mismatched receipts fail the required strict outcomes; missing, duplicate or mismatched receipts fail the required
aggregate. The existing maximum five-file flaky retry allowance applies across aggregate. The existing maximum five-file flaky retry allowance applies across
the entire lane, not separately to every machine. Refresh the full timing list the entire lane, not separately to every machine. Refresh the full timing list
with `bun run test:free --record-durations`. Profiling records failures faithfully with `bun run test:ubicloud --record-durations`. Profiling records failures faithfully
and is separate from final release acceptance. and is separate from final release acceptance.
**CI planner/executor/report.** `--emit-plan <path> --slices K` computes **CI planner/executor/report.** `--emit-plan <path> --slices K` computes
@@ -388,6 +394,39 @@ against a temp `GSTACK_INSTALL_DIR` / `GSTACK_SKILLS_DIR`, and
`freeze/bin/check-freeze.sh` with JSON payloads on stdin (including the `freeze/bin/check-freeze.sh` with JSON payloads on stdin (including the
`GSTACK_HOME` state-root parity against `bin/gstack-paths`). `GSTACK_HOME` state-root parity against `bin/gstack-paths`).
## Ubicloud VMs (`bun run test:ubicloud`)
`bun run test:ubicloud [test:free args]` runs the free suite on an ephemeral
Ubicloud VM instead of the local machine. It is the fast path from small dev
boxes, containers, and cloud sandboxes, and the way to record the duration seed
in the CI lane's environment. It needs `UBICLOUD_API_KEY` (a project token from
the Ubicloud console) plus `bash`, `curl`, `python3`, `ssh`, `ssh-keygen`, and
`tar` locally.
`scripts/ubicloud/ubi-runner.sh` creates the VM (`UBI_SIZE`, default
`standard-16`; `UBI_LOCATION`, default `eu-central-h1`) and streams the
checkout to it: tracked files, untracked files that are not ignored, and
`.git`, so uncommitted edits are tested. It then runs
`scripts/ubicloud/setup-free-suite.sh`, which mirrors the `free-suite` CI job
(same Bun pin, Playwright Chromium with its setuid sandbox helper, Xvfb,
poppler, emoji fonts, generated host outputs, gate binaries, and the CSO
helper), and runs `xvfb-run -a bun run test:free` with `GSTACK_EXPECT_BINARIES=1`
and `GSTACK_FREE_RETRY_FLAKY=1`. Shard logs are copied to
`.context/ubicloud/<timestamp>/`, and the VM is destroyed on every exit path.
The exit status is the suite's.
A stock Ubuntu 24.04 VM differs from a GitHub-hosted runner in three ways that
the scripts correct: the login umask is `002` (group-writable directories fail
the CSO private-state checks), AppArmor blocks the unprivileged user
namespaces Chromium's sandbox needs, and `clang` and `python3-venv` are absent
(required by the Dia readiness and Python runner tests).
VMs are named `ubirun-<epoch>-<hex>`. Every new VM first destroys `ubirun-*`
VMs older than `UBI_GC_HOURS` (default 12), so an interrupted client cannot
leak one for long. For other commands, use the runner directly:
`scripts/ubicloud/ubi-runner.sh run --setup <script> -- '<command>'`, or its
`up` / `ssh` / `sync` / `pull` / `down` steps (`--help` lists them).
## Cloud sandboxes (Vercel / Conductor cloud workspaces) ## Cloud sandboxes (Vercel / Conductor cloud workspaces)
Syscall-supervised sandboxes need environment setup before `bun run test` can Syscall-supervised sandboxes need environment setup before `bun run test` can
@@ -408,7 +447,7 @@ Two runner knobs exist for these environments (both no-ops unless set):
serial mega-shard and 6-way sharding both saturate the per-process syscall serial mega-shard and 6-way sharding both saturate the per-process syscall
supervisor), and `GSTACK_FREE_RETRY_FLAKY=1` re-runs attributed failures once supervisor), and `GSTACK_FREE_RETRY_FLAKY=1` re-runs attributed failures once
serially, downgrading a clean retry to a loud FLAKY-PASS (capped at 5 files so serially, downgrading a clean retry to a loud FLAKY-PASS (capped at 5 files so
a broken tree can't masquerade as flaky). The required CI free lane sets the a broken tree can't masquerade as flaky). The required CI free lane and the
retry knob too, appending every flaky pass to the JSONL ledger it uploads Windows lane set the retry knob too, appending every flaky pass to the JSONL ledger it uploads
(`GSTACK_FLAKE_LEDGER`) — a flaky pass never reds the lane, but it never (`GSTACK_FLAKE_LEDGER`) — a flaky pass never reds the lane, but it never
disappears either. disappears either.
+2 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "gstack", "name": "gstack",
"version": "1.91.4", "version": "1.91.5",
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",
@@ -26,6 +26,7 @@
"test": "bun run scripts/test-free-shards.ts", "test": "bun run scripts/test-free-shards.ts",
"test:free": "bun run scripts/test-free-shards.ts", "test:free": "bun run scripts/test-free-shards.ts",
"test:windows": "bun run scripts/test-free-shards.ts --windows-only", "test:windows": "bun run scripts/test-free-shards.ts --windows-only",
"test:ubicloud": "bash scripts/ubicloud/test-free.sh",
"test:evals": "EVALS=1 bun test --retry 1 --concurrent --max-concurrency ${EVALS_CONCURRENCY:-15} test/skill-llm-eval*.test.ts test/skill-e2e-*.test.ts test/skill-routing-e2e.test.ts test/codex-e2e*.test.ts test/gemini-e2e.test.ts test/llm-judge-recommendation.test.ts test/carve-section-loading*.test.ts", "test:evals": "EVALS=1 bun test --retry 1 --concurrent --max-concurrency ${EVALS_CONCURRENCY:-15} test/skill-llm-eval*.test.ts test/skill-e2e-*.test.ts test/skill-routing-e2e.test.ts test/codex-e2e*.test.ts test/gemini-e2e.test.ts test/llm-judge-recommendation.test.ts test/carve-section-loading*.test.ts",
"test:evals:all": "EVALS=1 EVALS_ALL=1 bun test --retry 1 --concurrent --max-concurrency ${EVALS_CONCURRENCY:-15} test/skill-llm-eval*.test.ts test/skill-e2e-*.test.ts test/skill-routing-e2e.test.ts test/codex-e2e*.test.ts test/gemini-e2e.test.ts test/llm-judge-recommendation.test.ts test/carve-section-loading*.test.ts", "test:evals:all": "EVALS=1 EVALS_ALL=1 bun test --retry 1 --concurrent --max-concurrency ${EVALS_CONCURRENCY:-15} test/skill-llm-eval*.test.ts test/skill-e2e-*.test.ts test/skill-routing-e2e.test.ts test/codex-e2e*.test.ts test/gemini-e2e.test.ts test/llm-judge-recommendation.test.ts test/carve-section-loading*.test.ts",
"test:e2e": "EVALS=1 bun test --retry 1 --concurrent --max-concurrency ${EVALS_CONCURRENCY:-15} test/skill-e2e-*.test.ts test/skill-routing-e2e.test.ts test/codex-e2e*.test.ts test/gemini-e2e.test.ts test/carve-section-loading*.test.ts", "test:e2e": "EVALS=1 bun test --retry 1 --concurrent --max-concurrency ${EVALS_CONCURRENCY:-15} test/skill-e2e-*.test.ts test/skill-routing-e2e.test.ts test/codex-e2e*.test.ts test/gemini-e2e.test.ts test/carve-section-loading*.test.ts",
File diff suppressed because it is too large. Load diff
+39 -11
View File
@@ -426,13 +426,16 @@ export function wallTimeoutForPackedShard(predictedMs: number, baseMs = DEFAULT_
} }
/** /**
* Full-suite parallelism: use all available CPUs, with a floor of one and a * Full-suite parallelism: use all available CPUs, with a floor of one and a
* cap of MAX_FULL_SUITE_JOBS. Shards stay serial internally; separate shard * per-platform cap (maxFullSuiteJobs). Shards stay serial internally; separate
* processes can overlap subprocess and I/O waits without a fixed CPU reserve. * shard processes can overlap subprocess and I/O waits without a fixed CPU
* Prefer availableParallelism() to honor CPU affinity, falling back to cpus() * reserve. Prefer availableParallelism() to honor CPU affinity (Bun also
* on runtimes without it. Keep the existing cap: beyond ~6 concurrent bun * honors a container's cgroup CPU quota there), falling back to cpus() on
* processes, playwright-heavy shards contended on browser launches in the * runtimes without it. macOS and Windows keep the cap of 6: beyond that,
* original M-series measurement. More shards are not a guaranteed speedup; * playwright-heavy shards contended on browser launches in the original
* compare complete-suite runs before raising the default further. * M-series measurement. Linux caps at 16: on a 16-vCPU Ubicloud VM with the
* CI lane's environment (2026-09-28), 16 shards ran the complete suite in
* 137s versus 327s for 6. More shards are not a guaranteed speedup; compare
* complete-suite runs before raising either cap.
* *
* GSTACK_FREE_JOBS overrides the computed count (the free runner's analogue * GSTACK_FREE_JOBS overrides the computed count (the free runner's analogue
* of the paid runner's EVALS_JOBS). Exists for syscall-supervised sandboxes: * of the paid runner's EVALS_JOBS). Exists for syscall-supervised sandboxes:
@@ -442,12 +445,17 @@ export function wallTimeoutForPackedShard(predictedMs: number, baseMs = DEFAULT_
* `git init` probes in fresh mktemp dirs fail with * `git init` probes in fresh mktemp dirs fail with
* "Cannot access work tree: Permission denied" while the suite runs, 0/200 * "Cannot access work tree: Permission denied" while the suite runs, 0/200
* when idle — access(dir, X_OK) = EACCES under strace). Fewer shards keep * when idle — access(dir, X_OK) = EACCES under strace). Fewer shards keep
* the supervisor inside its budget. Not clamped by MAX_FULL_SUITE_JOBS so a * the supervisor inside its budget. Not clamped by maxFullSuiteJobs so a
* beefy box can also raise it deliberately. * beefy box can also raise it deliberately.
*/ */
export const MAX_FULL_SUITE_JOBS = 6; export const MAX_FULL_SUITE_JOBS = 6;
export const MAX_LINUX_FULL_SUITE_JOBS = 16;
export function fullSuiteJobs(): number { export function maxFullSuiteJobs(platform: NodeJS.Platform = process.platform): number {
return platform === 'linux' ? MAX_LINUX_FULL_SUITE_JOBS : MAX_FULL_SUITE_JOBS;
}
export function fullSuiteJobs(platform: NodeJS.Platform = process.platform): number {
const raw = process.env.GSTACK_FREE_JOBS; const raw = process.env.GSTACK_FREE_JOBS;
if (raw !== undefined && raw !== '') { if (raw !== undefined && raw !== '') {
// Strict digits-only: parseInt would silently truncate "2abc" -> 2 and // Strict digits-only: parseInt would silently truncate "2abc" -> 2 and
@@ -458,7 +466,7 @@ export function fullSuiteJobs(): number {
return Number.parseInt(raw, 10); return Number.parseInt(raw, 10);
} }
const availableCpus = os.availableParallelism?.() ?? os.cpus().length; const availableCpus = os.availableParallelism?.() ?? os.cpus().length;
return Math.max(1, Math.min(MAX_FULL_SUITE_JOBS, availableCpus)); return Math.max(1, Math.min(maxFullSuiteJobs(platform), availableCpus));
} }
/** /**
@@ -695,6 +703,23 @@ export function packShardsByDuration(
return { shards: shards.map((s) => s.sort()), predictedMs: loads }; return { shards: shards.map((s) => s.sort()), predictedMs: loads };
} }
/**
* Files missing from the duration seed are packed at the 75th percentile, so
* one slow new file can become the whole run's long pole without anyone
* noticing. Name them (on stderr: --ci-plan's stdout is the CI matrix).
*/
export function unseededFreeFiles(files: string[], durations: Record<string, number>): string[] {
return files.filter((f) => durations[normalizeRelativePath(f)] === undefined);
}
function warnUnseededFreeFiles(files: string[], durations: Record<string, number>): void {
const unseeded = unseededFreeFiles(files, durations);
if (unseeded.length === 0) return;
const shown = unseeded.slice(0, 5).join(', ') + (unseeded.length > 5 ? `, +${unseeded.length - 5} more` : '');
console.error(`[test:free] ${unseeded.length} file(s) have no recorded duration and are packed at the 75th-percentile estimate: ${shown}.`
+ ' Refresh scripts/free-test-durations.json with `bun run test:ubicloud --record-durations`.');
}
export interface FreeCiPlan { export interface FreeCiPlan {
version: 1; version: 1;
revision: string; revision: string;
@@ -1749,7 +1774,9 @@ async function main(): Promise<number> {
fs.renameSync(temporary, file); fs.renameSync(temporary, file);
}; };
if (options.ciPlan) { if (options.ciPlan) {
const plan = createFreeCiPlan(allFiles, options.shardCount, loadFreeTestDurations() ?? {}, revision); const durations = loadFreeTestDurations() ?? {};
warnUnseededFreeFiles(allFiles, durations);
const plan = createFreeCiPlan(allFiles, options.shardCount, durations, revision);
validateFreeCiPlan(plan, allFiles, revision); validateFreeCiPlan(plan, allFiles, revision);
writeJson(options.ciPlan, plan); writeJson(options.ciPlan, plan);
console.log(JSON.stringify({ shard: plan.shards.map(shard => shard.shard) })); console.log(JSON.stringify({ shard: plan.shards.map(shard => shard.shard) }));
@@ -1852,6 +1879,7 @@ async function main(): Promise<number> {
const mutators = files.filter((f) => f in TREE_MUTATING); const mutators = files.filter((f) => f in TREE_MUTATING);
const readers = files.filter((f) => !(f in TREE_MUTATING)); const readers = files.filter((f) => !(f in TREE_MUTATING));
const durations = loadFreeTestDurations(); const durations = loadFreeTestDurations();
if (durations) warnUnseededFreeFiles(files, durations);
const packed = durations ? packShardsByDuration(readers, jobs, durations) : null; const packed = durations ? packShardsByDuration(readers, jobs, durations) : null;
const shards = packed ? packed.shards : assignFilesToShards(readers, jobs); const shards = packed ? packed.shards : assignFilesToShards(readers, jobs);
const totalShards = jobs + (mutators.length > 0 ? 1 : 0); const totalShards = jobs + (mutators.length > 0 ? 1 : 0);
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env bash
# Bootstrap a fresh ubuntu-noble Ubicloud VM to run the free suite exactly as
# the required CI lane does (.github/workflows/free-tests.yml, free-suite job).
# Runs as user `ubi` (passwordless sudo) from the synced checkout.
set -euo pipefail
BUN_VERSION=1.4.0
NODE_VERSION=22.20.0
export DEBIAN_FRONTEND=noninteractive
sudo -E apt-get update -qq
sudo -E apt-get install -y -qq --no-install-recommends \
git curl unzip xz-utils ca-certificates build-essential clang python3-venv jq \
xvfb x11-utils poppler-utils fonts-noto-color-emoji >/dev/null
# Ubuntu 24.04 blocks unprivileged user namespaces, which Chromium's sandbox
# needs; GitHub-hosted runners ship with this relaxed.
sudo sysctl -qw kernel.apparmor_restrict_unprivileged_userns=0
if ! command -v node >/dev/null; then
curl -fsSL "https://nodejs.org/dist/v$NODE_VERSION/node-v$NODE_VERSION-linux-x64.tar.xz" \
| sudo tar -xJ -C /usr/local --strip-components=1
fi
if [ ! -x "$HOME/.bun/bin/bun" ]; then
curl -fsSL https://bun.sh/install | bash -s "bun-v$BUN_VERSION" >/dev/null
fi
grep -q '.bun/bin' "$HOME/.profile" || echo 'export PATH="$HOME/.bun/bin:$PATH"' >>"$HOME/.profile"
export PATH="$HOME/.bun/bin:$PATH"
git config --global user.email "free-tests@gstack.test"
git config --global user.name "Free Tests"
git config --global init.defaultBranch main
git config --global --add safe.directory '*'
bun install --frozen-lockfile
bunx playwright install --with-deps chromium
chrome=$(bun -e 'import { chromium } from "playwright"; import { realpathSync } from "node:fs"; console.log(realpathSync(chromium.executablePath()))')
sudo install -T -o root -g root -m 4755 "${chrome%/*}/chrome_sandbox" "${chrome%/*}/chrome-sandbox"
{
bun run gen:skill-docs --host all
bun run vendor:xterm
bash browse/scripts/build-node-server.sh
bun run build:gates
bun run build:cso
} >/dev/null
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env bash
# bun run test:ubicloud [test:free args] — run the free suite on an ephemeral
# Ubicloud VM (standard-16 unless UBI_SIZE says otherwise) with the required CI
# lane's environment. Shard logs land in .context/ubicloud/<timestamp>/.
# With --record-durations, the refreshed scripts/free-test-durations.json is
# copied back into this checkout.
set -euo pipefail
here=$(cd "$(dirname "$0")" && pwd)
root=$(cd "$here/../.." && pwd)
logs="$root/.context/ubicloud/$(date +%Y%m%d-%H%M%S)"
args=(--src "$root" --setup "$here/setup-free-suite.sh"
--env GSTACK_EXPECT_BINARIES=1 --env GSTACK_FREE_RETRY_FLAKY=1
--pull "/tmp/gstack-free-test-*:$logs")
[ -z "${GSTACK_FREE_JOBS:-}" ] || args+=(--env "GSTACK_FREE_JOBS=$GSTACK_FREE_JOBS")
for arg in "$@"; do
[ "$arg" != --record-durations ] || args+=(--pull "work/$(basename "$root")/scripts/free-test-durations.json:$root/scripts")
done
"$here/ubi-runner.sh" run "${args[@]}" -- "xvfb-run -a bun run test:free $*"
+282
View File
@@ -0,0 +1,282 @@
#!/usr/bin/env bash
# ubi-runner — run a command on an ephemeral Ubicloud VM.
#
# UBICLOUD_API_KEY=... scripts/ubicloud/ubi-runner.sh run --setup S.sh -- 'make test'
#
# Creates a VM (standard-16 by default), streams the checkout to it (tracked +
# untracked-unignored files + .git, so uncommitted edits are included), runs an
# optional setup script and the command, copies requested artifacts back, and
# destroys the VM on every exit path. Exits with the command's status.
# Needs only bash, curl, python3, ssh, ssh-keygen, and tar locally, so it works
# from dev boxes, containers, and cloud sandboxes. VMs are named
# ubirun-<epoch>-<hex>; every `up` first destroys ubirun-* VMs older than
# UBI_GC_HOURS (default 12) so an interrupted client cannot leak one for long.
set -euo pipefail
# Keep heredoc bodies on temp files, not the pipe window (test/heredoc-pipe-deadlock.test.ts).
BASH_COMPAT=50
API="${UBICLOUD_API_URL:-https://api.ubicloud.com}"
STATE_ROOT="${UBI_RUNNER_STATE:-${XDG_STATE_HOME:-$HOME/.local/state}/ubi-runner}"
DEFAULT_SIZE="${UBI_SIZE:-standard-16}"
DEFAULT_LOCATION="${UBI_LOCATION:-eu-central-h1}"
GC_HOURS="${UBI_GC_HOURS:-12}"
PREFIX="ubirun"
die() { echo "ubi-runner: $*" >&2; exit 1; }
log() { echo "ubi-runner: $*" >&2; }
for bin in curl python3 ssh ssh-keygen tar; do
command -v "$bin" >/dev/null || die "$bin is required"
done
[ -n "${UBICLOUD_API_KEY:-}" ] || die "UBICLOUD_API_KEY is not set (create a token under your Ubicloud project's Tokens page)"
cli() {
local out code
out=$(mktemp)
code=$(python3 -c 'import json,sys; print(json.dumps({"argv": sys.argv[1:]}))' "$@" \
| curl -sS -o "$out" -w '%{http_code}' -X POST \
-H "Authorization: Bearer $UBICLOUD_API_KEY" \
-H 'Accept: text/plain' -H 'Content-Type: application/json' \
-H 'X-Ubi-Version: 1.0.0' --data @- "$API/cli") || { rm -f "$out"; die "request failed: ubi $*"; }
if [ "$code" != 200 ]; then
cat "$out" >&2
rm -f "$out"
return 1
fi
cat "$out"
rm -f "$out"
}
state_dir() { echo "$STATE_ROOT/$1"; }
load() {
local dir
dir=$(state_dir "$1")
[ -f "$dir/env" ] || die "no local state for VM '$1' (created on another machine?)"
# shellcheck disable=SC1091
. "$dir/env"
KEY="$dir/key"
}
ssh_opts() {
echo -i "$KEY" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
-o LogLevel=ERROR -o ServerAliveInterval=30 -o ServerAliveCountMax=6 -o ConnectTimeout=10
}
# Ubuntu's default umask (002) leaves new directories group-writable, which
# permission-checking code under test rejects; every remote command uses 022.
remote() {
local name=$1; shift
load "$name"
# shellcheck disable=SC2046
ssh $(ssh_opts) "ubi@$IP" "umask 022; $*"
}
cmd_gc() {
local hours=${1:-$GC_HOURS} now loc name ts
[ "$hours" -gt 0 ] || return 0
now=$(date +%s)
cli vm list -N -f location,name | while read -r loc name; do
[[ "$name" =~ ^${PREFIX}-([0-9]{10})- ]] || continue
ts=${BASH_REMATCH[1]}
if [ $(( (now - ts) / 3600 )) -ge "$hours" ]; then
log "destroying stale $loc/$name (older than ${hours}h)"
cli vm "$loc/$name" destroy -f >/dev/null || log "failed to destroy $loc/$name"
rm -rf "$(state_dir "$name")"
fi
done
}
write_state() {
printf 'NAME=%q\nLOCATION=%q\nSIZE=%q\nIP=%q\n' "$1" "$2" "$3" "$4" >"$(state_dir "$1")/env"
}
cmd_up() {
local size=$DEFAULT_SIZE location=$DEFAULT_LOCATION name="" storage="" image=ubuntu-noble
while [ $# -gt 0 ]; do
case $1 in
-s|--size) size=$2; shift 2 ;;
-l|--location) location=$2; shift 2 ;;
-n|--name) name=$2; shift 2 ;;
-S|--storage) storage=$2; shift 2 ;;
-b|--image) image=$2; shift 2 ;;
*) die "up: unknown option $1" ;;
esac
done
[ -n "$name" ] || name="$PREFIX-$(date +%s)-$(head -c4 /dev/urandom | od -An -tx1 | tr -d ' \n')"
cmd_gc "$GC_HOURS" || log "stale-VM cleanup failed; continuing"
local dir
dir=$(state_dir "$name")
mkdir -p "$dir"
chmod 700 "$dir"
ssh-keygen -q -t ed25519 -N '' -C "$name" -f "$dir/key"
local args=(vm "$location/$name" create -s "$size" -b "$image")
[ -z "$storage" ] || args+=(-S "$storage")
args+=("$(cat "$dir/key.pub")")
log "creating $location/$name ($size)"
cli "${args[@]}" >/dev/null || { rm -rf "$dir"; die "create failed"; }
write_state "$name" "$location" "$size" ""
local deadline=$(( $(date +%s) + 600 )) show state ip
while :; do
show=$(cli vm "$location/$name" show 2>/dev/null || true)
state=$(sed -n 's/^state: //p' <<<"$show")
ip=$(sed -n 's/^ip4: //p' <<<"$show")
[ "$state" = running ] && [ -n "$ip" ] && break
[ "$(date +%s)" -lt "$deadline" ] || { cmd_down "$name"; die "VM did not reach running in 10 minutes (last state: ${state:-unknown})"; }
sleep 5
done
write_state "$name" "$location" "$size" "$ip"
load "$name"
deadline=$(( $(date +%s) + 300 ))
# shellcheck disable=SC2046
until ssh $(ssh_opts) "ubi@$IP" true 2>/dev/null; do
[ "$(date +%s)" -lt "$deadline" ] || { cmd_down "$name"; die "SSH did not come up on $IP"; }
sleep 5
done
remote "$name" 'cloud-init status --wait >/dev/null 2>&1 || true'
log "ready: $name ($IP)"
echo "$name"
}
cmd_down() {
local name=$1 dir loc
dir=$(state_dir "$name")
if [ -f "$dir/env" ]; then
load "$name"
loc=$LOCATION
else
loc=$(cli vm list -N -f location,name | awk -v n="$name" '$2==n {print $1}')
[ -n "$loc" ] || die "VM '$name' not found"
fi
cli vm "$loc/$name" destroy -f >/dev/null && log "destroyed $loc/$name"
rm -rf "$dir"
}
cmd_sync() {
local name=$1 src=${2:-.} dest=${3:-}
src=$(cd "$src" && pwd)
[ -n "$dest" ] || dest="work/$(basename "$src")"
local qdest
qdest=$(printf %q "$dest")
if git -C "$src" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
(
cd "$src"
{ git ls-files -z -co --exclude-standard; printf '.git\0'; } \
| while IFS= read -r -d '' f; do
if [ -e "$f" ] || [ -L "$f" ]; then printf '%s\0' "$f"; fi
done \
| tar --null -T - -czf -
) | remote "$name" "mkdir -p $qdest && tar -xzf - -C $qdest"
else
tar -C "$src" -czf - . | remote "$name" "mkdir -p $qdest && tar -xzf - -C $qdest"
fi
log "synced $src -> $name:$dest"
}
# pull NAME REMOTE_GLOB LOCAL_DIR: copy matching remote entries into LOCAL_DIR.
# Relative remote paths start at /home/ubi.
cmd_pull() {
local name=$1 from=$2 to=$3
mkdir -p "$to"
remote "$name" "cd $(printf %q "$(dirname "$from")") && tar -czf - $(basename "$from")" | tar -xzf - -C "$to"
}
cmd_run() {
local up_args=() src=. dest="" setup="" keep=0 pulls=() envs=()
while [ $# -gt 0 ]; do
case $1 in
-s|--size|-l|--location|-n|--name|-S|--storage|-b|--image) up_args+=("$1" "$2"); shift 2 ;;
--src) src=$2; shift 2 ;;
--dest) dest=$2; shift 2 ;;
--setup) setup=$2; shift 2 ;;
--env) envs+=("$2"); shift 2 ;;
--pass) [ -n "${!2+x}" ] || die "--pass $2: not set locally"; envs+=("$2=${!2}"); shift 2 ;;
--pull) pulls+=("$2"); shift 2 ;;
--keep) keep=1; shift ;;
--) shift; break ;;
*) die "run: unknown option $1" ;;
esac
done
[ $# -gt 0 ] || die "run: missing command after --"
[ -z "$setup" ] || [ -f "$setup" ] || die "setup script not found: $setup"
src=$(cd "$src" && pwd)
[ -n "$dest" ] || dest="work/$(basename "$src")"
RUN_VM=$(cmd_up ${up_args[@]+"${up_args[@]}"})
local name=$RUN_VM
if [ "$keep" = 1 ]; then
log "--keep: leaving $name running; destroy with: $0 down $name"
else
trap 'cmd_down "$RUN_VM" || log "WARNING: failed to destroy $RUN_VM; run: $0 down $RUN_VM"' EXIT
trap 'exit 130' INT TERM
fi
local e
for e in ${envs[@]+"${envs[@]}"}; do printf 'export %s=%q\n' "${e%%=*}" "${e#*=}"; done \
| remote "$name" 'umask 077 && cat > ~/.ubirun-env'
cmd_sync "$name" "$src" "$dest"
local qdest
qdest=$(printf %q "$dest")
if [ -n "$setup" ]; then
remote "$name" 'cat > ~/.ubirun-setup.sh' <"$setup"
log "running setup $(basename "$setup")"
remote "$name" "cd $qdest && . ~/.ubirun-env && bash -l ~/.ubirun-setup.sh" || die "setup failed"
fi
local start rc=0
start=$(date +%s)
log "running on $name: $*"
remote "$name" "cd $qdest && . ~/.ubirun-env && bash -lc $(printf %q "$*")" || rc=$?
log "command exited $rc after $(( $(date +%s) - start ))s"
local p
for p in ${pulls[@]+"${pulls[@]}"}; do
cmd_pull "$name" "${p%%:*}" "${p#*:}" || log "pull failed: $p"
done
return "$rc"
}
usage() {
cat <<EOF
usage: ubi-runner.sh <command> [args]
run [up opts] [--src DIR] [--dest PATH] [--setup FILE] [--env K=V]...
[--pass NAME]... [--pull REMOTE_GLOB:LOCAL_DIR]... [--keep] -- COMMAND
up + sync + setup + command + pull + destroy; exits with COMMAND's status
up [-s SIZE] [-l LOCATION] [-n NAME] [-S STORAGE_GIB] [-b IMAGE]
create a VM and wait for SSH; prints its name
ssh NAME [COMMAND] shell or login-shell command on the VM (user ubi, passwordless sudo)
sync NAME [SRC] [DEST] stream a checkout (tracked + untracked-unignored + .git)
pull NAME REMOTE_GLOB LOCAL_DIR
copy matching remote entries into LOCAL_DIR
down NAME destroy the VM
list list all VMs in the project
gc [HOURS] destroy $PREFIX-* VMs older than HOURS (default $GC_HOURS)
cli ARGS... raw Ubicloud CLI passthrough (e.g. cli vm list)
defaults: size=$DEFAULT_SIZE location=$DEFAULT_LOCATION (env UBI_SIZE, UBI_LOCATION)
EOF
}
cmd=${1:-help}
[ $# -eq 0 ] || shift
case $cmd in
run) cmd_run "$@" ;;
up) cmd_up "$@" ;;
ssh) name=$1; shift; load "$name"
# shellcheck disable=SC2046
if [ $# -eq 0 ]; then exec ssh -t $(ssh_opts) "ubi@$IP"; else remote "$name" "bash -lc $(printf %q "$*")"; fi ;;
sync) cmd_sync "$@" ;;
pull) cmd_pull "$@" ;;
down) cmd_down "$@" ;;
list) cli vm list ;;
gc) cmd_gc "$@" ;;
cli) cli "$@" ;;
help|-h|--help) usage ;;
*) usage >&2; exit 2 ;;
esac
+9 -2
View File
@@ -81,12 +81,19 @@ describe('paid CI coordination stays off the eval image', () => {
expect(planner.steps.find(step => step.run?.includes('--emit-plan'))?.run).toContain('bun --no-install run'); expect(planner.steps.find(step => step.run?.includes('--emit-plan'))?.run).toContain('bun --no-install run');
}); });
test(`${name}: cancel-in-progress can stop every job (no job-level always())`, () => {
for (const [id, job] of Object.entries(jobs)) {
const condition = String((job as { if?: unknown }).if ?? '');
expect(`${id}: ${condition}`).not.toMatch(/(^|[^!])always\(\)/);
}
});
test(`${name}: executors still require both prerequisites and consume the image`, () => { test(`${name}: executors still require both prerequisites and consume the image`, () => {
const executor = jobs['eval-slices']; const executor = jobs['eval-slices'];
expect(executor.needs).toEqual(['build-image', 'plan-slices']); expect(executor.needs).toEqual(['build-image', 'plan-slices']);
expect(JSON.stringify(executor.container)).toContain('needs.build-image.outputs.image-tag'); expect(JSON.stringify(executor.container)).toContain('needs.build-image.outputs.image-tag');
if (name === 'evals.yml') { if (name === 'evals.yml') {
expect(executor.if).toBe("always() && needs.build-image.result == 'success' && needs.plan-slices.result == 'success'"); expect(executor.if).toBe("${{ !cancelled() && needs.build-image.result == 'success' && needs.plan-slices.result == 'success' }}");
} else { } else {
expect(executor.if).toBeUndefined(); expect(executor.if).toBeUndefined();
} }
@@ -99,7 +106,7 @@ describe('paid CI coordination stays off the eval image', () => {
expect(report.container).toBeUndefined(); expect(report.container).toBeUndefined();
expect(report.needs).toContain('plan-slices'); expect(report.needs).toContain('plan-slices');
expect(report.needs).toContain('eval-slices'); expect(report.needs).toContain('eval-slices');
expect(report.if).toBe("always() && needs.plan-slices.result == 'success'"); expect(report.if).toBe("${{ !cancelled() && needs.plan-slices.result == 'success' }}");
expect(JSON.stringify(report.steps)).not.toMatch(/restore-deps|bun install/); expect(JSON.stringify(report.steps)).not.toMatch(/restore-deps|bun install/);
expect(report.steps.find(step => step.run?.includes('--report'))?.run).toContain('bun --no-install run'); expect(report.steps.find(step => step.run?.includes('--report'))?.run).toContain('bun --no-install run');
if (name === 'evals.yml') expect(report.permissions).toEqual({ contents: 'read' }); if (name === 'evals.yml') expect(report.permissions).toEqual({ contents: 'read' });
+2
View File
@@ -149,6 +149,8 @@ const SCANNER_EXEMPT: Record<string, string> = {
'served-page JS talking to its own loopback server (same-origin relative fetch)', 'served-page JS talking to its own loopback server (same-origin relative fetch)',
'design/src/compare.ts': 'design/src/compare.ts':
'served-page JS talking to its own loopback server (relative ./api fetch)', 'served-page JS talking to its own loopback server (relative ./api fetch)',
'scripts/ubicloud':
'developer-invoked test infrastructure: Ubicloud API calls (VM create/show/destroy) with the developer\'s own token, the checkout streamed over SSH to that developer\'s own ephemeral VM, and toolchain downloads run on the VM; no installed-gstack user state is sent',
// Skill prose templates: these render agent-executed instructions (the // Skill prose templates: these render agent-executed instructions (the
// agent runs git in the USER\'S repo at the user\'s direction), they are // agent runs git in the USER\'S repo at the user\'s direction), they are
// not gstack binaries. Includes the preamble-generated brain-sync block — // not gstack binaries. Includes the preamble-generated brain-sync block —
+132
View File
@@ -0,0 +1,132 @@
import { afterEach, expect } from 'bun:test';
import { spawnSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import { copyFileSync, cpSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, readlinkSync, realpathSync, rmSync, symlinkSync, utimesSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
export const ROOT = resolve(import.meta.dir, '../..');
export const files = spawnSync('git', ['ls-files', '-z'], { cwd: ROOT, encoding: 'utf8', timeout: 10_000 });
if (files.status !== 0) throw new Error(files.stderr);
export const owned: string[] = [];
export const quote = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`;
export function assertFixtureWrite(file: string) {
const root = owned.find(dir => file === dir || file.startsWith(dir + '/'));
if (!root) throw new Error(`Fixture write outside owned roots: ${file}`);
let existing = file;
while (!existsSync(existing)) {
if (lstatSync(existing, { throwIfNoEntry: false })?.isSymbolicLink()) throw new Error(`Unresolvable fixture link: ${existing}`);
existing = dirname(existing);
}
const target = realpathSync(existing);
const physicalRoot = realpathSync(root);
if (target !== physicalRoot && !target.startsWith(physicalRoot + '/')) throw new Error(`Fixture write escapes physical root: ${file} -> ${target}`);
}
export function fixtureWriteFileSync(...args: Parameters<typeof writeFileSync>) {
assertFixtureWrite(String(args[0]));
return writeFileSync(...args);
}
export function fixtureCopyFileSync(...args: Parameters<typeof copyFileSync>) {
assertFixtureWrite(String(args[1]));
return copyFileSync(...args);
}
export function fixtureMkdirSync(...args: Parameters<typeof mkdirSync>) {
assertFixtureWrite(String(args[0]));
return mkdirSync(...args);
}
export function fixtureUtimesSync(...args: Parameters<typeof utimesSync>) {
assertFixtureWrite(String(args[0]));
return utimesSync(...args);
}
export function tree(dir: string): unknown {
const stat = lstatSync(dir);
if (stat.isSymbolicLink()) return { link: readlinkSync(dir) };
if (stat.isDirectory()) return Object.fromEntries(readdirSync(dir).sort().map(name => [name, tree(join(dir, name))]));
return createHash('sha256').update(readFileSync(dir)).digest('hex');
}
export function fixture(layout: string) {
const dir = mkdtempSync(join(tmpdir(), 'gstack-codex-scope-'));
owned.push(dir);
const home = join(dir, 'home');
const project = join(dir, 'project-a');
const other = join(dir, 'project-b');
const source = layout === 'machine' ? join(home, '.claude/skills/gstack')
: layout === 'ordinary' ? join(project, 'custom-checkout') : join(project, layout, 'skills/gstack');
const commands = join(dir, 'commands');
for (const d of [home, other, commands, source]) fixtureMkdirSync(d, { recursive: true });
for (const rel of [...files.stdout.split('\0').filter(Boolean), 'scripts/external-skill-names.ts', 'scripts/preflight-codex-overlap.ts']) {
if (/^(?:test|docs|browse\/test|\.github)\//.test(rel)) continue;
const dest = join(source, rel);
fixtureMkdirSync(dirname(dest), { recursive: true });
if (lstatSync(join(ROOT, rel)).isSymbolicLink()) {
const target = readlinkSync(join(ROOT, rel));
expect(resolve(dirname(dest), target).startsWith(source + '/')).toBe(true);
symlinkSync(target, dest);
} else fixtureCopyFileSync(join(ROOT, rel), dest);
}
const write = (file: string, content: string) => {
fixtureMkdirSync(dirname(file), { recursive: true });
fixtureWriteFileSync(file, content, { mode: 0o755 });
};
for (const rel of ['browse/dist/browse', 'design/dist/design', 'make-pdf/dist/pdf', 'browse/dist/.build-complete']) {
const file = join(source, rel);
write(file, '#!/bin/sh\nexit 0\n');
fixtureUtimesSync(file, new Date('2040-01-01'), new Date('2040-01-01'));
}
write(join(commands, 'bun'), `#!/usr/bin/env bash
case "$*" in
'install --frozen-lockfile') exit 0 ;;
'build --help') echo 'Fixture Bun has no CSO compile flags'; exit 0 ;;
'run build') echo 'Unexpected build in registration fixture' >&2; exit 90 ;;
*) exec ${quote(process.execPath)} "$@" ;;
esac
`);
const realRm = Bun.which('rm');
if (!realRm) throw new Error('rm is required');
write(join(commands, 'rm'), `#!/usr/bin/env bash
if [ "$#" -eq 2 ] && [ "$1" = -f ] && [ "$2" = /tmp/gstack-latest-version ]; then exit 0; fi
exec ${quote(realRm)} "$@"
`);
for (const name of ['codex', 'claude']) write(join(commands, name), '#!/bin/sh\nexit 0\n');
const global = join(home, '.codex/skills');
const previous = join(dir, 'previous/gstack');
write(join(previous, 'bin/gstack-autoplan-snapshot.ts'), readFileSync(join(ROOT, 'bin/gstack-autoplan-snapshot.ts'), 'utf8'));
write(join(previous, 'lib/claude-bin.ts'), 'export {};\n');
for (const name of ['gstack-review', 'gstack-claude', 'gstack-retired']) {
write(join(previous, name, 'SKILL.md'), `---\nname: ${name}\n---\n<!-- AUTO-GENERATED from SKILL.md.tmpl — do not edit directly -->\n<!-- Regenerate: bun run gen:skill-docs -->\nPrior global skill.\n`);
fixtureMkdirSync(global, { recursive: true });
if (name === 'gstack-claude') {
write(join(global, name, 'SKILL.md'), readFileSync(join(previous, name, 'SKILL.md'), 'utf8'));
} else symlinkSync(join(previous, name), join(global, name), 'dir');
}
fixtureMkdirSync(join(global, 'gstack'), { recursive: true });
symlinkSync(join(previous, 'bin'), join(global, 'gstack/bin'), 'dir');
symlinkSync(join(previous, 'lib'), join(global, 'gstack/lib'), 'dir');
write(join(global, 'gstack/SKILL.md'), '<!-- AUTO-GENERATED from SKILL.md.tmpl — do not edit directly -->\n<!-- Regenerate: bun run gen:skill-docs -->\nGlobal router.\n');
write(join(global, 'custom/SKILL.md'), 'User-owned skill.\n');
const env = {
PATH: `${commands}:${process.env.PATH}`, HOME: home, USERPROFILE: home,
CODEX_HOME: join(home, '.codex'), CLAUDE_CONFIG_DIR: join(home, '.claude'),
GSTACK_HOME: join(home, '.gstack'), GSTACK_STATE_ROOT: join(home, '.gstack'),
TMPDIR: dir, TMP: dir, TEMP: dir,
GSTACK_SKIP_PLAYWRIGHT: '1', GSTACK_SKIP_FONTS: '1', GSTACK_SKIP_COREUTILS: '1', GSTACK_SKIP_ASIDE: '1', GSTACK_SKIP_GBRAIN_REGEN: '1',
BUN_RUNTIME_TRANSPILER_CACHE_PATH: join(tmpdir(), 'gstack-preflight-bun-cache'),
};
write(join(home, '.gstack/config.yaml'), 'telemetry: off\nartifacts_sync: off\n');
return { dir, source, home, project, other, global, previous, env };
}
export function install(f: ReturnType<typeof fixture>, args = '--host codex') {
const result = spawnSync('bash', [join(f.source, 'setup'), ...args.split(' '), '--no-plan-tune-hooks', '--no-timeline-stop-hook', '--no-team'], {
cwd: f.other, env: f.env, encoding: 'utf8', timeout: 60_000,
});
expect(result.status, result.stdout + result.stderr).toBe(0);
return result;
}
export function cleanupOwnedFixtures() { for (const dir of owned.splice(0)) rmSync(dir, { recursive: true, force: true }); }
afterEach(cleanupOwnedFixtures);
+264
View File
@@ -0,0 +1,264 @@
import { describe, expect, test } from 'bun:test';
import { spawnSync } from 'node:child_process';
import { cpSync, existsSync, lstatSync, readFileSync, realpathSync, rmSync, symlinkSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { owned, fixtureWriteFileSync, fixtureCopyFileSync, fixtureMkdirSync, fixtureUtimesSync, tree, fixture, install } from './helpers/setup-codex-scope-fixture';
describe.skipIf(process.platform === 'win32')('setup Codex destination follows recognized source scope', () => {
for (const localLegacy of [false, true]) for (const marker of ['current', '1.85.0.0']) test(`excluded global legacy render survives local migration=${localLegacy}, marker=${marker} and generation`, () => {
const f = fixture('.claude');
const oldRender = join(f.source, '.agents/skills/gstack-claude');
fixtureMkdirSync(oldRender, { recursive: true });
const oldBytes = '---\nname: gstack-claude\n---\n<!-- AUTO-GENERATED from SKILL.md.tmpl — do not edit directly -->\n<!-- Regenerate: bun run gen:skill-docs -->\nExisting legacy global workflow.\n';
fixtureWriteFileSync(join(oldRender, 'SKILL.md'), oldBytes);
rmSync(join(f.global, 'gstack-claude'), { recursive: true });
symlinkSync(oldRender, join(f.global, 'gstack-claude'), 'dir');
for (const rel of ['bin', 'lib']) {
const target = join(f.global, 'gstack', rel);
expect(lstatSync(target).isSymbolicLink()).toBe(true);
rmSync(target);
fixtureMkdirSync(target);
fixtureWriteFileSync(join(target, 'prior'), 'Existing copied global runtime.\n');
}
const local = join(f.project, '.agents/skills');
if (localLegacy) {
fixtureMkdirSync(local, { recursive: true });
symlinkSync(oldRender, join(local, 'gstack-claude'), 'dir');
}
fixtureWriteFileSync(join(f.home, '.gstack/.last-setup-version'), marker === 'current' ? readFileSync(join(f.source, 'VERSION')) : marker);
const before = tree(f.global);
install(f);
expect(tree(f.global)).toEqual(before);
expect(readFileSync(join(f.global, 'gstack-claude/SKILL.md'), 'utf8')).toBe(oldBytes);
expect(realpathSync(join(local, 'gstack-claude-code/SKILL.md'))).toBe(join(f.source, '.agents/skills/gstack-claude-code/SKILL.md'));
expect(lstatSync(join(local, 'gstack-claude'), { throwIfNoEntry: false })).toBeUndefined();
}, 90_000);
for (const nested of [false, true]) for (const global of [false, true]) for (const windows of [false, true]) {
test(`named ${nested ? 'ancestor' : 'source'} overlap: logical local=${!global}, Windows=${windows}`, () => {
const f = fixture('.claude');
const physical = join(f.project, '.agents/skills/gstack-review', ...(nested ? ['checkout'] : []));
fixtureMkdirSync(physical, { recursive: true });
cpSync(f.source, physical, { recursive: true, verbatimSymlinks: true, preserveTimestamps: true });
rmSync(f.source, { recursive: true });
symlinkSync(physical, f.source, 'dir');
if (nested) fixtureWriteFileSync(join(dirname(physical), 'SKILL.md'), '<!-- AUTO-GENERATED from SKILL.md.tmpl — do not edit directly -->\n<!-- Regenerate: bun run gen:skill-docs -->\nPrior render.\n');
fixtureWriteFileSync(join(physical, 'uncommitted-proof'), 'Do not erase this checkout.\n');
if (windows) {
fixtureWriteFileSync(join(f.dir, 'commands/uname'), '#!/bin/sh\nprintf "MINGW64_NT-10.0\\n"\n', { mode: 0o755 });
for (const rel of ['browse/dist/browse.exe', 'design/dist/design.exe', 'make-pdf/dist/pdf.exe']) {
fixtureCopyFileSync(join(physical, rel.replace('.exe', '')), join(physical, rel));
fixtureUtimesSync(join(physical, rel), new Date('2040-01-01'), new Date('2040-01-01'));
}
}
const env = global ? { ...f.env, CODEX_HOME: join(f.project, '.agents') } : f.env;
const before = tree(f.dir);
const result = spawnSync('bash', [join(f.source, 'setup'), '--host', 'codex', ...(global ? ['--global'] : []), '--no-team', '--no-plan-tune-hooks', '--no-timeline-stop-hook'], {
cwd: f.other, env, encoding: 'utf8', timeout: 60_000,
});
if (windows) {
expect(result.status, result.stdout + result.stderr).toBe(1);
expect(result.stderr).toContain('Codex skill copy replacement overlaps source');
expect(tree(f.dir)).toEqual(before);
} else {
expect(result.status, result.stdout + result.stderr).toBe(0);
expect(readFileSync(join(physical, 'uncommitted-proof'), 'utf8')).toBe('Do not erase this checkout.\n');
}
}, 90_000);
}
for (const target of ['source', 'project', 'root-sidecar']) for (const host of target === 'root-sidecar' ? ['codex'] : ['codex', 'claude']) test(`generated namespace alias to ${target} refuses before writes, host=${host}`, () => {
const f = fixture('ordinary');
const render = join(f.source, '.agents/skills');
fixtureMkdirSync(render, { recursive: true });
const alias = join(render, target === 'root-sidecar' ? 'gstack' : 'gstack-review');
const destination = target === 'project' ? join(f.project, 'ordinary-review') : f.source;
if (target === 'project') {
fixtureMkdirSync(destination);
fixtureWriteFileSync(join(destination, 'SKILL.md'), 'Project-owned content.\n');
}
symlinkSync(destination, alias, 'dir');
if (target === 'root-sidecar') fixtureWriteFileSync(join(f.dir, 'commands/uname'), '#!/bin/sh\nprintf "MINGW64_NT-10.0\\n"\n', { mode: 0o755 });
const before = tree(f.dir);
const result = spawnSync('bash', [join(f.source, 'setup'), '--host', host, '--no-team', '--no-plan-tune-hooks', '--no-timeline-stop-hook'], {
cwd: f.other, env: f.env, encoding: 'utf8', timeout: 60_000,
});
expect(result.status, result.stdout + result.stderr).toBe(1);
expect(result.stderr).toContain(target === 'root-sidecar' ? 'Codex sidecar' : 'Codex generated skill write');
expect(tree(f.dir)).toEqual(before);
}, 90_000);
for (const target of ['absolute-generation-alias', 'occupied-relocation']) for (const windows of [false, true]) {
test(`direct global checkout preflights ${target} before moving, Windows=${windows}`, () => {
const f = fixture('ordinary');
const direct = join(f.global, 'gstack');
rmSync(direct, { recursive: true });
cpSync(f.source, direct, { recursive: true, verbatimSymlinks: true, preserveTimestamps: true });
fixtureWriteFileSync(join(direct, 'uncommitted-proof'), 'Keep source checkout.\n');
if (target === 'absolute-generation-alias') {
const generated = join(direct, 'generated-codex');
fixtureMkdirSync(generated);
symlinkSync(generated, join(direct, '.agents'), 'dir');
} else {
fixtureMkdirSync(join(f.home, '.gstack/repos'), { recursive: true });
symlinkSync(direct, join(f.home, '.gstack/repos/gstack'), 'dir');
}
if (windows) fixtureWriteFileSync(join(f.dir, 'commands/uname'), '#!/bin/sh\nprintf "MINGW64_NT-10.0\\n"\n', { mode: 0o755 });
const before = tree(f.dir);
const result = spawnSync('bash', [join(direct, 'setup'), '--host', 'codex', '--no-team', '--no-plan-tune-hooks', '--no-timeline-stop-hook'], {
cwd: f.other, env: f.env, encoding: 'utf8', timeout: 60_000,
});
expect(result.status, result.stdout + result.stderr).toBe(1);
expect(result.stderr).toContain(target === 'absolute-generation-alias' ? 'post-relocation generation namespace' : 'checkout relocation');
expect(tree(f.dir)).toEqual(before);
}, 90_000);
}
test('Claude-only setup cannot prune a bannered stale skill inside its source checkout', () => {
const f = fixture('ordinary');
const skill = join(f.source, 'skills/gstack-obsolete/SKILL.md');
fixtureMkdirSync(dirname(skill), { recursive: true });
fixtureWriteFileSync(skill, '<!-- AUTO-GENERATED from SKILL.md.tmpl — do not edit directly -->\n<!-- Regenerate: bun run gen:skill-docs -->\nPreserve source content.\n');
const env = { ...f.env, CODEX_HOME: f.source };
const before = tree(f.dir);
const result = spawnSync('bash', [join(f.source, 'setup'), '--host', 'claude', '--no-team', '--no-plan-tune-hooks', '--no-timeline-stop-hook'], {
cwd: f.other, env, encoding: 'utf8', timeout: 60_000,
});
expect(result.status, result.stdout + result.stderr).toBe(1);
expect(result.stderr).toContain('Codex stale host cleanup');
expect(tree(f.dir)).toEqual(before);
}, 90_000);
test('a dangling owned legacy Codex link reaches the rename migration', () => {
const f = fixture('ordinary');
const old = join(f.global, 'gstack-claude');
rmSync(old, { recursive: true });
symlinkSync(join(f.source, '.agents/skills/gstack-claude'), old, 'dir');
for (const rel of ['bin', 'lib']) {
const target = join(f.global, 'gstack', rel);
rmSync(target);
symlinkSync(join(f.source, rel), target, 'dir');
}
install(f);
expect(lstatSync(old, { throwIfNoEntry: false })).toBeUndefined();
expect(readFileSync(join(f.global, 'gstack-claude-code/SKILL.md'), 'utf8')).toContain('name: claude-code');
expect(readFileSync(join(f.global, 'custom/SKILL.md'), 'utf8')).toBe('User-owned skill.\n');
}, 90_000);
for (const explicit of [false, true]) for (const windows of [false, true]) {
test(`global handwritten runtime is refused before writes, explicit=${explicit}, Windows=${windows}`, () => {
const f = fixture(explicit ? '.claude' : 'ordinary');
const runtime = join(f.global, 'gstack');
rmSync(runtime, { recursive: true });
fixtureMkdirSync(runtime);
fixtureWriteFileSync(join(runtime, 'SKILL.md'), 'A handwritten root skill.\n');
fixtureWriteFileSync(join(runtime, 'user-notes'), 'Keep these notes.\n');
if (windows) fixtureWriteFileSync(join(f.dir, 'commands/uname'), '#!/bin/sh\nprintf "MINGW64_NT-10.0\\n"\n', { mode: 0o755 });
const before = tree(f.dir);
const result = spawnSync('bash', [join(f.source, 'setup'), '--host', 'codex', ...(explicit ? ['--global'] : []), '--no-team', '--no-plan-tune-hooks', '--no-timeline-stop-hook'], {
cwd: f.other, env: f.env, encoding: 'utf8', timeout: 60_000,
});
expect(result.status, result.stdout + result.stderr).toBe(1);
expect(result.stderr).toContain(`global Codex runtime ${runtime} is a real user-owned skill`);
expect(result.stderr).toContain('choose another CODEX_HOME');
expect(tree(f.dir)).toEqual(before);
}, 90_000);
}
for (const prior of ['managed', 'partial']) test(`global ${prior} runtime remains refreshable`, () => {
const f = fixture('ordinary');
const runtime = join(f.global, 'gstack');
rmSync(runtime, { recursive: true });
fixtureMkdirSync(runtime);
if (prior === 'managed') fixtureWriteFileSync(join(runtime, 'SKILL.md'), '<!-- AUTO-GENERATED from SKILL.md.tmpl — do not edit directly -->\n<!-- Regenerate: bun run gen:skill-docs -->\nPrior runtime.\n');
fixtureWriteFileSync(join(runtime, 'prior-asset'), 'A previous runtime asset.\n');
install(f);
install(f);
expect(existsSync(join(runtime, 'prior-asset'))).toBe(false);
expect(readFileSync(join(runtime, 'SKILL.md'), 'utf8')).toContain('<!-- AUTO-GENERATED from');
expect(realpathSync(join(runtime, 'bin'))).toBe(join(f.source, 'bin'));
}, 90_000);
test('Claude-only setup leaves a global handwritten runtime untouched', () => {
const f = fixture('ordinary');
const runtime = join(f.global, 'gstack');
rmSync(runtime, { recursive: true });
fixtureMkdirSync(runtime);
fixtureWriteFileSync(join(runtime, 'SKILL.md'), 'A handwritten root skill.\n');
fixtureWriteFileSync(join(runtime, 'user-notes'), 'Keep these notes.\n');
const before = tree(runtime);
const result = spawnSync('bash', [join(f.source, 'setup'), '--host', 'claude', '--no-team', '--no-plan-tune-hooks', '--no-timeline-stop-hook'], {
cwd: f.other, env: f.env, encoding: 'utf8', timeout: 60_000,
});
expect(result.status, result.stdout + result.stderr).toBe(0);
expect(tree(runtime)).toEqual(before);
}, 90_000);
test('Unix rename does not overwrite a source checkout registered as another skill', () => {
const f = fixture('ordinary');
const source = join(f.global, 'gstack-review');
rmSync(source);
cpSync(f.source, source, { recursive: true, preserveTimestamps: true, verbatimSymlinks: true });
fixtureWriteFileSync(join(source, 'uncommitted-proof'), 'Keep this source.\n');
for (const rel of ['bin', 'lib']) {
const asset = join(f.global, 'gstack', rel);
rmSync(asset);
fixtureMkdirSync(asset);
}
const before = tree(f.dir);
const result = spawnSync('bash', [join(source, 'setup'), '--host', 'codex', '--no-team', '--no-plan-tune-hooks', '--no-timeline-stop-hook'], {
cwd: f.other, env: f.env, encoding: 'utf8', timeout: 60_000,
});
expect(result.status, result.stdout + result.stderr).toBe(1);
expect(result.stderr).toContain('Codex rename workflow write');
expect(tree(f.dir)).toEqual(before);
}, 90_000);
for (const alias of ['SKILL leaf', 'metadata parent']) test(`direct relocation refuses a generated absolute ${alias} before moving source`, () => {
const f = fixture('ordinary');
const direct = join(f.global, 'gstack');
rmSync(direct, { recursive: true });
cpSync(f.source, direct, { recursive: true, preserveTimestamps: true, verbatimSymlinks: true });
fixtureWriteFileSync(join(direct, 'uncommitted-proof'), 'Keep this source.\n');
const generated = join(direct, '.agents/skills/gstack-review');
fixtureMkdirSync(generated, { recursive: true });
if (alias === 'SKILL leaf') {
fixtureWriteFileSync(join(generated, 'saved.md'), '<!-- AUTO-GENERATED from SKILL.md.tmpl -->\nPrior render.\n');
symlinkSync(join(generated, 'saved.md'), join(generated, 'SKILL.md'));
} else {
const prior = join(direct, '.agents/skills/gstack-office-hours/agents');
fixtureMkdirSync(prior, { recursive: true });
symlinkSync(prior, join(generated, 'agents'), 'dir');
}
const before = tree(f.dir);
const result = spawnSync('bash', [join(direct, 'setup'), '--host', 'codex', '--no-team', '--no-plan-tune-hooks', '--no-timeline-stop-hook'], {
cwd: f.other, env: f.env, encoding: 'utf8', timeout: 60_000,
});
expect(result.status, result.stdout + result.stderr).toBe(1);
expect(result.stderr).toContain('post-relocation generated alias');
expect(tree(f.dir)).toEqual(before);
}, 90_000);
test('managed rename detaches a metadata parent link without touching its source', () => {
const f = fixture('ordinary');
const installed = join(f.global, 'gstack-review');
rmSync(installed);
fixtureMkdirSync(installed);
fixtureWriteFileSync(join(installed, 'SKILL.md'), '<!-- AUTO-GENERATED from SKILL.md.tmpl — do not edit directly -->\n<!-- Regenerate: bun run gen:skill-docs -->\nPrior installed review.\n');
const sourceAgents = join(f.source, '.agents/skills/gstack-review/agents');
fixtureMkdirSync(sourceAgents, { recursive: true });
fixtureWriteFileSync(join(dirname(sourceAgents), 'SKILL.md'), '<!-- AUTO-GENERATED from SKILL.md.tmpl — do not edit directly -->\n<!-- Regenerate: bun run gen:skill-docs -->\nPrior canonical review.\n');
fixtureWriteFileSync(join(sourceAgents, 'user-notes'), 'Preserve source metadata.\n');
symlinkSync(sourceAgents, join(installed, 'agents'), 'dir');
for (const rel of ['bin', 'lib']) {
const asset = join(f.global, 'gstack', rel);
rmSync(asset);
symlinkSync(join(f.source, rel), asset, 'dir');
}
const result = install(f);
expect(result.stderr).toContain('migrated 1 installed skill');
expect(lstatSync(join(installed, 'agents')).isDirectory()).toBe(true);
expect(readFileSync(join(sourceAgents, 'user-notes'), 'utf8')).toBe('Preserve source metadata.\n');
expect(existsSync(join(installed, 'agents/openai.yaml'))).toBe(true);
}, 90_000);
});
+273
View File
@@ -0,0 +1,273 @@
import { describe, expect, test } from 'bun:test';
import { spawnSync } from 'node:child_process';
import { cpSync, existsSync, readFileSync, realpathSync, rmSync, symlinkSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fixtureWriteFileSync, fixtureCopyFileSync, fixtureMkdirSync, fixtureUtimesSync, tree, fixture, install } from './helpers/setup-codex-scope-fixture';
describe.skipIf(process.platform === 'win32')('setup Codex destination follows recognized source scope', () => {
for (const global of [false, true]) for (const windows of [false, true]) test(`a real runtime containing the source is refused before writes, global=${global}, Windows=${windows}`, () => {
const f = fixture('.claude');
const runtime = join(f.project, '.agents/skills/gstack');
const nested = join(runtime, 'checkout');
cpSync(f.source, nested, { recursive: true, verbatimSymlinks: true, preserveTimestamps: true });
fixtureWriteFileSync(join(nested, 'uncommitted-work'), 'Preserve the nested checkout.\n');
rmSync(f.source, { recursive: true });
symlinkSync(nested, f.source, 'dir');
if (windows) fixtureWriteFileSync(join(f.dir, 'commands/uname'), '#!/bin/sh\nprintf "MINGW64_NT-10.0\\n"\n', { mode: 0o755 });
const env = global ? { ...f.env, CODEX_HOME: join(f.project, '.agents') } : f.env;
const before = tree(f.dir);
for (const host of ['codex', 'auto']) for (let run = 0; run < 2; run++) {
const args = [join(f.source, 'setup'), '--host', host, '--no-team', '--no-plan-tune-hooks', '--no-timeline-stop-hook'];
if (global) args.push('--global');
const result = spawnSync('bash', args, { cwd: f.other, env, encoding: 'utf8', timeout: 60_000 });
expect(tree(f.dir)).toEqual(before);
expect(result.status, result.stdout + result.stderr).toBe(1);
expect(result.stderr).toContain('runtime directory contains the source checkout');
expect(result.stderr).toContain(runtime);
}
}, 90_000);
test('a distinct sibling source checkout is refused before any mutation', () => {
const f = fixture('.claude');
const sibling = join(f.project, '.agents/skills/gstack');
cpSync(f.source, sibling, { recursive: true, verbatimSymlinks: true });
fixtureWriteFileSync(join(sibling, 'uncommitted-work'), 'Keep sibling checkout edits.\n');
const before = tree(f.dir);
const result = spawnSync('bash', [join(f.source, 'setup'), '--host', 'codex', '--no-team', '--no-plan-tune-hooks', '--no-timeline-stop-hook'], {
cwd: f.other, env: f.env, encoding: 'utf8', timeout: 60_000,
});
expect(tree(f.dir)).toEqual(before);
expect(result.status).toBe(1);
expect(result.stderr).toContain(`existing source checkout at ${sibling}`);
}, 90_000);
test('a project destination aliased to global skills is refused before any mutation', () => {
const f = fixture('.claude');
fixtureMkdirSync(join(f.project, '.agents'));
symlinkSync(f.global, join(f.project, '.agents/skills'), 'dir');
const before = tree(f.dir);
const result = spawnSync('bash', [join(f.source, 'setup'), '--host', 'codex', '--no-team', '--no-plan-tune-hooks', '--no-timeline-stop-hook'], {
cwd: f.other, env: f.env, encoding: 'utf8', timeout: 60_000,
});
expect(tree(f.dir)).toEqual(before);
expect(result.status).toBe(1);
expect(result.stderr).toContain('project-local Codex destination resolves outside the project');
}, 90_000);
for (const target of ['root', 'rendered', 'missing-tail', 'project-root']) for (const marker of ['current', '1.85.0.0']) test(`another payload cannot own the local namespace: ${target}, marker=${marker}`, () => {
const f = fixture('.claude');
const sibling = target === 'project-root' ? f.project : join(f.project, 'other-checkout');
if (target === 'project-root') {
for (const rel of ['setup', 'VERSION', 'bin/gstack-relink']) {
fixtureMkdirSync(dirname(join(sibling, rel)), { recursive: true });
fixtureCopyFileSync(join(f.source, rel), join(sibling, rel));
}
} else cpSync(f.source, sibling, { recursive: true, verbatimSymlinks: true });
const rendered = join(sibling, '.agents/skills');
const root = join(rendered, 'gstack');
const review = join(rendered, 'gstack-review');
for (const dir of [root, review]) {
fixtureMkdirSync(dir, { recursive: true });
fixtureWriteFileSync(join(dir, 'SKILL.md'), '<!-- AUTO-GENERATED from SKILL.md.tmpl — do not edit directly -->\n<!-- Regenerate: bun run gen:skill-docs -->\nKeep the sibling workflow.\n');
}
symlinkSync(join(sibling, 'bin'), join(root, 'bin'), 'dir');
for (const [name, source] of [['gstack', root], ['gstack-review', review]]) {
rmSync(join(f.global, name!), { recursive: true });
symlinkSync(source!, join(f.global, name!), 'dir');
}
if (target === 'missing-tail') {
expect(existsSync(join(sibling, 'skills'))).toBe(false);
symlinkSync(sibling, join(f.project, '.agents'), 'dir');
} else if (target !== 'project-root') {
fixtureMkdirSync(join(f.project, '.agents'));
symlinkSync(target === 'root' ? sibling : rendered, join(f.project, '.agents/skills'), 'dir');
}
fixtureWriteFileSync(join(f.home, '.gstack/.last-setup-version'), marker === 'current' ? readFileSync(join(f.source, 'VERSION')) : marker);
const before = tree(f.dir);
for (let run = 0; run < 2; run++) {
const result = spawnSync('bash', [join(f.source, 'setup'), '--host', 'codex', '--no-team', '--no-plan-tune-hooks', '--no-timeline-stop-hook'], {
cwd: f.other, env: f.env, encoding: 'utf8', timeout: 60_000,
});
expect(result.status, result.stdout + result.stderr).toBe(1);
expect(result.stderr).toContain('overlaps the source tree');
expect(result.stderr).toContain(sibling);
expect(tree(f.dir)).toEqual(before);
}
}, 90_000);
for (const preexisting of [false, true]) test(`generated runtime leaf alias survives setup, preexisting=${preexisting}`, () => {
const f = fixture('.claude');
const renderedSkills = join(f.source, '.agents/skills');
const renderedRoot = join(renderedSkills, 'gstack');
const local = join(f.project, '.agents/skills');
if (preexisting) {
fixtureMkdirSync(renderedRoot, { recursive: true });
fixtureWriteFileSync(join(renderedRoot, 'SKILL.md'), readFileSync(join(f.source, 'SKILL.md')));
}
fixtureMkdirSync(local, { recursive: true });
symlinkSync(renderedRoot, join(local, 'gstack'), 'dir');
const globalRoot = join(f.global, 'gstack/SKILL.md');
rmSync(globalRoot);
symlinkSync(join(renderedRoot, 'SKILL.md'), globalRoot);
const before = tree(f.global);
for (let run = 0; run < 2; run++) {
install(f);
expect(tree(f.global)).toEqual(before);
expect(existsSync(join(renderedRoot, 'SKILL.md'))).toBe(true);
expect(realpathSync(join(local, 'gstack/SKILL.md'))).toBe(join(renderedRoot, 'SKILL.md'));
expect(readFileSync(globalRoot)).toEqual(readFileSync(join(renderedRoot, 'SKILL.md')));
expect(realpathSync(join(local, 'gstack/bin'))).toBe(join(f.source, 'bin'));
}
}, 90_000);
for (const target of ['source', 'rendered', 'missing-rendered']) for (const windows of [false, true]) test(`source-backed namespace is refused without mutation: ${target}, Windows=${windows}`, () => {
const f = fixture('.claude');
const renderedSkills = join(f.source, '.agents/skills');
const oldRender = join(renderedSkills, 'gstack-claude');
if (target !== 'missing-rendered') {
fixtureMkdirSync(oldRender, { recursive: true });
fixtureWriteFileSync(join(oldRender, 'SKILL.md'), '---\nname: gstack-claude\n---\n<!-- AUTO-GENERATED from SKILL.md.tmpl — do not edit directly -->\n<!-- Regenerate: bun run gen:skill-docs -->\nExcluded global workflow.\n');
rmSync(join(f.global, 'gstack-claude'), { recursive: true });
symlinkSync(oldRender, join(f.global, 'gstack-claude'), 'dir');
}
fixtureMkdirSync(join(f.project, '.agents'));
symlinkSync(target === 'source' ? f.source : renderedSkills, join(f.project, '.agents/skills'), 'dir');
if (windows) fixtureWriteFileSync(join(f.dir, 'commands/uname'), '#!/bin/sh\nprintf "MINGW64_NT-10.0\\n"\n', { mode: 0o755 });
const before = tree(f.dir);
const result = spawnSync('bash', [join(f.source, 'setup'), '--host', 'codex', '--no-team', '--no-plan-tune-hooks', '--no-timeline-stop-hook'], {
cwd: f.other, env: f.env, encoding: 'utf8', timeout: 60_000,
});
expect(result.status, result.stdout + result.stderr).toBe(1);
expect(result.stderr).toContain(target === 'missing-rendered' ? 'unresolvable directory' : 'overlaps the source tree');
expect(result.stderr).toContain('Use a separate project-local skills directory');
expect(tree(f.dir)).toEqual(before);
}, 90_000);
for (const level of ['agents', 'skills']) for (const preexisting of [false, true]) for (const windows of [false, true]) test(`outward generation namespace is refused before writes: ${level}, preexisting=${preexisting}, Windows=${windows}`, () => {
const f = fixture('.claude');
const agents = join(f.project, '.agents');
const local = join(agents, 'skills');
fixtureMkdirSync(agents);
if (preexisting || level === 'skills') fixtureMkdirSync(local);
if (level === 'agents') symlinkSync(agents, join(f.source, '.agents'), 'dir');
else {
fixtureMkdirSync(join(f.source, '.agents'));
symlinkSync(local, join(f.source, '.agents/skills'), 'dir');
}
if (preexisting) for (const name of ['gstack', 'gstack-review']) {
fixtureMkdirSync(join(local, name));
fixtureWriteFileSync(join(local, name, 'SKILL.md'), `Handwritten ${name} workflow.\n`);
fixtureWriteFileSync(join(local, name, 'user-notes'), 'Preserve unrelated user notes.\n');
}
if (!preexisting && level === 'skills') rmSync(local, { recursive: true });
if (windows) fixtureWriteFileSync(join(f.dir, 'commands/uname'), '#!/bin/sh\nprintf "MINGW64_NT-10.0\\n"\n', { mode: 0o755 });
fixtureWriteFileSync(join(f.home, '.gstack/.last-setup-version'), '1.85.0.0');
const before = tree(f.dir);
for (const host of ['codex', 'claude', 'auto']) for (let run = 0; run < 2; run++) {
const result = spawnSync('bash', [join(f.source, 'setup'), '--host', host, '--no-team', '--no-plan-tune-hooks', '--no-timeline-stop-hook'], {
cwd: f.other, env: f.env, encoding: 'utf8', timeout: 60_000,
});
expect(tree(f.dir)).toEqual(before);
expect(result.status, result.stdout + result.stderr).toBe(1);
expect(result.stderr).toContain('generation namespace');
expect(result.stderr).toContain('Use a separate project-local skills directory');
}
}, 90_000);
for (const target of ['dangling-agents', 'dangling-skills', 'cyclic-agents', 'cyclic-skills', 'file-agents', 'file-skills', 'source-root']) test(`unresolvable generation namespace is refused without mutation: ${target}`, () => {
const f = fixture('.claude');
const agents = join(f.source, '.agents');
const component = target.endsWith('skills') || target === 'source-root' ? join(agents, 'skills') : agents;
if (component !== agents) fixtureMkdirSync(agents);
if (target.startsWith('file')) fixtureWriteFileSync(component, 'Preserve namespace file.\n');
else symlinkSync(target === 'source-root' ? f.source : target.startsWith('cyclic') ? component : join(f.source, 'missing-generation'), component, 'dir');
const before = tree(f.dir);
for (let run = 0; run < 2; run++) {
const result = spawnSync('bash', [join(f.source, 'setup'), '--host', 'codex', '--no-team'], {
cwd: f.other, env: f.env, encoding: 'utf8', timeout: 60_000,
});
expect(tree(f.dir)).toEqual(before);
expect(result.status, result.stdout + result.stderr).toBe(1);
expect(result.stderr).toContain('generation namespace');
}
}, 90_000);
for (const level of ['agents', 'skills']) for (const preexisting of [false, true]) for (const windows of [false, true]) test(`internal generation namespace alias remains supported: ${level}, preexisting=${preexisting}, Windows=${windows}`, () => {
const f = fixture('.claude');
const internal = join(f.source, 'generated-codex');
fixtureMkdirSync(internal);
if (level === 'agents') symlinkSync(internal, join(f.source, '.agents'), 'dir');
else {
fixtureMkdirSync(join(f.source, '.agents'));
symlinkSync(internal, join(f.source, '.agents/skills'), 'dir');
}
const generated = join(f.source, '.agents/skills');
if (preexisting) {
fixtureMkdirSync(join(generated, 'gstack-review'), { recursive: true });
fixtureWriteFileSync(join(generated, 'gstack-review/SKILL.md'), 'Prior internal render.\n');
}
if (windows) {
fixtureWriteFileSync(join(f.dir, 'commands/uname'), '#!/bin/sh\nprintf "MINGW64_NT-10.0\\n"\n', { mode: 0o755 });
for (const rel of ['browse/dist/browse.exe', 'design/dist/design.exe', 'make-pdf/dist/pdf.exe']) {
fixtureWriteFileSync(join(f.source, rel), '#!/bin/sh\nexit 0\n', { mode: 0o755 });
fixtureUtimesSync(join(f.source, rel), new Date('2040-01-01'), new Date('2040-01-01'));
}
}
fixtureWriteFileSync(join(f.source, 'uncommitted-work'), 'Preserve source edits.\n');
const excluded = tree(f.global), sibling = tree(f.other);
const sourceBefore = Object.fromEntries(['SKILL.md', 'bin', 'lib', 'uncommitted-work'].map(rel => [rel, tree(join(f.source, rel))]));
for (let run = 0; run < 2; run++) {
install(f);
expect(tree(f.global)).toEqual(excluded);
expect(tree(f.other)).toEqual(sibling);
for (const [rel, before] of Object.entries(sourceBefore)) expect(tree(join(f.source, rel))).toEqual(before);
expect(readFileSync(join(f.project, '.agents/skills/gstack-review/SKILL.md'))).toEqual(readFileSync(join(generated, 'gstack-review/SKILL.md')));
expect(existsSync(join(f.source, 'bin/bin'))).toBe(false);
}
}, 90_000);
for (const windows of [false, true]) test(`individual generated skill leaf alias remains supported, Windows=${windows}`, () => {
const f = fixture('.claude');
const rendered = join(f.source, '.agents/skills/gstack-review');
const local = join(f.project, '.agents/skills');
fixtureMkdirSync(rendered, { recursive: true });
fixtureMkdirSync(local, { recursive: true });
fixtureWriteFileSync(join(rendered, 'user-notes'), 'Keep notes beside the source render.\n');
symlinkSync(rendered, join(local, 'gstack-review'), 'dir');
if (windows) {
fixtureWriteFileSync(join(f.dir, 'commands/uname'), '#!/bin/sh\nprintf "MINGW64_NT-10.0\\n"\n', { mode: 0o755 });
for (const rel of ['browse/dist/browse.exe', 'design/dist/design.exe', 'make-pdf/dist/pdf.exe']) {
fixtureWriteFileSync(join(f.source, rel), '#!/bin/sh\nexit 0\n', { mode: 0o755 });
fixtureUtimesSync(join(f.source, rel), new Date('2040-01-01'), new Date('2040-01-01'));
}
}
const excluded = tree(f.global);
for (let run = 0; run < 2; run++) {
install(f);
expect(tree(f.global)).toEqual(excluded);
expect(readFileSync(join(rendered, 'user-notes'), 'utf8')).toBe('Keep notes beside the source render.\n');
expect(readFileSync(join(local, 'gstack-review/SKILL.md'))).toEqual(readFileSync(join(rendered, 'SKILL.md')));
}
}, 90_000);
for (const target of ['dangling-skills', 'dangling-agents', 'cyclic-skills', 'file-skills']) test(`unresolvable namespace is refused without mutation: ${target}`, () => {
const f = fixture('.claude');
const agents = join(f.project, '.agents');
if (target === 'dangling-agents') symlinkSync(join(f.dir, 'missing-agents'), agents, 'dir');
else {
fixtureMkdirSync(agents);
const local = join(agents, 'skills');
if (target === 'file-skills') fixtureWriteFileSync(local, 'Preserve this file.\n');
else symlinkSync(target === 'cyclic-skills' ? 'skills' : join(f.dir, 'missing-skills'), local, 'dir');
}
const before = tree(f.dir);
const result = spawnSync('bash', [join(f.source, 'setup'), '--host', 'codex', '--no-team', '--no-plan-tune-hooks', '--no-timeline-stop-hook'], {
cwd: f.other, env: f.env, encoding: 'utf8', timeout: 60_000,
});
expect(result.status, result.stdout + result.stderr).toBe(1);
expect(result.stderr).toContain('unresolvable directory');
expect(result.stderr).toContain('Use a separate project-local skills directory');
expect(tree(f.dir)).toEqual(before);
}, 90_000);
});
@@ -0,0 +1,87 @@
import { describe, expect, test } from 'bun:test';
import { spawnSync } from 'node:child_process';
import { existsSync, lstatSync, rmSync, symlinkSync } from 'node:fs';
import { join } from 'node:path';
import { fixtureWriteFileSync, fixtureCopyFileSync, fixtureMkdirSync, fixtureUtimesSync, tree, fixture, install } from './helpers/setup-codex-scope-fixture';
describe.skipIf(process.platform === 'win32')('F13 independent review boundaries', () => {
for (const target of ['legacy', 'replacement', 'legacy-skill', 'runtime', 'runtime-bin', 'runtime-lib']) {
test(`Claude-only setup preserves cyclic foreign ownership link: ${target}`, () => {
const f = fixture('ordinary');
rmSync(join(f.global, 'gstack-retired'));
for (const rel of ['bin', 'lib']) {
const asset = join(f.global, 'gstack', rel);
rmSync(asset);
symlinkSync(join(f.source, rel), asset, 'dir');
}
const old = join(f.global, 'gstack-claude');
rmSync(old, { recursive: true });
const oldRender = join(f.source, '.agents/skills/gstack-claude');
fixtureMkdirSync(oldRender, { recursive: true });
fixtureWriteFileSync(join(oldRender, 'SKILL.md'), '<!-- AUTO-GENERATED from SKILL.md.tmpl -->\n<!-- Regenerate: bun run gen:skill-docs -->\nPrior legacy render.\n');
symlinkSync(oldRender, old, 'dir');
const link = target === 'legacy' ? old
: target === 'replacement' ? join(f.global, 'gstack-claude-code')
: target === 'legacy-skill' ? join(old, 'SKILL.md')
: target === 'runtime' ? join(f.global, 'gstack')
: join(f.global, 'gstack', target === 'runtime-bin' ? 'bin' : 'lib');
if (target === 'legacy-skill') {
rmSync(old);
fixtureMkdirSync(old);
}
if (existsSync(link) || lstatSync(link, { throwIfNoEntry: false })) rmSync(link, { recursive: true });
symlinkSync(link, link);
const before = tree(f.global);
for (let run = 0; run < 2; run++) {
install(f, '--host claude');
expect(tree(f.global)).toEqual(before);
}
}, 90_000);
}
for (const alias of [false, true]) for (const windows of [false, true]) {
test(`global generation namespace is refused before mutation: alias=${alias}, Windows=${windows}`, () => {
const f = fixture('ordinary');
const agents = join(f.source, '.agents');
fixtureMkdirSync(agents, { recursive: true });
const codexHome = alias ? join(f.home, 'generation-alias') : agents;
if (alias) symlinkSync(agents, codexHome, 'dir');
if (windows) {
fixtureWriteFileSync(join(f.dir, 'commands/uname'), '#!/bin/sh\nprintf "MINGW64_NT-10.0\n"\n', { mode: 0o755 });
for (const rel of ['browse/dist/browse.exe', 'design/dist/design.exe', 'make-pdf/dist/pdf.exe']) {
fixtureCopyFileSync(join(f.source, rel.replace('.exe', '')), join(f.source, rel));
fixtureUtimesSync(join(f.source, rel), new Date('2040-01-01'), new Date('2040-01-01'));
}
}
const before = tree(f.dir);
for (let run = 0; run < 2; run++) {
const result = spawnSync('bash', [join(f.source, 'setup'), '--host', 'codex', '--no-team', '--no-plan-tune-hooks', '--no-timeline-stop-hook'], {
cwd: f.other, env: { ...f.env, CODEX_HOME: codexHome }, encoding: 'utf8', timeout: 60_000,
});
expect(result.status, result.stdout + result.stderr).toBe(1);
expect(result.stderr).toContain('host namespace');
expect(tree(f.dir)).toEqual(before);
}
}, 90_000);
}
for (const leaf of ['SKILL.md', 'agents']) {
test(`selected generated cyclic write remains fail-closed: ${leaf}`, () => {
const f = fixture('ordinary');
const generated = join(f.source, '.agents/skills/gstack-review');
fixtureMkdirSync(generated, { recursive: true });
const link = join(generated, leaf);
symlinkSync(link, link);
const before = tree(f.dir);
for (let run = 0; run < 2; run++) {
const result = spawnSync('bash', [join(f.source, 'setup'), '--host', 'codex', '--no-team', '--no-plan-tune-hooks', '--no-timeline-stop-hook'], {
cwd: f.other, env: f.env, encoding: 'utf8', timeout: 60_000,
});
expect(result.status, result.stdout + result.stderr).toBe(1);
expect(result.stderr).toContain('ELOOP');
expect(tree(f.dir)).toEqual(before);
}
}, 90_000);
}
});
+4 -732
View File
@@ -1,136 +1,12 @@
import { afterEach, describe, expect, test } from 'bun:test'; import { describe, expect, test } from 'bun:test';
import { spawnSync } from 'node:child_process'; import { spawnSync } from 'node:child_process';
import { createHash } from 'node:crypto'; import { cpSync, existsSync, lstatSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync } from 'node:fs';
import { copyFileSync, cpSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, readlinkSync, realpathSync, rmSync, symlinkSync, utimesSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path'; import { dirname, join } from 'node:path';
import { generateAutoplanSnapshotTool } from '../scripts/resolvers/composition'; import { generateAutoplanSnapshotTool } from '../scripts/resolvers/composition';
import { HOST_PATHS, type TemplateContext } from '../scripts/resolvers/types'; import { HOST_PATHS, type TemplateContext } from '../scripts/resolvers/types';
import { runBashScript } from './helpers/bash-script'; import { runBashScript } from './helpers/bash-script';
import { ROOT, owned, fixtureWriteFileSync, fixtureCopyFileSync, fixtureMkdirSync, fixtureUtimesSync, tree, fixture, install } from './helpers/setup-codex-scope-fixture';
const ROOT = resolve(import.meta.dir, '..');
const files = spawnSync('git', ['ls-files', '-z'], { cwd: ROOT, encoding: 'utf8', timeout: 10_000 });
if (files.status !== 0) throw new Error(files.stderr);
const owned: string[] = [];
afterEach(() => { for (const dir of owned.splice(0)) rmSync(dir, { recursive: true, force: true }); });
const quote = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`;
function assertFixtureWrite(file: string) {
const root = owned.find(dir => file === dir || file.startsWith(dir + '/'));
if (!root) throw new Error(`Fixture write outside owned roots: ${file}`);
let existing = file;
while (!existsSync(existing)) {
if (lstatSync(existing, { throwIfNoEntry: false })?.isSymbolicLink()) throw new Error(`Unresolvable fixture link: ${existing}`);
existing = dirname(existing);
}
const target = realpathSync(existing);
const physicalRoot = realpathSync(root);
if (target !== physicalRoot && !target.startsWith(physicalRoot + '/')) throw new Error(`Fixture write escapes physical root: ${file} -> ${target}`);
}
function fixtureWriteFileSync(...args: Parameters<typeof writeFileSync>) {
assertFixtureWrite(String(args[0]));
return writeFileSync(...args);
}
function fixtureCopyFileSync(...args: Parameters<typeof copyFileSync>) {
assertFixtureWrite(String(args[1]));
return copyFileSync(...args);
}
function fixtureMkdirSync(...args: Parameters<typeof mkdirSync>) {
assertFixtureWrite(String(args[0]));
return mkdirSync(...args);
}
function fixtureUtimesSync(...args: Parameters<typeof utimesSync>) {
assertFixtureWrite(String(args[0]));
return utimesSync(...args);
}
function tree(dir: string): unknown {
const stat = lstatSync(dir);
if (stat.isSymbolicLink()) return { link: readlinkSync(dir) };
if (stat.isDirectory()) return Object.fromEntries(readdirSync(dir).sort().map(name => [name, tree(join(dir, name))]));
return createHash('sha256').update(readFileSync(dir)).digest('hex');
}
function fixture(layout: string) {
const dir = mkdtempSync(join(tmpdir(), 'gstack-codex-scope-'));
owned.push(dir);
const home = join(dir, 'home');
const project = join(dir, 'project-a');
const other = join(dir, 'project-b');
const source = layout === 'machine' ? join(home, '.claude/skills/gstack')
: layout === 'ordinary' ? join(project, 'custom-checkout') : join(project, layout, 'skills/gstack');
const commands = join(dir, 'commands');
for (const d of [home, other, commands, source]) fixtureMkdirSync(d, { recursive: true });
for (const rel of [...files.stdout.split('\0').filter(Boolean), 'scripts/external-skill-names.ts', 'scripts/preflight-codex-overlap.ts']) {
if (/^(?:test|docs|browse\/test|\.github)\//.test(rel)) continue;
const dest = join(source, rel);
fixtureMkdirSync(dirname(dest), { recursive: true });
if (lstatSync(join(ROOT, rel)).isSymbolicLink()) {
const target = readlinkSync(join(ROOT, rel));
expect(resolve(dirname(dest), target).startsWith(source + '/')).toBe(true);
symlinkSync(target, dest);
} else fixtureCopyFileSync(join(ROOT, rel), dest);
}
const write = (file: string, content: string) => {
fixtureMkdirSync(dirname(file), { recursive: true });
fixtureWriteFileSync(file, content, { mode: 0o755 });
};
for (const rel of ['browse/dist/browse', 'design/dist/design', 'make-pdf/dist/pdf', 'browse/dist/.build-complete']) {
const file = join(source, rel);
write(file, '#!/bin/sh\nexit 0\n');
fixtureUtimesSync(file, new Date('2040-01-01'), new Date('2040-01-01'));
}
write(join(commands, 'bun'), `#!/usr/bin/env bash
case "$*" in
'install --frozen-lockfile') exit 0 ;;
'build --help') echo 'Fixture Bun has no CSO compile flags'; exit 0 ;;
'run build') echo 'Unexpected build in registration fixture' >&2; exit 90 ;;
*) exec ${quote(process.execPath)} "$@" ;;
esac
`);
const realRm = Bun.which('rm');
if (!realRm) throw new Error('rm is required');
write(join(commands, 'rm'), `#!/usr/bin/env bash
if [ "$#" -eq 2 ] && [ "$1" = -f ] && [ "$2" = /tmp/gstack-latest-version ]; then exit 0; fi
exec ${quote(realRm)} "$@"
`);
for (const name of ['codex', 'claude']) write(join(commands, name), '#!/bin/sh\nexit 0\n');
const global = join(home, '.codex/skills');
const previous = join(dir, 'previous/gstack');
write(join(previous, 'bin/gstack-autoplan-snapshot.ts'), readFileSync(join(ROOT, 'bin/gstack-autoplan-snapshot.ts'), 'utf8'));
write(join(previous, 'lib/claude-bin.ts'), 'export {};\n');
for (const name of ['gstack-review', 'gstack-claude', 'gstack-retired']) {
write(join(previous, name, 'SKILL.md'), `---\nname: ${name}\n---\n<!-- AUTO-GENERATED from SKILL.md.tmpl — do not edit directly -->\n<!-- Regenerate: bun run gen:skill-docs -->\nPrior global skill.\n`);
fixtureMkdirSync(global, { recursive: true });
if (name === 'gstack-claude') {
write(join(global, name, 'SKILL.md'), readFileSync(join(previous, name, 'SKILL.md'), 'utf8'));
} else symlinkSync(join(previous, name), join(global, name), 'dir');
}
fixtureMkdirSync(join(global, 'gstack'), { recursive: true });
symlinkSync(join(previous, 'bin'), join(global, 'gstack/bin'), 'dir');
symlinkSync(join(previous, 'lib'), join(global, 'gstack/lib'), 'dir');
write(join(global, 'gstack/SKILL.md'), '<!-- AUTO-GENERATED from SKILL.md.tmpl — do not edit directly -->\n<!-- Regenerate: bun run gen:skill-docs -->\nGlobal router.\n');
write(join(global, 'custom/SKILL.md'), 'User-owned skill.\n');
const env = {
PATH: `${commands}:${process.env.PATH}`, HOME: home, USERPROFILE: home,
CODEX_HOME: join(home, '.codex'), CLAUDE_CONFIG_DIR: join(home, '.claude'),
GSTACK_HOME: join(home, '.gstack'), GSTACK_STATE_ROOT: join(home, '.gstack'),
TMPDIR: dir, TMP: dir, TEMP: dir,
GSTACK_SKIP_PLAYWRIGHT: '1', GSTACK_SKIP_FONTS: '1', GSTACK_SKIP_COREUTILS: '1', GSTACK_SKIP_ASIDE: '1', GSTACK_SKIP_GBRAIN_REGEN: '1',
BUN_RUNTIME_TRANSPILER_CACHE_PATH: join(tmpdir(), 'gstack-preflight-bun-cache'),
};
write(join(home, '.gstack/config.yaml'), 'telemetry: off\nartifacts_sync: off\n');
return { dir, source, home, project, other, global, previous, env };
}
function install(f: ReturnType<typeof fixture>, args = '--host codex') {
const result = spawnSync('bash', [join(f.source, 'setup'), ...args.split(' '), '--no-plan-tune-hooks', '--no-timeline-stop-hook', '--no-team'], {
cwd: f.other, env: f.env, encoding: 'utf8', timeout: 60_000,
});
expect(result.status, result.stdout + result.stderr).toBe(0);
return result;
}
test.skipIf(process.platform === 'win32')('fixture writes reject physical escapes and allow aliased temporary roots', () => { test.skipIf(process.platform === 'win32')('fixture writes reject physical escapes and allow aliased temporary roots', () => {
const dir = mkdtempSync(join(tmpdir(), 'gstack-fixture-guard-')); const dir = mkdtempSync(join(tmpdir(), 'gstack-fixture-guard-'));
@@ -335,608 +211,4 @@ describe.skipIf(process.platform === 'win32')('setup Codex destination follows r
expect(realpathSync(join(f.project, '.agents/skills/gstack/bin'))).toBe(join(f.source, 'bin')); expect(realpathSync(join(f.project, '.agents/skills/gstack/bin'))).toBe(join(f.source, 'bin'));
}, 90_000); }, 90_000);
for (const global of [false, true]) for (const windows of [false, true]) test(`a real runtime containing the source is refused before writes, global=${global}, Windows=${windows}`, () => {
const f = fixture('.claude');
const runtime = join(f.project, '.agents/skills/gstack');
const nested = join(runtime, 'checkout');
cpSync(f.source, nested, { recursive: true, verbatimSymlinks: true, preserveTimestamps: true });
fixtureWriteFileSync(join(nested, 'uncommitted-work'), 'Preserve the nested checkout.\n');
rmSync(f.source, { recursive: true });
symlinkSync(nested, f.source, 'dir');
if (windows) fixtureWriteFileSync(join(f.dir, 'commands/uname'), '#!/bin/sh\nprintf "MINGW64_NT-10.0\\n"\n', { mode: 0o755 });
const env = global ? { ...f.env, CODEX_HOME: join(f.project, '.agents') } : f.env;
const before = tree(f.dir);
for (const host of ['codex', 'auto']) for (let run = 0; run < 2; run++) {
const args = [join(f.source, 'setup'), '--host', host, '--no-team', '--no-plan-tune-hooks', '--no-timeline-stop-hook'];
if (global) args.push('--global');
const result = spawnSync('bash', args, { cwd: f.other, env, encoding: 'utf8', timeout: 60_000 });
expect(tree(f.dir)).toEqual(before);
expect(result.status, result.stdout + result.stderr).toBe(1);
expect(result.stderr).toContain('runtime directory contains the source checkout');
expect(result.stderr).toContain(runtime);
}
}, 90_000);
test('a distinct sibling source checkout is refused before any mutation', () => {
const f = fixture('.claude');
const sibling = join(f.project, '.agents/skills/gstack');
cpSync(f.source, sibling, { recursive: true, verbatimSymlinks: true });
fixtureWriteFileSync(join(sibling, 'uncommitted-work'), 'Keep sibling checkout edits.\n');
const before = tree(f.dir);
const result = spawnSync('bash', [join(f.source, 'setup'), '--host', 'codex', '--no-team', '--no-plan-tune-hooks', '--no-timeline-stop-hook'], {
cwd: f.other, env: f.env, encoding: 'utf8', timeout: 60_000,
});
expect(tree(f.dir)).toEqual(before);
expect(result.status).toBe(1);
expect(result.stderr).toContain(`existing source checkout at ${sibling}`);
}, 90_000);
test('a project destination aliased to global skills is refused before any mutation', () => {
const f = fixture('.claude');
fixtureMkdirSync(join(f.project, '.agents'));
symlinkSync(f.global, join(f.project, '.agents/skills'), 'dir');
const before = tree(f.dir);
const result = spawnSync('bash', [join(f.source, 'setup'), '--host', 'codex', '--no-team', '--no-plan-tune-hooks', '--no-timeline-stop-hook'], {
cwd: f.other, env: f.env, encoding: 'utf8', timeout: 60_000,
});
expect(tree(f.dir)).toEqual(before);
expect(result.status).toBe(1);
expect(result.stderr).toContain('project-local Codex destination resolves outside the project');
}, 90_000);
for (const target of ['root', 'rendered', 'missing-tail', 'project-root']) for (const marker of ['current', '1.85.0.0']) test(`another payload cannot own the local namespace: ${target}, marker=${marker}`, () => {
const f = fixture('.claude');
const sibling = target === 'project-root' ? f.project : join(f.project, 'other-checkout');
if (target === 'project-root') {
for (const rel of ['setup', 'VERSION', 'bin/gstack-relink']) {
fixtureMkdirSync(dirname(join(sibling, rel)), { recursive: true });
fixtureCopyFileSync(join(f.source, rel), join(sibling, rel));
}
} else cpSync(f.source, sibling, { recursive: true, verbatimSymlinks: true });
const rendered = join(sibling, '.agents/skills');
const root = join(rendered, 'gstack');
const review = join(rendered, 'gstack-review');
for (const dir of [root, review]) {
fixtureMkdirSync(dir, { recursive: true });
fixtureWriteFileSync(join(dir, 'SKILL.md'), '<!-- AUTO-GENERATED from SKILL.md.tmpl — do not edit directly -->\n<!-- Regenerate: bun run gen:skill-docs -->\nKeep the sibling workflow.\n');
}
symlinkSync(join(sibling, 'bin'), join(root, 'bin'), 'dir');
for (const [name, source] of [['gstack', root], ['gstack-review', review]]) {
rmSync(join(f.global, name!), { recursive: true });
symlinkSync(source!, join(f.global, name!), 'dir');
}
if (target === 'missing-tail') {
expect(existsSync(join(sibling, 'skills'))).toBe(false);
symlinkSync(sibling, join(f.project, '.agents'), 'dir');
} else if (target !== 'project-root') {
fixtureMkdirSync(join(f.project, '.agents'));
symlinkSync(target === 'root' ? sibling : rendered, join(f.project, '.agents/skills'), 'dir');
}
fixtureWriteFileSync(join(f.home, '.gstack/.last-setup-version'), marker === 'current' ? readFileSync(join(f.source, 'VERSION')) : marker);
const before = tree(f.dir);
for (let run = 0; run < 2; run++) {
const result = spawnSync('bash', [join(f.source, 'setup'), '--host', 'codex', '--no-team', '--no-plan-tune-hooks', '--no-timeline-stop-hook'], {
cwd: f.other, env: f.env, encoding: 'utf8', timeout: 60_000,
});
expect(result.status, result.stdout + result.stderr).toBe(1);
expect(result.stderr).toContain('overlaps the source tree');
expect(result.stderr).toContain(sibling);
expect(tree(f.dir)).toEqual(before);
}
}, 90_000);
for (const preexisting of [false, true]) test(`generated runtime leaf alias survives setup, preexisting=${preexisting}`, () => {
const f = fixture('.claude');
const renderedSkills = join(f.source, '.agents/skills');
const renderedRoot = join(renderedSkills, 'gstack');
const local = join(f.project, '.agents/skills');
if (preexisting) {
fixtureMkdirSync(renderedRoot, { recursive: true });
fixtureWriteFileSync(join(renderedRoot, 'SKILL.md'), readFileSync(join(f.source, 'SKILL.md')));
}
fixtureMkdirSync(local, { recursive: true });
symlinkSync(renderedRoot, join(local, 'gstack'), 'dir');
const globalRoot = join(f.global, 'gstack/SKILL.md');
rmSync(globalRoot);
symlinkSync(join(renderedRoot, 'SKILL.md'), globalRoot);
const before = tree(f.global);
for (let run = 0; run < 2; run++) {
install(f);
expect(tree(f.global)).toEqual(before);
expect(existsSync(join(renderedRoot, 'SKILL.md'))).toBe(true);
expect(realpathSync(join(local, 'gstack/SKILL.md'))).toBe(join(renderedRoot, 'SKILL.md'));
expect(readFileSync(globalRoot)).toEqual(readFileSync(join(renderedRoot, 'SKILL.md')));
expect(realpathSync(join(local, 'gstack/bin'))).toBe(join(f.source, 'bin'));
}
}, 90_000);
for (const target of ['source', 'rendered', 'missing-rendered']) for (const windows of [false, true]) test(`source-backed namespace is refused without mutation: ${target}, Windows=${windows}`, () => {
const f = fixture('.claude');
const renderedSkills = join(f.source, '.agents/skills');
const oldRender = join(renderedSkills, 'gstack-claude');
if (target !== 'missing-rendered') {
fixtureMkdirSync(oldRender, { recursive: true });
fixtureWriteFileSync(join(oldRender, 'SKILL.md'), '---\nname: gstack-claude\n---\n<!-- AUTO-GENERATED from SKILL.md.tmpl — do not edit directly -->\n<!-- Regenerate: bun run gen:skill-docs -->\nExcluded global workflow.\n');
rmSync(join(f.global, 'gstack-claude'), { recursive: true });
symlinkSync(oldRender, join(f.global, 'gstack-claude'), 'dir');
}
fixtureMkdirSync(join(f.project, '.agents'));
symlinkSync(target === 'source' ? f.source : renderedSkills, join(f.project, '.agents/skills'), 'dir');
if (windows) fixtureWriteFileSync(join(f.dir, 'commands/uname'), '#!/bin/sh\nprintf "MINGW64_NT-10.0\\n"\n', { mode: 0o755 });
const before = tree(f.dir);
const result = spawnSync('bash', [join(f.source, 'setup'), '--host', 'codex', '--no-team', '--no-plan-tune-hooks', '--no-timeline-stop-hook'], {
cwd: f.other, env: f.env, encoding: 'utf8', timeout: 60_000,
});
expect(result.status, result.stdout + result.stderr).toBe(1);
expect(result.stderr).toContain(target === 'missing-rendered' ? 'unresolvable directory' : 'overlaps the source tree');
expect(result.stderr).toContain('Use a separate project-local skills directory');
expect(tree(f.dir)).toEqual(before);
}, 90_000);
for (const level of ['agents', 'skills']) for (const preexisting of [false, true]) for (const windows of [false, true]) test(`outward generation namespace is refused before writes: ${level}, preexisting=${preexisting}, Windows=${windows}`, () => {
const f = fixture('.claude');
const agents = join(f.project, '.agents');
const local = join(agents, 'skills');
fixtureMkdirSync(agents);
if (preexisting || level === 'skills') fixtureMkdirSync(local);
if (level === 'agents') symlinkSync(agents, join(f.source, '.agents'), 'dir');
else {
fixtureMkdirSync(join(f.source, '.agents'));
symlinkSync(local, join(f.source, '.agents/skills'), 'dir');
}
if (preexisting) for (const name of ['gstack', 'gstack-review']) {
fixtureMkdirSync(join(local, name));
fixtureWriteFileSync(join(local, name, 'SKILL.md'), `Handwritten ${name} workflow.\n`);
fixtureWriteFileSync(join(local, name, 'user-notes'), 'Preserve unrelated user notes.\n');
}
if (!preexisting && level === 'skills') rmSync(local, { recursive: true });
if (windows) fixtureWriteFileSync(join(f.dir, 'commands/uname'), '#!/bin/sh\nprintf "MINGW64_NT-10.0\\n"\n', { mode: 0o755 });
fixtureWriteFileSync(join(f.home, '.gstack/.last-setup-version'), '1.85.0.0');
const before = tree(f.dir);
for (const host of ['codex', 'claude', 'auto']) for (let run = 0; run < 2; run++) {
const result = spawnSync('bash', [join(f.source, 'setup'), '--host', host, '--no-team', '--no-plan-tune-hooks', '--no-timeline-stop-hook'], {
cwd: f.other, env: f.env, encoding: 'utf8', timeout: 60_000,
});
expect(tree(f.dir)).toEqual(before);
expect(result.status, result.stdout + result.stderr).toBe(1);
expect(result.stderr).toContain('generation namespace');
expect(result.stderr).toContain('Use a separate project-local skills directory');
}
}, 90_000);
for (const target of ['dangling-agents', 'dangling-skills', 'cyclic-agents', 'cyclic-skills', 'file-agents', 'file-skills', 'source-root']) test(`unresolvable generation namespace is refused without mutation: ${target}`, () => {
const f = fixture('.claude');
const agents = join(f.source, '.agents');
const component = target.endsWith('skills') || target === 'source-root' ? join(agents, 'skills') : agents;
if (component !== agents) fixtureMkdirSync(agents);
if (target.startsWith('file')) fixtureWriteFileSync(component, 'Preserve namespace file.\n');
else symlinkSync(target === 'source-root' ? f.source : target.startsWith('cyclic') ? component : join(f.source, 'missing-generation'), component, 'dir');
const before = tree(f.dir);
for (let run = 0; run < 2; run++) {
const result = spawnSync('bash', [join(f.source, 'setup'), '--host', 'codex', '--no-team'], {
cwd: f.other, env: f.env, encoding: 'utf8', timeout: 60_000,
});
expect(tree(f.dir)).toEqual(before);
expect(result.status, result.stdout + result.stderr).toBe(1);
expect(result.stderr).toContain('generation namespace');
}
}, 90_000);
for (const level of ['agents', 'skills']) for (const preexisting of [false, true]) for (const windows of [false, true]) test(`internal generation namespace alias remains supported: ${level}, preexisting=${preexisting}, Windows=${windows}`, () => {
const f = fixture('.claude');
const internal = join(f.source, 'generated-codex');
fixtureMkdirSync(internal);
if (level === 'agents') symlinkSync(internal, join(f.source, '.agents'), 'dir');
else {
fixtureMkdirSync(join(f.source, '.agents'));
symlinkSync(internal, join(f.source, '.agents/skills'), 'dir');
}
const generated = join(f.source, '.agents/skills');
if (preexisting) {
fixtureMkdirSync(join(generated, 'gstack-review'), { recursive: true });
fixtureWriteFileSync(join(generated, 'gstack-review/SKILL.md'), 'Prior internal render.\n');
}
if (windows) {
fixtureWriteFileSync(join(f.dir, 'commands/uname'), '#!/bin/sh\nprintf "MINGW64_NT-10.0\\n"\n', { mode: 0o755 });
for (const rel of ['browse/dist/browse.exe', 'design/dist/design.exe', 'make-pdf/dist/pdf.exe']) {
fixtureWriteFileSync(join(f.source, rel), '#!/bin/sh\nexit 0\n', { mode: 0o755 });
fixtureUtimesSync(join(f.source, rel), new Date('2040-01-01'), new Date('2040-01-01'));
}
}
fixtureWriteFileSync(join(f.source, 'uncommitted-work'), 'Preserve source edits.\n');
const excluded = tree(f.global), sibling = tree(f.other);
const sourceBefore = Object.fromEntries(['SKILL.md', 'bin', 'lib', 'uncommitted-work'].map(rel => [rel, tree(join(f.source, rel))]));
for (let run = 0; run < 2; run++) {
install(f);
expect(tree(f.global)).toEqual(excluded);
expect(tree(f.other)).toEqual(sibling);
for (const [rel, before] of Object.entries(sourceBefore)) expect(tree(join(f.source, rel))).toEqual(before);
expect(readFileSync(join(f.project, '.agents/skills/gstack-review/SKILL.md'))).toEqual(readFileSync(join(generated, 'gstack-review/SKILL.md')));
expect(existsSync(join(f.source, 'bin/bin'))).toBe(false);
}
}, 90_000);
for (const windows of [false, true]) test(`individual generated skill leaf alias remains supported, Windows=${windows}`, () => {
const f = fixture('.claude');
const rendered = join(f.source, '.agents/skills/gstack-review');
const local = join(f.project, '.agents/skills');
fixtureMkdirSync(rendered, { recursive: true });
fixtureMkdirSync(local, { recursive: true });
fixtureWriteFileSync(join(rendered, 'user-notes'), 'Keep notes beside the source render.\n');
symlinkSync(rendered, join(local, 'gstack-review'), 'dir');
if (windows) {
fixtureWriteFileSync(join(f.dir, 'commands/uname'), '#!/bin/sh\nprintf "MINGW64_NT-10.0\\n"\n', { mode: 0o755 });
for (const rel of ['browse/dist/browse.exe', 'design/dist/design.exe', 'make-pdf/dist/pdf.exe']) {
fixtureWriteFileSync(join(f.source, rel), '#!/bin/sh\nexit 0\n', { mode: 0o755 });
fixtureUtimesSync(join(f.source, rel), new Date('2040-01-01'), new Date('2040-01-01'));
}
}
const excluded = tree(f.global);
for (let run = 0; run < 2; run++) {
install(f);
expect(tree(f.global)).toEqual(excluded);
expect(readFileSync(join(rendered, 'user-notes'), 'utf8')).toBe('Keep notes beside the source render.\n');
expect(readFileSync(join(local, 'gstack-review/SKILL.md'))).toEqual(readFileSync(join(rendered, 'SKILL.md')));
}
}, 90_000);
for (const target of ['dangling-skills', 'dangling-agents', 'cyclic-skills', 'file-skills']) test(`unresolvable namespace is refused without mutation: ${target}`, () => {
const f = fixture('.claude');
const agents = join(f.project, '.agents');
if (target === 'dangling-agents') symlinkSync(join(f.dir, 'missing-agents'), agents, 'dir');
else {
fixtureMkdirSync(agents);
const local = join(agents, 'skills');
if (target === 'file-skills') fixtureWriteFileSync(local, 'Preserve this file.\n');
else symlinkSync(target === 'cyclic-skills' ? 'skills' : join(f.dir, 'missing-skills'), local, 'dir');
}
const before = tree(f.dir);
const result = spawnSync('bash', [join(f.source, 'setup'), '--host', 'codex', '--no-team', '--no-plan-tune-hooks', '--no-timeline-stop-hook'], {
cwd: f.other, env: f.env, encoding: 'utf8', timeout: 60_000,
});
expect(result.status, result.stdout + result.stderr).toBe(1);
expect(result.stderr).toContain('unresolvable directory');
expect(result.stderr).toContain('Use a separate project-local skills directory');
expect(tree(f.dir)).toEqual(before);
}, 90_000);
for (const localLegacy of [false, true]) for (const marker of ['current', '1.85.0.0']) test(`excluded global legacy render survives local migration=${localLegacy}, marker=${marker} and generation`, () => {
const f = fixture('.claude');
const oldRender = join(f.source, '.agents/skills/gstack-claude');
fixtureMkdirSync(oldRender, { recursive: true });
const oldBytes = '---\nname: gstack-claude\n---\n<!-- AUTO-GENERATED from SKILL.md.tmpl — do not edit directly -->\n<!-- Regenerate: bun run gen:skill-docs -->\nExisting legacy global workflow.\n';
fixtureWriteFileSync(join(oldRender, 'SKILL.md'), oldBytes);
rmSync(join(f.global, 'gstack-claude'), { recursive: true });
symlinkSync(oldRender, join(f.global, 'gstack-claude'), 'dir');
for (const rel of ['bin', 'lib']) {
const target = join(f.global, 'gstack', rel);
expect(lstatSync(target).isSymbolicLink()).toBe(true);
rmSync(target);
fixtureMkdirSync(target);
fixtureWriteFileSync(join(target, 'prior'), 'Existing copied global runtime.\n');
}
const local = join(f.project, '.agents/skills');
if (localLegacy) {
fixtureMkdirSync(local, { recursive: true });
symlinkSync(oldRender, join(local, 'gstack-claude'), 'dir');
}
fixtureWriteFileSync(join(f.home, '.gstack/.last-setup-version'), marker === 'current' ? readFileSync(join(f.source, 'VERSION')) : marker);
const before = tree(f.global);
install(f);
expect(tree(f.global)).toEqual(before);
expect(readFileSync(join(f.global, 'gstack-claude/SKILL.md'), 'utf8')).toBe(oldBytes);
expect(realpathSync(join(local, 'gstack-claude-code/SKILL.md'))).toBe(join(f.source, '.agents/skills/gstack-claude-code/SKILL.md'));
expect(lstatSync(join(local, 'gstack-claude'), { throwIfNoEntry: false })).toBeUndefined();
}, 90_000);
for (const nested of [false, true]) for (const global of [false, true]) for (const windows of [false, true]) {
test(`named ${nested ? 'ancestor' : 'source'} overlap: logical local=${!global}, Windows=${windows}`, () => {
const f = fixture('.claude');
const physical = join(f.project, '.agents/skills/gstack-review', ...(nested ? ['checkout'] : []));
fixtureMkdirSync(physical, { recursive: true });
cpSync(f.source, physical, { recursive: true, verbatimSymlinks: true, preserveTimestamps: true });
rmSync(f.source, { recursive: true });
symlinkSync(physical, f.source, 'dir');
if (nested) fixtureWriteFileSync(join(dirname(physical), 'SKILL.md'), '<!-- AUTO-GENERATED from SKILL.md.tmpl — do not edit directly -->\n<!-- Regenerate: bun run gen:skill-docs -->\nPrior render.\n');
fixtureWriteFileSync(join(physical, 'uncommitted-proof'), 'Do not erase this checkout.\n');
if (windows) {
fixtureWriteFileSync(join(f.dir, 'commands/uname'), '#!/bin/sh\nprintf "MINGW64_NT-10.0\\n"\n', { mode: 0o755 });
for (const rel of ['browse/dist/browse.exe', 'design/dist/design.exe', 'make-pdf/dist/pdf.exe']) {
fixtureCopyFileSync(join(physical, rel.replace('.exe', '')), join(physical, rel));
fixtureUtimesSync(join(physical, rel), new Date('2040-01-01'), new Date('2040-01-01'));
}
}
const env = global ? { ...f.env, CODEX_HOME: join(f.project, '.agents') } : f.env;
const before = tree(f.dir);
const result = spawnSync('bash', [join(f.source, 'setup'), '--host', 'codex', ...(global ? ['--global'] : []), '--no-team', '--no-plan-tune-hooks', '--no-timeline-stop-hook'], {
cwd: f.other, env, encoding: 'utf8', timeout: 60_000,
});
if (windows) {
expect(result.status, result.stdout + result.stderr).toBe(1);
expect(result.stderr).toContain('Codex skill copy replacement overlaps source');
expect(tree(f.dir)).toEqual(before);
} else {
expect(result.status, result.stdout + result.stderr).toBe(0);
expect(readFileSync(join(physical, 'uncommitted-proof'), 'utf8')).toBe('Do not erase this checkout.\n');
}
}, 90_000);
}
for (const target of ['source', 'project', 'root-sidecar']) for (const host of target === 'root-sidecar' ? ['codex'] : ['codex', 'claude']) test(`generated namespace alias to ${target} refuses before writes, host=${host}`, () => {
const f = fixture('ordinary');
const render = join(f.source, '.agents/skills');
fixtureMkdirSync(render, { recursive: true });
const alias = join(render, target === 'root-sidecar' ? 'gstack' : 'gstack-review');
const destination = target === 'project' ? join(f.project, 'ordinary-review') : f.source;
if (target === 'project') {
fixtureMkdirSync(destination);
fixtureWriteFileSync(join(destination, 'SKILL.md'), 'Project-owned content.\n');
}
symlinkSync(destination, alias, 'dir');
if (target === 'root-sidecar') fixtureWriteFileSync(join(f.dir, 'commands/uname'), '#!/bin/sh\nprintf "MINGW64_NT-10.0\\n"\n', { mode: 0o755 });
const before = tree(f.dir);
const result = spawnSync('bash', [join(f.source, 'setup'), '--host', host, '--no-team', '--no-plan-tune-hooks', '--no-timeline-stop-hook'], {
cwd: f.other, env: f.env, encoding: 'utf8', timeout: 60_000,
});
expect(result.status, result.stdout + result.stderr).toBe(1);
expect(result.stderr).toContain(target === 'root-sidecar' ? 'Codex sidecar' : 'Codex generated skill write');
expect(tree(f.dir)).toEqual(before);
}, 90_000);
for (const target of ['absolute-generation-alias', 'occupied-relocation']) for (const windows of [false, true]) {
test(`direct global checkout preflights ${target} before moving, Windows=${windows}`, () => {
const f = fixture('ordinary');
const direct = join(f.global, 'gstack');
rmSync(direct, { recursive: true });
cpSync(f.source, direct, { recursive: true, verbatimSymlinks: true, preserveTimestamps: true });
fixtureWriteFileSync(join(direct, 'uncommitted-proof'), 'Keep source checkout.\n');
if (target === 'absolute-generation-alias') {
const generated = join(direct, 'generated-codex');
fixtureMkdirSync(generated);
symlinkSync(generated, join(direct, '.agents'), 'dir');
} else {
fixtureMkdirSync(join(f.home, '.gstack/repos'), { recursive: true });
symlinkSync(direct, join(f.home, '.gstack/repos/gstack'), 'dir');
}
if (windows) fixtureWriteFileSync(join(f.dir, 'commands/uname'), '#!/bin/sh\nprintf "MINGW64_NT-10.0\\n"\n', { mode: 0o755 });
const before = tree(f.dir);
const result = spawnSync('bash', [join(direct, 'setup'), '--host', 'codex', '--no-team', '--no-plan-tune-hooks', '--no-timeline-stop-hook'], {
cwd: f.other, env: f.env, encoding: 'utf8', timeout: 60_000,
});
expect(result.status, result.stdout + result.stderr).toBe(1);
expect(result.stderr).toContain(target === 'absolute-generation-alias' ? 'post-relocation generation namespace' : 'checkout relocation');
expect(tree(f.dir)).toEqual(before);
}, 90_000);
}
test('Claude-only setup cannot prune a bannered stale skill inside its source checkout', () => {
const f = fixture('ordinary');
const skill = join(f.source, 'skills/gstack-obsolete/SKILL.md');
fixtureMkdirSync(dirname(skill), { recursive: true });
fixtureWriteFileSync(skill, '<!-- AUTO-GENERATED from SKILL.md.tmpl — do not edit directly -->\n<!-- Regenerate: bun run gen:skill-docs -->\nPreserve source content.\n');
const env = { ...f.env, CODEX_HOME: f.source };
const before = tree(f.dir);
const result = spawnSync('bash', [join(f.source, 'setup'), '--host', 'claude', '--no-team', '--no-plan-tune-hooks', '--no-timeline-stop-hook'], {
cwd: f.other, env, encoding: 'utf8', timeout: 60_000,
});
expect(result.status, result.stdout + result.stderr).toBe(1);
expect(result.stderr).toContain('Codex stale host cleanup');
expect(tree(f.dir)).toEqual(before);
}, 90_000);
test('a dangling owned legacy Codex link reaches the rename migration', () => {
const f = fixture('ordinary');
const old = join(f.global, 'gstack-claude');
rmSync(old, { recursive: true });
symlinkSync(join(f.source, '.agents/skills/gstack-claude'), old, 'dir');
for (const rel of ['bin', 'lib']) {
const target = join(f.global, 'gstack', rel);
rmSync(target);
symlinkSync(join(f.source, rel), target, 'dir');
}
install(f);
expect(lstatSync(old, { throwIfNoEntry: false })).toBeUndefined();
expect(readFileSync(join(f.global, 'gstack-claude-code/SKILL.md'), 'utf8')).toContain('name: claude-code');
expect(readFileSync(join(f.global, 'custom/SKILL.md'), 'utf8')).toBe('User-owned skill.\n');
}, 90_000);
for (const explicit of [false, true]) for (const windows of [false, true]) {
test(`global handwritten runtime is refused before writes, explicit=${explicit}, Windows=${windows}`, () => {
const f = fixture(explicit ? '.claude' : 'ordinary');
const runtime = join(f.global, 'gstack');
rmSync(runtime, { recursive: true });
fixtureMkdirSync(runtime);
fixtureWriteFileSync(join(runtime, 'SKILL.md'), 'A handwritten root skill.\n');
fixtureWriteFileSync(join(runtime, 'user-notes'), 'Keep these notes.\n');
if (windows) fixtureWriteFileSync(join(f.dir, 'commands/uname'), '#!/bin/sh\nprintf "MINGW64_NT-10.0\\n"\n', { mode: 0o755 });
const before = tree(f.dir);
const result = spawnSync('bash', [join(f.source, 'setup'), '--host', 'codex', ...(explicit ? ['--global'] : []), '--no-team', '--no-plan-tune-hooks', '--no-timeline-stop-hook'], {
cwd: f.other, env: f.env, encoding: 'utf8', timeout: 60_000,
});
expect(result.status, result.stdout + result.stderr).toBe(1);
expect(result.stderr).toContain(`global Codex runtime ${runtime} is a real user-owned skill`);
expect(result.stderr).toContain('choose another CODEX_HOME');
expect(tree(f.dir)).toEqual(before);
}, 90_000);
}
for (const prior of ['managed', 'partial']) test(`global ${prior} runtime remains refreshable`, () => {
const f = fixture('ordinary');
const runtime = join(f.global, 'gstack');
rmSync(runtime, { recursive: true });
fixtureMkdirSync(runtime);
if (prior === 'managed') fixtureWriteFileSync(join(runtime, 'SKILL.md'), '<!-- AUTO-GENERATED from SKILL.md.tmpl — do not edit directly -->\n<!-- Regenerate: bun run gen:skill-docs -->\nPrior runtime.\n');
fixtureWriteFileSync(join(runtime, 'prior-asset'), 'A previous runtime asset.\n');
install(f);
install(f);
expect(existsSync(join(runtime, 'prior-asset'))).toBe(false);
expect(readFileSync(join(runtime, 'SKILL.md'), 'utf8')).toContain('<!-- AUTO-GENERATED from');
expect(realpathSync(join(runtime, 'bin'))).toBe(join(f.source, 'bin'));
}, 90_000);
test('Claude-only setup leaves a global handwritten runtime untouched', () => {
const f = fixture('ordinary');
const runtime = join(f.global, 'gstack');
rmSync(runtime, { recursive: true });
fixtureMkdirSync(runtime);
fixtureWriteFileSync(join(runtime, 'SKILL.md'), 'A handwritten root skill.\n');
fixtureWriteFileSync(join(runtime, 'user-notes'), 'Keep these notes.\n');
const before = tree(runtime);
const result = spawnSync('bash', [join(f.source, 'setup'), '--host', 'claude', '--no-team', '--no-plan-tune-hooks', '--no-timeline-stop-hook'], {
cwd: f.other, env: f.env, encoding: 'utf8', timeout: 60_000,
});
expect(result.status, result.stdout + result.stderr).toBe(0);
expect(tree(runtime)).toEqual(before);
}, 90_000);
test('Unix rename does not overwrite a source checkout registered as another skill', () => {
const f = fixture('ordinary');
const source = join(f.global, 'gstack-review');
rmSync(source);
cpSync(f.source, source, { recursive: true, preserveTimestamps: true, verbatimSymlinks: true });
fixtureWriteFileSync(join(source, 'uncommitted-proof'), 'Keep this source.\n');
for (const rel of ['bin', 'lib']) {
const asset = join(f.global, 'gstack', rel);
rmSync(asset);
fixtureMkdirSync(asset);
}
const before = tree(f.dir);
const result = spawnSync('bash', [join(source, 'setup'), '--host', 'codex', '--no-team', '--no-plan-tune-hooks', '--no-timeline-stop-hook'], {
cwd: f.other, env: f.env, encoding: 'utf8', timeout: 60_000,
});
expect(result.status, result.stdout + result.stderr).toBe(1);
expect(result.stderr).toContain('Codex rename workflow write');
expect(tree(f.dir)).toEqual(before);
}, 90_000);
for (const alias of ['SKILL leaf', 'metadata parent']) test(`direct relocation refuses a generated absolute ${alias} before moving source`, () => {
const f = fixture('ordinary');
const direct = join(f.global, 'gstack');
rmSync(direct, { recursive: true });
cpSync(f.source, direct, { recursive: true, preserveTimestamps: true, verbatimSymlinks: true });
fixtureWriteFileSync(join(direct, 'uncommitted-proof'), 'Keep this source.\n');
const generated = join(direct, '.agents/skills/gstack-review');
fixtureMkdirSync(generated, { recursive: true });
if (alias === 'SKILL leaf') {
fixtureWriteFileSync(join(generated, 'saved.md'), '<!-- AUTO-GENERATED from SKILL.md.tmpl -->\nPrior render.\n');
symlinkSync(join(generated, 'saved.md'), join(generated, 'SKILL.md'));
} else {
const prior = join(direct, '.agents/skills/gstack-office-hours/agents');
fixtureMkdirSync(prior, { recursive: true });
symlinkSync(prior, join(generated, 'agents'), 'dir');
}
const before = tree(f.dir);
const result = spawnSync('bash', [join(direct, 'setup'), '--host', 'codex', '--no-team', '--no-plan-tune-hooks', '--no-timeline-stop-hook'], {
cwd: f.other, env: f.env, encoding: 'utf8', timeout: 60_000,
});
expect(result.status, result.stdout + result.stderr).toBe(1);
expect(result.stderr).toContain('post-relocation generated alias');
expect(tree(f.dir)).toEqual(before);
}, 90_000);
test('managed rename detaches a metadata parent link without touching its source', () => {
const f = fixture('ordinary');
const installed = join(f.global, 'gstack-review');
rmSync(installed);
fixtureMkdirSync(installed);
fixtureWriteFileSync(join(installed, 'SKILL.md'), '<!-- AUTO-GENERATED from SKILL.md.tmpl — do not edit directly -->\n<!-- Regenerate: bun run gen:skill-docs -->\nPrior installed review.\n');
const sourceAgents = join(f.source, '.agents/skills/gstack-review/agents');
fixtureMkdirSync(sourceAgents, { recursive: true });
fixtureWriteFileSync(join(dirname(sourceAgents), 'SKILL.md'), '<!-- AUTO-GENERATED from SKILL.md.tmpl — do not edit directly -->\n<!-- Regenerate: bun run gen:skill-docs -->\nPrior canonical review.\n');
fixtureWriteFileSync(join(sourceAgents, 'user-notes'), 'Preserve source metadata.\n');
symlinkSync(sourceAgents, join(installed, 'agents'), 'dir');
for (const rel of ['bin', 'lib']) {
const asset = join(f.global, 'gstack', rel);
rmSync(asset);
symlinkSync(join(f.source, rel), asset, 'dir');
}
const result = install(f);
expect(result.stderr).toContain('migrated 1 installed skill');
expect(lstatSync(join(installed, 'agents')).isDirectory()).toBe(true);
expect(readFileSync(join(sourceAgents, 'user-notes'), 'utf8')).toBe('Preserve source metadata.\n');
expect(existsSync(join(installed, 'agents/openai.yaml'))).toBe(true);
}, 90_000);
});
describe.skipIf(process.platform === 'win32')('F13 independent review boundaries', () => {
for (const target of ['legacy', 'replacement', 'legacy-skill', 'runtime', 'runtime-bin', 'runtime-lib']) {
test(`Claude-only setup preserves cyclic foreign ownership link: ${target}`, () => {
const f = fixture('ordinary');
rmSync(join(f.global, 'gstack-retired'));
for (const rel of ['bin', 'lib']) {
const asset = join(f.global, 'gstack', rel);
rmSync(asset);
symlinkSync(join(f.source, rel), asset, 'dir');
}
const old = join(f.global, 'gstack-claude');
rmSync(old, { recursive: true });
const oldRender = join(f.source, '.agents/skills/gstack-claude');
fixtureMkdirSync(oldRender, { recursive: true });
fixtureWriteFileSync(join(oldRender, 'SKILL.md'), '<!-- AUTO-GENERATED from SKILL.md.tmpl -->\n<!-- Regenerate: bun run gen:skill-docs -->\nPrior legacy render.\n');
symlinkSync(oldRender, old, 'dir');
const link = target === 'legacy' ? old
: target === 'replacement' ? join(f.global, 'gstack-claude-code')
: target === 'legacy-skill' ? join(old, 'SKILL.md')
: target === 'runtime' ? join(f.global, 'gstack')
: join(f.global, 'gstack', target === 'runtime-bin' ? 'bin' : 'lib');
if (target === 'legacy-skill') {
rmSync(old);
fixtureMkdirSync(old);
}
if (existsSync(link) || lstatSync(link, { throwIfNoEntry: false })) rmSync(link, { recursive: true });
symlinkSync(link, link);
const before = tree(f.global);
for (let run = 0; run < 2; run++) {
install(f, '--host claude');
expect(tree(f.global)).toEqual(before);
}
}, 90_000);
}
for (const alias of [false, true]) for (const windows of [false, true]) {
test(`global generation namespace is refused before mutation: alias=${alias}, Windows=${windows}`, () => {
const f = fixture('ordinary');
const agents = join(f.source, '.agents');
fixtureMkdirSync(agents, { recursive: true });
const codexHome = alias ? join(f.home, 'generation-alias') : agents;
if (alias) symlinkSync(agents, codexHome, 'dir');
if (windows) {
fixtureWriteFileSync(join(f.dir, 'commands/uname'), '#!/bin/sh\nprintf "MINGW64_NT-10.0\n"\n', { mode: 0o755 });
for (const rel of ['browse/dist/browse.exe', 'design/dist/design.exe', 'make-pdf/dist/pdf.exe']) {
fixtureCopyFileSync(join(f.source, rel.replace('.exe', '')), join(f.source, rel));
fixtureUtimesSync(join(f.source, rel), new Date('2040-01-01'), new Date('2040-01-01'));
}
}
const before = tree(f.dir);
for (let run = 0; run < 2; run++) {
const result = spawnSync('bash', [join(f.source, 'setup'), '--host', 'codex', '--no-team', '--no-plan-tune-hooks', '--no-timeline-stop-hook'], {
cwd: f.other, env: { ...f.env, CODEX_HOME: codexHome }, encoding: 'utf8', timeout: 60_000,
});
expect(result.status, result.stdout + result.stderr).toBe(1);
expect(result.stderr).toContain('host namespace');
expect(tree(f.dir)).toEqual(before);
}
}, 90_000);
}
for (const leaf of ['SKILL.md', 'agents']) {
test(`selected generated cyclic write remains fail-closed: ${leaf}`, () => {
const f = fixture('ordinary');
const generated = join(f.source, '.agents/skills/gstack-review');
fixtureMkdirSync(generated, { recursive: true });
const link = join(generated, leaf);
symlinkSync(link, link);
const before = tree(f.dir);
for (let run = 0; run < 2; run++) {
const result = spawnSync('bash', [join(f.source, 'setup'), '--host', 'codex', '--no-team', '--no-plan-tune-hooks', '--no-timeline-stop-hook'], {
cwd: f.other, env: f.env, encoding: 'utf8', timeout: 60_000,
});
expect(result.status, result.stdout + result.stderr).toBe(1);
expect(result.stderr).toContain('ELOOP');
expect(tree(f.dir)).toEqual(before);
}
}, 90_000);
}
}); });
+11 -11
View File
@@ -110,14 +110,14 @@ describeIfSelected('LLM-as-judge quality evals', [
name: 'command reference table', name: 'command reference table',
suite: 'LLM-as-judge quality evals', suite: 'LLM-as-judge quality evals',
tier: 'llm-judge', tier: 'llm-judge',
passed: scores.clarity >= 4 && scores.completeness >= 3 && scores.actionability >= 4, passed: scores.clarity >= 3 && scores.completeness >= 3 && scores.actionability >= 4,
duration_ms: Date.now() - t0, duration_ms: Date.now() - t0,
cost_usd: 0.02, cost_usd: 0.02,
judge_scores: { clarity: scores.clarity, completeness: scores.completeness, actionability: scores.actionability }, judge_scores: { clarity: scores.clarity, completeness: scores.completeness, actionability: scores.actionability },
judge_reasoning: scores.reasoning, judge_reasoning: scores.reasoning,
}); });
expect(scores.clarity).toBeGreaterThanOrEqual(4); expect(scores.clarity).toBeGreaterThanOrEqual(3);
expect(scores.completeness).toBeGreaterThanOrEqual(3); expect(scores.completeness).toBeGreaterThanOrEqual(3);
expect(scores.actionability).toBeGreaterThanOrEqual(4); expect(scores.actionability).toBeGreaterThanOrEqual(4);
}, JUDGE_MS); }, JUDGE_MS);
@@ -136,14 +136,14 @@ describeIfSelected('LLM-as-judge quality evals', [
name: 'snapshot flags reference', name: 'snapshot flags reference',
suite: 'LLM-as-judge quality evals', suite: 'LLM-as-judge quality evals',
tier: 'llm-judge', tier: 'llm-judge',
passed: scores.clarity >= 4 && scores.completeness >= 4 && scores.actionability >= 4, passed: scores.clarity >= 3 && scores.completeness >= 4 && scores.actionability >= 4,
duration_ms: Date.now() - t0, duration_ms: Date.now() - t0,
cost_usd: 0.02, cost_usd: 0.02,
judge_scores: { clarity: scores.clarity, completeness: scores.completeness, actionability: scores.actionability }, judge_scores: { clarity: scores.clarity, completeness: scores.completeness, actionability: scores.actionability },
judge_reasoning: scores.reasoning, judge_reasoning: scores.reasoning,
}); });
expect(scores.clarity).toBeGreaterThanOrEqual(4); expect(scores.clarity).toBeGreaterThanOrEqual(3);
expect(scores.completeness).toBeGreaterThanOrEqual(4); expect(scores.completeness).toBeGreaterThanOrEqual(4);
expect(scores.actionability).toBeGreaterThanOrEqual(4); expect(scores.actionability).toBeGreaterThanOrEqual(4);
}, JUDGE_MS); }, JUDGE_MS);
@@ -160,14 +160,14 @@ describeIfSelected('LLM-as-judge quality evals', [
name: 'browse/SKILL.md reference', name: 'browse/SKILL.md reference',
suite: 'LLM-as-judge quality evals', suite: 'LLM-as-judge quality evals',
tier: 'llm-judge', tier: 'llm-judge',
passed: scores.clarity >= 4 && scores.completeness >= 4 && scores.actionability >= 4, passed: scores.clarity >= 3 && scores.completeness >= 4 && scores.actionability >= 4,
duration_ms: Date.now() - t0, duration_ms: Date.now() - t0,
cost_usd: 0.02, cost_usd: 0.02,
judge_scores: { clarity: scores.clarity, completeness: scores.completeness, actionability: scores.actionability }, judge_scores: { clarity: scores.clarity, completeness: scores.completeness, actionability: scores.actionability },
judge_reasoning: scores.reasoning, judge_reasoning: scores.reasoning,
}); });
expect(scores.clarity).toBeGreaterThanOrEqual(4); expect(scores.clarity).toBeGreaterThanOrEqual(3);
expect(scores.completeness).toBeGreaterThanOrEqual(4); expect(scores.completeness).toBeGreaterThanOrEqual(4);
expect(scores.actionability).toBeGreaterThanOrEqual(4); expect(scores.actionability).toBeGreaterThanOrEqual(4);
}, JUDGE_MS); }, JUDGE_MS);
@@ -349,14 +349,14 @@ ${section}`);
name: 'qa/SKILL.md workflow', name: 'qa/SKILL.md workflow',
suite: 'QA skill quality evals', suite: 'QA skill quality evals',
tier: 'llm-judge', tier: 'llm-judge',
passed: scores.clarity >= 4 && scores.completeness >= 3 && scores.actionability >= 4, passed: scores.clarity >= 3 && scores.completeness >= 3 && scores.actionability >= 4,
duration_ms: Date.now() - t0, duration_ms: Date.now() - t0,
cost_usd: 0.02, cost_usd: 0.02,
judge_scores: { clarity: scores.clarity, completeness: scores.completeness, actionability: scores.actionability }, judge_scores: { clarity: scores.clarity, completeness: scores.completeness, actionability: scores.actionability },
judge_reasoning: scores.reasoning, judge_reasoning: scores.reasoning,
}); });
expect(scores.clarity).toBeGreaterThanOrEqual(4); expect(scores.clarity).toBeGreaterThanOrEqual(3);
// Completeness scores 3 when judge notes the health rubric is in a separate // Completeness scores 3 when judge notes the health rubric is in a separate
// section (the eval only passes the Workflow section, not the full document). // section (the eval only passes the Workflow section, not the full document).
expect(scores.completeness).toBeGreaterThanOrEqual(3); expect(scores.completeness).toBeGreaterThanOrEqual(3);
@@ -391,14 +391,14 @@ ${section}`);
name: 'qa/SKILL.md health rubric', name: 'qa/SKILL.md health rubric',
suite: 'QA skill quality evals', suite: 'QA skill quality evals',
tier: 'llm-judge', tier: 'llm-judge',
passed: scores.clarity >= 4 && scores.completeness >= 3 && scores.actionability >= 4, passed: scores.clarity >= 3 && scores.completeness >= 3 && scores.actionability >= 4,
duration_ms: Date.now() - t0, duration_ms: Date.now() - t0,
cost_usd: 0.02, cost_usd: 0.02,
judge_scores: { clarity: scores.clarity, completeness: scores.completeness, actionability: scores.actionability }, judge_scores: { clarity: scores.clarity, completeness: scores.completeness, actionability: scores.actionability },
judge_reasoning: scores.reasoning, judge_reasoning: scores.reasoning,
}); });
expect(scores.clarity).toBeGreaterThanOrEqual(4); expect(scores.clarity).toBeGreaterThanOrEqual(3);
expect(scores.completeness).toBeGreaterThanOrEqual(3); expect(scores.completeness).toBeGreaterThanOrEqual(3);
expect(scores.actionability).toBeGreaterThanOrEqual(4); expect(scores.actionability).toBeGreaterThanOrEqual(4);
}, JUDGE_MS); }, JUDGE_MS);
@@ -664,7 +664,7 @@ async function runWorkflowJudge(opts: {
// Timeout/retry finalizes once; late provider continuations cannot publish evidence. // Timeout/retry finalizes once; late provider continuations cannot publish evidence.
const work = async () => { const work = async () => {
checkActive(); checkActive();
const thresholds = { clarity: 4, completeness: 3, actionability: 4, ...opts.thresholds }; const thresholds = { clarity: 3, completeness: 3, actionability: 4, ...opts.thresholds };
const input = opts.readInput ? opts.readInput() : readWorkflowJudgeInput({ root: ROOT, skillPath: opts.skillPath, const input = opts.readInput ? opts.readInput() : readWorkflowJudgeInput({ root: ROOT, skillPath: opts.skillPath,
startMarker: opts.startMarker, endMarker: opts.endMarker }); startMarker: opts.startMarker, endMarker: opts.endMarker });
checkActive(); checkActive();
+40 -13
View File
@@ -6,7 +6,7 @@
* main() — these tests pin its two load-bearing inputs: * main() — these tests pin its two load-bearing inputs:
* *
* 1. fullSuiteJobs(): env override wins, is deliberately UNclamped by * 1. fullSuiteJobs(): env override wins, is deliberately UNclamped by
* MAX_FULL_SUITE_JOBS, and rejects garbage loudly (a silent fallback to * the per-platform cap, and rejects garbage loudly (a silent fallback to
* the default would saturate the sandbox's seccomp supervisor — the * the default would saturate the sandbox's seccomp supervisor — the
* exact failure the knob exists to prevent). * exact failure the knob exists to prevent).
* 2. FreeShardOutcome.failingFiles: empty on pass, attributed files on * 2. FreeShardOutcome.failingFiles: empty on pass, attributed files on
@@ -20,6 +20,8 @@ import { readFileSync } from 'node:fs';
import { import {
fullSuiteJobs, fullSuiteJobs,
MAX_FULL_SUITE_JOBS, MAX_FULL_SUITE_JOBS,
MAX_LINUX_FULL_SUITE_JOBS,
maxFullSuiteJobs,
runFreeShard, runFreeShard,
} from '../scripts/test-free-shards'; } from '../scripts/test-free-shards';
@@ -38,20 +40,31 @@ function withJobsEnv<T>(value: string | undefined, fn: () => T): T {
describe('test-free-shards: fullSuiteJobs (GSTACK_FREE_JOBS override)', () => { describe('test-free-shards: fullSuiteJobs (GSTACK_FREE_JOBS override)', () => {
test('unset and empty string both take the computed default — available CPUs, capped, floor 1', () => { test('unset and empty string both take the computed default — available CPUs, capped, floor 1', () => {
const expected = Math.max(1, Math.min(MAX_FULL_SUITE_JOBS, os.availableParallelism?.() ?? os.cpus().length)); const expected = Math.max(1, Math.min(maxFullSuiteJobs(), os.availableParallelism?.() ?? os.cpus().length));
expect(withJobsEnv(undefined, fullSuiteJobs)).toBe(expected); expect(withJobsEnv(undefined, fullSuiteJobs)).toBe(expected);
// A stray `export GSTACK_FREE_JOBS=` must not throw. // A stray `export GSTACK_FREE_JOBS=` must not throw.
expect(withJobsEnv('', fullSuiteJobs)).toBe(expected); expect(withJobsEnv('', fullSuiteJobs)).toBe(expected);
}); });
test.each([[0, 1], [1, 1], [2, 2], [4, 4], [8, 6]])( test('Linux caps at 16 shard processes; macOS and Windows keep the cap of 6', () => {
'%i available CPUs default to %i serial shard processes regardless of host CPU count', expect(maxFullSuiteJobs('linux')).toBe(MAX_LINUX_FULL_SUITE_JOBS);
(availableCpus, expected) => { expect(MAX_LINUX_FULL_SUITE_JOBS).toBe(16);
expect(maxFullSuiteJobs('darwin')).toBe(MAX_FULL_SUITE_JOBS);
expect(maxFullSuiteJobs('win32')).toBe(MAX_FULL_SUITE_JOBS);
expect(MAX_FULL_SUITE_JOBS).toBe(6);
});
test.each([
['darwin', 0, 1], ['darwin', 1, 1], ['darwin', 2, 2], ['darwin', 4, 4], ['darwin', 8, 6],
['linux', 0, 1], ['linux', 4, 4], ['linux', 8, 8], ['linux', 16, 16], ['linux', 64, 16],
] as const)(
'%s: %i available CPUs default to %i serial shard processes regardless of host CPU count',
(platform, availableCpus, expected) => {
const available = spyOn(os, 'availableParallelism').mockReturnValue(availableCpus); const available = spyOn(os, 'availableParallelism').mockReturnValue(availableCpus);
const cpus = spyOn(os, 'cpus').mockReturnValue(Array<os.CpuInfo>(16)); const cpus = spyOn(os, 'cpus').mockReturnValue(Array<os.CpuInfo>(64));
try { try {
expect(withJobsEnv(undefined, fullSuiteJobs)).toBe(expected); expect(withJobsEnv(undefined, () => fullSuiteJobs(platform))).toBe(expected);
expect(withJobsEnv('', fullSuiteJobs)).toBe(expected); expect(withJobsEnv('', () => fullSuiteJobs(platform))).toBe(expected);
expect(cpus).not.toHaveBeenCalled(); expect(cpus).not.toHaveBeenCalled();
} finally { } finally {
available.mockRestore(); available.mockRestore();
@@ -73,14 +86,14 @@ describe('test-free-shards: fullSuiteJobs (GSTACK_FREE_JOBS override)', () => {
})); }));
const { fullSuiteJobs } = await import(${JSON.stringify(import.meta.resolve('../scripts/test-free-shards'))}); const { fullSuiteJobs } = await import(${JSON.stringify(import.meta.resolve('../scripts/test-free-shards'))});
delete process.env.GSTACK_FREE_JOBS; delete process.env.GSTACK_FREE_JOBS;
console.log(JSON.stringify([0, 1, 2, 4, 8].map(count => { console.log(JSON.stringify([0, 1, 2, 4, 8, 32].map(count => {
cpuCount = count; cpuCount = count;
return fullSuiteJobs(); return [fullSuiteJobs('darwin'), fullSuiteJobs('linux')];
}))); })));
`], { timeout: 10_000 }); `], { timeout: 10_000 });
expect(result.exitCode).toBe(0); expect(result.exitCode).toBe(0);
expect(result.stderr.toString()).toBe(''); expect(result.stderr.toString()).toBe('');
expect(JSON.parse(result.stdout.toString())).toEqual([1, 1, 2, 4, 6]); expect(JSON.parse(result.stdout.toString())).toEqual([[1, 1], [1, 1], [2, 2], [4, 4], [6, 8], [6, 16]]);
}); });
test('a positive integer override is honored exactly (the sandbox recipe sets 2)', () => { test('a positive integer override is honored exactly (the sandbox recipe sets 2)', () => {
@@ -100,6 +113,20 @@ describe('test-free-shards: fullSuiteJobs (GSTACK_FREE_JOBS override)', () => {
expect(withJobsEnv(step?.env?.GSTACK_FREE_JOBS, fullSuiteJobs)).toBe(2); expect(withJobsEnv(step?.env?.GSTACK_FREE_JOBS, fullSuiteJobs)).toBe(2);
}); });
test('Windows CI retries attributed flakes serially and uploads every flaky pass', () => {
const source = readFileSync(new URL('../.github/workflows/windows-free-tests.yml', import.meta.url), 'utf8');
const workflow = Bun.YAML.parse(source) as {
jobs: Record<string, { steps: Array<{ name?: string; if?: string; run?: string; env?: Record<string, string>; with?: Record<string, unknown> }> }>;
};
const steps = workflow.jobs['windows-free-tests'].steps;
const suite = steps.find(step => step.run === 'bun run test:windows');
expect(suite?.env?.GSTACK_FREE_RETRY_FLAKY).toBe('1');
expect(suite?.env?.GSTACK_FLAKE_LEDGER).toBe('${{ runner.temp }}/flake-ledger.jsonl');
const upload = steps.find(step => step.name === 'Upload flake ledger');
expect(upload?.if).toBe('always()');
expect(upload?.with?.path).toBe('${{ runner.temp }}/flake-ledger.jsonl');
});
test('an explicit override does not probe the available CPUs', () => { test('an explicit override does not probe the available CPUs', () => {
const available = spyOn(os, 'availableParallelism').mockImplementation(() => { const available = spyOn(os, 'availableParallelism').mockImplementation(() => {
throw new Error('CPU detection must not run for an explicit override'); throw new Error('CPU detection must not run for an explicit override');
@@ -118,8 +145,8 @@ describe('test-free-shards: fullSuiteJobs (GSTACK_FREE_JOBS override)', () => {
} }
}); });
test('override is deliberately NOT clamped by MAX_FULL_SUITE_JOBS (beefy boxes may raise it)', () => { test('override is deliberately NOT clamped by the platform cap (beefy boxes may raise it)', () => {
const above = MAX_FULL_SUITE_JOBS + 6; const above = MAX_LINUX_FULL_SUITE_JOBS + 6;
expect(withJobsEnv(String(above), fullSuiteJobs)).toBe(above); expect(withJobsEnv(String(above), fullSuiteJobs)).toBe(above);
}); });
+17
View File
@@ -32,6 +32,7 @@ import {
verifyFreeCiResults, verifyFreeCiResults,
eligibleFreeRetryFiles, eligibleFreeRetryFiles,
selectQuickFreeFiles, selectQuickFreeFiles,
unseededFreeFiles,
QUICK_CORE, QUICK_CORE,
type FreeCiResult, type FreeCiResult,
DEFAULT_WALL_TIMEOUT_MS as WALL_BASE_MS, DEFAULT_WALL_TIMEOUT_MS as WALL_BASE_MS,
@@ -846,6 +847,22 @@ describe('test-free-shards: duration-aware packing (full-suite LPT)', () => {
expect(() => packShardsByDuration(files, 0, {})).toThrow(); expect(() => packShardsByDuration(files, 0, {})).toThrow();
}); });
test('files missing from the seed are named, and --ci-plan warns on stderr without touching the matrix', () => {
expect(unseededFreeFiles(files, { 'test/a.test.ts': 1, 'test/c.test.ts': 1 })).toEqual(['test/b.test.ts', 'test/d.test.ts']);
expect(unseededFreeFiles(files, Object.fromEntries(files.map(f => [f, 1])))).toEqual([]);
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'free-seed-drift-'));
try {
const seedPath = path.join(dir, 'seed.json');
fs.writeFileSync(seedPath, JSON.stringify({ version: 1, durations: { 'test/strict-output.test.ts': 1_000 } }));
const planned = Bun.spawnSync([process.execPath, path.join(ROOT, 'scripts/test-free-shards.ts'), '--ci-plan', path.join(dir, 'plan.json'), '--shards', '2'], {
env: { ...process.env, GSTACK_FREE_TEST_DURATIONS: seedPath }, timeout: 10_000,
});
expect(planned.exitCode, planned.stderr.toString()).toBe(0);
expect(JSON.parse(planned.stdout.toString())).toEqual({ shard: [1, 2] });
expect(planned.stderr.toString()).toMatch(/\d+ file\(s\) have no recorded duration .*bun run test:ubicloud --record-durations/);
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
});
test('corrupt seed falls back to null (hash sharding), never throws', () => { test('corrupt seed falls back to null (hash sharding), never throws', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'durations-seed-')); const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'durations-seed-'));
const seedPath = path.join(dir, 'seed.json'); const seedPath = path.join(dir, 'seed.json');
+63
View File
@@ -0,0 +1,63 @@
/**
* Static and offline pins for `bun run test:ubicloud` (scripts/ubicloud/).
* The VM path needs a Ubicloud token and costs money, so it is exercised by
* hand; these checks keep its environment from drifting away from the
* required CI free lane it mirrors, and prove it fails before any network
* call when the token is absent.
*/
import { describe, expect, test } from 'bun:test';
import { readFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
const ROOT = resolve(import.meta.dir, '..');
const DIR = join(ROOT, 'scripts/ubicloud');
const read = (rel: string) => readFileSync(join(ROOT, rel), 'utf8');
type Step = { uses?: string; run?: string; with?: Record<string, unknown>; env?: Record<string, string> };
const workflow = Bun.YAML.parse(read('.github/workflows/free-tests.yml')) as { jobs: Record<string, { steps: Step[] }> };
const freeSuite = workflow.jobs['free-suite'].steps;
describe('ubicloud free-suite runner', () => {
test('setup pins the same Bun version as the CI free-suite job', () => {
const ciBun = freeSuite.find(step => step.uses?.startsWith('oven-sh/setup-bun'))?.with?.['bun-version'];
expect(ciBun).toBeDefined();
expect(read('scripts/ubicloud/setup-free-suite.sh')).toContain(`BUN_VERSION=${ciBun}\n`);
});
test('setup performs the CI job’s build steps', () => {
const setup = read('scripts/ubicloud/setup-free-suite.sh');
for (const command of ['bun install --frozen-lockfile', 'bun run gen:skill-docs --host all', 'bun run vendor:xterm',
'bash browse/scripts/build-node-server.sh', 'bun run build:gates', 'bun run build:cso']) {
expect(freeSuite.some(step => step.run?.includes(command))).toBe(true);
expect(setup).toContain(command);
}
expect(setup).toContain('chrome-sandbox');
expect(setup).toContain('kernel.apparmor_restrict_unprivileged_userns=0');
});
test('the wrapper runs the suite under Xvfb with the CI lane’s strictness knobs', () => {
const ciEnv = freeSuite.find(step => step.run?.includes('bun run test:free'))?.env ?? {};
const wrapper = read('scripts/ubicloud/test-free.sh');
expect(ciEnv.GSTACK_EXPECT_BINARIES).toBe('1');
expect(ciEnv.GSTACK_FREE_RETRY_FLAKY).toBe('1');
expect(wrapper).toContain('--env GSTACK_EXPECT_BINARIES=1');
expect(wrapper).toContain('--env GSTACK_FREE_RETRY_FLAKY=1');
expect(wrapper).toContain('xvfb-run -a bun run test:free');
expect(JSON.parse(read('package.json')).scripts['test:ubicloud']).toBe('bash scripts/ubicloud/test-free.sh');
});
test('remote commands force umask 022 and teardown is trapped on exit', () => {
const runner = read('scripts/ubicloud/ubi-runner.sh');
expect(runner).toContain('"umask 022; $*"');
expect(runner).toMatch(/trap 'cmd_down "\$RUN_VM"[^']*' EXIT/);
});
test('refuses to start without UBICLOUD_API_KEY, before any network call', () => {
const env = { ...process.env, UBICLOUD_API_URL: 'http://127.0.0.1:9' } as Record<string, string | undefined>;
delete env.UBICLOUD_API_KEY;
const result = Bun.spawnSync(['bash', join(DIR, 'ubi-runner.sh'), 'list'], { env, timeout: 10_000 });
expect(result.exitCode).toBe(1);
expect(result.stderr.toString()).toContain('UBICLOUD_API_KEY is not set');
expect(result.stdout.toString()).toBe('');
});
});