@kortix/agent-tunnel 0.13.53 → 0.13.55

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kortix/agent-tunnel",
3
- "version": "0.13.53",
3
+ "version": "0.13.55",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/kortix-ai/suna.git",
@@ -5,7 +5,16 @@ import { join } from 'node:path';
5
5
 
6
6
  import {
7
7
  ACCESS_HOLD_MS,
8
+ accessCommand,
8
9
  accessFilePath,
10
+ accessView,
11
+ accessWidens,
12
+ applyAccess,
13
+ nextAccess,
14
+ parseAccessChange,
15
+ readAccessRequest,
16
+ type AccessChange,
17
+ type AccessState,
9
18
  accessRequestPath,
10
19
  decideAccess,
11
20
  keepAwakeCommand,
@@ -162,3 +171,83 @@ describe('keep awake', () => {
162
171
  expect(keepAwakeCommand('win32', 42)).toBeNull();
163
172
  });
164
173
  });
174
+
175
+ describe('the access rules (one copy: the CLI, the agent, and the desktop app all use these)', () => {
176
+ const now = Date.parse('2030-01-01T12:00:00.000Z');
177
+ const ask = { mode: 'ask' as const, grantedUntil: null, deniedUntil: null, keepAwake: false };
178
+
179
+ test('grants (max 24 h), revokes, denies for 10 minutes, switches mode and keep awake', () => {
180
+ expect(nextAccess(ask, { grantMinutes: 60 }, now)).toMatchObject({ grantedUntil: '2030-01-01T13:00:00.000Z', deniedUntil: null });
181
+ expect(nextAccess(ask, { grantMinutes: 10_000 }, now).grantedUntil).toBe('2030-01-02T12:00:00.000Z');
182
+ expect(nextAccess({ ...ask, grantedUntil: 'x' }, { revoke: true }, now).grantedUntil).toBeNull();
183
+ expect(nextAccess(ask, { deny: true }, now)).toMatchObject({ grantedUntil: null, deniedUntil: '2030-01-01T12:10:00.000Z' });
184
+ expect(nextAccess(ask, { mode: 'off', keepAwake: true }, now)).toMatchObject({ mode: 'off', keepAwake: true });
185
+ expect(() => nextAccess(ask, { mode: 'sometimes' as never }, now)).toThrow(/mode/);
186
+ expect(() => nextAccess(ask, { grantMinutes: 0 }, now)).toThrow(/grantMinutes/);
187
+ // A mode change drops the old grant and denial: back to "Ask each time" asks again.
188
+ const granted = { mode: 'off' as const, grantedUntil: '2030-01-01T20:00:00.000Z', deniedUntil: '2030-01-01T12:05:00.000Z', keepAwake: false };
189
+ expect(nextAccess(granted, { mode: 'ask' }, now)).toMatchObject({ mode: 'ask', grantedUntil: null, deniedUntil: null });
190
+ });
191
+
192
+ test('only `always` and a longer grant widen access; everything else narrows', () => {
193
+ const widens = (current: typeof ask | AccessState, change: AccessChange) => accessWidens(current, nextAccess(current, change, now), now);
194
+ expect(widens(ask, { mode: 'always' })).toBe(true);
195
+ expect(widens(ask, { grantMinutes: 1440 })).toBe(true);
196
+ expect(widens({ ...ask, grantedUntil: '2030-01-01T18:00:00.000Z' }, { grantMinutes: 60 })).toBe(false);
197
+ expect(widens({ ...ask, mode: 'off' }, { mode: 'ask' })).toBe(false);
198
+ expect(widens({ ...ask, mode: 'always' }, { mode: 'ask' })).toBe(false);
199
+ expect(widens(ask, { mode: 'off' })).toBe(false);
200
+ expect(widens({ ...ask, grantedUntil: '2030-01-01T13:00:00.000Z' }, { revoke: true })).toBe(false);
201
+ expect(widens(ask, { keepAwake: true })).toBe(false);
202
+ expect(widens(ask, { deny: true })).toBe(false);
203
+ });
204
+
205
+ test('a relayed change keeps only known fields with their types', () => {
206
+ expect(parseAccessChange({ mode: 'off', keepAwake: false, extra: 1 })).toEqual({ mode: 'off', keepAwake: false });
207
+ expect(() => parseAccessChange({ mode: 'sometimes' })).toThrow(/mode/);
208
+ expect(() => parseAccessChange({ grantMinutes: '60' })).toThrow(/grantMinutes/);
209
+ expect(() => parseAccessChange({ revoke: 'yes' })).toThrow(/revoke/);
210
+ expect(() => parseAccessChange({})).toThrow(/No access change/);
211
+ expect(() => parseAccessChange(null)).toThrow(/No access change/);
212
+ });
213
+
214
+ test('names the terminal command for a change', () => {
215
+ expect(accessCommand({ mode: 'always' })).toBe('npx @kortix/agent-tunnel@latest access always');
216
+ expect(accessCommand({ grantMinutes: 60 })).toBe('npx @kortix/agent-tunnel@latest access --allow-for 60');
217
+ expect(accessCommand({ keepAwake: true })).toBe('npx @kortix/agent-tunnel@latest keep-awake on');
218
+ });
219
+
220
+ test('the view shows a pending request while it is fresh, and no lapsed grant or denial', () => {
221
+ const home = tempHome();
222
+ writeAccess({ ...ask, grantedUntil: '2030-01-01T11:00:00.000Z', deniedUntil: '2030-01-01T11:30:00.000Z' }, home);
223
+ const fresh = { id: 'r1', capability: 'filesystem', method: 'fs.list', requestedAt: new Date(now - 5_000).toISOString() };
224
+ writeFileSync(accessRequestPath(home), JSON.stringify(fresh));
225
+ expect(accessView(home, 'darwin', now)).toEqual({
226
+ ...ask,
227
+ keepAwakeSupported: true,
228
+ pendingRequest: { id: 'r1', capability: 'filesystem', requestedAt: fresh.requestedAt },
229
+ });
230
+ expect(accessView(home, 'darwin', now + 120_000).pendingRequest).toBeNull();
231
+ expect(accessView(home, 'win32', now).keepAwakeSupported).toBe(false);
232
+ });
233
+
234
+ test('applying a change answers the request asked before it; deny on a relayed change only declines it', () => {
235
+ const home = tempHome();
236
+ writeAccess(ask, home);
237
+ writeAccessRequest({ capability: 'filesystem', method: 'fs.list' }, home);
238
+ expect(applyAccess({ keepAwake: true }, home).pendingRequest).not.toBeNull();
239
+ expect(applyAccess({ grantMinutes: 60 }, home)).toMatchObject({ pendingRequest: null, keepAwake: true });
240
+ expect(readAccess(home).grantedUntil).not.toBeNull();
241
+
242
+ writeAccessRequest({ capability: 'access', method: 'tunnel.access.set', change: { mode: 'always' } }, home);
243
+ const declined = applyAccess({ deny: true }, home);
244
+ expect(declined.pendingRequest).toBeNull();
245
+ // Declining a change is not a denial of calls: the grant stays.
246
+ expect(declined.deniedUntil).toBeNull();
247
+ expect(declined.grantedUntil).not.toBeNull();
248
+
249
+ writeAccessRequest({ capability: 'shell', method: 'shell.exec' }, home);
250
+ expect(applyAccess({ deny: true }, home)).toMatchObject({ pendingRequest: null, grantedUntil: null });
251
+ expect(readAccess(home).deniedUntil).not.toBeNull();
252
+ });
253
+ });
@@ -1,6 +1,7 @@
1
1
  import { spawn } from 'child_process';
2
2
  import { randomUUID } from 'crypto';
3
3
  import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'fs';
4
+ import { platform } from 'os';
4
5
  import { isAbsolute, join } from 'path';
5
6
 
6
7
  import { agentTunnelHome } from './service-paths';
@@ -8,9 +9,11 @@ import { agentTunnelHome } from './service-paths';
8
9
  /**
9
10
  * AnyDesk-style access control, enforced on this machine (contract v2 §A).
10
11
  *
11
- * `<home>/access.json` is the owner's standing answer. The desktop app writes
12
- * it (grant, deny, mode); the agent reads it before every call. A machine
13
- * without the file predates access control and stays `always`.
12
+ * `<home>/access.json` is the owner's standing answer. Only this package
13
+ * writes it: the `access` and `keep-awake` commands (the terminal, and the
14
+ * desktop app, which runs them), and the agent itself for a change relayed by
15
+ * Kortix. The agent reads it before every call. A machine without the file
16
+ * predates access control and stays `always`.
14
17
  */
15
18
  export type AccessMode = 'ask' | 'always' | 'off';
16
19
 
@@ -21,11 +24,34 @@ export interface AccessState {
21
24
  keepAwake: boolean;
22
25
  }
23
26
 
27
+ /** One owner action. `deny` answers the pending request with No. */
28
+ export interface AccessChange {
29
+ mode?: AccessMode;
30
+ /** Allow for this many minutes (at most 24 h). */
31
+ grantMinutes?: number;
32
+ /** End the current grant now. */
33
+ revoke?: boolean;
34
+ deny?: boolean;
35
+ keepAwake?: boolean;
36
+ }
37
+
38
+ /**
39
+ * A question for the owner on this machine: an agent call that waits for an
40
+ * answer, or (`change`) a wider access asked for through Kortix, which only
41
+ * the owner on this machine may confirm.
42
+ */
24
43
  export interface AccessRequest {
25
44
  id: string;
26
45
  requestedAt: string;
27
46
  capability: string;
28
47
  method: string;
48
+ change?: AccessChange;
49
+ }
50
+
51
+ /** What `access --json`, `status --json`, and the report to Kortix show. */
52
+ export interface AccessView extends AccessState {
53
+ keepAwakeSupported: boolean;
54
+ pendingRequest: Pick<AccessRequest, 'id' | 'capability' | 'requestedAt' | 'change'> | null;
29
55
  }
30
56
 
31
57
  /** How long a call waits for the owner to answer the prompt. */
@@ -34,6 +60,13 @@ export const ACCESS_HOLD_MS = 20_000;
34
60
  export const DESKTOP_APP_HEARTBEAT_MS = 30_000;
35
61
  /** A grant never lasts longer than this, whatever the file says. */
36
62
  export const MAX_GRANT_MS = 24 * 3_600_000;
63
+ /** "Deny" refuses calls for this long. */
64
+ export const DENY_MS = 10 * 60_000;
65
+ /**
66
+ * A request is shown to the owner this long. Longer than the hold: a late
67
+ * answer still applies to the agent's next try.
68
+ */
69
+ export const REQUEST_FRESH_MS = 60_000;
37
70
 
38
71
  const MODES: readonly AccessMode[] = ['ask', 'always', 'off'];
39
72
  const COMPAT: AccessState = { mode: 'always', grantedUntil: null, deniedUntil: null, keepAwake: false };
@@ -90,8 +123,117 @@ export function decideAccess(state: AccessState, now = Date.now()): AccessDecisi
90
123
  return granted > now && granted - now <= MAX_GRANT_MS ? 'run' : 'ask';
91
124
  }
92
125
 
126
+ const keepAwakeSupported = (os: NodeJS.Platform) => os === 'darwin' || os === 'linux';
127
+
128
+ /** A change as Kortix relays it: only the known fields, each with its type. Throws otherwise. */
129
+ export function parseAccessChange(params: unknown): AccessChange {
130
+ const raw = (params && typeof params === 'object' ? params : {}) as Record<string, unknown>;
131
+ const change: AccessChange = {};
132
+ if (raw.mode !== undefined) {
133
+ if (!MODES.includes(raw.mode as AccessMode)) throw new Error(`mode must be one of ${MODES.join(', ')}`);
134
+ change.mode = raw.mode as AccessMode;
135
+ }
136
+ if (raw.grantMinutes !== undefined) {
137
+ if (typeof raw.grantMinutes !== 'number' || !(raw.grantMinutes > 0)) throw new Error('grantMinutes must be a positive number');
138
+ change.grantMinutes = raw.grantMinutes;
139
+ }
140
+ for (const key of ['revoke', 'deny', 'keepAwake'] as const) {
141
+ if (raw[key] === undefined) continue;
142
+ if (typeof raw[key] !== 'boolean') throw new Error(`${key} must be true or false`);
143
+ change[key] = raw[key] as boolean;
144
+ }
145
+ if (Object.keys(change).length === 0) throw new Error('No access change given');
146
+ return change;
147
+ }
148
+
149
+ /** The terminal command that makes `change` on this machine. */
150
+ export function accessCommand(change: AccessChange): string {
151
+ const npx = 'npx @kortix/agent-tunnel@latest';
152
+ if (change.keepAwake !== undefined && Object.keys(change).length === 1) return `${npx} keep-awake ${change.keepAwake ? 'on' : 'off'}`;
153
+ const args = [change.mode, change.grantMinutes !== undefined ? `--allow-for ${change.grantMinutes}` : undefined];
154
+ return [`${npx} access`, ...args.filter(Boolean)].join(' ');
155
+ }
156
+
157
+ /** One owner action applied to the current state. Throws on bad input. */
158
+ export function nextAccess(current: AccessState, change: AccessChange, now = Date.now()): AccessState {
159
+ const next = { ...current };
160
+ if (change.mode !== undefined) {
161
+ if (!MODES.includes(change.mode)) throw new Error(`mode must be one of ${MODES.join(', ')}`);
162
+ // A grant or denial belongs to the mode it was given in: switching back
163
+ // to "Ask each time" must ask again.
164
+ if (change.mode !== current.mode) {
165
+ next.grantedUntil = null;
166
+ next.deniedUntil = null;
167
+ }
168
+ next.mode = change.mode;
169
+ }
170
+ if (change.grantMinutes !== undefined) {
171
+ const minutes = Number(change.grantMinutes);
172
+ if (!Number.isFinite(minutes) || minutes <= 0) throw new Error('grantMinutes must be a positive number');
173
+ next.grantedUntil = new Date(now + Math.min(minutes * 60_000, MAX_GRANT_MS)).toISOString();
174
+ next.deniedUntil = null;
175
+ }
176
+ if (change.revoke === true) next.grantedUntil = null;
177
+ if (change.deny === true) {
178
+ next.grantedUntil = null;
179
+ next.deniedUntil = new Date(now + DENY_MS).toISOString();
180
+ }
181
+ if (change.keepAwake !== undefined) next.keepAwake = change.keepAwake === true;
182
+ return next;
183
+ }
184
+
185
+ /**
186
+ * True when `next` lets calls run that `current` does not: `always` from
187
+ * another mode, or a grant that reaches further. Only the owner on this
188
+ * machine may widen access; Kortix may only narrow it.
189
+ */
190
+ export function accessWidens(current: AccessState, next: AccessState, now = Date.now()): boolean {
191
+ if (next.mode === 'always') return current.mode !== 'always';
192
+ if (next.mode !== 'ask') return false;
193
+ const reach = (state: AccessState) =>
194
+ state.mode === 'ask' && decideAccess(state, now) === 'run' ? Date.parse(state.grantedUntil!) : 0;
195
+ return reach(next) > reach(current);
196
+ }
197
+
198
+ /** The pending request, while the owner can still usefully answer it. */
199
+ export function pendingAccessRequest(home = agentTunnelHome(), now = Date.now()): AccessRequest | null {
200
+ const request = readAccessRequest(home);
201
+ return request && now - Date.parse(request.requestedAt) < REQUEST_FRESH_MS ? request : null;
202
+ }
203
+
204
+ export function accessView(home = agentTunnelHome(), os: NodeJS.Platform = platform(), now = Date.now()): AccessView {
205
+ const state = readAccess(home);
206
+ const request = pendingAccessRequest(home, now);
207
+ return {
208
+ ...state,
209
+ // A lapsed grant or denial is none: every reader sees the same answer.
210
+ grantedUntil: state.grantedUntil && Date.parse(state.grantedUntil) > now ? state.grantedUntil : null,
211
+ deniedUntil: state.deniedUntil && Date.parse(state.deniedUntil) > now ? state.deniedUntil : null,
212
+ keepAwakeSupported: keepAwakeSupported(os),
213
+ pendingRequest: request
214
+ ? { id: request.id, capability: request.capability, requestedAt: request.requestedAt, ...(request.change ? { change: request.change } : {}) }
215
+ : null,
216
+ };
217
+ }
218
+
219
+ /**
220
+ * One access change applied as given: the owner's own hand (the `access` and
221
+ * `keep-awake` commands), or a narrowing relayed by Kortix. It answers the
222
+ * pending request: a relayed change that `deny` declines is dropped without
223
+ * refusing calls; a call request is answered.
224
+ */
225
+ export function applyAccess(change: AccessChange, home = agentTunnelHome(), now = Date.now()): AccessView {
226
+ const request = readAccessRequest(home);
227
+ const declinesChange = change.deny === true && Boolean(request?.change);
228
+ const own = declinesChange ? { ...change, deny: undefined } : change;
229
+ writeAccess(nextAccess(readAccess(home), own, now), home);
230
+ const answers = own.mode !== undefined || own.grantMinutes !== undefined || change.deny === true;
231
+ if (request && answers && !(Date.parse(request.requestedAt) > now)) rmSync(accessRequestPath(home), { force: true });
232
+ return accessView(home, platform(), now);
233
+ }
234
+
93
235
  export function writeAccessRequest(
94
- input: { capability: string; method: string },
236
+ input: { capability: string; method: string; change?: AccessChange },
95
237
  home = agentTunnelHome(),
96
238
  ): AccessRequest {
97
239
  const request: AccessRequest = { id: randomUUID(), requestedAt: new Date().toISOString(), ...input };
@@ -135,11 +135,11 @@ describe('TunnelAgent auth close handling', () => {
135
135
 
136
136
  // ── v2: reliability (R1, R2, R4), access control (A2, A3) and X2 ───────────
137
137
 
138
- import { mkdtempSync, rmSync, existsSync as fileExists } from 'node:fs';
138
+ import { mkdtempSync, rmSync, writeFileSync, existsSync as fileExists } from 'node:fs';
139
139
  import { tmpdir } from 'node:os';
140
140
  import { join } from 'node:path';
141
141
  import { reconnectDelay } from './agent';
142
- import { writeAccess, accessRequestPath, readAccessRequest } from './access';
142
+ import { applyAccess, writeAccess, accessRequestPath, readAccess, readAccessRequest } from './access';
143
143
  import { signMessage } from '../shared/crypto';
144
144
 
145
145
  const SIGNING_KEY = 'test-signing-key';
@@ -527,11 +527,19 @@ describe('machine-side access control (A2, A3, X1, X2)', () => {
527
527
  writeAccess({ mode: 'ask', grantedUntil: null, deniedUntil: null, keepAwake: false }, home);
528
528
  await withOnlineAgent(home, async (relay) => {
529
529
  await until(() => relay.notifications('tunnel.access.state').length === 1);
530
- expect(relay.notifications('tunnel.access.state')[0].params).toEqual({ mode: 'ask', grantedUntil: null });
530
+ expect(relay.notifications('tunnel.access.state')[0].params).toEqual({
531
+ mode: 'ask',
532
+ grantedUntil: null,
533
+ deniedUntil: null,
534
+ keepAwake: false,
535
+ keepAwakeSupported: process.platform !== 'win32',
536
+ pendingRequest: null,
537
+ desktopPermissions: null,
538
+ });
531
539
  expect(typeof relay.notifications('tunnel.access.state')[0]._sig).toBe('string');
532
540
  writeAccess({ mode: 'off', grantedUntil: null, deniedUntil: null, keepAwake: false }, home);
533
541
  await until(() => relay.notifications('tunnel.access.state').length === 2);
534
- expect(relay.notifications('tunnel.access.state')[1].params).toEqual({ mode: 'off', grantedUntil: null });
542
+ expect(relay.notifications('tunnel.access.state')[1].params).toMatchObject({ mode: 'off', grantedUntil: null });
535
543
  });
536
544
  } finally {
537
545
  rmSync(home, { recursive: true, force: true });
@@ -539,6 +547,128 @@ describe('machine-side access control (A2, A3, X1, X2)', () => {
539
547
  });
540
548
  });
541
549
 
550
+ describe('access changes relayed by Kortix (tunnel.access.set)', () => {
551
+ async function withAgent(home: string, run: (relay: FakeRelay) => Promise<void>, registry = new CapabilityRegistry()) {
552
+ FakeRelay.sockets = [];
553
+ globalThis.WebSocket = FakeRelay as unknown as typeof WebSocket;
554
+ const agent = new TunnelAgent(loadConfig(TEST_CONFIG), registry, {}, { home, accessHoldMs: 600, watchdogIntervalMs: 20 });
555
+ await quiet(async () => {
556
+ try {
557
+ agent.connect();
558
+ await until(() => FakeRelay.sockets[0]?.sent.some((m) => m.type === 'auth') === true);
559
+ await until(() => FakeRelay.sockets[0]!.notifications('tunnel.access.state').length >= 1);
560
+ await run(FakeRelay.sockets[0]!);
561
+ } finally {
562
+ agent.disconnect();
563
+ rmSync(home, { recursive: true, force: true });
564
+ }
565
+ });
566
+ }
567
+ const set = (relay: FakeRelay, id: string, params: Record<string, unknown>) =>
568
+ relay.deliverSigned({ jsonrpc: '2.0', id, method: 'tunnel.access.set', params });
569
+ /** A desktop app that is running: the agent will not launch anything. */
570
+ const runningApp = (home: string) =>
571
+ writeFileSync(join(home, 'desktop-app.json'), JSON.stringify({ command: process.execPath, args: [], pid: process.pid, env: {} }));
572
+
573
+ test('says in its handshake that it serves access changes', async () => {
574
+ await withAgent(mkdtempSync(join(tmpdir(), 'agent-tunnel-set-')), async (relay) => {
575
+ expect(relay.sent.find((m) => m.type === 'auth')).toMatchObject({ reportsAccess: true, controlsAccess: true });
576
+ });
577
+ });
578
+
579
+ test('a narrowing applies at once, even while access is off, and reports the new state', async () => {
580
+ const home = mkdtempSync(join(tmpdir(), 'agent-tunnel-set-'));
581
+ writeAccess({ mode: 'off', grantedUntil: null, deniedUntil: null, keepAwake: false }, home);
582
+ await withAgent(home, async (relay) => {
583
+ set(relay, 's1', { mode: 'ask', keepAwake: true });
584
+ await until(() => relay.responses('s1').length === 1);
585
+ expect(relay.responses('s1')[0].result).toMatchObject({ mode: 'ask', keepAwake: true, pendingRequest: null });
586
+ expect(readAccess(home)).toMatchObject({ mode: 'ask', keepAwake: true });
587
+ await until(() => relay.notifications('tunnel.access.state').some((n) => n.params.mode === 'ask'));
588
+ });
589
+ });
590
+
591
+ test('a widening without the desktop app is refused with the command to run on the computer', async () => {
592
+ const home = mkdtempSync(join(tmpdir(), 'agent-tunnel-set-'));
593
+ writeAccess({ mode: 'ask', grantedUntil: null, deniedUntil: null, keepAwake: false }, home);
594
+ await withAgent(home, async (relay) => {
595
+ set(relay, 's1', { mode: 'always' });
596
+ await until(() => relay.responses('s1').length === 1);
597
+ expect(relay.responses('s1')[0].error.code).toBe(-32014);
598
+ expect(relay.responses('s1')[0].error.message).toContain('npx @kortix/agent-tunnel@latest access always');
599
+ expect(readAccess(home).mode).toBe('ask');
600
+ expect(readAccessRequest(home)).toBeNull();
601
+ });
602
+ });
603
+
604
+ test('a widening waits for the owner on the computer: allowed there, it returns the new state', async () => {
605
+ const home = mkdtempSync(join(tmpdir(), 'agent-tunnel-set-'));
606
+ writeAccess({ mode: 'ask', grantedUntil: null, deniedUntil: null, keepAwake: false }, home);
607
+ runningApp(home);
608
+ await withAgent(home, async (relay) => {
609
+ set(relay, 's1', { mode: 'always' });
610
+ await until(() => readAccessRequest(home)?.change?.mode === 'always');
611
+ // Kortix sees what the owner is asked.
612
+ await until(() => relay.notifications('tunnel.access.state').some((n) => n.params.pendingRequest?.change?.mode === 'always'));
613
+ // The desktop app runs `access always` after the owner clicks Allow.
614
+ applyAccess({ mode: 'always' }, home);
615
+ await until(() => relay.responses('s1').length === 1);
616
+ expect(relay.responses('s1')[0].result).toMatchObject({ mode: 'always', pendingRequest: null });
617
+ });
618
+ });
619
+
620
+ test('a widening the owner declines, or nobody answers, is refused and changes nothing', async () => {
621
+ const home = mkdtempSync(join(tmpdir(), 'agent-tunnel-set-'));
622
+ writeAccess({ mode: 'ask', grantedUntil: null, deniedUntil: null, keepAwake: false }, home);
623
+ runningApp(home);
624
+ await withAgent(home, async (relay) => {
625
+ set(relay, 's1', { grantMinutes: 60 });
626
+ await until(() => readAccessRequest(home) !== null);
627
+ applyAccess({ deny: true }, home);
628
+ await until(() => relay.responses('s1').length === 1);
629
+ expect(relay.responses('s1')[0].error).toMatchObject({ code: -32014 });
630
+ expect(relay.responses('s1')[0].error.message).toContain('declined');
631
+ // Declining a change does not refuse calls.
632
+ expect(readAccess(home)).toMatchObject({ grantedUntil: null, deniedUntil: null });
633
+
634
+ set(relay, 's2', { mode: 'always' });
635
+ await until(() => relay.responses('s2').length === 1, 3_000);
636
+ expect(relay.responses('s2')[0].error.message).toContain('Nobody confirmed');
637
+ expect(readAccessRequest(home)).toBeNull();
638
+ expect(readAccess(home).mode).toBe('ask');
639
+ });
640
+ });
641
+
642
+ test('bad input is refused without touching access.json', async () => {
643
+ const home = mkdtempSync(join(tmpdir(), 'agent-tunnel-set-'));
644
+ writeAccess({ mode: 'ask', grantedUntil: null, deniedUntil: null, keepAwake: false }, home);
645
+ await withAgent(home, async (relay) => {
646
+ set(relay, 's1', { mode: 'sometimes' });
647
+ await until(() => relay.responses('s1').length === 1);
648
+ expect(relay.responses('s1')[0].error.message).toContain('mode');
649
+ expect(readAccess(home).mode).toBe('ask');
650
+ });
651
+ });
652
+
653
+ test('reports the macOS permissions as this process holds them', async () => {
654
+ const registry = new CapabilityRegistry();
655
+ registry.register({
656
+ name: 'desktop',
657
+ methods: new Map([
658
+ ['desktop.cua.call', async (params: Record<string, unknown>) => {
659
+ expect(params).toMatchObject({ tool: 'check_permissions', __permission: { capability: 'desktop' } });
660
+ return { accessibility: true, screen_recording: false };
661
+ }],
662
+ ]),
663
+ });
664
+ await withAgent(mkdtempSync(join(tmpdir(), 'agent-tunnel-probe-')), async (relay) => {
665
+ await until(() => relay.notifications('tunnel.access.state').some((n) => n.params.desktopPermissions));
666
+ const last = relay.notifications('tunnel.access.state').at(-1)!;
667
+ expect(last.params.desktopPermissions).toEqual({ accessibility: true, screenRecording: false });
668
+ }, registry);
669
+ });
670
+ });
671
+
542
672
  describe('contract v2 review fixes', () => {
543
673
  test('standby backs off, doubling, so two holders of one credential stop trading the socket', async () => {
544
674
  globalThis.WebSocket = closingWebSocket(4004, 'replaced') as unknown as typeof WebSocket;
@@ -624,9 +754,9 @@ describe('contract v2 review fixes', () => {
624
754
  try {
625
755
  agent.connect();
626
756
  await until(() => FakeRelay.sockets[0]?.notifications('tunnel.access.state').length === 1);
627
- expect(FakeRelay.sockets[0]!.notifications('tunnel.access.state')[0].params).toEqual({ mode: 'ask', grantedUntil: grant });
757
+ expect(FakeRelay.sockets[0]!.notifications('tunnel.access.state')[0].params).toMatchObject({ mode: 'ask', grantedUntil: grant });
628
758
  await until(() => FakeRelay.sockets[0]!.notifications('tunnel.access.state').length === 2, 2_000);
629
- expect(FakeRelay.sockets[0]!.notifications('tunnel.access.state')[1].params).toEqual({ mode: 'ask', grantedUntil: null });
759
+ expect(FakeRelay.sockets[0]!.notifications('tunnel.access.state')[1].params).toMatchObject({ mode: 'ask', grantedUntil: null });
630
760
  } finally {
631
761
  agent.disconnect();
632
762
  rmSync(home, { recursive: true, force: true });