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