@kortix/agent-tunnel 0.12.7 → 0.13.0

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.
@@ -194,6 +194,25 @@ export function absoluteWsPath(value: string): string {
194
194
  return value;
195
195
  }
196
196
 
197
+ /**
198
+ * Builds the relay WebSocket URL. The token is never placed in the URL — the
199
+ * agent sends it in the first `auth` message instead.
200
+ */
201
+ export function buildTunnelWsUrl(
202
+ config: Pick<TunnelConfig, 'apiUrl' | 'tunnelId'> & { wsPath?: string },
203
+ ): string {
204
+ const base = trustedHttpUrl(config.apiUrl)
205
+ .replace(/^http:/, 'ws:')
206
+ .replace(/^https:/, 'wss:');
207
+
208
+ const wsPath = absoluteWsPath(config.wsPath || '/ws');
209
+ const params = new URLSearchParams({
210
+ tunnelId: trustedCredential(config.tunnelId, 'tunnelId'),
211
+ });
212
+
213
+ return `${base}${wsPath}?${params.toString()}`;
214
+ }
215
+
197
216
  export function loadConfig(overrides: Partial<TunnelConfig> = {}): TunnelConfig {
198
217
  let fileConfig: Partial<TunnelConfig> = {};
199
218
  if (existsSync(CONFIG_FILE)) {
@@ -0,0 +1,95 @@
1
+ import '../node-ws-polyfill';
2
+ import { AGENT_REPLACED_CLOSE_CODE, AGENT_VERSION, AUTH_REJECTED_CLOSE_CODES } from './agent';
3
+ import { buildTunnelWsUrl, trustedCredential, type TunnelConfig } from './config';
4
+
5
+ /**
6
+ * - `valid` the relay accepted the credential.
7
+ * - `rejected` the relay refused it. Re-pairing is the only fix.
8
+ * - `unreachable` no verdict was reached. The credential is NOT proven bad, so
9
+ * callers must keep it.
10
+ */
11
+ export type CredentialProbeResult = 'valid' | 'rejected' | 'unreachable';
12
+
13
+ export interface ProbeCredentialsOptions {
14
+ /** Capabilities advertised in the handshake. Must match what the agent sends. */
15
+ capabilities?: string[];
16
+ timeoutMs?: number;
17
+ }
18
+
19
+ const DEFAULT_PROBE_TIMEOUT_MS = 10_000;
20
+
21
+ /**
22
+ * Runs one relay handshake and reports whether the credential still works.
23
+ *
24
+ * `connect` calls this before reusing a saved credential. Without it, a revoked
25
+ * token is only discovered after the background service is installed, which
26
+ * produces a silent restart loop instead of a re-pair prompt.
27
+ *
28
+ * Callers must already hold the tunnel lease — see acquireTunnelLease(). The
29
+ * relay allows a single agent per tunnel, so probing while the service is live
30
+ * would evict it.
31
+ */
32
+ export function probeCredentials(
33
+ config: TunnelConfig,
34
+ options: ProbeCredentialsOptions = {},
35
+ ): Promise<CredentialProbeResult> {
36
+ return new Promise<CredentialProbeResult>((resolve) => {
37
+ let socket: WebSocket;
38
+ try {
39
+ socket = new WebSocket(new URL(buildTunnelWsUrl(config)));
40
+ } catch {
41
+ resolve('unreachable');
42
+ return;
43
+ }
44
+
45
+ // resolve() is idempotent, so the first verdict wins and later events are
46
+ // harmless no-ops. That removes any need to track settled state by hand.
47
+ const settle = (result: CredentialProbeResult): void => {
48
+ clearTimeout(timer);
49
+ try { socket.close(1000, 'probe complete'); } catch {}
50
+ resolve(result);
51
+ };
52
+
53
+ const timer = setTimeout(() => settle('unreachable'), options.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS);
54
+
55
+ socket.addEventListener('open', () => {
56
+ try {
57
+ // Handing the saved credential to the relay is the whole point of the
58
+ // handshake. loadConfig() has already validated that the file is
59
+ // private and user-owned; trustedCredential() rejects control characters.
60
+ socket.send(JSON.stringify({
61
+ type: 'auth',
62
+ token: trustedCredential(config.token, 'token'),
63
+ capabilities: options.capabilities ?? [],
64
+ agentVersion: AGENT_VERSION,
65
+ }));
66
+ } catch {
67
+ settle('unreachable');
68
+ }
69
+ });
70
+
71
+ socket.addEventListener('message', (event) => {
72
+ try {
73
+ const message: unknown = JSON.parse(String((event as MessageEvent).data));
74
+ if (isJsonRecord(message) && message.type === 'auth_ok') settle('valid');
75
+ } catch {
76
+ // Not the message we are waiting for.
77
+ }
78
+ });
79
+
80
+ socket.addEventListener('close', (event) => {
81
+ const { code } = event as CloseEvent;
82
+ if (AUTH_REJECTED_CLOSE_CODES.includes(code)) return settle('rejected');
83
+ // The relay only replaces a socket it registered, and it only registers
84
+ // one that authenticated. Being replaced proves the credential is good.
85
+ if (code === AGENT_REPLACED_CLOSE_CODE) return settle('valid');
86
+ settle('unreachable');
87
+ });
88
+
89
+ socket.addEventListener('error', () => settle('unreachable'));
90
+ });
91
+ }
92
+
93
+ function isJsonRecord(value: unknown): value is Record<string, unknown> {
94
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
95
+ }
@@ -0,0 +1,74 @@
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'fs';
2
+ import { homedir } from 'os';
3
+ import { join } from 'path';
4
+
5
+ export const CONFIG_DIR = join(homedir(), '.agent-tunnel');
6
+ export const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
7
+
8
+ /** Fields that identify a pairing. Everything else in the file is user settings. */
9
+ const PAIRING_FIELDS = ['token', 'tunnelId', 'enabledCapabilities'] as const;
10
+
11
+ function readConfigFile(): Record<string, unknown> {
12
+ try {
13
+ const parsed: unknown = JSON.parse(readFileSync(CONFIG_FILE, 'utf-8'));
14
+ return parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)
15
+ ? (parsed as Record<string, unknown>)
16
+ : {};
17
+ } catch {
18
+ return {};
19
+ }
20
+ }
21
+
22
+ /**
23
+ * Replaces the config file atomically, private-by-default.
24
+ *
25
+ * Both writers need identical tmp-write/rename/chmod handling; duplicating it
26
+ * once already meant two places to get the permission bits right.
27
+ */
28
+ function writeConfigFileAtomic(next: Record<string, unknown>): void {
29
+ mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
30
+ try { chmodSync(CONFIG_DIR, 0o700); } catch {}
31
+
32
+ const tmpFile = join(CONFIG_DIR, `config.${process.pid}.${Date.now()}.tmp`);
33
+ writeFileSync(tmpFile, JSON.stringify(next, null, 2), { mode: 0o600, flag: 'wx' });
34
+ try { chmodSync(tmpFile, 0o600); } catch {}
35
+ renameSync(tmpFile, CONFIG_FILE);
36
+ try { chmodSync(CONFIG_FILE, 0o600); } catch {}
37
+ }
38
+
39
+ export function saveCredentials(
40
+ tunnelId: string,
41
+ token: string,
42
+ apiUrl: string,
43
+ enabledCapabilities?: string[],
44
+ ): void {
45
+ writeConfigFileAtomic({
46
+ ...readConfigFile(),
47
+ tunnelId,
48
+ token,
49
+ apiUrl,
50
+ ...(enabledCapabilities !== undefined ? { enabledCapabilities } : {}),
51
+ });
52
+ }
53
+
54
+ /**
55
+ * Drops only the pairing fields, so user-tuned settings such as `allowedPaths`
56
+ * survive a re-pair. Returns true when a credential was actually present.
57
+ */
58
+ export function clearSavedCredentials(): boolean {
59
+ if (!existsSync(CONFIG_FILE)) return false;
60
+
61
+ let existing: Record<string, unknown>;
62
+ try {
63
+ existing = JSON.parse(readFileSync(CONFIG_FILE, 'utf-8')) as Record<string, unknown>;
64
+ } catch {
65
+ // Unparseable file: there is nothing worth preserving.
66
+ rmSync(CONFIG_FILE, { force: true });
67
+ return true;
68
+ }
69
+
70
+ const hadCredentials = PAIRING_FIELDS.some((key) => key in existing && existing[key] != null);
71
+ for (const key of PAIRING_FIELDS) delete existing[key];
72
+ writeConfigFileAtomic(existing);
73
+ return hadCredentials;
74
+ }
@@ -0,0 +1,132 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import {
3
+ InvalidDeviceAuthResponseError,
4
+ awaitDeviceAuthorization,
5
+ parseDeviceAuthChallenge,
6
+ parseDeviceAuthStatus,
7
+ } from './device-auth';
8
+
9
+ const VALID_TUNNEL_ID = '00000000-0000-4000-8000-000000000042';
10
+ const VALID_TOKEN = `kortix_tnl_${'A'.repeat(36)}`;
11
+
12
+ function challenge(overrides: Record<string, unknown> = {}) {
13
+ return {
14
+ deviceCode: 'ABCD-1234',
15
+ deviceSecret: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ123456',
16
+ verificationUrl: 'https://kortix.com/tunnel/authorize/ABCD-1234',
17
+ expiresAt: new Date(Date.now() + 60_000).toISOString(),
18
+ pollIntervalMs: 2000,
19
+ ...overrides,
20
+ };
21
+ }
22
+
23
+ describe('device auth challenge validation', () => {
24
+ test('accepts a well-formed challenge', () => {
25
+ const parsed = parseDeviceAuthChallenge(challenge());
26
+ expect(parsed.deviceCode).toBe('ABCD-1234');
27
+ expect(parsed.verificationUrl).toStartWith('https://kortix.com/');
28
+ });
29
+
30
+ test('rejects a verification URL that is not https and not loopback', () => {
31
+ expect(() => parseDeviceAuthChallenge(challenge({ verificationUrl: 'http://evil.example/x' })))
32
+ .toThrow(InvalidDeviceAuthResponseError);
33
+ });
34
+
35
+ test('allows a loopback URL over http for local development', () => {
36
+ const parsed = parseDeviceAuthChallenge(
37
+ challenge({ verificationUrl: 'http://127.0.0.1:3000/tunnel/authorize/ABCD-1234' }),
38
+ );
39
+ expect(parsed.verificationUrl).toContain('127.0.0.1');
40
+ });
41
+
42
+ test('rejects a URL carrying credentials', () => {
43
+ expect(() => parseDeviceAuthChallenge(challenge({ verificationUrl: 'https://user:pw@kortix.com/x' })))
44
+ .toThrow(/unsafe verification URL/);
45
+ });
46
+
47
+ test('rejects a non-http scheme outright', () => {
48
+ expect(() => parseDeviceAuthChallenge(challenge({ verificationUrl: 'javascript:alert(1)' })))
49
+ .toThrow(InvalidDeviceAuthResponseError);
50
+ });
51
+
52
+ test.each([
53
+ ['device code', { deviceCode: 'nope' }],
54
+ ['device secret', { deviceSecret: 'short' }],
55
+ ['expiration in the past', { expiresAt: new Date(Date.now() - 1000).toISOString() }],
56
+ ['expiration too far out', { expiresAt: new Date(Date.now() + 60 * 60_000).toISOString() }],
57
+ ['poll interval too small', { pollIntervalMs: 10 }],
58
+ ['poll interval too large', { pollIntervalMs: 60_000 }],
59
+ ])('rejects a bad %s', (_label, overrides) => {
60
+ expect(() => parseDeviceAuthChallenge(challenge(overrides))).toThrow(InvalidDeviceAuthResponseError);
61
+ });
62
+ });
63
+
64
+ describe('device auth status validation', () => {
65
+ test('returns null while the request is still pending', () => {
66
+ expect(parseDeviceAuthStatus({ status: 'pending' })).toBeNull();
67
+ });
68
+
69
+ test('narrows an approval to supported capabilities and dedupes them', () => {
70
+ const outcome = parseDeviceAuthStatus({
71
+ status: 'approved',
72
+ tunnelId: VALID_TUNNEL_ID,
73
+ token: VALID_TOKEN,
74
+ capabilities: ['shell', 'shell', 'filesystem', 'root-access'],
75
+ });
76
+ expect(outcome).toEqual({
77
+ status: 'approved',
78
+ tunnelId: VALID_TUNNEL_ID,
79
+ token: VALID_TOKEN,
80
+ capabilities: ['shell', 'filesystem'],
81
+ });
82
+ });
83
+
84
+ test('reports an approval that carries no token separately', () => {
85
+ expect(parseDeviceAuthStatus({ status: 'approved' })).toEqual({ status: 'approved-without-token' });
86
+ });
87
+
88
+ test('rejects a malformed tunnel id', () => {
89
+ expect(() => parseDeviceAuthStatus({ status: 'approved', tunnelId: 'nope', token: VALID_TOKEN }))
90
+ .toThrow(/invalid tunnel ID/);
91
+ });
92
+
93
+ test('rejects a malformed setup token', () => {
94
+ expect(() => parseDeviceAuthStatus({ status: 'approved', tunnelId: VALID_TUNNEL_ID, token: 'bad' }))
95
+ .toThrow(/invalid setup token/);
96
+ });
97
+
98
+ test.each([['denied'], ['expired']])('passes %s through', (status) => {
99
+ expect(parseDeviceAuthStatus({ status })).toEqual({ status } as never);
100
+ });
101
+ });
102
+
103
+ describe('polling', () => {
104
+ test('retries transport failures and stops on a decision', async () => {
105
+ let calls = 0;
106
+ const originalFetch = globalThis.fetch;
107
+ globalThis.fetch = (async () => {
108
+ calls++;
109
+ if (calls < 3) throw new Error('network down');
110
+ return new Response(JSON.stringify({ status: 'denied' }), { status: 200 });
111
+ }) as unknown as typeof fetch;
112
+
113
+ try {
114
+ const outcome = await awaitDeviceAuthorization('http://127.0.0.1:1/v1/tunnel', challenge(), {
115
+ sleep: async () => {},
116
+ });
117
+ expect(outcome).toEqual({ status: 'denied' });
118
+ expect(calls).toBe(3);
119
+ } finally {
120
+ globalThis.fetch = originalFetch;
121
+ }
122
+ });
123
+
124
+ test('gives up once the challenge has expired', async () => {
125
+ const outcome = await awaitDeviceAuthorization(
126
+ 'http://127.0.0.1:1/v1/tunnel',
127
+ { ...challenge(), expiresAt: new Date(Date.now() - 1).toISOString() },
128
+ { sleep: async () => {} },
129
+ );
130
+ expect(outcome).toEqual({ status: 'expired' });
131
+ });
132
+ });
@@ -0,0 +1,213 @@
1
+ import { spawn } from 'child_process';
2
+ import { hostname, platform } from 'os';
3
+ import { isTunnelCapability } from '../shared/permissions';
4
+ import type { TunnelCapability } from '../shared/types';
5
+
6
+ /**
7
+ * The device authorization protocol, with no terminal output of its own.
8
+ *
9
+ * Keeping parsing and polling separate from presentation means the validation
10
+ * rules below can be read — and tested — without a CLI around them.
11
+ */
12
+
13
+ const TUNNEL_ID_PATTERN =
14
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
15
+ const SETUP_TOKEN_PATTERN = /^kortix_tnl_[A-Za-z0-9_-]{32,64}$/;
16
+ const DEVICE_CODE_PATTERN = /^[A-Z]{4}-[0-9]{4}$/;
17
+ const DEVICE_SECRET_PATTERN = /^[A-Za-z0-9]{32}$/;
18
+ const MAX_CHALLENGE_LIFETIME_MS = 10 * 60_000;
19
+
20
+ export interface DeviceAuthChallenge {
21
+ deviceCode: string;
22
+ deviceSecret: string;
23
+ verificationUrl: string;
24
+ expiresAt: string;
25
+ pollIntervalMs: number;
26
+ }
27
+
28
+ export type DeviceAuthOutcome =
29
+ | { status: 'approved'; tunnelId: string; token: string; capabilities: TunnelCapability[] }
30
+ | { status: 'denied' }
31
+ | { status: 'expired' }
32
+ | { status: 'approved-without-token' };
33
+
34
+ export class InvalidDeviceAuthResponseError extends Error {
35
+ constructor(message: string) {
36
+ super(message);
37
+ this.name = 'InvalidDeviceAuthResponseError';
38
+ }
39
+ }
40
+
41
+ function invalid(what: string): never {
42
+ throw new InvalidDeviceAuthResponseError(`Authorization server returned an invalid ${what}`);
43
+ }
44
+
45
+ function isJsonRecord(value: unknown): value is Record<string, unknown> {
46
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
47
+ }
48
+
49
+ function normalizeBrowserUrl(value: string): string | null {
50
+ try {
51
+ const url = new URL(value);
52
+ return url.protocol === 'https:' || url.protocol === 'http:' ? url.toString() : null;
53
+ } catch {
54
+ return null;
55
+ }
56
+ }
57
+
58
+ function isLoopback(url: URL): boolean {
59
+ return ['localhost', '127.0.0.1', '[::1]', '::1'].includes(url.hostname);
60
+ }
61
+
62
+ /** Only the approval URL is opened, and only after it passes this check. */
63
+ function assertSafeVerificationUrl(value: string): string {
64
+ if (value.length > 2048) invalid('verification URL');
65
+ const browserUrl = normalizeBrowserUrl(value);
66
+ if (!browserUrl) invalid('verification URL');
67
+
68
+ const url = new URL(browserUrl);
69
+ if (url.username || url.password || (url.protocol !== 'https:' && !isLoopback(url))) {
70
+ throw new InvalidDeviceAuthResponseError(
71
+ 'Authorization server returned an unsafe verification URL',
72
+ );
73
+ }
74
+ return browserUrl;
75
+ }
76
+
77
+ export function parseDeviceAuthChallenge(value: unknown): DeviceAuthChallenge {
78
+ if (!isJsonRecord(value)) invalid('challenge');
79
+ const { deviceCode, deviceSecret, verificationUrl, expiresAt, pollIntervalMs } = value;
80
+
81
+ if (typeof deviceCode !== 'string' || !DEVICE_CODE_PATTERN.test(deviceCode)) invalid('device code');
82
+ if (typeof deviceSecret !== 'string' || !DEVICE_SECRET_PATTERN.test(deviceSecret)) {
83
+ invalid('device secret');
84
+ }
85
+ if (typeof verificationUrl !== 'string') invalid('verification URL');
86
+ if (typeof expiresAt !== 'string') invalid('expiration');
87
+
88
+ const expiresAtMs = Date.parse(expiresAt);
89
+ const now = Date.now();
90
+ if (!Number.isFinite(expiresAtMs) || expiresAtMs <= now || expiresAtMs > now + MAX_CHALLENGE_LIFETIME_MS) {
91
+ invalid('expiration');
92
+ }
93
+ if (!Number.isSafeInteger(pollIntervalMs) || (pollIntervalMs as number) < 250 || (pollIntervalMs as number) > 10_000) {
94
+ invalid('poll interval');
95
+ }
96
+
97
+ return {
98
+ deviceCode,
99
+ deviceSecret,
100
+ verificationUrl: assertSafeVerificationUrl(verificationUrl),
101
+ expiresAt,
102
+ pollIntervalMs: pollIntervalMs as number,
103
+ };
104
+ }
105
+
106
+ function parseApprovedCredentials(value: Record<string, unknown>): { tunnelId: string; token: string } {
107
+ const { tunnelId, token } = value;
108
+ if (typeof tunnelId !== 'string' || !TUNNEL_ID_PATTERN.test(tunnelId)) invalid('tunnel ID');
109
+ if (typeof token !== 'string' || !SETUP_TOKEN_PATTERN.test(token)) invalid('setup token');
110
+ return { tunnelId, token };
111
+ }
112
+
113
+ /** The browser-approved set, deduplicated and narrowed to capabilities we support. */
114
+ function parseApprovedCapabilities(value: unknown): TunnelCapability[] {
115
+ if (!Array.isArray(value)) return [];
116
+ return [...new Set(value)].filter((item): item is TunnelCapability => isTunnelCapability(item));
117
+ }
118
+
119
+ export function parseDeviceAuthStatus(value: unknown): DeviceAuthOutcome | null {
120
+ if (!isJsonRecord(value) || typeof value.status !== 'string') invalid('status response');
121
+
122
+ switch (value.status) {
123
+ case 'approved': {
124
+ if (!value.tunnelId || !value.token) return { status: 'approved-without-token' };
125
+ return {
126
+ status: 'approved',
127
+ ...parseApprovedCredentials(value),
128
+ capabilities: parseApprovedCapabilities(value.capabilities),
129
+ };
130
+ }
131
+ case 'denied':
132
+ return { status: 'denied' };
133
+ case 'expired':
134
+ return { status: 'expired' };
135
+ default:
136
+ // Still pending: the caller keeps polling.
137
+ return null;
138
+ }
139
+ }
140
+
141
+ export async function requestDeviceAuthorization(apiUrl: string): Promise<DeviceAuthChallenge> {
142
+ const response = await fetch(`${apiUrl}/device-auth`, {
143
+ method: 'POST',
144
+ headers: { 'Content-Type': 'application/json' },
145
+ body: JSON.stringify({ machineHostname: hostname() }),
146
+ });
147
+ if (!response.ok) {
148
+ const body = await response.text().catch(() => '');
149
+ throw new InvalidDeviceAuthResponseError(
150
+ `Failed to create device auth request: ${response.status} ${body.slice(0, 200)}`,
151
+ );
152
+ }
153
+ return parseDeviceAuthChallenge(await response.json());
154
+ }
155
+
156
+ /**
157
+ * Polls until the request is decided or the challenge expires.
158
+ *
159
+ * `onWaiting` receives the seconds left so the caller owns every rendered byte.
160
+ * Transport errors are swallowed and retried; only a malformed response, which
161
+ * means the server is not speaking the protocol, aborts.
162
+ */
163
+ export async function awaitDeviceAuthorization(
164
+ apiUrl: string,
165
+ challenge: DeviceAuthChallenge,
166
+ options: { onWaiting?: (secondsRemaining: number) => void; sleep?: (ms: number) => Promise<void> } = {},
167
+ ): Promise<DeviceAuthOutcome> {
168
+ const wait = options.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)));
169
+ const expiresAtMs = Date.parse(challenge.expiresAt);
170
+
171
+ for (;;) {
172
+ const secondsRemaining = Math.max(0, Math.floor((expiresAtMs - Date.now()) / 1000));
173
+ if (secondsRemaining <= 0) return { status: 'expired' };
174
+ options.onWaiting?.(secondsRemaining);
175
+
176
+ let payload: unknown;
177
+ try {
178
+ const response = await fetch(`${apiUrl}/device-auth/${challenge.deviceCode}/status`, {
179
+ headers: { Authorization: `Bearer ${challenge.deviceSecret}` },
180
+ });
181
+ if (!response.ok) {
182
+ await wait(challenge.pollIntervalMs);
183
+ continue;
184
+ }
185
+ payload = await response.json();
186
+ } catch {
187
+ await wait(challenge.pollIntervalMs);
188
+ continue;
189
+ }
190
+
191
+ const outcome = parseDeviceAuthStatus(payload);
192
+ if (outcome) return outcome;
193
+ await wait(challenge.pollIntervalMs);
194
+ }
195
+ }
196
+
197
+ export function openBrowser(url: string): void {
198
+ if (process.env.KORTIX_AGENT_TUNNEL_NO_BROWSER === '1') return;
199
+ const safeUrl = normalizeBrowserUrl(url);
200
+ if (!safeUrl) return;
201
+
202
+ const opener: Record<string, [string, string[]]> = {
203
+ darwin: ['open', [safeUrl]],
204
+ win32: ['rundll32.exe', ['url.dll,FileProtocolHandler', safeUrl]],
205
+ };
206
+ const [command, args] = opener[platform()] ?? ['xdg-open', [safeUrl]];
207
+
208
+ try {
209
+ spawn(command, args, { detached: true, stdio: 'ignore' }).unref();
210
+ } catch {
211
+ // An unopenable browser is not fatal: the URL is printed too.
212
+ }
213
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Collapses runs of identical lines.
3
+ *
4
+ * A restart loop writes the same lines thousands of times. Printing them
5
+ * verbatim buries the one detail that matters — how many times it happened.
6
+ */
7
+ export function collapseRepeatedLines(lines: string[]): string[] {
8
+ const collapsed: string[] = [];
9
+ let index = 0;
10
+ while (index < lines.length) {
11
+ let repeats = 1;
12
+ while (index + repeats < lines.length && lines[index + repeats] === lines[index]) repeats++;
13
+ collapsed.push(repeats > 1 ? `${lines[index]} (x${repeats})` : lines[index]);
14
+ index += repeats;
15
+ }
16
+ return collapsed;
17
+ }
18
+
19
+ /** Noise the supervisor's login shell writes before the agent ever starts. */
20
+ export function isShellStartupNoise(line: string): boolean {
21
+ return /\/\.(profile|bash_profile|zprofile|zshrc|bashrc)\b.*:.*(No such file or directory|command not found)/.test(
22
+ line,
23
+ );
24
+ }
@@ -0,0 +1,37 @@
1
+ import { createInterface } from 'readline/promises';
2
+ import { c } from './terminal';
3
+
4
+ export function isInteractiveTerminal(): boolean {
5
+ return process.stdin.isTTY === true && process.stdout.isTTY === true;
6
+ }
7
+
8
+ export function isTruthyFlag(value: string | undefined): boolean {
9
+ return value === 'true' || value === '1' || value === 'yes';
10
+ }
11
+
12
+ /** True when any of `names` is set to a truthy value. */
13
+ export function anyFlag(flags: Record<string, string>, names: readonly string[]): boolean {
14
+ return names.some((name) => isTruthyFlag(flags[name]));
15
+ }
16
+
17
+ export async function promptYesNo(question: string, defaultValue: boolean): Promise<boolean> {
18
+ const suffix = defaultValue ? ' [Y/n] ' : ' [y/N] ';
19
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
20
+ try {
21
+ for (;;) {
22
+ const answer = (await rl.question(`${question}${suffix}`)).trim().toLowerCase();
23
+ if (!answer) return defaultValue;
24
+ if (['y', 'yes'].includes(answer)) return true;
25
+ if (['n', 'no'].includes(answer)) return false;
26
+ console.log(` ${c.yellow}!${c.reset} Please answer yes or no.`);
27
+ }
28
+ } catch (error) {
29
+ if (error instanceof Error && error.name === 'AbortError') {
30
+ process.stdout.write('\n');
31
+ process.exit(130);
32
+ }
33
+ throw error;
34
+ } finally {
35
+ rl.close();
36
+ }
37
+ }
@@ -0,0 +1,69 @@
1
+ import {
2
+ type ServiceStatus,
3
+ getServiceStatus,
4
+ installService,
5
+ restartService,
6
+ startService,
7
+ stopService,
8
+ uninstallService,
9
+ } from './service';
10
+ import { c, blankLine, field, glyph } from './terminal';
11
+
12
+ /**
13
+ * Exactly one process may hold a tunnel: the relay closes the older socket with
14
+ * 4004 and the displaced agent stops for good. Anything in the foreground that
15
+ * needs the credential must therefore take it from the background service
16
+ * first, and hand it back if it did not end up using it.
17
+ */
18
+ export interface TunnelLease {
19
+ /** True when this lease actually stopped a running service. */
20
+ readonly serviceWasActive: boolean;
21
+ /** Restarts the service if this lease stopped it. Safe to call more than once. */
22
+ resumeService(): void;
23
+ }
24
+
25
+ export function acquireTunnelLease(): TunnelLease {
26
+ const serviceWasActive = getServiceStatus().active === true;
27
+ if (serviceWasActive) stopService();
28
+
29
+ let resumed = false;
30
+ return {
31
+ serviceWasActive,
32
+ resumeService() {
33
+ if (!serviceWasActive || resumed) return;
34
+ resumed = true;
35
+ startService();
36
+ },
37
+ };
38
+ }
39
+
40
+ /** Service verbs that map one-to-one onto a supervisor operation. */
41
+ export const SERVICE_ACTIONS = {
42
+ start: { run: startService, label: 'started' },
43
+ stop: { run: stopService, label: 'stopped' },
44
+ restart: { run: restartService, label: 'restarted' },
45
+ uninstall: { run: uninstallService, label: 'removed' },
46
+ install: { run: installService, label: 'installed' },
47
+ } as const;
48
+
49
+ export type ServiceAction = keyof typeof SERVICE_ACTIONS;
50
+
51
+ export function describeService(status: ServiceStatus): string {
52
+ if (!status.installed) return `${glyph.off} not installed`;
53
+ if (status.active) return `${glyph.on} running ${c.dim}· starts at login${c.reset}`;
54
+ return `${c.yellow}○${c.reset} installed ${c.dim}· stopped${c.reset}`;
55
+ }
56
+
57
+ /**
58
+ * Renders the result of a service verb.
59
+ *
60
+ * These used to be five copy-pasted `console.log(JSON.stringify(...))` wrappers
61
+ * that printed a different shape from `status`. One renderer, one shape.
62
+ */
63
+ export function renderServiceAction(action: ServiceAction, status: ServiceStatus): void {
64
+ blankLine();
65
+ console.log(` ${glyph.on} ${c.bold}Background service ${SERVICE_ACTIONS[action].label}${c.reset}`);
66
+ if (status.path) field('', `${c.dim}${status.path}${c.reset}`);
67
+ if (status.detail) field('', `${c.gray}${status.detail}${c.reset}`);
68
+ blankLine();
69
+ }