@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 +18 -7
- package/dist/agent-cli.js +86 -37
- package/package.json +1 -7
- package/src/agent/access.test.ts +21 -0
- package/src/agent/access.ts +17 -0
- package/src/agent/agent.ts +4 -1
- package/src/agent/capabilities/desktop/cua-driver.ts +47 -9
- package/src/agent/capabilities/enabled-registry.ts +23 -5
- package/src/agent/capabilities/index.ts +2 -0
- package/src/agent/capabilities/security.test.ts +14 -0
- package/src/agent/capabilities/shell.ts +1 -0
- package/src/agent/cli-reauth.test.ts +3 -3
- package/src/agent/cli.ts +9 -17
- package/src/agent/device-auth.ts +2 -2
- package/src/agent/prompts.test.ts +12 -0
- package/src/agent/prompts.ts +0 -25
- package/src/agent/service-drivers.test.ts +9 -0
- package/src/agent/service-drivers.ts +12 -6
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
|
|
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".
|
|
188
|
-
|
|
189
|
-
|
|
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.
|
|
241
|
-
|
|
242
|
-
|
|
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-${
|
|
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:
|
|
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 (
|
|
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
|
-
|
|
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 (!
|
|
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")
|
|
5623
|
-
|
|
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() :
|
|
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
|
|
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 = !
|
|
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 =
|
|
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>
|
|
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}\\${
|
|
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
|
-
|
|
6395
|
-
|
|
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.
|
|
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",
|
package/src/agent/access.test.ts
CHANGED
|
@@ -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
|
+
});
|
package/src/agent/access.ts
CHANGED
|
@@ -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 {
|
package/src/agent/agent.ts
CHANGED
|
@@ -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
|
|
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 =
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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 (!
|
|
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
|
-
//
|
|
19
|
-
// local driver
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
registry.
|
|
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(
|
|
@@ -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
|
|
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
|
-
//
|
|
283
|
-
expect(JSON.parse(await readFile(accessPath, 'utf8'))).toEqual({ mode: '
|
|
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
|
-
|
|
122
|
-
|
|
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
|
|
292
|
-
//
|
|
293
|
-
|
|
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,
|
package/src/agent/device-auth.ts
CHANGED
|
@@ -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
|
+
});
|
package/src/agent/prompts.ts
CHANGED
|
@@ -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&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
|
-
|
|
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
|
-
|
|
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.
|
|
342
|
-
*
|
|
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>
|
|
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
|
}
|