@kortix/agent-tunnel 0.13.41 → 0.13.43

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.
@@ -173,7 +173,23 @@ describe('agent tunnel connect re-authorization', () => {
173
173
  }
174
174
  }, 30_000);
175
175
 
176
- test('logout clears the credential but keeps unrelated settings', async () => {
176
+ test('logout unpairs on the server first, then clears the credential but keeps unrelated settings (X4)', async () => {
177
+ const requests: Array<{ method: string; path: string; auth: string | null; tunnelId: string | null }> = [];
178
+ const server = Bun.serve({
179
+ port: 0,
180
+ fetch(request) {
181
+ const url = new URL(request.url);
182
+ requests.push({
183
+ method: request.method,
184
+ path: url.pathname,
185
+ auth: request.headers.get('authorization'),
186
+ tunnelId: request.headers.get('x-tunnel-id'),
187
+ });
188
+ return url.pathname === '/v1/tunnel/self' && request.method === 'DELETE'
189
+ ? Response.json({ ok: true })
190
+ : new Response('not found', { status: 404 });
191
+ },
192
+ });
177
193
  const home = await mkdtemp(join(tmpdir(), 'agent-tunnel-logout-home-'));
178
194
  temporaryHomes.add(home);
179
195
  const configDir = join(home, '.agent-tunnel');
@@ -184,22 +200,106 @@ describe('agent tunnel connect re-authorization', () => {
184
200
  JSON.stringify({
185
201
  tunnelId: STALE_TUNNEL_ID,
186
202
  token: STALE_TOKEN,
187
- apiUrl: 'https://api.kortix.com/v1/tunnel',
203
+ apiUrl: `http://127.0.0.1:${server.port}/v1/tunnel`,
188
204
  shellTimeout: 12_345,
189
205
  }),
190
206
  { mode: 0o600 },
191
207
  );
208
+ await writeFile(join(configDir, 'access.json'), JSON.stringify({ mode: 'always', grantedUntil: null, deniedUntil: null, keepAwake: false }), { mode: 0o600 });
209
+
210
+ try {
211
+ const child = spawn(process.execPath, ['run', CLI_PATH, 'logout', '--json', '--keep-service'], {
212
+ env: { ...process.env, HOME: home },
213
+ stdio: ['ignore', 'pipe', 'pipe'],
214
+ });
215
+ children.add(child);
216
+ let stdout = '';
217
+ child.stdout?.on('data', (chunk) => { stdout += String(chunk); });
218
+ expect(await new Promise<number | null>((r) => child.once('exit', r))).toBe(0);
192
219
 
193
- const child = spawn(process.execPath, ['run', CLI_PATH, 'logout'], {
220
+ expect(requests).toEqual([
221
+ { method: 'DELETE', path: '/v1/tunnel/self', auth: `Bearer ${STALE_TOKEN}`, tunnelId: STALE_TUNNEL_ID },
222
+ ]);
223
+ expect(JSON.parse(stdout)).toEqual({ ok: true, serverUnpaired: true, credentialsCleared: true, serviceRemoved: false });
224
+ const config = JSON.parse(await readFile(configPath, 'utf8')) as Record<string, unknown>;
225
+ expect(config.token).toBeUndefined();
226
+ expect(config.tunnelId).toBeUndefined();
227
+ expect(config.shellTimeout).toBe(12_345);
228
+ // The access answer goes with the pairing.
229
+ await expect(readFile(join(configDir, 'access.json'), 'utf8')).rejects.toThrow();
230
+ } finally {
231
+ server.stop(true);
232
+ }
233
+ }, 30_000);
234
+
235
+ test('logout still completes locally when the server cannot be reached', async () => {
236
+ const home = await mkdtemp(join(tmpdir(), 'agent-tunnel-logout-offline-'));
237
+ temporaryHomes.add(home);
238
+ const configDir = join(home, '.agent-tunnel');
239
+ await mkdir(configDir, { recursive: true, mode: 0o700 });
240
+ const configPath = join(configDir, 'config.json');
241
+ await writeFile(
242
+ configPath,
243
+ JSON.stringify({ tunnelId: STALE_TUNNEL_ID, token: STALE_TOKEN, apiUrl: 'http://127.0.0.1:9/v1/tunnel' }),
244
+ { mode: 0o600 },
245
+ );
246
+
247
+ const child = spawn(process.execPath, ['run', CLI_PATH, 'logout', '--json', '--keep-service'], {
194
248
  env: { ...process.env, HOME: home },
195
249
  stdio: ['ignore', 'pipe', 'pipe'],
196
250
  });
197
251
  children.add(child);
198
- await new Promise<number | null>((r) => child.once('exit', r));
252
+ let stdout = '';
253
+ child.stdout?.on('data', (chunk) => { stdout += String(chunk); });
254
+ expect(await new Promise<number | null>((r) => child.once('exit', r))).toBe(0);
255
+ expect(JSON.parse(stdout)).toMatchObject({ ok: true, serverUnpaired: false, credentialsCleared: true });
256
+ expect(JSON.parse(await readFile(configPath, 'utf8')).token).toBeUndefined();
257
+ }, 30_000);
258
+
259
+ test('a new pairing from the desktop app asks before every use and never inherits the previous grant (A1)', async () => {
260
+ const relay = startRejectingRelay({ serveDeviceAuth: true });
261
+ const apiUrl = `http://127.0.0.1:${relay.server.port}/v1/tunnel`;
262
+ const home = await mkdtemp(join(tmpdir(), 'agent-tunnel-access-default-'));
263
+ temporaryHomes.add(home);
264
+ const configDir = join(home, '.agent-tunnel');
265
+ const accessPath = join(configDir, 'access.json');
266
+ await mkdir(configDir, { recursive: true, mode: 0o700 });
267
+ // The desktop app records itself before it pairs: it is the one that answers prompts.
268
+ await writeFile(join(configDir, 'desktop-app.json'), JSON.stringify({ command: '/Applications/Kortix.app/Contents/MacOS/Kortix', args: [], pid: process.pid }));
269
+ try {
270
+ const first = runConnect(home, apiUrl);
271
+ const access = await waitFor(async () => JSON.parse(await readFile(accessPath, 'utf8')));
272
+ expect(access).toEqual({ mode: 'ask', grantedUntil: null, deniedUntil: null, keepAwake: false });
273
+ first.child.kill('SIGTERM');
199
274
 
200
- const config = JSON.parse(await readFile(configPath, 'utf8')) as Record<string, unknown>;
201
- expect(config.token).toBeUndefined();
202
- expect(config.tunnelId).toBeUndefined();
203
- expect(config.shellTimeout).toBe(12_345);
275
+ const grant = new Date(Date.now() + 3_600_000).toISOString();
276
+ await writeFile(accessPath, JSON.stringify({ mode: 'always', grantedUntil: grant, deniedUntil: null, keepAwake: true }), { mode: 0o600 });
277
+ const second = runConnect(home, apiUrl, ['--reauth']);
278
+ await waitFor(async () => {
279
+ if (!second.out().includes('Authorized')) throw new Error('not yet');
280
+ });
281
+ second.child.kill('SIGTERM');
282
+ // Keep awake is a machine setting and stays; the answer resets with the pairing.
283
+ expect(JSON.parse(await readFile(accessPath, 'utf8'))).toEqual({ mode: 'ask', grantedUntil: null, deniedUntil: null, keepAwake: true });
284
+ } finally {
285
+ relay.server.stop(true);
286
+ }
287
+ }, 30_000);
288
+
289
+ test('a CLI-only pairing (no desktop app to answer a prompt) stays always allowed', async () => {
290
+ const relay = startRejectingRelay({ serveDeviceAuth: true });
291
+ const apiUrl = `http://127.0.0.1:${relay.server.port}/v1/tunnel`;
292
+ const home = await mkdtemp(join(tmpdir(), 'agent-tunnel-access-cli-'));
293
+ temporaryHomes.add(home);
294
+ try {
295
+ const run = runConnect(home, apiUrl);
296
+ await waitFor(async () => {
297
+ if (!run.out().includes('Authorized')) throw new Error('not yet');
298
+ });
299
+ run.child.kill('SIGTERM');
300
+ expect(JSON.parse(await readFile(join(home, '.agent-tunnel', 'access.json'), 'utf8'))).toMatchObject({ mode: 'always', grantedUntil: null });
301
+ } finally {
302
+ relay.server.stop(true);
303
+ }
204
304
  }, 30_000);
205
305
  });
package/src/agent/cli.ts CHANGED
@@ -1,9 +1,10 @@
1
1
  import '../node-ws-polyfill';
2
- import { existsSync, readFileSync, writeFileSync } from 'fs';
2
+ import { existsSync, readFileSync, rmSync, writeFileSync } from 'fs';
3
3
  import { hostname } from 'os';
4
4
  import { join } from 'path';
5
5
 
6
6
  import { TunnelAgent } from './agent';
7
+ import { accessFilePath, accessRequestPath, desktopAppPath, readAccess, writeAccess } from './access';
7
8
  import { printStartupBanner } from './banner';
8
9
  import { createEnabledCapabilityRegistry } from './capabilities/enabled-registry';
9
10
  import { loadConfig, type TunnelConfig } from './config';
@@ -11,6 +12,7 @@ import { CONFIG_FILE, clearSavedCredentials, saveCredentials } from './credentia
11
12
  import { probeCredentials } from './credential-probe';
12
13
  import {
13
14
  InvalidDeviceAuthResponseError,
15
+ UUID_PATTERN,
14
16
  awaitDeviceAuthorization,
15
17
  openBrowser,
16
18
  requestDeviceAuthorization,
@@ -19,7 +21,7 @@ import { collapseRepeatedLines, isShellStartupNoise } from './log-format';
19
21
  import { anyFlag, isInteractiveTerminal, isTruthyFlag, promptYesNo } from './prompts';
20
22
  import {
21
23
  DEFAULT_INSTALL_BACKGROUND_SERVICE,
22
- TERMINAL_SERVICE_EXIT_CODE,
24
+ agentTunnelHome,
23
25
  getServicePaths,
24
26
  getServiceStatus,
25
27
  rotateServiceLogs,
@@ -32,6 +34,7 @@ import {
32
34
  describeService,
33
35
  renderServiceAction,
34
36
  } from './service-control';
37
+ import { readAgentState, writeAgentState } from './state-file';
35
38
  import { blankLine, c, clearScreen, field, glyph, stripAnsi } from './terminal';
36
39
  import { agentTunnelVersion } from './version';
37
40
 
@@ -53,8 +56,23 @@ function parseArgs(argv: string[]): { command: string; flags: Flags } {
53
56
  return { command: argv[2] || 'help', flags };
54
57
  }
55
58
 
59
+ // ── machine-readable events (connect --json) ─────────────────────────────────
60
+
61
+ /**
62
+ * `--json` turns `connect` into newline-delimited JSON events on stdout for a
63
+ * program such as the desktop app: `challenge`, `approved`, `service`, `error`.
64
+ * It never prompts and never opens a browser; the caller shows the URL.
65
+ */
66
+ let jsonMode = false;
67
+ const writeStdout = process.stdout.write.bind(process.stdout);
68
+
69
+ function emit(event: string, fields: Record<string, unknown> = {}): void {
70
+ writeStdout(`${JSON.stringify({ event, ...fields })}\n`);
71
+ }
72
+
56
73
  function fail(message: string): never {
57
- console.error(` ${glyph.bad} ${message}`);
74
+ if (jsonMode) emit('error', { message });
75
+ else console.error(` ${glyph.bad} ${message}`);
58
76
  process.exit(1);
59
77
  }
60
78
 
@@ -66,6 +84,12 @@ function shortenHomePath(path: string): string {
66
84
  // ── running the agent ────────────────────────────────────────────────────────
67
85
 
68
86
  function startAgent(config: TunnelConfig, options: { service?: boolean } = {}): void {
87
+ if (jsonMode) {
88
+ // The agent logs to stdout. Keep stdout pure NDJSON for the caller.
89
+ // ponytail: redirect by rebinding; give TunnelAgent a log sink if a second caller needs one.
90
+ process.stdout.write = process.stderr.write.bind(process.stderr) as typeof process.stdout.write;
91
+ options = { ...options, service: true };
92
+ }
69
93
  const registry = createEnabledCapabilityRegistry(config);
70
94
  if (config.enabledCapabilities?.includes('desktop') && !registry.has('desktop')) {
71
95
  console.error(
@@ -74,7 +98,7 @@ function startAgent(config: TunnelConfig, options: { service?: boolean } = {}):
74
98
  }
75
99
 
76
100
  if (options.service) {
77
- console.log(`[agent-tunnel] service starting: ${config.tunnelId} -> ${config.apiUrl}`);
101
+ process.stdout.write(`[agent-tunnel] service starting: ${config.tunnelId} -> ${config.apiUrl}\n`);
78
102
  } else {
79
103
  clearScreen();
80
104
  void printStartupBanner({
@@ -85,15 +109,19 @@ function startAgent(config: TunnelConfig, options: { service?: boolean } = {}):
85
109
  });
86
110
  }
87
111
 
88
- const agent = new TunnelAgent(config, registry, {
89
- onTerminalClose: ({ reason }) => {
90
- if (!options.service) return;
91
- // Staying alive would leave a supervised process connected to nothing that
92
- // the supervisor never restarts. Exit cleanly so the service stops.
93
- console.log(`[agent-tunnel] stopping service: ${reason}`);
94
- process.exit(TERMINAL_SERVICE_EXIT_CODE);
112
+ // A service never stops on its own (R2): a refused credential waits in
113
+ // `rejected` and re-reads config.json, so pairing again heals it.
114
+ const agent = new TunnelAgent(
115
+ config,
116
+ registry,
117
+ // The agent passes the credential it uses NOW: a re-pair picked up by a
118
+ // `rejected` service has a new tunnelId, and state.json must name it.
119
+ { onStatus: (status, current) => writeAgentState(status, current) },
120
+ {
121
+ persistent: options.service === true,
122
+ reloadConfig: () => loadConfig({ apiUrl: config.apiUrl }),
95
123
  },
96
- });
124
+ );
97
125
  agent.connect();
98
126
 
99
127
  const shutdown = () => {
@@ -110,7 +138,7 @@ function startAgent(config: TunnelConfig, options: { service?: boolean } = {}):
110
138
  async function chooseBackgroundMode(flags: Flags): Promise<boolean> {
111
139
  if (anyFlag(flags, BACKGROUND_FLAGS)) return true;
112
140
  if (anyFlag(flags, FOREGROUND_FLAGS)) return false;
113
- if (!isInteractiveTerminal()) return false;
141
+ if (jsonMode || !isInteractiveTerminal()) return false;
114
142
 
115
143
  blankLine();
116
144
  console.log(` ${glyph.warn} ${c.bold}Security note${c.reset}`);
@@ -125,25 +153,41 @@ async function chooseBackgroundMode(flags: Flags): Promise<boolean> {
125
153
  async function launch(config: TunnelConfig, flags: Flags, lease?: { serviceWasActive: boolean }): Promise<void> {
126
154
  if (await chooseBackgroundMode(flags)) {
127
155
  saveCredentials(config.tunnelId, config.token, config.apiUrl);
128
- renderServiceAction('install', SERVICE_ACTIONS.install.run());
156
+ if (!jsonMode) {
157
+ renderServiceAction('install', SERVICE_ACTIONS.install.run());
158
+ return;
159
+ }
160
+ // Return instead of process.exit(): an exit can cut off a piped stdout.
161
+ try {
162
+ const status = SERVICE_ACTIONS.install.run();
163
+ // Installed is not enough: a job launchd did not load keeps the computer offline.
164
+ const ok = status.installed && status.active === true;
165
+ emit('service', { action: 'install', ok, active: status.active, detail: status.detail ?? '' });
166
+ if (!ok) process.exitCode = 1;
167
+ } catch (error) {
168
+ emit('service', { action: 'install', ok: false, detail: error instanceof Error ? error.message : String(error) });
169
+ process.exitCode = 1;
170
+ }
129
171
  return;
130
172
  }
131
173
 
132
- if (lease?.serviceWasActive) {
174
+ if (lease?.serviceWasActive && !jsonMode) {
133
175
  console.log(` ${c.dim}Background service stays paused while this terminal holds the tunnel.${c.reset}`);
134
176
  console.log(` ${c.dim}Resume it with${c.reset} ${c.white}agent-tunnel start${c.reset}${c.dim}, or leave it — it starts again at login.${c.reset}`);
135
177
  }
136
178
  startAgent(config);
137
179
  }
138
180
 
139
- async function pairThisMachine(apiUrl: string, flags: Flags): Promise<void> {
140
- blankLine();
141
- console.log(` ${glyph.mark} ${c.bold}Device Authorization${c.reset}`);
142
- blankLine();
181
+ async function pairThisMachine(apiUrl: string, flags: Flags, lease?: { serviceWasActive: boolean }): Promise<void> {
182
+ if (!jsonMode) {
183
+ blankLine();
184
+ console.log(` ${glyph.mark} ${c.bold}Device Authorization${c.reset}`);
185
+ blankLine();
186
+ }
143
187
 
144
188
  let challenge;
145
189
  try {
146
- challenge = await requestDeviceAuthorization(apiUrl);
190
+ challenge = await requestDeviceAuthorization(apiUrl, { projectId: flags['project-id'] });
147
191
  } catch (error) {
148
192
  fail(
149
193
  error instanceof InvalidDeviceAuthResponseError
@@ -152,27 +196,36 @@ async function pairThisMachine(apiUrl: string, flags: Flags): Promise<void> {
152
196
  );
153
197
  }
154
198
 
155
- console.log(` ${c.dim}Code:${c.reset} ${c.bold}${c.white}${challenge.deviceCode}${c.reset}`);
156
- blankLine();
157
- console.log(` ${c.dim}Open this URL on any device to approve:${c.reset}`);
158
- console.log(` ${c.cyan}${challenge.verificationUrl}${c.reset}`);
159
- blankLine();
160
- openBrowser(challenge.verificationUrl);
199
+ if (jsonMode) {
200
+ emit('challenge', {
201
+ deviceCode: challenge.deviceCode,
202
+ verificationUrl: challenge.verificationUrl,
203
+ expiresAt: challenge.expiresAt,
204
+ });
205
+ } else {
206
+ console.log(` ${c.dim}Code:${c.reset} ${c.bold}${c.white}${challenge.deviceCode}${c.reset}`);
207
+ blankLine();
208
+ console.log(` ${c.dim}Open this URL on any device to approve:${c.reset}`);
209
+ console.log(` ${c.cyan}${challenge.verificationUrl}${c.reset}`);
210
+ blankLine();
211
+ openBrowser(challenge.verificationUrl);
212
+ }
161
213
 
214
+ const clearWaiting = () => { if (!jsonMode) process.stdout.write(`\r${' '.repeat(60)}\r`); };
162
215
  let outcome;
163
216
  try {
164
217
  outcome = await awaitDeviceAuthorization(apiUrl, challenge, {
165
- onWaiting: (secondsRemaining) => {
218
+ onWaiting: jsonMode ? undefined : (secondsRemaining) => {
166
219
  const minutes = Math.floor(secondsRemaining / 60);
167
220
  const seconds = String(secondsRemaining % 60).padStart(2, '0');
168
221
  process.stdout.write(`\r ${c.dim}Waiting for approval... ${c.white}${minutes}:${seconds}${c.reset} `);
169
222
  },
170
223
  });
171
224
  } catch (error) {
172
- process.stdout.write(`\r${' '.repeat(60)}\r`);
225
+ clearWaiting();
173
226
  fail(error instanceof Error ? error.message : 'Device authorization failed');
174
227
  }
175
- process.stdout.write(`\r${' '.repeat(60)}\r`);
228
+ clearWaiting();
176
229
 
177
230
  if (outcome.status === 'denied') fail('Authorization denied.');
178
231
  if (outcome.status === 'expired') fail('Authorization expired. Please try again.');
@@ -183,6 +236,7 @@ async function pairThisMachine(apiUrl: string, flags: Flags): Promise<void> {
183
236
  // The approved set is a ceiling only re-pairing can widen. Saving an empty one
184
237
  // yields a tunnel that connects, reports success, and can do nothing.
185
238
  if (outcome.capabilities.length === 0) {
239
+ if (jsonMode) fail('No capabilities were approved. Nothing was saved. Pair again and approve at least one capability.');
186
240
  console.log(` ${glyph.bad} ${c.bold}No capabilities were approved${c.reset}`);
187
241
  blankLine();
188
242
  console.log(` ${c.dim}A tunnel with no capabilities connects but cannot act, and the${c.reset}`);
@@ -193,13 +247,28 @@ async function pairThisMachine(apiUrl: string, flags: Flags): Promise<void> {
193
247
  process.exit(1);
194
248
  }
195
249
 
196
- console.log(` ${glyph.on} ${c.bold}Authorized${c.reset}`);
197
250
  saveCredentials(outcome.tunnelId, outcome.token, apiUrl, outcome.capabilities);
198
- console.log(` ${c.dim}Saved to ${CONFIG_FILE}${c.reset}`);
199
- console.log(` ${c.dim}Access: ${outcome.capabilities.join(', ')}${c.reset}`);
200
- blankLine();
251
+ // A1: a pairing made from the desktop app asks before each use; the app is
252
+ // what answers. A CLI-only machine has nobody to answer a prompt, so it is
253
+ // always allowed (a documented deviation from A1). A new pairing never
254
+ // inherits a grant or an `always` from the previous one; keep awake is a
255
+ // machine setting and stays.
256
+ writeAccess({
257
+ mode: existsSync(desktopAppPath()) ? 'ask' : 'always',
258
+ grantedUntil: null,
259
+ deniedUntil: null,
260
+ keepAwake: readAccess().keepAwake,
261
+ });
262
+ if (jsonMode) {
263
+ emit('approved', { tunnelId: outcome.tunnelId, capabilities: outcome.capabilities });
264
+ } else {
265
+ console.log(` ${glyph.on} ${c.bold}Authorized${c.reset}`);
266
+ console.log(` ${c.dim}Saved to ${CONFIG_FILE}${c.reset}`);
267
+ console.log(` ${c.dim}Access: ${outcome.capabilities.join(', ')}${c.reset}`);
268
+ blankLine();
269
+ }
201
270
 
202
- await launch(loadConfig({ apiUrl }), flags);
271
+ await launch(loadConfig({ apiUrl }), flags, lease);
203
272
  }
204
273
 
205
274
  async function commandConnect(flags: Flags): Promise<void> {
@@ -215,23 +284,36 @@ async function commandConnect(flags: Flags): Promise<void> {
215
284
  if (Boolean(config.token) !== Boolean(config.tunnelId)) {
216
285
  fail('Provide both --token and --tunnel-id, or neither (for device auth)');
217
286
  }
287
+ if (flags['project-id'] !== undefined && !UUID_PATTERN.test(flags['project-id'])) {
288
+ fail('--project-id must be a project UUID');
289
+ }
218
290
 
219
- if (!config.token) {
220
- await pairThisMachine(config.apiUrl, flags);
291
+ // X6: a credential saved for another backend is never sent to this one. Pair
292
+ // afresh; the other pairing stays on disk until this one succeeds.
293
+ const savedForOtherBackend =
294
+ Boolean(config.token) && !explicitCredentials && Boolean(flags['api-url']) &&
295
+ new URL(loadConfig().apiUrl).origin !== new URL(config.apiUrl).origin;
296
+
297
+ // Take the credential from the background service before probing or pairing:
298
+ // the relay allows one connection, and a waiting or `rejected` service picks
299
+ // up a new pairing within seconds and would displace this process.
300
+ const lease = acquireTunnelLease();
301
+
302
+ if (!config.token || savedForOtherBackend) {
303
+ await pairThisMachine(config.apiUrl, flags, lease);
221
304
  return;
222
305
  }
223
306
 
224
307
  if (isTruthyFlag(flags.reauth) && !explicitCredentials) {
225
308
  clearSavedCredentials();
226
- await pairThisMachine(config.apiUrl, flags);
309
+ await pairThisMachine(config.apiUrl, flags, lease);
227
310
  return;
228
311
  }
229
312
 
230
- // Take the credential from the background service before probing, so the two
231
- // never race for the single connection the relay allows.
232
- const lease = acquireTunnelLease();
233
- blankLine();
234
- console.log(` ${glyph.mark} ${c.dim}Checking saved credentials…${c.reset}`);
313
+ if (!jsonMode) {
314
+ blankLine();
315
+ console.log(` ${glyph.mark} ${c.dim}Checking saved credentials…${c.reset}`);
316
+ }
235
317
 
236
318
  let probe;
237
319
  try {
@@ -251,12 +333,16 @@ async function commandConnect(flags: Flags): Promise<void> {
251
333
 
252
334
  if (probe === 'rejected') {
253
335
  if (explicitCredentials) fail('The supplied --token was rejected for this tunnel.');
254
- console.log(` ${glyph.warn} ${c.dim}Saved token rejected — re-authorizing${c.reset}`);
336
+ if (!jsonMode) console.log(` ${glyph.warn} ${c.dim}Saved token rejected — re-authorizing${c.reset}`);
255
337
  clearSavedCredentials();
256
- await pairThisMachine(config.apiUrl, flags);
338
+ await pairThisMachine(config.apiUrl, flags, lease);
257
339
  return;
258
340
  }
259
341
 
342
+ // The saved pairing is still valid: report it as the approved credential.
343
+ if (jsonMode) {
344
+ emit('approved', { tunnelId: config.tunnelId, capabilities: config.enabledCapabilities ?? [], existing: true });
345
+ }
260
346
  await launch(config, flags, lease);
261
347
  }
262
348
 
@@ -271,10 +357,21 @@ function commandRun(flags: Flags): void {
271
357
  const asService = flags.service === 'true';
272
358
 
273
359
  if (!config.token || !config.tunnelId) {
274
- console.error(` ${glyph.bad} No saved tunnel credentials found. Run \`agent-tunnel connect\` first.`);
275
- // Restarting cannot conjure a credential. Under a supervisor this exits
276
- // cleanly so the service stops instead of respawning forever.
277
- process.exit(asService ? TERMINAL_SERVICE_EXIT_CODE : 1);
360
+ if (!asService) {
361
+ console.error(` ${glyph.bad} No saved tunnel credentials found. Run \`agent-tunnel connect\` first.`);
362
+ process.exit(1);
363
+ }
364
+ // R2: the supervisor restarts on every exit, so exiting would only spin.
365
+ // Wait for a pairing to write the credential instead.
366
+ process.stdout.write('[agent-tunnel] no saved credential — waiting for a credential (pair this computer to start)\n');
367
+ const wait = setInterval(() => {
368
+ const next = loadConfig({ apiUrl: flags['api-url'] });
369
+ if (!next.token || !next.tunnelId) return;
370
+ clearInterval(wait);
371
+ rotateServiceLogs();
372
+ startAgent(next, { service: true });
373
+ }, 60_000);
374
+ return;
278
375
  }
279
376
 
280
377
  if (asService) rotateServiceLogs();
@@ -307,7 +404,11 @@ function commandStatus(flags: Flags): void {
307
404
  apiUrl: config.apiUrl,
308
405
  capabilities: [...approved],
309
406
  version: agentTunnelVersion(),
407
+ home: agentTunnelHome(),
408
+ serviceLabel: getServicePaths().label,
310
409
  service,
410
+ state: readAgentState(),
411
+ access: readAccess(),
311
412
  lastActivity: lastServiceActivity(),
312
413
  }, null, 2));
313
414
  return;
@@ -347,12 +448,49 @@ function commandStatus(flags: Flags): void {
347
448
  blankLine();
348
449
  }
349
450
 
350
- function commandLogout(flags: Flags): void {
451
+ /**
452
+ * X4: `DELETE /v1/tunnel/self`, authenticated by this machine's own credential,
453
+ * removes the machine and its accounts on the server. Best effort: a failure
454
+ * never blocks the local sign-out.
455
+ */
456
+ async function unpairOnServer(): Promise<boolean> {
457
+ let config: TunnelConfig;
458
+ try {
459
+ config = loadConfig();
460
+ } catch {
461
+ return false;
462
+ }
463
+ if (!config.token || !config.tunnelId) return false;
464
+ try {
465
+ const response = await fetch(`${config.apiUrl}/self`, {
466
+ method: 'DELETE',
467
+ headers: { Authorization: `Bearer ${config.token}`, 'X-Tunnel-Id': config.tunnelId },
468
+ signal: AbortSignal.timeout(10_000),
469
+ });
470
+ return response.ok;
471
+ } catch {
472
+ return false;
473
+ }
474
+ }
475
+
476
+ async function commandLogout(flags: Flags): Promise<void> {
477
+ const serverUnpaired = await unpairOnServer();
351
478
  const removed = clearSavedCredentials();
479
+ // The access answer belongs to this pairing; the next one starts fresh.
480
+ rmSync(accessFilePath(), { force: true });
481
+ rmSync(accessRequestPath(), { force: true });
352
482
  const keepService = isTruthyFlag(flags['keep-service']);
353
483
  if (!keepService) SERVICE_ACTIONS.uninstall.run();
354
484
 
485
+ if (isTruthyFlag(flags.json)) {
486
+ console.log(JSON.stringify({ ok: true, serverUnpaired, credentialsCleared: removed, serviceRemoved: !keepService }));
487
+ return;
488
+ }
489
+
355
490
  blankLine();
491
+ console.log(serverUnpaired
492
+ ? ` ${glyph.on} ${c.dim}Removed this computer from Kortix${c.reset}`
493
+ : ` ${glyph.warn} ${c.dim}Could not reach Kortix — remove this computer from its settings${c.reset}`);
356
494
  console.log(removed
357
495
  ? ` ${glyph.on} ${c.bold}Signed out${c.reset} ${c.dim}(credentials cleared from ${CONFIG_FILE})${c.reset}`
358
496
  : ` ${glyph.off} ${c.dim}No saved credentials to clear${c.reset}`);
@@ -444,11 +582,15 @@ const COMMANDS: Record<string, Command> = {
444
582
  },
445
583
  status: { summary: 'Show pairing, capabilities, and service state (--json)', run: commandStatus },
446
584
  logs: { summary: 'Show recent service logs (--lines <n>, --all, --clear)', run: commandLogs },
447
- start: { summary: 'Start the background service', run: (f) => commandServiceAction('start', f) },
585
+ start: {
586
+ summary: 'Start the background service (resumes a paused one)',
587
+ run: (f) => commandServiceAction('start', f),
588
+ aliases: ['resume'],
589
+ },
448
590
  stop: {
449
- summary: 'Stop the background service (keeps it installed)',
591
+ summary: 'Pause the background service; it stays stopped after restart until `start`',
450
592
  run: (f) => commandServiceAction('stop', f),
451
- aliases: ['disable'],
593
+ aliases: ['disable', 'pause'],
452
594
  },
453
595
  restart: { summary: 'Restart the background service', run: (f) => commandServiceAction('restart', f) },
454
596
  'install-service': {
@@ -460,10 +602,10 @@ const COMMANDS: Record<string, Command> = {
460
602
  run: (f) => commandServiceAction('uninstall', f),
461
603
  },
462
604
  'service-status': {
463
- summary: 'Show the background service state (same view as status)',
605
+ summary: 'Show the background service state (same view as status, --json)',
464
606
  run: commandStatus,
465
607
  },
466
- logout: { summary: 'Clear saved credentials and remove the service', run: commandLogout },
608
+ logout: { summary: 'Remove this computer from Kortix, clear credentials, remove the service', run: commandLogout },
467
609
  run: { summary: 'Run using saved credentials (used by the service)', run: commandRun },
468
610
  'start-service': { summary: '', run: (f) => commandServiceAction('start', f), hidden: true },
469
611
  'stop-service': { summary: '', run: (f) => commandServiceAction('stop', f), hidden: true },
@@ -477,7 +619,8 @@ const OPTIONS: ReadonlyArray<readonly [string, string]> = [
477
619
  ['--token <token> --tunnel-id <id>', 'Skip device auth and use an explicit credential'],
478
620
  ['--reauth', 'With connect: discard the saved credential and pair again'],
479
621
  ['--daemon / --foreground', 'With connect: skip the prompt and choose the mode'],
480
- ['--json', 'With status: machine-readable output'],
622
+ ['--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'],
481
624
  ['--keep-service', 'With logout: keep the background service installed'],
482
625
  ];
483
626
 
@@ -506,11 +649,13 @@ function showHelp(): void {
506
649
  }
507
650
  blankLine();
508
651
  console.log(` ${c.dim}Config: ${CONFIG_FILE}${c.reset}`);
652
+ console.log(` ${c.dim}Set AGENT_TUNNEL_HOME to use another config directory and service.${c.reset}`);
509
653
  console.log(` ${c.dim}powered by ${c.cyan}kortix${c.reset}`);
510
654
  blankLine();
511
655
  }
512
656
 
513
657
  const { command, flags } = parseArgs(process.argv);
658
+ jsonMode = command === 'connect' && isTruthyFlag(flags.json);
514
659
 
515
660
  if (Object.prototype.hasOwnProperty.call(flags, 'keep-awake')) {
516
661
  console.error(` ${glyph.bad} --keep-awake is not supported. Configure sleep behavior in the operating system.`);
@@ -525,7 +670,6 @@ if (!resolved) {
525
670
  showHelp();
526
671
  } else {
527
672
  void Promise.resolve(resolved.run(flags)).catch((error: unknown) => {
528
- console.error(` ${glyph.bad} ${error instanceof Error ? error.message : String(error)}`);
529
- process.exit(1);
673
+ fail(error instanceof Error ? error.message : String(error));
530
674
  });
531
675
  }
@@ -1,6 +1,7 @@
1
1
  import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
2
2
 
3
3
  import { loadConfig } from './config';
4
+ import { agentTunnelHome } from './service-paths';
4
5
 
5
6
  const ENV_KEYS = ['TUNNEL_API_URL', 'TUNNEL_WS_PATH'] as const;
6
7
 
@@ -44,6 +45,10 @@ describe('loadConfig', () => {
44
45
  );
45
46
  });
46
47
 
48
+ it('always blocks the agent home, whatever config.json says', () => {
49
+ expect(loadConfig({ blockedPaths: [] }).blockedPaths).toContain(agentTunnelHome());
50
+ });
51
+
47
52
  it('requires the websocket path to be an absolute path', () => {
48
53
  expect(loadConfig({ wsPath: '/relay/ws' }).wsPath).toBe('/relay/ws');
49
54
  expect(() => loadConfig({ wsPath: 'https://relay.example/ws' })).toThrow(