From 64de84e21b31479aa261f3b51027236a8530e511 Mon Sep 17 00:00:00 2001 From: DoodleBear Date: Sun, 15 Nov 2026 05:45:58 +0900 Subject: [PATCH 1/2] test(core): lock Owner-only relationship and gateway-session invariants - Member/Admin refusal now covers every relationship action, derived from the handler's schema, so new actions (e.g. #4115 account enrollment) are covered automatically. - A grant not bound to the current Owner never mints or forwards with the Owner session: unary, avatar, stream ticket, header and ticket upgrades. Refs #4138 #4109 Co-Authored-By: Claude Opus 5.5 --- .../core/src/devices/relationship-http.ts | 4 ++ packages/core/test/device-gateway.test.ts | 51 +++++++++++++++++++ packages/core/test/team-mode.test.ts | 6 ++- 3 files changed, 60 insertions(+), 1 deletion(-) diff --git a/packages/core/src/devices/relationship-http.ts b/packages/core/src/devices/relationship-http.ts index dca453facd..757b539894 100644 --- a/packages/core/src/devices/relationship-http.ts +++ b/packages/core/src/devices/relationship-http.ts @@ -20,6 +20,10 @@ const command = z.discriminatedUnion('action', [ z.object({ action: z.literal('cancel'), id, confirm: z.literal(true) }), ]); +export const RELATIONSHIP_ACTIONS: readonly string[] = command.options.map( + (option) => option.shape.action.value, +); + export function createRelationshipHttp(control: RelationshipControl) { return async (request: Request): Promise => { const reply = (body: unknown, status = 200) => diff --git a/packages/core/test/device-gateway.test.ts b/packages/core/test/device-gateway.test.ts index fc97c653f6..42fba5d87d 100644 --- a/packages/core/test/device-gateway.test.ts +++ b/packages/core/test/device-gateway.test.ts @@ -52,6 +52,7 @@ async function fakeDsh( token: 'launch-token', seen: [] as Record[], upgrades: [] as string[], + mints: 0, }; const cookie = () => `dsh=${String(state.secret)}`; let gateway: ReturnType | undefined; @@ -59,6 +60,7 @@ async function fakeDsh( const url = new URL(req.url ?? '/', 'http://x'); if (url.pathname.startsWith(DEVICE_GATEWAY_PREFIX)) return void gateway!.handle(req, res); if (url.pathname === '/' && url.searchParams.get('token') === state.token) { + state.mints++; res.writeHead(303, { 'set-cookie': `${cookie()}; HttpOnly`, location: '/' }); return res.end(); } @@ -317,6 +319,55 @@ it('does not let a paired device manage or inherit Host relationships', async () expect(host.pairing.admit(`Device ${paired.credential}`).ok).toBe(true); }); +it('never uses the Owner session for a grant that is not bound to the current Owner', async () => { + let ownerHumanId = OWNER_HUMAN_CONTEXT.humanId; + const host = await fakeDsh( + undefined, + undefined, + undefined, + undefined, + undefined, + () => ownerHumanId, + ); + const paired = await pair(host.base, host.pairing.createOffer().offer); + const authorization = `Device ${paired.body.credential}`; + const ticket = (await ( + await fetch(`${host.base}/botharness-device/stream-ticket`, { + method: 'POST', + headers: { authorization }, + }) + ).json()) as { ticket: string }; + // The grant now belongs to a Human who is not the Host Owner (a member grant or a transferred Owner). + ownerHumanId = 'not-the-owner'; + const refused = { code: 'device-revoked' }; + const unary = await fetch(`${host.base}/botharness-device/api/botharness/list`, { + method: 'POST', + headers: { authorization, 'content-type': 'application/json' }, + body: '{"type":"client-request"}', + }); + expect([unary.status, await unary.json()]).toEqual([401, refused]); + const avatar = await fetch( + `${host.base}/botharness-device/api/botharness/bot-avatar?slug=ada&v=portrait-1`, + { headers: { authorization } }, + ); + expect([avatar.status, await avatar.json()]).toEqual([401, refused]); + const issued = await fetch(`${host.base}/botharness-device/stream-ticket`, { + method: 'POST', + headers: { authorization }, + }); + expect([issued.status, await issued.json()]).toEqual([401, refused]); + expect(await stream(host.port, { authorization })).toEqual({ + status: 401, + body: JSON.stringify(refused), + }); + expect( + await stream(host.port, {}, `/botharness-device/api/remote.mux?ticket=${ticket.ticket}`), + ).toEqual({ status: 401, body: JSON.stringify(refused) }); + expect(host.state.mints).toBe(0); + expect(host.state.seen).toEqual([]); + expect(host.state.upgrades).toEqual([]); +}); + it('attributes Feed acknowledgement to the authenticated device and refuses revoked or missing credentials', async () => { const host = await fakeDsh(); const paired = host.pairing.redeem({ offer: host.pairing.createOffer().offer }); diff --git a/packages/core/test/team-mode.test.ts b/packages/core/test/team-mode.test.ts index d058817b81..3cff35f690 100644 --- a/packages/core/test/team-mode.test.ts +++ b/packages/core/test/team-mode.test.ts @@ -4,6 +4,7 @@ import { createAttachmentStore } from '../src/attachments/store.js'; import { DatabaseSync } from 'node:sqlite'; import { BOT_HARNESS_SCHEMA_PLAN } from '../src/database/schema-plan.js'; import { createDevicePairing } from '../src/devices/pairing.js'; +import { RELATIONSHIP_ACTIONS } from '../src/devices/relationship-http.js'; import { createServer, IncomingMessage, request as httpRequest } from 'node:http'; import { Socket } from 'node:net'; import { readFileSync } from 'node:fs'; @@ -1185,7 +1186,10 @@ describe('member HTTP carrier', { timeout: 60_000 }, () => { const http = await memberHttp(f); const cookie = (await http.signIn('bob', 'member-password-1')).headers.get('set-cookie') ?? ''; - for (const action of ['list', 'initiate', 'approve', 'sync', 'enroll', 'disconnect']) { + expect(RELATIONSHIP_ACTIONS).toEqual( + expect.arrayContaining(['initiate', 'approve', 'enroll', 'disconnect']), + ); + for (const action of ['list', ...RELATIONSHIP_ACTIONS]) { const response = await fetch(`${http.origin}${TEAM_PATH}/api/botharness/relationships`, { method: action === 'list' ? 'GET' : 'POST', headers: { cookie, 'content-type': 'application/json' }, From 14f22e27057b2f78c6e540f2e7d2e5f8fa80075b Mon Sep 17 00:00:00 2001 From: DoodleBear Date: Sun, 15 Nov 2026 05:52:52 +0900 Subject: [PATCH 2/2] test(core): drop prohibited code comment from gateway Owner-session test Co-Authored-By: Claude Opus 5.5 --- packages/core/test/device-gateway.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/core/test/device-gateway.test.ts b/packages/core/test/device-gateway.test.ts index 42fba5d87d..0bca33d0b0 100644 --- a/packages/core/test/device-gateway.test.ts +++ b/packages/core/test/device-gateway.test.ts @@ -337,7 +337,6 @@ it('never uses the Owner session for a grant that is not bound to the current Ow headers: { authorization }, }) ).json()) as { ticket: string }; - // The grant now belongs to a Human who is not the Host Owner (a member grant or a transferred Owner). ownerHumanId = 'not-the-owner'; const refused = { code: 'device-revoked' }; const unary = await fetch(`${host.base}/botharness-device/api/botharness/list`, {