@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.
Files changed (67) hide show
  1. package/README.md +65 -0
  2. package/dist/agent-cli.js +4676 -3174
  3. package/dist/client-cli.js +203 -494
  4. package/package.json +24 -29
  5. package/src/agent/agent.ts +67 -17
  6. package/src/agent/capabilities/desktop/cua-driver.ts +284 -0
  7. package/src/agent/capabilities/desktop.ts +100 -167
  8. package/src/agent/capabilities/enabled-registry.ts +24 -0
  9. package/src/agent/capabilities/filesystem.ts +136 -32
  10. package/src/agent/capabilities/index.ts +1 -1
  11. package/src/agent/capabilities/security.test.ts +204 -0
  12. package/src/agent/capabilities/shell.ts +37 -3
  13. package/src/agent/cli-device-auth.test.ts +179 -0
  14. package/src/agent/cli-help.test.ts +25 -0
  15. package/src/agent/cli.ts +475 -38
  16. package/src/agent/config.test.ts +53 -0
  17. package/src/agent/config.ts +169 -7
  18. package/src/agent/index.ts +1 -0
  19. package/src/agent/security/command-validator.ts +4 -2
  20. package/src/agent/security/path-validator.ts +73 -18
  21. package/src/agent/security/permission-guard.test.ts +52 -0
  22. package/src/agent/security/permission-guard.ts +35 -8
  23. package/src/agent/service.test.ts +63 -0
  24. package/src/agent/service.ts +410 -0
  25. package/src/client/cli.test.ts +150 -547
  26. package/src/client/cli.ts +116 -537
  27. package/src/client/index.ts +1 -1
  28. package/src/client/tools.ts +95 -356
  29. package/src/client/tunnel-client.ts +50 -80
  30. package/src/index.ts +7 -1
  31. package/src/node-ws-polyfill.test.ts +18 -0
  32. package/src/node-ws-polyfill.ts +5 -3
  33. package/src/server/heartbeat.ts +13 -6
  34. package/src/server/relay.test.ts +72 -0
  35. package/src/server/relay.ts +50 -9
  36. package/src/server/server.test.ts +33 -0
  37. package/src/server/server.ts +26 -6
  38. package/src/server/ws-handler.test.ts +158 -0
  39. package/src/server/ws-handler.ts +94 -36
  40. package/src/shared/crypto.ts +2 -3
  41. package/src/shared/index.ts +8 -0
  42. package/src/shared/permissions.ts +292 -0
  43. package/src/shared/types.ts +70 -41
  44. package/dist/agent/index.d.ts +0 -140
  45. package/dist/agent/index.js +0 -21
  46. package/dist/agent/index.js.map +0 -1
  47. package/dist/chunk-7N7GSU6K.js +0 -34
  48. package/dist/client/index.d.ts +0 -183
  49. package/dist/client/index.js +0 -8
  50. package/dist/client/index.js.map +0 -1
  51. package/dist/index.d.ts +0 -7
  52. package/dist/index.js +0 -55
  53. package/dist/index.js.map +0 -1
  54. package/dist/server/index.d.ts +0 -89
  55. package/dist/server/index.js +0 -14
  56. package/dist/server/index.js.map +0 -1
  57. package/dist/shared/index.d.ts +0 -10
  58. package/dist/shared/index.js +0 -20
  59. package/dist/shared/index.js.map +0 -1
  60. package/dist/types-Dpwrd8Ai.d.ts +0 -194
  61. package/src/agent/capabilities/desktop/atspi-helper.ts +0 -345
  62. package/src/agent/capabilities/desktop/csharp-helper.ts +0 -914
  63. package/src/agent/capabilities/desktop/linux-driver.ts +0 -368
  64. package/src/agent/capabilities/desktop/macos-driver.ts +0 -601
  65. package/src/agent/capabilities/desktop/swift-helper.ts +0 -736
  66. package/src/agent/capabilities/desktop/types.ts +0 -201
  67. 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 { CapabilityRegistry } from './capabilities/index';
5
- import { createFilesystemCapability } from './capabilities/filesystem';
6
- import { createShellCapability } from './capabilities/shell';
7
- import { createDesktopCapability } from './capabilities/desktop';
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 { execSync } from 'child_process';
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 = new CapabilityRegistry();
123
- registry.register(createFilesystemCapability(config));
124
- registry.register(createShellCapability(config));
125
- registry.register(createDesktopCapability());
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
- clearScreen();
128
- printStartup(config, registry.getCapabilityNames(), '0.1.2');
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
- if (plat === 'darwin') execSync(`open "${url}"`);
147
- else if (plat === 'win32') execSync(`start "" "${url}"`);
148
- else execSync(`xdg-open "${url}"`);
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 saveCredentials(tunnelId: string, token: string, apiUrl: string): void {
156
- mkdirSync(CONFIG_DIR, { recursive: true });
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
- writeFileSync(CONFIG_FILE, JSON.stringify({ ...existing, tunnelId, token, apiUrl }, null, 2));
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 data = await res.json();
188
- deviceCode = data.deviceCode;
189
- deviceSecret = data.deviceSecret;
190
- verificationUrl = data.verificationUrl;
191
- expiresAt = data.expiresAt;
192
- pollIntervalMs = data.pollIntervalMs || 2000;
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
- console.error(` ${c.red}✗${c.reset} Failed to reach API at ${config.apiUrl}`);
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?secret=${deviceSecret}`);
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
- // Save credentials
233
- saveCredentials(data.tunnelId, data.token, config.apiUrl);
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: data.token,
240
- tunnelId: data.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 (opens browser)`);
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;