sakura-agent 0.1.0 → 0.1.2

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.
Files changed (103) hide show
  1. package/README.md +207 -77
  2. package/README.zh-CN.md +208 -78
  3. package/THIRD_PARTY_NOTICES.md +8 -58
  4. package/dist/packages/agent/src/index.d.ts +49 -11
  5. package/dist/packages/agent/src/index.js +50 -55
  6. package/dist/packages/agent/src/restrict-tools.d.ts +6 -0
  7. package/dist/packages/agent/src/restrict-tools.js +11 -0
  8. package/dist/packages/agent/src/system-prompt.d.ts +5 -0
  9. package/dist/packages/agent/src/system-prompt.js +1 -1
  10. package/dist/packages/agent/src/wake.d.ts +41 -0
  11. package/dist/packages/agent/src/wake.js +71 -0
  12. package/dist/packages/env/src/confine.d.ts +13 -0
  13. package/dist/packages/env/src/confine.js +40 -0
  14. package/dist/packages/env/src/connect-ssh.d.ts +45 -0
  15. package/dist/packages/env/src/connect-ssh.js +242 -0
  16. package/dist/packages/env/src/index.d.ts +6 -1
  17. package/dist/packages/env/src/index.js +3 -1
  18. package/dist/packages/env/src/local.d.ts +11 -2
  19. package/dist/packages/env/src/local.js +42 -10
  20. package/dist/packages/env/src/remote-stream.d.ts +48 -0
  21. package/dist/packages/env/src/remote-stream.js +143 -0
  22. package/dist/packages/env/src/remote-types.d.ts +17 -0
  23. package/dist/packages/env/src/remote-types.js +10 -0
  24. package/dist/packages/env/src/remote.d.ts +16 -7
  25. package/dist/packages/env/src/remote.js +73 -10
  26. package/dist/packages/evals/src/bfcl.d.ts +24 -0
  27. package/dist/packages/evals/src/bfcl.js +71 -0
  28. package/dist/packages/evals/src/index.d.ts +3 -0
  29. package/dist/packages/evals/src/index.js +2 -0
  30. package/dist/packages/evals/src/png.d.ts +2 -0
  31. package/dist/packages/evals/src/png.js +30 -0
  32. package/dist/packages/evals/src/pool.d.ts +3 -0
  33. package/dist/packages/evals/src/pool.js +22 -0
  34. package/dist/packages/evals/src/providers.d.ts +13 -0
  35. package/dist/packages/evals/src/providers.js +23 -0
  36. package/dist/packages/kernel/src/compaction.d.ts +24 -3
  37. package/dist/packages/kernel/src/compaction.js +23 -16
  38. package/dist/packages/kernel/src/goal.d.ts +10 -1
  39. package/dist/packages/kernel/src/goal.js +28 -0
  40. package/dist/packages/kernel/src/hooks.d.ts +5 -0
  41. package/dist/packages/kernel/src/hooks.js +21 -1
  42. package/dist/packages/kernel/src/index.d.ts +3 -2
  43. package/dist/packages/kernel/src/index.js +1 -0
  44. package/dist/packages/kernel/src/loop.d.ts +28 -4
  45. package/dist/packages/kernel/src/loop.js +69 -47
  46. package/dist/packages/kernel/src/types.d.ts +25 -4
  47. package/dist/packages/kernel/src/usage.d.ts +3 -0
  48. package/dist/packages/kernel/src/usage.js +16 -0
  49. package/dist/packages/llm/src/chat/errors.d.ts +11 -0
  50. package/dist/packages/llm/src/chat/errors.js +17 -0
  51. package/dist/packages/llm/src/chat/request.js +22 -4
  52. package/dist/packages/llm/src/chat/retry.d.ts +1 -1
  53. package/dist/packages/llm/src/chat/retry.js +1 -1
  54. package/dist/packages/llm/src/chat/stream.js +2 -1
  55. package/dist/packages/llm/src/index.js +7 -2
  56. package/dist/packages/plugins/context/src/index.d.ts +2 -1
  57. package/dist/packages/plugins/context/src/index.js +2 -1
  58. package/dist/packages/plugins/limits/src/index.d.ts +4 -1
  59. package/dist/packages/plugins/redaction/src/index.d.ts +3 -0
  60. package/dist/packages/plugins/redaction/src/index.js +3 -0
  61. package/dist/packages/plugins/redaction/src/rules.gen.js +1 -1
  62. package/dist/packages/plugins/review/src/chain.d.ts +10 -1
  63. package/dist/packages/plugins/review/src/chain.js +7 -4
  64. package/dist/packages/plugins/review/src/hard-rules.d.ts +6 -0
  65. package/dist/packages/plugins/review/src/hard-rules.js +58 -25
  66. package/dist/packages/plugins/review/src/index.d.ts +21 -0
  67. package/dist/packages/plugins/review/src/index.js +42 -7
  68. package/dist/packages/plugins/review/src/reviewer.d.ts +32 -3
  69. package/dist/packages/plugins/review/src/reviewer.js +115 -27
  70. package/dist/packages/plugins/tools/src/builtin/bash.js +24 -10
  71. package/dist/packages/plugins/tools/src/builtin/image.d.ts +11 -0
  72. package/dist/packages/plugins/tools/src/builtin/image.js +84 -0
  73. package/dist/packages/plugins/tools/src/builtin/read.js +26 -12
  74. package/dist/packages/plugins/toolset/src/codemode.d.ts +17 -6
  75. package/dist/packages/plugins/toolset/src/codemode.js +84 -26
  76. package/dist/packages/plugins/toolset/src/index.d.ts +1 -1
  77. package/dist/packages/plugins/toolset/src/index.js +1 -1
  78. package/dist/packages/plugins/trace/src/index.d.ts +27 -0
  79. package/dist/packages/plugins/trace/src/index.js +29 -0
  80. package/dist/packages/plugins/trace/src/modules.d.ts +6 -0
  81. package/dist/packages/plugins/trace/src/modules.js +28 -0
  82. package/dist/packages/session/src/file-log.d.ts +6 -1
  83. package/dist/packages/session/src/file-log.js +45 -25
  84. package/dist/packages/session/src/fork.d.ts +16 -0
  85. package/dist/packages/session/src/fork.js +70 -0
  86. package/dist/packages/session/src/index.d.ts +2 -0
  87. package/dist/packages/session/src/index.js +1 -0
  88. package/dist/packages/session/src/log-core.d.ts +37 -0
  89. package/dist/packages/session/src/log-core.js +47 -0
  90. package/dist/packages/session/src/memory-log.d.ts +3 -12
  91. package/dist/packages/session/src/memory-log.js +3 -23
  92. package/dist/packages/session/src/projection.js +22 -3
  93. package/dist/packages/testing/src/faux.d.ts +36 -0
  94. package/dist/packages/testing/src/faux.js +69 -0
  95. package/dist/packages/testing/src/index.d.ts +4 -0
  96. package/dist/packages/testing/src/index.js +2 -0
  97. package/dist/packages/testing/src/rng.d.ts +7 -0
  98. package/dist/packages/testing/src/rng.js +17 -0
  99. package/dist/sdk/index.d.ts +11 -3
  100. package/dist/sdk/index.js +5 -1
  101. package/dist/sdk/testing.d.ts +2 -0
  102. package/dist/sdk/testing.js +2 -0
  103. package/package.json +7 -2
@@ -0,0 +1,242 @@
1
+ import { spawn } from "node:child_process";
2
+ import { mkdtemp, rm } from "node:fs/promises";
3
+ import { createRemoteEnvironment, RemoteConnectionError } from "./remote.js";
4
+ /** Output kept from one command, as for a local one. */
5
+ const MAX_OUTPUT_CHARS = 20_000_000;
6
+ const quote = (text) => `'${text.replace(/'/g, `'\\''`)}'`;
7
+ /** Runs ssh on this machine and collects what it prints. */
8
+ function runSsh(program, args, options) {
9
+ return new Promise((resolve, reject) => {
10
+ const child = spawn(program, args, { stdio: ["ignore", "pipe", "pipe"] });
11
+ let output = "";
12
+ let stderr = "";
13
+ let timedOut = false;
14
+ const keep = (chunk, target) => {
15
+ const text = chunk.toString("utf8");
16
+ output += text;
17
+ if (output.length > MAX_OUTPUT_CHARS)
18
+ output = output.slice(output.length - MAX_OUTPUT_CHARS);
19
+ if (target === "stderr")
20
+ stderr += text;
21
+ };
22
+ child.stdout.on("data", (chunk) => keep(chunk, "both"));
23
+ child.stderr.on("data", (chunk) => keep(chunk, "stderr"));
24
+ const stop = () => {
25
+ child.kill("SIGTERM");
26
+ setTimeout(() => child.kill("SIGKILL"), 1000).unref();
27
+ };
28
+ const timer = options.timeoutMs !== undefined
29
+ ? setTimeout(() => {
30
+ timedOut = true;
31
+ stop();
32
+ }, options.timeoutMs)
33
+ : undefined;
34
+ if (options.signal?.aborted)
35
+ stop();
36
+ options.signal?.addEventListener("abort", stop, { once: true });
37
+ child.on("error", (error) => {
38
+ clearTimeout(timer);
39
+ reject(new RemoteConnectionError(`could not run ${program}: ${error.message}`));
40
+ });
41
+ const finish = (code) => {
42
+ clearTimeout(timer);
43
+ options.signal?.removeEventListener("abort", stop);
44
+ resolve({ exitCode: code, output, timedOut, stderr });
45
+ };
46
+ child.on("close", finish);
47
+ // An ssh that makes the connection leaves a copy of itself behind in the background (the first one does, and so does one that has to make it again), and
48
+ // that copy may keep our pipes open for as long as it lives. So the answer is the ssh we started exiting, with a moment for what it wrote to arrive.
49
+ child.on("exit", (code) => setTimeout(() => {
50
+ child.stdout.destroy();
51
+ child.stderr.destroy();
52
+ finish(code);
53
+ }, 100));
54
+ });
55
+ }
56
+ /** Runs ssh on this machine and passes on what it prints as it prints it. What ssh itself says (its stderr) is kept apart from the command's output. */
57
+ function streamSsh(program, args, options) {
58
+ return new Promise((resolve, reject) => {
59
+ const child = spawn(program, args, { stdio: ["ignore", "pipe", "pipe"] });
60
+ let stderr = "";
61
+ child.stdout.setEncoding("utf8");
62
+ child.stdout.on("data", (text) => options.onData(text));
63
+ child.stderr.on("data", (chunk) => {
64
+ stderr = (stderr + chunk.toString("utf8")).slice(-4000);
65
+ });
66
+ const stop = () => {
67
+ child.kill("SIGTERM");
68
+ setTimeout(() => child.kill("SIGKILL"), 1000).unref();
69
+ };
70
+ if (options.signal?.aborted)
71
+ stop();
72
+ options.signal?.addEventListener("abort", stop, { once: true });
73
+ child.on("error", (error) => reject(new RemoteConnectionError(`could not run ${program}: ${error.message}`)));
74
+ // "close" comes after the last byte of both pipes was read, so nothing the command printed is lost
75
+ child.on("close", (code) => {
76
+ options.signal?.removeEventListener("abort", stop);
77
+ resolve({ exitCode: code, stderr });
78
+ });
79
+ });
80
+ }
81
+ /** How long a command waits for a free place before it is given up with an error. */
82
+ const SESSION_WAIT_MS = 60_000;
83
+ /** A fixed number of places, handed out in the order they were asked for. `take` resolves with the function that gives the place back. */
84
+ function createPlaces(count) {
85
+ let free = count;
86
+ const waiting = [];
87
+ const giveBack = () => {
88
+ const next = waiting.shift();
89
+ if (next)
90
+ next.grant();
91
+ else
92
+ free++;
93
+ };
94
+ const release = () => {
95
+ let given = false;
96
+ return () => {
97
+ if (given)
98
+ return;
99
+ given = true;
100
+ giveBack();
101
+ };
102
+ };
103
+ return {
104
+ take(waitMs, tooLong) {
105
+ if (free > 0) {
106
+ free--;
107
+ return Promise.resolve(release());
108
+ }
109
+ return new Promise((resolve, reject) => {
110
+ const entry = {
111
+ grant: () => {
112
+ clearTimeout(timer);
113
+ resolve(release());
114
+ },
115
+ };
116
+ const timer = setTimeout(() => {
117
+ waiting.splice(waiting.indexOf(entry), 1);
118
+ reject(tooLong());
119
+ }, waitMs);
120
+ waiting.push(entry);
121
+ });
122
+ },
123
+ };
124
+ }
125
+ /** How often an idle connection is touched, so that it is not given up by ControlPersist while the session is still there (the master is meant to live as long as we do). */
126
+ const KEEP_ALIVE_MS = 4 * 60_000;
127
+ /**
128
+ * Opens one connection to the machine and runs every command over it. The first ssh logs in (once) and stays in the background; each later ssh finds it
129
+ * through a socket in a directory of ours and only starts a new process on the other side, so a command costs milliseconds and not a login. If the
130
+ * connection dies it is NOT made again: commands are run with ControlMaster=no and ProxyCommand=false, so an ssh that finds no live master has nowhere to
131
+ * go and fails at once (by default, ssh_config(5) says, a client that cannot use the socket makes a connection of its own, silently and with a new login).
132
+ * The result is a RemoteConnectionError that tells the caller to stop and report it.
133
+ */
134
+ export async function connectSsh(options) {
135
+ const program = options.ssh ?? "ssh";
136
+ // sockets have a short length limit (104 characters on macOS), so the directory is under /tmp and the name is a hash
137
+ const directory = await mkdtemp("/tmp/sk-ssh-");
138
+ const control = `${directory}/%C`;
139
+ const common = [
140
+ "-o",
141
+ `ControlPath=${control}`,
142
+ "-o",
143
+ "BatchMode=yes",
144
+ "-o",
145
+ `ConnectTimeout=${options.connectTimeoutSec ?? 15}`,
146
+ "-o",
147
+ "ServerAliveInterval=15",
148
+ "-o",
149
+ "ServerAliveCountMax=3",
150
+ ...(options.port !== undefined ? ["-p", String(options.port)] : []),
151
+ ...(options.identityFile !== undefined ? ["-i", options.identityFile] : []),
152
+ ...Object.entries(options.options ?? {}).flatMap(([name, value]) => ["-o", `${name}=${value}`]),
153
+ ];
154
+ let closed = false;
155
+ const cleanup = () => rm(directory, { recursive: true, force: true });
156
+ try {
157
+ // log in now, so a wrong host or key shows up here and not in the middle of a task
158
+ const opened = await runSsh(program, [...common, "-o", "ControlMaster=yes", "-o", "ControlPersist=10m", "-N", options.host], {});
159
+ if (opened.exitCode !== 0)
160
+ throw new RemoteConnectionError(`could not connect to ${options.host}: ${opened.stderr.trim() || `ssh exited with ${opened.exitCode}`}`);
161
+ }
162
+ catch (error) {
163
+ await cleanup();
164
+ throw error;
165
+ }
166
+ /** Arguments for one command: through the master or nowhere. Ours come first because with ssh the first value of an option wins over later ones. */
167
+ const throughMaster = (remote) => ["-o", "ControlMaster=no", "-o", "ProxyCommand=false", ...common, options.host, remote];
168
+ const goneBecause = (detail) => new RemoteConnectionError(`the connection to ${options.host} is gone and is not made again by itself: ${detail || "ssh exited with code 255"}`);
169
+ const remoteCommand = (command, cwd) => `${cwd !== undefined ? `cd ${quote(cwd)} && ` : ""}bash -c ${quote(command)}`;
170
+ // Two places stay for short jobs (files, stopping a command), so that commands that run for long cannot keep a command from being stopped.
171
+ const SHORT_PLACES = 2;
172
+ const maxSessions = Math.max(SHORT_PLACES + 1, options.maxSessions ?? 8);
173
+ const commandPlaces = createPlaces(maxSessions - SHORT_PLACES);
174
+ const shortPlaces = createPlaces(SHORT_PLACES);
175
+ const waitForPlace = (places, limit) => places.take(SESSION_WAIT_MS, () => new RemoteConnectionError(`too many commands are running at once over the connection to ${options.host} (at most ${limit}); none ended within ${SESSION_WAIT_MS / 1000} seconds. Wait for one to finish or stop one, then try again.`));
176
+ const run = async (command, runOptions) => {
177
+ if (closed)
178
+ throw new RemoteConnectionError("the connection was closed");
179
+ const giveBack = await waitForPlace(shortPlaces, SHORT_PLACES);
180
+ try {
181
+ const result = await runSsh(program, throughMaster(remoteCommand(command, runOptions.cwd)), { timeoutMs: runOptions.timeoutMs, signal: runOptions.signal });
182
+ // ssh itself exits with 255 when it could not reach the machine; a command that exits with 255 on its own is taken for that too
183
+ if (result.exitCode === 255 && !result.timedOut && !runOptions.signal?.aborted) {
184
+ throw new RemoteConnectionError(`${goneBecause(result.stderr.trim()).message}. The command may or may not have run. Stop here and tell the user that the connection was lost.`);
185
+ }
186
+ return { exitCode: result.exitCode, output: result.output, timedOut: result.timedOut };
187
+ }
188
+ finally {
189
+ giveBack();
190
+ }
191
+ };
192
+ const stream = async (command, streamOptions) => {
193
+ if (closed)
194
+ throw new RemoteConnectionError("the connection was closed");
195
+ const giveBack = await waitForPlace(commandPlaces, maxSessions - SHORT_PLACES);
196
+ try {
197
+ const outcome = await streamSsh(program, throughMaster(remoteCommand(command, streamOptions.cwd)), { signal: streamOptions.signal, onData: streamOptions.onData });
198
+ // the command's own code travels in its output; the child itself ends with 255 only when ssh could not do its job
199
+ if (outcome.exitCode === 255 && !streamOptions.signal?.aborted)
200
+ throw goneBecause(outcome.stderr.trim());
201
+ return outcome;
202
+ }
203
+ finally {
204
+ giveBack();
205
+ }
206
+ };
207
+ const keepAlive = setInterval(() => void run("true", {}).catch(() => undefined), KEEP_ALIVE_MS);
208
+ keepAlive.unref();
209
+ return {
210
+ run,
211
+ stream,
212
+ socketDirectory: directory,
213
+ async close() {
214
+ if (closed)
215
+ return;
216
+ closed = true;
217
+ clearInterval(keepAlive);
218
+ await runSsh(program, [...common, "-O", "exit", options.host], {}).catch(() => undefined);
219
+ await cleanup();
220
+ },
221
+ };
222
+ }
223
+ /** What the model is told about where its tools run, over ssh. */
224
+ export function describeSsh(host, cwd) {
225
+ return (`a remote machine (${host}), not the computer that runs you. Every command is sent over an SSH connection and starts fresh in ${cwd}: cd and export do not carry over to the next command. ` +
226
+ "The connection can be slow or drop, and a command can fail for reasons that have nothing to do with it, so results are not guaranteed to be repeatable. " +
227
+ "If a tool says the connection failed, the command may or may not have run: look at the current state (list the files, read the output file) before you repeat anything that changes something. " +
228
+ "A lost connection is not made again by itself: when a tool says it was lost, stop and tell the user. " +
229
+ "Files are on the remote machine.");
230
+ }
231
+ /** An environment whose tools run on another machine over ssh. Close it (or dispose of the agent) to hang up. */
232
+ export async function createSshEnvironment(options) {
233
+ const connection = await connectSsh(options);
234
+ return createRemoteEnvironment({
235
+ cwd: options.cwd,
236
+ run: connection.run,
237
+ stream: connection.stream,
238
+ close: connection.close,
239
+ description: describeSsh(options.host, options.cwd),
240
+ ...(options.workDir !== undefined ? { workDir: options.workDir } : {}),
241
+ });
242
+ }
@@ -1,3 +1,8 @@
1
1
  export { createLocalEnvironment } from "./local.js";
2
- export { createRemoteEnvironment } from "./remote.js";
2
+ export type { LocalOptions } from "./local.js";
3
+ export { OutsideDirectoryError } from "./confine.js";
4
+ export { createRemoteEnvironment, RemoteConnectionError } from "./remote.js";
3
5
  export type { RemoteRunner } from "./remote.js";
6
+ export type { RemoteStreamer } from "./remote-stream.js";
7
+ export { connectSsh, createSshEnvironment } from "./connect-ssh.js";
8
+ export type { SshConnection, SshOptions } from "./connect-ssh.js";
@@ -1,2 +1,4 @@
1
1
  export { createLocalEnvironment } from "./local.js";
2
- export { createRemoteEnvironment } from "./remote.js";
2
+ export { OutsideDirectoryError } from "./confine.js";
3
+ export { createRemoteEnvironment, RemoteConnectionError } from "./remote.js";
4
+ export { connectSsh, createSshEnvironment } from "./connect-ssh.js";
@@ -1,3 +1,12 @@
1
1
  import type { Environment } from "../../kernel/src/index.js";
2
- /** The environment tools run in when there is no sandbox: the local machine. */
3
- export declare function createLocalEnvironment(cwd: string): Environment;
2
+ export interface LocalOptions {
3
+ /**
4
+ * Keep every file operation inside the working directory: reading, writing, listing and the directory of a command are refused (OutsideDirectoryError)
5
+ * for a path outside it, however it is written (absolute, with .., through a link). This is a boundary for the file tools. It is not a sandbox: a shell
6
+ * command can still reach anything this process can, so with a shell tool use it together with the review, or without a shell tool at all.
7
+ * It guards against honest mistakes and a model that wanders, not against a program that races the check.
8
+ */
9
+ confine?: boolean;
10
+ }
11
+ /** The environment tools run in by default: this machine, with the permissions of this process. It confines nothing unless `confine` is set. */
12
+ export declare function createLocalEnvironment(cwd: string, options?: LocalOptions): Environment;
@@ -1,8 +1,9 @@
1
1
  import { spawn as spawnProcess } from "node:child_process";
2
2
  import { createWriteStream } from "node:fs";
3
- import { mkdir, mkdtemp, readdir, readFile, stat, writeFile } from "node:fs/promises";
3
+ import { mkdir, mkdtemp, readdir, readFile, realpath, stat, writeFile } from "node:fs/promises";
4
4
  import { tmpdir } from "node:os";
5
5
  import { dirname, isAbsolute, join, resolve } from "node:path";
6
+ import { isInside, OutsideDirectoryError, realLocation } from "./confine.js";
6
7
  const MAX_OUTPUT_CHARS = 20_000_000;
7
8
  const KILL_GRACE_MS = 1500;
8
9
  function killGroup(pid, signal) {
@@ -15,7 +16,9 @@ function killGroup(pid, signal) {
15
16
  // the group is already gone
16
17
  }
17
18
  }
18
- // Commands run in their own process group, so they would outlive a host process that is stopped without closing the environment
19
+ // Commands run in their own process group, so that stopping one stops everything it started. (A command that starts a session of its own, with
20
+ // setsid for example, leaves the group and is not reached by that; to stop everything, run the commands in a container.)
21
+ // They would also outlive a host process that is stopped without closing the environment
19
22
  // (Ctrl-C, process.exit, a script that ends while a background command still runs). Every group still running is stopped when the host exits.
20
23
  const liveGroups = new Set();
21
24
  let exitHooked = false;
@@ -39,15 +42,43 @@ function hookExit() {
39
42
  process.on(signal, onSignal);
40
43
  }
41
44
  }
42
- /** The environment tools run in when there is no sandbox: the local machine. */
43
- export function createLocalEnvironment(cwd) {
45
+ /** The environment tools run in by default: this machine, with the permissions of this process. It confines nothing unless `confine` is set. */
46
+ export function createLocalEnvironment(cwd, options = {}) {
44
47
  const root = resolve(cwd);
45
- const resolvePath = (path) => (isAbsolute(path) ? resolve(path) : resolve(root, path));
48
+ const confine = options.confine === true;
49
+ /** Where the output files of the commands of this environment are: outside the working directory, and the model is told to read them. */
50
+ let outputRoot;
51
+ const inOutputRoot = (absolute) => outputRoot !== undefined && isInside(outputRoot, absolute);
52
+ const resolvePath = (path) => {
53
+ const absolute = isAbsolute(path) ? resolve(path) : resolve(root, path);
54
+ if (confine && !isInside(root, absolute) && !inOutputRoot(absolute))
55
+ throw new OutsideDirectoryError(absolute, root);
56
+ return absolute;
57
+ };
58
+ let realRoot;
59
+ /** For file operations: the text check of resolvePath, and then where the path really is, because a link inside the folder can lead out of it. */
60
+ const checked = async (path, forWriting = false) => {
61
+ const absolute = resolvePath(path);
62
+ if (confine && inOutputRoot(absolute)) {
63
+ if (forWriting)
64
+ throw new OutsideDirectoryError(absolute, root); // the output files may be read, not written
65
+ return absolute;
66
+ }
67
+ if (confine) {
68
+ realRoot ??= realpath(root);
69
+ if (!isInside(await realRoot, await realLocation(absolute, root)))
70
+ throw new OutsideDirectoryError(absolute, root);
71
+ }
72
+ return absolute;
73
+ };
46
74
  const running = new Set();
47
75
  let outputDir;
48
76
  let counter = 0;
49
77
  const nextOutputFile = async () => {
50
- outputDir ??= mkdtemp(join(tmpdir(), "sakura-env-"));
78
+ outputDir ??= mkdtemp(join(tmpdir(), "sakura-env-")).then((directory) => {
79
+ outputRoot = resolve(directory);
80
+ return directory;
81
+ });
51
82
  return join(await outputDir, `command-${++counter}.log`);
52
83
  };
53
84
  function start(command, options) {
@@ -129,16 +160,17 @@ export function createLocalEnvironment(cwd) {
129
160
  }
130
161
  return {
131
162
  cwd: root,
163
+ ...(confine ? { description: `this machine (${process.platform}). The file tools work only inside ${root}; a path outside it is refused.` } : {}),
132
164
  resolve: resolvePath,
133
- readFile: async (path) => new Uint8Array(await readFile(resolvePath(path))),
165
+ readFile: async (path) => new Uint8Array(await readFile(await checked(path))),
134
166
  async writeFile(path, data) {
135
- const target = resolvePath(path);
167
+ const target = await checked(path, true);
136
168
  await mkdir(dirname(target), { recursive: true });
137
169
  await writeFile(target, data);
138
170
  },
139
171
  async stat(path) {
140
172
  try {
141
- const info = await stat(resolvePath(path));
173
+ const info = await stat(await checked(path));
142
174
  return { isFile: info.isFile(), isDirectory: info.isDirectory(), size: info.size };
143
175
  }
144
176
  catch (error) {
@@ -148,7 +180,7 @@ export function createLocalEnvironment(cwd) {
148
180
  }
149
181
  },
150
182
  async list(path) {
151
- return (await readdir(resolvePath(path))).sort();
183
+ return (await readdir(await checked(path))).sort();
152
184
  },
153
185
  spawn: start,
154
186
  async exec(command, options) {
@@ -0,0 +1,48 @@
1
+ import type { ProcessHandle } from "../../kernel/src/index.js";
2
+ import type { RemoteRunner } from "./remote-types.js";
3
+ /**
4
+ * Runs one command on the other side as a child that lives as long as the command is watched, and hands over what it prints as it is printed. Resolves
5
+ * with the exit code of that child (null when it was stopped by a signal) and what it wrote to its own error stream, which is the transport's talk (ssh
6
+ * says why it could not connect) and never the command's. Rejects with a RemoteConnectionError when the child could not be started at all.
7
+ */
8
+ export type RemoteStreamer = (command: string, options: {
9
+ cwd?: string;
10
+ signal?: AbortSignal;
11
+ onData(text: string): void;
12
+ }) => Promise<{
13
+ exitCode: number | null;
14
+ stderr: string;
15
+ }>;
16
+ export interface StreamedSpawnDependencies {
17
+ stream: RemoteStreamer;
18
+ /** One short command over the same connection (used to stop a command). */
19
+ run: RemoteRunner;
20
+ root: string;
21
+ workDir: string;
22
+ resolvePath(path: string): string;
23
+ nextId(): string;
24
+ running: Set<ProcessHandle>;
25
+ killing: Map<ProcessHandle, Promise<void>>;
26
+ }
27
+ /**
28
+ * The shell text one streamed command is sent as. It writes the script, starts it detached (own session, output into a log file, so it survives the
29
+ * connection and the log can be read afterwards), prints the process group id, then copies the log to its own output as the log grows. When the command
30
+ * has ended and the log is copied to the last byte, it prints the exit code after a marker. `wait` gives the exit code without anyone looking for it.
31
+ */
32
+ export declare function launcherFor(options: {
33
+ id: string;
34
+ command: string;
35
+ cwd: string;
36
+ workDir: string;
37
+ }): string;
38
+ /** `kill` text for the group of a command: ask nicely, then insist. */
39
+ export declare const stopCommand: (pid: number) => string;
40
+ /**
41
+ * `spawn` for an environment that has a streamer. Each command is one child of the connection; the exit code is the end of that child's script, no
42
+ * file is looked at to find it; stopping a command stops its process group on the other side. When the connection is lost the command is NOT followed:
43
+ * it keeps running there (it is detached), and the error says that, where its output is, and that the model must stop and report it.
44
+ */
45
+ export declare function createStreamedSpawn(deps: StreamedSpawnDependencies): (command: string, options: {
46
+ cwd?: string;
47
+ signal?: AbortSignal;
48
+ }) => ProcessHandle;
@@ -0,0 +1,143 @@
1
+ import { posix } from "node:path";
2
+ import { RemoteConnectionError } from "./remote-types.js";
3
+ const quote = (text) => `'${text.replace(/'/g, `'\\''`)}'`;
4
+ /** Output kept from one command, as for a local one. */
5
+ const MAX_OUTPUT_CHARS = 20_000_000;
6
+ /** After a stop was asked for, how long the connection gets to report the end before the local child is killed. */
7
+ const STOP_GRACE_MS = 4000;
8
+ /** What the model sees of a command that was lost in the middle. */
9
+ const TAIL_LINES = 20;
10
+ /**
11
+ * The shell text one streamed command is sent as. It writes the script, starts it detached (own session, output into a log file, so it survives the
12
+ * connection and the log can be read afterwards), prints the process group id, then copies the log to its own output as the log grows. When the command
13
+ * has ended and the log is copied to the last byte, it prints the exit code after a marker. `wait` gives the exit code without anyone looking for it.
14
+ */
15
+ export function launcherFor(options) {
16
+ const { id, workDir } = options;
17
+ const script = posix.join(workDir, `${id}.sh`);
18
+ const log = posix.join(workDir, `${id}.log`);
19
+ const stop = posix.join(workDir, `${id}.stop`);
20
+ const body = `cd ${quote(options.cwd)} || exit 1\n${options.command}\n`;
21
+ const detached = `bash ${quote(script)} > ${quote(log)} 2>&1; code=$?; exit $code`;
22
+ return [
23
+ `mkdir -p ${quote(workDir)} && echo ${Buffer.from(body).toString("base64")} | base64 -d > ${quote(script)} && : > ${quote(log)} && rm -f ${quote(stop)} || exit 126`,
24
+ `setsid bash -c ${quote(detached)} < /dev/null > /dev/null 2>&1 &`,
25
+ "main=$$ job=$!",
26
+ `printf '@@${id}-pid %s\\n' "$job"`,
27
+ // copy the log as it grows; the end is known when the stop file exists (or the shell that started this is gone) AND nothing more was written after that was seen
28
+ `( sent=0; while :; do ended=0; [ -e ${quote(stop)} ] && ended=1; kill -0 "$main" 2>/dev/null || ended=1; size=$(wc -c < ${quote(log)}); if [ "$size" -gt "$sent" ]; then tail -c +$((sent+1)) ${quote(log)} | head -c $((size-sent)); sent=$size; continue; fi; [ $ended = 1 ] && break; sleep 0.05; done ) &`,
29
+ "follower=$!",
30
+ 'wait "$job"; code=$?',
31
+ `: > ${quote(stop)}`,
32
+ 'wait "$follower"',
33
+ `printf '\\n@@${id}-exit %s\\n' "$code"`,
34
+ // the command's own code is in the line above; the child ends with 0, so that an ssh that ends with 255 can only mean that the connection failed
35
+ "exit 0",
36
+ ].join("\n");
37
+ }
38
+ /** `kill` text for the group of a command: ask nicely, then insist. */
39
+ export const stopCommand = (pid) => `kill -TERM -- -${pid} 2>/dev/null; sleep 1; kill -KILL -- -${pid} 2>/dev/null; true`;
40
+ const lastLines = (text) => text.trimEnd().split("\n").slice(-TAIL_LINES).join("\n");
41
+ /**
42
+ * `spawn` for an environment that has a streamer. Each command is one child of the connection; the exit code is the end of that child's script, no
43
+ * file is looked at to find it; stopping a command stops its process group on the other side. When the connection is lost the command is NOT followed:
44
+ * it keeps running there (it is detached), and the error says that, where its output is, and that the model must stop and report it.
45
+ */
46
+ export function createStreamedSpawn(deps) {
47
+ return (command, spawnOptions) => {
48
+ const id = deps.nextId();
49
+ const log = posix.join(deps.workDir, `${id}.log`);
50
+ const pidMarker = `@@${id}-pid `;
51
+ const exitMarker = `\n@@${id}-exit `;
52
+ const local = new AbortController();
53
+ let pid = 0;
54
+ let finished = false;
55
+ let stopRequested = false;
56
+ let received = "";
57
+ let pidSeen;
58
+ const pidKnown = new Promise((resolve) => {
59
+ pidSeen = resolve;
60
+ });
61
+ const launcher = launcherFor({ id, command, cwd: spawnOptions.cwd ? deps.resolvePath(spawnOptions.cwd) : deps.root, workDir: deps.workDir });
62
+ const kill = () => {
63
+ if (deps.killing.has(handle))
64
+ return;
65
+ stopRequested = true;
66
+ deps.killing.set(handle, (async () => {
67
+ const ended = new Promise((resolve) => void done.then(() => resolve(), () => resolve()));
68
+ await Promise.race([pidKnown, ended]);
69
+ if (pid > 0 && !finished)
70
+ await deps.run(stopCommand(pid), { cwd: deps.root }).catch(() => undefined);
71
+ // the connection normally reports the end now; if it does not, the local child is stopped so that nobody waits for ever
72
+ const timer = setTimeout(() => local.abort(), STOP_GRACE_MS);
73
+ await ended;
74
+ clearTimeout(timer);
75
+ })());
76
+ };
77
+ spawnOptions.signal?.addEventListener("abort", kill, { once: true });
78
+ if (spawnOptions.signal?.aborted)
79
+ queueMicrotask(kill);
80
+ const lostMessage = (reason) => `The connection to the remote machine was lost while the command was running (${reason}). The command may still be running or may have finished; its output is being written to ${log} on the remote machine. ` +
81
+ "Stop here and tell the user that the connection was lost, and where that output file is. Do not reconnect or run the command again yourself." +
82
+ (received.trim() !== "" ? `\nLast output received:\n${lastLines(received)}` : "");
83
+ const handle = {
84
+ get pid() {
85
+ return pid;
86
+ },
87
+ outputFile: log,
88
+ wait: () => done,
89
+ kill,
90
+ get finished() {
91
+ return finished;
92
+ },
93
+ };
94
+ const done = (async () => {
95
+ const end = () => {
96
+ finished = true;
97
+ deps.running.delete(handle);
98
+ };
99
+ let outcome;
100
+ try {
101
+ outcome = await deps.stream(launcher, {
102
+ cwd: deps.root,
103
+ signal: local.signal,
104
+ onData: (text) => {
105
+ received += text;
106
+ if (received.length > MAX_OUTPUT_CHARS)
107
+ received = received.slice(received.length - MAX_OUTPUT_CHARS);
108
+ if (pid === 0 && received.startsWith(pidMarker) && received.includes("\n")) {
109
+ pid = Number.parseInt(received.slice(pidMarker.length, received.indexOf("\n")), 10) || 0;
110
+ received = received.slice(received.indexOf("\n") + 1);
111
+ pidSeen();
112
+ }
113
+ },
114
+ });
115
+ }
116
+ catch (error) {
117
+ end();
118
+ pidSeen();
119
+ if (!(error instanceof RemoteConnectionError))
120
+ throw error;
121
+ throw new RemoteConnectionError(pid === 0
122
+ ? `The connection to the remote machine failed while the command was being started, so it may or may not be running (${error.message}). If it is, its output is being written to ${log} on the remote machine. Stop here and tell the user.`
123
+ : lostMessage(error.message));
124
+ }
125
+ end();
126
+ pidSeen();
127
+ const at = received.lastIndexOf(exitMarker);
128
+ const trailer = at === -1 ? undefined : /^(\d+)\n$/.exec(received.slice(at + exitMarker.length));
129
+ if (at !== -1 && trailer) {
130
+ const code = Number(trailer[1]);
131
+ const output = received.slice(0, at);
132
+ // stopped on request: the number says which signal ended it, which nobody asked about
133
+ return { exitCode: stopRequested && code > 128 ? null : code, output: output.length > MAX_OUTPUT_CHARS ? output.slice(output.length - MAX_OUTPUT_CHARS) : output, timedOut: false };
134
+ }
135
+ if (pid === 0)
136
+ return { exitCode: null, output: `the command could not be started${outcome.stderr.trim() ? `: ${outcome.stderr.trim()}` : ""}`, timedOut: false };
137
+ throw new RemoteConnectionError(lostMessage(outcome.stderr.trim() || `the connection ended with ${outcome.exitCode === null ? "a signal" : `exit code ${outcome.exitCode}`}`));
138
+ })();
139
+ done.catch(() => undefined); // a caller that never waits must not cause an unhandled rejection
140
+ deps.running.add(handle);
141
+ return handle;
142
+ };
143
+ }
@@ -0,0 +1,17 @@
1
+ import type { ExecResult } from "../../kernel/src/index.js";
2
+ /**
3
+ * Thrown by a runner when it could not reach the other side, so nothing is known about whether the command ran. A command that did run and failed
4
+ * is not this: it is a result with its own exit code. Whoever writes a runner translates its transport's failures (ssh exits with 255, for example) into this.
5
+ */
6
+ export declare class RemoteConnectionError extends Error {
7
+ constructor(message: string);
8
+ }
9
+ /**
10
+ * The one thing a remote environment needs: a way to run a shell command somewhere else and get its output back.
11
+ * It resolves with the result of a command that ran, whatever its exit code, and rejects with a RemoteConnectionError when it could not reach the other side.
12
+ */
13
+ export type RemoteRunner = (command: string, options: {
14
+ cwd?: string;
15
+ timeoutMs?: number;
16
+ signal?: AbortSignal;
17
+ }) => Promise<ExecResult>;
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Thrown by a runner when it could not reach the other side, so nothing is known about whether the command ran. A command that did run and failed
3
+ * is not this: it is a result with its own exit code. Whoever writes a runner translates its transport's failures (ssh exits with 255, for example) into this.
4
+ */
5
+ export class RemoteConnectionError extends Error {
6
+ constructor(message) {
7
+ super(message);
8
+ this.name = "RemoteConnectionError";
9
+ }
10
+ }
@@ -1,10 +1,8 @@
1
- import type { Environment, ExecResult } from "../../kernel/src/index.js";
2
- /** The one thing a remote environment needs: a way to run a shell command somewhere else and get its output back. */
3
- export type RemoteRunner = (command: string, options: {
4
- cwd?: string;
5
- timeoutMs?: number;
6
- signal?: AbortSignal;
7
- }) => Promise<ExecResult>;
1
+ import type { Environment } from "../../kernel/src/index.js";
2
+ import type { RemoteRunner } from "./remote-types.js";
3
+ import type { RemoteStreamer } from "./remote-stream.js";
4
+ export { RemoteConnectionError } from "./remote-types.js";
5
+ export type { RemoteRunner } from "./remote-types.js";
8
6
  /**
9
7
  * An environment on another machine (a container, say) reached only through `run`. Files are read and written
10
8
  * with base64 over the shell, and long commands are started detached so they can outlive a single call.
@@ -12,4 +10,15 @@ export type RemoteRunner = (command: string, options: {
12
10
  export declare function createRemoteEnvironment(options: {
13
11
  cwd: string;
14
12
  run: RemoteRunner;
13
+ /**
14
+ * Optional. With it, every command (`spawn` and `exec`) is one streamed child of the connection and its end is the end of that child; without it the
15
+ * environment starts commands detached through `run` and looks for their exit code from time to time, which is all a runner without a live channel can do.
16
+ */
17
+ stream?: RemoteStreamer;
18
+ /** Called once when the environment is disposed, after its commands were stopped: close the connection the runner uses. */
19
+ close?: () => Promise<void>;
20
+ /** What the model is told about where its tools run (see Environment.description). */
21
+ description?: string;
22
+ /** Where the scripts and logs of started commands are kept on the other machine, and where a background command's output file is. Default /tmp/.sakura-remote, so the working directory is left as the task has it. */
23
+ workDir?: string;
15
24
  }): Environment;