@kortix/agent-tunnel 0.13.46 → 0.13.48

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 CHANGED
@@ -2971,7 +2971,7 @@ if (typeof globalThis.WebSocket === "undefined") {
2971
2971
  }
2972
2972
 
2973
2973
  // src/agent/cli.ts
2974
- import { existsSync as existsSync7, readFileSync as readFileSync10, rmSync as rmSync5, writeFileSync as writeFileSync7 } from "fs";
2974
+ import { existsSync as existsSync7, readFileSync as readFileSync11, rmSync as rmSync5, writeFileSync as writeFileSync7 } from "fs";
2975
2975
  import { hostname as hostname4 } from "os";
2976
2976
  import { join as join12 } from "path";
2977
2977
 
@@ -4036,6 +4036,8 @@ var LIVENESS_TIMEOUT_MS = 75000;
4036
4036
  var WATCHDOG_INTERVAL_MS = 5000;
4037
4037
  var CLOCK_JUMP_MS = 30000;
4038
4038
  var REJECTED_RETRY_MS = 5 * 60000;
4039
+ var PAIR_AGAIN_COMMAND = "npx @kortix/agent-tunnel@latest connect --reauth";
4040
+ var NO_LONGER_CONNECTED = "This computer is no longer connected to Kortix (disconnected, or removed after 30 days offline).";
4039
4041
  var STANDBY_RETRY_MS = 60000;
4040
4042
  var MAX_STANDBY_RETRY_MS = 30 * 60000;
4041
4043
  var CONNECT_DEADLINE_MS = 20000;
@@ -4196,7 +4198,7 @@ class TunnelAgent {
4196
4198
  this.setStatus("rejected");
4197
4199
  if (this.now() - this.lastRejectedLogAt >= REJECTED_LOG_EVERY_MS) {
4198
4200
  this.lastRejectedLogAt = this.now();
4199
- log(`${c.red}✗${c.reset}`, `Credential rejected — waiting for this computer to be paired again (retrying every 5 min)`);
4201
+ log(`${c.red}✗${c.reset}`, `${NO_LONGER_CONNECTED} Connect it again from the Kortix desktop app, or run \`${PAIR_AGAIN_COMMAND}\`. Checking again every 5 min.`);
4200
4202
  }
4201
4203
  this.retryAfter(this.options.rejectedRetryMs ?? REJECTED_RETRY_MS);
4202
4204
  return;
@@ -4210,13 +4212,13 @@ class TunnelAgent {
4210
4212
  }
4211
4213
  if (event.code === 4001 && isCredentialRejection(event.code, event.reason)) {
4212
4214
  this.isShuttingDown = true;
4213
- log(`${c.red}✗${c.reset}`, `Credential rejected — run \`agent-tunnel connect --reauth\` to pair again`);
4215
+ log(`${c.red}✗${c.reset}`, `${NO_LONGER_CONNECTED} Connect it again: \`${PAIR_AGAIN_COMMAND}\``);
4214
4216
  this.hooks.onTerminalClose?.({ code: event.code, reason: "credential-rejected" });
4215
4217
  return;
4216
4218
  }
4217
4219
  if (event.code === 4003) {
4218
4220
  this.isShuttingDown = true;
4219
- log(`${c.red}✗${c.reset}`, `Device credential was revoked — run \`agent-tunnel connect --reauth\` to pair again`);
4221
+ log(`${c.red}✗${c.reset}`, `${NO_LONGER_CONNECTED} Connect it again: \`${PAIR_AGAIN_COMMAND}\``);
4220
4222
  this.hooks.onTerminalClose?.({ code: event.code, reason: "credential-rejected" });
4221
4223
  return;
4222
4224
  }
@@ -4256,7 +4258,7 @@ class TunnelAgent {
4256
4258
  this.signingKey = msg.signingKey;
4257
4259
  const capabilityNames = this.registry.getCapabilityNames();
4258
4260
  if (capabilityNames.length === 0) {
4259
- log(`${c.yellow}!${c.reset}`, `Connected, but no capabilities are enabled — this tunnel cannot do anything. Run \`agent-tunnel connect --reauth\` to pair again.`);
4261
+ log(`${c.yellow}!${c.reset}`, `Connected, but no capabilities are enabled — this tunnel cannot do anything. Run \`${PAIR_AGAIN_COMMAND}\` to pair again.`);
4260
4262
  } else {
4261
4263
  log(`${c.green}●${c.reset}`, `Connected ${c.reset}${c.gray}(${capabilityNames.join(", ")})${c.reset}`);
4262
4264
  }
@@ -4669,9 +4671,9 @@ class CapabilityRegistry {
4669
4671
 
4670
4672
  // src/agent/capabilities/desktop/cua-driver.ts
4671
4673
  import { spawn as spawn4 } from "child_process";
4672
- import { existsSync as existsSync3, realpathSync, statSync as statSync2 } from "fs";
4673
- import { homedir as homedir3, platform as platform4 } from "os";
4674
- import { join as join5 } from "path";
4674
+ import { existsSync as existsSync3, readFileSync as readFileSync6, realpathSync, statSync as statSync2 } from "fs";
4675
+ import { homedir as homedir3, platform as platform4, tmpdir } from "os";
4676
+ import { basename as basename2, dirname as dirname3, join as join5 } from "path";
4675
4677
  var MAX_DRIVER_OUTPUT_BYTES = 5 * 1024 * 1024;
4676
4678
  var DRIVER_ENV_KEYS = [
4677
4679
  "PATH",
@@ -4687,9 +4689,25 @@ var DRIVER_ENV_KEYS = [
4687
4689
  "XAUTHORITY",
4688
4690
  "DBUS_SESSION_BUS_ADDRESS"
4689
4691
  ];
4692
+ function driverEnvironment(extra = {}) {
4693
+ const env = { CUA_DRIVER_RS_TELEMETRY_ENABLED: "0" };
4694
+ for (const key of DRIVER_ENV_KEYS) {
4695
+ if (process.env[key] !== undefined)
4696
+ env[key] = process.env[key];
4697
+ }
4698
+ return { ...env, ...extra };
4699
+ }
4700
+ function bundledCuaDriverPath(agentEntry = process.argv[1]) {
4701
+ if (!agentEntry || basename2(dirname3(agentEntry)) !== "agent-tunnel")
4702
+ return null;
4703
+ const exe = process.platform === "win32" ? "cua-driver.exe" : "cua-driver";
4704
+ const candidate = join5(dirname3(dirname3(agentEntry)), "cua-driver", exe);
4705
+ return existsSync3(candidate) ? candidate : null;
4706
+ }
4690
4707
  function candidateBins() {
4691
4708
  const candidates = [
4692
4709
  process.env.CUA_DRIVER_BIN,
4710
+ bundledCuaDriverPath(),
4693
4711
  join5(homedir3(), ".local", "bin", process.platform === "win32" ? "cua-driver.exe" : "cua-driver"),
4694
4712
  "/usr/local/bin/cua-driver",
4695
4713
  "/opt/homebrew/bin/cua-driver"
@@ -4720,19 +4738,19 @@ function findCuaDriverBinary() {
4720
4738
  }
4721
4739
  return null;
4722
4740
  }
4723
- function driverEnvironment() {
4724
- const env = {};
4725
- for (const key of DRIVER_ENV_KEYS) {
4726
- if (process.env[key] !== undefined)
4727
- env[key] = process.env[key];
4741
+ function hostBundleId() {
4742
+ try {
4743
+ const plist = readFileSync6(join5(dirname3(dirname3(process.execPath)), "Info.plist"), "utf8");
4744
+ return /<key>CFBundleIdentifier<\/key>\s*<string>([^<]+)<\/string>/.exec(plist)?.[1] ?? "";
4745
+ } catch {
4746
+ return "";
4728
4747
  }
4729
- return env;
4730
4748
  }
4731
- function execFile(cmd, args, timeoutMs = 30000) {
4749
+ function execFile(cmd, args, timeoutMs = 30000, env = driverEnvironment()) {
4732
4750
  return new Promise((resolve, reject) => {
4733
4751
  const proc = spawn4(cmd, args, {
4734
4752
  stdio: ["ignore", "pipe", "pipe"],
4735
- env: driverEnvironment()
4753
+ env
4736
4754
  });
4737
4755
  let stdout = "";
4738
4756
  let stderr = "";
@@ -4810,8 +4828,33 @@ function sanitizeArgs(args) {
4810
4828
  }
4811
4829
 
4812
4830
  class CuaDriver {
4813
- binary = null;
4831
+ binary;
4832
+ embedded;
4833
+ socketPath;
4834
+ hostBundleId;
4814
4835
  daemonReady = false;
4836
+ daemon = null;
4837
+ constructor(options = {}) {
4838
+ this.binary = options.binary ?? null;
4839
+ this.embedded = options.embedded;
4840
+ const uid = typeof process.getuid === "function" ? process.getuid() : "user";
4841
+ this.socketPath = options.socketPath ?? join5(tmpdir(), `kortix-cua-${uid}.sock`);
4842
+ this.hostBundleId = options.hostBundleId;
4843
+ }
4844
+ isEmbedded(binary) {
4845
+ return this.embedded ?? binary === bundledCuaDriverPath();
4846
+ }
4847
+ mode(binary) {
4848
+ if (!this.isEmbedded(binary))
4849
+ return { env: driverEnvironment(), socket: [] };
4850
+ return {
4851
+ env: driverEnvironment({
4852
+ CUA_DRIVER_EMBEDDED: "1",
4853
+ CUA_DRIVER_HOST_BUNDLE_ID: this.hostBundleId ?? hostBundleId()
4854
+ }),
4855
+ socket: ["--socket", this.socketPath]
4856
+ };
4857
+ }
4815
4858
  async ensureInstalled() {
4816
4859
  if (this.binary && existsSync3(this.binary))
4817
4860
  return this.binary;
@@ -4824,22 +4867,23 @@ class CuaDriver {
4824
4867
  }
4825
4868
  async version() {
4826
4869
  const bin = await this.ensureInstalled();
4827
- const { stdout } = await execFile(bin, ["--version"], 1e4);
4870
+ const { stdout } = await execFile(bin, ["--version"], 1e4, this.mode(bin).env);
4828
4871
  return stdout.trim();
4829
4872
  }
4830
4873
  async listTools() {
4831
4874
  const bin = await this.ensureInstalled();
4832
- const { stdout } = await execFile(bin, ["list-tools"], 1e4);
4875
+ const { stdout } = await execFile(bin, ["list-tools"], 1e4, this.mode(bin).env);
4833
4876
  return stdout.trim();
4834
4877
  }
4835
4878
  async describe(tool) {
4836
4879
  const bin = await this.ensureInstalled();
4837
- const { stdout } = await execFile(bin, ["describe", tool], 1e4);
4880
+ const { stdout } = await execFile(bin, ["describe", tool], 1e4, this.mode(bin).env);
4838
4881
  return stdout.trim();
4839
4882
  }
4840
4883
  async status() {
4841
4884
  const bin = await this.ensureInstalled();
4842
- const { stdout } = await execFile(bin, ["status"], 1e4);
4885
+ const { env, socket } = this.mode(bin);
4886
+ const { stdout } = await execFile(bin, ["status", ...socket], 1e4, env);
4843
4887
  return stdout.trim();
4844
4888
  }
4845
4889
  async call(tool, args = {}) {
@@ -4847,11 +4891,12 @@ class CuaDriver {
4847
4891
  throw new Error("CUA tool name is required");
4848
4892
  await this.ensureDaemonReady();
4849
4893
  const bin = await this.ensureInstalled();
4894
+ const { env, socket } = this.mode(bin);
4850
4895
  const payload = JSON.stringify(sanitizeArgs(args));
4851
4896
  let lastError;
4852
4897
  for (let attempt = 0;attempt < 4; attempt++) {
4853
4898
  try {
4854
- const { stdout, stderr } = await execFile(bin, ["call", tool, payload], 60000);
4899
+ const { stdout, stderr } = await execFile(bin, ["call", tool, payload, ...socket], 60000, env);
4855
4900
  if (isDaemonProxyFallback(stderr)) {
4856
4901
  lastError = new Error(stderr.trim());
4857
4902
  await sleep2(150 * (attempt + 1));
@@ -4871,13 +4916,16 @@ class CuaDriver {
4871
4916
  }
4872
4917
  async startDaemon() {
4873
4918
  const bin = await this.ensureInstalled();
4874
- if (platform4() === "darwin") {
4919
+ if (this.isEmbedded(bin)) {
4920
+ await this.startEmbeddedDaemon(bin);
4921
+ } else if (platform4() === "darwin") {
4875
4922
  const child = spawn4("open", ["-n", "-g", "-a", "CuaDriver", "--args", "serve"], {
4876
4923
  detached: true,
4877
4924
  stdio: "ignore",
4878
4925
  env: driverEnvironment()
4879
4926
  });
4880
4927
  child.unref();
4928
+ await sleep2(750);
4881
4929
  } else {
4882
4930
  const child = spawn4(bin, ["serve"], {
4883
4931
  detached: true,
@@ -4885,8 +4933,8 @@ class CuaDriver {
4885
4933
  env: driverEnvironment()
4886
4934
  });
4887
4935
  child.unref();
4936
+ await sleep2(750);
4888
4937
  }
4889
- await new Promise((resolve) => setTimeout(resolve, 750));
4890
4938
  try {
4891
4939
  const status = await this.status();
4892
4940
  this.daemonReady = true;
@@ -4895,6 +4943,38 @@ class CuaDriver {
4895
4943
  return { ok: true };
4896
4944
  }
4897
4945
  }
4946
+ async startEmbeddedDaemon(bin) {
4947
+ if (this.daemon && this.daemon.exitCode === null)
4948
+ return;
4949
+ const { env, socket } = this.mode(bin);
4950
+ const child = spawn4(bin, ["serve", "--embedded", "--no-permissions-gate", ...socket], {
4951
+ stdio: "ignore",
4952
+ env
4953
+ });
4954
+ child.unref();
4955
+ child.on("exit", () => {
4956
+ if (this.daemon === child) {
4957
+ this.daemon = null;
4958
+ this.daemonReady = false;
4959
+ }
4960
+ });
4961
+ this.daemon = child;
4962
+ process.once("exit", () => child.kill());
4963
+ for (let waited = 0;waited < 1e4 && !existsSync3(this.socketPath); waited += 50) {
4964
+ if (child.exitCode !== null)
4965
+ break;
4966
+ await sleep2(50);
4967
+ }
4968
+ if (!existsSync3(this.socketPath)) {
4969
+ child.kill();
4970
+ throw new Error("cua-driver did not start (no socket after 10 s)");
4971
+ }
4972
+ }
4973
+ stop() {
4974
+ this.daemon?.kill();
4975
+ this.daemon = null;
4976
+ this.daemonReady = false;
4977
+ }
4898
4978
  async ensureDaemonReady() {
4899
4979
  if (this.daemonReady)
4900
4980
  return;
@@ -5025,7 +5105,7 @@ function createDesktopCapability() {
5025
5105
  }
5026
5106
 
5027
5107
  // src/agent/capabilities/filesystem.ts
5028
- import { open, writeFile, readdir, stat, unlink, mkdir } from "fs/promises";
5108
+ import { open, writeFile, readdir, stat, lstat, unlink, rmdir, mkdir } from "fs/promises";
5029
5109
  import { createHash as createHash4 } from "node:crypto";
5030
5110
 
5031
5111
  // src/shared/filesystem-validation.ts
@@ -5059,10 +5139,10 @@ function validateFilesystemParams(method, params) {
5059
5139
  }
5060
5140
 
5061
5141
  // src/agent/capabilities/filesystem.ts
5062
- import { join as join7, dirname as dirname4 } from "path";
5142
+ import { join as join7, dirname as dirname5 } from "path";
5063
5143
 
5064
5144
  // src/agent/security/path-validator.ts
5065
- import { dirname as dirname3, basename as basename2, join as join6, resolve as resolve2, normalize, relative, isAbsolute as isAbsolute2 } from "path";
5145
+ import { dirname as dirname4, basename as basename3, join as join6, resolve as resolve2, normalize, relative, isAbsolute as isAbsolute2 } from "path";
5066
5146
  import { realpathSync as realpathSync2 } from "fs";
5067
5147
  function resolveExistingRoot(path) {
5068
5148
  const normalized = normalize(resolve2(path));
@@ -5081,10 +5161,10 @@ function resolvePathForValidation(path) {
5081
5161
  if (code !== "ENOENT") {
5082
5162
  throw new Error(`Access denied: cannot resolve path "${path}" (${code})`);
5083
5163
  }
5084
- const parent = dirname3(normalized);
5164
+ const parent = dirname4(normalized);
5085
5165
  if (parent === normalized)
5086
5166
  return normalized;
5087
- return join6(resolvePathForValidation(parent), basename2(normalized));
5167
+ return join6(resolvePathForValidation(parent), basename3(normalized));
5088
5168
  }
5089
5169
  }
5090
5170
  function assertAllowedResolvedPath(originalPath, resolved, allowedPaths, blockedPaths = []) {
@@ -5217,7 +5297,7 @@ function createFilesystemCapability(config) {
5217
5297
  if (params.sha256 !== undefined && params.sha256.toLowerCase() !== expectedHash) {
5218
5298
  throw new Error("SHA-256 mismatch: content differs from the source; destination was not modified");
5219
5299
  }
5220
- await mkdir(dirname4(path), { recursive: true });
5300
+ await mkdir(dirname5(path), { recursive: true });
5221
5301
  validateFilesystemPath(path, config, params, true);
5222
5302
  await writeFile(path, bytes);
5223
5303
  validateFilesystemPath(path, config, params);
@@ -5288,7 +5368,11 @@ function createFilesystemCapability(config) {
5288
5368
  assertFilesystemOperation(params, "fs.delete");
5289
5369
  const path = params.path;
5290
5370
  validateFilesystemPath(path, config, params);
5291
- await unlink(path);
5371
+ if ((await lstat(path)).isDirectory()) {
5372
+ await rmdir(path);
5373
+ } else {
5374
+ await unlink(path);
5375
+ }
5292
5376
  return { deleted: true, path };
5293
5377
  });
5294
5378
  return {
@@ -5437,14 +5521,14 @@ function createEnabledCapabilityRegistry(config, findDesktopDriver = findCuaDriv
5437
5521
  }
5438
5522
 
5439
5523
  // src/agent/credential-store.ts
5440
- import { chmodSync as chmodSync3, existsSync as existsSync4, mkdirSync as mkdirSync2, readFileSync as readFileSync6, renameSync as renameSync2, rmSync as rmSync2, writeFileSync as writeFileSync3 } from "fs";
5524
+ import { chmodSync as chmodSync3, existsSync as existsSync4, mkdirSync as mkdirSync2, readFileSync as readFileSync7, renameSync as renameSync2, rmSync as rmSync2, writeFileSync as writeFileSync3 } from "fs";
5441
5525
  import { join as join8 } from "path";
5442
5526
  var CONFIG_DIR2 = agentTunnelHome();
5443
5527
  var CONFIG_FILE2 = join8(CONFIG_DIR2, "config.json");
5444
5528
  var PAIRING_FIELDS = ["token", "tunnelId", "enabledCapabilities"];
5445
5529
  function readConfigFile() {
5446
5530
  try {
5447
- const parsed = JSON.parse(readFileSync6(CONFIG_FILE2, "utf-8"));
5531
+ const parsed = JSON.parse(readFileSync7(CONFIG_FILE2, "utf-8"));
5448
5532
  return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
5449
5533
  } catch {
5450
5534
  return {};
@@ -5479,7 +5563,7 @@ function clearSavedCredentials() {
5479
5563
  return false;
5480
5564
  let existing;
5481
5565
  try {
5482
- existing = JSON.parse(readFileSync6(CONFIG_FILE2, "utf-8"));
5566
+ existing = JSON.parse(readFileSync7(CONFIG_FILE2, "utf-8"));
5483
5567
  } catch {
5484
5568
  rmSync2(CONFIG_FILE2, { force: true });
5485
5569
  return true;
@@ -5599,15 +5683,15 @@ async function promptYesNo(question, defaultValue) {
5599
5683
  }
5600
5684
 
5601
5685
  // src/agent/service.ts
5602
- import { chmodSync as chmodSync4, copyFileSync, existsSync as existsSync6, mkdirSync as mkdirSync4, readFileSync as readFileSync8, realpathSync as realpathSync3, rmSync as rmSync4, writeFileSync as writeFileSync5 } from "fs";
5686
+ import { chmodSync as chmodSync4, copyFileSync, existsSync as existsSync6, mkdirSync as mkdirSync4, readFileSync as readFileSync9, realpathSync as realpathSync3, rmSync as rmSync4, writeFileSync as writeFileSync5 } from "fs";
5603
5687
  import { platform as platform6 } from "os";
5604
5688
  import { join as join10 } from "path";
5605
5689
 
5606
5690
  // src/agent/service-drivers.ts
5607
5691
  import { spawnSync } from "child_process";
5608
- import { existsSync as existsSync5, mkdirSync as mkdirSync3, readFileSync as readFileSync7, rmSync as rmSync3, writeFileSync as writeFileSync4 } from "fs";
5692
+ import { existsSync as existsSync5, mkdirSync as mkdirSync3, readFileSync as readFileSync8, rmSync as rmSync3, writeFileSync as writeFileSync4 } from "fs";
5609
5693
  import { homedir as homedir4, platform as platform5, userInfo } from "os";
5610
- import { dirname as dirname5, join as join9 } from "path";
5694
+ import { dirname as dirname6, join as join9 } from "path";
5611
5695
 
5612
5696
  // src/agent/service-quoting.ts
5613
5697
  function shellQuote(value) {
@@ -5734,7 +5818,7 @@ var launchd = {
5734
5818
  unitPath: (paths) => paths.launchdPlist,
5735
5819
  render: (paths, runner) => renderLaunchdPlist(posixShellCommand(runner), paths),
5736
5820
  install(paths, runner) {
5737
- mkdirSync3(dirname5(paths.launchdPlist), { recursive: true });
5821
+ mkdirSync3(dirname6(paths.launchdPlist), { recursive: true });
5738
5822
  writeFileSync4(paths.launchdPlist, launchd.render(paths, runner), { mode: 384 });
5739
5823
  run("launchctl", ["bootout", launchdTarget(), paths.launchdPlist]);
5740
5824
  run("launchctl", ["enable", `${launchdTarget()}/${paths.label}`]);
@@ -5785,7 +5869,7 @@ var launchd = {
5785
5869
  return {
5786
5870
  active: status.ok,
5787
5871
  enabled,
5788
- detail: status.detail || (installed ? readFileSync7(paths.launchdPlist, "utf8") : undefined)
5872
+ detail: status.detail || (installed ? readFileSync8(paths.launchdPlist, "utf8") : undefined)
5789
5873
  };
5790
5874
  }
5791
5875
  };
@@ -5793,7 +5877,7 @@ var systemd = {
5793
5877
  unitPath: (paths) => paths.systemdUnit,
5794
5878
  render: (paths, runner) => renderSystemdUnit(posixShellCommand(runner), paths),
5795
5879
  install(paths, runner) {
5796
- mkdirSync3(dirname5(paths.systemdUnit), { recursive: true });
5880
+ mkdirSync3(dirname6(paths.systemdUnit), { recursive: true });
5797
5881
  writeFileSync4(paths.systemdUnit, systemd.render(paths, runner), { mode: 384 });
5798
5882
  const reload = run("systemctl", ["--user", "daemon-reload"]);
5799
5883
  const enable = run("systemctl", ["--user", "enable", `${paths.label}.service`]);
@@ -5916,7 +6000,7 @@ var scheduledTask = {
5916
6000
  return {
5917
6001
  active: state === "Running",
5918
6002
  enabled: state !== "Disabled",
5919
- detail: state || (installed ? readFileSync7(paths.windowsScript, "utf8") : undefined)
6003
+ detail: state || (installed ? readFileSync8(paths.windowsScript, "utf8") : undefined)
5920
6004
  };
5921
6005
  }
5922
6006
  };
@@ -6037,7 +6121,7 @@ function getServiceStatus() {
6037
6121
  return withDriver((driver, paths, installed) => {
6038
6122
  let upToDate = false;
6039
6123
  try {
6040
- upToDate = installed && readFileSync8(driver.unitPath(paths), "utf8") === driver.render(paths, currentRunnerParts(plannedRunner));
6124
+ upToDate = installed && readFileSync9(driver.unitPath(paths), "utf8") === driver.render(paths, currentRunnerParts(plannedRunner));
6041
6125
  } catch {}
6042
6126
  return { ...driver.status(paths, installed), upToDate };
6043
6127
  }, { installed: false, active: null });
@@ -6086,7 +6170,7 @@ function renderServiceAction(action, status) {
6086
6170
  }
6087
6171
 
6088
6172
  // src/agent/state-file.ts
6089
- import { chmodSync as chmodSync5, mkdirSync as mkdirSync5, readFileSync as readFileSync9, renameSync as renameSync3, writeFileSync as writeFileSync6 } from "fs";
6173
+ import { chmodSync as chmodSync5, mkdirSync as mkdirSync5, readFileSync as readFileSync10, renameSync as renameSync3, writeFileSync as writeFileSync6 } from "fs";
6090
6174
  import { join as join11 } from "path";
6091
6175
  function stateFilePath(home = agentTunnelHome()) {
6092
6176
  return join11(home, "state.json");
@@ -6113,7 +6197,7 @@ function writeAgentState(status, tunnel, home = agentTunnelHome()) {
6113
6197
  }
6114
6198
  function readAgentState(home = agentTunnelHome()) {
6115
6199
  try {
6116
- const parsed = JSON.parse(readFileSync9(stateFilePath(home), "utf8"));
6200
+ const parsed = JSON.parse(readFileSync10(stateFilePath(home), "utf8"));
6117
6201
  return parsed && typeof parsed === "object" && typeof parsed.status === "string" ? parsed : null;
6118
6202
  } catch {
6119
6203
  return null;
@@ -6393,7 +6477,7 @@ function commandRun(flags) {
6393
6477
  }
6394
6478
  function lastServiceActivity() {
6395
6479
  try {
6396
- const lines = readFileSync10(join12(getServicePaths().logDir, "agent-tunnel.out.log"), "utf8").split(/\r?\n/).map((line) => stripAnsi(line).trim()).filter((line) => line.length > 0);
6480
+ const lines = readFileSync11(join12(getServicePaths().logDir, "agent-tunnel.out.log"), "utf8").split(/\r?\n/).map((line) => stripAnsi(line).trim()).filter((line) => line.length > 0);
6397
6481
  return lines.at(-1) ?? null;
6398
6482
  } catch {
6399
6483
  return null;
@@ -6443,7 +6527,7 @@ function commandStatus(flags) {
6443
6527
  blankLine();
6444
6528
  if (approved.size === 0) {
6445
6529
  console.log(` ${glyph.warn} ${c.dim}No capabilities approved — this tunnel cannot act.${c.reset}`);
6446
- console.log(` ${c.dim}Pair again with${c.reset} ${c.white}agent-tunnel connect --reauth${c.reset}`);
6530
+ console.log(` ${c.dim}Pair again with${c.reset} ${c.white}${PAIR_AGAIN_COMMAND}${c.reset}`);
6447
6531
  blankLine();
6448
6532
  }
6449
6533
  console.log(` ${c.dim}Recent logs:${c.reset} ${c.white}agent-tunnel logs${c.reset}`);
@@ -6515,7 +6599,7 @@ function commandLogs(flags) {
6515
6599
  console.log(` ${c.dim}not created yet${c.reset}`);
6516
6600
  continue;
6517
6601
  }
6518
- const kept = readFileSync10(file, "utf8").split(/\r?\n/).map((line) => line.trimEnd()).filter((line) => line.trim().length > 0).filter((line) => showAll || !isShellStartupNoise(line));
6602
+ const kept = readFileSync11(file, "utf8").split(/\r?\n/).map((line) => line.trimEnd()).filter((line) => line.trim().length > 0).filter((line) => showAll || !isShellStartupNoise(line));
6519
6603
  const lines = collapseRepeatedLines(kept).slice(-limit);
6520
6604
  if (lines.length === 0) {
6521
6605
  console.log(` ${c.dim}empty${c.reset}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kortix/agent-tunnel",
3
- "version": "0.13.46",
3
+ "version": "0.13.48",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/kortix-ai/suna.git",
@@ -246,6 +246,30 @@ describe('service mode never gives up (R2)', () => {
246
246
  expect(statuses).toContain('rejected');
247
247
  });
248
248
 
249
+ test('a removed computer says so, and names the one command that pairs it again', async () => {
250
+ FakeRelay.sockets = [];
251
+ globalThis.WebSocket = closingWebSocket(4001, 'authentication failed') as unknown as typeof WebSocket;
252
+ const Socket = globalThis.WebSocket as unknown as { created: number };
253
+ Socket.created = 0;
254
+ const agent = new TunnelAgent(loadConfig(TEST_CONFIG), new CapabilityRegistry(), {}, { persistent: true, rejectedRetryMs: 60 });
255
+ const output: string[] = [];
256
+ const originalWrite = process.stdout.write;
257
+ process.stdout.write = ((chunk: string | Uint8Array) => {
258
+ output.push(String(chunk));
259
+ return true;
260
+ }) as typeof process.stdout.write;
261
+ try {
262
+ agent.connect();
263
+ await until(() => Socket.created >= 2);
264
+ } finally {
265
+ agent.disconnect();
266
+ process.stdout.write = originalWrite;
267
+ }
268
+ const text = output.join('');
269
+ expect(text).toContain('This computer is no longer connected to Kortix');
270
+ expect(text).toContain('npx @kortix/agent-tunnel@latest connect --reauth');
271
+ });
272
+
249
273
  test('a re-pair while `rejected` reconnects at once, without waiting out the re-probe', async () => {
250
274
  globalThis.WebSocket = closingWebSocket(4001, 'authentication failed') as unknown as typeof WebSocket;
251
275
  const Socket = globalThis.WebSocket as unknown as { created: number };
@@ -103,6 +103,14 @@ const WATCHDOG_INTERVAL_MS = 5_000;
103
103
  /** A tick that arrives this much later than scheduled means the machine slept. */
104
104
  const CLOCK_JUMP_MS = 30_000;
105
105
  const REJECTED_RETRY_MS = 5 * 60_000;
106
+ /**
107
+ * The one command that pairs this computer again. An npx user has no
108
+ * `agent-tunnel` on PATH, so the hint names the package. The relay removes a
109
+ * computer when its owner disconnects it, and a registration without a
110
+ * hardware id after 30 days without a sign of life.
111
+ */
112
+ export const PAIR_AGAIN_COMMAND = 'npx @kortix/agent-tunnel@latest connect --reauth';
113
+ const NO_LONGER_CONNECTED = 'This computer is no longer connected to Kortix (disconnected, or removed after 30 days offline).';
106
114
  /** Standby backs off from this, doubling, so two holders of one credential stop trading the socket. */
107
115
  const STANDBY_RETRY_MS = 60_000;
108
116
  const MAX_STANDBY_RETRY_MS = 30 * 60_000;
@@ -301,7 +309,10 @@ export class TunnelAgent {
301
309
  this.setStatus('rejected');
302
310
  if (this.now() - this.lastRejectedLogAt >= REJECTED_LOG_EVERY_MS) {
303
311
  this.lastRejectedLogAt = this.now();
304
- log(`${c.red}✗${c.reset}`, `Credential rejected — waiting for this computer to be paired again (retrying every 5 min)`);
312
+ log(
313
+ `${c.red}✗${c.reset}`,
314
+ `${NO_LONGER_CONNECTED} Connect it again from the Kortix desktop app, or run \`${PAIR_AGAIN_COMMAND}\`. Checking again every 5 min.`,
315
+ );
305
316
  }
306
317
  this.retryAfter(this.options.rejectedRetryMs ?? REJECTED_RETRY_MS);
307
318
  return;
@@ -315,13 +326,13 @@ export class TunnelAgent {
315
326
  }
316
327
  if (event.code === 4001 && isCredentialRejection(event.code, event.reason)) {
317
328
  this.isShuttingDown = true;
318
- log(`${c.red}✗${c.reset}`, `Credential rejected — run \`agent-tunnel connect --reauth\` to pair again`);
329
+ log(`${c.red}✗${c.reset}`, `${NO_LONGER_CONNECTED} Connect it again: \`${PAIR_AGAIN_COMMAND}\``);
319
330
  this.hooks.onTerminalClose?.({ code: event.code, reason: 'credential-rejected' });
320
331
  return; // Don't reconnect on auth failure
321
332
  }
322
333
  if (event.code === 4003) {
323
334
  this.isShuttingDown = true;
324
- log(`${c.red}✗${c.reset}`, `Device credential was revoked — run \`agent-tunnel connect --reauth\` to pair again`);
335
+ log(`${c.red}✗${c.reset}`, `${NO_LONGER_CONNECTED} Connect it again: \`${PAIR_AGAIN_COMMAND}\``);
325
336
  this.hooks.onTerminalClose?.({ code: event.code, reason: 'credential-rejected' });
326
337
  return;
327
338
  }
@@ -374,7 +385,7 @@ export class TunnelAgent {
374
385
  // Reporting a bare "Connected ()" hides that this tunnel is inert.
375
386
  log(
376
387
  `${c.yellow}!${c.reset}`,
377
- `Connected, but no capabilities are enabled — this tunnel cannot do anything. Run \`agent-tunnel connect --reauth\` to pair again.`,
388
+ `Connected, but no capabilities are enabled — this tunnel cannot do anything. Run \`${PAIR_AGAIN_COMMAND}\` to pair again.`,
378
389
  );
379
390
  } else {
380
391
  log(`${c.green}●${c.reset}`, `Connected ${c.reset}${c.gray}(${capabilityNames.join(', ')})${c.reset}`);
@@ -0,0 +1,88 @@
1
+ import { afterEach, describe, expect, test } from 'bun:test';
2
+ import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs';
3
+ import { tmpdir } from 'os';
4
+ import { join } from 'path';
5
+
6
+ import { bundledCuaDriverPath, CuaDriver } from './cua-driver';
7
+
8
+ const dirs: string[] = [];
9
+ afterEach(() => {
10
+ for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true });
11
+ });
12
+
13
+ function tempDir(): string {
14
+ const dir = mkdtempSync(join(tmpdir(), 'cua-driver-test-'));
15
+ dirs.push(dir);
16
+ return dir;
17
+ }
18
+
19
+ /** A stand-in driver: logs each invocation, and `serve` creates its socket. */
20
+ function fakeDriver(dir: string): { binary: string; log: string } {
21
+ const binary = join(dir, 'cua-driver');
22
+ const log = join(dir, 'calls.log');
23
+ writeFileSync(
24
+ binary,
25
+ `#!/bin/sh
26
+ echo "$* | embedded=$CUA_DRIVER_EMBEDDED telemetry=$CUA_DRIVER_RS_TELEMETRY_ENABLED host=$CUA_DRIVER_HOST_BUNDLE_ID" >> "${log}"
27
+ case "$1" in
28
+ serve)
29
+ while [ "$#" -gt 0 ]; do [ "$1" = "--socket" ] && touch "$2"; shift; done
30
+ sleep 30 ;;
31
+ status)
32
+ # Like the real driver: running only while a daemon listens on the socket.
33
+ if [ "$2" = "--socket" ] && [ ! -e "$3" ]; then echo "not running"; else echo "running"; fi ;;
34
+ call) echo '{"ok":true}' ;;
35
+ esac
36
+ `,
37
+ );
38
+ chmodSync(binary, 0o755);
39
+ return { binary, log };
40
+ }
41
+
42
+ describe('bundledCuaDriverPath', () => {
43
+ test('finds the driver the desktop app ships beside the agent bundle', () => {
44
+ const resources = join(tempDir(), 'Kortix.app', 'Contents', 'Resources');
45
+ mkdirSync(join(resources, 'agent-tunnel'), { recursive: true });
46
+ mkdirSync(join(resources, 'cua-driver'), { recursive: true });
47
+ const agent = join(resources, 'agent-tunnel', 'agent-cli.js');
48
+ writeFileSync(agent, '');
49
+ expect(bundledCuaDriverPath(agent)).toBeNull();
50
+ writeFileSync(join(resources, 'cua-driver', 'cua-driver'), '');
51
+ expect(bundledCuaDriverPath(agent)).toBe(join(resources, 'cua-driver', 'cua-driver'));
52
+ });
53
+
54
+ test('an npm install has no bundled driver', () => {
55
+ expect(bundledCuaDriverPath('/usr/local/lib/node_modules/@kortix/agent-tunnel/dist/agent-cli.js')).toBeNull();
56
+ expect(bundledCuaDriverPath(undefined)).toBeNull();
57
+ });
58
+ });
59
+
60
+ describe('embedded driver', () => {
61
+ test('starts its own private daemon as a child, never through LaunchServices, and calls through it', async () => {
62
+ const dir = tempDir();
63
+ const { binary, log } = fakeDriver(dir);
64
+ const socketPath = join(dir, 'cua.sock');
65
+ const driver = new CuaDriver({ binary, embedded: true, socketPath, hostBundleId: 'com.kortix.desktop' });
66
+ try {
67
+ expect(await driver.call('click', { pid: 1 })).toEqual({ ok: true });
68
+ const calls = readFileSync(log, 'utf8').trim().split('\n');
69
+ const serve = calls.find((line) => line.startsWith('serve '));
70
+ expect(serve).toBe(
71
+ `serve --embedded --no-permissions-gate --socket ${socketPath} | embedded=1 telemetry=0 host=com.kortix.desktop`,
72
+ );
73
+ expect(calls.at(-1)).toBe(
74
+ `call click {"pid":1} --socket ${socketPath} | embedded=1 telemetry=0 host=com.kortix.desktop`,
75
+ );
76
+ } finally {
77
+ driver.stop();
78
+ }
79
+ });
80
+
81
+ test('a separately installed driver keeps its standalone mode, without telemetry', async () => {
82
+ const dir = tempDir();
83
+ const { binary, log } = fakeDriver(dir);
84
+ const driver = new CuaDriver({ binary });
85
+ expect(await driver.status()).toBe('running');
86
+ expect(readFileSync(log, 'utf8').trim()).toBe('status | embedded= telemetry=0 host=');
87
+ });
88
+ });
@@ -1,7 +1,7 @@
1
- import { spawn } from 'child_process';
2
- import { existsSync, realpathSync, statSync } from 'fs';
3
- import { homedir, platform } from 'os';
4
- import { join } from 'path';
1
+ import { spawn, type ChildProcess } from 'child_process';
2
+ import { existsSync, readFileSync, realpathSync, statSync } from 'fs';
3
+ import { homedir, platform, tmpdir } from 'os';
4
+ import { basename, dirname, join } from 'path';
5
5
 
6
6
  interface ExecResult {
7
7
  stdout: string;
@@ -24,14 +24,37 @@ const DRIVER_ENV_KEYS = [
24
24
  'DBUS_SESSION_BUS_ADDRESS',
25
25
  ] as const;
26
26
 
27
+ /** The driver's own product telemetry stays off on a customer's machine. */
28
+ function driverEnvironment(extra: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv {
29
+ const env: NodeJS.ProcessEnv = { CUA_DRIVER_RS_TELEMETRY_ENABLED: '0' };
30
+ for (const key of DRIVER_ENV_KEYS) {
31
+ if (process.env[key] !== undefined) env[key] = process.env[key];
32
+ }
33
+ return { ...env, ...extra };
34
+ }
35
+
27
36
  export interface CuaToolCall {
28
37
  tool: string;
29
38
  args?: Record<string, unknown>;
30
39
  }
31
40
 
41
+ /**
42
+ * The driver the desktop app ships in its own bundle, beside the agent:
43
+ * `Resources/agent-tunnel/agent-cli.js` → `Resources/cua-driver/cua-driver`.
44
+ * Running it embedded keeps every macOS permission prompt and grant on the
45
+ * Kortix app, not on a separate CuaDriver app. `null` for an npm install.
46
+ */
47
+ export function bundledCuaDriverPath(agentEntry: string | undefined = process.argv[1]): string | null {
48
+ if (!agentEntry || basename(dirname(agentEntry)) !== 'agent-tunnel') return null;
49
+ const exe = process.platform === 'win32' ? 'cua-driver.exe' : 'cua-driver';
50
+ const candidate = join(dirname(dirname(agentEntry)), 'cua-driver', exe);
51
+ return existsSync(candidate) ? candidate : null;
52
+ }
53
+
32
54
  function candidateBins(): string[] {
33
55
  const candidates = [
34
56
  process.env.CUA_DRIVER_BIN,
57
+ bundledCuaDriverPath(),
35
58
  join(
36
59
  homedir(),
37
60
  '.local',
@@ -67,19 +90,26 @@ export function findCuaDriverBinary(): string | null {
67
90
  return null;
68
91
  }
69
92
 
70
- function driverEnvironment(): NodeJS.ProcessEnv {
71
- const env: NodeJS.ProcessEnv = {};
72
- for (const key of DRIVER_ENV_KEYS) {
73
- if (process.env[key] !== undefined) env[key] = process.env[key];
93
+ /** The `CFBundleIdentifier` of the app whose binary runs this agent, if any. */
94
+ function hostBundleId(): string {
95
+ try {
96
+ const plist = readFileSync(join(dirname(dirname(process.execPath)), 'Info.plist'), 'utf8');
97
+ return /<key>CFBundleIdentifier<\/key>\s*<string>([^<]+)<\/string>/.exec(plist)?.[1] ?? '';
98
+ } catch {
99
+ return '';
74
100
  }
75
- return env;
76
101
  }
77
102
 
78
- function execFile(cmd: string, args: string[], timeoutMs = 30_000): Promise<ExecResult> {
103
+ function execFile(
104
+ cmd: string,
105
+ args: string[],
106
+ timeoutMs = 30_000,
107
+ env: NodeJS.ProcessEnv = driverEnvironment(),
108
+ ): Promise<ExecResult> {
79
109
  return new Promise((resolve, reject) => {
80
110
  const proc = spawn(cmd, args, {
81
111
  stdio: ['ignore', 'pipe', 'pipe'],
82
- env: driverEnvironment(),
112
+ env,
83
113
  });
84
114
  let stdout = '';
85
115
  let stderr = '';
@@ -168,9 +198,59 @@ function sanitizeArgs(args: Record<string, unknown>): Record<string, unknown> {
168
198
  return sanitized;
169
199
  }
170
200
 
201
+ export interface CuaDriverOptions {
202
+ /** A resolved driver binary. Default: the bundled one, else an installed one. */
203
+ binary?: string;
204
+ /** Run the driver inside this app's macOS permissions. Default: the bundled driver. */
205
+ embedded?: boolean;
206
+ socketPath?: string;
207
+ hostBundleId?: string;
208
+ }
209
+
210
+ /**
211
+ * The local computer-use driver (trycua `cua-driver`).
212
+ *
213
+ * - Embedded (the driver the desktop app bundles): this agent spawns
214
+ * `serve --embedded` as its own child on a private socket. The agent runs as
215
+ * the Kortix app's binary, so macOS attributes Accessibility and Screen
216
+ * Recording to Kortix: one grant, one entry in System Settings, no CuaDriver
217
+ * app. The driver never shows its own permission UI (`--no-permissions-gate`);
218
+ * the desktop app asks for the grants.
219
+ * - Standalone (a separately installed CuaDriver.app): unchanged, it starts
220
+ * through LaunchServices and owns its own grants.
221
+ */
171
222
  export class CuaDriver {
172
- private binary: string | null = null;
223
+ private binary: string | null;
224
+ private readonly embedded: boolean | undefined;
225
+ private readonly socketPath: string;
226
+ private readonly hostBundleId: string | undefined;
173
227
  private daemonReady = false;
228
+ private daemon: ChildProcess | null = null;
229
+
230
+ constructor(options: CuaDriverOptions = {}) {
231
+ this.binary = options.binary ?? null;
232
+ this.embedded = options.embedded;
233
+ const uid = typeof process.getuid === 'function' ? process.getuid() : 'user';
234
+ // Under $TMPDIR: a unix socket path is capped at 104 bytes on macOS.
235
+ this.socketPath = options.socketPath ?? join(tmpdir(), `kortix-cua-${uid}.sock`);
236
+ this.hostBundleId = options.hostBundleId;
237
+ }
238
+
239
+ private isEmbedded(binary: string): boolean {
240
+ return this.embedded ?? binary === bundledCuaDriverPath();
241
+ }
242
+
243
+ /** Environment and trailing arguments every driver command needs in this mode. */
244
+ private mode(binary: string): { env: NodeJS.ProcessEnv; socket: string[] } {
245
+ if (!this.isEmbedded(binary)) return { env: driverEnvironment(), socket: [] };
246
+ return {
247
+ env: driverEnvironment({
248
+ CUA_DRIVER_EMBEDDED: '1',
249
+ CUA_DRIVER_HOST_BUNDLE_ID: this.hostBundleId ?? hostBundleId(),
250
+ }),
251
+ socket: ['--socket', this.socketPath],
252
+ };
253
+ }
174
254
 
175
255
  async ensureInstalled(): Promise<string> {
176
256
  if (this.binary && existsSync(this.binary)) return this.binary;
@@ -188,25 +268,26 @@ export class CuaDriver {
188
268
 
189
269
  async version(): Promise<string> {
190
270
  const bin = await this.ensureInstalled();
191
- const { stdout } = await execFile(bin, ['--version'], 10_000);
271
+ const { stdout } = await execFile(bin, ['--version'], 10_000, this.mode(bin).env);
192
272
  return stdout.trim();
193
273
  }
194
274
 
195
275
  async listTools(): Promise<string> {
196
276
  const bin = await this.ensureInstalled();
197
- const { stdout } = await execFile(bin, ['list-tools'], 10_000);
277
+ const { stdout } = await execFile(bin, ['list-tools'], 10_000, this.mode(bin).env);
198
278
  return stdout.trim();
199
279
  }
200
280
 
201
281
  async describe(tool: string): Promise<string> {
202
282
  const bin = await this.ensureInstalled();
203
- const { stdout } = await execFile(bin, ['describe', tool], 10_000);
283
+ const { stdout } = await execFile(bin, ['describe', tool], 10_000, this.mode(bin).env);
204
284
  return stdout.trim();
205
285
  }
206
286
 
207
287
  async status(): Promise<string> {
208
288
  const bin = await this.ensureInstalled();
209
- const { stdout } = await execFile(bin, ['status'], 10_000);
289
+ const { env, socket } = this.mode(bin);
290
+ const { stdout } = await execFile(bin, ['status', ...socket], 10_000, env);
210
291
  return stdout.trim();
211
292
  }
212
293
 
@@ -214,12 +295,13 @@ export class CuaDriver {
214
295
  if (!tool || typeof tool !== 'string') throw new Error('CUA tool name is required');
215
296
  await this.ensureDaemonReady();
216
297
  const bin = await this.ensureInstalled();
298
+ const { env, socket } = this.mode(bin);
217
299
  const payload = JSON.stringify(sanitizeArgs(args));
218
300
  let lastError: unknown;
219
301
 
220
302
  for (let attempt = 0; attempt < 4; attempt++) {
221
303
  try {
222
- const { stdout, stderr } = await execFile(bin, ['call', tool, payload], 60_000);
304
+ const { stdout, stderr } = await execFile(bin, ['call', tool, payload, ...socket], 60_000, env);
223
305
  if (isDaemonProxyFallback(stderr)) {
224
306
  lastError = new Error(stderr.trim());
225
307
  await sleep(150 * (attempt + 1));
@@ -242,13 +324,16 @@ export class CuaDriver {
242
324
  async startDaemon(): Promise<{ ok: true; status?: string }> {
243
325
  const bin = await this.ensureInstalled();
244
326
 
245
- if (platform() === 'darwin') {
327
+ if (this.isEmbedded(bin)) {
328
+ await this.startEmbeddedDaemon(bin);
329
+ } else if (platform() === 'darwin') {
246
330
  const child = spawn('open', ['-n', '-g', '-a', 'CuaDriver', '--args', 'serve'], {
247
331
  detached: true,
248
332
  stdio: 'ignore',
249
333
  env: driverEnvironment(),
250
334
  });
251
335
  child.unref();
336
+ await sleep(750);
252
337
  } else {
253
338
  const child = spawn(bin, ['serve'], {
254
339
  detached: true,
@@ -256,9 +341,9 @@ export class CuaDriver {
256
341
  env: driverEnvironment(),
257
342
  });
258
343
  child.unref();
344
+ await sleep(750);
259
345
  }
260
346
 
261
- await new Promise((resolve) => setTimeout(resolve, 750));
262
347
  try {
263
348
  const status = await this.status();
264
349
  this.daemonReady = true;
@@ -268,6 +353,43 @@ export class CuaDriver {
268
353
  }
269
354
  }
270
355
 
356
+ /**
357
+ * A direct child (never `open`, which would hand it to LaunchServices and
358
+ * out of this app's permission chain). It exits with the agent.
359
+ */
360
+ private async startEmbeddedDaemon(bin: string): Promise<void> {
361
+ if (this.daemon && this.daemon.exitCode === null) return;
362
+ const { env, socket } = this.mode(bin);
363
+ const child = spawn(bin, ['serve', '--embedded', '--no-permissions-gate', ...socket], {
364
+ stdio: 'ignore',
365
+ env,
366
+ });
367
+ child.unref();
368
+ child.on('exit', () => {
369
+ if (this.daemon === child) {
370
+ this.daemon = null;
371
+ this.daemonReady = false;
372
+ }
373
+ });
374
+ this.daemon = child;
375
+ process.once('exit', () => child.kill());
376
+ for (let waited = 0; waited < 10_000 && !existsSync(this.socketPath); waited += 50) {
377
+ if (child.exitCode !== null) break;
378
+ await sleep(50);
379
+ }
380
+ if (!existsSync(this.socketPath)) {
381
+ child.kill();
382
+ throw new Error('cua-driver did not start (no socket after 10 s)');
383
+ }
384
+ }
385
+
386
+ /** Stops an embedded daemon this agent started. Restarting picks up new grants. */
387
+ stop(): void {
388
+ this.daemon?.kill();
389
+ this.daemon = null;
390
+ this.daemonReady = false;
391
+ }
392
+
271
393
  private async ensureDaemonReady(): Promise<void> {
272
394
  if (this.daemonReady) return;
273
395
  try {
@@ -0,0 +1,109 @@
1
+ import { afterEach, expect, test } from 'bun:test';
2
+ import { lstat, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { createFilesystemCapability } from './filesystem';
6
+ import type { TunnelConfig } from '../config';
7
+
8
+ const roots: string[] = [];
9
+ afterEach(async () => {
10
+ await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true })));
11
+ });
12
+
13
+ async function fixture() {
14
+ const root = await mkdtemp(join(tmpdir(), 'tunnel-delete-'));
15
+ roots.push(root);
16
+ const config: TunnelConfig = {
17
+ token: '', tunnelId: '', apiUrl: 'http://localhost', wsPath: '/ws',
18
+ maxFileSize: 1024, allowedPaths: [root], blockedPaths: [],
19
+ allowedCommands: [], blockedCommands: [], workingDir: root,
20
+ shellTimeout: 1000, shellMaxTimeout: 1000, shellMaxOutputSize: 1024, shellEnvPassthrough: [],
21
+ };
22
+ const handler = createFilesystemCapability(config).methods.get('fs.delete');
23
+ if (!handler) throw new Error('fs.delete handler missing');
24
+ const permission = { permissionId: 'test', capability: 'filesystem', scope: { paths: [root], operations: ['delete'] } };
25
+ return { root, config, permission, deletePath: (path: string) => handler({ path, __permission: permission }) };
26
+ }
27
+
28
+ test('deletes an empty allowed directory and rejects a second deletion', async () => {
29
+ const { root, deletePath } = await fixture();
30
+ const path = join(root, 'empty');
31
+ await mkdir(path);
32
+ expect(await deletePath(path)).toEqual({ deleted: true, path });
33
+ await expect(lstat(path)).rejects.toMatchObject({ code: 'ENOENT' });
34
+ await expect(deletePath(path)).rejects.toMatchObject({ code: 'ENOENT' });
35
+ });
36
+
37
+ test('deletes an allowed file', async () => {
38
+ const { root, deletePath } = await fixture();
39
+ const path = join(root, 'file');
40
+ await writeFile(path, 'fixture');
41
+ expect(await deletePath(path)).toEqual({ deleted: true, path });
42
+ await expect(lstat(path)).rejects.toMatchObject({ code: 'ENOENT' });
43
+ });
44
+
45
+ test('rejects a non-empty directory without modifying its contents', async () => {
46
+ const { root, deletePath } = await fixture();
47
+ const path = join(root, 'non-empty');
48
+ await mkdir(path);
49
+ const child = join(path, 'keep');
50
+ await writeFile(child, 'preserve');
51
+ await expect(deletePath(path)).rejects.toThrow();
52
+ expect((await lstat(path)).isDirectory()).toBe(true);
53
+ expect(await readFile(child, 'utf8')).toBe('preserve');
54
+ });
55
+
56
+ test('rejects a missing path', async () => {
57
+ const { root, deletePath } = await fixture();
58
+ await expect(deletePath(join(root, 'missing'))).rejects.toMatchObject({ code: 'ENOENT' });
59
+ });
60
+
61
+ test('preserves directories outside the local ceiling and permission scope', async () => {
62
+ const fixtureA = await fixture();
63
+ const fixtureB = await fixture();
64
+ await expect(fixtureA.deletePath(fixtureB.root)).rejects.toThrow('outside allowed directories');
65
+ expect((await lstat(fixtureB.root)).isDirectory()).toBe(true);
66
+ const sibling = join(fixtureA.root, 'sibling');
67
+ await mkdir(sibling);
68
+ fixtureA.permission.scope.paths = [join(fixtureA.root, 'approved')];
69
+ await expect(fixtureA.deletePath(sibling)).rejects.toThrow('outside allowed directories');
70
+ expect((await lstat(sibling)).isDirectory()).toBe(true);
71
+ });
72
+
73
+ test('preserves blocked directories and directories without delete permission', async () => {
74
+ const { root, config, permission, deletePath } = await fixture();
75
+ const path = join(root, 'keep');
76
+ await mkdir(path);
77
+ config.blockedPaths.push(path);
78
+ await expect(deletePath(path)).rejects.toThrow('blocked path');
79
+ config.blockedPaths = [];
80
+ permission.scope.operations = ['read'];
81
+ await expect(deletePath(path)).rejects.toThrow('operation "delete" is not allowed');
82
+ expect((await lstat(path)).isDirectory()).toBe(true);
83
+ });
84
+
85
+ for (const kind of ['file', 'directory', 'missing']) {
86
+ test(`deletes an allowed ${kind} symlink without deleting its target`, async () => {
87
+ const { root, deletePath } = await fixture();
88
+ const target = join(root, 'target');
89
+ if (kind === 'file') await writeFile(target, 'preserve');
90
+ if (kind === 'directory') await mkdir(target);
91
+ const path = join(root, 'link');
92
+ await symlink(target, path);
93
+ expect(await deletePath(path)).toEqual({ deleted: true, path });
94
+ await expect(lstat(path)).rejects.toMatchObject({ code: 'ENOENT' });
95
+ if (kind === 'file') expect(await readFile(target, 'utf8')).toBe('preserve');
96
+ if (kind === 'directory') expect((await lstat(target)).isDirectory()).toBe(true);
97
+ if (kind === 'missing') await expect(lstat(target)).rejects.toMatchObject({ code: 'ENOENT' });
98
+ });
99
+ }
100
+
101
+ test('rejects an outside-target symlink and preserves both link and target', async () => {
102
+ const { root, deletePath } = await fixture();
103
+ const outside = await fixture();
104
+ const path = join(root, 'escape');
105
+ await symlink(outside.root, path);
106
+ await expect(deletePath(path)).rejects.toThrow('outside allowed directories');
107
+ expect((await lstat(path)).isSymbolicLink()).toBe(true);
108
+ expect((await lstat(outside.root)).isDirectory()).toBe(true);
109
+ });
@@ -5,7 +5,7 @@
5
5
  * even though the server already validates permissions.
6
6
  */
7
7
 
8
- import { open, writeFile, readdir, stat, unlink, mkdir } from 'fs/promises';
8
+ import { open, writeFile, readdir, stat, lstat, unlink, rmdir, mkdir } from 'fs/promises';
9
9
  import { createHash } from 'node:crypto';
10
10
  import { validateFilesystemParams } from '../../shared/filesystem-validation';
11
11
  import { join, dirname } from 'path';
@@ -243,7 +243,11 @@ export function createFilesystemCapability(config: TunnelConfig): Capability {
243
243
 
244
244
  validateFilesystemPath(path, config, params);
245
245
 
246
- await unlink(path);
246
+ if ((await lstat(path)).isDirectory()) {
247
+ await rmdir(path);
248
+ } else {
249
+ await unlink(path);
250
+ }
247
251
 
248
252
  return { deleted: true, path };
249
253
  });
@@ -1,5 +1,5 @@
1
1
  import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
2
- import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises';
2
+ import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
5
5
  import { protectedAgentPaths, type TunnelConfig } from '../config';
@@ -169,6 +169,47 @@ describe('local capability permission enforcement', () => {
169
169
  ).rejects.toThrow('desktop feature "mouse" is not allowed');
170
170
  });
171
171
 
172
+ test('desktop discovery dispatches to driver metadata commands and preserves unsupported output', async () => {
173
+ const binary = join(root, 'fake-discovery-driver');
174
+ const log = join(root, 'discovery-args.jsonl');
175
+ await writeFile(binary, [
176
+ '#!/usr/bin/env node',
177
+ `const fs = require('node:fs');`,
178
+ `fs.appendFileSync(${JSON.stringify(log)}, JSON.stringify(process.argv.slice(2)) + '\\n');`,
179
+ 'const [command, tool] = process.argv.slice(2);',
180
+ `if (command === 'list-tools') process.stdout.write('double_click\\n');`,
181
+ `else if (command === 'describe' && tool === 'double_click') process.stdout.write('Double click coordinates\\n');`,
182
+ `else if (command === 'describe') { process.stderr.write('Unsupported tool: ' + tool); process.exitCode = 2; }`,
183
+ `else { process.stderr.write('Unexpected command'); process.exitCode = 3; }`,
184
+ ].join('\n'));
185
+ await chmod(binary, 0o700);
186
+ const previousBinary = process.env.CUA_DRIVER_BIN;
187
+ process.env.CUA_DRIVER_BIN = binary;
188
+ try {
189
+ const capability = createDesktopCapability();
190
+ const list = capability.methods.get('desktop.cua.list_tools');
191
+ const describeTool = capability.methods.get('desktop.cua.describe');
192
+ if (!list || !describeTool) throw new Error('Discovery handlers missing');
193
+ const __permission = {
194
+ permissionId: 'permission-discovery', capability: 'desktop',
195
+ scope: { features: ['computer_use'] },
196
+ };
197
+ await expect(list({})).rejects.toThrow('desktop permission required');
198
+ await expect(list({ __permission: { ...__permission, scope: { features: ['screenshot'] } } }))
199
+ .rejects.toThrow('not allowed');
200
+ expect(await list({ __permission })).toEqual({ tools: 'double_click' });
201
+ expect(await describeTool({ tool: 'double_click', __permission }))
202
+ .toEqual({ description: 'Double click coordinates' });
203
+ await expect(describeTool({ tool: 'unsupported_tool', __permission }))
204
+ .rejects.toThrow('Unsupported tool: unsupported_tool');
205
+ expect((await readFile(log, 'utf8')).trim().split('\n').map((line) => JSON.parse(line)))
206
+ .toEqual([['list-tools'], ['describe', 'double_click'], ['describe', 'unsupported_tool']]);
207
+ } finally {
208
+ if (previousBinary === undefined) delete process.env.CUA_DRIVER_BIN;
209
+ else process.env.CUA_DRIVER_BIN = previousBinary;
210
+ }
211
+ });
212
+
172
213
  test('remote desktop calls cannot trigger mutable installer or update tools', async () => {
173
214
  const capability = createDesktopCapability();
174
215
  expect(capability.methods.has('desktop.cua.check_for_update')).toBe(false);
package/src/agent/cli.ts CHANGED
@@ -3,7 +3,7 @@ import { existsSync, readFileSync, rmSync, writeFileSync } from 'fs';
3
3
  import { hostname } from 'os';
4
4
  import { join } from 'path';
5
5
 
6
- import { TunnelAgent } from './agent';
6
+ import { PAIR_AGAIN_COMMAND, TunnelAgent } from './agent';
7
7
  import { accessFilePath, accessRequestPath, desktopAppPath, readAccess, writeAccess } from './access';
8
8
  import { printStartupBanner } from './banner';
9
9
  import { createEnabledCapabilityRegistry } from './capabilities/enabled-registry';
@@ -441,7 +441,7 @@ function commandStatus(flags: Flags): void {
441
441
 
442
442
  if (approved.size === 0) {
443
443
  console.log(` ${glyph.warn} ${c.dim}No capabilities approved — this tunnel cannot act.${c.reset}`);
444
- console.log(` ${c.dim}Pair again with${c.reset} ${c.white}agent-tunnel connect --reauth${c.reset}`);
444
+ console.log(` ${c.dim}Pair again with${c.reset} ${c.white}${PAIR_AGAIN_COMMAND}${c.reset}`);
445
445
  blankLine();
446
446
  }
447
447
  console.log(` ${c.dim}Recent logs:${c.reset} ${c.white}agent-tunnel logs${c.reset}`);