dsh-permission-remote 0.1.0 → 0.2.0
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 +5 -1
- package/lib/client.js +2 -1
- package/lib/index.js +324 -125
- package/package.json +3 -2
- package/src/client/index.tsx +2 -1
- package/src/config.ts +54 -4
- package/src/execute.ts +144 -54
- package/src/index.ts +82 -39
- package/src/probe.ts +9 -4
- package/src/profile.ts +51 -7
- package/src/router.ts +2 -2
- package/src/shadow.ts +1 -1
- package/src/ssh-pool.ts +74 -13
package/lib/index.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
|
-
import { mkdir } from "node:fs/promises";
|
|
2
|
+
import { mkdir as mkdir2 } from "node:fs/promises";
|
|
3
3
|
import { basename } from "node:path";
|
|
4
4
|
import { credentialKey } from "@deepseek-ai/dsh-credentials";
|
|
5
|
+
import { ESCALATION_TARGETS } from "@deepseek-ai/dsh-sandbox";
|
|
5
6
|
import { TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
|
|
6
7
|
|
|
7
8
|
// src/config.ts
|
|
8
9
|
import Schema from "@deepseek-ai/schemastery";
|
|
10
|
+
import { createVolatile, isVolatile, updateVolatile } from "@deepseek-ai/cosmokit";
|
|
9
11
|
var Host = Schema.object({
|
|
10
12
|
id: Schema.string().required(),
|
|
11
13
|
name: Schema.string().default(""),
|
|
@@ -23,19 +25,55 @@ var Host = Schema.object({
|
|
|
23
25
|
fingerprint: Schema.string().default("")
|
|
24
26
|
});
|
|
25
27
|
var Config = Schema.object({
|
|
26
|
-
|
|
28
|
+
// Settings only lists and writes fields marked volatile. Without this, the
|
|
29
|
+
// host row is dropped from describe() and every save is a silent no-op.
|
|
30
|
+
hosts: Schema.array(Host).default([]).volatile(),
|
|
27
31
|
maxConnections: Schema.natural().min(1).max(32).default(4)
|
|
28
32
|
});
|
|
33
|
+
function isRecord(value) {
|
|
34
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
35
|
+
}
|
|
36
|
+
function isHostRef(value) {
|
|
37
|
+
return isVolatile(value);
|
|
38
|
+
}
|
|
39
|
+
function asHostArray(value) {
|
|
40
|
+
if (!Array.isArray(value)) throw new Error("SSH host list is not an array");
|
|
41
|
+
return value;
|
|
42
|
+
}
|
|
43
|
+
function copyHostRows(value) {
|
|
44
|
+
if (value === void 0 || value === null) return void 0;
|
|
45
|
+
return asHostArray(value).map((item) => ({ ...item }));
|
|
46
|
+
}
|
|
47
|
+
function commitHosts(ref, hosts) {
|
|
48
|
+
updateVolatile(ref, createVolatile(hosts.map((host) => ({ ...host }))));
|
|
49
|
+
}
|
|
29
50
|
function readConfig(raw) {
|
|
30
|
-
const
|
|
51
|
+
const record = isRecord(raw) ? raw : {};
|
|
52
|
+
const incoming = record.hosts;
|
|
53
|
+
const parsed = Config({
|
|
54
|
+
...record,
|
|
55
|
+
hosts: copyHostRows(isHostRef(incoming) ? incoming.get() : incoming)
|
|
56
|
+
});
|
|
57
|
+
const maxConnections = parsed.maxConnections ?? 4;
|
|
58
|
+
const ref = isHostRef(incoming) ? incoming : parsed.hosts;
|
|
59
|
+
if (isHostRef(ref)) return viewConfig(ref, maxConnections);
|
|
60
|
+
return { hosts: asHostArray(ref), maxConnections };
|
|
61
|
+
}
|
|
62
|
+
function viewConfig(ref, maxConnections) {
|
|
31
63
|
return {
|
|
32
|
-
|
|
33
|
-
|
|
64
|
+
get hosts() {
|
|
65
|
+
return asHostArray(ref.get()).map((host) => ({ ...host }));
|
|
66
|
+
},
|
|
67
|
+
set hosts(value) {
|
|
68
|
+
commitHosts(ref, value);
|
|
69
|
+
},
|
|
70
|
+
maxConnections
|
|
34
71
|
};
|
|
35
72
|
}
|
|
36
73
|
|
|
37
74
|
// src/execute.ts
|
|
38
75
|
import { randomBytes } from "node:crypto";
|
|
76
|
+
import { mkdir } from "node:fs/promises";
|
|
39
77
|
import { createRequire } from "node:module";
|
|
40
78
|
import { posix as posix3 } from "node:path";
|
|
41
79
|
import { FsError } from "@deepseek-ai/dsh-fs";
|
|
@@ -43,7 +81,8 @@ import {
|
|
|
43
81
|
SandboxUnavailableError,
|
|
44
82
|
approveEscalation,
|
|
45
83
|
escalationHintMarker,
|
|
46
|
-
sandboxDenialMarker
|
|
84
|
+
sandboxDenialMarker,
|
|
85
|
+
validateEscalationArgs
|
|
47
86
|
} from "@deepseek-ai/dsh-sandbox";
|
|
48
87
|
|
|
49
88
|
// src/install-commands.ts
|
|
@@ -112,10 +151,34 @@ function composeCreatedPath(ancestorReal, missingNames) {
|
|
|
112
151
|
return posix.normalize(posix.join(ancestorReal, ...suffix));
|
|
113
152
|
}
|
|
114
153
|
|
|
154
|
+
// src/shell-quote.ts
|
|
155
|
+
function shQuote(value) {
|
|
156
|
+
return `'${value.replaceAll("'", `'\\''`)}'`;
|
|
157
|
+
}
|
|
158
|
+
function shJoin(argv) {
|
|
159
|
+
return argv.map(shQuote).join(" ");
|
|
160
|
+
}
|
|
161
|
+
|
|
115
162
|
// src/profile.ts
|
|
116
163
|
function bwrapProfileArgs(mode, workspaceRoot) {
|
|
117
164
|
if (mode === "danger-full-access") return null;
|
|
118
|
-
const args =
|
|
165
|
+
const args = bwrapBaseArgs();
|
|
166
|
+
if (mode === "workspace-write") {
|
|
167
|
+
args.push("--tmpfs", "/tmp", "--bind", workspaceRoot, workspaceRoot);
|
|
168
|
+
}
|
|
169
|
+
return args;
|
|
170
|
+
}
|
|
171
|
+
function bwrapFileProfileArgs(mode, roots) {
|
|
172
|
+
if (mode === "danger-full-access") return null;
|
|
173
|
+
const args = bwrapBaseArgs();
|
|
174
|
+
if (mode === "workspace-write") {
|
|
175
|
+
const binds = fileWritableRoots(mode, roots).slice().sort((left, right) => left.length - right.length || left.localeCompare(right));
|
|
176
|
+
for (const root of binds) args.push("--bind", root, root);
|
|
177
|
+
}
|
|
178
|
+
return args;
|
|
179
|
+
}
|
|
180
|
+
function bwrapBaseArgs() {
|
|
181
|
+
return [
|
|
119
182
|
"--ro-bind",
|
|
120
183
|
"/",
|
|
121
184
|
"/",
|
|
@@ -126,10 +189,10 @@ function bwrapProfileArgs(mode, workspaceRoot) {
|
|
|
126
189
|
"/proc",
|
|
127
190
|
"--die-with-parent"
|
|
128
191
|
];
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
return
|
|
192
|
+
}
|
|
193
|
+
function seatbeltBashCommand(mode, writable, workdir, command) {
|
|
194
|
+
const profile = seatbeltProfileArgs(mode, writable) ?? [];
|
|
195
|
+
return `cd ${shJoin([workdir])} && ${shJoin(["sandbox-exec", ...profile, "--", "bash", "-c", command])}`;
|
|
133
196
|
}
|
|
134
197
|
function bwrapProbeArgv() {
|
|
135
198
|
return ["bwrap", ...bwrapProfileArgs("read-only", "/"), "--", "true"];
|
|
@@ -156,7 +219,7 @@ function sbplString(value) {
|
|
|
156
219
|
}
|
|
157
220
|
function fileWritableRoots(mode, roots) {
|
|
158
221
|
if (mode !== "workspace-write") return [];
|
|
159
|
-
return [
|
|
222
|
+
return [...new Set([roots.workspaceRoot, roots.tmp, roots.userTmp].filter((root) => root.length > 0))];
|
|
160
223
|
}
|
|
161
224
|
function pathInsideRoot(target, root) {
|
|
162
225
|
const normalizedTarget = target.length > 1 && target.endsWith("/") ? target.slice(0, -1) : target;
|
|
@@ -183,6 +246,11 @@ function grepArgv(rg, pattern, base, include) {
|
|
|
183
246
|
function globArgv(rg, pattern) {
|
|
184
247
|
return [rg, "--no-config", "--files", "--hidden", "--iglob", pattern, "--glob", "!.git/**", "--sort", "modified"];
|
|
185
248
|
}
|
|
249
|
+
function atomicWriteScript(directory, tmp, target) {
|
|
250
|
+
const write = `cat > ${shJoin([tmp])} && mv -f ${shJoin([tmp, target])}`;
|
|
251
|
+
const cleanup = `{ status=$?; rm -f ${shJoin([tmp])}; exit $status; }`;
|
|
252
|
+
return `mkdir -p ${shJoin([directory])} && ${write} || ${cleanup}`;
|
|
253
|
+
}
|
|
186
254
|
|
|
187
255
|
// src/probe.ts
|
|
188
256
|
function probeOutcome(exitCode, timedOut, stderr = "") {
|
|
@@ -194,12 +262,13 @@ function probeOutcome(exitCode, timedOut, stderr = "") {
|
|
|
194
262
|
return { available: false, reason: "rejected" };
|
|
195
263
|
}
|
|
196
264
|
function hostFactsCommand() {
|
|
265
|
+
const line = (body) => `( ${body} ) || printf '\\n'`;
|
|
197
266
|
return [
|
|
198
267
|
"uname -s",
|
|
199
268
|
"uname -m",
|
|
200
|
-
"cd /tmp && pwd -P",
|
|
201
|
-
'cd "${TMPDIR:-/tmp}" && pwd -P',
|
|
202
|
-
|
|
269
|
+
line("cd /tmp && pwd -P"),
|
|
270
|
+
line('cd "${TMPDIR:-/tmp}" && pwd -P'),
|
|
271
|
+
`printf '%s\\n' "$HOME"`
|
|
203
272
|
].join("; ");
|
|
204
273
|
}
|
|
205
274
|
function parseHostFacts(stdout) {
|
|
@@ -246,8 +315,7 @@ function remoteRoot(env = process.env) {
|
|
|
246
315
|
return path.join(home, "remote");
|
|
247
316
|
}
|
|
248
317
|
function isValidHostId(hostId) {
|
|
249
|
-
|
|
250
|
-
return /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(hostId);
|
|
318
|
+
return /^[a-z][a-z0-9-]*$/.test(hostId);
|
|
251
319
|
}
|
|
252
320
|
function encodeRemotePath(remotePath) {
|
|
253
321
|
return Buffer.from(remotePath, "utf8").toString("base64url");
|
|
@@ -314,14 +382,6 @@ function toModelPath(remotePath, route, env = process.env) {
|
|
|
314
382
|
return toPlaceholderPath(normalized, route.hostId, env);
|
|
315
383
|
}
|
|
316
384
|
|
|
317
|
-
// src/shell-quote.ts
|
|
318
|
-
function shQuote(value) {
|
|
319
|
-
return `'${value.replaceAll("'", `'\\''`)}'`;
|
|
320
|
-
}
|
|
321
|
-
function shJoin(argv) {
|
|
322
|
-
return argv.map(shQuote).join(" ");
|
|
323
|
-
}
|
|
324
|
-
|
|
325
385
|
// src/execute.ts
|
|
326
386
|
var require2 = createRequire(import.meta.url);
|
|
327
387
|
var READ_CAP = 1024 * 1024;
|
|
@@ -332,8 +392,8 @@ async function probeHost(pool, host, secret) {
|
|
|
332
392
|
const uname = await pool.exec(host, secret, hostFactsCommand(), 2e4);
|
|
333
393
|
const facts = parseHostFacts(uname.stdout);
|
|
334
394
|
const { os: os2, arch } = facts;
|
|
335
|
-
const tmp = await
|
|
336
|
-
const userTmp = await
|
|
395
|
+
const tmp = await existingRemotePath(pool, host, secret, facts.tmp);
|
|
396
|
+
const userTmp = await existingRemotePath(pool, host, secret, facts.userTmp);
|
|
337
397
|
const home = facts.home;
|
|
338
398
|
const roots = { workspaceRoot: "/", tmp, userTmp };
|
|
339
399
|
let sandbox = { available: false, reason: "missing" };
|
|
@@ -393,10 +453,8 @@ function buildResolveMode(ctx) {
|
|
|
393
453
|
return async (exec, requested, justification, toolName, subject) => {
|
|
394
454
|
const session = exec.agent?.session;
|
|
395
455
|
const standing = ctx.sandboxPolicy.resolve(session === void 0 ? {} : { session }).mode;
|
|
396
|
-
|
|
397
|
-
if (requested === void 0 || justification === void 0)
|
|
398
|
-
throw new Error("sandbox_permissions and justification must be sent together");
|
|
399
|
-
}
|
|
456
|
+
validateEscalationArgs(requested, justification);
|
|
457
|
+
if (requested === void 0 || justification === void 0) return standing;
|
|
400
458
|
const approved = await approveEscalation({
|
|
401
459
|
requestedMode: requested,
|
|
402
460
|
justification,
|
|
@@ -414,9 +472,17 @@ function buildResolveMode(ctx) {
|
|
|
414
472
|
}
|
|
415
473
|
async function rootsFor(status, workspace, pool, host, secret) {
|
|
416
474
|
const workspaceRoot = await canonicalRemotePath(pool, host, secret, workspace);
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
475
|
+
return { workspaceRoot, tmp: status.roots.tmp, userTmp: status.roots.userTmp };
|
|
476
|
+
}
|
|
477
|
+
var NOT_FOUND = /no such file|cannot find|not found|does not exist/i;
|
|
478
|
+
async function existingRemotePath(pool, host, secret, remotePath) {
|
|
479
|
+
if (remotePath.trim().length === 0) return "";
|
|
480
|
+
try {
|
|
481
|
+
return await pool.sftpRealpath(host, secret, remotePath);
|
|
482
|
+
} catch (error) {
|
|
483
|
+
if (error instanceof Error && NOT_FOUND.test(error.message)) return "";
|
|
484
|
+
return posix3.normalize(remotePath);
|
|
485
|
+
}
|
|
420
486
|
}
|
|
421
487
|
async function canonicalRemotePath(pool, host, secret, remotePath) {
|
|
422
488
|
try {
|
|
@@ -464,12 +530,26 @@ function commandArgv(status, mode, roots, workdir, command) {
|
|
|
464
530
|
return `cd ${shJoin([workdir])} && ${shJoin(inner)}`;
|
|
465
531
|
}
|
|
466
532
|
if (status.sandboxTool === "bwrap") {
|
|
467
|
-
const
|
|
468
|
-
return shJoin(["bwrap", ...
|
|
533
|
+
const profile = bwrapProfileArgs(mode, roots.workspaceRoot) ?? [];
|
|
534
|
+
return shJoin(["bwrap", ...profile, "--chdir", workdir, "--", ...inner]);
|
|
469
535
|
}
|
|
470
|
-
const writable = mode === "workspace-write" ?
|
|
471
|
-
|
|
472
|
-
|
|
536
|
+
const writable = mode === "workspace-write" ? fileWritableRoots(mode, roots) : [];
|
|
537
|
+
return seatbeltBashCommand(mode, writable, workdir, command);
|
|
538
|
+
}
|
|
539
|
+
function fileCommand(status, mode, roots, script) {
|
|
540
|
+
if (status.sandboxTool === "bwrap") {
|
|
541
|
+
const profile = bwrapFileProfileArgs(mode, roots) ?? [];
|
|
542
|
+
return shJoin(["bwrap", ...profile, "--chdir", roots.workspaceRoot, "--", "bash", "-c", script]);
|
|
543
|
+
}
|
|
544
|
+
const writable = mode === "workspace-write" ? fileWritableRoots(mode, roots) : [];
|
|
545
|
+
return seatbeltBashCommand(mode, writable, roots.workspaceRoot, script);
|
|
546
|
+
}
|
|
547
|
+
function finishBackground(result, note, append) {
|
|
548
|
+
if (note.length > 0) append(note);
|
|
549
|
+
return {
|
|
550
|
+
status: result.timedOut || result.signal === "KILL" ? "killed" : "completed",
|
|
551
|
+
detail: result.timedOut ? "timed out" : result.signal === "KILL" ? "cancelled" : `exit code: ${result.code ?? "null"}`
|
|
552
|
+
};
|
|
473
553
|
}
|
|
474
554
|
async function startBackground(hostApi, host, secret, status, route, command, workdir, cwd, mode, exec) {
|
|
475
555
|
const jobs = hostApi.jobs;
|
|
@@ -487,23 +567,20 @@ async function startBackground(hostApi, host, secret, status, route, command, wo
|
|
|
487
567
|
kind: "bash",
|
|
488
568
|
label: command,
|
|
489
569
|
owner: exec.agent?.session?.id,
|
|
490
|
-
toolCall: exec.callId === void 0 ? void 0 : {
|
|
570
|
+
toolCall: exec.callId === void 0 ? void 0 : {
|
|
571
|
+
callId: exec.callId,
|
|
572
|
+
rootCallId: exec.rootCallId ?? exec.callId
|
|
573
|
+
},
|
|
491
574
|
run: (job) => {
|
|
492
575
|
let opened;
|
|
493
576
|
let cancelled = false;
|
|
494
|
-
const done = hostApi.pool.open(host, secret, argv, 0, exec.signal
|
|
577
|
+
const done = hostApi.pool.open(host, secret, argv, 0, exec.signal, void 0, (chunk) => {
|
|
578
|
+
job.append(chunk.text, { channel: chunk.stream });
|
|
579
|
+
}).then((commandHandle) => {
|
|
495
580
|
opened = commandHandle;
|
|
496
581
|
if (cancelled || exec.signal?.aborted === true) commandHandle.close();
|
|
497
582
|
return commandHandle.done;
|
|
498
|
-
}).then((result) => {
|
|
499
|
-
const text = `${capturedText(result.stdout, result.stdoutTruncated)}${capturedText(result.stderr, result.stderrTruncated)}${note}`;
|
|
500
|
-
job.append(text);
|
|
501
|
-
return {
|
|
502
|
-
status: result.timedOut || result.signal === "KILL" ? "killed" : "completed",
|
|
503
|
-
detail: result.timedOut ? "timed out" : result.signal === "KILL" ? "cancelled" : `exit code: ${result.code ?? "null"}`,
|
|
504
|
-
result: text
|
|
505
|
-
};
|
|
506
|
-
}).catch((error) => ({
|
|
583
|
+
}).then((result) => finishBackground(result, note, (text) => job.append(text))).catch((error) => ({
|
|
507
584
|
status: "failed",
|
|
508
585
|
detail: error instanceof Error ? error.message : String(error)
|
|
509
586
|
}));
|
|
@@ -523,8 +600,7 @@ async function runRead(hostApi, route, args, exec) {
|
|
|
523
600
|
const host = requireHost(hostApi, route.hostId);
|
|
524
601
|
const secret = await hostApi.secretOf(host);
|
|
525
602
|
const remotePath = resolveRemotePath(String(args.file_path ?? ""), route.remoteCwd, route.placeholderCwd);
|
|
526
|
-
const bytes = await hostApi.pool.sftpRead(host, secret, remotePath, READ_CAP);
|
|
527
|
-
throwIfAborted(exec?.signal);
|
|
603
|
+
const bytes = await withSignal(hostApi.pool.sftpRead(host, secret, remotePath, READ_CAP), exec?.signal);
|
|
528
604
|
const text = bytes.toString("utf8");
|
|
529
605
|
const all = text.split(/\n/);
|
|
530
606
|
const offset = typeof args.offset === "number" && args.offset > 0 ? Math.floor(args.offset) : 1;
|
|
@@ -551,8 +627,9 @@ ${hintMarker("operation")}`, "FS_SANDBOX_DENIED");
|
|
|
551
627
|
}
|
|
552
628
|
let before = null;
|
|
553
629
|
try {
|
|
554
|
-
before = (await hostApi.pool.sftpRead(host, secret, target, READ_CAP)).toString("utf8");
|
|
555
|
-
} catch {
|
|
630
|
+
before = (await withSignal(hostApi.pool.sftpRead(host, secret, target, READ_CAP), exec.signal)).toString("utf8");
|
|
631
|
+
} catch (error) {
|
|
632
|
+
if (exec.signal?.aborted === true || error instanceof Error && error.name === "AbortError") throw error;
|
|
556
633
|
before = null;
|
|
557
634
|
}
|
|
558
635
|
const content = String(args.content ?? "");
|
|
@@ -576,7 +653,7 @@ async function runEdit(hostApi, route, args, exec) {
|
|
|
576
653
|
throw new FsError(`${denialMarker(mode)}
|
|
577
654
|
${hintMarker("operation")}`, "FS_SANDBOX_DENIED");
|
|
578
655
|
}
|
|
579
|
-
const before = (await hostApi.pool.sftpRead(host, secret, real, READ_CAP)).toString("utf8");
|
|
656
|
+
const before = (await withSignal(hostApi.pool.sftpRead(host, secret, real, READ_CAP), exec.signal)).toString("utf8");
|
|
580
657
|
const oldString = String(args.old_string ?? "");
|
|
581
658
|
const newString = String(args.new_string ?? "");
|
|
582
659
|
const replaceAll = args.replace_all === true;
|
|
@@ -587,15 +664,17 @@ ${hintMarker("operation")}`, "FS_SANDBOX_DENIED");
|
|
|
587
664
|
await commitFile(hostApi, host, secret, status, mode, roots, real, after, exec.signal);
|
|
588
665
|
return { path: toModelPath(real, route), before, after };
|
|
589
666
|
}
|
|
590
|
-
async function runReadImage(hostApi, route, args, save) {
|
|
667
|
+
async function runReadImage(hostApi, route, args, save, exec) {
|
|
668
|
+
throwIfAborted(exec?.signal);
|
|
591
669
|
const host = requireHost(hostApi, route.hostId);
|
|
592
670
|
const secret = await hostApi.secretOf(host);
|
|
593
671
|
const remotePath = resolveRemotePath(String(args.file_path ?? ""), route.remoteCwd, route.placeholderCwd);
|
|
594
|
-
const data = await hostApi.pool.sftpRead(host, secret, remotePath, IMAGE_CAP);
|
|
672
|
+
const data = await withSignal(hostApi.pool.sftpRead(host, secret, remotePath, IMAGE_CAP), exec?.signal);
|
|
595
673
|
const image = await save(data, posix3.basename(remotePath));
|
|
596
674
|
return { path: toModelPath(remotePath, route), image };
|
|
597
675
|
}
|
|
598
|
-
async function runGlob(hostApi, route, args) {
|
|
676
|
+
async function runGlob(hostApi, route, args, exec) {
|
|
677
|
+
throwIfAborted(exec?.signal);
|
|
599
678
|
const host = requireHost(hostApi, route.hostId);
|
|
600
679
|
const secret = await hostApi.secretOf(host);
|
|
601
680
|
const status = await hostApi.statusOf(host);
|
|
@@ -603,23 +682,24 @@ async function runGlob(hostApi, route, args) {
|
|
|
603
682
|
const base = typeof args.path === "string" ? resolveRemotePath(args.path, route.remoteCwd, route.placeholderCwd) : route.remoteCwd;
|
|
604
683
|
const pattern = String(args.pattern ?? "");
|
|
605
684
|
const command = `cd ${shJoin([base])} && ${shJoin(globArgv(status.ripgrepPath, pattern))}`;
|
|
606
|
-
const result = await hostApi.pool.exec(host, secret, command, 3e4);
|
|
607
|
-
|
|
685
|
+
const result = await hostApi.pool.exec(host, secret, command, 3e4, exec?.signal);
|
|
686
|
+
throwIfRgFailed(result, exec?.signal, "glob");
|
|
608
687
|
const paths = result.stdout.split("\n").map((line) => line.trim()).filter(Boolean).map((line) => {
|
|
609
688
|
const absolute = posix3.isAbsolute(line) ? line : posix3.join(base, line);
|
|
610
689
|
return toModelPath(absolute, route);
|
|
611
690
|
});
|
|
612
691
|
return { root: toModelPath(base, route), paths };
|
|
613
692
|
}
|
|
614
|
-
async function runGrep(hostApi, route, args) {
|
|
693
|
+
async function runGrep(hostApi, route, args, exec) {
|
|
694
|
+
throwIfAborted(exec?.signal);
|
|
615
695
|
const host = requireHost(hostApi, route.hostId);
|
|
616
696
|
const secret = await hostApi.secretOf(host);
|
|
617
697
|
const status = await hostApi.statusOf(host);
|
|
618
698
|
if (status.ripgrepPath.length === 0) throw new Error("ripgrep is not available on this remote host");
|
|
619
699
|
const base = typeof args.path === "string" ? resolveRemotePath(args.path, route.remoteCwd, route.placeholderCwd) : route.remoteCwd;
|
|
620
700
|
const argv = grepArgv(status.ripgrepPath, String(args.pattern ?? ""), base, typeof args.include === "string" ? args.include : void 0);
|
|
621
|
-
const result = await hostApi.pool.exec(host, secret, shJoin(argv), 3e4);
|
|
622
|
-
|
|
701
|
+
const result = await hostApi.pool.exec(host, secret, shJoin(argv), 3e4, exec?.signal);
|
|
702
|
+
throwIfRgFailed(result, exec?.signal, "grep");
|
|
623
703
|
const matches = [];
|
|
624
704
|
for (const row of result.stdout.split("\n")) {
|
|
625
705
|
if (row.trim().length === 0) continue;
|
|
@@ -639,6 +719,24 @@ async function runGrep(hostApi, route, args) {
|
|
|
639
719
|
}
|
|
640
720
|
return { matches };
|
|
641
721
|
}
|
|
722
|
+
async function runWorkingDirectory(hostApi, route, args, exec, setDirectory) {
|
|
723
|
+
if (args.cd === void 0 || args.cd === "") return { cwd: route.placeholderCwd };
|
|
724
|
+
const requested = resolveRemotePath(String(args.cd), route.remoteCwd, route.placeholderCwd);
|
|
725
|
+
const host = requireHost(hostApi, route.hostId);
|
|
726
|
+
const secret = await hostApi.secretOf(host);
|
|
727
|
+
let remote;
|
|
728
|
+
try {
|
|
729
|
+
remote = await hostApi.pool.sftpRealpath(host, secret, requested);
|
|
730
|
+
} catch {
|
|
731
|
+
throw new Error(`directory does not exist on the remote host: ${requested}`);
|
|
732
|
+
}
|
|
733
|
+
const local = mapRemoteToLocal(route.hostId, remote);
|
|
734
|
+
if (local === null) throw new Error("remote directory cannot be mapped to a workspace path");
|
|
735
|
+
await mkdir(local, { recursive: true });
|
|
736
|
+
if (exec.agent === void 0) throw new Error("working_directory requires an agent Session");
|
|
737
|
+
const cwd = await setDirectory(local);
|
|
738
|
+
return { cwd };
|
|
739
|
+
}
|
|
642
740
|
async function resolveCreatedPath(pool, host, secret, lexicalPath) {
|
|
643
741
|
let cursor = posix3.normalize(lexicalPath);
|
|
644
742
|
const missing = [];
|
|
@@ -660,21 +758,33 @@ async function commitFile(hostApi, host, secret, status, mode, roots, target, co
|
|
|
660
758
|
if (mode !== "danger-full-access" && !status.sandbox.available) {
|
|
661
759
|
throw new SandboxUnavailableError(mode, "remote sandbox probe failed");
|
|
662
760
|
}
|
|
663
|
-
const
|
|
664
|
-
|
|
665
|
-
const
|
|
666
|
-
const wrapped = mode === "danger-full-access" ?
|
|
667
|
-
const result = await hostApi.pool.exec(host, secret, wrapped, 2e4, signal);
|
|
668
|
-
if (result.code !== 0)
|
|
669
|
-
await hostApi.pool.exec(host, secret, `rm -f ${shJoin([tmp])}`, 1e4).catch(() => void 0);
|
|
670
|
-
throw new Error(result.stderr || "remote write failed");
|
|
671
|
-
}
|
|
761
|
+
const directory = posix3.dirname(target);
|
|
762
|
+
const tmp = posix3.join(directory, `.dsh-permission-remote-${randomBytes(8).toString("hex")}`);
|
|
763
|
+
const script = atomicWriteScript(directory, tmp, target);
|
|
764
|
+
const wrapped = mode === "danger-full-access" ? script : fileCommand(status, mode, roots, script);
|
|
765
|
+
const result = await hostApi.pool.exec(host, secret, wrapped, 2e4, signal, content);
|
|
766
|
+
if (result.code !== 0) throw new Error(result.stderr || "remote write failed");
|
|
672
767
|
}
|
|
673
768
|
function throwIfAborted(signal) {
|
|
674
|
-
if (signal?.aborted
|
|
769
|
+
if (signal?.aborted === true) throw abortError();
|
|
770
|
+
}
|
|
771
|
+
function throwIfRgFailed(result, signal, label) {
|
|
772
|
+
if (signal?.aborted === true) throw abortError();
|
|
773
|
+
if (result.code !== 0 && result.code !== 1) throw new Error(result.stderr || `${label} failed`);
|
|
774
|
+
}
|
|
775
|
+
function abortError() {
|
|
675
776
|
const error = new Error("tool call aborted");
|
|
676
777
|
error.name = "AbortError";
|
|
677
|
-
|
|
778
|
+
return error;
|
|
779
|
+
}
|
|
780
|
+
function withSignal(work, signal) {
|
|
781
|
+
throwIfAborted(signal);
|
|
782
|
+
if (signal === void 0) return work;
|
|
783
|
+
return new Promise((resolve, reject) => {
|
|
784
|
+
const fail = () => reject(abortError());
|
|
785
|
+
signal.addEventListener("abort", fail, { once: true });
|
|
786
|
+
work.then(resolve, reject).finally(() => signal.removeEventListener("abort", fail));
|
|
787
|
+
});
|
|
678
788
|
}
|
|
679
789
|
function capturedText(text, truncated) {
|
|
680
790
|
if (!truncated) return text;
|
|
@@ -698,7 +808,7 @@ function sniffImage(data) {
|
|
|
698
808
|
}
|
|
699
809
|
|
|
700
810
|
// src/shadow.ts
|
|
701
|
-
var ROUTED_TOOLS = ["bash", "read", "write", "edit", "read_image", "glob", "grep"];
|
|
811
|
+
var ROUTED_TOOLS = ["bash", "read", "write", "edit", "read_image", "glob", "grep", "working_directory"];
|
|
702
812
|
function planShadows(visible) {
|
|
703
813
|
const plans = [];
|
|
704
814
|
for (const name2 of ROUTED_TOOLS) {
|
|
@@ -724,6 +834,7 @@ function dispatchShadow(official, remote, remoteExecute, args, exec) {
|
|
|
724
834
|
|
|
725
835
|
// src/ssh-pool.ts
|
|
726
836
|
import { readFile } from "node:fs/promises";
|
|
837
|
+
import { StringDecoder } from "node:string_decoder";
|
|
727
838
|
import { Client } from "ssh2";
|
|
728
839
|
|
|
729
840
|
// src/pool-claim.ts
|
|
@@ -740,6 +851,14 @@ function claimPending(map, key, create) {
|
|
|
740
851
|
|
|
741
852
|
// src/ssh-pool.ts
|
|
742
853
|
var OUTPUT_CAP = 1024 * 1024;
|
|
854
|
+
function takeChannelText(decoder, chunk) {
|
|
855
|
+
return decoder.write(chunk);
|
|
856
|
+
}
|
|
857
|
+
function truncationMarker(noted) {
|
|
858
|
+
if (noted.current) return "";
|
|
859
|
+
noted.current = true;
|
|
860
|
+
return "\n[output truncated]";
|
|
861
|
+
}
|
|
743
862
|
var SshFailure = class extends Error {
|
|
744
863
|
constructor(message, stage) {
|
|
745
864
|
super(message);
|
|
@@ -775,14 +894,14 @@ var SshPool = class {
|
|
|
775
894
|
if (current === void 0) return;
|
|
776
895
|
void current.then((live) => closeLive(live)).catch(() => void 0);
|
|
777
896
|
}
|
|
778
|
-
async exec(host, secret, command, timeoutMs, signal) {
|
|
779
|
-
const commandHandle = await this.open(host, secret, command, timeoutMs, signal);
|
|
897
|
+
async exec(host, secret, command, timeoutMs, signal, stdin, onOutput) {
|
|
898
|
+
const commandHandle = await this.open(host, secret, command, timeoutMs, signal, stdin, onOutput);
|
|
780
899
|
return commandHandle.done;
|
|
781
900
|
}
|
|
782
901
|
/** `timeoutMs` of 0 waits until the command exits, the signal aborts, or `close` kills its channel. */
|
|
783
|
-
async open(host, secret, command, timeoutMs, signal) {
|
|
902
|
+
async open(host, secret, command, timeoutMs, signal, stdin, onOutput) {
|
|
784
903
|
const live = await this.acquire(host, secret, true);
|
|
785
|
-
const commandHandle = openChannel(live.client, command, timeoutMs);
|
|
904
|
+
const commandHandle = openChannel(live.client, command, timeoutMs, stdin, onOutput);
|
|
786
905
|
if (signal !== void 0) {
|
|
787
906
|
const abort = () => commandHandle.close();
|
|
788
907
|
if (signal.aborted) commandHandle.close();
|
|
@@ -826,6 +945,10 @@ var SshPool = class {
|
|
|
826
945
|
const session = await this.sftp(host, secret);
|
|
827
946
|
return realpathOf(session, remotePath);
|
|
828
947
|
}
|
|
948
|
+
async sftpStat(host, secret, remotePath) {
|
|
949
|
+
const session = await this.sftp(host, secret);
|
|
950
|
+
return statInfo(session, remotePath);
|
|
951
|
+
}
|
|
829
952
|
async sftpList(host, secret, remotePath) {
|
|
830
953
|
const session = await this.sftp(host, secret);
|
|
831
954
|
const rows = await readdir(session, remotePath);
|
|
@@ -934,7 +1057,7 @@ function connect(host, secret, accept) {
|
|
|
934
1057
|
client.connect(options);
|
|
935
1058
|
});
|
|
936
1059
|
}
|
|
937
|
-
function openChannel(client, command, timeoutMs) {
|
|
1060
|
+
function openChannel(client, command, timeoutMs, stdin, onOutput) {
|
|
938
1061
|
let settled = false;
|
|
939
1062
|
let cause = "exit";
|
|
940
1063
|
let stream;
|
|
@@ -948,9 +1071,33 @@ function openChannel(client, command, timeoutMs) {
|
|
|
948
1071
|
const stderr = [];
|
|
949
1072
|
let stdoutTruncated = false;
|
|
950
1073
|
let stderrTruncated = false;
|
|
1074
|
+
const decoders = {
|
|
1075
|
+
stdout: new StringDecoder("utf8"),
|
|
1076
|
+
stderr: new StringDecoder("utf8")
|
|
1077
|
+
};
|
|
1078
|
+
const decoderEnded = { stdout: false, stderr: false };
|
|
1079
|
+
const truncationNoted = { current: false };
|
|
1080
|
+
const emit = (which, text) => {
|
|
1081
|
+
if (text.length === 0 || onOutput === void 0) return;
|
|
1082
|
+
try {
|
|
1083
|
+
onOutput({ stream: which, text });
|
|
1084
|
+
} catch {
|
|
1085
|
+
}
|
|
1086
|
+
};
|
|
1087
|
+
const flushDecoder = (which) => {
|
|
1088
|
+
if (decoderEnded[which]) return;
|
|
1089
|
+
decoderEnded[which] = true;
|
|
1090
|
+
emit(which, decoders[which].end());
|
|
1091
|
+
};
|
|
1092
|
+
const markTruncation = (which) => {
|
|
1093
|
+
flushDecoder(which);
|
|
1094
|
+
emit(which, truncationMarker(truncationNoted));
|
|
1095
|
+
};
|
|
951
1096
|
const finish = (code, signal) => {
|
|
952
1097
|
if (settled) return;
|
|
953
1098
|
settled = true;
|
|
1099
|
+
flushDecoder("stdout");
|
|
1100
|
+
flushDecoder("stderr");
|
|
954
1101
|
clearTimeout(timer);
|
|
955
1102
|
clearTimeout(fallback);
|
|
956
1103
|
resolveDone({
|
|
@@ -994,24 +1141,31 @@ function openChannel(client, command, timeoutMs) {
|
|
|
994
1141
|
stream = opened;
|
|
995
1142
|
const push = (target, chunk, which) => {
|
|
996
1143
|
const used = target.reduce((sum, part) => sum + part.length, 0);
|
|
997
|
-
|
|
1144
|
+
const room = OUTPUT_CAP - used;
|
|
1145
|
+
if (room <= 0) {
|
|
998
1146
|
if (which === "stdout") stdoutTruncated = true;
|
|
999
1147
|
else stderrTruncated = true;
|
|
1148
|
+
markTruncation(which);
|
|
1000
1149
|
stop("truncate");
|
|
1001
1150
|
return;
|
|
1002
1151
|
}
|
|
1003
|
-
|
|
1004
|
-
|
|
1152
|
+
const kept = Buffer.from(chunk.subarray(0, Math.min(chunk.length, room)));
|
|
1153
|
+
target.push(kept);
|
|
1154
|
+
emit(which, takeChannelText(decoders[which], kept));
|
|
1155
|
+
if (kept.length < chunk.length) {
|
|
1005
1156
|
if (which === "stdout") stdoutTruncated = true;
|
|
1006
1157
|
else stderrTruncated = true;
|
|
1158
|
+
markTruncation(which);
|
|
1007
1159
|
stop("truncate");
|
|
1008
|
-
return;
|
|
1009
1160
|
}
|
|
1010
|
-
target.push(chunk);
|
|
1011
1161
|
};
|
|
1012
1162
|
stream.on("data", (chunk) => push(stdout, chunk, "stdout"));
|
|
1013
1163
|
stream.stderr.on("data", (chunk) => push(stderr, chunk, "stderr"));
|
|
1014
1164
|
stream.on("close", (code, signal) => finish(code, signal));
|
|
1165
|
+
if (stdin !== void 0 && cause === "exit") {
|
|
1166
|
+
stream.write(typeof stdin === "string" ? Buffer.from(stdin) : stdin);
|
|
1167
|
+
stream.end();
|
|
1168
|
+
}
|
|
1015
1169
|
if (cause !== "exit") {
|
|
1016
1170
|
try {
|
|
1017
1171
|
stream.signal?.("KILL", () => void 0);
|
|
@@ -1053,14 +1207,17 @@ async function closeLive(live) {
|
|
|
1053
1207
|
}
|
|
1054
1208
|
live.client.end();
|
|
1055
1209
|
}
|
|
1056
|
-
function
|
|
1210
|
+
function statInfo(sftp, remotePath) {
|
|
1057
1211
|
return new Promise((resolve, reject) => {
|
|
1058
1212
|
sftp.stat(remotePath, (error, stats) => {
|
|
1059
1213
|
if (error) reject(new SshFailure(error.message, "sftp-stat"));
|
|
1060
|
-
else resolve(stats.size);
|
|
1214
|
+
else resolve({ isDirectory: stats.isDirectory(), size: stats.size });
|
|
1061
1215
|
});
|
|
1062
1216
|
});
|
|
1063
1217
|
}
|
|
1218
|
+
function statSize(sftp, remotePath) {
|
|
1219
|
+
return statInfo(sftp, remotePath).then((info) => info.size);
|
|
1220
|
+
}
|
|
1064
1221
|
function readAll(sftp, remotePath) {
|
|
1065
1222
|
return new Promise((resolve, reject) => {
|
|
1066
1223
|
sftp.readFile(remotePath, (error, data) => {
|
|
@@ -1130,10 +1287,11 @@ var SshApi = class extends TypertRemoteService {
|
|
|
1130
1287
|
}
|
|
1131
1288
|
async createWorkspace(input) {
|
|
1132
1289
|
const host = this.core.requireHost(input.hostId);
|
|
1133
|
-
await this.core.pool.
|
|
1290
|
+
const info = await this.core.pool.sftpStat(host, await this.core.secret(host), input.path);
|
|
1291
|
+
if (!info.isDirectory) throw new Error(`remote path is not a directory: ${input.path}`);
|
|
1134
1292
|
const local = mapRemoteToLocal(input.hostId, input.path);
|
|
1135
1293
|
if (local === null) throw new Error("remote path must be absolute");
|
|
1136
|
-
await
|
|
1294
|
+
await mkdir2(local, { recursive: true });
|
|
1137
1295
|
const title = input.title?.trim() || basename(input.path);
|
|
1138
1296
|
await this.core.workspaces.create(local, title);
|
|
1139
1297
|
return { path: local };
|
|
@@ -1161,13 +1319,6 @@ var Core = class {
|
|
|
1161
1319
|
pool;
|
|
1162
1320
|
config;
|
|
1163
1321
|
status = /* @__PURE__ */ new Map();
|
|
1164
|
-
update(config) {
|
|
1165
|
-
this.config = config;
|
|
1166
|
-
this.status.clear();
|
|
1167
|
-
}
|
|
1168
|
-
hosts() {
|
|
1169
|
-
return this.config.hosts;
|
|
1170
|
-
}
|
|
1171
1322
|
requireHost(hostId) {
|
|
1172
1323
|
const host = this.config.hosts.find((item) => item.id === hostId);
|
|
1173
1324
|
if (host === void 0) throw new Error(`unknown SSH host ${hostId}`);
|
|
@@ -1175,10 +1326,7 @@ var Core = class {
|
|
|
1175
1326
|
}
|
|
1176
1327
|
async trust(hostId, fingerprint) {
|
|
1177
1328
|
this.requireHost(hostId);
|
|
1178
|
-
this.config = {
|
|
1179
|
-
...this.config,
|
|
1180
|
-
hosts: this.config.hosts.map((item) => item.id === hostId ? { ...item, fingerprint } : item)
|
|
1181
|
-
};
|
|
1329
|
+
this.config.hosts = this.config.hosts.map((item) => item.id === hostId ? { ...item, fingerprint } : item);
|
|
1182
1330
|
this.pool.invalidate(hostId);
|
|
1183
1331
|
this.status.delete(hostId);
|
|
1184
1332
|
await this.persistHosts();
|
|
@@ -1193,6 +1341,8 @@ var Core = class {
|
|
|
1193
1341
|
return { password: record.payload.password, passphrase: record.payload.passphrase };
|
|
1194
1342
|
}
|
|
1195
1343
|
async saveSecret(hostId, material) {
|
|
1344
|
+
assertHostId(hostId);
|
|
1345
|
+
this.requireHost(hostId);
|
|
1196
1346
|
const key = credentialKey("dsh-permission-remote", hostId);
|
|
1197
1347
|
await this.ctx.credentials.modifyRecord(key, async (current) => {
|
|
1198
1348
|
const previous = current?.kind === "grant" && isPayload(current.payload) ? current.payload : {};
|
|
@@ -1206,19 +1356,23 @@ var Core = class {
|
|
|
1206
1356
|
});
|
|
1207
1357
|
}
|
|
1208
1358
|
async saveHost(host) {
|
|
1359
|
+
assertHostId(host.id);
|
|
1209
1360
|
const hosts = this.config.hosts.filter((item) => item.id !== host.id);
|
|
1210
|
-
this.config =
|
|
1361
|
+
this.config.hosts = [...hosts, host];
|
|
1211
1362
|
await this.persistHosts();
|
|
1212
1363
|
}
|
|
1213
1364
|
async deleteHost(hostId) {
|
|
1214
|
-
this.config =
|
|
1365
|
+
this.config.hosts = this.config.hosts.filter((item) => item.id !== hostId);
|
|
1215
1366
|
this.pool.invalidate(hostId);
|
|
1216
1367
|
this.status.delete(hostId);
|
|
1217
1368
|
await this.persistHosts();
|
|
1218
1369
|
}
|
|
1219
1370
|
async persistHosts() {
|
|
1220
1371
|
const row = this.ctx.settings.describe().find((item) => String(item.ns) === "permission-remote");
|
|
1221
|
-
if (row === void 0)
|
|
1372
|
+
if (row === void 0) {
|
|
1373
|
+
this.ctx.logger?.warn('[@dsh-permission-remote] settings row "permission-remote" is missing; the host list changed in memory but was not persisted');
|
|
1374
|
+
return;
|
|
1375
|
+
}
|
|
1222
1376
|
await this.ctx.settings.update(String(row.ns), { hosts: this.config.hosts });
|
|
1223
1377
|
}
|
|
1224
1378
|
probe(hostId, refresh = false) {
|
|
@@ -1246,6 +1400,9 @@ var Core = class {
|
|
|
1246
1400
|
};
|
|
1247
1401
|
}
|
|
1248
1402
|
};
|
|
1403
|
+
function assertHostId(hostId) {
|
|
1404
|
+
if (!isValidHostId(hostId)) throw new Error("host id must be a lowercase credential key: it starts with a letter and contains only letters, digits, and hyphens");
|
|
1405
|
+
}
|
|
1249
1406
|
function isPayload(value) {
|
|
1250
1407
|
return typeof value === "object" && value !== null;
|
|
1251
1408
|
}
|
|
@@ -1264,7 +1421,7 @@ function markRemote(service) {
|
|
|
1264
1421
|
}
|
|
1265
1422
|
var RUNNERS = {
|
|
1266
1423
|
bash: (host, route, args, exec) => runBash(host, route, args, exec),
|
|
1267
|
-
read: (host, route, args) => runRead(host, route, args),
|
|
1424
|
+
read: (host, route, args, exec) => runRead(host, route, args, exec),
|
|
1268
1425
|
write: (host, route, args, exec) => runWrite(host, route, args, exec),
|
|
1269
1426
|
edit: (host, route, args, exec) => runEdit(host, route, args, exec),
|
|
1270
1427
|
read_image: (host, route, args, exec, ctx) => runReadImage(host, route, args, async (data, fileName) => {
|
|
@@ -1273,9 +1430,14 @@ var RUNNERS = {
|
|
|
1273
1430
|
const mediaType = sniffImage(data);
|
|
1274
1431
|
if (mediaType === void 0) throw new Error(`cannot read "${fileName}": the file content is not a PNG, JPEG, WebP, or GIF`);
|
|
1275
1432
|
return attachments.saveImage({ data, mediaType, name: fileName });
|
|
1276
|
-
}),
|
|
1277
|
-
glob: (host, route, args) => runGlob(host, route, args),
|
|
1278
|
-
grep: (host, route, args) => runGrep(host, route, args)
|
|
1433
|
+
}, exec),
|
|
1434
|
+
glob: (host, route, args, exec) => runGlob(host, route, args, exec),
|
|
1435
|
+
grep: (host, route, args, exec) => runGrep(host, route, args, exec),
|
|
1436
|
+
working_directory: (host, route, args, exec, ctx) => runWorkingDirectory(host, route, args, exec, async (directory) => {
|
|
1437
|
+
const directories = ctx.get("workingDirectory");
|
|
1438
|
+
if (directories === void 0 || exec.agent === void 0) throw new Error("working_directory requires an agent Session");
|
|
1439
|
+
return directories.set(exec.agent, directory, exec.signal);
|
|
1440
|
+
})
|
|
1279
1441
|
};
|
|
1280
1442
|
function apply(rawCtx, rawConfig) {
|
|
1281
1443
|
const ctx = rawCtx;
|
|
@@ -1289,17 +1451,38 @@ function apply(rawCtx, rawConfig) {
|
|
|
1289
1451
|
void core.pool.dispose();
|
|
1290
1452
|
});
|
|
1291
1453
|
void api;
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1454
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1455
|
+
const attach = (agent) => {
|
|
1456
|
+
if (seen.has(agent)) return;
|
|
1457
|
+
seen.add(agent);
|
|
1458
|
+
let installed = false;
|
|
1459
|
+
try {
|
|
1460
|
+
installed = installShadows(ctx, core, agent, unregisters, () => {
|
|
1461
|
+
seen.delete(agent);
|
|
1462
|
+
});
|
|
1463
|
+
} finally {
|
|
1464
|
+
if (!installed) seen.delete(agent);
|
|
1465
|
+
}
|
|
1466
|
+
};
|
|
1467
|
+
ctx.on("agent/created", ({ agent }) => attach(agent));
|
|
1468
|
+
const agents = ctx.get("agents");
|
|
1469
|
+
if (typeof agents?.list === "function") {
|
|
1470
|
+
for (const agent of agents.list()) attach(agent);
|
|
1471
|
+
}
|
|
1472
|
+
ctx.logger?.info("[@dsh-permission-remote] loaded");
|
|
1473
|
+
}
|
|
1474
|
+
function installShadows(ctx, core, agent, unregisters, forget) {
|
|
1475
|
+
const cwd = agent.session?.header?.cwd;
|
|
1476
|
+
const initial = routeByCwd(cwd);
|
|
1477
|
+
const disposers = [];
|
|
1478
|
+
try {
|
|
1295
1479
|
const plans = planShadows((name2) => agent.ctx.tools.get(name2, agent));
|
|
1296
1480
|
const host = core.toolHost(agent.ctx.get("jobs"));
|
|
1297
|
-
const disposers = [];
|
|
1298
1481
|
for (const plan of plans) {
|
|
1299
1482
|
if (plan.official === void 0 && !(plan.name === "bash" && initial.kind === "remote")) continue;
|
|
1300
1483
|
const official = plan.official;
|
|
1301
1484
|
const runner = RUNNERS[plan.name];
|
|
1302
|
-
|
|
1485
|
+
disposers.push(agent.ctx.tools.register(withRemoteTimeout({
|
|
1303
1486
|
...capturedSchema(agent.ctx, plan.name, agent),
|
|
1304
1487
|
name: plan.name,
|
|
1305
1488
|
async execute(args, exec) {
|
|
@@ -1311,19 +1494,35 @@ function apply(rawCtx, rawConfig) {
|
|
|
1311
1494
|
}
|
|
1312
1495
|
return runner(host, current, args, exec, ctx);
|
|
1313
1496
|
}
|
|
1314
|
-
}));
|
|
1315
|
-
disposers.push(unregister);
|
|
1497
|
+
})));
|
|
1316
1498
|
}
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
};
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1499
|
+
} catch (error) {
|
|
1500
|
+
for (const unregister of disposers) unregister();
|
|
1501
|
+
ctx.logger?.warn(`[@dsh-permission-remote] shadow install failed for agent ${agentLabel(agent)}: ${error instanceof Error ? error.message : String(error)}`);
|
|
1502
|
+
if (initial.kind === "remote") throw error;
|
|
1503
|
+
return false;
|
|
1504
|
+
}
|
|
1505
|
+
let dropped = false;
|
|
1506
|
+
const drop = () => {
|
|
1507
|
+
if (dropped) return;
|
|
1508
|
+
dropped = true;
|
|
1509
|
+
for (const unregister of disposers) unregister();
|
|
1510
|
+
unregisters.delete(drop);
|
|
1511
|
+
forget();
|
|
1512
|
+
};
|
|
1513
|
+
unregisters.add(drop);
|
|
1514
|
+
try {
|
|
1515
|
+
agent.ctx.effect(() => drop);
|
|
1516
|
+
} catch (error) {
|
|
1517
|
+
drop();
|
|
1518
|
+
ctx.logger?.warn(`[@dsh-permission-remote] shadow install failed for agent ${agentLabel(agent)}: ${error instanceof Error ? error.message : String(error)}`);
|
|
1519
|
+
if (initial.kind === "remote") throw error;
|
|
1520
|
+
return false;
|
|
1521
|
+
}
|
|
1522
|
+
return true;
|
|
1523
|
+
}
|
|
1524
|
+
function agentLabel(agent) {
|
|
1525
|
+
return agent.session?.id ?? agent.session?.header?.cwd ?? "unknown";
|
|
1327
1526
|
}
|
|
1328
1527
|
function withRemoteTimeout(definition) {
|
|
1329
1528
|
const parameters = definition.parameters;
|
|
@@ -1366,7 +1565,7 @@ function fallbackSchema(toolName) {
|
|
|
1366
1565
|
timeoutMs: { type: "number" },
|
|
1367
1566
|
workdir: { type: "string" },
|
|
1368
1567
|
run_in_background: { type: "boolean" },
|
|
1369
|
-
sandbox_permissions: { type: "string", enum: [
|
|
1568
|
+
sandbox_permissions: { type: "string", enum: [...ESCALATION_TARGETS] },
|
|
1370
1569
|
justification: { type: "string" }
|
|
1371
1570
|
},
|
|
1372
1571
|
output: {
|