@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.
- package/dist/agent-cli.js +1191 -887
- package/package.json +1 -1
- package/src/agent/agent.ts +53 -29
- package/src/agent/banner.ts +82 -0
- package/src/agent/cli-help.test.ts +79 -15
- package/src/agent/cli-reauth.test.ts +205 -0
- package/src/agent/cli.ts +399 -661
- package/src/agent/config.ts +19 -0
- package/src/agent/credential-probe.ts +95 -0
- package/src/agent/credential-store.ts +74 -0
- package/src/agent/device-auth.test.ts +132 -0
- package/src/agent/device-auth.ts +213 -0
- package/src/agent/log-format.ts +24 -0
- package/src/agent/prompts.ts +37 -0
- package/src/agent/service-control.ts +69 -0
- package/src/agent/service-drivers.ts +299 -0
- package/src/agent/service-lifecycle.test.ts +248 -0
- package/src/agent/service-paths.ts +80 -0
- package/src/agent/service-quoting.ts +16 -0
- package/src/agent/service.test.ts +52 -2
- package/src/agent/service.ts +132 -356
- package/src/agent/terminal.ts +53 -0
- package/src/agent/version.ts +52 -0
package/src/agent/cli.ts
CHANGED
|
@@ -1,252 +1,69 @@
|
|
|
1
1
|
import '../node-ws-polyfill';
|
|
2
|
-
import {
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync } from 'fs';
|
|
3
|
+
import { hostname } from 'os';
|
|
4
|
+
import { join } from 'path';
|
|
5
|
+
|
|
3
6
|
import { TunnelAgent } from './agent';
|
|
7
|
+
import { printStartupBanner } from './banner';
|
|
4
8
|
import { createEnabledCapabilityRegistry } from './capabilities/enabled-registry';
|
|
9
|
+
import { loadConfig, type TunnelConfig } from './config';
|
|
10
|
+
import { CONFIG_FILE, clearSavedCredentials, saveCredentials } from './credential-store';
|
|
11
|
+
import { probeCredentials } from './credential-probe';
|
|
12
|
+
import {
|
|
13
|
+
InvalidDeviceAuthResponseError,
|
|
14
|
+
awaitDeviceAuthorization,
|
|
15
|
+
openBrowser,
|
|
16
|
+
requestDeviceAuthorization,
|
|
17
|
+
} from './device-auth';
|
|
18
|
+
import { collapseRepeatedLines, isShellStartupNoise } from './log-format';
|
|
19
|
+
import { anyFlag, isInteractiveTerminal, isTruthyFlag, promptYesNo } from './prompts';
|
|
5
20
|
import {
|
|
6
21
|
DEFAULT_INSTALL_BACKGROUND_SERVICE,
|
|
22
|
+
TERMINAL_SERVICE_EXIT_CODE,
|
|
7
23
|
getServicePaths,
|
|
8
24
|
getServiceStatus,
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
startService,
|
|
12
|
-
stopService,
|
|
13
|
-
uninstallService,
|
|
25
|
+
rotateServiceLogs,
|
|
26
|
+
serviceLogFiles,
|
|
14
27
|
} from './service';
|
|
15
|
-
import {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
gray: '\x1b[90m',
|
|
35
|
-
bgCyan: '\x1b[46m',
|
|
36
|
-
bgBlue: '\x1b[44m',
|
|
37
|
-
};
|
|
38
|
-
|
|
39
|
-
function parseArgs(argv: string[]): { command: string; flags: Record<string, string> } {
|
|
40
|
-
const command = argv[2] || 'help';
|
|
41
|
-
const flags: Record<string, string> = {};
|
|
42
|
-
|
|
28
|
+
import {
|
|
29
|
+
SERVICE_ACTIONS,
|
|
30
|
+
type ServiceAction,
|
|
31
|
+
acquireTunnelLease,
|
|
32
|
+
describeService,
|
|
33
|
+
renderServiceAction,
|
|
34
|
+
} from './service-control';
|
|
35
|
+
import { blankLine, c, clearScreen, field, glyph, stripAnsi } from './terminal';
|
|
36
|
+
import { agentTunnelVersion } from './version';
|
|
37
|
+
|
|
38
|
+
const ALL_CAPABILITIES = ['filesystem', 'shell', 'desktop'] as const;
|
|
39
|
+
const BACKGROUND_FLAGS = ['daemon', 'service', 'background', 'always-online'] as const;
|
|
40
|
+
const FOREGROUND_FLAGS = ['foreground', 'no-daemon', 'no-service', 'no-background'] as const;
|
|
41
|
+
const DEFAULT_LOG_LINES = 60;
|
|
42
|
+
|
|
43
|
+
type Flags = Record<string, string>;
|
|
44
|
+
|
|
45
|
+
function parseArgs(argv: string[]): { command: string; flags: Flags } {
|
|
46
|
+
const flags: Flags = {};
|
|
43
47
|
for (let i = 3; i < argv.length; i++) {
|
|
44
48
|
const arg = argv[i];
|
|
45
|
-
if (arg.startsWith('--'))
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
flags[key] = value;
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
return { command, flags };
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
function clearScreen(): void {
|
|
56
|
-
process.stdout.write('\x1b[2J\x1b[3J\x1b[H');
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
const sleep = (ms: number) => new Promise<void>(r => setTimeout(r, ms));
|
|
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';
|
|
49
|
+
if (!arg.startsWith('--')) continue;
|
|
50
|
+
const next = argv[i + 1];
|
|
51
|
+
flags[arg.slice(2)] = next && !next.startsWith('--') ? argv[++i] : 'true';
|
|
86
52
|
}
|
|
53
|
+
return { command: argv[2] || 'help', flags };
|
|
87
54
|
}
|
|
88
55
|
|
|
89
|
-
function
|
|
90
|
-
|
|
56
|
+
function fail(message: string): never {
|
|
57
|
+
console.error(` ${glyph.bad} ${message}`);
|
|
58
|
+
process.exit(1);
|
|
91
59
|
}
|
|
92
60
|
|
|
93
|
-
function
|
|
94
|
-
|
|
95
|
-
)
|
|
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 };
|
|
61
|
+
function shortenHomePath(path: string): string {
|
|
62
|
+
const home = process.env.HOME ?? '';
|
|
63
|
+
return home && path.startsWith(home) ? `~${path.slice(home.length)}` : path;
|
|
108
64
|
}
|
|
109
65
|
|
|
110
|
-
|
|
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
|
-
|
|
183
|
-
async function printStartup(config: { tunnelId: string; apiUrl: string }, capabilities: string[], version: string): Promise<void> {
|
|
184
|
-
const machine = hostname();
|
|
185
|
-
const plat = `${platform()} ${arch()}`;
|
|
186
|
-
|
|
187
|
-
const truncate = (s: string, max: number) => s.length > max ? s.slice(0, max) + '…' : s;
|
|
188
|
-
const tunnelDisplay = truncate(config.tunnelId, 40);
|
|
189
|
-
const apiDisplay = truncate(config.apiUrl, 40);
|
|
190
|
-
const machineDisplay = truncate(machine, 28);
|
|
191
|
-
|
|
192
|
-
// ── ASCII art ───────────────────────────────────────────
|
|
193
|
-
console.log('');
|
|
194
|
-
console.log(` ${c.cyan}▄▀█ █▀▀ █▀▀ █▄ █ ▀█▀${c.reset} ${c.cyan}▀█▀ █ █ █▄ █ █▄ █ █▀▀ █ ${c.reset}`);
|
|
195
|
-
console.log(` ${c.cyan}█▀█ █▄█ ██▄ █ ▀█ █${c.reset} ${c.cyan} █ █▄█ █ ▀█ █ ▀█ ██▄ █▄▄${c.reset}`);
|
|
196
|
-
console.log('');
|
|
197
|
-
|
|
198
|
-
// ── Tunnel connection animation ─────────────────────────
|
|
199
|
-
const barW = 50;
|
|
200
|
-
const frames = 14;
|
|
201
|
-
|
|
202
|
-
for (let i = 0; i <= frames; i++) {
|
|
203
|
-
const filled = Math.round((i / frames) * barW);
|
|
204
|
-
const empty = barW - filled;
|
|
205
|
-
process.stdout.write(
|
|
206
|
-
`\r ${c.cyan}◇${c.reset} ${c.cyan}${'═'.repeat(filled)}${c.reset}${c.gray}${'─'.repeat(empty)}${c.reset} `,
|
|
207
|
-
);
|
|
208
|
-
await sleep(20);
|
|
209
|
-
}
|
|
210
|
-
process.stdout.write(`\r ${c.cyan}◇ ${'═'.repeat(barW)} ◆${c.reset} \n`);
|
|
211
|
-
await sleep(120);
|
|
212
|
-
|
|
213
|
-
// ── Info box ────────────────────────────────────────────
|
|
214
|
-
const W = 60;
|
|
215
|
-
const vLen = (s: string) => s.replace(/\x1b\[[0-9;]*m/g, '').length;
|
|
216
|
-
|
|
217
|
-
const row = (content: string) => {
|
|
218
|
-
const pad = Math.max(0, W - vLen(content));
|
|
219
|
-
console.log(` ${c.gray}│${c.reset}${content}${' '.repeat(pad)}${c.gray}│${c.reset}`);
|
|
220
|
-
};
|
|
221
|
-
|
|
222
|
-
const blank = () => console.log(` ${c.gray}│${c.reset}${' '.repeat(W)}${c.gray}│${c.reset}`);
|
|
223
|
-
|
|
224
|
-
const titleL = ` ${c.cyan}◆${c.reset} ${c.bold}${c.white}Agent Tunnel${c.reset}`;
|
|
225
|
-
const titleR = `${c.dim}v${version}${c.reset} `;
|
|
226
|
-
const titleLLen = 18;
|
|
227
|
-
const titleRLen = 1 + version.length + 3;
|
|
228
|
-
const titlePad = Math.max(1, W - titleLLen - titleRLen);
|
|
229
|
-
|
|
230
|
-
const capStr = capabilities
|
|
231
|
-
.map(name => `${c.green}●${c.reset} ${c.white}${name}${c.reset}`)
|
|
232
|
-
.join(' ');
|
|
233
|
-
|
|
234
|
-
const brand = 'created by kortix';
|
|
235
|
-
const brandFill = W - brand.length - 3;
|
|
236
|
-
|
|
237
|
-
console.log('');
|
|
238
|
-
console.log(` ${c.gray}╭${'─'.repeat(W)}╮${c.reset}`);
|
|
239
|
-
blank();
|
|
240
|
-
row(`${titleL}${' '.repeat(titlePad)}${titleR}`);
|
|
241
|
-
row(` ${c.dim}Bridge between AI agents & local machines${c.reset}`);
|
|
242
|
-
blank();
|
|
243
|
-
row(` ${c.dim}tunnel${c.reset} ${c.white}${tunnelDisplay}${c.reset}`);
|
|
244
|
-
row(` ${c.dim}relay${c.reset} ${c.white}${apiDisplay}${c.reset}`);
|
|
245
|
-
row(` ${c.dim}machine${c.reset} ${c.white}${machineDisplay}${c.reset} ${c.dim}(${plat})${c.reset}`);
|
|
246
|
-
blank();
|
|
247
|
-
console.log(` ${c.gray}╰${'─'.repeat(brandFill)} ${c.dim}created by ${c.cyan}kortix${c.reset} ${c.gray}─╯${c.reset}`);
|
|
248
|
-
console.log('');
|
|
249
|
-
}
|
|
66
|
+
// ── running the agent ────────────────────────────────────────────────────────
|
|
250
67
|
|
|
251
68
|
function startAgent(config: TunnelConfig, options: { service?: boolean } = {}): void {
|
|
252
69
|
const registry = createEnabledCapabilityRegistry(config);
|
|
@@ -256,14 +73,27 @@ function startAgent(config: TunnelConfig, options: { service?: boolean } = {}):
|
|
|
256
73
|
);
|
|
257
74
|
}
|
|
258
75
|
|
|
259
|
-
if (
|
|
260
|
-
clearScreen();
|
|
261
|
-
printStartup(config, registry.getCapabilityNames(), '0.1.2');
|
|
262
|
-
} else {
|
|
76
|
+
if (options.service) {
|
|
263
77
|
console.log(`[agent-tunnel] service starting: ${config.tunnelId} -> ${config.apiUrl}`);
|
|
78
|
+
} else {
|
|
79
|
+
clearScreen();
|
|
80
|
+
void printStartupBanner({
|
|
81
|
+
tunnelId: config.tunnelId,
|
|
82
|
+
apiUrl: config.apiUrl,
|
|
83
|
+
capabilities: registry.getCapabilityNames(),
|
|
84
|
+
version: agentTunnelVersion(),
|
|
85
|
+
});
|
|
264
86
|
}
|
|
265
87
|
|
|
266
|
-
const agent = new TunnelAgent(config, registry
|
|
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);
|
|
95
|
+
},
|
|
96
|
+
});
|
|
267
97
|
agent.connect();
|
|
268
98
|
|
|
269
99
|
const shutdown = () => {
|
|
@@ -271,523 +101,431 @@ function startAgent(config: TunnelConfig, options: { service?: boolean } = {}):
|
|
|
271
101
|
agent.disconnect();
|
|
272
102
|
process.exit(0);
|
|
273
103
|
};
|
|
274
|
-
|
|
275
104
|
process.on('SIGTERM', shutdown);
|
|
276
105
|
process.on('SIGINT', shutdown);
|
|
277
106
|
}
|
|
278
107
|
|
|
279
|
-
|
|
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
|
-
}
|
|
108
|
+
// ── pairing ──────────────────────────────────────────────────────────────────
|
|
287
109
|
|
|
288
|
-
function
|
|
289
|
-
if (
|
|
290
|
-
|
|
291
|
-
if (!
|
|
292
|
-
try {
|
|
293
|
-
const plat = platform();
|
|
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();
|
|
308
|
-
} catch {}
|
|
309
|
-
}
|
|
110
|
+
async function chooseBackgroundMode(flags: Flags): Promise<boolean> {
|
|
111
|
+
if (anyFlag(flags, BACKGROUND_FLAGS)) return true;
|
|
112
|
+
if (anyFlag(flags, FOREGROUND_FLAGS)) return false;
|
|
113
|
+
if (!isInteractiveTerminal()) return false;
|
|
310
114
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
function isTruthyFlag(value: string | undefined): boolean {
|
|
319
|
-
return value === 'true' || value === '1' || value === 'yes';
|
|
320
|
-
}
|
|
115
|
+
blankLine();
|
|
116
|
+
console.log(` ${glyph.warn} ${c.bold}Security note${c.reset}`);
|
|
117
|
+
console.log(` ${c.dim}Background mode starts at login, continues after this terminal closes, and restarts after failures.${c.reset}`);
|
|
118
|
+
console.log(` ${c.dim}The computer must remain powered on, awake, and connected to the internet.${c.reset}`);
|
|
119
|
+
blankLine();
|
|
321
120
|
|
|
322
|
-
|
|
323
|
-
return process.stdin.isTTY === true && process.stdout.isTTY === true;
|
|
121
|
+
return promptYesNo(' Install the background service now?', DEFAULT_INSTALL_BACKGROUND_SERVICE);
|
|
324
122
|
}
|
|
325
123
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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();
|
|
124
|
+
/** Starts the agent the way the caller asked for, and returns. */
|
|
125
|
+
async function launch(config: TunnelConfig, flags: Flags, lease?: { serviceWasActive: boolean }): Promise<void> {
|
|
126
|
+
if (await chooseBackgroundMode(flags)) {
|
|
127
|
+
saveCredentials(config.tunnelId, config.token, config.apiUrl);
|
|
128
|
+
renderServiceAction('install', SERVICE_ACTIONS.install.run());
|
|
129
|
+
return;
|
|
345
130
|
}
|
|
346
|
-
}
|
|
347
131
|
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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 };
|
|
132
|
+
if (lease?.serviceWasActive) {
|
|
133
|
+
console.log(` ${c.dim}Background service stays paused while this terminal holds the tunnel.${c.reset}`);
|
|
134
|
+
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}`);
|
|
365
135
|
}
|
|
366
|
-
|
|
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 };
|
|
136
|
+
startAgent(config);
|
|
381
137
|
}
|
|
382
138
|
|
|
383
|
-
function
|
|
384
|
-
|
|
385
|
-
console.log(
|
|
386
|
-
|
|
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
|
-
}
|
|
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();
|
|
392
143
|
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
if (existsSync(CONFIG_FILE)) {
|
|
403
|
-
try { existing = JSON.parse(readFileSync(CONFIG_FILE, 'utf-8')); } catch {}
|
|
144
|
+
let challenge;
|
|
145
|
+
try {
|
|
146
|
+
challenge = await requestDeviceAuthorization(apiUrl);
|
|
147
|
+
} catch (error) {
|
|
148
|
+
fail(
|
|
149
|
+
error instanceof InvalidDeviceAuthResponseError
|
|
150
|
+
? error.message
|
|
151
|
+
: 'Failed to start device authorization',
|
|
152
|
+
);
|
|
404
153
|
}
|
|
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 {}
|
|
420
|
-
}
|
|
421
154
|
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
console.log(` ${c.
|
|
425
|
-
console.log(
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
let deviceCode: string;
|
|
429
|
-
let deviceSecret: string;
|
|
430
|
-
let verificationUrl: string;
|
|
431
|
-
let expiresAt: string;
|
|
432
|
-
let pollIntervalMs: number;
|
|
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);
|
|
433
161
|
|
|
162
|
+
let outcome;
|
|
434
163
|
try {
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
164
|
+
outcome = await awaitDeviceAuthorization(apiUrl, challenge, {
|
|
165
|
+
onWaiting: (secondsRemaining) => {
|
|
166
|
+
const minutes = Math.floor(secondsRemaining / 60);
|
|
167
|
+
const seconds = String(secondsRemaining % 60).padStart(2, '0');
|
|
168
|
+
process.stdout.write(`\r ${c.dim}Waiting for approval... ${c.white}${minutes}:${seconds}${c.reset} `);
|
|
169
|
+
},
|
|
439
170
|
});
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
171
|
+
} catch (error) {
|
|
172
|
+
process.stdout.write(`\r${' '.repeat(60)}\r`);
|
|
173
|
+
fail(error instanceof Error ? error.message : 'Device authorization failed');
|
|
174
|
+
}
|
|
175
|
+
process.stdout.write(`\r${' '.repeat(60)}\r`);
|
|
176
|
+
|
|
177
|
+
if (outcome.status === 'denied') fail('Authorization denied.');
|
|
178
|
+
if (outcome.status === 'expired') fail('Authorization expired. Please try again.');
|
|
179
|
+
if (outcome.status === 'approved-without-token') {
|
|
180
|
+
fail('Authorization was approved, but the setup token was not available. Run connect again.');
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// The approved set is a ceiling only re-pairing can widen. Saving an empty one
|
|
184
|
+
// yields a tunnel that connects, reports success, and can do nothing.
|
|
185
|
+
if (outcome.capabilities.length === 0) {
|
|
186
|
+
console.log(` ${glyph.bad} ${c.bold}No capabilities were approved${c.reset}`);
|
|
187
|
+
blankLine();
|
|
188
|
+
console.log(` ${c.dim}A tunnel with no capabilities connects but cannot act, and the${c.reset}`);
|
|
189
|
+
console.log(` ${c.dim}approved set can only be changed by pairing again. Nothing was saved.${c.reset}`);
|
|
190
|
+
blankLine();
|
|
191
|
+
console.log(` ${c.dim}Run connect again and approve at least one of${c.reset} ${c.white}${ALL_CAPABILITIES.join(', ')}${c.reset}${c.dim}.${c.reset}`);
|
|
192
|
+
blankLine();
|
|
454
193
|
process.exit(1);
|
|
455
|
-
return;
|
|
456
194
|
}
|
|
457
195
|
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
console.log(
|
|
461
|
-
console.log(` ${c.dim}
|
|
462
|
-
|
|
463
|
-
console.log('');
|
|
464
|
-
|
|
465
|
-
openBrowser(verificationUrl);
|
|
466
|
-
|
|
467
|
-
// Step 3: Poll for approval
|
|
468
|
-
const expiresAtMs = new Date(expiresAt).getTime();
|
|
196
|
+
console.log(` ${glyph.on} ${c.bold}Authorized${c.reset}`);
|
|
197
|
+
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();
|
|
469
201
|
|
|
470
|
-
|
|
471
|
-
const remaining = Math.max(0, Math.floor((expiresAtMs - Date.now()) / 1000));
|
|
472
|
-
if (remaining <= 0) {
|
|
473
|
-
console.log(`\n ${c.red}✗${c.reset} Authorization expired. Please try again.`);
|
|
474
|
-
process.exit(1);
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
const min = Math.floor(remaining / 60);
|
|
478
|
-
const sec = remaining % 60;
|
|
479
|
-
process.stdout.write(`\r ${c.dim}Waiting for approval... ${c.white}${min}:${sec.toString().padStart(2, '0')}${c.reset} `);
|
|
480
|
-
|
|
481
|
-
try {
|
|
482
|
-
const res = await fetch(`${config.apiUrl}/device-auth/${deviceCode}/status`, {
|
|
483
|
-
headers: { Authorization: `Bearer ${deviceSecret}` },
|
|
484
|
-
});
|
|
485
|
-
if (res.ok) {
|
|
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
|
-
}
|
|
493
|
-
|
|
494
|
-
if (data.status === 'approved' && data.tunnelId && data.token) {
|
|
495
|
-
const credentials = parseApprovedDeviceCredentials(data);
|
|
496
|
-
process.stdout.write('\r' + ' '.repeat(60) + '\r');
|
|
497
|
-
console.log(` ${c.green}●${c.reset} ${c.bold}Authorized!${c.reset}`);
|
|
498
|
-
console.log('');
|
|
499
|
-
|
|
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
|
-
);
|
|
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
|
-
);
|
|
520
|
-
console.log('');
|
|
521
|
-
|
|
522
|
-
const mode = await chooseConnectMode(flags);
|
|
523
|
-
if (mode.background) {
|
|
524
|
-
installBackgroundService();
|
|
525
|
-
return;
|
|
526
|
-
}
|
|
527
|
-
|
|
528
|
-
// Connect with received credentials
|
|
529
|
-
const fullConfig = loadConfig({
|
|
530
|
-
token: credentials.token,
|
|
531
|
-
tunnelId: credentials.tunnelId,
|
|
532
|
-
apiUrl: config.apiUrl,
|
|
533
|
-
});
|
|
534
|
-
startAgent(fullConfig);
|
|
535
|
-
return;
|
|
536
|
-
}
|
|
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
|
-
|
|
545
|
-
if (data.status === 'denied') {
|
|
546
|
-
process.stdout.write('\r' + ' '.repeat(60) + '\r');
|
|
547
|
-
console.log(` ${c.red}✗${c.reset} Authorization denied.`);
|
|
548
|
-
process.exit(1);
|
|
549
|
-
}
|
|
550
|
-
|
|
551
|
-
if (data.status === 'expired') {
|
|
552
|
-
process.stdout.write('\r' + ' '.repeat(60) + '\r');
|
|
553
|
-
console.log(` ${c.red}✗${c.reset} Authorization expired. Please try again.`);
|
|
554
|
-
process.exit(1);
|
|
555
|
-
}
|
|
556
|
-
}
|
|
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
|
-
}
|
|
564
|
-
|
|
565
|
-
await sleep(pollIntervalMs);
|
|
566
|
-
}
|
|
202
|
+
await launch(loadConfig({ apiUrl }), flags);
|
|
567
203
|
}
|
|
568
204
|
|
|
569
|
-
async function commandConnect(flags:
|
|
205
|
+
async function commandConnect(flags: Flags): Promise<void> {
|
|
570
206
|
const config = loadConfig({
|
|
571
207
|
token: flags.token,
|
|
572
208
|
tunnelId: flags['tunnel-id'],
|
|
573
209
|
apiUrl: flags['api-url'],
|
|
574
210
|
});
|
|
211
|
+
// Credentials typed on the command line, as opposed to ones loadConfig()
|
|
212
|
+
// restored from disk. Only the latter may be discarded and re-paired.
|
|
213
|
+
const explicitCredentials = Boolean(flags.token && flags['tunnel-id']);
|
|
575
214
|
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
return;
|
|
583
|
-
}
|
|
584
|
-
startAgent(config);
|
|
215
|
+
if (Boolean(config.token) !== Boolean(config.tunnelId)) {
|
|
216
|
+
fail('Provide both --token and --tunnel-id, or neither (for device auth)');
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (!config.token) {
|
|
220
|
+
await pairThisMachine(config.apiUrl, flags);
|
|
585
221
|
return;
|
|
586
222
|
}
|
|
587
223
|
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
await
|
|
224
|
+
if (isTruthyFlag(flags.reauth) && !explicitCredentials) {
|
|
225
|
+
clearSavedCredentials();
|
|
226
|
+
await pairThisMachine(config.apiUrl, flags);
|
|
591
227
|
return;
|
|
592
228
|
}
|
|
593
229
|
|
|
594
|
-
//
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
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}`);
|
|
598
235
|
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
})
|
|
236
|
+
let probe;
|
|
237
|
+
try {
|
|
238
|
+
probe = await probeCredentials(config, {
|
|
239
|
+
capabilities: createEnabledCapabilityRegistry(config).getCapabilityNames(),
|
|
240
|
+
});
|
|
241
|
+
} catch (error) {
|
|
242
|
+
lease.resumeService();
|
|
243
|
+
throw error;
|
|
244
|
+
}
|
|
605
245
|
|
|
606
|
-
if (
|
|
607
|
-
|
|
608
|
-
|
|
246
|
+
if (probe === 'unreachable') {
|
|
247
|
+
// The credential is unproven, so restore exactly what was running before.
|
|
248
|
+
lease.resumeService();
|
|
249
|
+
fail(`Cannot reach the relay at ${config.apiUrl}. Check your network, then run connect again.`);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (probe === 'rejected') {
|
|
253
|
+
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}`);
|
|
255
|
+
clearSavedCredentials();
|
|
256
|
+
await pairThisMachine(config.apiUrl, flags);
|
|
257
|
+
return;
|
|
609
258
|
}
|
|
610
259
|
|
|
611
|
-
|
|
260
|
+
await launch(config, flags, lease);
|
|
612
261
|
}
|
|
613
262
|
|
|
614
|
-
|
|
263
|
+
// ── other commands ───────────────────────────────────────────────────────────
|
|
264
|
+
|
|
265
|
+
function commandRun(flags: Flags): void {
|
|
615
266
|
const config = loadConfig({
|
|
616
267
|
token: flags.token,
|
|
617
268
|
tunnelId: flags['tunnel-id'],
|
|
618
269
|
apiUrl: flags['api-url'],
|
|
619
270
|
});
|
|
271
|
+
const asService = flags.service === 'true';
|
|
620
272
|
|
|
621
273
|
if (!config.token || !config.tunnelId) {
|
|
622
|
-
console.error(
|
|
623
|
-
|
|
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);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (asService) rotateServiceLogs();
|
|
281
|
+
startAgent(config, { service: asService });
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Last agent line in the service log, so status reports evidence not a guess. */
|
|
285
|
+
function lastServiceActivity(): string | null {
|
|
286
|
+
try {
|
|
287
|
+
const lines = readFileSync(join(getServicePaths().logDir, 'agent-tunnel.out.log'), 'utf8')
|
|
288
|
+
.split(/\r?\n/)
|
|
289
|
+
.map((line) => stripAnsi(line).trim())
|
|
290
|
+
.filter((line) => line.length > 0);
|
|
291
|
+
return lines.at(-1) ?? null;
|
|
292
|
+
} catch {
|
|
293
|
+
return null;
|
|
624
294
|
}
|
|
295
|
+
}
|
|
625
296
|
|
|
626
|
-
|
|
297
|
+
function commandStatus(flags: Flags): void {
|
|
298
|
+
const config = loadConfig({ apiUrl: flags['api-url'] });
|
|
299
|
+
const service = getServiceStatus();
|
|
300
|
+
const paired = Boolean(config.token && config.tunnelId);
|
|
301
|
+
const approved = new Set(config.enabledCapabilities ?? []);
|
|
302
|
+
|
|
303
|
+
if (isTruthyFlag(flags.json)) {
|
|
627
304
|
console.log(JSON.stringify({
|
|
628
|
-
|
|
305
|
+
paired,
|
|
306
|
+
tunnelId: paired ? config.tunnelId : null,
|
|
629
307
|
apiUrl: config.apiUrl,
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
service
|
|
308
|
+
capabilities: [...approved],
|
|
309
|
+
version: agentTunnelVersion(),
|
|
310
|
+
service,
|
|
311
|
+
lastActivity: lastServiceActivity(),
|
|
633
312
|
}, null, 2));
|
|
634
313
|
return;
|
|
635
314
|
}
|
|
636
315
|
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
});
|
|
641
|
-
|
|
642
|
-
if (!res.ok) {
|
|
643
|
-
console.error(`Error: ${res.status} ${await res.text()}`);
|
|
644
|
-
process.exit(1);
|
|
645
|
-
}
|
|
316
|
+
blankLine();
|
|
317
|
+
console.log(` ${glyph.mark} ${c.bold}${c.white}Agent Tunnel${c.reset} ${c.dim}v${agentTunnelVersion()}${c.reset} ${c.dim}${hostname()}${c.reset}`);
|
|
318
|
+
blankLine();
|
|
646
319
|
|
|
647
|
-
|
|
648
|
-
console.log(
|
|
649
|
-
|
|
650
|
-
console.
|
|
651
|
-
|
|
320
|
+
if (!paired) {
|
|
321
|
+
console.log(` ${glyph.off} ${c.bold}Not paired${c.reset}`);
|
|
322
|
+
blankLine();
|
|
323
|
+
console.log(` ${c.dim}Pair this machine:${c.reset} ${c.white}agent-tunnel connect --api-url <url>${c.reset}`);
|
|
324
|
+
blankLine();
|
|
325
|
+
return;
|
|
652
326
|
}
|
|
653
|
-
}
|
|
654
327
|
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
328
|
+
field('tunnel', `${c.white}${config.tunnelId}${c.reset}`);
|
|
329
|
+
field('relay', `${c.white}${config.apiUrl}${c.reset}`);
|
|
330
|
+
field('capabilities', ALL_CAPABILITIES
|
|
331
|
+
.map((name) => approved.has(name) ? `${glyph.on} ${c.white}${name}${c.reset}` : `${c.gray}○ ${name}${c.reset}`)
|
|
332
|
+
.join(' '));
|
|
333
|
+
blankLine();
|
|
334
|
+
field('service', describeService(service));
|
|
335
|
+
if (service.installed && service.path) field('', `${c.dim}${shortenHomePath(service.path)}${c.reset}`);
|
|
661
336
|
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
}
|
|
337
|
+
const activity = lastServiceActivity();
|
|
338
|
+
if (activity) field('last log', `${c.dim}${activity}${c.reset}`);
|
|
339
|
+
blankLine();
|
|
666
340
|
|
|
667
|
-
if (
|
|
668
|
-
|
|
341
|
+
if (approved.size === 0) {
|
|
342
|
+
console.log(` ${glyph.warn} ${c.dim}No capabilities approved — this tunnel cannot act.${c.reset}`);
|
|
343
|
+
console.log(` ${c.dim}Pair again with${c.reset} ${c.white}agent-tunnel connect --reauth${c.reset}`);
|
|
344
|
+
blankLine();
|
|
669
345
|
}
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
console.log(JSON.stringify(status, null, 2));
|
|
346
|
+
console.log(` ${c.dim}Recent logs:${c.reset} ${c.white}agent-tunnel logs${c.reset}`);
|
|
347
|
+
blankLine();
|
|
673
348
|
}
|
|
674
349
|
|
|
675
|
-
function
|
|
676
|
-
|
|
350
|
+
function commandLogout(flags: Flags): void {
|
|
351
|
+
const removed = clearSavedCredentials();
|
|
352
|
+
const keepService = isTruthyFlag(flags['keep-service']);
|
|
353
|
+
if (!keepService) SERVICE_ACTIONS.uninstall.run();
|
|
354
|
+
|
|
355
|
+
blankLine();
|
|
356
|
+
console.log(removed
|
|
357
|
+
? ` ${glyph.on} ${c.bold}Signed out${c.reset} ${c.dim}(credentials cleared from ${CONFIG_FILE})${c.reset}`
|
|
358
|
+
: ` ${glyph.off} ${c.dim}No saved credentials to clear${c.reset}`);
|
|
359
|
+
console.log(keepService
|
|
360
|
+
? ` ${glyph.warn} ${c.dim}Background service kept — it cannot authenticate until you connect again${c.reset}`
|
|
361
|
+
: ` ${c.dim}Background service removed${c.reset}`);
|
|
362
|
+
blankLine();
|
|
363
|
+
console.log(` ${c.dim}Pair again with:${c.reset} ${c.white}agent-tunnel connect --api-url <url>${c.reset}`);
|
|
364
|
+
blankLine();
|
|
677
365
|
}
|
|
678
366
|
|
|
679
|
-
function
|
|
680
|
-
|
|
681
|
-
}
|
|
367
|
+
function commandLogs(flags: Flags): void {
|
|
368
|
+
const paths = getServicePaths();
|
|
682
369
|
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
}
|
|
370
|
+
if (isTruthyFlag(flags.clear)) {
|
|
371
|
+
for (const file of serviceLogFiles(paths)) {
|
|
372
|
+
try { writeFileSync(file, '', { mode: 0o600 }); } catch {}
|
|
373
|
+
}
|
|
374
|
+
blankLine();
|
|
375
|
+
console.log(` ${glyph.on} ${c.dim}Service logs cleared${c.reset}`);
|
|
376
|
+
blankLine();
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
686
379
|
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
380
|
+
const requested = Number.parseInt(flags.lines ?? '', 10);
|
|
381
|
+
const limit = Number.isSafeInteger(requested) && requested > 0 ? requested : DEFAULT_LOG_LINES;
|
|
382
|
+
const showAll = isTruthyFlag(flags.all);
|
|
690
383
|
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
384
|
+
for (const [label, file] of [
|
|
385
|
+
['output', join(paths.logDir, 'agent-tunnel.out.log')],
|
|
386
|
+
['errors', join(paths.logDir, 'agent-tunnel.err.log')],
|
|
387
|
+
] as const) {
|
|
388
|
+
blankLine();
|
|
389
|
+
console.log(` ${c.bold}${c.white}${label}${c.reset} ${c.dim}${shortenHomePath(file)}${c.reset}`);
|
|
694
390
|
|
|
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
391
|
if (!existsSync(file)) {
|
|
704
|
-
console.log(
|
|
392
|
+
console.log(` ${c.dim}not created yet${c.reset}`);
|
|
705
393
|
continue;
|
|
706
394
|
}
|
|
707
|
-
|
|
708
|
-
const
|
|
709
|
-
|
|
395
|
+
|
|
396
|
+
const kept = readFileSync(file, 'utf8')
|
|
397
|
+
.split(/\r?\n/)
|
|
398
|
+
.map((line) => line.trimEnd())
|
|
399
|
+
.filter((line) => line.trim().length > 0)
|
|
400
|
+
.filter((line) => showAll || !isShellStartupNoise(line));
|
|
401
|
+
|
|
402
|
+
const lines = collapseRepeatedLines(kept).slice(-limit);
|
|
403
|
+
if (lines.length === 0) {
|
|
404
|
+
console.log(` ${c.dim}empty${c.reset}`);
|
|
405
|
+
continue;
|
|
406
|
+
}
|
|
407
|
+
for (const line of lines) console.log(` ${line}`);
|
|
408
|
+
}
|
|
409
|
+
blankLine();
|
|
410
|
+
console.log(` ${c.dim}--lines <n> to show more, --all to keep shell noise, --clear to empty them.${c.reset}`);
|
|
411
|
+
blankLine();
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function commandServiceAction(action: ServiceAction, flags: Flags): void {
|
|
415
|
+
if (action === 'install') {
|
|
416
|
+
const config = loadConfig({
|
|
417
|
+
token: flags.token,
|
|
418
|
+
tunnelId: flags['tunnel-id'],
|
|
419
|
+
apiUrl: flags['api-url'],
|
|
420
|
+
});
|
|
421
|
+
if (!config.token || !config.tunnelId) {
|
|
422
|
+
fail('No saved tunnel credentials found. Run `agent-tunnel connect` first, or pass --token and --tunnel-id.');
|
|
423
|
+
}
|
|
424
|
+
if (flags.token && flags['tunnel-id']) {
|
|
425
|
+
saveCredentials(config.tunnelId, config.token, config.apiUrl);
|
|
426
|
+
}
|
|
710
427
|
}
|
|
428
|
+
renderServiceAction(action, SERVICE_ACTIONS[action].run());
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// ── dispatch ─────────────────────────────────────────────────────────────────
|
|
432
|
+
|
|
433
|
+
interface Command {
|
|
434
|
+
summary: string;
|
|
435
|
+
run: (flags: Flags) => void | Promise<void>;
|
|
436
|
+
aliases?: readonly string[];
|
|
437
|
+
hidden?: boolean;
|
|
711
438
|
}
|
|
712
439
|
|
|
440
|
+
const COMMANDS: Record<string, Command> = {
|
|
441
|
+
connect: {
|
|
442
|
+
summary: 'Pair this machine, then run it in the background or this terminal',
|
|
443
|
+
run: commandConnect,
|
|
444
|
+
},
|
|
445
|
+
status: { summary: 'Show pairing, capabilities, and service state (--json)', run: commandStatus },
|
|
446
|
+
logs: { summary: 'Show recent service logs (--lines <n>, --all, --clear)', run: commandLogs },
|
|
447
|
+
start: { summary: 'Start the background service', run: (f) => commandServiceAction('start', f) },
|
|
448
|
+
stop: {
|
|
449
|
+
summary: 'Stop the background service (keeps it installed)',
|
|
450
|
+
run: (f) => commandServiceAction('stop', f),
|
|
451
|
+
aliases: ['disable'],
|
|
452
|
+
},
|
|
453
|
+
restart: { summary: 'Restart the background service', run: (f) => commandServiceAction('restart', f) },
|
|
454
|
+
'install-service': {
|
|
455
|
+
summary: 'Install and start the background service',
|
|
456
|
+
run: (f) => commandServiceAction('install', f),
|
|
457
|
+
},
|
|
458
|
+
'uninstall-service': {
|
|
459
|
+
summary: 'Stop and remove the background service',
|
|
460
|
+
run: (f) => commandServiceAction('uninstall', f),
|
|
461
|
+
},
|
|
462
|
+
'service-status': {
|
|
463
|
+
summary: 'Show the background service state (same view as status)',
|
|
464
|
+
run: commandStatus,
|
|
465
|
+
},
|
|
466
|
+
logout: { summary: 'Clear saved credentials and remove the service', run: commandLogout },
|
|
467
|
+
run: { summary: 'Run using saved credentials (used by the service)', run: commandRun },
|
|
468
|
+
'start-service': { summary: '', run: (f) => commandServiceAction('start', f), hidden: true },
|
|
469
|
+
'stop-service': { summary: '', run: (f) => commandServiceAction('stop', f), hidden: true },
|
|
470
|
+
'restart-service': { summary: '', run: (f) => commandServiceAction('restart', f), hidden: true },
|
|
471
|
+
'sign-out': { summary: '', run: commandLogout, hidden: true },
|
|
472
|
+
unpair: { summary: '', run: commandLogout, hidden: true },
|
|
473
|
+
};
|
|
474
|
+
|
|
475
|
+
const OPTIONS: ReadonlyArray<readonly [string, string]> = [
|
|
476
|
+
['--api-url <url>', 'Relay API URL'],
|
|
477
|
+
['--token <token> --tunnel-id <id>', 'Skip device auth and use an explicit credential'],
|
|
478
|
+
['--reauth', 'With connect: discard the saved credential and pair again'],
|
|
479
|
+
['--daemon / --foreground', 'With connect: skip the prompt and choose the mode'],
|
|
480
|
+
['--json', 'With status: machine-readable output'],
|
|
481
|
+
['--keep-service', 'With logout: keep the background service installed'],
|
|
482
|
+
];
|
|
483
|
+
|
|
713
484
|
function showHelp(): void {
|
|
714
|
-
|
|
485
|
+
blankLine();
|
|
715
486
|
console.log(` ${c.cyan}▄▀█ █▀▀ █▀▀ █▄ █ ▀█▀${c.reset} ${c.cyan}▀█▀ █ █ █▄ █ █▄ █ █▀▀ █ ${c.reset}`);
|
|
716
487
|
console.log(` ${c.cyan}█▀█ █▄█ ██▄ █ ▀█ █${c.reset} ${c.cyan} █ █▄█ █ ▀█ █ ▀█ ██▄ █▄▄${c.reset}`);
|
|
717
|
-
|
|
488
|
+
blankLine();
|
|
718
489
|
console.log(` ${c.dim}Secure bridge between AI agents & local machines${c.reset}`);
|
|
719
|
-
|
|
490
|
+
blankLine();
|
|
720
491
|
console.log(` ${c.bold}Usage${c.reset} ${c.dim}npx --yes @kortix/agent-tunnel@latest <command> [options]${c.reset}`);
|
|
721
|
-
|
|
492
|
+
blankLine();
|
|
493
|
+
|
|
722
494
|
console.log(`${c.gray} ── Commands ────────────────────────────────────────${c.reset}`);
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
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`);
|
|
732
|
-
console.log(` ${c.cyan}status${c.reset} Check tunnel connection status`);
|
|
733
|
-
console.log(` ${c.cyan}help${c.reset} Show this help message`);
|
|
734
|
-
console.log('');
|
|
495
|
+
const visible = Object.entries(COMMANDS).filter(([, command]) => !command.hidden);
|
|
496
|
+
const width = Math.max(...visible.map(([name]) => name.length)) + 2;
|
|
497
|
+
for (const [name, command] of visible) {
|
|
498
|
+
console.log(` ${c.cyan}${name.padEnd(width)}${c.reset}${command.summary}`);
|
|
499
|
+
}
|
|
500
|
+
blankLine();
|
|
501
|
+
|
|
735
502
|
console.log(`${c.gray} ── Options ─────────────────────────────────────────${c.reset}`);
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
console.log(
|
|
742
|
-
console.log(` ${c.dim}Config: ~/.agent-tunnel/config.json${c.reset}`);
|
|
503
|
+
const optionWidth = Math.max(...OPTIONS.map(([flag]) => flag.length)) + 2;
|
|
504
|
+
for (const [flag, description] of OPTIONS) {
|
|
505
|
+
console.log(` ${c.white}${flag.padEnd(optionWidth)}${c.reset}${c.dim}${description}${c.reset}`);
|
|
506
|
+
}
|
|
507
|
+
blankLine();
|
|
508
|
+
console.log(` ${c.dim}Config: ${CONFIG_FILE}${c.reset}`);
|
|
743
509
|
console.log(` ${c.dim}powered by ${c.cyan}kortix${c.reset}`);
|
|
744
|
-
|
|
510
|
+
blankLine();
|
|
745
511
|
}
|
|
746
512
|
|
|
747
513
|
const { command, flags } = parseArgs(process.argv);
|
|
748
514
|
|
|
749
515
|
if (Object.prototype.hasOwnProperty.call(flags, 'keep-awake')) {
|
|
750
|
-
console.error(
|
|
516
|
+
console.error(` ${glyph.bad} --keep-awake is not supported. Configure sleep behavior in the operating system.`);
|
|
751
517
|
process.exit(2);
|
|
752
518
|
}
|
|
753
519
|
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
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;
|
|
786
|
-
case 'status':
|
|
787
|
-
commandStatus(flags);
|
|
788
|
-
break;
|
|
789
|
-
case 'help':
|
|
790
|
-
default:
|
|
791
|
-
showHelp();
|
|
792
|
-
break;
|
|
520
|
+
const resolved =
|
|
521
|
+
COMMANDS[command] ??
|
|
522
|
+
Object.values(COMMANDS).find((entry) => entry.aliases?.includes(command));
|
|
523
|
+
|
|
524
|
+
if (!resolved) {
|
|
525
|
+
showHelp();
|
|
526
|
+
} else {
|
|
527
|
+
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);
|
|
530
|
+
});
|
|
793
531
|
}
|