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/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
- hosts: Schema.array(Host).default([]),
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 value = Config(raw ?? {});
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
- hosts: value.hosts ?? [],
33
- maxConnections: value.maxConnections ?? 4
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
- if (mode === "workspace-write") {
130
- args.push("--tmpfs", "/tmp", "--bind", workspaceRoot, workspaceRoot);
131
- }
132
- return args;
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 [.../* @__PURE__ */ new Set([roots.workspaceRoot, roots.tmp, roots.userTmp])];
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
- 'printf %s "$HOME"'
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
- if (hostId.length === 0 || hostId === "." || hostId === "..") return false;
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 canonicalRemotePath(pool, host, secret, facts.tmp);
336
- const userTmp = await canonicalRemotePath(pool, host, secret, facts.userTmp);
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
- if (requested === void 0 && justification === void 0) return standing;
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
- const tmp = await canonicalRemotePath(pool, host, secret, status.roots.tmp);
418
- const userTmp = await canonicalRemotePath(pool, host, secret, status.roots.userTmp);
419
- return { workspaceRoot, tmp, userTmp };
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 profile2 = bwrapProfileArgs(mode, roots.workspaceRoot) ?? [];
468
- return shJoin(["bwrap", ...profile2, "--chdir", workdir, "--", ...inner]);
533
+ const profile = bwrapProfileArgs(mode, roots.workspaceRoot) ?? [];
534
+ return shJoin(["bwrap", ...profile, "--chdir", workdir, "--", ...inner]);
469
535
  }
470
- const writable = mode === "workspace-write" ? [roots.workspaceRoot, roots.tmp, roots.userTmp] : [];
471
- const profile = seatbeltProfileArgs(mode, writable) ?? [];
472
- return shJoin(["sandbox-exec", ...profile, "--", ...inner]);
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 : { callId: exec.callId },
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).then((commandHandle) => {
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
- if (result.code !== 0 && result.code !== 1) throw new Error(result.stderr || "glob failed");
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
- if (result.code !== 0 && result.code !== 1) throw new Error(result.stderr || "grep failed");
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 tmp = posix3.join(roots.workspaceRoot, `.dsh-permission-remote-${randomBytes(8).toString("hex")}`);
664
- await hostApi.pool.sftpWrite(host, secret, tmp, content);
665
- const command = `mkdir -p ${shJoin([posix3.dirname(target)])} && mv -f ${shJoin([tmp, target])}`;
666
- const wrapped = mode === "danger-full-access" ? command : commandArgv(status, mode, roots, roots.workspaceRoot, command);
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 !== true) return;
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
- throw error;
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
- if (used >= OUTPUT_CAP) {
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
- if (used + chunk.length > OUTPUT_CAP) {
1004
- target.push(chunk.subarray(0, OUTPUT_CAP - used));
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 statSize(sftp, remotePath) {
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.sftpRealpath(host, await this.core.secret(host), input.path);
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 mkdir(local, { recursive: true });
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 = { ...this.config, hosts: [...hosts, host] };
1361
+ this.config.hosts = [...hosts, host];
1211
1362
  await this.persistHosts();
1212
1363
  }
1213
1364
  async deleteHost(hostId) {
1214
- this.config = { ...this.config, hosts: this.config.hosts.filter((item) => item.id !== hostId) };
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) return;
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
- ctx.on("agent/created", ({ agent }) => {
1293
- const cwd = agent.session?.header?.cwd;
1294
- const initial = routeByCwd(cwd);
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
- const unregister = agent.ctx.tools.register(withRemoteTimeout({
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
- const drop = () => {
1318
- for (const unregister of disposers) unregister();
1319
- };
1320
- unregisters.add(drop);
1321
- agent.ctx.effect(() => () => {
1322
- drop();
1323
- unregisters.delete(drop);
1324
- });
1325
- });
1326
- ctx.logger?.info("[@dsh-permission-remote] loaded");
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: ["workspace-write", "danger-full-access"] },
1568
+ sandbox_permissions: { type: "string", enum: [...ESCALATION_TARGETS] },
1370
1569
  justification: { type: "string" }
1371
1570
  },
1372
1571
  output: {