From b3f92ee8f8dc06cb0a28ca5070c3d95934f58677 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 16:52:29 +0000 Subject: [PATCH 1/7] fix(ci): pin Bun to the workspace version and untrack a local node_modules symlink Every `Tests / *` job on #517 and #518 was cancelled at the 20-minute budget while still inside `bun ci`, before any test ran. Two things combined: - trios/agent-server/apps/server/node_modules was committed as a symlink to a local macOS path. `.gitignore` said `node_modules/`, which only matches directories, so the symlink slipped through. - setup-bun ran without a version. The `packageManager: bun@1.3.6` pin lives in trios/agent-server/package.json, not at the repo root, so CI got the latest release (1.4.2), which hangs on that dangling symlink. 1.3.x installs past it. Untrack the symlink, make the ignore rule match files too, and read the Bun version from the workspace package.json in test.yml. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SJ8KjRoGNBoHBoDR92fAo2 --- .github/workflows/test.yml | 6 ++++++ trios/agent-server/.gitignore | 4 +++- trios/agent-server/apps/server/node_modules | 1 - 3 files changed, 9 insertions(+), 2 deletions(-) delete mode 120000 trios/agent-server/apps/server/node_modules diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index da5e35e3f3..decf3c12d6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -134,6 +134,12 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 + with: + # The pin (packageManager: bun@1.3.6) lives in the workspace, not at the + # repo root, so without this setup-bun installs the latest release. + # Bun 1.4.2 hung in `bun ci` for the whole 20-minute budget on every + # suite (#517, #518) before a single test ran. + bun-version-file: trios/agent-server/package.json - name: Install dependencies run: bun ci diff --git a/trios/agent-server/.gitignore b/trios/agent-server/.gitignore index 5b07fe5f44..fade51cb84 100644 --- a/trios/agent-server/.gitignore +++ b/trios/agent-server/.gitignore @@ -53,7 +53,9 @@ bower_components build/Release # Dependency directories -node_modules/ +# No trailing slash: a symlinked node_modules is a file, and `node_modules/` +# let one pointing at a local Mac path be committed and hang CI's install. +node_modules jspm_packages/ # Snowpack dependency directory (https://snowpack.dev/) diff --git a/trios/agent-server/apps/server/node_modules b/trios/agent-server/apps/server/node_modules deleted file mode 120000 index d9dd43591a..0000000000 --- a/trios/agent-server/apps/server/node_modules +++ /dev/null @@ -1 +0,0 @@ -/Users/playom/queen-patches/work/browseros-deploy/trios/agent-server/apps/server/node_modules \ No newline at end of file From 583d8ea76f65fcec2fe15052e55a5d907ba797a4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 17:04:04 +0000 Subject: [PATCH 2/7] test(tools): get_page_content reads a constructed page, not the live example.com With installs no longer hanging, server-tools ran for the first time since 2026-09-23 and failed one test: get_page_content read https://example.com 57 ms after opening it and found no "Example Domain". The test is about extracting text, so it now writes that text into about:blank with evaluate_script, as get_page_links already does. Locally (BrowserOS AppImage, headless, --no-sandbox): the old test fails the same way; the new one passes 3/3. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SJ8KjRoGNBoHBoDR92fAo2 --- .../apps/server/tests/tools/observation.test.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/trios/agent-server/apps/server/tests/tools/observation.test.ts b/trios/agent-server/apps/server/tests/tools/observation.test.ts index 982296c644..373f2e41fa 100644 --- a/trios/agent-server/apps/server/tests/tools/observation.test.ts +++ b/trios/agent-server/apps/server/tests/tools/observation.test.ts @@ -157,8 +157,17 @@ describe('observation tools', () => { it('get_page_content returns markdown text', async () => { await withBrowser(async ({ execute }) => { - const newResult = await execute(new_page, { url: 'https://example.com' }) + // Constructed in place rather than fetched: reading https://example.com + // raced its own load (CI read it 57 ms after opening, before any text + // arrived), and the test is about extracting text, not about the network. + const newResult = await execute(new_page, { url: 'about:blank' }) const pageId = pageIdOf(newResult) + await execute(evaluate_script, { + page: pageId, + expression: `document.body.innerHTML = ${JSON.stringify( + '

Example Domain

This domain is for use in documentation examples.

', + )}`, + }) const contentResult = await execute(get_page_content, { page: pageId }) assert.ok(!contentResult.isError, textOf(contentResult)) From 26938a669ce23d9f2de05ca1fb4dc23d9951b1a4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 17:13:48 +0000 Subject: [PATCH 3/7] test(helpers): killProcessOnPort kills listeners only, never the test process server-tools still exited 1 after every test in observation.test.ts passed: before navigation-newtab-guard.test.ts the helper ran `lsof -ti :`, which also lists clients still connected to the port. One of them was the bun test process itself (its CDP socket to the previous file's browser), so the SIGTERM ended the whole run and no junit report was written ("workflow > server-tools setup"). Use `lsof -ti tcp: -sTCP:LISTEN` and drop process.pid. Locally, input.test.ts + navigation-newtab-guard.test.ts in one process: before, exit 143 right after "Terminating process(es) , ..."; after, 18 pass / 0 fail. The whole test:tools group now runs to the end (242 pass; the 2 local failures load https://example.com, which this sandbox's browser cannot reach and CI can). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SJ8KjRoGNBoHBoDR92fAo2 --- .../apps/server/tests/__helpers__/utils.ts | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/trios/agent-server/apps/server/tests/__helpers__/utils.ts b/trios/agent-server/apps/server/tests/__helpers__/utils.ts index 7c684896b4..c808f6e245 100644 --- a/trios/agent-server/apps/server/tests/__helpers__/utils.ts +++ b/trios/agent-server/apps/server/tests/__helpers__/utils.ts @@ -14,22 +14,29 @@ export async function killProcessOnPort(port: number): Promise { try { console.log(`Finding process on port ${port}...`) - const pids = execSync(`lsof -ti :${port}`, { + // LISTEN only, and never this process. A bare `lsof -i :port` also lists + // every client still connected to the port - including this test process, + // whose CDP socket to the previous file's browser outlives it - and the + // SIGTERM that followed ended the whole server-tools run (exit 143). + const pids = execSync(`lsof -ti tcp:${port} -sTCP:LISTEN`, { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'], - }).trim() + }) + .split('\n') + .map((pid) => pid.trim()) + .filter((pid) => pid !== '' && pid !== String(process.pid)) + .join(' ') if (pids) { - const pidList = pids.replace(/\n/g, ', ') - console.log(`Terminating process(es) ${pidList} on port ${port}...`) + console.log(`Terminating process(es) ${pids} on port ${port}...`) try { - execSync(`kill -15 ${pids.replace(/\n/g, ' ')}`, { + execSync(`kill -15 ${pids}`, { stdio: 'ignore', }) await new Promise((resolve) => setTimeout(resolve, 500)) } catch { - execSync(`kill -9 ${pids.replace(/\n/g, ' ')}`, { + execSync(`kill -9 ${pids}`, { stdio: 'ignore', }) } From 3d57649dada9a41b3f837fbd023ec5083224d9de Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 17:38:27 +0000 Subject: [PATCH 4/7] test(tools): wait_for waits for text a data: page adds, not the live example.com With the run no longer killing itself, server-tools finished in CI with 243 pass / 1 fail: `wait_for finds text on page` waited its full 10 s for "Example Domain" on https://example.com and never saw it - the same page get_page_content could not read either. The page now adds that text itself 500 ms after load, so the test still proves wait_for waits, with nothing outside the runner involved. Locally: 2/2 wait_for tests pass on repeat; the whole test:tools group is 243 pass, the one local failure being take_screenshot (a 60 s hang in this sandbox only - it passes in CI). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SJ8KjRoGNBoHBoDR92fAo2 --- .../apps/server/tests/tools/navigation.test.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/trios/agent-server/apps/server/tests/tools/navigation.test.ts b/trios/agent-server/apps/server/tests/tools/navigation.test.ts index f78b9942bf..b69331d2ed 100644 --- a/trios/agent-server/apps/server/tests/tools/navigation.test.ts +++ b/trios/agent-server/apps/server/tests/tools/navigation.test.ts @@ -157,7 +157,15 @@ describe('navigation tools', () => { it('wait_for finds text on page', async () => { await withBrowser(async ({ execute }) => { - const newResult = await execute(new_page, { url: 'https://example.com' }) + // The text arrives half a second after load, from the page itself, so + // this still exercises the waiting - without depending on the live + // https://example.com, which CI's browser never showed it on. + const page = `` + const newResult = await execute(new_page, { + url: `data:text/html,${encodeURIComponent(page)}`, + }) const pageId = structuredOf<{ pageId: number }>(newResult).pageId const waitResult = await execute(wait_for, { From 83213496ae2a3784eee7a3721df72cc37040fc1a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 17:42:02 +0000 Subject: [PATCH 5/7] test(tools): the class-selector search_dom test retries the load race too server-tools on 3d57649 ran clean except one test that had passed on both earlier runs: `search_dom > finds multiple elements with CSS class selector` (123 ms, fewer than 3 matches). It searches once, straight after new_page - the race this file already names and fixes with searchUntil for two sibling tests. Use the same helper here. Locally: search_dom 13/13, three runs in a row. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SJ8KjRoGNBoHBoDR92fAo2 --- trios/agent-server/apps/server/tests/tools/dom.test.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/trios/agent-server/apps/server/tests/tools/dom.test.ts b/trios/agent-server/apps/server/tests/tools/dom.test.ts index d487a8f2cb..53e9ccd686 100644 --- a/trios/agent-server/apps/server/tests/tools/dom.test.ts +++ b/trios/agent-server/apps/server/tests/tools/dom.test.ts @@ -400,10 +400,9 @@ describe('search_dom', () => { const newResult = await execute(new_page, { url: RICH_PAGE }) const pageId = pageIdOf(newResult) - const result = await execute(search_dom, { - page: pageId, - query: '.nav-link', - }) + // Same load race searchUntil exists for: CI found fewer than 3 once + // (2026-10-01) on a run where the identical query passed before. + const result = await searchUntil(execute, pageId, '.nav-link', 'Found 3') assert.ok(!result.isError, textOf(result)) const text = textOf(result) assert.ok(text.includes('Found 3'), 'Should find exactly 3 nav links') From 350d55969383eef7fbfacd16590f5987357d563c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 17:44:28 +0000 Subject: [PATCH 6/7] test(queen): give the 205-file salvage rename test an explicit 30 s budget `the salvage commit > never splits a rename across the path cap` runs real git over 205 files and salvageWorktree. It takes ~2 s for the whole file locally and passed on the two CI runs before, then hit bun's 5 s default once on a loaded runner (job 110500921083) with nothing in the change touching salvage. A git-heavy fixture test should not share the budget of a pure unit test. Locally: queen-salvage-guards.test.ts 13 pass / 0 fail. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SJ8KjRoGNBoHBoDR92fAo2 --- .../apps/server/tests/api/queen-salvage-guards.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/trios/agent-server/apps/server/tests/api/queen-salvage-guards.test.ts b/trios/agent-server/apps/server/tests/api/queen-salvage-guards.test.ts index a533efeecc..f80ba552ca 100644 --- a/trios/agent-server/apps/server/tests/api/queen-salvage-guards.test.ts +++ b/trios/agent-server/apps/server/tests/api/queen-salvage-guards.test.ts @@ -541,7 +541,9 @@ describe('the salvage commit', () => { expect(head).toContain('trios/docs/a/new.md') expect(head).not.toContain('trios/docs/z/old.md') rmSync(f.scratch, { recursive: true, force: true }) - }) + // Real git over 205 files: ~0.2 s here, but once over bun's 5 s default on + // a loaded CI runner (2026-10-01) while passing on the runs either side. + }, 30_000) }) // --------------------------------------------------------------------------- From aa74689dfc605ac3e02c2e7e719a787bc3cbfdd7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 03:23:23 +0000 Subject: [PATCH 7/7] test(queen): the route-guard audit knows /queen/contributor-keys #522 mounted /queen/contributor-keys and left the route-guard audit unchanged, so feat/queen-supervisor fails four route-guard tests: 46 mounts against a pin of 45, 23 /queen mounts against 22, and an unguarded mount nobody allowlisted. The route is a server-to-server door for the app render proxy and has its own guard: a bearer equal to QUEEN_CONTRIBUTOR_PROXY_TOKEN (32+ bytes, timingSafeEqual) plus a verified contributor header, and it is off while that token is unset. The trusted-origin check would refuse its only caller, so it is allowlisted with that reason and the pins are re-measured. No other number moved. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SJ8KjRoGNBoHBoDR92fAo2 --- .../tests/api/routes/route-guard.test.ts | 20 +++++++++++++------ trios/tools/route-guard-audit.mjs | 5 +++++ 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/trios/agent-server/apps/server/tests/api/routes/route-guard.test.ts b/trios/agent-server/apps/server/tests/api/routes/route-guard.test.ts index f5950ac673..cee95fcc39 100644 --- a/trios/agent-server/apps/server/tests/api/routes/route-guard.test.ts +++ b/trios/agent-server/apps/server/tests/api/routes/route-guard.test.ts @@ -25,7 +25,7 @@ import { const source = readServerSource() const report = auditServer(source, DEFAULT_ALLOWLIST) -// Regression pin for the --no-allowlist run: exactly these seven mounts carry +// Regression pin for the --no-allowlist run: exactly these nine mounts carry // no guard today, each for a reason the comments beside the mount give. // RE-MEASURED 2026-09-13: /api/inngest joined. It is not a shell - it is the // Queen's scheduler endpoint - and it is unguarded on purpose: Inngest signs @@ -39,6 +39,7 @@ const report = auditServer(source, DEFAULT_ALLOWLIST) const EXPECTED_UNGUARDED_WITHOUT_ALLOWLIST = [ '/api/inngest', '/health', + '/queen/contributor-keys', '/queen/dashboard', '/queen/feed', '/queen/hq', @@ -84,7 +85,10 @@ describe('route-guard audit over src/api/server.ts', () => { // where a route named `public` belongs. Every other number here is // unchanged, which is the part worth stating: no guarded route quietly lost // its guard to make room for it. - expect(report.totalMounts).toBe(45) + // RE-MEASURED 2026-10-02: 45 became 46 with /queen/contributor-keys + // (#522), a server-to-server route behind its own capability token. It is + // allowlisted with that reason; no other number moved. + expect(report.totalMounts).toBe(46) expect(report.prefixGuardCount).toBe(18) expect(report.guardedSubAppCount).toBe(15) expect(report.publicReadCount).toBe(8) @@ -97,7 +101,7 @@ describe('route-guard audit over src/api/server.ts', () => { expect(report.entriesMissingReason).toEqual([]) }) - it('reports exactly the seven reasoned exceptions when the allowlist is dropped', () => { + it('reports exactly the nine reasoned exceptions when the allowlist is dropped', () => { // The classifier reports mounts in file order; the assertion is on the // exact set, so both sides are sorted before comparing. expect([...unguardedMounts(source, [])].sort()).toEqual( @@ -105,7 +109,7 @@ describe('route-guard audit over src/api/server.ts', () => { ) }) - it('splits the twenty-two /queen mounts into 8 public-read, 8 wrapper-guarded and 6 allowlisted shells', () => { + it('splits the twenty-three /queen mounts into 8 public-read, 8 wrapper-guarded and 7 allowlisted', () => { const queenMounts = classifyMounts(source).filter( (mount) => mount.path === '/queen' || mount.path.startsWith('/queen/'), ) @@ -127,7 +131,11 @@ describe('route-guard audit over src/api/server.ts', () => { // issue title, no worker text and no credential: only a key's INDEX ever // reaches the database, so there is nothing here a stranger could read that // the board does not already show. - expect(queenMounts.length).toBe(22) + // RE-MEASURED 2026-10-02: twenty-two became twenty-three. The seventh + // allowlisted mount is /queen/contributor-keys (#522), which serves data + // only to a caller holding QUEEN_CONTRIBUTOR_PROXY_TOKEN - its own guard, + // not the trusted-origin one, because its caller is a server. + expect(queenMounts.length).toBe(23) const counts: Record = { 'public-read': 0, @@ -144,7 +152,7 @@ describe('route-guard audit over src/api/server.ts', () => { 'public-read': 8, 'prefix-guard': 0, wrapper: 8, - unguarded: 6, + unguarded: 7, }) // Every unguarded /queen mount must be one of the allowlisted shells. diff --git a/trios/tools/route-guard-audit.mjs b/trios/tools/route-guard-audit.mjs index 0254f4feb8..5dda90e16d 100644 --- a/trios/tools/route-guard-audit.mjs +++ b/trios/tools/route-guard-audit.mjs @@ -105,6 +105,11 @@ export const DEFAULT_ALLOWLIST = [ reason: 'shell only — the operator page holds no state and no token; its numbers come from /queen/lease and its one action POSTs there with a bearer the reader supplies, so both stay guarded (comment at the mount)', }, + { + path: '/queen/contributor-keys', + reason: + 'own capability - a server-to-server route for the app render proxy, with no browser Origin; every request is refused unless its bearer equals QUEEN_CONTRIBUTOR_PROXY_TOKEN (at least 32 bytes, compared with timingSafeEqual) and it carries a verified x-queen-contributor-id, and the route is off while that token is unset (src/api/routes/queen-contributor-keys.ts, tests/api/queen-contributor-keys.test.ts)', + }, { path: '/api/inngest', reason: