@kortix/agent-tunnel 0.1.3 → 0.12.7
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 +65 -0
- package/dist/agent-cli.js +4676 -3174
- package/dist/client-cli.js +203 -494
- package/package.json +24 -29
- package/src/agent/agent.ts +67 -17
- package/src/agent/capabilities/desktop/cua-driver.ts +284 -0
- package/src/agent/capabilities/desktop.ts +100 -167
- package/src/agent/capabilities/enabled-registry.ts +24 -0
- package/src/agent/capabilities/filesystem.ts +136 -32
- package/src/agent/capabilities/index.ts +1 -1
- package/src/agent/capabilities/security.test.ts +204 -0
- package/src/agent/capabilities/shell.ts +37 -3
- package/src/agent/cli-device-auth.test.ts +179 -0
- package/src/agent/cli-help.test.ts +25 -0
- package/src/agent/cli.ts +475 -38
- package/src/agent/config.test.ts +53 -0
- package/src/agent/config.ts +169 -7
- package/src/agent/index.ts +1 -0
- package/src/agent/security/command-validator.ts +4 -2
- package/src/agent/security/path-validator.ts +73 -18
- package/src/agent/security/permission-guard.test.ts +52 -0
- package/src/agent/security/permission-guard.ts +35 -8
- package/src/agent/service.test.ts +63 -0
- package/src/agent/service.ts +410 -0
- package/src/client/cli.test.ts +150 -547
- package/src/client/cli.ts +116 -537
- package/src/client/index.ts +1 -1
- package/src/client/tools.ts +95 -356
- package/src/client/tunnel-client.ts +50 -80
- package/src/index.ts +7 -1
- package/src/node-ws-polyfill.test.ts +18 -0
- package/src/node-ws-polyfill.ts +5 -3
- package/src/server/heartbeat.ts +13 -6
- package/src/server/relay.test.ts +72 -0
- package/src/server/relay.ts +50 -9
- package/src/server/server.test.ts +33 -0
- package/src/server/server.ts +26 -6
- package/src/server/ws-handler.test.ts +158 -0
- package/src/server/ws-handler.ts +94 -36
- package/src/shared/crypto.ts +2 -3
- package/src/shared/index.ts +8 -0
- package/src/shared/permissions.ts +292 -0
- package/src/shared/types.ts +70 -41
- package/dist/agent/index.d.ts +0 -140
- package/dist/agent/index.js +0 -21
- package/dist/agent/index.js.map +0 -1
- package/dist/chunk-7N7GSU6K.js +0 -34
- package/dist/client/index.d.ts +0 -183
- package/dist/client/index.js +0 -8
- package/dist/client/index.js.map +0 -1
- package/dist/index.d.ts +0 -7
- package/dist/index.js +0 -55
- package/dist/index.js.map +0 -1
- package/dist/server/index.d.ts +0 -89
- package/dist/server/index.js +0 -14
- package/dist/server/index.js.map +0 -1
- package/dist/shared/index.d.ts +0 -10
- package/dist/shared/index.js +0 -20
- package/dist/shared/index.js.map +0 -1
- package/dist/types-Dpwrd8Ai.d.ts +0 -194
- package/src/agent/capabilities/desktop/atspi-helper.ts +0 -345
- package/src/agent/capabilities/desktop/csharp-helper.ts +0 -914
- package/src/agent/capabilities/desktop/linux-driver.ts +0 -368
- package/src/agent/capabilities/desktop/macos-driver.ts +0 -601
- package/src/agent/capabilities/desktop/swift-helper.ts +0 -736
- package/src/agent/capabilities/desktop/types.ts +0 -201
- package/src/agent/capabilities/desktop/windows-driver.ts +0 -220
package/src/agent/cli.ts
CHANGED
|
@@ -1,15 +1,23 @@
|
|
|
1
1
|
import '../node-ws-polyfill';
|
|
2
2
|
import { loadConfig, type TunnelConfig } from './config';
|
|
3
3
|
import { TunnelAgent } from './agent';
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
|
|
4
|
+
import { createEnabledCapabilityRegistry } from './capabilities/enabled-registry';
|
|
5
|
+
import {
|
|
6
|
+
DEFAULT_INSTALL_BACKGROUND_SERVICE,
|
|
7
|
+
getServicePaths,
|
|
8
|
+
getServiceStatus,
|
|
9
|
+
installService,
|
|
10
|
+
restartService,
|
|
11
|
+
startService,
|
|
12
|
+
stopService,
|
|
13
|
+
uninstallService,
|
|
14
|
+
} from './service';
|
|
8
15
|
import { hostname, platform, arch, release } from 'os';
|
|
9
|
-
import { existsSync, mkdirSync, writeFileSync, readFileSync } from 'fs';
|
|
16
|
+
import { chmodSync, existsSync, mkdirSync, writeFileSync, readFileSync, renameSync } from 'fs';
|
|
10
17
|
import { join } from 'path';
|
|
11
18
|
import { homedir } from 'os';
|
|
12
|
-
import {
|
|
19
|
+
import { spawn } from 'child_process';
|
|
20
|
+
import { createInterface } from 'readline/promises';
|
|
13
21
|
|
|
14
22
|
const c = {
|
|
15
23
|
reset: '\x1b[0m',
|
|
@@ -50,6 +58,128 @@ function clearScreen(): void {
|
|
|
50
58
|
|
|
51
59
|
const sleep = (ms: number) => new Promise<void>(r => setTimeout(r, ms));
|
|
52
60
|
|
|
61
|
+
type ConnectMode = {
|
|
62
|
+
background: boolean;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
type ApprovedDeviceCredentials = {
|
|
66
|
+
tunnelId: string;
|
|
67
|
+
token: string;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
type DeviceAuthChallenge = {
|
|
71
|
+
deviceCode: string;
|
|
72
|
+
deviceSecret: string;
|
|
73
|
+
verificationUrl: string;
|
|
74
|
+
expiresAt: string;
|
|
75
|
+
pollIntervalMs: number;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const TUNNEL_ID_PATTERN =
|
|
79
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
80
|
+
const SETUP_TOKEN_PATTERN = /^kortix_tnl_[A-Za-z0-9_-]{32,64}$/;
|
|
81
|
+
|
|
82
|
+
class InvalidDeviceAuthResponseError extends Error {
|
|
83
|
+
constructor(message: string) {
|
|
84
|
+
super(message);
|
|
85
|
+
this.name = 'InvalidDeviceAuthResponseError';
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function isJsonRecord(value: unknown): value is Record<string, unknown> {
|
|
90
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function parseApprovedDeviceCredentials(
|
|
94
|
+
value: Record<string, unknown>,
|
|
95
|
+
): ApprovedDeviceCredentials {
|
|
96
|
+
const { tunnelId, token } = value;
|
|
97
|
+
if (typeof tunnelId !== 'string' || !TUNNEL_ID_PATTERN.test(tunnelId)) {
|
|
98
|
+
throw new InvalidDeviceAuthResponseError(
|
|
99
|
+
'Authorization server returned an invalid tunnel ID',
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
if (typeof token !== 'string' || !SETUP_TOKEN_PATTERN.test(token)) {
|
|
103
|
+
throw new InvalidDeviceAuthResponseError(
|
|
104
|
+
'Authorization server returned an invalid setup token',
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
return { tunnelId, token };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function parseDeviceAuthChallenge(value: unknown): DeviceAuthChallenge {
|
|
111
|
+
if (!isJsonRecord(value)) {
|
|
112
|
+
throw new InvalidDeviceAuthResponseError(
|
|
113
|
+
'Authorization server returned an invalid challenge',
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
const { deviceCode, deviceSecret, verificationUrl, expiresAt, pollIntervalMs } = value;
|
|
117
|
+
if (typeof deviceCode !== 'string' || !/^[A-Z]{4}-[0-9]{4}$/.test(deviceCode)) {
|
|
118
|
+
throw new InvalidDeviceAuthResponseError(
|
|
119
|
+
'Authorization server returned an invalid device code',
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
if (typeof deviceSecret !== 'string' || !/^[A-Za-z0-9]{32}$/.test(deviceSecret)) {
|
|
123
|
+
throw new InvalidDeviceAuthResponseError(
|
|
124
|
+
'Authorization server returned an invalid device secret',
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
if (typeof verificationUrl !== 'string' || verificationUrl.length > 2048) {
|
|
128
|
+
throw new InvalidDeviceAuthResponseError(
|
|
129
|
+
'Authorization server returned an invalid verification URL',
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
const browserUrl = normalizeBrowserUrl(verificationUrl);
|
|
133
|
+
if (!browserUrl) {
|
|
134
|
+
throw new InvalidDeviceAuthResponseError(
|
|
135
|
+
'Authorization server returned an invalid verification URL',
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
const parsedVerificationUrl = new URL(browserUrl);
|
|
139
|
+
const loopback =
|
|
140
|
+
parsedVerificationUrl.hostname === 'localhost' ||
|
|
141
|
+
parsedVerificationUrl.hostname === '127.0.0.1' ||
|
|
142
|
+
parsedVerificationUrl.hostname === '[::1]' ||
|
|
143
|
+
parsedVerificationUrl.hostname === '::1';
|
|
144
|
+
if (
|
|
145
|
+
parsedVerificationUrl.username ||
|
|
146
|
+
parsedVerificationUrl.password ||
|
|
147
|
+
(parsedVerificationUrl.protocol !== 'https:' && !loopback)
|
|
148
|
+
) {
|
|
149
|
+
throw new InvalidDeviceAuthResponseError(
|
|
150
|
+
'Authorization server returned an unsafe verification URL',
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
if (typeof expiresAt !== 'string') {
|
|
154
|
+
throw new InvalidDeviceAuthResponseError(
|
|
155
|
+
'Authorization server returned an invalid expiration',
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
const expiresAtMs = Date.parse(expiresAt);
|
|
159
|
+
const now = Date.now();
|
|
160
|
+
if (!Number.isFinite(expiresAtMs) || expiresAtMs <= now || expiresAtMs > now + 10 * 60_000) {
|
|
161
|
+
throw new InvalidDeviceAuthResponseError(
|
|
162
|
+
'Authorization server returned an invalid expiration',
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
if (
|
|
166
|
+
!Number.isSafeInteger(pollIntervalMs) ||
|
|
167
|
+
(pollIntervalMs as number) < 250 ||
|
|
168
|
+
(pollIntervalMs as number) > 10_000
|
|
169
|
+
) {
|
|
170
|
+
throw new InvalidDeviceAuthResponseError(
|
|
171
|
+
'Authorization server returned an invalid poll interval',
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
return {
|
|
175
|
+
deviceCode,
|
|
176
|
+
deviceSecret,
|
|
177
|
+
verificationUrl: browserUrl,
|
|
178
|
+
expiresAt,
|
|
179
|
+
pollIntervalMs: pollIntervalMs as number,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
53
183
|
async function printStartup(config: { tunnelId: string; apiUrl: string }, capabilities: string[], version: string): Promise<void> {
|
|
54
184
|
const machine = hostname();
|
|
55
185
|
const plat = `${platform()} ${arch()}`;
|
|
@@ -118,20 +248,26 @@ async function printStartup(config: { tunnelId: string; apiUrl: string }, capabi
|
|
|
118
248
|
console.log('');
|
|
119
249
|
}
|
|
120
250
|
|
|
121
|
-
function startAgent(config: TunnelConfig): void {
|
|
122
|
-
const registry =
|
|
123
|
-
registry.
|
|
124
|
-
|
|
125
|
-
|
|
251
|
+
function startAgent(config: TunnelConfig, options: { service?: boolean } = {}): void {
|
|
252
|
+
const registry = createEnabledCapabilityRegistry(config);
|
|
253
|
+
if (config.enabledCapabilities?.includes('desktop') && !registry.has('desktop')) {
|
|
254
|
+
console.error(
|
|
255
|
+
'[agent-tunnel] Computer Use is approved but unavailable: install the trusted cua-driver locally, then restart Agent Tunnel.',
|
|
256
|
+
);
|
|
257
|
+
}
|
|
126
258
|
|
|
127
|
-
|
|
128
|
-
|
|
259
|
+
if (!options.service) {
|
|
260
|
+
clearScreen();
|
|
261
|
+
printStartup(config, registry.getCapabilityNames(), '0.1.2');
|
|
262
|
+
} else {
|
|
263
|
+
console.log(`[agent-tunnel] service starting: ${config.tunnelId} -> ${config.apiUrl}`);
|
|
264
|
+
}
|
|
129
265
|
|
|
130
266
|
const agent = new TunnelAgent(config, registry);
|
|
131
267
|
agent.connect();
|
|
132
268
|
|
|
133
269
|
const shutdown = () => {
|
|
134
|
-
console.log(`\n${c.dim} Shutting down…${c.reset}`);
|
|
270
|
+
if (!options.service) console.log(`\n${c.dim} Shutting down…${c.reset}`);
|
|
135
271
|
agent.disconnect();
|
|
136
272
|
process.exit(0);
|
|
137
273
|
};
|
|
@@ -140,28 +276,150 @@ function startAgent(config: TunnelConfig): void {
|
|
|
140
276
|
process.on('SIGINT', shutdown);
|
|
141
277
|
}
|
|
142
278
|
|
|
279
|
+
function normalizeBrowserUrl(value: string): string | null {
|
|
280
|
+
try {
|
|
281
|
+
const url = new URL(value);
|
|
282
|
+
return url.protocol === 'https:' || url.protocol === 'http:' ? url.toString() : null;
|
|
283
|
+
} catch {
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
143
288
|
function openBrowser(url: string): void {
|
|
289
|
+
if (process.env.KORTIX_AGENT_TUNNEL_NO_BROWSER === '1') return;
|
|
290
|
+
const safeUrl = normalizeBrowserUrl(url);
|
|
291
|
+
if (!safeUrl) return;
|
|
144
292
|
try {
|
|
145
293
|
const plat = platform();
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
294
|
+
let command: string;
|
|
295
|
+
let args: string[];
|
|
296
|
+
if (plat === 'darwin') {
|
|
297
|
+
command = 'open';
|
|
298
|
+
args = [safeUrl];
|
|
299
|
+
} else if (plat === 'win32') {
|
|
300
|
+
command = 'rundll32.exe';
|
|
301
|
+
args = ['url.dll,FileProtocolHandler', safeUrl];
|
|
302
|
+
} else {
|
|
303
|
+
command = 'xdg-open';
|
|
304
|
+
args = [safeUrl];
|
|
305
|
+
}
|
|
306
|
+
const child = spawn(command, args, { detached: true, stdio: 'ignore' });
|
|
307
|
+
child.unref();
|
|
149
308
|
} catch {}
|
|
150
309
|
}
|
|
151
310
|
|
|
152
311
|
const CONFIG_DIR = join(homedir(), '.agent-tunnel');
|
|
153
312
|
const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
|
|
154
313
|
|
|
155
|
-
function
|
|
156
|
-
|
|
314
|
+
function isSetupTunnelToken(token: string): boolean {
|
|
315
|
+
return token.startsWith('kortix_tnl_') || token.startsWith('tnl_');
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function isTruthyFlag(value: string | undefined): boolean {
|
|
319
|
+
return value === 'true' || value === '1' || value === 'yes';
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function isInteractiveTerminal(): boolean {
|
|
323
|
+
return process.stdin.isTTY === true && process.stdout.isTTY === true;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async function promptYesNo(question: string, defaultValue: boolean): Promise<boolean> {
|
|
327
|
+
const suffix = defaultValue ? ' [Y/n] ' : ' [y/N] ';
|
|
328
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
329
|
+
try {
|
|
330
|
+
for (;;) {
|
|
331
|
+
const answer = (await rl.question(`${question}${suffix}`)).trim().toLowerCase();
|
|
332
|
+
if (!answer) return defaultValue;
|
|
333
|
+
if (['y', 'yes'].includes(answer)) return true;
|
|
334
|
+
if (['n', 'no'].includes(answer)) return false;
|
|
335
|
+
console.log(` ${c.yellow}!${c.reset} Please answer yes or no.`);
|
|
336
|
+
}
|
|
337
|
+
} catch (error) {
|
|
338
|
+
if (error instanceof Error && error.name === 'AbortError') {
|
|
339
|
+
process.stdout.write('\n');
|
|
340
|
+
process.exit(130);
|
|
341
|
+
}
|
|
342
|
+
throw error;
|
|
343
|
+
} finally {
|
|
344
|
+
rl.close();
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
async function chooseConnectMode(flags: Record<string, string>): Promise<ConnectMode> {
|
|
349
|
+
const explicitBackground =
|
|
350
|
+
isTruthyFlag(flags.daemon) ||
|
|
351
|
+
isTruthyFlag(flags.service) ||
|
|
352
|
+
isTruthyFlag(flags.background) ||
|
|
353
|
+
isTruthyFlag(flags['always-online']);
|
|
354
|
+
const explicitForeground =
|
|
355
|
+
isTruthyFlag(flags.foreground) ||
|
|
356
|
+
isTruthyFlag(flags['no-daemon']) ||
|
|
357
|
+
isTruthyFlag(flags['no-service']) ||
|
|
358
|
+
isTruthyFlag(flags['no-background']);
|
|
359
|
+
|
|
360
|
+
if (explicitBackground) {
|
|
361
|
+
return { background: true };
|
|
362
|
+
}
|
|
363
|
+
if (explicitForeground) {
|
|
364
|
+
return { background: false };
|
|
365
|
+
}
|
|
366
|
+
if (!isInteractiveTerminal()) {
|
|
367
|
+
return { background: false };
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
console.log('');
|
|
371
|
+
console.log(` ${c.yellow}!${c.reset} ${c.bold}Security note${c.reset}`);
|
|
372
|
+
console.log(` ${c.dim}Background mode starts at login, continues after this terminal closes, and restarts after failures.${c.reset}`);
|
|
373
|
+
console.log(` ${c.dim}The computer must remain powered on, awake, and connected to the internet.${c.reset}`);
|
|
374
|
+
console.log('');
|
|
375
|
+
|
|
376
|
+
const background = await promptYesNo(
|
|
377
|
+
' Install the background service now?',
|
|
378
|
+
DEFAULT_INSTALL_BACKGROUND_SERVICE,
|
|
379
|
+
);
|
|
380
|
+
return { background };
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function installBackgroundService(): void {
|
|
384
|
+
const status = installService();
|
|
385
|
+
console.log('');
|
|
386
|
+
console.log(` ${c.green}●${c.reset} ${c.bold}Background service installed${c.reset}`);
|
|
387
|
+
if (status.path) console.log(` ${c.dim}${status.path}${c.reset}`);
|
|
388
|
+
console.log(` ${c.dim}Starts at login and restarts after failures.${c.reset}`);
|
|
389
|
+
if (status.detail) console.log(` ${c.gray}${status.detail}${c.reset}`);
|
|
390
|
+
console.log('');
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function saveCredentials(
|
|
394
|
+
tunnelId: string,
|
|
395
|
+
token: string,
|
|
396
|
+
apiUrl: string,
|
|
397
|
+
enabledCapabilities?: string[],
|
|
398
|
+
): void {
|
|
399
|
+
mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
400
|
+
try { chmodSync(CONFIG_DIR, 0o700); } catch {}
|
|
157
401
|
let existing: Record<string, unknown> = {};
|
|
158
402
|
if (existsSync(CONFIG_FILE)) {
|
|
159
403
|
try { existing = JSON.parse(readFileSync(CONFIG_FILE, 'utf-8')); } catch {}
|
|
160
404
|
}
|
|
161
|
-
|
|
405
|
+
const tmpFile = join(CONFIG_DIR, `config.${process.pid}.${Date.now()}.tmp`);
|
|
406
|
+
const next = {
|
|
407
|
+
...existing,
|
|
408
|
+
tunnelId,
|
|
409
|
+
token,
|
|
410
|
+
apiUrl,
|
|
411
|
+
...(enabledCapabilities !== undefined ? { enabledCapabilities } : {}),
|
|
412
|
+
};
|
|
413
|
+
// The device-auth response passes strict UUID and token-format validation.
|
|
414
|
+
// The destination is a fixed private file under the current user's home.
|
|
415
|
+
// lgtm[js/http-to-file-access]
|
|
416
|
+
writeFileSync(tmpFile, JSON.stringify(next, null, 2), { mode: 0o600, flag: 'wx' });
|
|
417
|
+
try { chmodSync(tmpFile, 0o600); } catch {}
|
|
418
|
+
renameSync(tmpFile, CONFIG_FILE);
|
|
419
|
+
try { chmodSync(CONFIG_FILE, 0o600); } catch {}
|
|
162
420
|
}
|
|
163
421
|
|
|
164
|
-
async function commandConnectDeviceAuth(config: TunnelConfig): Promise<void> {
|
|
422
|
+
async function commandConnectDeviceAuth(config: TunnelConfig, flags: Record<string, string>): Promise<void> {
|
|
165
423
|
console.log('');
|
|
166
424
|
console.log(` ${c.cyan}◆${c.reset} ${c.bold}Device Authorization${c.reset}`);
|
|
167
425
|
console.log('');
|
|
@@ -184,14 +442,15 @@ async function commandConnectDeviceAuth(config: TunnelConfig): Promise<void> {
|
|
|
184
442
|
console.error(` ${c.red}✗${c.reset} Failed to create device auth request: ${res.status} ${text.slice(0, 200)}`);
|
|
185
443
|
process.exit(1);
|
|
186
444
|
}
|
|
187
|
-
const
|
|
188
|
-
deviceCode =
|
|
189
|
-
deviceSecret =
|
|
190
|
-
verificationUrl =
|
|
191
|
-
expiresAt =
|
|
192
|
-
pollIntervalMs =
|
|
445
|
+
const challenge = parseDeviceAuthChallenge(await res.json());
|
|
446
|
+
deviceCode = challenge.deviceCode;
|
|
447
|
+
deviceSecret = challenge.deviceSecret;
|
|
448
|
+
verificationUrl = challenge.verificationUrl;
|
|
449
|
+
expiresAt = challenge.expiresAt;
|
|
450
|
+
pollIntervalMs = challenge.pollIntervalMs;
|
|
193
451
|
} catch (err) {
|
|
194
|
-
|
|
452
|
+
const detail = err instanceof InvalidDeviceAuthResponseError ? `: ${err.message}` : '';
|
|
453
|
+
console.error(` ${c.red}✗${c.reset} Failed to start device authorization${detail}`);
|
|
195
454
|
process.exit(1);
|
|
196
455
|
return;
|
|
197
456
|
}
|
|
@@ -220,30 +479,69 @@ async function commandConnectDeviceAuth(config: TunnelConfig): Promise<void> {
|
|
|
220
479
|
process.stdout.write(`\r ${c.dim}Waiting for approval... ${c.white}${min}:${sec.toString().padStart(2, '0')}${c.reset} `);
|
|
221
480
|
|
|
222
481
|
try {
|
|
223
|
-
const res = await fetch(`${config.apiUrl}/device-auth/${deviceCode}/status
|
|
482
|
+
const res = await fetch(`${config.apiUrl}/device-auth/${deviceCode}/status`, {
|
|
483
|
+
headers: { Authorization: `Bearer ${deviceSecret}` },
|
|
484
|
+
});
|
|
224
485
|
if (res.ok) {
|
|
225
|
-
const data = await res.json();
|
|
486
|
+
const data: unknown = await res.json();
|
|
487
|
+
|
|
488
|
+
if (!isJsonRecord(data) || typeof data.status !== 'string') {
|
|
489
|
+
throw new InvalidDeviceAuthResponseError(
|
|
490
|
+
'Authorization server returned an invalid status response',
|
|
491
|
+
);
|
|
492
|
+
}
|
|
226
493
|
|
|
227
494
|
if (data.status === 'approved' && data.tunnelId && data.token) {
|
|
495
|
+
const credentials = parseApprovedDeviceCredentials(data);
|
|
228
496
|
process.stdout.write('\r' + ' '.repeat(60) + '\r');
|
|
229
497
|
console.log(` ${c.green}●${c.reset} ${c.bold}Authorized!${c.reset}`);
|
|
230
498
|
console.log('');
|
|
231
499
|
|
|
232
|
-
|
|
233
|
-
|
|
500
|
+
const enabledCapabilities = Array.isArray(data.capabilities)
|
|
501
|
+
? [...new Set(data.capabilities)].filter(
|
|
502
|
+
(capability): capability is string =>
|
|
503
|
+
typeof capability === 'string' &&
|
|
504
|
+
['filesystem', 'shell', 'desktop'].includes(capability),
|
|
505
|
+
)
|
|
506
|
+
: [];
|
|
507
|
+
|
|
508
|
+
// Persist the browser-approved capabilities as a local ceiling. A
|
|
509
|
+
// later server grant cannot silently enable another capability.
|
|
510
|
+
saveCredentials(
|
|
511
|
+
credentials.tunnelId,
|
|
512
|
+
credentials.token,
|
|
513
|
+
config.apiUrl,
|
|
514
|
+
enabledCapabilities,
|
|
515
|
+
);
|
|
234
516
|
console.log(` ${c.dim}Credentials saved to ${CONFIG_FILE}${c.reset}`);
|
|
517
|
+
console.log(
|
|
518
|
+
` ${c.dim}Local capabilities: ${enabledCapabilities.join(', ') || 'none'}${c.reset}`,
|
|
519
|
+
);
|
|
235
520
|
console.log('');
|
|
236
521
|
|
|
522
|
+
const mode = await chooseConnectMode(flags);
|
|
523
|
+
if (mode.background) {
|
|
524
|
+
installBackgroundService();
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
|
|
237
528
|
// Connect with received credentials
|
|
238
529
|
const fullConfig = loadConfig({
|
|
239
|
-
token:
|
|
240
|
-
tunnelId:
|
|
530
|
+
token: credentials.token,
|
|
531
|
+
tunnelId: credentials.tunnelId,
|
|
241
532
|
apiUrl: config.apiUrl,
|
|
242
533
|
});
|
|
243
534
|
startAgent(fullConfig);
|
|
244
535
|
return;
|
|
245
536
|
}
|
|
246
537
|
|
|
538
|
+
if (data.status === 'approved') {
|
|
539
|
+
process.stdout.write('\r' + ' '.repeat(60) + '\r');
|
|
540
|
+
console.log(` ${c.red}✗${c.reset} Authorization was approved, but the setup token was not available.`);
|
|
541
|
+
console.log(` ${c.dim}Run the connect command again to create a fresh device authorization code.${c.reset}`);
|
|
542
|
+
process.exit(1);
|
|
543
|
+
}
|
|
544
|
+
|
|
247
545
|
if (data.status === 'denied') {
|
|
248
546
|
process.stdout.write('\r' + ' '.repeat(60) + '\r');
|
|
249
547
|
console.log(` ${c.red}✗${c.reset} Authorization denied.`);
|
|
@@ -256,7 +554,13 @@ async function commandConnectDeviceAuth(config: TunnelConfig): Promise<void> {
|
|
|
256
554
|
process.exit(1);
|
|
257
555
|
}
|
|
258
556
|
}
|
|
259
|
-
} catch {
|
|
557
|
+
} catch (error) {
|
|
558
|
+
if (error instanceof InvalidDeviceAuthResponseError) {
|
|
559
|
+
process.stdout.write('\r' + ' '.repeat(60) + '\r');
|
|
560
|
+
console.error(` ${c.red}✗${c.reset} ${error.message}`);
|
|
561
|
+
process.exit(1);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
260
564
|
|
|
261
565
|
await sleep(pollIntervalMs);
|
|
262
566
|
}
|
|
@@ -271,13 +575,19 @@ async function commandConnect(flags: Record<string, string>): Promise<void> {
|
|
|
271
575
|
|
|
272
576
|
// If both token and tunnelId are provided, connect directly
|
|
273
577
|
if (config.token && config.tunnelId) {
|
|
578
|
+
const mode = await chooseConnectMode(flags);
|
|
579
|
+
if (mode.background) {
|
|
580
|
+
saveCredentials(config.tunnelId, config.token, config.apiUrl);
|
|
581
|
+
installBackgroundService();
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
274
584
|
startAgent(config);
|
|
275
585
|
return;
|
|
276
586
|
}
|
|
277
587
|
|
|
278
588
|
// If neither is provided, use device auth flow
|
|
279
589
|
if (!config.token && !config.tunnelId) {
|
|
280
|
-
await commandConnectDeviceAuth(config);
|
|
590
|
+
await commandConnectDeviceAuth(config, flags);
|
|
281
591
|
return;
|
|
282
592
|
}
|
|
283
593
|
|
|
@@ -286,6 +596,21 @@ async function commandConnect(flags: Record<string, string>): Promise<void> {
|
|
|
286
596
|
process.exit(1);
|
|
287
597
|
}
|
|
288
598
|
|
|
599
|
+
async function commandRun(flags: Record<string, string>): Promise<void> {
|
|
600
|
+
const config = loadConfig({
|
|
601
|
+
token: flags.token,
|
|
602
|
+
tunnelId: flags['tunnel-id'],
|
|
603
|
+
apiUrl: flags['api-url'],
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
if (!config.token || !config.tunnelId) {
|
|
607
|
+
console.error(`${c.red}${c.bold} error${c.reset} No saved tunnel credentials found. Run \`agent-tunnel connect\` first.`);
|
|
608
|
+
process.exit(1);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
startAgent(config, { service: flags.service === 'true' });
|
|
612
|
+
}
|
|
613
|
+
|
|
289
614
|
async function commandStatus(flags: Record<string, string>): Promise<void> {
|
|
290
615
|
const config = loadConfig({
|
|
291
616
|
token: flags.token,
|
|
@@ -298,6 +623,17 @@ async function commandStatus(flags: Record<string, string>): Promise<void> {
|
|
|
298
623
|
process.exit(1);
|
|
299
624
|
}
|
|
300
625
|
|
|
626
|
+
if (isSetupTunnelToken(config.token)) {
|
|
627
|
+
console.log(JSON.stringify({
|
|
628
|
+
tunnelId: config.tunnelId,
|
|
629
|
+
apiUrl: config.apiUrl,
|
|
630
|
+
credential: 'device-setup-token',
|
|
631
|
+
note: 'Saved device credentials authenticate the local WebSocket agent. HTTP live status requires a user or sandbox API key.',
|
|
632
|
+
service: getServiceStatus(),
|
|
633
|
+
}, null, 2));
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
|
|
301
637
|
try {
|
|
302
638
|
const res = await fetch(`${config.apiUrl}/connections/${config.tunnelId}`, {
|
|
303
639
|
headers: { Authorization: `Bearer ${config.token}` },
|
|
@@ -316,6 +652,64 @@ async function commandStatus(flags: Record<string, string>): Promise<void> {
|
|
|
316
652
|
}
|
|
317
653
|
}
|
|
318
654
|
|
|
655
|
+
function commandInstallService(flags: Record<string, string>): void {
|
|
656
|
+
const config = loadConfig({
|
|
657
|
+
token: flags.token,
|
|
658
|
+
tunnelId: flags['tunnel-id'],
|
|
659
|
+
apiUrl: flags['api-url'],
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
if (!config.token || !config.tunnelId) {
|
|
663
|
+
console.error(`${c.red}${c.bold} error${c.reset} No saved tunnel credentials found. Run \`agent-tunnel connect\` first, or pass --token and --tunnel-id.`);
|
|
664
|
+
process.exit(1);
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
if (flags.token && flags['tunnel-id']) {
|
|
668
|
+
saveCredentials(config.tunnelId, config.token, config.apiUrl);
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
const status = installService();
|
|
672
|
+
console.log(JSON.stringify(status, null, 2));
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
function commandUninstallService(): void {
|
|
676
|
+
console.log(JSON.stringify(uninstallService(), null, 2));
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
function commandStartService(): void {
|
|
680
|
+
console.log(JSON.stringify(startService(), null, 2));
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
function commandStopService(): void {
|
|
684
|
+
console.log(JSON.stringify(stopService(), null, 2));
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
function commandRestartService(): void {
|
|
688
|
+
console.log(JSON.stringify(restartService(), null, 2));
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function commandServiceStatus(): void {
|
|
692
|
+
console.log(JSON.stringify(getServiceStatus(), null, 2));
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
function commandLogs(): void {
|
|
696
|
+
const paths = getServicePaths();
|
|
697
|
+
const files = [
|
|
698
|
+
join(paths.logDir, 'agent-tunnel.out.log'),
|
|
699
|
+
join(paths.logDir, 'agent-tunnel.err.log'),
|
|
700
|
+
];
|
|
701
|
+
for (const file of files) {
|
|
702
|
+
console.log(`\n${c.bold}${file}${c.reset}`);
|
|
703
|
+
if (!existsSync(file)) {
|
|
704
|
+
console.log(`${c.dim}not created yet${c.reset}`);
|
|
705
|
+
continue;
|
|
706
|
+
}
|
|
707
|
+
const body = readFileSync(file, 'utf8');
|
|
708
|
+
const lines = body.split(/\r?\n/).slice(-120).join('\n').trim();
|
|
709
|
+
console.log(lines || `${c.dim}empty${c.reset}`);
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
|
|
319
713
|
function showHelp(): void {
|
|
320
714
|
console.log('');
|
|
321
715
|
console.log(` ${c.cyan}▄▀█ █▀▀ █▀▀ █▄ █ ▀█▀${c.reset} ${c.cyan}▀█▀ █ █ █▄ █ █▄ █ █▀▀ █ ${c.reset}`);
|
|
@@ -323,10 +717,18 @@ function showHelp(): void {
|
|
|
323
717
|
console.log('');
|
|
324
718
|
console.log(` ${c.dim}Secure bridge between AI agents & local machines${c.reset}`);
|
|
325
719
|
console.log('');
|
|
326
|
-
console.log(` ${c.bold}Usage${c.reset} ${c.dim}npx @kortix/agent-tunnel <command> [options]${c.reset}`);
|
|
720
|
+
console.log(` ${c.bold}Usage${c.reset} ${c.dim}npx --yes @kortix/agent-tunnel@latest <command> [options]${c.reset}`);
|
|
327
721
|
console.log('');
|
|
328
722
|
console.log(`${c.gray} ── Commands ────────────────────────────────────────${c.reset}`);
|
|
329
|
-
console.log(` ${c.cyan}connect${c.reset} Connect via device auth
|
|
723
|
+
console.log(` ${c.cyan}connect${c.reset} Connect via device auth; interactively choose foreground/background`);
|
|
724
|
+
console.log(` ${c.cyan}run${c.reset} Run using saved credentials ${c.dim}(used by service)${c.reset}`);
|
|
725
|
+
console.log(` ${c.cyan}install-service${c.reset} Install/start a persistent background service`);
|
|
726
|
+
console.log(` ${c.cyan}start${c.reset} Start the installed background service`);
|
|
727
|
+
console.log(` ${c.cyan}stop${c.reset} Stop the installed background service ${c.dim}(keeps it installed)${c.reset}`);
|
|
728
|
+
console.log(` ${c.cyan}restart${c.reset} Restart the installed background service`);
|
|
729
|
+
console.log(` ${c.cyan}service-status${c.reset} Check persistent service status`);
|
|
730
|
+
console.log(` ${c.cyan}logs${c.reset} Show recent service logs`);
|
|
731
|
+
console.log(` ${c.cyan}uninstall-service${c.reset} Stop/remove the persistent service`);
|
|
330
732
|
console.log(` ${c.cyan}status${c.reset} Check tunnel connection status`);
|
|
331
733
|
console.log(` ${c.cyan}help${c.reset} Show this help message`);
|
|
332
734
|
console.log('');
|
|
@@ -334,6 +736,8 @@ function showHelp(): void {
|
|
|
334
736
|
console.log(` ${c.white}--token${c.reset} ${c.dim}<token>${c.reset} Skip device auth, connect directly`);
|
|
335
737
|
console.log(` ${c.white}--tunnel-id${c.reset} ${c.dim}<id>${c.reset} Tunnel ID ${c.dim}(required with --token)${c.reset}`);
|
|
336
738
|
console.log(` ${c.white}--api-url${c.reset} ${c.dim}<url>${c.reset} API URL ${c.dim}(default: http://localhost:8080)${c.reset}`);
|
|
739
|
+
console.log(` ${c.white}--daemon${c.reset} With connect: skip the prompt and install the background service`);
|
|
740
|
+
console.log(` ${c.white}--foreground${c.reset} With connect: skip prompts and run only in this terminal`);
|
|
337
741
|
console.log('');
|
|
338
742
|
console.log(` ${c.dim}Config: ~/.agent-tunnel/config.json${c.reset}`);
|
|
339
743
|
console.log(` ${c.dim}powered by ${c.cyan}kortix${c.reset}`);
|
|
@@ -342,10 +746,43 @@ function showHelp(): void {
|
|
|
342
746
|
|
|
343
747
|
const { command, flags } = parseArgs(process.argv);
|
|
344
748
|
|
|
749
|
+
if (Object.prototype.hasOwnProperty.call(flags, 'keep-awake')) {
|
|
750
|
+
console.error(`${c.red}${c.bold} error${c.reset} --keep-awake is not supported. Configure sleep behavior in the operating system.`);
|
|
751
|
+
process.exit(2);
|
|
752
|
+
}
|
|
753
|
+
|
|
345
754
|
switch (command) {
|
|
346
755
|
case 'connect':
|
|
347
756
|
commandConnect(flags);
|
|
348
757
|
break;
|
|
758
|
+
case 'run':
|
|
759
|
+
commandRun(flags);
|
|
760
|
+
break;
|
|
761
|
+
case 'install-service':
|
|
762
|
+
commandInstallService(flags);
|
|
763
|
+
break;
|
|
764
|
+
case 'start':
|
|
765
|
+
case 'start-service':
|
|
766
|
+
commandStartService();
|
|
767
|
+
break;
|
|
768
|
+
case 'stop':
|
|
769
|
+
case 'stop-service':
|
|
770
|
+
case 'disable':
|
|
771
|
+
commandStopService();
|
|
772
|
+
break;
|
|
773
|
+
case 'restart':
|
|
774
|
+
case 'restart-service':
|
|
775
|
+
commandRestartService();
|
|
776
|
+
break;
|
|
777
|
+
case 'service-status':
|
|
778
|
+
commandServiceStatus();
|
|
779
|
+
break;
|
|
780
|
+
case 'logs':
|
|
781
|
+
commandLogs();
|
|
782
|
+
break;
|
|
783
|
+
case 'uninstall-service':
|
|
784
|
+
commandUninstallService();
|
|
785
|
+
break;
|
|
349
786
|
case 'status':
|
|
350
787
|
commandStatus(flags);
|
|
351
788
|
break;
|