@kortix/agent-tunnel 0.1.1 → 0.1.3

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/src/agent/cli.ts CHANGED
@@ -1,11 +1,15 @@
1
- #!/usr/bin/env bun
2
- import { loadConfig } from './config';
1
+ import '../node-ws-polyfill';
2
+ import { loadConfig, type TunnelConfig } from './config';
3
3
  import { TunnelAgent } from './agent';
4
4
  import { CapabilityRegistry } from './capabilities/index';
5
5
  import { createFilesystemCapability } from './capabilities/filesystem';
6
6
  import { createShellCapability } from './capabilities/shell';
7
7
  import { createDesktopCapability } from './capabilities/desktop';
8
8
  import { hostname, platform, arch, release } from 'os';
9
+ import { existsSync, mkdirSync, writeFileSync, readFileSync } from 'fs';
10
+ import { join } from 'path';
11
+ import { homedir } from 'os';
12
+ import { execSync } from 'child_process';
9
13
 
10
14
  const c = {
11
15
  reset: '\x1b[0m',
@@ -114,30 +118,14 @@ async function printStartup(config: { tunnelId: string; apiUrl: string }, capabi
114
118
  console.log('');
115
119
  }
116
120
 
117
- async function commandConnect(flags: Record<string, string>): Promise<void> {
118
- const config = loadConfig({
119
- token: flags.token,
120
- tunnelId: flags['tunnel-id'],
121
- apiUrl: flags['api-url'],
122
- });
123
-
124
- if (!config.token) {
125
- console.error(`${c.red}${c.bold} error${c.reset} --token is required`);
126
- process.exit(1);
127
- }
128
-
129
- if (!config.tunnelId) {
130
- console.error(`${c.red}${c.bold} error${c.reset} --tunnel-id is required`);
131
- process.exit(1);
132
- }
133
-
121
+ function startAgent(config: TunnelConfig): void {
134
122
  const registry = new CapabilityRegistry();
135
123
  registry.register(createFilesystemCapability(config));
136
124
  registry.register(createShellCapability(config));
137
125
  registry.register(createDesktopCapability());
138
126
 
139
127
  clearScreen();
140
- await printStartup(config, registry.getCapabilityNames(), '0.1.0');
128
+ printStartup(config, registry.getCapabilityNames(), '0.1.2');
141
129
 
142
130
  const agent = new TunnelAgent(config, registry);
143
131
  agent.connect();
@@ -152,6 +140,152 @@ async function commandConnect(flags: Record<string, string>): Promise<void> {
152
140
  process.on('SIGINT', shutdown);
153
141
  }
154
142
 
143
+ function openBrowser(url: string): void {
144
+ try {
145
+ const plat = platform();
146
+ if (plat === 'darwin') execSync(`open "${url}"`);
147
+ else if (plat === 'win32') execSync(`start "" "${url}"`);
148
+ else execSync(`xdg-open "${url}"`);
149
+ } catch {}
150
+ }
151
+
152
+ const CONFIG_DIR = join(homedir(), '.agent-tunnel');
153
+ const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
154
+
155
+ function saveCredentials(tunnelId: string, token: string, apiUrl: string): void {
156
+ mkdirSync(CONFIG_DIR, { recursive: true });
157
+ let existing: Record<string, unknown> = {};
158
+ if (existsSync(CONFIG_FILE)) {
159
+ try { existing = JSON.parse(readFileSync(CONFIG_FILE, 'utf-8')); } catch {}
160
+ }
161
+ writeFileSync(CONFIG_FILE, JSON.stringify({ ...existing, tunnelId, token, apiUrl }, null, 2));
162
+ }
163
+
164
+ async function commandConnectDeviceAuth(config: TunnelConfig): Promise<void> {
165
+ console.log('');
166
+ console.log(` ${c.cyan}◆${c.reset} ${c.bold}Device Authorization${c.reset}`);
167
+ console.log('');
168
+
169
+ // Step 1: Create device auth request
170
+ let deviceCode: string;
171
+ let deviceSecret: string;
172
+ let verificationUrl: string;
173
+ let expiresAt: string;
174
+ let pollIntervalMs: number;
175
+
176
+ try {
177
+ const res = await fetch(`${config.apiUrl}/device-auth`, {
178
+ method: 'POST',
179
+ headers: { 'Content-Type': 'application/json' },
180
+ body: JSON.stringify({ machineHostname: hostname() }),
181
+ });
182
+ if (!res.ok) {
183
+ const text = await res.text().catch(() => '');
184
+ console.error(` ${c.red}✗${c.reset} Failed to create device auth request: ${res.status} ${text.slice(0, 200)}`);
185
+ process.exit(1);
186
+ }
187
+ const data = await res.json();
188
+ deviceCode = data.deviceCode;
189
+ deviceSecret = data.deviceSecret;
190
+ verificationUrl = data.verificationUrl;
191
+ expiresAt = data.expiresAt;
192
+ pollIntervalMs = data.pollIntervalMs || 2000;
193
+ } catch (err) {
194
+ console.error(` ${c.red}✗${c.reset} Failed to reach API at ${config.apiUrl}`);
195
+ process.exit(1);
196
+ return;
197
+ }
198
+
199
+ // Step 2: Display code and open browser
200
+ console.log(` ${c.dim}Code:${c.reset} ${c.bold}${c.white}${deviceCode}${c.reset}`);
201
+ console.log('');
202
+ console.log(` ${c.dim}Open this URL on any device to approve:${c.reset}`);
203
+ console.log(` ${c.cyan}${verificationUrl}${c.reset}`);
204
+ console.log('');
205
+
206
+ openBrowser(verificationUrl);
207
+
208
+ // Step 3: Poll for approval
209
+ const expiresAtMs = new Date(expiresAt).getTime();
210
+
211
+ while (true) {
212
+ const remaining = Math.max(0, Math.floor((expiresAtMs - Date.now()) / 1000));
213
+ if (remaining <= 0) {
214
+ console.log(`\n ${c.red}✗${c.reset} Authorization expired. Please try again.`);
215
+ process.exit(1);
216
+ }
217
+
218
+ const min = Math.floor(remaining / 60);
219
+ const sec = remaining % 60;
220
+ process.stdout.write(`\r ${c.dim}Waiting for approval... ${c.white}${min}:${sec.toString().padStart(2, '0')}${c.reset} `);
221
+
222
+ try {
223
+ const res = await fetch(`${config.apiUrl}/device-auth/${deviceCode}/status?secret=${deviceSecret}`);
224
+ if (res.ok) {
225
+ const data = await res.json();
226
+
227
+ if (data.status === 'approved' && data.tunnelId && data.token) {
228
+ process.stdout.write('\r' + ' '.repeat(60) + '\r');
229
+ console.log(` ${c.green}●${c.reset} ${c.bold}Authorized!${c.reset}`);
230
+ console.log('');
231
+
232
+ // Save credentials
233
+ saveCredentials(data.tunnelId, data.token, config.apiUrl);
234
+ console.log(` ${c.dim}Credentials saved to ${CONFIG_FILE}${c.reset}`);
235
+ console.log('');
236
+
237
+ // Connect with received credentials
238
+ const fullConfig = loadConfig({
239
+ token: data.token,
240
+ tunnelId: data.tunnelId,
241
+ apiUrl: config.apiUrl,
242
+ });
243
+ startAgent(fullConfig);
244
+ return;
245
+ }
246
+
247
+ if (data.status === 'denied') {
248
+ process.stdout.write('\r' + ' '.repeat(60) + '\r');
249
+ console.log(` ${c.red}✗${c.reset} Authorization denied.`);
250
+ process.exit(1);
251
+ }
252
+
253
+ if (data.status === 'expired') {
254
+ process.stdout.write('\r' + ' '.repeat(60) + '\r');
255
+ console.log(` ${c.red}✗${c.reset} Authorization expired. Please try again.`);
256
+ process.exit(1);
257
+ }
258
+ }
259
+ } catch {}
260
+
261
+ await sleep(pollIntervalMs);
262
+ }
263
+ }
264
+
265
+ async function commandConnect(flags: Record<string, string>): Promise<void> {
266
+ const config = loadConfig({
267
+ token: flags.token,
268
+ tunnelId: flags['tunnel-id'],
269
+ apiUrl: flags['api-url'],
270
+ });
271
+
272
+ // If both token and tunnelId are provided, connect directly
273
+ if (config.token && config.tunnelId) {
274
+ startAgent(config);
275
+ return;
276
+ }
277
+
278
+ // If neither is provided, use device auth flow
279
+ if (!config.token && !config.tunnelId) {
280
+ await commandConnectDeviceAuth(config);
281
+ return;
282
+ }
283
+
284
+ // Partial — error
285
+ console.error(`${c.red}${c.bold} error${c.reset} Provide both --token and --tunnel-id, or neither (for device auth)`);
286
+ process.exit(1);
287
+ }
288
+
155
289
  async function commandStatus(flags: Record<string, string>): Promise<void> {
156
290
  const config = loadConfig({
157
291
  token: flags.token,
@@ -192,13 +326,13 @@ function showHelp(): void {
192
326
  console.log(` ${c.bold}Usage${c.reset} ${c.dim}npx @kortix/agent-tunnel <command> [options]${c.reset}`);
193
327
  console.log('');
194
328
  console.log(`${c.gray} ── Commands ────────────────────────────────────────${c.reset}`);
195
- console.log(` ${c.cyan}connect${c.reset} Connect and start handling RPC requests`);
329
+ console.log(` ${c.cyan}connect${c.reset} Connect via device auth (opens browser)`);
196
330
  console.log(` ${c.cyan}status${c.reset} Check tunnel connection status`);
197
331
  console.log(` ${c.cyan}help${c.reset} Show this help message`);
198
332
  console.log('');
199
333
  console.log(`${c.gray} ── Options ─────────────────────────────────────────${c.reset}`);
200
- console.log(` ${c.white}--token${c.reset} ${c.dim}<token>${c.reset} API token ${c.dim}(or TUNNEL_TOKEN)${c.reset}`);
201
- console.log(` ${c.white}--tunnel-id${c.reset} ${c.dim}<id>${c.reset} Tunnel ID ${c.dim}(or TUNNEL_ID)${c.reset}`);
334
+ console.log(` ${c.white}--token${c.reset} ${c.dim}<token>${c.reset} Skip device auth, connect directly`);
335
+ console.log(` ${c.white}--tunnel-id${c.reset} ${c.dim}<id>${c.reset} Tunnel ID ${c.dim}(required with --token)${c.reset}`);
202
336
  console.log(` ${c.white}--api-url${c.reset} ${c.dim}<url>${c.reset} API URL ${c.dim}(default: http://localhost:8080)${c.reset}`);
203
337
  console.log('');
204
338
  console.log(` ${c.dim}Config: ~/.agent-tunnel/config.json${c.reset}`);
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Strategy: spin up a lightweight mock HTTP server that simulates the
5
5
  * tunnel relay API, then invoke the CLI as a child process via `bun run`
6
- * with KORTIX_API_URL pointing at the mock. Assert on JSON stdout,
6
+ * with TUNNEL_API_URL pointing at the mock. Assert on JSON stdout,
7
7
  * stderr, and exit codes.
8
8
  *
9
9
  * Run: bun test src/client/cli.test.ts
@@ -267,9 +267,9 @@ function runCli(
267
267
  const child = spawn("bun", cliArgs, {
268
268
  env: {
269
269
  ...process.env,
270
- KORTIX_API_URL: `http://localhost:${mockPort}`,
271
- KORTIX_TOKEN: "test-token",
272
- KORTIX_TUNNEL_ID: "",
270
+ TUNNEL_API_URL: `http://localhost:${mockPort}`,
271
+ TUNNEL_TOKEN: "test-token",
272
+ TUNNEL_ID: "",
273
273
  ...envOverrides,
274
274
  },
275
275
  cwd: dirname(dirname(CLI_PATH)),
@@ -669,7 +669,7 @@ describe("Agent Tunnel CLI", () => {
669
669
 
670
670
  test("permission denied returns structured response", async () => {
671
671
  const r = await runCli("fs_read", '{"path":"/etc/passwd"}', {
672
- KORTIX_API_URL: `http://localhost:${permPort}`,
672
+ TUNNEL_API_URL: `http://localhost:${permPort}`,
673
673
  });
674
674
  // CLI should output permission-required JSON (not crash)
675
675
  expect(r.json).not.toBeNull();
@@ -683,7 +683,7 @@ describe("Agent Tunnel CLI", () => {
683
683
  const r = await runCli(
684
684
  "shell",
685
685
  '{"command":"rm","args":["-rf","/"]}',
686
- { KORTIX_API_URL: `http://localhost:${permPort}` }
686
+ { TUNNEL_API_URL: `http://localhost:${permPort}` }
687
687
  );
688
688
  expect(r.json!.success).toBe(false);
689
689
  expect(r.json!.permissionRequired).toBe(true);
@@ -695,7 +695,7 @@ describe("Agent Tunnel CLI", () => {
695
695
  describe("server unreachable", () => {
696
696
  test("status with dead server returns error JSON", async () => {
697
697
  const r = await runCli("status", undefined, {
698
- KORTIX_API_URL: "http://localhost:1",
698
+ TUNNEL_API_URL: "http://localhost:1",
699
699
  });
700
700
  expect(r.exitCode).toBe(1);
701
701
  expect(r.json).not.toBeNull();
@@ -705,7 +705,7 @@ describe("Agent Tunnel CLI", () => {
705
705
 
706
706
  test("fs_read with dead server returns error JSON", async () => {
707
707
  const r = await runCli("fs_read", '{"path":"/tmp/x"}', {
708
- KORTIX_API_URL: "http://localhost:1",
708
+ TUNNEL_API_URL: "http://localhost:1",
709
709
  });
710
710
  expect(r.exitCode).toBe(1);
711
711
  expect(r.json!.success).toBe(false);
@@ -738,7 +738,7 @@ describe("Agent Tunnel CLI", () => {
738
738
 
739
739
  test("status with no connections returns empty list", async () => {
740
740
  const r = await runCli("status", undefined, {
741
- KORTIX_API_URL: `http://localhost:${emptyPort}`,
741
+ TUNNEL_API_URL: `http://localhost:${emptyPort}`,
742
742
  });
743
743
  expect(r.exitCode).toBe(0);
744
744
  expect(r.json!.success).toBe(true);
@@ -748,7 +748,7 @@ describe("Agent Tunnel CLI", () => {
748
748
 
749
749
  test("fs_read with no connections returns error", async () => {
750
750
  const r = await runCli("fs_read", '{"path":"/tmp/x"}', {
751
- KORTIX_API_URL: `http://localhost:${emptyPort}`,
751
+ TUNNEL_API_URL: `http://localhost:${emptyPort}`,
752
752
  });
753
753
  expect(r.exitCode).toBe(1);
754
754
  expect(r.json!.success).toBe(false);
package/src/client/cli.ts CHANGED
@@ -1,8 +1,7 @@
1
- #!/usr/bin/env bun
2
1
  /**
3
2
  * Agent Tunnel CLI — interact with the user's local machine via Agent Tunnel.
4
3
  *
5
- * Usage: bun run cli.ts <command> [args as JSON]
4
+ * Usage: agent-tunnel-cli <command> [args as JSON]
6
5
  *
7
6
  * Commands:
8
7
  * status — list all tunnel connections
@@ -56,15 +55,15 @@ function getEnv(key: string): string | undefined {
56
55
  const FALLBACK_API_URL = "http://localhost:8008";
57
56
 
58
57
  function getApiBase(): string {
59
- const raw = getEnv("KORTIX_API_URL") || FALLBACK_API_URL;
58
+ const raw = getEnv("TUNNEL_API_URL") || FALLBACK_API_URL;
60
59
  const url = raw.startsWith("http") ? raw : FALLBACK_API_URL;
61
60
  return url.replace(/\/+$/, "");
62
61
  }
63
62
 
64
63
  const client = new TunnelClient({
65
64
  apiUrl: `${getApiBase()}/v1/tunnel`,
66
- token: getEnv("KORTIX_TOKEN") || "",
67
- tunnelId: getEnv("KORTIX_TUNNEL_ID"),
65
+ token: getEnv("TUNNEL_TOKEN") || "",
66
+ tunnelId: getEnv("TUNNEL_ID"),
68
67
  });
69
68
 
70
69
  // ── Helpers ───────────────────────────────────────────────────────────────
@@ -619,7 +618,7 @@ const [cmd, rawArgs] = process.argv.slice(2);
619
618
 
620
619
  if (!cmd) {
621
620
  console.error(
622
- `Usage: bun run cli.ts <command> [args as JSON]\n\nAvailable: ${ALL_COMMANDS.join(" | ")}`
621
+ `Usage: agent-tunnel-cli <command> [args as JSON]\n\nAvailable: ${ALL_COMMANDS.join(" | ")}`
623
622
  );
624
623
  process.exit(1);
625
624
  }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * WebSocket polyfill for Node.js < 22.
3
+ * If global WebSocket is already available (Node 22+, Bun, browsers), this is a no-op.
4
+ * Otherwise, it loads the `ws` package and assigns it to globalThis.WebSocket.
5
+ */
6
+ if (typeof globalThis.WebSocket === 'undefined') {
7
+ try {
8
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
9
+ const ws = require('ws');
10
+ globalThis.WebSocket = ws.default || ws;
11
+ } catch {
12
+ console.error(
13
+ '[agent-tunnel] WebSocket is not available. Install the "ws" package or use Node.js 22+.',
14
+ );
15
+ process.exit(1);
16
+ }
17
+ }
@@ -81,6 +81,11 @@ export function createWsHandlers(relay: TunnelRelay, opts?: WsHandlerOptions): W
81
81
  return;
82
82
  }
83
83
 
84
+ // Send signing key to agent so it never needs the server secret
85
+ try {
86
+ pending.ws.send(JSON.stringify({ type: 'auth_ok', signingKey: result.signingKey }));
87
+ } catch {}
88
+
84
89
  relay.registerAgent(tunnelId, pending.ws, result.signingKey, result.metadata);
85
90
  if (heartbeat) {
86
91
  heartbeat.register(tunnelId);
@@ -1,6 +1,5 @@
1
1
  import { createHash, createHmac, timingSafeEqual, randomBytes } from 'crypto';
2
2
 
3
- const SIGNING_KEY_CONTEXT = 'kortix-tunnel-signing-v1';
4
3
  const CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
5
4
 
6
5
  function randomAlphanumeric(length: number): string {
@@ -12,8 +11,8 @@ function randomAlphanumeric(length: number): string {
12
11
  return result;
13
12
  }
14
13
 
15
- export function deriveSigningKey(token: string): string {
16
- return createHmac('sha256', SIGNING_KEY_CONTEXT)
14
+ export function deriveSigningKey(token: string, secret: string): string {
15
+ return createHmac('sha256', secret)
17
16
  .update(token)
18
17
  .digest('hex');
19
18
  }