@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/README.md +74 -14
- package/dist/agent-cli.js +464 -249
- 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 +143 -13
- 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/credential-probe.ts +4 -4
- package/src/agent/self-restart.test.ts +24 -0
- package/src/agent/self-restart.ts +47 -0
- package/src/agent/service-lifecycle.test.ts +46 -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
|
@@ -72,7 +72,7 @@ describe('local capability permission enforcement', () => {
|
|
|
72
72
|
() => '/trusted/cua-driver',
|
|
73
73
|
);
|
|
74
74
|
expect(registry.getCapabilityNames()).toEqual(['desktop']);
|
|
75
|
-
expect(registry.getHandler('desktop.cua.
|
|
75
|
+
expect(registry.getHandler('desktop.cua.call')).not.toBeNull();
|
|
76
76
|
});
|
|
77
77
|
test('a permission scope cannot widen the local filesystem ceiling', async () => {
|
|
78
78
|
const handler = createFilesystemCapability(config()).methods.get('fs.read')!;
|
|
@@ -154,19 +154,59 @@ describe('local capability permission enforcement', () => {
|
|
|
154
154
|
).rejects.toThrow('not in the allowed commands list');
|
|
155
155
|
});
|
|
156
156
|
|
|
157
|
-
test('desktop feature
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
157
|
+
test('a desktop grant allows every driver tool, and a legacy feature scope no longer narrows it', async () => {
|
|
158
|
+
const binary = join(root, 'fake-call-driver');
|
|
159
|
+
const log = join(root, 'call-args.jsonl');
|
|
160
|
+
await writeFile(binary, [
|
|
161
|
+
'#!/usr/bin/env node',
|
|
162
|
+
`require('node:fs').appendFileSync(${JSON.stringify(log)}, JSON.stringify(process.argv.slice(2, 4)) + '\\n');`,
|
|
163
|
+
`process.stdout.write('{"ok":true}');`,
|
|
164
|
+
].join('\n'));
|
|
165
|
+
await chmod(binary, 0o700);
|
|
166
|
+
const previousBinary = process.env.CUA_DRIVER_BIN;
|
|
167
|
+
process.env.CUA_DRIVER_BIN = binary;
|
|
168
|
+
try {
|
|
169
|
+
const call = createDesktopCapability().methods.get('desktop.cua.call')!;
|
|
170
|
+
const __permission = { permissionId: 'p', capability: 'desktop', scope: { features: ['screenshot'] } };
|
|
171
|
+
// health_report was never in Kortix's tool list; the driver owns that list.
|
|
172
|
+
expect(await call({ tool: 'health_report', args: {}, __permission })).toEqual({ ok: true });
|
|
173
|
+
await expect(call({ tool: 'install_ffmpeg', __permission })).rejects.toThrow('local-only');
|
|
174
|
+
await expect(call({ tool: 'click', __permission: { ...__permission, capability: 'shell' } }))
|
|
175
|
+
.rejects.toThrow('desktop permission required');
|
|
176
|
+
const calls = (await readFile(log, 'utf8')).trim().split('\n').map((line) => JSON.parse(line));
|
|
177
|
+
expect(calls.at(-1)).toEqual(['call', 'health_report']);
|
|
178
|
+
} finally {
|
|
179
|
+
if (previousBinary === undefined) delete process.env.CUA_DRIVER_BIN;
|
|
180
|
+
else process.env.CUA_DRIVER_BIN = previousBinary;
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test('a failed call reports missing macOS grants as computer_desktop_permission_missing (-32013)', async () => {
|
|
185
|
+
const binary = join(root, 'fake-untrusted-driver');
|
|
186
|
+
await writeFile(binary, [
|
|
187
|
+
'#!/usr/bin/env node',
|
|
188
|
+
'const [command, tool] = process.argv.slice(2);',
|
|
189
|
+
`if (command === 'call' && tool === 'check_permissions') process.stdout.write('{"accessibility":false,"screen_recording":false}');`,
|
|
190
|
+
`else if (command === 'call') { process.stderr.write('AX is not trusted'); process.exitCode = 1; }`,
|
|
191
|
+
`else process.stdout.write('running');`,
|
|
192
|
+
].join('\n'));
|
|
193
|
+
await chmod(binary, 0o700);
|
|
194
|
+
const previousBinary = process.env.CUA_DRIVER_BIN;
|
|
195
|
+
process.env.CUA_DRIVER_BIN = binary;
|
|
196
|
+
let restarts = 0;
|
|
197
|
+
try {
|
|
198
|
+
const call = createDesktopCapability({ onPermissionMissing: () => restarts++ }).methods.get('desktop.cua.call')!;
|
|
199
|
+
const error = await call({ tool: 'click', args: { pid: 1 }, __permission: { permissionId: 'p', capability: 'desktop', scope: {} } })
|
|
200
|
+
.catch((err: unknown) => err as Error & { code?: number });
|
|
201
|
+
expect((error as Error).message).toBe(
|
|
202
|
+
'computer_desktop_permission_missing: macOS has not given CuaDriver Accessibility and Screen Recording on this computer.',
|
|
203
|
+
);
|
|
204
|
+
expect((error as { code?: number }).code).toBe(-32013);
|
|
205
|
+
expect(restarts).toBe(1);
|
|
206
|
+
} finally {
|
|
207
|
+
if (previousBinary === undefined) delete process.env.CUA_DRIVER_BIN;
|
|
208
|
+
else process.env.CUA_DRIVER_BIN = previousBinary;
|
|
209
|
+
}
|
|
170
210
|
});
|
|
171
211
|
|
|
172
212
|
test('desktop discovery dispatches to driver metadata commands and preserves unsupported output', async () => {
|
|
@@ -195,8 +235,6 @@ describe('local capability permission enforcement', () => {
|
|
|
195
235
|
scope: { features: ['computer_use'] },
|
|
196
236
|
};
|
|
197
237
|
await expect(list({})).rejects.toThrow('desktop permission required');
|
|
198
|
-
await expect(list({ __permission: { ...__permission, scope: { features: ['screenshot'] } } }))
|
|
199
|
-
.rejects.toThrow('not allowed');
|
|
200
238
|
expect(await list({ __permission })).toEqual({ tools: 'double_click' });
|
|
201
239
|
expect(await describeTool({ tool: 'double_click', __permission }))
|
|
202
240
|
.toEqual({ description: 'Double click coordinates' });
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import { spawnSync } from "child_process";
|
|
3
|
-
import { mkdtempSync, rmSync } from "fs";
|
|
3
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync } from "fs";
|
|
4
4
|
import { tmpdir } from "os";
|
|
5
5
|
import { join, resolve } from "path";
|
|
6
6
|
|
|
@@ -20,6 +20,8 @@ const DOCUMENTED_SURFACE = [
|
|
|
20
20
|
"status",
|
|
21
21
|
"logs",
|
|
22
22
|
"logout",
|
|
23
|
+
"access",
|
|
24
|
+
"keep-awake",
|
|
23
25
|
"--daemon",
|
|
24
26
|
"--foreground",
|
|
25
27
|
] as const;
|
|
@@ -84,6 +86,55 @@ describe("agent tunnel service UX", () => {
|
|
|
84
86
|
);
|
|
85
87
|
|
|
86
88
|
expect(result.status).toBe(2);
|
|
87
|
-
expect(result.stderr).toContain("--keep-awake is not
|
|
89
|
+
expect(result.stderr).toContain("--keep-awake is not a flag. Run: agent-tunnel keep-awake on");
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe("access and keep-awake: the one way to change them on this computer", () => {
|
|
94
|
+
const run = (home: string, ...args: string[]) =>
|
|
95
|
+
spawnSync("bun", ["run", CLI_PATH, ...args], {
|
|
96
|
+
encoding: "utf8",
|
|
97
|
+
env: { ...process.env, AGENT_TUNNEL_HOME: home },
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("changes access and keep awake, prints the state, and writes access.json", () => {
|
|
101
|
+
const home = mkdtempSync(join(tmpdir(), "agent-tunnel-cli-access-"));
|
|
102
|
+
try {
|
|
103
|
+
const shown = JSON.parse(run(home, "access", "--json").stdout);
|
|
104
|
+
expect(shown).toMatchObject({ mode: "always", keepAwake: false, pendingRequest: null });
|
|
105
|
+
|
|
106
|
+
const ask = run(home, "access", "ask", "--json");
|
|
107
|
+
expect(ask.status).toBe(0);
|
|
108
|
+
expect(JSON.parse(ask.stdout).mode).toBe("ask");
|
|
109
|
+
|
|
110
|
+
const granted = JSON.parse(run(home, "access", "--allow-for", "60", "--json").stdout);
|
|
111
|
+
expect(Date.parse(granted.grantedUntil) - Date.now()).toBeGreaterThan(59 * 60_000);
|
|
112
|
+
|
|
113
|
+
expect(JSON.parse(run(home, "access", "--revoke", "--json").stdout).grantedUntil).toBeNull();
|
|
114
|
+
expect(JSON.parse(run(home, "keep-awake", "on", "--json").stdout).keepAwake).toBe(true);
|
|
115
|
+
expect(JSON.parse(readFileSync(join(home, "access.json"), "utf8"))).toMatchObject({ mode: "ask", keepAwake: true });
|
|
116
|
+
|
|
117
|
+
const status = JSON.parse(run(home, "status", "--json").stdout);
|
|
118
|
+
expect(status.access).toMatchObject({ mode: "ask", keepAwake: true, keepAwakeSupported: process.platform !== "win32" });
|
|
119
|
+
} finally {
|
|
120
|
+
rmSync(home, { recursive: true, force: true });
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("refuses a wrong mode or duration with exit 1 and changes nothing", () => {
|
|
125
|
+
const home = mkdtempSync(join(tmpdir(), "agent-tunnel-cli-access-"));
|
|
126
|
+
try {
|
|
127
|
+
const mode = run(home, "access", "sometimes");
|
|
128
|
+
expect(mode.status).toBe(1);
|
|
129
|
+
expect(mode.stderr).toContain("Access is one of: ask, always, off");
|
|
130
|
+
const minutes = run(home, "access", "--allow-for", "soon");
|
|
131
|
+
expect(minutes.status).toBe(1);
|
|
132
|
+
expect(minutes.stderr).toContain("--allow-for takes minutes");
|
|
133
|
+
const awake = run(home, "keep-awake", "maybe");
|
|
134
|
+
expect(awake.status).toBe(1);
|
|
135
|
+
expect(existsSync(join(home, "access.json"))).toBe(false);
|
|
136
|
+
} finally {
|
|
137
|
+
rmSync(home, { recursive: true, force: true });
|
|
138
|
+
}
|
|
88
139
|
});
|
|
89
140
|
});
|
package/src/agent/cli.ts
CHANGED
|
@@ -4,7 +4,17 @@ import { hostname } from 'os';
|
|
|
4
4
|
import { join } from 'path';
|
|
5
5
|
|
|
6
6
|
import { PAIR_AGAIN_COMMAND, TunnelAgent } from './agent';
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
accessFilePath,
|
|
9
|
+
accessRequestPath,
|
|
10
|
+
accessView,
|
|
11
|
+
applyAccess,
|
|
12
|
+
desktopAppPath,
|
|
13
|
+
readAccess,
|
|
14
|
+
writeAccess,
|
|
15
|
+
type AccessChange,
|
|
16
|
+
type AccessView,
|
|
17
|
+
} from './access';
|
|
8
18
|
import { printStartupBanner } from './banner';
|
|
9
19
|
import { createEnabledCapabilityRegistry } from './capabilities/enabled-registry';
|
|
10
20
|
import { loadConfig, type TunnelConfig } from './config';
|
|
@@ -18,12 +28,12 @@ import {
|
|
|
18
28
|
requestDeviceAuthorization,
|
|
19
29
|
} from './device-auth';
|
|
20
30
|
import { collapseRepeatedLines, isShellStartupNoise } from './log-format';
|
|
21
|
-
import { anyFlag, isInteractiveTerminal, isTruthyFlag
|
|
31
|
+
import { anyFlag, isInteractiveTerminal, isTruthyFlag } from './prompts';
|
|
22
32
|
import {
|
|
23
|
-
DEFAULT_INSTALL_BACKGROUND_SERVICE,
|
|
24
33
|
agentTunnelHome,
|
|
25
34
|
getServicePaths,
|
|
26
35
|
getServiceStatus,
|
|
36
|
+
installedAppRunner,
|
|
27
37
|
rotateServiceLogs,
|
|
28
38
|
serviceLogFiles,
|
|
29
39
|
} from './service';
|
|
@@ -34,6 +44,7 @@ import {
|
|
|
34
44
|
describeService,
|
|
35
45
|
renderServiceAction,
|
|
36
46
|
} from './service-control';
|
|
47
|
+
import { watchForRestart } from './self-restart';
|
|
37
48
|
import { readAgentState, writeAgentState } from './state-file';
|
|
38
49
|
import { blankLine, c, clearScreen, field, glyph, stripAnsi } from './terminal';
|
|
39
50
|
import { agentTunnelVersion } from './version';
|
|
@@ -43,16 +54,22 @@ const BACKGROUND_FLAGS = ['daemon', 'service', 'background', 'always-online'] as
|
|
|
43
54
|
const FOREGROUND_FLAGS = ['foreground', 'no-daemon', 'no-service', 'no-background'] as const;
|
|
44
55
|
const DEFAULT_LOG_LINES = 60;
|
|
45
56
|
|
|
57
|
+
/** `_` holds the words after the command (`access always` → `_: 'always'`). */
|
|
46
58
|
type Flags = Record<string, string>;
|
|
47
59
|
|
|
48
60
|
function parseArgs(argv: string[]): { command: string; flags: Flags } {
|
|
49
61
|
const flags: Flags = {};
|
|
62
|
+
const words: string[] = [];
|
|
50
63
|
for (let i = 3; i < argv.length; i++) {
|
|
51
64
|
const arg = argv[i];
|
|
52
|
-
if (!arg.startsWith('--'))
|
|
65
|
+
if (!arg.startsWith('--')) {
|
|
66
|
+
words.push(arg);
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
53
69
|
const next = argv[i + 1];
|
|
54
70
|
flags[arg.slice(2)] = next && !next.startsWith('--') ? argv[++i] : 'true';
|
|
55
71
|
}
|
|
72
|
+
if (words.length > 0) flags._ = words.join(' ');
|
|
56
73
|
return { command: argv[2] || 'help', flags };
|
|
57
74
|
}
|
|
58
75
|
|
|
@@ -90,7 +107,17 @@ function startAgent(config: TunnelConfig, options: { service?: boolean } = {}):
|
|
|
90
107
|
process.stdout.write = process.stderr.write.bind(process.stderr) as typeof process.stdout.write;
|
|
91
108
|
options = { ...options, service: true };
|
|
92
109
|
}
|
|
93
|
-
|
|
110
|
+
let agent: TunnelAgent | null = null;
|
|
111
|
+
const restart = options.service
|
|
112
|
+
? watchForRestart((reason) => {
|
|
113
|
+
process.stdout.write(`[agent-tunnel] restarting: ${reason}\n`);
|
|
114
|
+
agent?.disconnect();
|
|
115
|
+
process.exit(0);
|
|
116
|
+
})
|
|
117
|
+
: null;
|
|
118
|
+
const registry = createEnabledCapabilityRegistry(config, undefined, {
|
|
119
|
+
onPermissionMissing: restart?.permissionMissing,
|
|
120
|
+
});
|
|
94
121
|
if (config.enabledCapabilities?.includes('desktop') && !registry.has('desktop')) {
|
|
95
122
|
console.error(
|
|
96
123
|
'[agent-tunnel] Computer Use is approved but unavailable: install the trusted cua-driver locally, then restart Agent Tunnel.',
|
|
@@ -111,7 +138,7 @@ function startAgent(config: TunnelConfig, options: { service?: boolean } = {}):
|
|
|
111
138
|
|
|
112
139
|
// A service never stops on its own (R2): a refused credential waits in
|
|
113
140
|
// `rejected` and re-reads config.json, so pairing again heals it.
|
|
114
|
-
|
|
141
|
+
agent = new TunnelAgent(
|
|
115
142
|
config,
|
|
116
143
|
registry,
|
|
117
144
|
// The agent passes the credential it uses NOW: a re-pair picked up by a
|
|
@@ -126,7 +153,7 @@ function startAgent(config: TunnelConfig, options: { service?: boolean } = {}):
|
|
|
126
153
|
|
|
127
154
|
const shutdown = () => {
|
|
128
155
|
if (!options.service) console.log(`\n${c.dim} Shutting down…${c.reset}`);
|
|
129
|
-
agent
|
|
156
|
+
agent?.disconnect();
|
|
130
157
|
process.exit(0);
|
|
131
158
|
};
|
|
132
159
|
process.on('SIGTERM', shutdown);
|
|
@@ -135,26 +162,38 @@ function startAgent(config: TunnelConfig, options: { service?: boolean } = {}):
|
|
|
135
162
|
|
|
136
163
|
// ── pairing ──────────────────────────────────────────────────────────────────
|
|
137
164
|
|
|
138
|
-
|
|
165
|
+
/**
|
|
166
|
+
* A person at a terminal gets what the desktop app gives: a background
|
|
167
|
+
* connection, no question asked, and the exact commands that pause, resume,
|
|
168
|
+
* and remove it. `--foreground` keeps it in this terminal only. A script
|
|
169
|
+
* (no TTY) or `--json` keeps the foreground default it always had.
|
|
170
|
+
*/
|
|
171
|
+
function chooseBackgroundMode(flags: Flags): boolean {
|
|
139
172
|
if (anyFlag(flags, BACKGROUND_FLAGS)) return true;
|
|
140
173
|
if (anyFlag(flags, FOREGROUND_FLAGS)) return false;
|
|
141
|
-
|
|
174
|
+
return !jsonMode && isInteractiveTerminal();
|
|
175
|
+
}
|
|
142
176
|
|
|
177
|
+
function printServiceControls(): void {
|
|
178
|
+
const npx = 'npx @kortix/agent-tunnel@latest';
|
|
179
|
+
console.log(` ${c.dim}Runs in the background: starts at login, restarts after failures, keeps running after this terminal closes.${c.reset}`);
|
|
180
|
+
if (installedAppRunner()) {
|
|
181
|
+
console.log(` ${c.dim}It runs on the Kortix app on this Mac: same connection, same macOS permissions. Control it from Your computer in the app.${c.reset}`);
|
|
182
|
+
}
|
|
143
183
|
blankLine();
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
184
|
+
field('Pause', `${c.white}${npx} stop${c.reset}`);
|
|
185
|
+
field('Resume', `${c.white}${npx} start${c.reset}`);
|
|
186
|
+
field('Disconnect', `${c.white}${npx} logout${c.reset}`);
|
|
147
187
|
blankLine();
|
|
148
|
-
|
|
149
|
-
return promptYesNo(' Install the background service now?', DEFAULT_INSTALL_BACKGROUND_SERVICE);
|
|
150
188
|
}
|
|
151
189
|
|
|
152
190
|
/** Starts the agent the way the caller asked for, and returns. */
|
|
153
191
|
async function launch(config: TunnelConfig, flags: Flags, lease?: { serviceWasActive: boolean }): Promise<void> {
|
|
154
|
-
if (
|
|
192
|
+
if (chooseBackgroundMode(flags)) {
|
|
155
193
|
saveCredentials(config.tunnelId, config.token, config.apiUrl);
|
|
156
194
|
if (!jsonMode) {
|
|
157
195
|
renderServiceAction('install', SERVICE_ACTIONS.install.run());
|
|
196
|
+
printServiceControls();
|
|
158
197
|
return;
|
|
159
198
|
}
|
|
160
199
|
// Return instead of process.exit(): an exit can cut off a piped stdout.
|
|
@@ -408,7 +447,7 @@ function commandStatus(flags: Flags): void {
|
|
|
408
447
|
serviceLabel: getServicePaths().label,
|
|
409
448
|
service,
|
|
410
449
|
state: readAgentState(),
|
|
411
|
-
access:
|
|
450
|
+
access: accessView(),
|
|
412
451
|
lastActivity: lastServiceActivity(),
|
|
413
452
|
}, null, 2));
|
|
414
453
|
return;
|
|
@@ -566,6 +605,69 @@ function commandServiceAction(action: ServiceAction, flags: Flags): void {
|
|
|
566
605
|
renderServiceAction(action, SERVICE_ACTIONS[action].run());
|
|
567
606
|
}
|
|
568
607
|
|
|
608
|
+
/** "Ask each time · allowed until 14:05", for a person at a terminal. */
|
|
609
|
+
function describeAccess(view: AccessView): string {
|
|
610
|
+
const clock = (iso: string) => new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
611
|
+
if (view.mode === 'always') return `${c.white}Always allowed${c.reset}`;
|
|
612
|
+
if (view.mode === 'off') return `${c.white}Off${c.reset} ${c.dim}(every agent call is refused)${c.reset}`;
|
|
613
|
+
const now = view.grantedUntil
|
|
614
|
+
? `allowed until ${clock(view.grantedUntil)}`
|
|
615
|
+
: view.deniedUntil
|
|
616
|
+
? `denied until ${clock(view.deniedUntil)}`
|
|
617
|
+
: 'asks before the next call';
|
|
618
|
+
return `${c.white}Ask each time${c.reset} ${c.dim}· ${now}${c.reset}`;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function printAccess(view: AccessView, flags: Flags): void {
|
|
622
|
+
if (isTruthyFlag(flags.json)) {
|
|
623
|
+
console.log(JSON.stringify(view, null, 2));
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
const npx = 'npx @kortix/agent-tunnel@latest';
|
|
627
|
+
blankLine();
|
|
628
|
+
field('access', describeAccess(view));
|
|
629
|
+
field('keep awake', view.keepAwakeSupported ? (view.keepAwake ? `${c.white}on${c.reset}` : `${c.white}off${c.reset}`) : `${c.dim}not available on this system${c.reset}`);
|
|
630
|
+
if (view.pendingRequest) {
|
|
631
|
+
field('waiting', `${c.white}${view.pendingRequest.change ? 'a change asked for through Kortix' : `an agent call (${view.pendingRequest.capability})`}${c.reset}`);
|
|
632
|
+
field('', `${c.dim}${npx} access --allow-for 60 or --deny${c.reset}`);
|
|
633
|
+
}
|
|
634
|
+
blankLine();
|
|
635
|
+
console.log(` ${c.dim}Change it:${c.reset} ${c.white}${npx} access ask|always|off${c.reset}${c.dim}, --allow-for <minutes>, --revoke, --deny${c.reset}`);
|
|
636
|
+
blankLine();
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/**
|
|
640
|
+
* `access [ask|always|off] [--allow-for <minutes>] [--revoke] [--deny]`: when
|
|
641
|
+
* Kortix may use this computer. The person at this machine is its owner, so
|
|
642
|
+
* this widens access too; the desktop app runs this command after its own
|
|
643
|
+
* confirmation. No change prints the current state.
|
|
644
|
+
*/
|
|
645
|
+
function commandAccess(flags: Flags): void {
|
|
646
|
+
const change: AccessChange = {};
|
|
647
|
+
if (flags._) change.mode = flags._ as AccessChange['mode'];
|
|
648
|
+
if (flags['allow-for'] !== undefined) {
|
|
649
|
+
change.grantMinutes = Number(flags['allow-for']);
|
|
650
|
+
if (!(change.grantMinutes > 0)) fail('--allow-for takes minutes, at most 1440. Example: agent-tunnel access --allow-for 60');
|
|
651
|
+
}
|
|
652
|
+
if (isTruthyFlag(flags.revoke)) change.revoke = true;
|
|
653
|
+
if (isTruthyFlag(flags.deny)) change.deny = true;
|
|
654
|
+
if (Object.keys(change).length === 0) return printAccess(accessView(), flags);
|
|
655
|
+
if (change.mode !== undefined && !['ask', 'always', 'off'].includes(change.mode)) {
|
|
656
|
+
fail('Access is one of: ask, always, off. Example: agent-tunnel access ask');
|
|
657
|
+
}
|
|
658
|
+
// A grant applies in "Ask each time"; from another mode it means "ask, allowed for now".
|
|
659
|
+
if (change.grantMinutes !== undefined && change.mode === undefined && readAccess().mode !== 'ask') change.mode = 'ask';
|
|
660
|
+
printAccess(applyAccess(change), flags);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/** `keep-awake [on|off]`: hold off system sleep while the agent runs (macOS, Linux). */
|
|
664
|
+
function commandKeepAwake(flags: Flags): void {
|
|
665
|
+
if (flags._ === undefined) return printAccess(accessView(), flags);
|
|
666
|
+
if (flags._ !== 'on' && flags._ !== 'off') fail('Keep awake is on or off. Example: agent-tunnel keep-awake on');
|
|
667
|
+
if (!accessView().keepAwakeSupported) fail('Keep awake is not available on this system yet.');
|
|
668
|
+
printAccess(applyAccess({ keepAwake: flags._ === 'on' }), flags);
|
|
669
|
+
}
|
|
670
|
+
|
|
569
671
|
// ── dispatch ─────────────────────────────────────────────────────────────────
|
|
570
672
|
|
|
571
673
|
interface Command {
|
|
@@ -581,6 +683,11 @@ const COMMANDS: Record<string, Command> = {
|
|
|
581
683
|
run: commandConnect,
|
|
582
684
|
},
|
|
583
685
|
status: { summary: 'Show pairing, capabilities, and service state (--json)', run: commandStatus },
|
|
686
|
+
access: {
|
|
687
|
+
summary: 'When Kortix may use this computer: ask, always, off (--allow-for <min>, --revoke, --deny, --json)',
|
|
688
|
+
run: commandAccess,
|
|
689
|
+
},
|
|
690
|
+
'keep-awake': { summary: 'Keep this computer awake while connected: on, off (--json)', run: commandKeepAwake },
|
|
584
691
|
logs: { summary: 'Show recent service logs (--lines <n>, --all, --clear)', run: commandLogs },
|
|
585
692
|
start: {
|
|
586
693
|
summary: 'Start the background service (resumes a paused one)',
|
|
@@ -620,7 +727,7 @@ const OPTIONS: ReadonlyArray<readonly [string, string]> = [
|
|
|
620
727
|
['--reauth', 'With connect: discard the saved credential and pair again'],
|
|
621
728
|
['--daemon / --foreground', 'With connect: skip the prompt and choose the mode'],
|
|
622
729
|
['--project-id <uuid>', 'With connect: offer "Also share with <project>" on the approval page; the computer is yours in every project either way'],
|
|
623
|
-
['--json', 'With connect: NDJSON events, no prompts. With status and logout: machine-readable output'],
|
|
730
|
+
['--json', 'With connect: NDJSON events, no prompts. With status, access, keep-awake, and logout: machine-readable output'],
|
|
624
731
|
['--keep-service', 'With logout: keep the background service installed'],
|
|
625
732
|
];
|
|
626
733
|
|
|
@@ -658,7 +765,7 @@ const { command, flags } = parseArgs(process.argv);
|
|
|
658
765
|
jsonMode = command === 'connect' && isTruthyFlag(flags.json);
|
|
659
766
|
|
|
660
767
|
if (Object.prototype.hasOwnProperty.call(flags, 'keep-awake')) {
|
|
661
|
-
console.error(` ${glyph.bad} --keep-awake is not
|
|
768
|
+
console.error(` ${glyph.bad} --keep-awake is not a flag. Run: agent-tunnel keep-awake on`);
|
|
662
769
|
process.exit(2);
|
|
663
770
|
}
|
|
664
771
|
|
|
@@ -669,7 +776,8 @@ const resolved =
|
|
|
669
776
|
if (!resolved) {
|
|
670
777
|
showHelp();
|
|
671
778
|
} else {
|
|
672
|
-
|
|
779
|
+
// `.then`: a command that throws synchronously reports like one that rejects.
|
|
780
|
+
void Promise.resolve().then(() => resolved.run(flags)).catch((error: unknown) => {
|
|
673
781
|
fail(error instanceof Error ? error.message : String(error));
|
|
674
782
|
});
|
|
675
783
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import '../node-ws-polyfill';
|
|
2
|
-
import { AGENT_REPLACED_CLOSE_CODE, AGENT_VERSION, isCredentialRejection } from './agent';
|
|
2
|
+
import { AGENT_REPLACED_CLOSE_CODE, AGENT_VERSION, isCredentialRejection, openRelaySocket } from './agent';
|
|
3
3
|
import { buildTunnelWsUrl, trustedCredential, type TunnelConfig } from './config';
|
|
4
4
|
|
|
5
5
|
/**
|
|
@@ -36,18 +36,18 @@ export function probeCredentials(
|
|
|
36
36
|
return new Promise<CredentialProbeResult>((resolve) => {
|
|
37
37
|
let socket: WebSocket;
|
|
38
38
|
try {
|
|
39
|
-
socket =
|
|
39
|
+
socket = openRelaySocket(new URL(buildTunnelWsUrl(config)).href);
|
|
40
40
|
} catch {
|
|
41
41
|
resolve('unreachable');
|
|
42
42
|
return;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
// resolve() is idempotent, so the first verdict wins and later events are
|
|
46
|
-
// harmless no-ops.
|
|
46
|
+
// harmless no-ops. Resolve before close(): Bun fires 'close' inside it.
|
|
47
47
|
const settle = (result: CredentialProbeResult): void => {
|
|
48
48
|
clearTimeout(timer);
|
|
49
|
-
try { socket.close(1000, 'probe complete'); } catch {}
|
|
50
49
|
resolve(result);
|
|
50
|
+
try { socket.close(1000, 'probe complete'); } catch {}
|
|
51
51
|
};
|
|
52
52
|
|
|
53
53
|
const timer = setTimeout(() => settle('unreachable'), options.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS);
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { expect, test } from 'bun:test';
|
|
2
|
+
import { mkdtempSync, renameSync, rmSync, writeFileSync } from 'fs';
|
|
3
|
+
import { tmpdir } from 'os';
|
|
4
|
+
import { join } from 'path';
|
|
5
|
+
|
|
6
|
+
import { runtimeFingerprint } from './self-restart';
|
|
7
|
+
|
|
8
|
+
test('an update that swaps a runtime file in place changes the fingerprint', () => {
|
|
9
|
+
const dir = mkdtempSync(join(tmpdir(), 'self-restart-'));
|
|
10
|
+
try {
|
|
11
|
+
const binary = join(dir, 'Kortix');
|
|
12
|
+
writeFileSync(binary, 'v1');
|
|
13
|
+
const started = runtimeFingerprint([binary]);
|
|
14
|
+
expect(runtimeFingerprint([binary])).toBe(started);
|
|
15
|
+
// An app updater writes the new bundle beside the old one and renames it over.
|
|
16
|
+
writeFileSync(join(dir, 'next'), 'v1');
|
|
17
|
+
renameSync(join(dir, 'next'), binary);
|
|
18
|
+
expect(runtimeFingerprint([binary])).not.toBe(started);
|
|
19
|
+
rmSync(binary);
|
|
20
|
+
expect(runtimeFingerprint([binary])).toBe('missing');
|
|
21
|
+
} finally {
|
|
22
|
+
rmSync(dir, { recursive: true, force: true });
|
|
23
|
+
}
|
|
24
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { statSync } from 'fs';
|
|
2
|
+
|
|
3
|
+
/** Identity of the files this process runs; a replaced file changes it. */
|
|
4
|
+
export function runtimeFingerprint(files: readonly string[]): string {
|
|
5
|
+
return files
|
|
6
|
+
.map((file) => {
|
|
7
|
+
try {
|
|
8
|
+
const stat = statSync(file);
|
|
9
|
+
return `${stat.ino}:${stat.mtimeMs}:${stat.size}`;
|
|
10
|
+
} catch {
|
|
11
|
+
return 'missing';
|
|
12
|
+
}
|
|
13
|
+
})
|
|
14
|
+
.join('|');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** A macOS grant refused this long after start is not a grant this process missed. */
|
|
18
|
+
const PERMISSION_RESTART_MIN_UPTIME_S = 5 * 60;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A background service must not outlive its own code or its macOS grants:
|
|
22
|
+
*
|
|
23
|
+
* - An app update replaces the binary under the running agent. macOS then
|
|
24
|
+
* cannot validate the old process's signature and refuses it Accessibility
|
|
25
|
+
* and Screen Recording, while the updated app itself shows them allowed.
|
|
26
|
+
* - A grant made in System Settings applies to processes started after it.
|
|
27
|
+
*
|
|
28
|
+
* Both are healed by exiting: the supervisor (launchd KeepAlive, systemd
|
|
29
|
+
* Restart=always, the Windows loop) starts the service again on the current
|
|
30
|
+
* code with the current grants.
|
|
31
|
+
*/
|
|
32
|
+
export function watchForRestart(exit: (reason: string) => void, intervalMs = 60_000): {
|
|
33
|
+
permissionMissing: () => void;
|
|
34
|
+
} {
|
|
35
|
+
const files = [process.execPath, process.argv[1]].filter((file): file is string => Boolean(file));
|
|
36
|
+
const started = runtimeFingerprint(files);
|
|
37
|
+
setInterval(() => {
|
|
38
|
+
if (runtimeFingerprint(files) !== started) exit('its code was updated');
|
|
39
|
+
}, intervalMs).unref();
|
|
40
|
+
return {
|
|
41
|
+
permissionMissing: () => {
|
|
42
|
+
if (process.uptime() < PERMISSION_RESTART_MIN_UPTIME_S) return;
|
|
43
|
+
// After the refusal is sent: the caller gets the actionable error first.
|
|
44
|
+
setTimeout(() => exit('macOS refused a desktop permission'), 1_000).unref();
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
rotateServiceLogs,
|
|
14
14
|
} from './service';
|
|
15
15
|
import { collapseRepeatedLines } from './log-format';
|
|
16
|
+
import { AGENT_VERSION } from './agent';
|
|
16
17
|
import { probeCredentials } from './credential-probe';
|
|
17
18
|
|
|
18
19
|
const CLI_PATH = resolve(import.meta.dir, 'cli.ts');
|
|
@@ -104,6 +105,51 @@ describe('service log hygiene', () => {
|
|
|
104
105
|
});
|
|
105
106
|
|
|
106
107
|
describe('credential probe and the single-agent rule', () => {
|
|
108
|
+
test('every relay socket opens through openRelaySocket', () => {
|
|
109
|
+
const src = resolve(import.meta.dir, '..');
|
|
110
|
+
const raw: string[] = [];
|
|
111
|
+
for (const file of new Bun.Glob('**/*.ts').scanSync(src)) {
|
|
112
|
+
if (file.endsWith('.test.ts')) continue;
|
|
113
|
+
readFileSync(join(src, file), 'utf8').split('\n').forEach((line, i) => {
|
|
114
|
+
if (/\bnew WebSocket\(/.test(line) && !/^\s*(\/\/|\*)/.test(line)) raw.push(`${file}:${i + 1}`);
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
expect(raw).toEqual([]);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('auth_ok is valid, though Bun fires close inside socket.close()', async () => {
|
|
121
|
+
let userAgent: string | null = null as string | null;
|
|
122
|
+
const server = Bun.serve({
|
|
123
|
+
port: 0,
|
|
124
|
+
fetch(request, server) {
|
|
125
|
+
// Cloudflare refuses an upgrade without a User-Agent, and Bun sends none by default.
|
|
126
|
+
userAgent = request.headers.get('user-agent');
|
|
127
|
+
return server.upgrade(request) ? undefined : new Response('no upgrade', { status: 400 });
|
|
128
|
+
},
|
|
129
|
+
websocket: {
|
|
130
|
+
message(ws) {
|
|
131
|
+
ws.send(JSON.stringify({ type: 'auth_ok' }));
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
try {
|
|
137
|
+
const result = await probeCredentials(
|
|
138
|
+
{
|
|
139
|
+
apiUrl: `http://127.0.0.1:${server.port}/v1/tunnel`,
|
|
140
|
+
tunnelId: '00000000-0000-4000-8000-000000000001',
|
|
141
|
+
token: 'kortix_tnl_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
|
|
142
|
+
wsPath: '/ws',
|
|
143
|
+
} as never,
|
|
144
|
+
{ timeoutMs: 5_000 },
|
|
145
|
+
);
|
|
146
|
+
expect(result).toBe('valid');
|
|
147
|
+
expect(userAgent).toBe(`kortix-agent-tunnel/${AGENT_VERSION}`);
|
|
148
|
+
} finally {
|
|
149
|
+
server.stop(true);
|
|
150
|
+
}
|
|
151
|
+
}, 30_000);
|
|
152
|
+
|
|
107
153
|
test('treats being replaced as proof the credential is valid', async () => {
|
|
108
154
|
// The relay only replaces a socket it already registered, and registration
|
|
109
155
|
// happens after a successful handshake.
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
agentTunnelHome,
|
|
11
11
|
buildServiceShellCommand,
|
|
12
12
|
getServicePaths,
|
|
13
|
+
installedAppRunner,
|
|
13
14
|
runnerPartsFor,
|
|
14
15
|
serviceLabelFor,
|
|
15
16
|
isEphemeralRunnerPath,
|
|
@@ -28,7 +29,32 @@ describe('agent tunnel service definitions', () => {
|
|
|
28
29
|
const command = buildServiceShellCommand();
|
|
29
30
|
expect(command).toContain("'run'");
|
|
30
31
|
expect(command).toContain("'--service'");
|
|
31
|
-
|
|
32
|
+
// `export …;` first when it runs on the installed Kortix app (ELECTRON_RUN_AS_NODE).
|
|
33
|
+
expect(command).toMatch(/^(export [^;]+; )?exec /);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('a CLI install on a Mac with the Kortix app runs on the app runtime', () => {
|
|
37
|
+
const root = mkdtempSync(join(tmpdir(), 'kortix-app-'));
|
|
38
|
+
try {
|
|
39
|
+
const app = join(root, 'Kortix.app');
|
|
40
|
+
expect(installedAppRunner([app], 'darwin')).toBeNull();
|
|
41
|
+
mkdirSync(join(app, 'Contents', 'MacOS'), { recursive: true });
|
|
42
|
+
mkdirSync(join(app, 'Contents', 'Resources', 'agent-tunnel'), { recursive: true });
|
|
43
|
+
writeFileSync(join(app, 'Contents', 'MacOS', 'Kortix'), '');
|
|
44
|
+
writeFileSync(join(app, 'Contents', 'Resources', 'agent-tunnel', 'agent-cli.js'), '');
|
|
45
|
+
const runner = installedAppRunner([join(root, 'Missing.app'), app], 'darwin')!;
|
|
46
|
+
expect(runner).toEqual({
|
|
47
|
+
execPath: join(app, 'Contents', 'MacOS', 'Kortix'),
|
|
48
|
+
script: join(app, 'Contents', 'Resources', 'agent-tunnel', 'agent-cli.js'),
|
|
49
|
+
});
|
|
50
|
+
expect(installedAppRunner([app], 'linux')).toBeNull();
|
|
51
|
+
const parts = runnerPartsFor(runner.script, { execPath: runner.execPath, electron: 'installed-app' });
|
|
52
|
+
expect(parts.command).toBe(runner.execPath);
|
|
53
|
+
expect(parts.args).toEqual([runner.script, 'run', '--service']);
|
|
54
|
+
expect(parts.env?.ELECTRON_RUN_AS_NODE).toBe('1');
|
|
55
|
+
} finally {
|
|
56
|
+
rmSync(root, { recursive: true, force: true });
|
|
57
|
+
}
|
|
32
58
|
});
|
|
33
59
|
|
|
34
60
|
test('launchd plist restarts and runs at login', () => {
|