@kortix/agent-tunnel 0.13.52 → 0.13.54
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/README.md +74 -14
- package/dist/agent-cli.js +457 -246
- package/package.json +1 -1
- package/src/agent/access.test.ts +89 -0
- package/src/agent/access.ts +146 -4
- package/src/agent/agent.test.ts +136 -6
- package/src/agent/agent.ts +132 -12
- package/src/agent/capabilities/desktop/cua-driver.test.ts +43 -1
- package/src/agent/capabilities/desktop/cua-driver.ts +44 -4
- package/src/agent/capabilities/desktop.ts +41 -68
- package/src/agent/capabilities/enabled-registry.ts +2 -1
- package/src/agent/capabilities/security.test.ts +54 -16
- package/src/agent/cli-help.test.ts +53 -2
- package/src/agent/cli.ts +127 -19
- package/src/agent/self-restart.test.ts +24 -0
- package/src/agent/self-restart.ts +47 -0
- package/src/agent/service.test.ts +27 -1
- package/src/agent/service.ts +22 -1
- package/src/agent/state-file.test.ts +11 -1
- package/src/agent/state-file.ts +13 -1
- package/src/index.ts +1 -1
- package/src/shared/index.ts +1 -1
- package/src/shared/permissions.ts +26 -58
- package/src/shared/types.ts +8 -36
package/package.json
CHANGED
package/src/agent/access.test.ts
CHANGED
|
@@ -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
|
+
});
|
package/src/agent/access.ts
CHANGED
|
@@ -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.
|
|
12
|
-
* it
|
|
13
|
-
*
|
|
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 };
|
package/src/agent/agent.test.ts
CHANGED
|
@@ -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({
|
|
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).
|
|
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).
|
|
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).
|
|
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 });
|