mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-02 03:35:09 +02:00
8f3701b761
* feat: extend tunnel allowlist to 26 commands + extract canDispatchOverTunnel
Adds newtab, tabs, back, forward, reload, snapshot, fill, url, closetab to
TUNNEL_COMMANDS (matching what cli.ts and REMOTE_BROWSER_ACCESS.md already
documented). Each new command is bounded by the existing per-tab ownership
check at server.ts:613-624 — scoped tokens default to tabPolicy: 'own-only'
so paired agents still can't operate on tabs they don't own.
Refactors the inline gate check at server.ts:1771-1783 into a pure exported
function canDispatchOverTunnel(command). Same behavior as the inline check;
the difference is unit-testability without HTTP.
Adds BROWSE_TUNNEL_LOCAL_ONLY=1 test-mode flag that binds the second Bun.serve
listener with makeFetchHandler('tunnel') on 127.0.0.1 — no ngrok needed.
Production tunnel still requires BROWSE_TUNNEL=1 + valid NGROK_AUTHTOKEN.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test: source-level guards + pure-function unit test + dual-listener behavioral eval
Three layers of regression coverage for the tunnel allowlist:
1. dual-listener.test.ts: replaces must-include/must-exclude with exact-set
equality on the 26-command literal (the prior intersection-only style let
new commands sneak into the source without test updates). Adds a regex
assertion that the `command !== 'newtab'` ownership exemption at
server.ts:613 still exists — catches refactors that re-introduce the
catch-22 from the other side. Updates the /command handler test to look
for canDispatchOverTunnel(body?.command) instead of the inline check.
2. tunnel-gate-unit.test.ts (new): 53 expects covering all 26 allowed,
20 blocked, null/undefined/empty/non-string defensive handling, and alias
canonicalization (e.g. 'set-content' resolves to 'load-html' which is
correctly rejected since 'load-html' isn't tunnel-allowed).
3. pair-agent-tunnel-eval.test.ts (new): 4 behavioral tests that spawn the
daemon under BROWSE_HEADLESS_SKIP=1 BROWSE_TUNNEL_LOCAL_ONLY=1, bind both
listeners on 127.0.0.1, mint a scoped token via /pair → /connect, and
assert: (a) newtab over tunnel passes the gate; (b) pair over tunnel
403s with disallowed_command:pair AND writes a denial-log entry;
(c) pair over local does NOT trigger the tunnel gate (proves the gate
is surface-scoped); (d) regression for the catch-22 — newtab + goto on
the resulting tab does not 403 with "Tab not owned by your agent".
All four tests run free under bun test (no API spend, no ngrok).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: bump tunnel allowlist count 17 -> 26 in CLAUDE.md and REMOTE_BROWSER_ACCESS.md
Both docs already named the 9 new commands as remote-accessible (the operator
guide's per-command sections at lines 86-119 and 168, plus cli.ts:546-586's
instruction blocks). The allowlist count was the only place the drift was
visible. Also corrected REMOTE_BROWSER_ACCESS.md's denied-commands list:
'eval' is in the allowlist, not the denied list — prior doc was wrong.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: bump version and changelog (v1.21.0.0)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: re-version v1.21.0.0 -> v1.16.0.0 (lowest unclaimed slot)
The previous bump landed at v1.21.0.0 because gstack-next-version
advances past the highest claimed slot (v1.20.0.0 from #1252) rather
than picking the lowest unclaimed. v1.16-v1.18 are unclaimed and
v1.16.0.0 preserves monotonic version ordering on main once #1234
(v1.17), #1233 (v1.19), and #1252 (v1.20) merge after us.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(ci): version-gate enforces collisions, allows lower-but-unclaimed slots
The gate was rejecting any PR VERSION below the util's next-slot
recommendation, even when the lower slot was unclaimed. This blocked
PRs that legitimately want to land at an unclaimed slot below the queue
max — which is what /ship should pick when the goal is monotonic version
ordering on main (lower-numbered PRs landing first preserves order; the
util's "advance past max claimed" semantics only optimizes for fresh
runs picking unique slots, not for queue ordering on merge).
New gate logic:
1. Hard-fail if PR VERSION <= base VERSION (no actual bump).
2. Hard-fail if PR VERSION exactly matches another open PR's VERSION
(real collision).
3. Pass otherwise. If the PR is below the util's suggestion, emit an
informational ::notice:: explaining the slot is unclaimed.
The util's output stays informational — it tells fresh /ship runs what
the next-up slot should be, but the gate only blocks actual conflicts.
This is a strict relaxation: every PR that passed the old gate also
passes the new one.
Confirmed by dry-run against the current queue (4 open PRs claiming
1.17.0.0, 1.19.0.0, 1.21.1.0, 1.22.0.0):
- v1.16.0.0 → pass with informational notice (unclaimed)
- v1.17.0.0 → fail (collision with #1234)
- v1.15.0.0 → fail (no bump from base)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
107 lines
4.3 KiB
TypeScript
107 lines
4.3 KiB
TypeScript
#!/usr/bin/env bun
|
|
// compare-pr-version — CI gate helper. Validates the PR's branch VERSION
|
|
// against the queue of other open PRs' claimed versions. Exits 0 (pass)
|
|
// or 1 (confirmed collision).
|
|
//
|
|
// Input:
|
|
// argv[2] — path to next.json (the util's JSON output)
|
|
// argv[3] — optional PR number for log lines
|
|
//
|
|
// Design note: fail-open on util error. A gstack bug must never freeze the
|
|
// merge queue. The gate enforces ONE rule: this PR must not claim the same
|
|
// version as another open PR. Lower-than-the-util's-suggestion is fine if
|
|
// the slot is unclaimed — that preserves monotonic version ordering on main
|
|
// when this PR lands ahead of higher-numbered queued PRs. The util's output
|
|
// is informational (the *recommended* slot for fresh /ship runs); the gate
|
|
// only blocks actual collisions.
|
|
|
|
import { readFileSync } from "node:fs";
|
|
|
|
const [, , jsonPath, prNumber] = process.argv;
|
|
if (!jsonPath) {
|
|
console.error("Usage: compare-pr-version <next.json> [pr-number]");
|
|
process.exit(2);
|
|
}
|
|
|
|
let parsed: any;
|
|
try {
|
|
parsed = JSON.parse(readFileSync(jsonPath, "utf8"));
|
|
} catch (e) {
|
|
console.log("::warning::could not parse util output; failing open");
|
|
process.exit(0);
|
|
}
|
|
|
|
if (parsed.offline === true) {
|
|
console.log("::warning::workspace-aware-ship util offline; failing open (no collision check performed)");
|
|
console.log(`::notice::If you merge this PR and a queued PR landed ahead, CHANGELOG may need manual reconciliation.`);
|
|
process.exit(0);
|
|
}
|
|
|
|
// PR_VERSION is supplied via env (set by the workflow from `cat VERSION`).
|
|
const prVersion = (process.env.PR_VERSION ?? "").trim();
|
|
const nextSlot = parsed.version;
|
|
|
|
if (!prVersion) {
|
|
console.log("::warning::PR_VERSION not set; failing open");
|
|
process.exit(0);
|
|
}
|
|
|
|
// Parse versions for comparison.
|
|
function parseV(s: string): number[] | null {
|
|
const m = s.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/);
|
|
return m ? [Number(m[1]), Number(m[2]), Number(m[3]), Number(m[4])] : null;
|
|
}
|
|
function cmp(a: number[], b: number[]): number {
|
|
for (let i = 0; i < 4; i++) if (a[i] !== b[i]) return a[i] - b[i];
|
|
return 0;
|
|
}
|
|
const pPR = parseV(prVersion);
|
|
const pNext = parseV(nextSlot);
|
|
if (!pPR || !pNext) {
|
|
console.log(`::warning::malformed version string (PR=${prVersion}, next=${nextSlot}); failing open`);
|
|
process.exit(0);
|
|
}
|
|
|
|
const tag = prNumber ? `PR #${prNumber}` : "this PR";
|
|
const claimed = (parsed.claimed ?? []) as Array<{ pr: number; branch: string; version: string; url?: string }>;
|
|
|
|
// Emit a GitHub step summary (always helpful, even on pass).
|
|
const claimedList = claimed
|
|
.map((c) => ` #${c.pr} ${c.branch} → v${c.version}`)
|
|
.join("\n");
|
|
|
|
console.log(`::group::Version gate (${tag})`);
|
|
console.log(` PR VERSION: v${prVersion}`);
|
|
console.log(` Suggested: v${nextSlot} (util's next-slot recommendation)`);
|
|
console.log(` Queue (${claimed.length} open PRs claiming versions):`);
|
|
if (claimedList) console.log(claimedList);
|
|
console.log("::endgroup::");
|
|
|
|
// Hard rule 1: this PR's VERSION must be strictly greater than the base
|
|
// version, otherwise we're not actually bumping.
|
|
const pBase = parseV((parsed.base_version ?? "").trim());
|
|
if (pBase && cmp(pPR, pBase) <= 0) {
|
|
console.log(`::error::VERSION not bumped: ${tag} claims v${prVersion} but base is v${parsed.base_version}.`);
|
|
process.exit(1);
|
|
}
|
|
|
|
// Hard rule 2: no collision with another open PR's claimed VERSION.
|
|
const collision = claimed.find((c) => c.version.trim() === prVersion);
|
|
if (collision) {
|
|
console.log(`::error::VERSION collision: ${tag} claims v${prVersion} but #${collision.pr} (${collision.branch}) already claims the same slot.`);
|
|
console.log(`::error::Rerun /ship to pick a different slot, or coordinate with #${collision.pr} on landing order.`);
|
|
process.exit(1);
|
|
}
|
|
|
|
// Optional informational note: PR version is below the util's suggested next
|
|
// slot. This is allowed — the suggested slot is a recommendation for /ship's
|
|
// next run, but landing at a lower-but-unclaimed slot first preserves
|
|
// monotonic ordering on main when this PR merges ahead of higher-numbered
|
|
// queued PRs.
|
|
if (cmp(pPR, pNext) < 0) {
|
|
console.log(`::notice::${tag} claims v${prVersion}, below util's suggestion v${nextSlot}. Slot is unclaimed; gate passes. If this PR lands ahead of queued PRs at higher slots, version ordering on main remains monotonic.`);
|
|
}
|
|
|
|
console.log(`✓ ${tag} claims v${prVersion} — slot is free.`);
|
|
process.exit(0);
|