@kortix/agent-tunnel 0.14.2 → 0.14.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -69,7 +69,10 @@ It starts at login, and the supervisor restarts it after every exit (launchd
69
69
  `KeepAlive` with a 10 s throttle, systemd `Restart=always`, a 5 s Windows loop).
70
70
  On Linux, install enables `loginctl enable-linger` so the user service keeps
71
71
  running after logout and starts at boot; when that is not allowed, install
72
- prints a warning. The Windows task runs hidden, on battery, with no time limit.
72
+ prints a warning. The Windows task runs PowerShell under `conhost --headless`
73
+ (no console window, also where Windows Terminal is the default terminal), on
74
+ battery, with no time limit. Every process the agent starts on Windows sets
75
+ `windowsHide`, so no console window opens.
73
76
  The agent itself never stops on its own:
74
77
 
75
78
  - It reconnects with exponential backoff from 1 s to 30 s, ±20 % jitter, forever.
@@ -184,9 +187,10 @@ change to the agent as `tunnel.access.set`:
184
187
  A pairing made from the Kortix desktop app starts in `ask`: the app shows the
185
188
  prompt. A pairing made with this CLI alone starts in `always`, because nothing
186
189
  on the machine could answer a prompt. This is a deliberate deviation from
187
- "new pairings default to ask". Every new pairing resets `access.json`, so a
188
- grant or `always` from an earlier pairing never carries over, and `logout`
189
- deletes it. The agent home (`config.json`, `access.json`, `desktop-app.json`)
190
+ "new pairings default to ask". The mode and keep awake are the owner's
191
+ machine settings: pairing again (`--reauth`, a rejected or stale credential)
192
+ keeps them, and only a grant or a denial from the earlier pairing is dropped.
193
+ `logout` deletes `access.json`, so the next pairing starts from the default. The agent home (`config.json`, `access.json`, `desktop-app.json`)
190
194
  is always a blocked path: no file or shell call can change these files.
191
195
 
192
196
  The agent reports its state to Kortix as the signed notification
@@ -237,9 +241,16 @@ you trust with this computer.
237
241
 
238
242
  ## Computer Use driver
239
243
 
240
- Agent Tunnel never downloads or executes a desktop driver. Install `cua-driver`
241
- locally before enabling Computer Use. The tunnel uses an existing binary from
242
- `CUA_DRIVER_BIN`, `~/.local/bin`, `/usr/local/bin`, or `/opt/homebrew/bin`.
244
+ Agent Tunnel never downloads or executes a desktop driver. The Kortix desktop
245
+ app bundles `cua-driver` on macOS and Windows (x64), and the agent it runs uses
246
+ that copy. A CLI install uses an existing binary from `CUA_DRIVER_BIN`,
247
+ `~/.local/bin`, `/usr/local/bin`, or `/opt/homebrew/bin`.
248
+
249
+ An approved `desktop` registers whenever the driver is present. Missing macOS
250
+ grants do not unregister it: the agent reports `desktopPermissions` and the
251
+ setup asks for them. When the driver is missing or rejected (wrong owner,
252
+ group-writable), `desktop` does not register and the reason is reported as
253
+ `unavailableCapabilities.desktop` in `status --json` and in `machineInfo`.
243
254
  Treat that binary as trusted local code. Agent Tunnel does not verify or update
244
255
  it.
245
256
 
package/dist/agent-cli.js CHANGED
@@ -3521,6 +3521,15 @@ function writePrivateJson(file, home, value) {
3521
3521
  function writeAccess(state, home = agentTunnelHome()) {
3522
3522
  writePrivateJson(accessFilePath(home), home, state);
3523
3523
  }
3524
+ function accessAfterPairing(home, desktopApp) {
3525
+ const kept = existsSync2(accessFilePath(home)) ? readAccess(home) : null;
3526
+ return {
3527
+ mode: kept?.mode ?? (desktopApp ? "ask" : "always"),
3528
+ grantedUntil: null,
3529
+ deniedUntil: null,
3530
+ keepAwake: kept?.keepAwake ?? false
3531
+ };
3532
+ }
3524
3533
  function decideAccess(state, now = Date.now()) {
3525
3534
  if (state.mode === "always")
3526
3535
  return "run";
@@ -3808,7 +3817,7 @@ function machineDisplayName({
3808
3817
  os = platform2(),
3809
3818
  host = hostname(),
3810
3819
  env = process.env,
3811
- run = (command, args) => execFileSync(command, args, { encoding: "utf8", timeout: 1000, stdio: ["ignore", "pipe", "ignore"] })
3820
+ run = (command, args) => execFileSync(command, args, { encoding: "utf8", timeout: 1000, stdio: ["ignore", "pipe", "ignore"], windowsHide: true })
3812
3821
  } = {}) {
3813
3822
  const fallback = host.replace(/\.local$/i, "") || host;
3814
3823
  try {
@@ -3820,7 +3829,7 @@ function machineDisplayName({
3820
3829
  }
3821
3830
  function machineId({
3822
3831
  os = platform2(),
3823
- run = (command, args) => execFileSync(command, args, { encoding: "utf8", timeout: 2000, stdio: ["ignore", "pipe", "ignore"] }),
3832
+ run = (command, args) => execFileSync(command, args, { encoding: "utf8", timeout: 2000, stdio: ["ignore", "pipe", "ignore"], windowsHide: true }),
3824
3833
  read = (path) => readFileSync4(path, "utf8")
3825
3834
  } = {}) {
3826
3835
  const attempt = (source) => {
@@ -4465,7 +4474,8 @@ class TunnelAgent {
4465
4474
  osVersion: release(),
4466
4475
  agentVersion: AGENT_VERSION,
4467
4476
  homeDir: this.config.workingDir,
4468
- allowedPaths: this.config.allowedPaths
4477
+ allowedPaths: this.config.allowedPaths,
4478
+ unavailableCapabilities: Object.fromEntries(this.registry.unavailable)
4469
4479
  }
4470
4480
  }
4471
4481
  });
@@ -4590,7 +4600,7 @@ class TunnelAgent {
4590
4600
  const blocker = keepAwakeCommand(platform3(), process.pid);
4591
4601
  if (!blocker)
4592
4602
  return;
4593
- const child = spawn3(blocker.command, blocker.args, { stdio: "ignore" });
4603
+ const child = spawn3(blocker.command, blocker.args, { stdio: "ignore", windowsHide: true });
4594
4604
  child.on("error", () => {
4595
4605
  this.keepAwakeFailed = true;
4596
4606
  log(`${c.yellow}!${c.reset}`, `Keep awake is unavailable: ${blocker.command} could not start`);
@@ -4758,6 +4768,7 @@ async function printStartupBanner({
4758
4768
  // src/agent/capabilities/index.ts
4759
4769
  class CapabilityRegistry {
4760
4770
  capabilities = new Map;
4771
+ unavailable = new Map;
4761
4772
  register(capability) {
4762
4773
  this.capabilities.set(capability.name, capability);
4763
4774
  }
@@ -4783,7 +4794,7 @@ class CapabilityRegistry {
4783
4794
  // src/agent/capabilities/desktop/cua-driver.ts
4784
4795
  import { spawn as spawn4 } from "child_process";
4785
4796
  import { existsSync as existsSync3, readFileSync as readFileSync6, realpathSync, rmSync as rmSync2, statSync as statSync2 } from "fs";
4786
- import { homedir as homedir3, platform as platform5, tmpdir } from "os";
4797
+ import { homedir as homedir3, platform as platform5, tmpdir, userInfo } from "os";
4787
4798
  import { basename as basename2, dirname as dirname3, join as join5 } from "path";
4788
4799
  var MAX_DRIVER_OUTPUT_BYTES = 5 * 1024 * 1024;
4789
4800
  var DRIVER_ENV_KEYS = [
@@ -4798,7 +4809,19 @@ var DRIVER_ENV_KEYS = [
4798
4809
  "WAYLAND_DISPLAY",
4799
4810
  "XDG_RUNTIME_DIR",
4800
4811
  "XAUTHORITY",
4801
- "DBUS_SESSION_BUS_ADDRESS"
4812
+ "DBUS_SESSION_BUS_ADDRESS",
4813
+ "SystemRoot",
4814
+ "windir",
4815
+ "USERPROFILE",
4816
+ "USERNAME",
4817
+ "USERDOMAIN",
4818
+ "APPDATA",
4819
+ "LOCALAPPDATA",
4820
+ "ProgramData",
4821
+ "TEMP",
4822
+ "TMP",
4823
+ "ComSpec",
4824
+ "PATHEXT"
4802
4825
  ];
4803
4826
  function driverEnvironment(extra = {}) {
4804
4827
  const env = { CUA_DRIVER_RS_TELEMETRY_ENABLED: "0" };
@@ -4861,7 +4884,8 @@ function execFile(cmd, args, timeoutMs = 30000, env = driverEnvironment()) {
4861
4884
  return new Promise((resolve, reject) => {
4862
4885
  const proc = spawn4(cmd, args, {
4863
4886
  stdio: ["ignore", "pipe", "pipe"],
4864
- env
4887
+ env,
4888
+ windowsHide: true
4865
4889
  });
4866
4890
  let stdout = "";
4867
4891
  let stderr = "";
@@ -4948,9 +4972,8 @@ class CuaDriver {
4948
4972
  constructor(options = {}) {
4949
4973
  this.binary = options.binary ?? null;
4950
4974
  this.embedded = options.embedded;
4951
- const uid = typeof process.getuid === "function" ? process.getuid() : "user";
4952
4975
  const home = serviceLabelFor(agentTunnelHome()).slice(SERVICE_LABEL.length);
4953
- this.socketPath = options.socketPath ?? join5(tmpdir(), `kortix-cua-${uid}${home}.sock`);
4976
+ this.socketPath = options.socketPath ?? (process.platform === "win32" ? `\\\\.\\pipe\\kortix-cua-${userInfo().username}${home}` : join5(tmpdir(), `kortix-cua-${typeof process.getuid === "function" ? process.getuid() : "user"}${home}.sock`));
4954
4977
  this.hostBundleId = options.hostBundleId;
4955
4978
  }
4956
4979
  isEmbedded(binary) {
@@ -4959,10 +4982,11 @@ class CuaDriver {
4959
4982
  mode(binary) {
4960
4983
  if (!this.isEmbedded(binary))
4961
4984
  return { env: driverEnvironment(), socket: [] };
4985
+ const bundleId = this.hostBundleId ?? hostBundleId();
4962
4986
  return {
4963
4987
  env: driverEnvironment({
4964
4988
  CUA_DRIVER_EMBEDDED: "1",
4965
- CUA_DRIVER_HOST_BUNDLE_ID: this.hostBundleId ?? hostBundleId()
4989
+ ...bundleId ? { CUA_DRIVER_HOST_BUNDLE_ID: bundleId } : {}
4966
4990
  }),
4967
4991
  socket: ["--socket", this.socketPath]
4968
4992
  };
@@ -5036,7 +5060,8 @@ class CuaDriver {
5036
5060
  const child = spawn4("open", ["-n", "-g", "-a", "CuaDriver", "--args", "serve"], {
5037
5061
  detached: true,
5038
5062
  stdio: "ignore",
5039
- env: driverEnvironment()
5063
+ env: driverEnvironment(),
5064
+ windowsHide: true
5040
5065
  });
5041
5066
  child.unref();
5042
5067
  await sleep2(750);
@@ -5044,7 +5069,8 @@ class CuaDriver {
5044
5069
  const child = spawn4(bin, ["serve"], {
5045
5070
  detached: true,
5046
5071
  stdio: "ignore",
5047
- env: driverEnvironment()
5072
+ env: driverEnvironment(),
5073
+ windowsHide: true
5048
5074
  });
5049
5075
  child.unref();
5050
5076
  await sleep2(750);
@@ -5061,7 +5087,11 @@ class CuaDriver {
5061
5087
  if (this.daemon && this.daemon.exitCode === null)
5062
5088
  return;
5063
5089
  const { env, socket } = this.mode(bin);
5064
- if (existsSync3(this.socketPath)) {
5090
+ if (process.platform === "win32") {
5091
+ await execFile(bin, ["stop", ...socket], 1e4, env).catch(() => {
5092
+ return;
5093
+ });
5094
+ } else if (existsSync3(this.socketPath)) {
5065
5095
  await execFile(bin, ["stop", ...socket], 1e4, env).catch(() => {
5066
5096
  return;
5067
5097
  });
@@ -5069,7 +5099,8 @@ class CuaDriver {
5069
5099
  }
5070
5100
  const child = spawn4(bin, ["serve", "--embedded", "--no-permissions-gate", ...socket], {
5071
5101
  stdio: "ignore",
5072
- env
5102
+ env,
5103
+ windowsHide: true
5073
5104
  });
5074
5105
  child.unref();
5075
5106
  child.on("exit", () => {
@@ -5080,16 +5111,23 @@ class CuaDriver {
5080
5111
  });
5081
5112
  this.daemon = child;
5082
5113
  process.once("exit", () => child.kill());
5083
- for (let waited = 0;waited < 1e4 && !existsSync3(this.socketPath); waited += 50) {
5114
+ const started = Date.now();
5115
+ while (Date.now() - started < 1e4 && !await this.endpointUp(bin)) {
5084
5116
  if (child.exitCode !== null)
5085
5117
  break;
5086
5118
  await sleep2(50);
5087
5119
  }
5088
- if (!existsSync3(this.socketPath)) {
5120
+ if (!await this.endpointUp(bin)) {
5089
5121
  child.kill();
5090
5122
  throw new Error("cua-driver did not start (no socket after 10 s)");
5091
5123
  }
5092
5124
  }
5125
+ async endpointUp(bin) {
5126
+ if (process.platform !== "win32")
5127
+ return existsSync3(this.socketPath);
5128
+ const { env, socket } = this.mode(bin);
5129
+ return execFile(bin, ["status", ...socket], 5000, env).then(({ stdout }) => !/not\s+running|unhealthy/i.test(stdout), () => false);
5130
+ }
5093
5131
  async permissionOwner() {
5094
5132
  return this.isEmbedded(await this.ensureInstalled()) ? "Kortix" : "CuaDriver";
5095
5133
  }
@@ -5110,7 +5148,7 @@ class CuaDriver {
5110
5148
  async ensureDaemonReady() {
5111
5149
  const bin = await this.ensureInstalled();
5112
5150
  if (this.isEmbedded(bin)) {
5113
- if (this.daemon?.exitCode === null && existsSync3(this.socketPath))
5151
+ if (this.daemon?.exitCode === null && (process.platform === "win32" ? this.daemonReady : existsSync3(this.socketPath)))
5114
5152
  return;
5115
5153
  this.stop();
5116
5154
  await this.startDaemon();
@@ -5556,7 +5594,8 @@ function createShellCapability(config) {
5556
5594
  cwd,
5557
5595
  shell: false,
5558
5596
  timeout,
5559
- env: safeEnv
5597
+ env: safeEnv,
5598
+ windowsHide: true
5560
5599
  });
5561
5600
  let stdout = "";
5562
5601
  let stderr = "";
@@ -5612,6 +5651,14 @@ function createShellCapability(config) {
5612
5651
  }
5613
5652
 
5614
5653
  // src/agent/capabilities/enabled-registry.ts
5654
+ var DRIVER_NOT_FOUND = "cua-driver was not found. The Kortix desktop app bundles it on macOS and Windows; a CLI install needs it in CUA_DRIVER_BIN, ~/.local/bin, /usr/local/bin or /opt/homebrew/bin.";
5655
+ function desktopUnavailableReason(findDesktopDriver = findCuaDriverBinary) {
5656
+ try {
5657
+ return findDesktopDriver() ? null : DRIVER_NOT_FOUND;
5658
+ } catch (error) {
5659
+ return error instanceof Error ? error.message : String(error);
5660
+ }
5661
+ }
5615
5662
  function createEnabledCapabilityRegistry(config, findDesktopDriver = findCuaDriverBinary, desktop = {}) {
5616
5663
  const registry = new CapabilityRegistry;
5617
5664
  const enabled = new Set(config.enabledCapabilities ?? ["filesystem", "shell", "desktop"]);
@@ -5619,8 +5666,12 @@ function createEnabledCapabilityRegistry(config, findDesktopDriver = findCuaDriv
5619
5666
  registry.register(createFilesystemCapability(config));
5620
5667
  if (enabled.has("shell"))
5621
5668
  registry.register(createShellCapability(config));
5622
- if (enabled.has("desktop") && findDesktopDriver()) {
5623
- registry.register(createDesktopCapability(desktop));
5669
+ if (enabled.has("desktop")) {
5670
+ const reason = desktopUnavailableReason(findDesktopDriver);
5671
+ if (reason)
5672
+ registry.unavailable.set("desktop", reason);
5673
+ else
5674
+ registry.register(createDesktopCapability(desktop));
5624
5675
  }
5625
5676
  return registry;
5626
5677
  }
@@ -5769,7 +5820,7 @@ import { join as join10 } from "path";
5769
5820
  // src/agent/service-drivers.ts
5770
5821
  import { spawnSync } from "child_process";
5771
5822
  import { existsSync as existsSync5, mkdirSync as mkdirSync3, readFileSync as readFileSync8, rmSync as rmSync4, writeFileSync as writeFileSync4 } from "fs";
5772
- import { homedir as homedir4, platform as platform6, userInfo } from "os";
5823
+ import { homedir as homedir4, platform as platform6, userInfo as userInfo2 } from "os";
5773
5824
  import { dirname as dirname6, join as join9 } from "path";
5774
5825
 
5775
5826
  // src/agent/service-quoting.ts
@@ -5792,7 +5843,7 @@ function posixShellCommand(runner) {
5792
5843
  }
5793
5844
  var supervisor = {
5794
5845
  run(command, args) {
5795
- const result = spawnSync(command, args, { encoding: "utf8" });
5846
+ const result = spawnSync(command, args, { encoding: "utf8", windowsHide: true });
5796
5847
  return {
5797
5848
  ok: result.status === 0,
5798
5849
  detail: [result.stdout, result.stderr].filter(Boolean).join(`
@@ -5810,7 +5861,7 @@ function joinDetails(...results) {
5810
5861
  `);
5811
5862
  }
5812
5863
  function launchdTarget() {
5813
- const uid = typeof process.getuid === "function" ? process.getuid() : userInfo().uid;
5864
+ const uid = typeof process.getuid === "function" ? process.getuid() : userInfo2().uid;
5814
5865
  return `gui/${uid}`;
5815
5866
  }
5816
5867
  function logPaths(paths) {
@@ -5883,7 +5934,8 @@ function renderWindowsPowerShellScript(runner) {
5883
5934
  const args = runner.args.map(powershellQuote).join(" ");
5884
5935
  const env = Object.entries(runner.env ?? {}).map(([key, value]) => `$env:${key} = ${powershellQuote(value)}
5885
5936
  `).join("");
5886
- return `$ErrorActionPreference = 'Continue'
5937
+ return `# Run by the Kortix task under conhost --headless: no console window.
5938
+ $ErrorActionPreference = 'Continue'
5887
5939
  ${env}while ($true) {
5888
5940
  # The pipe makes PowerShell wait for a GUI-subsystem binary (the desktop
5889
5941
  # app's Kortix.exe) and set $LASTEXITCODE; a bare call returns at once.
@@ -5943,7 +5995,7 @@ var launchd = {
5943
5995
  },
5944
5996
  status(paths, installed) {
5945
5997
  const disabled = run("launchctl", ["print-disabled", launchdTarget()]);
5946
- const enabled = !new RegExp(`"${paths.label.replace(/\./g, "\\.")}" => (disabled|true)`).test(disabled.detail);
5998
+ const enabled = ![`"${paths.label}" => disabled`, `"${paths.label}" => true`].some((line) => disabled.detail.includes(line));
5947
5999
  const status = run("launchctl", ["print", `${launchdTarget()}/${paths.label}`]);
5948
6000
  return {
5949
6001
  active: status.ok,
@@ -5998,7 +6050,7 @@ var systemd = {
5998
6050
  }
5999
6051
  };
6000
6052
  function ensureLinger() {
6001
- const user = userInfo().username;
6053
+ const user = userInfo2().username;
6002
6054
  const linger = run("loginctl", ["show-user", user, "-p", "Linger"]);
6003
6055
  if (/Linger=yes/.test(linger.detail))
6004
6056
  return { ok: true, detail: "" };
@@ -6023,11 +6075,11 @@ function renderWindowsTaskXml(paths, user) {
6023
6075
  <StartWhenAvailable>true</StartWhenAvailable>
6024
6076
  <Enabled>true</Enabled>
6025
6077
  </Settings>
6026
- <Actions Context="Author"><Exec><Command>powershell.exe</Command><Arguments>${xmlEscape(`-NoProfile -NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -File "${paths.windowsScript}"`)}</Arguments></Exec></Actions>
6078
+ <Actions Context="Author"><Exec><Command>conhost.exe</Command><Arguments>${xmlEscape(`--headless powershell.exe -NoProfile -NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -File "${paths.windowsScript}"`)}</Arguments></Exec></Actions>
6027
6079
  </Task>
6028
6080
  `;
6029
6081
  }
6030
- var windowsUser = () => process.env.USERDOMAIN ? `${process.env.USERDOMAIN}\\${userInfo().username}` : userInfo().username;
6082
+ var windowsUser = () => process.env.USERDOMAIN ? `${process.env.USERDOMAIN}\\${userInfo2().username}` : userInfo2().username;
6031
6083
  var windowsTaskXmlPath = (paths) => paths.windowsScript.replace(/\.ps1$/, ".xml");
6032
6084
  var scheduledTask = {
6033
6085
  unitPath: (paths) => paths.windowsScript,
@@ -6391,9 +6443,9 @@ function startAgent(config, options = {}) {
6391
6443
  const registry = createEnabledCapabilityRegistry(config, undefined, {
6392
6444
  onPermissionMissing: restart?.permissionMissing
6393
6445
  });
6394
- if (config.enabledCapabilities?.includes("desktop") && !registry.has("desktop")) {
6395
- console.error("[agent-tunnel] Computer Use is approved but unavailable: install the trusted cua-driver locally, then restart Agent Tunnel.");
6396
- }
6446
+ const desktopReason = registry.unavailable.get("desktop");
6447
+ if (desktopReason)
6448
+ console.error(`[agent-tunnel] Computer Use is approved but unavailable: ${desktopReason}`);
6397
6449
  if (options.service) {
6398
6450
  process.stdout.write(`[agent-tunnel] service starting: ${config.tunnelId} -> ${config.apiUrl}
6399
6451
  `);
@@ -6530,12 +6582,7 @@ async function pairThisMachine(apiUrl, flags, lease) {
6530
6582
  process.exit(1);
6531
6583
  }
6532
6584
  saveCredentials(outcome.tunnelId, outcome.token, apiUrl, outcome.capabilities);
6533
- writeAccess({
6534
- mode: existsSync7(desktopAppPath()) ? "ask" : "always",
6535
- grantedUntil: null,
6536
- deniedUntil: null,
6537
- keepAwake: readAccess().keepAwake
6538
- });
6585
+ writeAccess(accessAfterPairing(agentTunnelHome(), existsSync7(desktopAppPath())));
6539
6586
  if (jsonMode) {
6540
6587
  emit("approved", { tunnelId: outcome.tunnelId, capabilities: outcome.capabilities });
6541
6588
  } else {
@@ -6642,12 +6689,14 @@ function commandStatus(flags) {
6642
6689
  const service = getServiceStatus();
6643
6690
  const paired = Boolean(config.token && config.tunnelId);
6644
6691
  const approved = new Set(config.enabledCapabilities ?? []);
6692
+ const desktopReason = approved.has("desktop") ? desktopUnavailableReason() : null;
6645
6693
  if (isTruthyFlag(flags.json)) {
6646
6694
  console.log(JSON.stringify({
6647
6695
  paired,
6648
6696
  tunnelId: paired ? config.tunnelId : null,
6649
6697
  apiUrl: config.apiUrl,
6650
6698
  capabilities: [...approved],
6699
+ unavailableCapabilities: desktopReason ? { desktop: desktopReason } : {},
6651
6700
  version: agentTunnelVersion(),
6652
6701
  home: agentTunnelHome(),
6653
6702
  serviceLabel: getServicePaths().label,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kortix/agent-tunnel",
3
- "version": "0.14.2",
3
+ "version": "0.14.4",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/kortix-ai/suna.git",
@@ -38,12 +38,6 @@
38
38
  "hono": "^4.13.7",
39
39
  "ws": "^8.18.0"
40
40
  },
41
- "devDependencies": {
42
- "@types/bun": "^1.3.9",
43
- "@types/ws": "^8.5.13",
44
- "@typescript/native-preview": "7.0.0-dev.20260707.2",
45
- "typescript": "^5.4.0"
46
- },
47
41
  "keywords": [
48
42
  "tunnel",
49
43
  "relay",
@@ -5,6 +5,7 @@ import { join } from 'node:path';
5
5
 
6
6
  import {
7
7
  ACCESS_HOLD_MS,
8
+ accessAfterPairing,
8
9
  accessCommand,
9
10
  accessFilePath,
10
11
  accessView,
@@ -251,3 +252,23 @@ describe('the access rules (one copy: the CLI, the agent, and the desktop app al
251
252
  expect(readAccess(home).deniedUntil).not.toBeNull();
252
253
  });
253
254
  });
255
+
256
+ describe('access after pairing', () => {
257
+ test('a re-pair keeps the owner mode and keep awake, and drops the old grant or denial', () => {
258
+ const home = tempHome();
259
+ writeFileSync(
260
+ accessFilePath(home),
261
+ JSON.stringify({ mode: 'always', grantedUntil: null, deniedUntil: '2099-01-01T00:00:00.000Z', keepAwake: true }),
262
+ );
263
+ expect(accessAfterPairing(home, true)).toEqual({ mode: 'always', grantedUntil: null, deniedUntil: null, keepAwake: true });
264
+ writeFileSync(accessFilePath(home), JSON.stringify({ mode: 'off', grantedUntil: null, deniedUntil: null, keepAwake: false }));
265
+ expect(accessAfterPairing(home, false).mode).toBe('off');
266
+ writeFileSync(accessFilePath(home), JSON.stringify({ mode: 'ask', grantedUntil: '2099-01-01T00:00:00.000Z', deniedUntil: null, keepAwake: false }));
267
+ expect(accessAfterPairing(home, false)).toEqual({ mode: 'ask', grantedUntil: null, deniedUntil: null, keepAwake: false });
268
+ });
269
+
270
+ test('a first pairing asks when the desktop app can answer, else always', () => {
271
+ expect(accessAfterPairing(tempHome(), true).mode).toBe('ask');
272
+ expect(accessAfterPairing(tempHome(), false).mode).toBe('always');
273
+ });
274
+ });
@@ -110,6 +110,23 @@ export function writeAccess(state: AccessState, home = agentTunnelHome()): void
110
110
  writePrivateJson(accessFilePath(home), home, state);
111
111
  }
112
112
 
113
+ /**
114
+ * A1: the access a pairing starts with. The owner's mode (and keep awake) is
115
+ * a machine setting: a re-pair (`--reauth`, a rejected token, a stale
116
+ * connection) keeps it. A grant or denial belongs to the old pairing and does
117
+ * not carry over. Only a first pairing (no access.json, e.g. after `logout`)
118
+ * picks a default: `ask` when the desktop app can answer a prompt, else `always`.
119
+ */
120
+ export function accessAfterPairing(home: string, desktopApp: boolean): AccessState {
121
+ const kept = existsSync(accessFilePath(home)) ? readAccess(home) : null;
122
+ return {
123
+ mode: kept?.mode ?? (desktopApp ? 'ask' : 'always'),
124
+ grantedUntil: null,
125
+ deniedUntil: null,
126
+ keepAwake: kept?.keepAwake ?? false,
127
+ };
128
+ }
129
+
113
130
  export type AccessDecision = 'run' | 'off' | 'denied' | 'ask';
114
131
 
115
132
  export function decideAccess(state: AccessState, now = Date.now()): AccessDecision {
@@ -638,6 +638,9 @@ export class TunnelAgent {
638
638
  // the right place instead of probing paths the local ceiling denies.
639
639
  homeDir: this.config.workingDir,
640
640
  allowedPaths: this.config.allowedPaths,
641
+ // Approved capabilities this machine cannot run, and why. Always
642
+ // sent, so a fixed machine clears the stored reason.
643
+ unavailableCapabilities: Object.fromEntries(this.registry.unavailable),
641
644
  },
642
645
  },
643
646
  });
@@ -793,7 +796,7 @@ export class TunnelAgent {
793
796
  if (this.keepAwake || this.keepAwakeFailed) return;
794
797
  const blocker = keepAwakeCommand(platform(), process.pid);
795
798
  if (!blocker) return;
796
- const child = spawn(blocker.command, blocker.args, { stdio: 'ignore' });
799
+ const child = spawn(blocker.command, blocker.args, { stdio: 'ignore', windowsHide: true });
797
800
  child.on('error', () => {
798
801
  this.keepAwakeFailed = true;
799
802
  log(`${c.yellow}!${c.reset}`, `Keep awake is unavailable: ${blocker.command} could not start`);
@@ -1,6 +1,6 @@
1
1
  import { spawn, type ChildProcess } from 'child_process';
2
2
  import { existsSync, readFileSync, realpathSync, rmSync, statSync } from 'fs';
3
- import { homedir, platform, tmpdir } from 'os';
3
+ import { homedir, platform, tmpdir, userInfo } from 'os';
4
4
  import { basename, dirname, join } from 'path';
5
5
  import { agentTunnelHome, serviceLabelFor, SERVICE_LABEL } from '../../service-paths';
6
6
 
@@ -23,6 +23,20 @@ const DRIVER_ENV_KEYS = [
23
23
  'XDG_RUNTIME_DIR',
24
24
  'XAUTHORITY',
25
25
  'DBUS_SESSION_BUS_ADDRESS',
26
+ // Windows: without these a Rust binary cannot start COM, Winsock, or UI
27
+ // Automation, and finds no profile or temp directory.
28
+ 'SystemRoot',
29
+ 'windir',
30
+ 'USERPROFILE',
31
+ 'USERNAME',
32
+ 'USERDOMAIN',
33
+ 'APPDATA',
34
+ 'LOCALAPPDATA',
35
+ 'ProgramData',
36
+ 'TEMP',
37
+ 'TMP',
38
+ 'ComSpec',
39
+ 'PATHEXT',
26
40
  ] as const;
27
41
 
28
42
  /** The driver's own product telemetry stays off on a customer's machine. */
@@ -111,6 +125,7 @@ function execFile(
111
125
  const proc = spawn(cmd, args, {
112
126
  stdio: ['ignore', 'pipe', 'pipe'],
113
127
  env,
128
+ windowsHide: true,
114
129
  });
115
130
  let stdout = '';
116
131
  let stderr = '';
@@ -232,11 +247,15 @@ export class CuaDriver {
232
247
  constructor(options: CuaDriverOptions = {}) {
233
248
  this.binary = options.binary ?? null;
234
249
  this.embedded = options.embedded;
235
- const uid = typeof process.getuid === 'function' ? process.getuid() : 'user';
236
250
  // One driver per agent home (the label's suffix), so two homes never share
237
- // or steal a driver. Under $TMPDIR: a socket path is capped at 104 bytes on macOS.
251
+ // or steal a driver. Under $TMPDIR: a socket path is capped at 104 bytes on
252
+ // macOS. Windows: the driver listens on a named pipe, one per user.
238
253
  const home = serviceLabelFor(agentTunnelHome()).slice(SERVICE_LABEL.length);
239
- this.socketPath = options.socketPath ?? join(tmpdir(), `kortix-cua-${uid}${home}.sock`);
254
+ this.socketPath =
255
+ options.socketPath ??
256
+ (process.platform === 'win32'
257
+ ? `\\\\.\\pipe\\kortix-cua-${userInfo().username}${home}`
258
+ : join(tmpdir(), `kortix-cua-${typeof process.getuid === 'function' ? process.getuid() : 'user'}${home}.sock`));
240
259
  this.hostBundleId = options.hostBundleId;
241
260
  }
242
261
 
@@ -247,10 +266,12 @@ export class CuaDriver {
247
266
  /** Environment and trailing arguments every driver command needs in this mode. */
248
267
  private mode(binary: string): { env: NodeJS.ProcessEnv; socket: string[] } {
249
268
  if (!this.isEmbedded(binary)) return { env: driverEnvironment(), socket: [] };
269
+ // macOS only: a Windows or Linux host has no bundle id.
270
+ const bundleId = this.hostBundleId ?? hostBundleId();
250
271
  return {
251
272
  env: driverEnvironment({
252
273
  CUA_DRIVER_EMBEDDED: '1',
253
- CUA_DRIVER_HOST_BUNDLE_ID: this.hostBundleId ?? hostBundleId(),
274
+ ...(bundleId ? { CUA_DRIVER_HOST_BUNDLE_ID: bundleId } : {}),
254
275
  }),
255
276
  socket: ['--socket', this.socketPath],
256
277
  };
@@ -337,6 +358,7 @@ export class CuaDriver {
337
358
  detached: true,
338
359
  stdio: 'ignore',
339
360
  env: driverEnvironment(),
361
+ windowsHide: true,
340
362
  });
341
363
  child.unref();
342
364
  await sleep(750);
@@ -345,6 +367,7 @@ export class CuaDriver {
345
367
  detached: true,
346
368
  stdio: 'ignore',
347
369
  env: driverEnvironment(),
370
+ windowsHide: true,
348
371
  });
349
372
  child.unref();
350
373
  await sleep(750);
@@ -370,13 +393,17 @@ export class CuaDriver {
370
393
  // when it exited, and the driver refuses to start over an existing endpoint.
371
394
  // Stop whatever may still answer on it (it runs outside this process's
372
395
  // macOS permissions), then remove it.
373
- if (existsSync(this.socketPath)) {
396
+ // A Windows named pipe is no file: always ask, it goes away with its server.
397
+ if (process.platform === 'win32') {
398
+ await execFile(bin, ['stop', ...socket], 10_000, env).catch(() => undefined);
399
+ } else if (existsSync(this.socketPath)) {
374
400
  await execFile(bin, ['stop', ...socket], 10_000, env).catch(() => undefined);
375
401
  rmSync(this.socketPath, { force: true });
376
402
  }
377
403
  const child = spawn(bin, ['serve', '--embedded', '--no-permissions-gate', ...socket], {
378
404
  stdio: 'ignore',
379
405
  env,
406
+ windowsHide: true,
380
407
  });
381
408
  child.unref();
382
409
  child.on('exit', () => {
@@ -387,16 +414,27 @@ export class CuaDriver {
387
414
  });
388
415
  this.daemon = child;
389
416
  process.once('exit', () => child.kill());
390
- for (let waited = 0; waited < 10_000 && !existsSync(this.socketPath); waited += 50) {
417
+ const started = Date.now();
418
+ while (Date.now() - started < 10_000 && !(await this.endpointUp(bin))) {
391
419
  if (child.exitCode !== null) break;
392
420
  await sleep(50);
393
421
  }
394
- if (!existsSync(this.socketPath)) {
422
+ if (!(await this.endpointUp(bin))) {
395
423
  child.kill();
396
424
  throw new Error('cua-driver did not start (no socket after 10 s)');
397
425
  }
398
426
  }
399
427
 
428
+ /** The daemon endpoint answers: a Unix socket is a file, a Windows named pipe is asked. */
429
+ private async endpointUp(bin: string): Promise<boolean> {
430
+ if (process.platform !== 'win32') return existsSync(this.socketPath);
431
+ const { env, socket } = this.mode(bin);
432
+ return execFile(bin, ['status', ...socket], 5_000, env).then(
433
+ ({ stdout }) => !/not\s+running|unhealthy/i.test(stdout),
434
+ () => false,
435
+ );
436
+ }
437
+
400
438
  /**
401
439
  * The macOS grants the driver reports missing for its permission owner:
402
440
  * Kortix when embedded, CuaDriver otherwise. Empty when nothing is missing
@@ -426,7 +464,7 @@ export class CuaDriver {
426
464
  const bin = await this.ensureInstalled();
427
465
  // Embedded: only a driver this agent started runs inside its permissions.
428
466
  if (this.isEmbedded(bin)) {
429
- if (this.daemon?.exitCode === null && existsSync(this.socketPath)) return;
467
+ if (this.daemon?.exitCode === null && (process.platform === 'win32' ? this.daemonReady : existsSync(this.socketPath))) return;
430
468
  this.stop();
431
469
  await this.startDaemon();
432
470
  return;
@@ -5,6 +5,23 @@ import { findCuaDriverBinary } from './desktop/cua-driver';
5
5
  import { createFilesystemCapability } from './filesystem';
6
6
  import { createShellCapability } from './shell';
7
7
 
8
+ const DRIVER_NOT_FOUND =
9
+ 'cua-driver was not found. The Kortix desktop app bundles it on macOS and Windows; a CLI install needs it in CUA_DRIVER_BIN, ~/.local/bin, /usr/local/bin or /opt/homebrew/bin.';
10
+
11
+ /**
12
+ * Why Computer Use cannot run here, or null when the trusted local driver is
13
+ * present. Never throws: a rejected driver is a reason, not an agent crash.
14
+ * Missing macOS grants are not a reason: the capability registers and the
15
+ * agent reports `desktopPermissions`, which drive the setup.
16
+ */
17
+ export function desktopUnavailableReason(findDesktopDriver: () => string | null = findCuaDriverBinary): string | null {
18
+ try {
19
+ return findDesktopDriver() ? null : DRIVER_NOT_FOUND;
20
+ } catch (error) {
21
+ return error instanceof Error ? error.message : String(error);
22
+ }
23
+ }
24
+
8
25
  /** Build the local RPC surface from the browser-approved capability ceiling. */
9
26
  export function createEnabledCapabilityRegistry(
10
27
  config: TunnelConfig,
@@ -15,11 +32,12 @@ export function createEnabledCapabilityRegistry(
15
32
  const enabled = new Set(config.enabledCapabilities ?? ['filesystem', 'shell', 'desktop']);
16
33
  if (enabled.has('filesystem')) registry.register(createFilesystemCapability(config));
17
34
  if (enabled.has('shell')) registry.register(createShellCapability(config));
18
- // Do not advertise Computer Use unless this process can execute the trusted
19
- // local driver. The server must see the real handler surface, not the user's
20
- // desired capability list.
21
- if (enabled.has('desktop') && findDesktopDriver()) {
22
- registry.register(createDesktopCapability(desktop));
35
+ // Advertise Computer Use only when this process can execute the trusted
36
+ // local driver; otherwise record why, so Kortix shows the reason.
37
+ if (enabled.has('desktop')) {
38
+ const reason = desktopUnavailableReason(findDesktopDriver);
39
+ if (reason) registry.unavailable.set('desktop', reason);
40
+ else registry.register(createDesktopCapability(desktop));
23
41
  }
24
42
  return registry;
25
43
  }
@@ -15,6 +15,8 @@ export interface Capability {
15
15
 
16
16
  export class CapabilityRegistry {
17
17
  private capabilities = new Map<string, Capability>();
18
+ /** Approved capabilities this machine cannot run, with the reason. */
19
+ readonly unavailable = new Map<string, string>();
18
20
 
19
21
  register(capability: Capability): void {
20
22
  this.capabilities.set(capability.name, capability);
@@ -64,6 +64,18 @@ describe('local capability permission enforcement', () => {
64
64
  );
65
65
  expect(registry.getCapabilityNames()).toEqual([]);
66
66
  expect(registry.getHandler('desktop.cua.get_screen_size')).toBeNull();
67
+ expect(registry.unavailable.get('desktop')).toContain('cua-driver was not found');
68
+ });
69
+
70
+ test('a rejected driver is reported as the reason, not thrown', () => {
71
+ const registry = createEnabledCapabilityRegistry(
72
+ { ...config(), enabledCapabilities: ['filesystem', 'desktop'] },
73
+ () => {
74
+ throw new Error('cua-driver is not owned by the current user or root: /x/cua-driver');
75
+ },
76
+ );
77
+ expect(registry.getCapabilityNames()).toEqual(['filesystem']);
78
+ expect(registry.unavailable.get('desktop')).toBe('cua-driver is not owned by the current user or root: /x/cua-driver');
67
79
  });
68
80
 
69
81
  test('desktop is advertised when a trusted local driver exists', () => {
@@ -73,7 +85,9 @@ describe('local capability permission enforcement', () => {
73
85
  );
74
86
  expect(registry.getCapabilityNames()).toEqual(['desktop']);
75
87
  expect(registry.getHandler('desktop.cua.call')).not.toBeNull();
88
+ expect(registry.unavailable.size).toBe(0);
76
89
  });
90
+
77
91
  test('a permission scope cannot widen the local filesystem ceiling', async () => {
78
92
  const handler = createFilesystemCapability(config()).methods.get('fs.read')!;
79
93
  await expect(
@@ -79,6 +79,7 @@ export function createShellCapability(config: TunnelConfig): Capability {
79
79
  shell: false,
80
80
  timeout,
81
81
  env: safeEnv,
82
+ windowsHide: true,
82
83
  });
83
84
 
84
85
  let stdout = '';
@@ -256,7 +256,7 @@ describe('agent tunnel connect re-authorization', () => {
256
256
  expect(JSON.parse(await readFile(configPath, 'utf8')).token).toBeUndefined();
257
257
  }, 30_000);
258
258
 
259
- test('a new pairing from the desktop app asks before every use and never inherits the previous grant (A1)', async () => {
259
+ test('a first pairing from the desktop app asks; a re-pair keeps the owner mode and drops the old grant (A1)', async () => {
260
260
  const relay = startRejectingRelay({ serveDeviceAuth: true });
261
261
  const apiUrl = `http://127.0.0.1:${relay.server.port}/v1/tunnel`;
262
262
  const home = await mkdtemp(join(tmpdir(), 'agent-tunnel-access-default-'));
@@ -279,8 +279,8 @@ describe('agent tunnel connect re-authorization', () => {
279
279
  if (!second.out().includes('Authorized')) throw new Error('not yet');
280
280
  });
281
281
  second.child.kill('SIGTERM');
282
- // Keep awake is a machine setting and stays; the answer resets with the pairing.
283
- expect(JSON.parse(await readFile(accessPath, 'utf8'))).toEqual({ mode: 'ask', grantedUntil: null, deniedUntil: null, keepAwake: true });
282
+ // The mode and keep awake are the owner's machine settings and stay; the grant resets with the pairing.
283
+ expect(JSON.parse(await readFile(accessPath, 'utf8'))).toEqual({ mode: 'always', grantedUntil: null, deniedUntil: null, keepAwake: true });
284
284
  } finally {
285
285
  relay.server.stop(true);
286
286
  }
package/src/agent/cli.ts CHANGED
@@ -5,6 +5,7 @@ import { join } from 'path';
5
5
 
6
6
  import { PAIR_AGAIN_COMMAND, TunnelAgent } from './agent';
7
7
  import {
8
+ accessAfterPairing,
8
9
  accessFilePath,
9
10
  accessRequestPath,
10
11
  accessView,
@@ -16,7 +17,7 @@ import {
16
17
  type AccessView,
17
18
  } from './access';
18
19
  import { printStartupBanner } from './banner';
19
- import { createEnabledCapabilityRegistry } from './capabilities/enabled-registry';
20
+ import { createEnabledCapabilityRegistry, desktopUnavailableReason } from './capabilities/enabled-registry';
20
21
  import { loadConfig, type TunnelConfig } from './config';
21
22
  import { CONFIG_FILE, clearSavedCredentials, saveCredentials } from './credential-store';
22
23
  import { probeCredentials } from './credential-probe';
@@ -118,11 +119,8 @@ function startAgent(config: TunnelConfig, options: { service?: boolean } = {}):
118
119
  const registry = createEnabledCapabilityRegistry(config, undefined, {
119
120
  onPermissionMissing: restart?.permissionMissing,
120
121
  });
121
- if (config.enabledCapabilities?.includes('desktop') && !registry.has('desktop')) {
122
- console.error(
123
- '[agent-tunnel] Computer Use is approved but unavailable: install the trusted cua-driver locally, then restart Agent Tunnel.',
124
- );
125
- }
122
+ const desktopReason = registry.unavailable.get('desktop');
123
+ if (desktopReason) console.error(`[agent-tunnel] Computer Use is approved but unavailable: ${desktopReason}`);
126
124
 
127
125
  if (options.service) {
128
126
  process.stdout.write(`[agent-tunnel] service starting: ${config.tunnelId} -> ${config.apiUrl}\n`);
@@ -288,17 +286,9 @@ async function pairThisMachine(apiUrl: string, flags: Flags, lease?: { serviceWa
288
286
  }
289
287
 
290
288
  saveCredentials(outcome.tunnelId, outcome.token, apiUrl, outcome.capabilities);
291
- // A1: a pairing made from the desktop app asks before each use; the app is
292
- // what answers. A CLI-only machine has nobody to answer a prompt, so it is
293
- // always allowed (a documented deviation from A1). A new pairing never
294
- // inherits a grant or an `always` from the previous one; keep awake is a
295
- // machine setting and stays.
296
- writeAccess({
297
- mode: existsSync(desktopAppPath()) ? 'ask' : 'always',
298
- grantedUntil: null,
299
- deniedUntil: null,
300
- keepAwake: readAccess().keepAwake,
301
- });
289
+ // A1: a re-pair keeps the owner's mode; a first pairing asks when the
290
+ // desktop app can answer, else always (see accessAfterPairing).
291
+ writeAccess(accessAfterPairing(agentTunnelHome(), existsSync(desktopAppPath())));
302
292
  if (jsonMode) {
303
293
  emit('approved', { tunnelId: outcome.tunnelId, capabilities: outcome.capabilities });
304
294
  } else {
@@ -436,6 +426,7 @@ function commandStatus(flags: Flags): void {
436
426
  const service = getServiceStatus();
437
427
  const paired = Boolean(config.token && config.tunnelId);
438
428
  const approved = new Set(config.enabledCapabilities ?? []);
429
+ const desktopReason = approved.has('desktop') ? desktopUnavailableReason() : null;
439
430
 
440
431
  if (isTruthyFlag(flags.json)) {
441
432
  console.log(JSON.stringify({
@@ -443,6 +434,7 @@ function commandStatus(flags: Flags): void {
443
434
  tunnelId: paired ? config.tunnelId : null,
444
435
  apiUrl: config.apiUrl,
445
436
  capabilities: [...approved],
437
+ unavailableCapabilities: desktopReason ? { desktop: desktopReason } : {},
446
438
  version: agentTunnelVersion(),
447
439
  home: agentTunnelHome(),
448
440
  serviceLabel: getServicePaths().label,
@@ -153,7 +153,7 @@ export function machineDisplayName({
153
153
  host = hostname(),
154
154
  env = process.env,
155
155
  run = (command: string, args: string[]) =>
156
- execFileSync(command, args, { encoding: 'utf8', timeout: 1_000, stdio: ['ignore', 'pipe', 'ignore'] }),
156
+ execFileSync(command, args, { encoding: 'utf8', timeout: 1_000, stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true }),
157
157
  }: {
158
158
  os?: string;
159
159
  host?: string;
@@ -180,7 +180,7 @@ export function machineDisplayName({
180
180
  export function machineId({
181
181
  os = platform(),
182
182
  run = (command: string, args: string[]) =>
183
- execFileSync(command, args, { encoding: 'utf8', timeout: 2_000, stdio: ['ignore', 'pipe', 'ignore'] }),
183
+ execFileSync(command, args, { encoding: 'utf8', timeout: 2_000, stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true }),
184
184
  read = (path: string) => readFileSync(path, 'utf8'),
185
185
  }: {
186
186
  os?: string;
@@ -0,0 +1,12 @@
1
+ import { expect, test } from 'bun:test';
2
+ import { anyFlag, isTruthyFlag } from './prompts';
3
+
4
+ test('flags accept only the existing lowercase truthy values', () => {
5
+ for (const value of ['true', '1', 'yes']) expect(isTruthyFlag(value)).toBe(true);
6
+ for (const value of [undefined, '', 'false', '0', 'no', 'TRUE', ' yes ']) {
7
+ expect(isTruthyFlag(value)).toBe(false);
8
+ }
9
+ expect(anyFlag({ yes: 'yes', no: 'false' }, ['missing', 'no', 'yes'])).toBe(true);
10
+ expect(anyFlag({ yes: 'yes', no: 'false' }, ['missing', 'no'])).toBe(false);
11
+ expect(anyFlag({ yes: 'yes' }, [])).toBe(false);
12
+ });
@@ -1,6 +1,3 @@
1
- import { createInterface } from 'readline/promises';
2
- import { c } from './terminal';
3
-
4
1
  export function isInteractiveTerminal(): boolean {
5
2
  return process.stdin.isTTY === true && process.stdout.isTTY === true;
6
3
  }
@@ -13,25 +10,3 @@ export function isTruthyFlag(value: string | undefined): boolean {
13
10
  export function anyFlag(flags: Record<string, string>, names: readonly string[]): boolean {
14
11
  return names.some((name) => isTruthyFlag(flags[name]));
15
12
  }
16
-
17
- export async function promptYesNo(question: string, defaultValue: boolean): Promise<boolean> {
18
- const suffix = defaultValue ? ' [Y/n] ' : ' [y/N] ';
19
- const rl = createInterface({ input: process.stdin, output: process.stdout });
20
- try {
21
- for (;;) {
22
- const answer = (await rl.question(`${question}${suffix}`)).trim().toLowerCase();
23
- if (!answer) return defaultValue;
24
- if (['y', 'yes'].includes(answer)) return true;
25
- if (['n', 'no'].includes(answer)) return false;
26
- console.log(` ${c.yellow}!${c.reset} Please answer yes or no.`);
27
- }
28
- } catch (error) {
29
- if (error instanceof Error && error.name === 'AbortError') {
30
- process.stdout.write('\n');
31
- process.exit(130);
32
- }
33
- throw error;
34
- } finally {
35
- rl.close();
36
- }
37
- }
@@ -65,6 +65,14 @@ describe('durable pause (R3)', () => {
65
65
  expect(uid).toBeGreaterThanOrEqual(0);
66
66
  });
67
67
 
68
+ test('launchd: status matches the label literally, regex characters included', () => {
69
+ const odd = { ...paths, label: 'com.kortix.a+b(1)\\x' };
70
+ recordCommands({ 'launchctl print-disabled': { ok: true, detail: `"${odd.label}" => true` } });
71
+ expect(serviceDriverFor('darwin')!.status(odd, false).enabled).toBe(false);
72
+ recordCommands({ 'launchctl print-disabled': { ok: true, detail: `"comXkortix.a+b(1)\\x" => disabled` } });
73
+ expect(serviceDriverFor('darwin')!.status(odd, false).enabled).toBe(true);
74
+ });
75
+
68
76
  test('systemd: pause is disable --now; resume is enable --now', () => {
69
77
  const calls = recordCommands();
70
78
  serviceDriverFor('linux')!.pause(paths, true);
@@ -148,6 +156,7 @@ describe('install really starts the service', () => {
148
156
  expect(xml).toContain('<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>');
149
157
  expect(xml).toContain('<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>');
150
158
  expect(xml).toContain('<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>');
159
+ expect(xml).toContain('<Command>conhost.exe</Command><Arguments>--headless powershell.exe ');
151
160
  expect(xml).toContain('-WindowStyle Hidden');
152
161
  expect(renderWindowsTaskXml(paths, 'DOMAIN\\a&b')).toContain('<UserId>DOMAIN\\a&amp;b</UserId>');
153
162
  } finally {
@@ -73,7 +73,7 @@ interface CommandResult {
73
73
  /** The one place a supervisor command runs. Replaced in tests. */
74
74
  export const supervisor = {
75
75
  run(command: string, args: string[]): CommandResult {
76
- const result = spawnSync(command, args, { encoding: 'utf8' });
76
+ const result = spawnSync(command, args, { encoding: 'utf8', windowsHide: true });
77
77
  return {
78
78
  ok: result.status === 0,
79
79
  detail: [result.stdout, result.stderr].filter(Boolean).join('\n').trim(),
@@ -174,7 +174,10 @@ export function renderWindowsPowerShellScript(runner: RunnerParts): string {
174
174
  const env = Object.entries(runner.env ?? {})
175
175
  .map(([key, value]) => `$env:${key} = ${powershellQuote(value)}\n`)
176
176
  .join('');
177
- return `$ErrorActionPreference = 'Continue'
177
+ // The first line also marks a unit from before the headless task: it no
178
+ // longer matches, so status reports upToDate false and repair reinstalls it.
179
+ return `# Run by the Kortix task under conhost --headless: no console window.
180
+ $ErrorActionPreference = 'Continue'
178
181
  ${env}while ($true) {
179
182
  # The pipe makes PowerShell wait for a GUI-subsystem binary (the desktop
180
183
  # app's Kortix.exe) and set $LASTEXITCODE; a bare call returns at once.
@@ -252,7 +255,8 @@ const launchd: ServiceDriver = {
252
255
 
253
256
  status(paths, installed) {
254
257
  const disabled = run('launchctl', ['print-disabled', launchdTarget()]);
255
- const enabled = !new RegExp(`"${paths.label.replace(/\./g, '\\.')}" => (disabled|true)`).test(disabled.detail);
258
+ // Plain substring match: the label is data, never a regex.
259
+ const enabled = ![`"${paths.label}" => disabled`, `"${paths.label}" => true`].some((line) => disabled.detail.includes(line));
256
260
  const status = run('launchctl', ['print', `${launchdTarget()}/${paths.label}`]);
257
261
  return {
258
262
  active: status.ok,
@@ -338,8 +342,10 @@ function ensureLinger(): CommandResult {
338
342
  /**
339
343
  * Task Scheduler definition. schtasks' own defaults stop the task on battery,
340
344
  * refuse to start it on battery, and kill it after 72 h, and a visible console
341
- * window ends the loop when the user closes it. Written as UTF-16, the
342
- * encoding schtasks /XML expects.
345
+ * window ends the loop when the user closes it. `-WindowStyle Hidden` alone
346
+ * still opens a window where Windows Terminal is the default terminal
347
+ * (Windows 11), so `conhost --headless` hosts PowerShell with no window at
348
+ * all. Written as UTF-16, the encoding schtasks /XML expects.
343
349
  */
344
350
  export function renderWindowsTaskXml(paths: ServicePaths, user: string): string {
345
351
  return `<?xml version="1.0" encoding="UTF-16"?>
@@ -355,7 +361,7 @@ export function renderWindowsTaskXml(paths: ServicePaths, user: string): string
355
361
  <StartWhenAvailable>true</StartWhenAvailable>
356
362
  <Enabled>true</Enabled>
357
363
  </Settings>
358
- <Actions Context="Author"><Exec><Command>powershell.exe</Command><Arguments>${xmlEscape(`-NoProfile -NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -File "${paths.windowsScript}"`)}</Arguments></Exec></Actions>
364
+ <Actions Context="Author"><Exec><Command>conhost.exe</Command><Arguments>${xmlEscape(`--headless powershell.exe -NoProfile -NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -File "${paths.windowsScript}"`)}</Arguments></Exec></Actions>
359
365
  </Task>
360
366
  `;
361
367
  }