@kortix/agent-tunnel 0.13.41 → 0.13.42
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +120 -11
- package/dist/agent-cli.js +1144 -435
- package/dist/client-cli.js +1 -3
- package/package.json +1 -1
- package/src/agent/access.test.ts +164 -0
- package/src/agent/access.ts +176 -0
- package/src/agent/agent.test.ts +641 -0
- package/src/agent/agent.ts +333 -10
- package/src/agent/capabilities/security.test.ts +20 -1
- package/src/agent/cli-device-auth.test.ts +114 -0
- package/src/agent/cli-reauth.test.ts +108 -8
- package/src/agent/cli.ts +201 -57
- package/src/agent/config.test.ts +5 -0
- package/src/agent/config.ts +17 -2
- package/src/agent/credential-probe.ts +3 -3
- package/src/agent/credential-store.ts +2 -2
- package/src/agent/device-auth.test.ts +57 -0
- package/src/agent/device-auth.ts +45 -3
- package/src/agent/security/path-validator.ts +3 -1
- package/src/agent/service-control.ts +5 -2
- package/src/agent/service-drivers.test.ts +164 -0
- package/src/agent/service-drivers.ts +205 -56
- package/src/agent/service-lifecycle.test.ts +32 -18
- package/src/agent/service-paths.ts +42 -10
- package/src/agent/service.test.ts +126 -5
- package/src/agent/service.ts +99 -12
- package/src/agent/state-file.test.ts +32 -0
- package/src/agent/state-file.ts +58 -0
- package/src/client/tunnel-client.ts +6 -3
- package/src/server/ws-handler.test.ts +31 -1
- package/src/server/ws-handler.ts +3 -3
- package/src/shared/types.ts +12 -0
|
@@ -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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
225
|
+
clearWaiting();
|
|
173
226
|
fail(error instanceof Error ? error.message : 'Device authorization failed');
|
|
174
227
|
}
|
|
175
|
-
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
220
|
-
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
-
|
|
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: {
|
|
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: '
|
|
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: '
|
|
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
|
-
['--
|
|
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
|
-
|
|
529
|
-
process.exit(1);
|
|
673
|
+
fail(error instanceof Error ? error.message : String(error));
|
|
530
674
|
});
|
|
531
675
|
}
|
package/src/agent/config.test.ts
CHANGED
|
@@ -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(
|