@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
|
@@ -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
|
}
|
|
@@ -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
|
+
}
|
|
@@ -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', () => {
|
package/src/agent/service.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'fs';
|
|
2
|
-
import { platform } from 'os';
|
|
2
|
+
import { homedir, platform } from 'os';
|
|
3
3
|
import { join } from 'path';
|
|
4
4
|
|
|
5
5
|
import {
|
|
@@ -128,6 +128,25 @@ export function runnerPartsFor(
|
|
|
128
128
|
return { command: runtime.execPath, args: [vendor(script, paths), 'run', '--service'], env };
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
+
/**
|
|
132
|
+
* The Kortix desktop app's own agent runtime, when the app is installed on
|
|
133
|
+
* this Mac. A CLI install runs on it, so the connection is the same agent
|
|
134
|
+
* build with the same bundled driver, and macOS grants Accessibility and
|
|
135
|
+
* Screen Recording to Kortix once for both, never to node or a terminal.
|
|
136
|
+
*/
|
|
137
|
+
export function installedAppRunner(
|
|
138
|
+
apps: readonly string[] = ['/Applications/Kortix.app', join(homedir(), 'Applications', 'Kortix.app')],
|
|
139
|
+
os: string = process.platform,
|
|
140
|
+
): { execPath: string; script: string } | null {
|
|
141
|
+
if (os !== 'darwin') return null;
|
|
142
|
+
for (const app of apps) {
|
|
143
|
+
const execPath = join(app, 'Contents', 'MacOS', 'Kortix');
|
|
144
|
+
const script = join(app, 'Contents', 'Resources', 'agent-tunnel', 'agent-cli.js');
|
|
145
|
+
if (existsSync(execPath) && existsSync(script)) return { execPath, script };
|
|
146
|
+
}
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
|
|
131
150
|
/** Where vendorRunner WOULD put the bundle, without copying anything. */
|
|
132
151
|
const plannedRunner = (script: string, paths: ServicePaths) =>
|
|
133
152
|
isEphemeralRunnerPath(script) ? paths.vendoredRunner : script;
|
|
@@ -135,6 +154,8 @@ const plannedRunner = (script: string, paths: ServicePaths) =>
|
|
|
135
154
|
function currentRunnerParts(
|
|
136
155
|
vendor: (script: string, paths: ServicePaths) => string = vendorRunner,
|
|
137
156
|
): RunnerParts {
|
|
157
|
+
const app = process.versions.electron ? null : installedAppRunner();
|
|
158
|
+
if (app) return runnerPartsFor(app.script, { execPath: app.execPath, electron: 'installed-app' }, getServicePaths(), vendor);
|
|
138
159
|
const script = process.argv[1];
|
|
139
160
|
if (script && existsSync(script)) return runnerPartsFor(script, undefined, getServicePaths(), vendor);
|
|
140
161
|
throw new Error(
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test';
|
|
2
|
-
import { mkdtempSync, readdirSync, rmSync, statSync } from 'node:fs';
|
|
2
|
+
import { mkdtempSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { tmpdir } from 'node:os';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
|
|
@@ -29,4 +29,14 @@ describe('agent state file', () => {
|
|
|
29
29
|
rmSync(join(home, '..'), { recursive: true, force: true });
|
|
30
30
|
}
|
|
31
31
|
});
|
|
32
|
+
|
|
33
|
+
test('a writer that died without writing offline reads as offline', () => {
|
|
34
|
+
const home = mkdtempSync(join(tmpdir(), 'agent-tunnel-state-'));
|
|
35
|
+
try {
|
|
36
|
+
writeFileSync(stateFilePath(home), JSON.stringify({ tunnelId: 't', apiUrl: 'u', status: 'online', since: '', agentVersion: '0', pid: 2 ** 22 + 7 }));
|
|
37
|
+
expect(readAgentState(home)?.status).toBe('offline');
|
|
38
|
+
} finally {
|
|
39
|
+
rmSync(home, { recursive: true, force: true });
|
|
40
|
+
}
|
|
41
|
+
});
|
|
32
42
|
});
|
package/src/agent/state-file.ts
CHANGED
|
@@ -48,10 +48,22 @@ export function writeAgentState(
|
|
|
48
48
|
}
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
function isAlive(pid: unknown): boolean {
|
|
52
|
+
if (typeof pid !== 'number' || !Number.isSafeInteger(pid) || pid <= 0) return false;
|
|
53
|
+
try {
|
|
54
|
+
process.kill(pid, 0);
|
|
55
|
+
return true;
|
|
56
|
+
} catch (error) {
|
|
57
|
+
return (error as NodeJS.ErrnoException).code === 'EPERM';
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The last status the agent wrote. A writer that died without writing `offline` reads as offline. */
|
|
51
62
|
export function readAgentState(home: string = agentTunnelHome()): AgentStateFile | null {
|
|
52
63
|
try {
|
|
53
64
|
const parsed = JSON.parse(readFileSync(stateFilePath(home), 'utf8')) as AgentStateFile;
|
|
54
|
-
|
|
65
|
+
if (!parsed || typeof parsed !== 'object' || typeof parsed.status !== 'string') return null;
|
|
66
|
+
return parsed.status !== 'offline' && !isAlive(parsed.pid) ? { ...parsed, status: 'offline' } : parsed;
|
|
55
67
|
} catch {
|
|
56
68
|
return null;
|
|
57
69
|
}
|
package/src/index.ts
CHANGED
package/src/shared/index.ts
CHANGED
|
@@ -19,54 +19,35 @@ export interface PermissionScopeValidationResult {
|
|
|
19
19
|
sanitized?: Record<string, unknown>;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
const
|
|
23
|
-
'desktop.cua.ensure': 'computer_use',
|
|
24
|
-
'desktop.cua.start_daemon': 'computer_use',
|
|
25
|
-
'desktop.cua.status': 'computer_use',
|
|
26
|
-
'desktop.cua.version': 'computer_use',
|
|
27
|
-
'desktop.cua.list_tools': 'computer_use',
|
|
28
|
-
'desktop.cua.describe': 'computer_use',
|
|
29
|
-
'desktop.cua.bring_to_front': 'windows',
|
|
30
|
-
'desktop.cua.check_for_update': 'computer_use',
|
|
31
|
-
'desktop.cua.check_permissions': 'computer_use',
|
|
32
|
-
'desktop.cua.click': 'mouse',
|
|
33
|
-
'desktop.cua.double_click': 'mouse',
|
|
34
|
-
'desktop.cua.drag': 'mouse',
|
|
35
|
-
'desktop.cua.end_session': 'computer_use',
|
|
36
|
-
'desktop.cua.get_accessibility_tree': 'accessibility',
|
|
37
|
-
'desktop.cua.get_agent_cursor_state': 'mouse',
|
|
38
|
-
'desktop.cua.get_config': 'computer_use',
|
|
39
|
-
'desktop.cua.get_cursor_position': 'mouse',
|
|
40
|
-
'desktop.cua.get_recording_state': 'computer_use',
|
|
41
|
-
'desktop.cua.get_screen_size': 'screenshot',
|
|
42
|
-
'desktop.cua.get_window_state': 'accessibility',
|
|
43
|
-
'desktop.cua.hotkey': 'keyboard',
|
|
44
|
-
'desktop.cua.kill_app': 'apps',
|
|
45
|
-
'desktop.cua.launch_app': 'apps',
|
|
46
|
-
'desktop.cua.list_apps': 'apps',
|
|
47
|
-
'desktop.cua.list_windows': 'windows',
|
|
48
|
-
'desktop.cua.move_cursor': 'mouse',
|
|
49
|
-
'desktop.cua.page': 'accessibility',
|
|
50
|
-
'desktop.cua.press_key': 'keyboard',
|
|
51
|
-
'desktop.cua.replay_trajectory': 'computer_use',
|
|
52
|
-
'desktop.cua.right_click': 'mouse',
|
|
53
|
-
'desktop.cua.scroll': 'keyboard',
|
|
54
|
-
'desktop.cua.set_agent_cursor_enabled': 'mouse',
|
|
55
|
-
'desktop.cua.set_agent_cursor_motion': 'mouse',
|
|
56
|
-
'desktop.cua.set_agent_cursor_style': 'mouse',
|
|
57
|
-
'desktop.cua.set_config': 'computer_use',
|
|
58
|
-
'desktop.cua.set_value': 'accessibility',
|
|
59
|
-
'desktop.cua.start_recording': 'screenshot',
|
|
60
|
-
'desktop.cua.install_ffmpeg': 'computer_use',
|
|
61
|
-
'desktop.cua.start_session': 'computer_use',
|
|
62
|
-
'desktop.cua.stop_recording': 'screenshot',
|
|
63
|
-
'desktop.cua.type_text': 'keyboard',
|
|
64
|
-
'desktop.cua.zoom': 'screenshot',
|
|
65
|
-
};
|
|
22
|
+
const CUA_PREFIX = 'desktop.cua.';
|
|
66
23
|
|
|
24
|
+
/** Every `desktop.cua.<tool>` is the desktop capability: the driver owns the tool list. */
|
|
67
25
|
export function capabilityForMethod(method: string): TunnelCapability | null {
|
|
68
26
|
const capability = (TunnelMethods as Readonly<Record<string, TunnelCapability | null>>)[method];
|
|
69
|
-
|
|
27
|
+
if (capability !== undefined) return capability;
|
|
28
|
+
return method.startsWith(CUA_PREFIX) && method.length > CUA_PREFIX.length ? 'desktop' : null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* `desktop.cua.<tool>` → `desktop.cua.call { tool, args }`. Every agent since
|
|
33
|
+
* 0.1.0 serves `desktop.cua.call` for any driver tool, so one wire method
|
|
34
|
+
* reaches every tool the installed driver has, with no per-tool list to keep
|
|
35
|
+
* in sync. Management methods (`ensure`, `status`, `describe`, …) pass through.
|
|
36
|
+
*/
|
|
37
|
+
export function normalizeDesktopCall(
|
|
38
|
+
method: string,
|
|
39
|
+
params: Record<string, unknown>,
|
|
40
|
+
): { method: string; params: Record<string, unknown> } {
|
|
41
|
+
if (!method.startsWith(CUA_PREFIX) || method in TunnelMethods) return { method, params };
|
|
42
|
+
const { permissionId, ...args } = params;
|
|
43
|
+
return {
|
|
44
|
+
method: 'desktop.cua.call',
|
|
45
|
+
params: {
|
|
46
|
+
tool: method.slice(CUA_PREFIX.length),
|
|
47
|
+
args,
|
|
48
|
+
...(permissionId === undefined ? {} : { permissionId }),
|
|
49
|
+
},
|
|
50
|
+
};
|
|
70
51
|
}
|
|
71
52
|
|
|
72
53
|
export function isTunnelCapability(value: string): value is TunnelCapability {
|
|
@@ -277,16 +258,3 @@ export function operationForMethod(method: string): string {
|
|
|
277
258
|
const separator = method.indexOf('.');
|
|
278
259
|
return separator === -1 ? method : method.slice(separator + 1);
|
|
279
260
|
}
|
|
280
|
-
|
|
281
|
-
export function desktopFeatureForMethod(
|
|
282
|
-
method: string,
|
|
283
|
-
args: Record<string, unknown> = {},
|
|
284
|
-
): string | undefined {
|
|
285
|
-
if (method === 'desktop.cua.call') {
|
|
286
|
-
const tool = args.tool;
|
|
287
|
-
if (typeof tool !== 'string' || tool.length === 0) return undefined;
|
|
288
|
-
const toolMethod = tool.startsWith('desktop.cua.') ? tool : `desktop.cua.${tool}`;
|
|
289
|
-
return DESKTOP_METHOD_FEATURES[toolMethod];
|
|
290
|
-
}
|
|
291
|
-
return DESKTOP_METHOD_FEATURES[method];
|
|
292
|
-
}
|