@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/package.json
CHANGED
package/src/agent/agent.ts
CHANGED
|
@@ -1,11 +1,26 @@
|
|
|
1
1
|
import { hostname, platform, arch, release } from 'os';
|
|
2
|
-
import {
|
|
2
|
+
import { buildTunnelWsUrl, trustedCredential, type TunnelConfig } from './config';
|
|
3
|
+
import { agentTunnelVersion } from './version';
|
|
4
|
+
import { c } from './terminal';
|
|
3
5
|
import { CapabilityRegistry } from './capabilities/index';
|
|
4
6
|
import { PermissionGuard } from './security/permission-guard';
|
|
5
7
|
import type { LocalPermission } from './security/permission-guard';
|
|
6
8
|
import { signMessage, verifyMessageSignature } from '../shared/crypto';
|
|
7
9
|
|
|
8
|
-
const AGENT_VERSION =
|
|
10
|
+
export const AGENT_VERSION = agentTunnelVersion();
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Relay close codes that mean the credential itself is bad. They are terminal:
|
|
14
|
+
* reconnecting with the same token can never succeed.
|
|
15
|
+
*/
|
|
16
|
+
export const AUTH_REJECTED_CLOSE_CODES: readonly number[] = [4001, 4003];
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The relay closes an already-registered socket with this code when a second
|
|
20
|
+
* process authenticates with the same credential. Only one agent may hold a
|
|
21
|
+
* tunnel, so this is terminal for the displaced process.
|
|
22
|
+
*/
|
|
23
|
+
export const AGENT_REPLACED_CLOSE_CODE = 4004;
|
|
9
24
|
|
|
10
25
|
interface JsonRpcRequest {
|
|
11
26
|
jsonrpc: '2.0';
|
|
@@ -27,25 +42,24 @@ interface JsonRpcNotification {
|
|
|
27
42
|
type IncomingMessage = JsonRpcRequest | JsonRpcNotification;
|
|
28
43
|
const MAX_RPC_MESSAGE_SIZE = 5 * 1024 * 1024;
|
|
29
44
|
|
|
30
|
-
const c = {
|
|
31
|
-
reset: '\x1b[0m',
|
|
32
|
-
bold: '\x1b[1m',
|
|
33
|
-
dim: '\x1b[2m',
|
|
34
|
-
cyan: '\x1b[36m',
|
|
35
|
-
green: '\x1b[32m',
|
|
36
|
-
yellow: '\x1b[33m',
|
|
37
|
-
red: '\x1b[31m',
|
|
38
|
-
white: '\x1b[97m',
|
|
39
|
-
gray: '\x1b[90m',
|
|
40
|
-
};
|
|
41
|
-
|
|
42
45
|
function log(icon: string, msg: string) {
|
|
43
46
|
const safeIcon = icon.replace(/[\r\n]/g, ' ');
|
|
44
47
|
const safeMsg = msg.replace(/[\r\n]/g, ' ');
|
|
45
48
|
process.stdout.write(` ${safeIcon} ${c.dim}${safeMsg}${c.reset}\n`);
|
|
46
49
|
}
|
|
47
50
|
|
|
51
|
+
export interface TunnelAgentHooks {
|
|
52
|
+
/**
|
|
53
|
+
* Fires when the relay closes the connection for a reason reconnecting cannot
|
|
54
|
+
* fix. The agent has already stopped retrying by this point.
|
|
55
|
+
*/
|
|
56
|
+
onTerminalClose?: (info: { code: number; reason: TerminalCloseReason }) => void;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export type TerminalCloseReason = 'credential-rejected' | 'replaced';
|
|
60
|
+
|
|
48
61
|
export class TunnelAgent {
|
|
62
|
+
private hooks: TunnelAgentHooks;
|
|
49
63
|
private ws: WebSocket | null = null;
|
|
50
64
|
private registry: CapabilityRegistry;
|
|
51
65
|
private permissionGuard: PermissionGuard;
|
|
@@ -64,10 +78,11 @@ export class TunnelAgent {
|
|
|
64
78
|
private lastNonce = 0;
|
|
65
79
|
private responseNonce = 0;
|
|
66
80
|
|
|
67
|
-
constructor(config: TunnelConfig, registry: CapabilityRegistry) {
|
|
81
|
+
constructor(config: TunnelConfig, registry: CapabilityRegistry, hooks: TunnelAgentHooks = {}) {
|
|
68
82
|
this.config = config;
|
|
69
83
|
this.registry = registry;
|
|
70
84
|
this.permissionGuard = new PermissionGuard();
|
|
85
|
+
this.hooks = hooks;
|
|
71
86
|
}
|
|
72
87
|
|
|
73
88
|
connect(): void {
|
|
@@ -149,19 +164,24 @@ export class TunnelAgent {
|
|
|
149
164
|
|
|
150
165
|
if (!this.isShuttingDown) {
|
|
151
166
|
if (event.code === 4001) {
|
|
152
|
-
|
|
167
|
+
this.isShuttingDown = true;
|
|
168
|
+
log(`${c.red}✗${c.reset}`, `Credential rejected — run \`agent-tunnel connect --reauth\` to pair again`);
|
|
169
|
+
this.hooks.onTerminalClose?.({ code: event.code, reason: 'credential-rejected' });
|
|
153
170
|
return; // Don't reconnect on auth failure
|
|
154
171
|
}
|
|
155
172
|
if (event.code === 4003) {
|
|
156
|
-
|
|
173
|
+
this.isShuttingDown = true;
|
|
174
|
+
log(`${c.red}✗${c.reset}`, `Device credential was revoked — run \`agent-tunnel connect --reauth\` to pair again`);
|
|
175
|
+
this.hooks.onTerminalClose?.({ code: event.code, reason: 'credential-rejected' });
|
|
157
176
|
return;
|
|
158
177
|
}
|
|
159
|
-
if (event.code ===
|
|
178
|
+
if (event.code === AGENT_REPLACED_CLOSE_CODE) {
|
|
160
179
|
this.isShuttingDown = true;
|
|
161
180
|
log(
|
|
162
181
|
`${c.yellow}○${c.reset}`,
|
|
163
182
|
`Another Agent Tunnel process connected with these credentials — stopping this process`,
|
|
164
183
|
);
|
|
184
|
+
this.hooks.onTerminalClose?.({ code: event.code, reason: 'replaced' });
|
|
165
185
|
return;
|
|
166
186
|
}
|
|
167
187
|
log(`${c.yellow}○${c.reset}`, `Disconnected ${c.gray}(code: ${event.code})${c.reset}`);
|
|
@@ -191,7 +211,16 @@ export class TunnelAgent {
|
|
|
191
211
|
// Handle auth_ok — server sends signing key after successful auth
|
|
192
212
|
if (msg.type === 'auth_ok' && msg.signingKey) {
|
|
193
213
|
this.signingKey = msg.signingKey;
|
|
194
|
-
|
|
214
|
+
const capabilityNames = this.registry.getCapabilityNames();
|
|
215
|
+
if (capabilityNames.length === 0) {
|
|
216
|
+
// Reporting a bare "Connected ()" hides that this tunnel is inert.
|
|
217
|
+
log(
|
|
218
|
+
`${c.yellow}!${c.reset}`,
|
|
219
|
+
`Connected, but no capabilities are enabled — this tunnel cannot do anything. Run \`agent-tunnel connect --reauth\` to pair again.`,
|
|
220
|
+
);
|
|
221
|
+
} else {
|
|
222
|
+
log(`${c.green}●${c.reset}`, `Connected ${c.reset}${c.gray}(${capabilityNames.join(', ')})${c.reset}`);
|
|
223
|
+
}
|
|
195
224
|
if (this.stableConnectionTimer) clearTimeout(this.stableConnectionTimer);
|
|
196
225
|
this.stableConnectionTimer = setTimeout(() => {
|
|
197
226
|
this.reconnectAttempts = 0;
|
|
@@ -358,6 +387,10 @@ export class TunnelAgent {
|
|
|
358
387
|
private send(data: unknown): void {
|
|
359
388
|
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
360
389
|
try {
|
|
390
|
+
// The auth handshake carries the credential read from the local config
|
|
391
|
+
// file to the relay by design. loadConfig() validates the file and
|
|
392
|
+
// trustedCredential() rejects control characters before it gets here.
|
|
393
|
+
// lgtm[js/file-access-to-http]
|
|
361
394
|
this.ws.send(JSON.stringify(data));
|
|
362
395
|
} catch (err) {
|
|
363
396
|
log(`${c.red}✗${c.reset}`, `Send failed`);
|
|
@@ -400,15 +433,6 @@ export class TunnelAgent {
|
|
|
400
433
|
}
|
|
401
434
|
|
|
402
435
|
private buildWsUrl(): string {
|
|
403
|
-
|
|
404
|
-
.replace(/^http:/, 'ws:')
|
|
405
|
-
.replace(/^https:/, 'wss:');
|
|
406
|
-
|
|
407
|
-
const wsPath = this.config.wsPath || '/ws';
|
|
408
|
-
const params = new URLSearchParams({
|
|
409
|
-
tunnelId: trustedCredential(this.config.tunnelId, 'tunnelId'),
|
|
410
|
-
});
|
|
411
|
-
|
|
412
|
-
return `${base}${wsPath}?${params.toString()}`;
|
|
436
|
+
return buildTunnelWsUrl(this.config);
|
|
413
437
|
}
|
|
414
438
|
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { arch, hostname, platform } from 'os';
|
|
2
|
+
import { c, sleep, visibleLength } from './terminal';
|
|
3
|
+
|
|
4
|
+
const BOX_WIDTH = 60;
|
|
5
|
+
const BAR_WIDTH = 50;
|
|
6
|
+
const BAR_FRAMES = 14;
|
|
7
|
+
|
|
8
|
+
const WORDMARK = [
|
|
9
|
+
`${c.cyan}▄▀█ █▀▀ █▀▀ █▄ █ ▀█▀${c.reset} ${c.cyan}▀█▀ █ █ █▄ █ █▄ █ █▀▀ █ ${c.reset}`,
|
|
10
|
+
`${c.cyan}█▀█ █▄█ ██▄ █ ▀█ █${c.reset} ${c.cyan} █ █▄█ █ ▀█ █ ▀█ ██▄ █▄▄${c.reset}`,
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
function truncate(value: string, max: number): string {
|
|
14
|
+
return value.length > max ? `${value.slice(0, max)}…` : value;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function animateConnectingBar(): Promise<void> {
|
|
18
|
+
for (let frame = 0; frame <= BAR_FRAMES; frame++) {
|
|
19
|
+
const filled = Math.round((frame / BAR_FRAMES) * BAR_WIDTH);
|
|
20
|
+
process.stdout.write(
|
|
21
|
+
`\r ${c.cyan}◇${c.reset} ${c.cyan}${'═'.repeat(filled)}${c.reset}${c.gray}${'─'.repeat(BAR_WIDTH - filled)}${c.reset} `,
|
|
22
|
+
);
|
|
23
|
+
await sleep(20);
|
|
24
|
+
}
|
|
25
|
+
process.stdout.write(`\r ${c.cyan}◇ ${'═'.repeat(BAR_WIDTH)} ◆${c.reset} \n`);
|
|
26
|
+
await sleep(120);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface BannerDetails {
|
|
30
|
+
tunnelId: string;
|
|
31
|
+
apiUrl: string;
|
|
32
|
+
capabilities: string[];
|
|
33
|
+
version: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function printStartupBanner({
|
|
37
|
+
tunnelId,
|
|
38
|
+
apiUrl,
|
|
39
|
+
capabilities,
|
|
40
|
+
version,
|
|
41
|
+
}: BannerDetails): Promise<void> {
|
|
42
|
+
console.log('');
|
|
43
|
+
for (const line of WORDMARK) console.log(` ${line}`);
|
|
44
|
+
console.log('');
|
|
45
|
+
|
|
46
|
+
await animateConnectingBar();
|
|
47
|
+
|
|
48
|
+
const row = (content: string) => {
|
|
49
|
+
const pad = Math.max(0, BOX_WIDTH - visibleLength(content));
|
|
50
|
+
console.log(` ${c.gray}│${c.reset}${content}${' '.repeat(pad)}${c.gray}│${c.reset}`);
|
|
51
|
+
};
|
|
52
|
+
const blank = () => row('');
|
|
53
|
+
|
|
54
|
+
const titleLeft =` ${c.cyan}◆${c.reset} ${c.bold}${c.white}Agent Tunnel${c.reset}`;
|
|
55
|
+
const titleRight = `${c.dim}v${version}${c.reset} `;
|
|
56
|
+
const titlePad = Math.max(1, BOX_WIDTH - visibleLength(titleLeft) - visibleLength(titleRight));
|
|
57
|
+
|
|
58
|
+
// An empty capability set means the tunnel can connect but do nothing, so it
|
|
59
|
+
// is stated outright rather than rendered as an empty gap.
|
|
60
|
+
const capabilityRow =
|
|
61
|
+
capabilities.length > 0
|
|
62
|
+
? capabilities.map((name) => `${c.green}●${c.reset} ${c.white}${name}${c.reset}`).join(' ')
|
|
63
|
+
: `${c.yellow}none — this tunnel cannot act${c.reset}`;
|
|
64
|
+
|
|
65
|
+
const brand = 'created by kortix';
|
|
66
|
+
|
|
67
|
+
console.log('');
|
|
68
|
+
console.log(` ${c.gray}╭${'─'.repeat(BOX_WIDTH)}╮${c.reset}`);
|
|
69
|
+
blank();
|
|
70
|
+
row(`${titleLeft}${' '.repeat(titlePad)}${titleRight}`);
|
|
71
|
+
row(` ${c.dim}Bridge between AI agents & local machines${c.reset}`);
|
|
72
|
+
blank();
|
|
73
|
+
row(` ${c.dim}tunnel${c.reset} ${c.white}${truncate(tunnelId, 40)}${c.reset}`);
|
|
74
|
+
row(` ${c.dim}relay${c.reset} ${c.white}${truncate(apiUrl, 40)}${c.reset}`);
|
|
75
|
+
row(` ${c.dim}machine${c.reset} ${c.white}${truncate(hostname(), 28)}${c.reset} ${c.dim}(${platform()} ${arch()})${c.reset}`);
|
|
76
|
+
row(` ${c.dim}access${c.reset} ${capabilityRow}`);
|
|
77
|
+
blank();
|
|
78
|
+
console.log(
|
|
79
|
+
` ${c.gray}╰${'─'.repeat(BOX_WIDTH - brand.length - 3)} ${c.dim}created by ${c.cyan}kortix${c.reset} ${c.gray}─╯${c.reset}`,
|
|
80
|
+
);
|
|
81
|
+
console.log('');
|
|
82
|
+
}
|
|
@@ -1,25 +1,89 @@
|
|
|
1
|
-
import { describe, expect, test } from
|
|
2
|
-
import { spawnSync } from
|
|
3
|
-
import {
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { spawnSync } from "child_process";
|
|
3
|
+
import { mkdtempSync, rmSync } from "fs";
|
|
4
|
+
import { tmpdir } from "os";
|
|
5
|
+
import { join, resolve } from "path";
|
|
4
6
|
|
|
5
|
-
const CLI_PATH = resolve(import.meta.dir,
|
|
7
|
+
const CLI_PATH = resolve(import.meta.dir, "cli.ts");
|
|
6
8
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
9
|
+
/**
|
|
10
|
+
* The published CLI's help text is its documented surface. tests/bin/package-quality.ts
|
|
11
|
+
* asserts the same list against the packed tarball; duplicating it here makes an
|
|
12
|
+
* accidental removal fail in this package's own suite instead of only in CI.
|
|
13
|
+
*/
|
|
14
|
+
const DOCUMENTED_SURFACE = [
|
|
15
|
+
"connect",
|
|
16
|
+
"run",
|
|
17
|
+
"install-service",
|
|
18
|
+
"service-status",
|
|
19
|
+
"uninstall-service",
|
|
20
|
+
"status",
|
|
21
|
+
"logs",
|
|
22
|
+
"logout",
|
|
23
|
+
"--daemon",
|
|
24
|
+
"--foreground",
|
|
25
|
+
] as const;
|
|
10
26
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
27
|
+
describe("agent tunnel documented CLI surface", () => {
|
|
28
|
+
for (const entry of DOCUMENTED_SURFACE) {
|
|
29
|
+
test(`help lists ${entry}`, () => {
|
|
30
|
+
const result = spawnSync("bun", ["run", CLI_PATH, "help"], {
|
|
31
|
+
encoding: "utf8",
|
|
32
|
+
});
|
|
33
|
+
expect(result.status).toBe(0);
|
|
34
|
+
expect(result.stdout).toContain(entry);
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Only read-only commands are executed here. start/stop/restart/logout/unpair
|
|
39
|
+
// mutate the real launchd domain and credential file — running them from a
|
|
40
|
+
// test suite would stop a developer's live service or delete their pairing.
|
|
41
|
+
for (const command of ["status", "service-status"] as const) {
|
|
42
|
+
test(`${command} routes to the status view`, () => {
|
|
43
|
+
const home = mkdtempSync(join(tmpdir(), "agent-tunnel-help-"));
|
|
44
|
+
try {
|
|
45
|
+
const result = spawnSync("bun", ["run", CLI_PATH, command, "--json"], {
|
|
46
|
+
encoding: "utf8",
|
|
47
|
+
env: { ...process.env, HOME: home },
|
|
48
|
+
});
|
|
49
|
+
expect(result.status).toBe(0);
|
|
50
|
+
expect(JSON.parse(result.stdout)).toMatchObject({ paired: false });
|
|
51
|
+
} finally {
|
|
52
|
+
rmSync(home, { recursive: true, force: true });
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
test("an unknown command falls back to help", () => {
|
|
58
|
+
const result = spawnSync("bun", ["run", CLI_PATH, "not-a-command"], {
|
|
59
|
+
encoding: "utf8",
|
|
60
|
+
});
|
|
61
|
+
expect(result.stdout).toContain("Secure bridge between AI agents");
|
|
15
62
|
});
|
|
63
|
+
});
|
|
16
64
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
65
|
+
describe("agent tunnel service UX", () => {
|
|
66
|
+
test("offers persistent daemon mode without an unsupported keep-awake flag", () => {
|
|
67
|
+
const result = spawnSync("bun", ["run", CLI_PATH, "help"], {
|
|
68
|
+
encoding: "utf8",
|
|
20
69
|
});
|
|
21
70
|
|
|
71
|
+
expect(result.status).toBe(0);
|
|
72
|
+
expect(result.stdout).toContain("--daemon");
|
|
73
|
+
expect(result.stdout).toContain("--foreground");
|
|
74
|
+
expect(result.stdout).not.toContain("--keep-awake");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("rejects the removed keep-awake flag instead of silently ignoring it", () => {
|
|
78
|
+
const result = spawnSync(
|
|
79
|
+
"bun",
|
|
80
|
+
["run", CLI_PATH, "connect", "--keep-awake"],
|
|
81
|
+
{
|
|
82
|
+
encoding: "utf8",
|
|
83
|
+
},
|
|
84
|
+
);
|
|
85
|
+
|
|
22
86
|
expect(result.status).toBe(2);
|
|
23
|
-
expect(result.stderr).toContain(
|
|
87
|
+
expect(result.stderr).toContain("--keep-awake is not supported");
|
|
24
88
|
});
|
|
25
89
|
});
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from 'bun:test';
|
|
2
|
+
import { spawn, type ChildProcess } from 'node:child_process';
|
|
3
|
+
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join, resolve } from 'node:path';
|
|
6
|
+
|
|
7
|
+
const CLI_PATH = resolve(import.meta.dir, 'cli.ts');
|
|
8
|
+
const children = new Set<ChildProcess>();
|
|
9
|
+
const temporaryHomes = new Set<string>();
|
|
10
|
+
|
|
11
|
+
const STALE_TUNNEL_ID = '00000000-0000-4000-8000-0000000000ff';
|
|
12
|
+
const STALE_TOKEN = 'kortix_tnl_STALEAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
|
|
13
|
+
const FRESH_TUNNEL_ID = '00000000-0000-4000-8000-000000000042';
|
|
14
|
+
const FRESH_TOKEN = 'kortix_tnl_FRESHBBBBBBBBBBBBBBBBBBBBBBBBBBBB';
|
|
15
|
+
|
|
16
|
+
afterEach(async () => {
|
|
17
|
+
for (const child of children) child.kill('SIGTERM');
|
|
18
|
+
children.clear();
|
|
19
|
+
await Promise.all(
|
|
20
|
+
[...temporaryHomes].map((path) => rm(path, { recursive: true, force: true })),
|
|
21
|
+
);
|
|
22
|
+
temporaryHomes.clear();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
async function seedStaleCredentials(apiUrl: string): Promise<string> {
|
|
26
|
+
const home = await mkdtemp(join(tmpdir(), 'agent-tunnel-reauth-home-'));
|
|
27
|
+
temporaryHomes.add(home);
|
|
28
|
+
const configDir = join(home, '.agent-tunnel');
|
|
29
|
+
await mkdir(configDir, { recursive: true, mode: 0o700 });
|
|
30
|
+
await writeFile(
|
|
31
|
+
join(configDir, 'config.json'),
|
|
32
|
+
JSON.stringify({ tunnelId: STALE_TUNNEL_ID, token: STALE_TOKEN, apiUrl }),
|
|
33
|
+
{ mode: 0o600 },
|
|
34
|
+
);
|
|
35
|
+
return home;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function waitFor<T>(read: () => Promise<T>, timeoutMs = 15_000): Promise<T> {
|
|
39
|
+
const deadline = Date.now() + timeoutMs;
|
|
40
|
+
let lastError: unknown;
|
|
41
|
+
while (Date.now() < deadline) {
|
|
42
|
+
try {
|
|
43
|
+
return await read();
|
|
44
|
+
} catch (error) {
|
|
45
|
+
lastError = error;
|
|
46
|
+
await Bun.sleep(50);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
throw lastError ?? new Error('timed out');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Relay that refuses every credential, and a device-auth endpoint that issues a fresh one. */
|
|
53
|
+
function startRejectingRelay(options: { serveDeviceAuth: boolean }) {
|
|
54
|
+
let deviceAuthCalls = 0;
|
|
55
|
+
const server = Bun.serve({
|
|
56
|
+
port: 0,
|
|
57
|
+
fetch(request, server) {
|
|
58
|
+
const url = new URL(request.url);
|
|
59
|
+
if (url.pathname === '/v1/tunnel/ws') {
|
|
60
|
+
return server.upgrade(request) ? undefined : new Response('no upgrade', { status: 400 });
|
|
61
|
+
}
|
|
62
|
+
if (!options.serveDeviceAuth) return new Response('not found', { status: 404 });
|
|
63
|
+
|
|
64
|
+
if (request.method === 'POST' && url.pathname === '/v1/tunnel/device-auth') {
|
|
65
|
+
deviceAuthCalls++;
|
|
66
|
+
return Response.json(
|
|
67
|
+
{
|
|
68
|
+
deviceCode: 'RAUT-0001',
|
|
69
|
+
deviceSecret: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ123456',
|
|
70
|
+
verificationUrl: 'https://dev.kortix.com/tunnel/authorize/RAUT-0001',
|
|
71
|
+
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
|
72
|
+
pollIntervalMs: 250,
|
|
73
|
+
},
|
|
74
|
+
{ status: 201 },
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
if (request.method === 'GET' && url.pathname.endsWith('/RAUT-0001/status')) {
|
|
78
|
+
return Response.json({
|
|
79
|
+
status: 'approved',
|
|
80
|
+
tunnelId: FRESH_TUNNEL_ID,
|
|
81
|
+
token: FRESH_TOKEN,
|
|
82
|
+
capabilities: ['filesystem'],
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
return new Response('not found', { status: 404 });
|
|
86
|
+
},
|
|
87
|
+
websocket: {
|
|
88
|
+
// 4001 is the relay's "credential refused" code.
|
|
89
|
+
message(ws) {
|
|
90
|
+
ws.close(4001, 'auth failed');
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
return { server, deviceAuthCalls: () => deviceAuthCalls };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function runConnect(home: string, apiUrl: string, extraArgs: string[] = []) {
|
|
98
|
+
const child = spawn(
|
|
99
|
+
process.execPath,
|
|
100
|
+
['run', CLI_PATH, 'connect', '--foreground', '--api-url', apiUrl, ...extraArgs],
|
|
101
|
+
{
|
|
102
|
+
env: { ...process.env, HOME: home, KORTIX_AGENT_TUNNEL_NO_BROWSER: '1' },
|
|
103
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
104
|
+
},
|
|
105
|
+
);
|
|
106
|
+
children.add(child);
|
|
107
|
+
let stdout = '';
|
|
108
|
+
let stderr = '';
|
|
109
|
+
child.stdout?.on('data', (chunk) => { stdout += String(chunk); });
|
|
110
|
+
child.stderr?.on('data', (chunk) => { stderr += String(chunk); });
|
|
111
|
+
return { child, out: () => stdout, err: () => stderr };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
describe('agent tunnel connect re-authorization', () => {
|
|
115
|
+
test('re-pairs through device auth when the saved token is rejected', async () => {
|
|
116
|
+
const relay = startRejectingRelay({ serveDeviceAuth: true });
|
|
117
|
+
const apiUrl = `http://127.0.0.1:${relay.server.port}/v1/tunnel`;
|
|
118
|
+
const home = await seedStaleCredentials(apiUrl);
|
|
119
|
+
const configPath = join(home, '.agent-tunnel', 'config.json');
|
|
120
|
+
const run = runConnect(home, apiUrl);
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
const config = await waitFor(async () => {
|
|
124
|
+
const parsed = JSON.parse(await readFile(configPath, 'utf8')) as { token?: string };
|
|
125
|
+
if (parsed.token !== FRESH_TOKEN) throw new Error('still holding the stale token');
|
|
126
|
+
return parsed as { token: string; tunnelId: string };
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
expect(config.tunnelId).toBe(FRESH_TUNNEL_ID);
|
|
130
|
+
expect(run.out()).toContain('Checking saved credentials');
|
|
131
|
+
expect(run.out()).toContain('Saved token rejected');
|
|
132
|
+
expect(relay.deviceAuthCalls()).toBeGreaterThan(0);
|
|
133
|
+
} finally {
|
|
134
|
+
run.child.kill('SIGTERM');
|
|
135
|
+
relay.server.stop(true);
|
|
136
|
+
}
|
|
137
|
+
}, 30_000);
|
|
138
|
+
|
|
139
|
+
test('keeps the saved credential when the relay is unreachable', async () => {
|
|
140
|
+
// Port 1 is reserved and refuses connections, so the probe cannot conclude
|
|
141
|
+
// anything about the credential itself.
|
|
142
|
+
const apiUrl = 'http://127.0.0.1:1/v1/tunnel';
|
|
143
|
+
const home = await seedStaleCredentials(apiUrl);
|
|
144
|
+
const configPath = join(home, '.agent-tunnel', 'config.json');
|
|
145
|
+
const run = runConnect(home, apiUrl);
|
|
146
|
+
|
|
147
|
+
const exitCode = await new Promise<number | null>((r) => run.child.once('exit', r));
|
|
148
|
+
expect(exitCode).toBe(1);
|
|
149
|
+
expect(run.err()).toContain('Cannot reach the relay');
|
|
150
|
+
|
|
151
|
+
const config = JSON.parse(await readFile(configPath, 'utf8')) as { token?: string };
|
|
152
|
+
expect(config.token).toBe(STALE_TOKEN);
|
|
153
|
+
}, 30_000);
|
|
154
|
+
|
|
155
|
+
test('--reauth discards a saved credential without probing it first', async () => {
|
|
156
|
+
const relay = startRejectingRelay({ serveDeviceAuth: true });
|
|
157
|
+
const apiUrl = `http://127.0.0.1:${relay.server.port}/v1/tunnel`;
|
|
158
|
+
const home = await seedStaleCredentials(apiUrl);
|
|
159
|
+
const configPath = join(home, '.agent-tunnel', 'config.json');
|
|
160
|
+
const run = runConnect(home, apiUrl, ['--reauth']);
|
|
161
|
+
|
|
162
|
+
try {
|
|
163
|
+
const config = await waitFor(async () => {
|
|
164
|
+
const parsed = JSON.parse(await readFile(configPath, 'utf8')) as { token?: string };
|
|
165
|
+
if (parsed.token !== FRESH_TOKEN) throw new Error('not re-paired yet');
|
|
166
|
+
return parsed;
|
|
167
|
+
});
|
|
168
|
+
expect(config.token).toBe(FRESH_TOKEN);
|
|
169
|
+
expect(run.out()).not.toContain('Checking saved credentials');
|
|
170
|
+
} finally {
|
|
171
|
+
run.child.kill('SIGTERM');
|
|
172
|
+
relay.server.stop(true);
|
|
173
|
+
}
|
|
174
|
+
}, 30_000);
|
|
175
|
+
|
|
176
|
+
test('logout clears the credential but keeps unrelated settings', async () => {
|
|
177
|
+
const home = await mkdtemp(join(tmpdir(), 'agent-tunnel-logout-home-'));
|
|
178
|
+
temporaryHomes.add(home);
|
|
179
|
+
const configDir = join(home, '.agent-tunnel');
|
|
180
|
+
await mkdir(configDir, { recursive: true, mode: 0o700 });
|
|
181
|
+
const configPath = join(configDir, 'config.json');
|
|
182
|
+
await writeFile(
|
|
183
|
+
configPath,
|
|
184
|
+
JSON.stringify({
|
|
185
|
+
tunnelId: STALE_TUNNEL_ID,
|
|
186
|
+
token: STALE_TOKEN,
|
|
187
|
+
apiUrl: 'https://api.kortix.com/v1/tunnel',
|
|
188
|
+
shellTimeout: 12_345,
|
|
189
|
+
}),
|
|
190
|
+
{ mode: 0o600 },
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
const child = spawn(process.execPath, ['run', CLI_PATH, 'logout'], {
|
|
194
|
+
env: { ...process.env, HOME: home },
|
|
195
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
196
|
+
});
|
|
197
|
+
children.add(child);
|
|
198
|
+
await new Promise<number | null>((r) => child.once('exit', r));
|
|
199
|
+
|
|
200
|
+
const config = JSON.parse(await readFile(configPath, 'utf8')) as Record<string, unknown>;
|
|
201
|
+
expect(config.token).toBeUndefined();
|
|
202
|
+
expect(config.tunnelId).toBeUndefined();
|
|
203
|
+
expect(config.shellTimeout).toBe(12_345);
|
|
204
|
+
}, 30_000);
|
|
205
|
+
});
|