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.
- package/README.md +207 -77
- package/README.zh-CN.md +208 -78
- package/THIRD_PARTY_NOTICES.md +8 -58
- package/dist/packages/agent/src/index.d.ts +49 -11
- package/dist/packages/agent/src/index.js +50 -55
- package/dist/packages/agent/src/restrict-tools.d.ts +6 -0
- package/dist/packages/agent/src/restrict-tools.js +11 -0
- package/dist/packages/agent/src/system-prompt.d.ts +5 -0
- package/dist/packages/agent/src/system-prompt.js +1 -1
- package/dist/packages/agent/src/wake.d.ts +41 -0
- package/dist/packages/agent/src/wake.js +71 -0
- package/dist/packages/env/src/confine.d.ts +13 -0
- package/dist/packages/env/src/confine.js +40 -0
- package/dist/packages/env/src/connect-ssh.d.ts +45 -0
- package/dist/packages/env/src/connect-ssh.js +242 -0
- package/dist/packages/env/src/index.d.ts +6 -1
- package/dist/packages/env/src/index.js +3 -1
- package/dist/packages/env/src/local.d.ts +11 -2
- package/dist/packages/env/src/local.js +42 -10
- package/dist/packages/env/src/remote-stream.d.ts +48 -0
- package/dist/packages/env/src/remote-stream.js +143 -0
- package/dist/packages/env/src/remote-types.d.ts +17 -0
- package/dist/packages/env/src/remote-types.js +10 -0
- package/dist/packages/env/src/remote.d.ts +16 -7
- package/dist/packages/env/src/remote.js +73 -10
- package/dist/packages/evals/src/bfcl.d.ts +24 -0
- package/dist/packages/evals/src/bfcl.js +71 -0
- package/dist/packages/evals/src/index.d.ts +3 -0
- package/dist/packages/evals/src/index.js +2 -0
- package/dist/packages/evals/src/png.d.ts +2 -0
- package/dist/packages/evals/src/png.js +30 -0
- package/dist/packages/evals/src/pool.d.ts +3 -0
- package/dist/packages/evals/src/pool.js +22 -0
- package/dist/packages/evals/src/providers.d.ts +13 -0
- package/dist/packages/evals/src/providers.js +23 -0
- package/dist/packages/kernel/src/compaction.d.ts +24 -3
- package/dist/packages/kernel/src/compaction.js +23 -16
- package/dist/packages/kernel/src/goal.d.ts +10 -1
- package/dist/packages/kernel/src/goal.js +28 -0
- package/dist/packages/kernel/src/hooks.d.ts +5 -0
- package/dist/packages/kernel/src/hooks.js +21 -1
- package/dist/packages/kernel/src/index.d.ts +3 -2
- package/dist/packages/kernel/src/index.js +1 -0
- package/dist/packages/kernel/src/loop.d.ts +28 -4
- package/dist/packages/kernel/src/loop.js +69 -47
- package/dist/packages/kernel/src/types.d.ts +25 -4
- package/dist/packages/kernel/src/usage.d.ts +3 -0
- package/dist/packages/kernel/src/usage.js +16 -0
- package/dist/packages/llm/src/chat/errors.d.ts +11 -0
- package/dist/packages/llm/src/chat/errors.js +17 -0
- package/dist/packages/llm/src/chat/request.js +22 -4
- package/dist/packages/llm/src/chat/retry.d.ts +1 -1
- package/dist/packages/llm/src/chat/retry.js +1 -1
- package/dist/packages/llm/src/chat/stream.js +2 -1
- package/dist/packages/llm/src/index.js +7 -2
- package/dist/packages/plugins/context/src/index.d.ts +2 -1
- package/dist/packages/plugins/context/src/index.js +2 -1
- package/dist/packages/plugins/limits/src/index.d.ts +4 -1
- package/dist/packages/plugins/redaction/src/index.d.ts +3 -0
- package/dist/packages/plugins/redaction/src/index.js +3 -0
- package/dist/packages/plugins/redaction/src/rules.gen.js +1 -1
- package/dist/packages/plugins/review/src/chain.d.ts +10 -1
- package/dist/packages/plugins/review/src/chain.js +7 -4
- package/dist/packages/plugins/review/src/hard-rules.d.ts +6 -0
- package/dist/packages/plugins/review/src/hard-rules.js +58 -25
- package/dist/packages/plugins/review/src/index.d.ts +21 -0
- package/dist/packages/plugins/review/src/index.js +42 -7
- package/dist/packages/plugins/review/src/reviewer.d.ts +32 -3
- package/dist/packages/plugins/review/src/reviewer.js +115 -27
- package/dist/packages/plugins/tools/src/builtin/bash.js +24 -10
- package/dist/packages/plugins/tools/src/builtin/image.d.ts +11 -0
- package/dist/packages/plugins/tools/src/builtin/image.js +84 -0
- package/dist/packages/plugins/tools/src/builtin/read.js +26 -12
- package/dist/packages/plugins/toolset/src/codemode.d.ts +17 -6
- package/dist/packages/plugins/toolset/src/codemode.js +84 -26
- package/dist/packages/plugins/toolset/src/index.d.ts +1 -1
- package/dist/packages/plugins/toolset/src/index.js +1 -1
- package/dist/packages/plugins/trace/src/index.d.ts +27 -0
- package/dist/packages/plugins/trace/src/index.js +29 -0
- package/dist/packages/plugins/trace/src/modules.d.ts +6 -0
- package/dist/packages/plugins/trace/src/modules.js +28 -0
- package/dist/packages/session/src/file-log.d.ts +6 -1
- package/dist/packages/session/src/file-log.js +45 -25
- package/dist/packages/session/src/fork.d.ts +16 -0
- package/dist/packages/session/src/fork.js +70 -0
- package/dist/packages/session/src/index.d.ts +2 -0
- package/dist/packages/session/src/index.js +1 -0
- package/dist/packages/session/src/log-core.d.ts +37 -0
- package/dist/packages/session/src/log-core.js +47 -0
- package/dist/packages/session/src/memory-log.d.ts +3 -12
- package/dist/packages/session/src/memory-log.js +3 -23
- package/dist/packages/session/src/projection.js +22 -3
- package/dist/packages/testing/src/faux.d.ts +36 -0
- package/dist/packages/testing/src/faux.js +69 -0
- package/dist/packages/testing/src/index.d.ts +4 -0
- package/dist/packages/testing/src/index.js +2 -0
- package/dist/packages/testing/src/rng.d.ts +7 -0
- package/dist/packages/testing/src/rng.js +17 -0
- package/dist/sdk/index.d.ts +11 -3
- package/dist/sdk/index.js +5 -1
- package/dist/sdk/testing.d.ts +2 -0
- package/dist/sdk/testing.js +2 -0
- package/package.json +7 -2
|
@@ -3,24 +3,26 @@ import { createLocalEnvironment } from "../../env/src/index.js";
|
|
|
3
3
|
import { createKernel } from "../../kernel/src/index.js";
|
|
4
4
|
import { agentsPlugin } from "../../plugins/agents/src/index.js";
|
|
5
5
|
import { contextPlugin } from "../../plugins/context/src/index.js";
|
|
6
|
-
import { codemodePlugin, connectMcp, searchPlugin } from "../../plugins/toolset/src/index.js";
|
|
6
|
+
import { CODEMODE_TOOL, codemodePlugin, connectMcp, searchPlugin } from "../../plugins/toolset/src/index.js";
|
|
7
7
|
import { limitsPlugin } from "../../plugins/limits/src/index.js";
|
|
8
8
|
import { resiliencePlugin } from "../../plugins/resilience/src/index.js";
|
|
9
9
|
import { builtinTools, toolsPlugin } from "../../plugins/tools/src/index.js";
|
|
10
|
-
import { createMemoryLog, openFileLog } from "../../session/src/index.js";
|
|
10
|
+
import { createMemoryLog, forkLog, openFileLog } from "../../session/src/index.js";
|
|
11
11
|
import { compose } from "./compose.js";
|
|
12
|
+
import { restrictTools } from "./restrict-tools.js";
|
|
12
13
|
import { buildSystemPrompt } from "./system-prompt.js";
|
|
14
|
+
import { createWaker } from "./wake.js";
|
|
13
15
|
/** The plugins every agent gets unless the caller passes its own list: project instructions (AGENTS.md), long tool output saved to files, resilience (replies that were cut off or came back empty are asked again), and limits (unlimited until configured). */
|
|
14
16
|
export function corePlugins() {
|
|
15
17
|
return [contextPlugin(), toolsPlugin(), resiliencePlugin(), limitsPlugin()];
|
|
16
18
|
}
|
|
17
19
|
const SUB_AGENT_NOTE = "You are a sub-agent working for another agent. Do the task you are given completely, then finish with a clear written report of what you did and found. That report is the only thing that goes back to the agent that asked.";
|
|
18
|
-
const CACHE_CHECK_MS = 240_000;
|
|
19
|
-
const checkInput = (labels) => `Background work you started is still running: ${labels.join(", ")}. Check on it once now (for example read its output), then stop and wait; you will be told when it ends. Do not poll.`;
|
|
20
20
|
const isClient = (model) => "stream" in model;
|
|
21
21
|
export async function createAgent(config) {
|
|
22
22
|
const cwd = config.cwd ?? process.cwd();
|
|
23
|
-
|
|
23
|
+
if (config.confine && config.environment)
|
|
24
|
+
throw new Error("confine applies to the default local environment; for your own environment build it with createLocalEnvironment(cwd, { confine: true })");
|
|
25
|
+
const env = config.environment ?? createLocalEnvironment(cwd, config.confine ? { confine: true } : {});
|
|
24
26
|
const connection = config.mcp ? await connectMcp(config.mcp.servers, config.mcp.timeoutMs !== undefined ? { timeoutMs: config.mcp.timeoutMs } : {}) : undefined;
|
|
25
27
|
const mcpTools = connection?.tools ?? [];
|
|
26
28
|
const expose = config.mcp?.expose ?? (mcpTools.length > 10 ? "search" : "direct");
|
|
@@ -28,7 +30,7 @@ export async function createAgent(config) {
|
|
|
28
30
|
...(config.bashSoftTimeoutMs !== undefined ? { softTimeoutMs: config.bashSoftTimeoutMs } : {}),
|
|
29
31
|
...(config.bashBackgroundGraceMs !== undefined ? { backgroundGraceMs: config.bashBackgroundGraceMs } : {}),
|
|
30
32
|
};
|
|
31
|
-
const tools =
|
|
33
|
+
const tools = (config.tools ?? ["read", "write", "edit", "bash"]).map((entry) => (typeof entry === "string" ? builtinTools([entry], { bash: bashOptions })[0] : entry));
|
|
32
34
|
const declared = expose === "direct" ? [...tools, ...mcpTools] : tools;
|
|
33
35
|
const clientFor = (model) => isClient(model)
|
|
34
36
|
? model
|
|
@@ -39,11 +41,20 @@ export async function createAgent(config) {
|
|
|
39
41
|
contextWindow: model.contextWindow,
|
|
40
42
|
...(model.compat ? { compat: model.compat } : {}),
|
|
41
43
|
...(model.headers ? { headers: model.headers } : {}),
|
|
44
|
+
// an image path that a tool returned is a path in the environment, which may be another machine
|
|
45
|
+
resolveImage: async (ref, mediaType) => `data:${mediaType};base64,${Buffer.from(await env.readFile(ref)).toString("base64")}`,
|
|
42
46
|
});
|
|
43
47
|
const llm = clientFor(config.model);
|
|
44
48
|
const thinkingModel = !isClient(config.model) && config.model.compat?.thinkingFormat !== undefined && config.model.compat.thinkingFormat !== "none";
|
|
45
49
|
const thinking = config.thinking ?? (thinkingModel ? "medium" : undefined);
|
|
46
|
-
const system = config.system ??
|
|
50
|
+
const system = config.system ??
|
|
51
|
+
buildSystemPrompt({
|
|
52
|
+
tools,
|
|
53
|
+
cwd: env.cwd,
|
|
54
|
+
platform: process.platform,
|
|
55
|
+
...(env.description !== undefined ? { description: env.description } : {}),
|
|
56
|
+
...(config.rules ? { rules: config.rules } : {}),
|
|
57
|
+
});
|
|
47
58
|
// bundles are opened and a plugin that appears twice is installed once; a missing requirement, a cycle or two plugins with one name fail here, before anything runs
|
|
48
59
|
const plugins = compose(config.plugins ?? corePlugins());
|
|
49
60
|
const notInherited = new Set(config.subAgents?.notInherited ?? ["agents", "limits", "context", "tools"]);
|
|
@@ -51,9 +62,13 @@ export async function createAgent(config) {
|
|
|
51
62
|
if (mcpTools.length > 0 && expose === "search")
|
|
52
63
|
plugins.push(searchPlugin({ catalog: mcpTools }));
|
|
53
64
|
// a plugin that offers `authorize` (the review plugin does) is asked before each call a script makes, because those calls do not pass the loop's hooks
|
|
54
|
-
const
|
|
55
|
-
|
|
65
|
+
const reviewer = plugins.find((plugin) => typeof plugin.authorize === "function");
|
|
66
|
+
const authorize = reviewer?.authorize;
|
|
67
|
+
if (mcpTools.length > 0 && expose === "codemode") {
|
|
56
68
|
plugins.push(codemodePlugin({ catalog: mcpTools, ...(authorize ? { authorize } : {}) }));
|
|
69
|
+
// every call a script makes is reviewed one by one, so the script as a whole is not reviewed again
|
|
70
|
+
reviewer?.exempt?.(CODEMODE_TOOL);
|
|
71
|
+
}
|
|
57
72
|
const childLogs = [];
|
|
58
73
|
const childModels = new Map(Object.entries(config.subAgents?.models ?? {}).map(([name, model]) => [name, clientFor(model)]));
|
|
59
74
|
if (config.subAgents) {
|
|
@@ -65,7 +80,7 @@ export async function createAgent(config) {
|
|
|
65
80
|
env,
|
|
66
81
|
system: `${definition.systemPrompt ?? system}\n\n${SUB_AGENT_NOTE}`,
|
|
67
82
|
tools: definition.tools ? tools.filter((tool) => definition.tools?.includes(tool.name)) : tools,
|
|
68
|
-
plugins: maxTurns !== undefined ? [
|
|
83
|
+
plugins: [...(definition.tools ? restrictTools(inherited, definition.tools) : inherited), ...(maxTurns !== undefined ? [limitsPlugin({ maxTurns })] : [])],
|
|
69
84
|
...(config.maxTokens !== undefined ? { maxTokens: config.maxTokens } : {}),
|
|
70
85
|
...(thinking !== undefined ? { thinking } : {}),
|
|
71
86
|
...(config.compaction !== undefined ? { compaction: config.compaction } : {}),
|
|
@@ -90,7 +105,7 @@ export async function createAgent(config) {
|
|
|
90
105
|
...(config.maxTokens !== undefined ? { maxTokens: config.maxTokens } : {}),
|
|
91
106
|
...(thinking !== undefined ? { thinking } : {}),
|
|
92
107
|
...(config.now ? { now: config.now } : {}),
|
|
93
|
-
|
|
108
|
+
onSignal: (_log, _text, options) => waker.signalled(options),
|
|
94
109
|
...(config.compaction !== undefined ? { compaction: config.compaction } : {}),
|
|
95
110
|
...(config.goal ? { goal: config.goal } : {}),
|
|
96
111
|
...(config.prompts ? { prompts: config.prompts } : {}),
|
|
@@ -98,63 +113,43 @@ export async function createAgent(config) {
|
|
|
98
113
|
const fileLog = config.sessionFile ? await openFileLog(config.sessionFile, { cwd: env.cwd }) : undefined;
|
|
99
114
|
const log = fileLog ?? createMemoryLog();
|
|
100
115
|
let active;
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
116
|
+
const waker = createWaker({
|
|
117
|
+
setting: config.wake,
|
|
118
|
+
pending: () => kernel.pending(log),
|
|
119
|
+
start: (input) => start(input),
|
|
120
|
+
busy: () => active !== undefined,
|
|
121
|
+
});
|
|
122
|
+
function start(input, options) {
|
|
123
|
+
waker.started();
|
|
124
|
+
const handle = kernel.run(log, input, options);
|
|
106
125
|
active = handle;
|
|
107
|
-
|
|
126
|
+
const ended = (cancelled) => {
|
|
108
127
|
if (active === handle)
|
|
109
128
|
active = undefined;
|
|
110
|
-
|
|
111
|
-
}
|
|
129
|
+
waker.ended(cancelled);
|
|
130
|
+
};
|
|
131
|
+
void handle.result.then((result) => ended(result.reason === "cancelled"), () => ended(false));
|
|
112
132
|
return handle;
|
|
113
133
|
}
|
|
114
|
-
function wakeWith(input) {
|
|
115
|
-
if (closed || active || !config.wake)
|
|
116
|
-
return;
|
|
117
|
-
config.wake.onRun(start(input));
|
|
118
|
-
}
|
|
119
|
-
function onBackgroundEnd() {
|
|
120
|
-
clearTimeout(timer);
|
|
121
|
-
checkUsed = false;
|
|
122
|
-
wakeWith(undefined); // while a run is going, it delivers the notice itself or afterRun does
|
|
123
|
-
}
|
|
124
|
-
/** Called when a run ends: wakes for anything the run did not get to tell the agent, or arms the one check before the cache expires. */
|
|
125
|
-
function afterRun() {
|
|
126
|
-
if (closed || !config.wake)
|
|
127
|
-
return;
|
|
128
|
-
const pending = kernel.pending(log);
|
|
129
|
-
if (pending.signals.length > 0) {
|
|
130
|
-
wakeWith(undefined);
|
|
131
|
-
return;
|
|
132
|
-
}
|
|
133
|
-
if (pending.watching.length === 0 || checkUsed)
|
|
134
|
-
return;
|
|
135
|
-
clearTimeout(timer);
|
|
136
|
-
timer = setTimeout(() => {
|
|
137
|
-
const now = kernel.pending(log);
|
|
138
|
-
if (now.signals.length > 0)
|
|
139
|
-
return wakeWith(undefined);
|
|
140
|
-
if (now.watching.length === 0)
|
|
141
|
-
return;
|
|
142
|
-
checkUsed = true;
|
|
143
|
-
wakeWith(checkInput(now.watching));
|
|
144
|
-
}, config.wake.cacheCheckMs ?? CACHE_CHECK_MS);
|
|
145
|
-
timer.unref();
|
|
146
|
-
}
|
|
147
134
|
return {
|
|
148
135
|
log,
|
|
149
136
|
mcp: connection?.status ?? {},
|
|
150
137
|
rewindTo: (entryId) => log.rewindTo(entryId),
|
|
151
|
-
|
|
138
|
+
fork: async (path, upTo) => {
|
|
139
|
+
await forkLog(log, path, { cwd: env.cwd, ...(upTo !== undefined ? { upTo } : {}), ...(fileLog ? { sessionId: fileLog.header.id } : {}) });
|
|
140
|
+
},
|
|
141
|
+
pending: () => kernel.pending(log),
|
|
142
|
+
goal: () => kernel.goal(log),
|
|
143
|
+
setGoal: (goal) => kernel.setGoal(log, goal),
|
|
144
|
+
clearGoal: () => kernel.clearGoal(log),
|
|
145
|
+
context: () => kernel.context(log),
|
|
146
|
+
usage: () => kernel.usage(log),
|
|
147
|
+
run: (input, options) => start(input, options),
|
|
152
148
|
queue: (text) => active?.queue(text),
|
|
153
149
|
steer: (text) => active?.steer(text),
|
|
154
150
|
cancel: () => active?.cancel(),
|
|
155
151
|
close: async () => {
|
|
156
|
-
|
|
157
|
-
clearTimeout(timer);
|
|
152
|
+
waker.close();
|
|
158
153
|
await connection?.close();
|
|
159
154
|
await env.dispose(); // stops commands that are still running in the background
|
|
160
155
|
await Promise.all(childLogs.map((log) => log.close()));
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { Plugin } from "../../kernel/src/index.js";
|
|
2
|
+
/**
|
|
3
|
+
* The plugins as a sub-agent with a limited tool list sees them: a tool a plugin registers is kept only if its name is on the list, the same rule
|
|
4
|
+
* that applies to the built-in tools. Everything else about the plugins stays as it is.
|
|
5
|
+
*/
|
|
6
|
+
export declare function restrictTools(plugins: readonly Plugin[], allowed: readonly string[]): Plugin[];
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The plugins as a sub-agent with a limited tool list sees them: a tool a plugin registers is kept only if its name is on the list, the same rule
|
|
3
|
+
* that applies to the built-in tools. Everything else about the plugins stays as it is.
|
|
4
|
+
*/
|
|
5
|
+
export function restrictTools(plugins, allowed) {
|
|
6
|
+
const names = new Set(allowed);
|
|
7
|
+
return plugins.map((plugin) => ({
|
|
8
|
+
...plugin,
|
|
9
|
+
setup: (api) => plugin.setup({ ...api, registerTool: (tool) => (names.has(tool.name) ? api.registerTool(tool) : undefined) }),
|
|
10
|
+
}));
|
|
11
|
+
}
|
|
@@ -3,6 +3,11 @@ export interface SystemPromptOptions {
|
|
|
3
3
|
tools: readonly Tool[];
|
|
4
4
|
cwd: string;
|
|
5
5
|
platform: string;
|
|
6
|
+
/**
|
|
7
|
+
* What the environment says about where the tools run (Environment.description). When there is one, it takes the place of the platform line,
|
|
8
|
+
* because the platform of this machine is then not where the commands run.
|
|
9
|
+
*/
|
|
10
|
+
description?: string;
|
|
6
11
|
/** Extra rules from plugins. */
|
|
7
12
|
rules?: string[];
|
|
8
13
|
}
|
|
@@ -42,7 +42,7 @@ export function buildSystemPrompt(options) {
|
|
|
42
42
|
PREAMBLE,
|
|
43
43
|
options.tools.length > 0 ? `<tools>\n${toolLines.join("\n")}\n</tools>` : "",
|
|
44
44
|
`<rules>\n${rules.map((rule) => `- ${rule}`).join("\n")}\n</rules>`,
|
|
45
|
-
`<environment>\nWorking directory: ${options.cwd}\
|
|
45
|
+
`<environment>\nWorking directory: ${options.cwd}\n${options.description !== undefined ? `Where your tools run: ${options.description}` : `Platform: ${options.platform}`}\n</environment>`,
|
|
46
46
|
]
|
|
47
47
|
.filter((section) => section !== "")
|
|
48
48
|
.join("\n\n");
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { RunHandle } from "../../kernel/src/index.js";
|
|
2
|
+
/** What the application can ask for. */
|
|
3
|
+
export interface WakeSetting {
|
|
4
|
+
/** Receives every run the agent starts by itself, to show its events. */
|
|
5
|
+
onRun(handle: RunHandle): void;
|
|
6
|
+
/** How long a quiet agent with work still under way is left before it is woken once to check on it (default 4 minutes, a little under the prompt cache lifetime). */
|
|
7
|
+
cacheCheckMs?: number;
|
|
8
|
+
}
|
|
9
|
+
export interface WakerOptions {
|
|
10
|
+
/** The application's setting; with none, the agent is woken only for work that asked for it. */
|
|
11
|
+
setting: WakeSetting | undefined;
|
|
12
|
+
/** What the kernel says is waiting: notices the agent has not been told yet, and work that is still under way. */
|
|
13
|
+
pending(): {
|
|
14
|
+
signals: string[];
|
|
15
|
+
watching: string[];
|
|
16
|
+
};
|
|
17
|
+
/** Starts a run on the agent's conversation. With no input it tells the agent what is waiting. */
|
|
18
|
+
start(input?: string): RunHandle;
|
|
19
|
+
/** Whether a run is going now. */
|
|
20
|
+
busy(): boolean;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* When the agent starts a run by itself. It decides three things and keeps the state they need in one place:
|
|
24
|
+
* - when background work ends and the agent is quiet, a run starts so that it hears about it, if the application asked for that in general or the work asked
|
|
25
|
+
* for it in particular (the work's own wish wins);
|
|
26
|
+
* - a quiet agent with work still under way is woken once to check on it before the prompt cache expires, then left alone until something ends;
|
|
27
|
+
* - after a run that was cancelled nothing starts by itself: whoever cancelled wanted it to stop, and what is waiting is told in the next run.
|
|
28
|
+
*/
|
|
29
|
+
export interface Waker {
|
|
30
|
+
/** Background work ended. `options.wake` is what that work asked for; left out, the application's setting decides. */
|
|
31
|
+
signalled(options?: {
|
|
32
|
+
wake?: boolean;
|
|
33
|
+
}): void;
|
|
34
|
+
/** A run is starting. It tells the agent everything that is waiting. */
|
|
35
|
+
started(): void;
|
|
36
|
+
/** A run ended. */
|
|
37
|
+
ended(cancelled: boolean): void;
|
|
38
|
+
/** No more runs by itself. */
|
|
39
|
+
close(): void;
|
|
40
|
+
}
|
|
41
|
+
export declare function createWaker(options: WakerOptions): Waker;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
const CACHE_CHECK_MS = 240_000;
|
|
2
|
+
const checkInput = (labels) => `Background work you started is still running: ${labels.join(", ")}. Check on it once now (for example read its output), then stop and wait; you will be told when it ends. Do not poll.`;
|
|
3
|
+
/** The run of an agent that woke itself when the application gave no setting: nobody shows it, the log has all of it. */
|
|
4
|
+
async function drain(handle) {
|
|
5
|
+
for await (const _event of handle.events)
|
|
6
|
+
void _event;
|
|
7
|
+
}
|
|
8
|
+
export function createWaker(options) {
|
|
9
|
+
const { setting } = options;
|
|
10
|
+
let timer;
|
|
11
|
+
let closed = false;
|
|
12
|
+
let checkUsed = false; // the one check per quiet period has been made
|
|
13
|
+
let owed = false; // something that asked to wake the agent ended, and no run has told it yet
|
|
14
|
+
function wakeWith(input) {
|
|
15
|
+
if (closed || options.busy())
|
|
16
|
+
return;
|
|
17
|
+
const handle = options.start(input);
|
|
18
|
+
if (setting)
|
|
19
|
+
setting.onRun(handle);
|
|
20
|
+
else
|
|
21
|
+
void drain(handle);
|
|
22
|
+
}
|
|
23
|
+
return {
|
|
24
|
+
signalled(wish) {
|
|
25
|
+
if (!(wish?.wake ?? setting !== undefined))
|
|
26
|
+
return;
|
|
27
|
+
clearTimeout(timer);
|
|
28
|
+
checkUsed = false;
|
|
29
|
+
owed = true;
|
|
30
|
+
wakeWith(); // while a run is going, it delivers the notice itself or `ended` does
|
|
31
|
+
},
|
|
32
|
+
started() {
|
|
33
|
+
owed = false;
|
|
34
|
+
},
|
|
35
|
+
ended(cancelled) {
|
|
36
|
+
if (closed)
|
|
37
|
+
return;
|
|
38
|
+
if (cancelled) {
|
|
39
|
+
owed = false;
|
|
40
|
+
clearTimeout(timer);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
const pending = options.pending();
|
|
44
|
+
if (pending.signals.length > 0 && (setting || owed)) {
|
|
45
|
+
wakeWith();
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
if (pending.signals.length === 0)
|
|
49
|
+
owed = false;
|
|
50
|
+
if (!setting)
|
|
51
|
+
return; // the check before the cache expires is the application's to ask for
|
|
52
|
+
if (pending.watching.length === 0 || checkUsed)
|
|
53
|
+
return;
|
|
54
|
+
clearTimeout(timer);
|
|
55
|
+
timer = setTimeout(() => {
|
|
56
|
+
const now = options.pending();
|
|
57
|
+
if (now.signals.length > 0)
|
|
58
|
+
return wakeWith();
|
|
59
|
+
if (now.watching.length === 0)
|
|
60
|
+
return;
|
|
61
|
+
checkUsed = true;
|
|
62
|
+
wakeWith(checkInput(now.watching));
|
|
63
|
+
}, setting.cacheCheckMs ?? CACHE_CHECK_MS);
|
|
64
|
+
timer.unref();
|
|
65
|
+
},
|
|
66
|
+
close() {
|
|
67
|
+
closed = true;
|
|
68
|
+
clearTimeout(timer);
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Thrown when a confined environment is asked for something outside its working directory. */
|
|
2
|
+
export declare class OutsideDirectoryError extends Error {
|
|
3
|
+
readonly path: string;
|
|
4
|
+
readonly root: string;
|
|
5
|
+
constructor(path: string, root: string);
|
|
6
|
+
}
|
|
7
|
+
/** Whether an absolute, normalised path is the root or below it, by text ("/work-evil" is not inside "/work"). */
|
|
8
|
+
export declare const isInside: (root: string, path: string) => boolean;
|
|
9
|
+
/**
|
|
10
|
+
* Where a path really is, following links, for a path that may not exist yet: the real location of its deepest existing ancestor, plus the
|
|
11
|
+
* names below it. A link that points nowhere is not followed blindly (writing through it would create its target): it counts as outside.
|
|
12
|
+
*/
|
|
13
|
+
export declare function realLocation(path: string, root: string): Promise<string>;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { lstat, realpath } from "node:fs/promises";
|
|
2
|
+
import { basename, dirname, resolve, sep } from "node:path";
|
|
3
|
+
/** Thrown when a confined environment is asked for something outside its working directory. */
|
|
4
|
+
export class OutsideDirectoryError extends Error {
|
|
5
|
+
path;
|
|
6
|
+
root;
|
|
7
|
+
constructor(path, root) {
|
|
8
|
+
super(`${path} is outside the working directory ${root}`);
|
|
9
|
+
this.name = "OutsideDirectoryError";
|
|
10
|
+
this.path = path;
|
|
11
|
+
this.root = root;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
/** Whether an absolute, normalised path is the root or below it, by text ("/work-evil" is not inside "/work"). */
|
|
15
|
+
export const isInside = (root, path) => path === root || path.startsWith(root.endsWith(sep) ? root : root + sep);
|
|
16
|
+
/**
|
|
17
|
+
* Where a path really is, following links, for a path that may not exist yet: the real location of its deepest existing ancestor, plus the
|
|
18
|
+
* names below it. A link that points nowhere is not followed blindly (writing through it would create its target): it counts as outside.
|
|
19
|
+
*/
|
|
20
|
+
export async function realLocation(path, root) {
|
|
21
|
+
const below = [];
|
|
22
|
+
let current = path;
|
|
23
|
+
while (true) {
|
|
24
|
+
try {
|
|
25
|
+
return resolve(await realpath(current), ...below.reverse());
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
const code = error.code;
|
|
29
|
+
if (code !== "ENOENT" && code !== "ENOTDIR")
|
|
30
|
+
throw error;
|
|
31
|
+
if (await lstat(current).then(() => true, () => false))
|
|
32
|
+
throw new OutsideDirectoryError(path, root); // a dangling link
|
|
33
|
+
const parent = dirname(current);
|
|
34
|
+
if (parent === current)
|
|
35
|
+
throw error;
|
|
36
|
+
below.push(basename(current));
|
|
37
|
+
current = parent;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { RemoteRunner } from "./remote.js";
|
|
2
|
+
import type { RemoteStreamer } from "./remote-stream.js";
|
|
3
|
+
export interface SshOptions {
|
|
4
|
+
/** What to connect to: a name from ~/.ssh/config, or user@host. Authentication, keys and host keys are whatever ssh is set up to do. */
|
|
5
|
+
host: string;
|
|
6
|
+
port?: number;
|
|
7
|
+
identityFile?: string;
|
|
8
|
+
/** Extra ssh options, written as `-o Name=value` (ProxyJump, StrictHostKeyChecking, ...). */
|
|
9
|
+
options?: Record<string, string>;
|
|
10
|
+
/** Seconds to wait for a connection to be made (default 15). */
|
|
11
|
+
connectTimeoutSec?: number;
|
|
12
|
+
/** The ssh program to run (default "ssh"). */
|
|
13
|
+
ssh?: string;
|
|
14
|
+
/**
|
|
15
|
+
* How many commands may be running over the connection at once (default 8). A server accepts only so many sessions on one connection (sshd's
|
|
16
|
+
* MaxSessions is 10 unless changed) and refuses the rest, so the others wait here for a free place. A command that runs in the background keeps its place.
|
|
17
|
+
* Two of the places are kept for short jobs (reading a file, stopping a command), so at most this number minus two commands run at once.
|
|
18
|
+
*/
|
|
19
|
+
maxSessions?: number;
|
|
20
|
+
}
|
|
21
|
+
export interface SshConnection {
|
|
22
|
+
/** Runs a command on the remote machine over the connection. Rejects with RemoteConnectionError when ssh could not reach it. */
|
|
23
|
+
run: RemoteRunner;
|
|
24
|
+
/** Runs a command as a streamed child of the connection: its output arrives as it is produced and the child ends when the command does. */
|
|
25
|
+
stream: RemoteStreamer;
|
|
26
|
+
/** The directory on this machine that holds the socket of the connection (for diagnostics and tests). */
|
|
27
|
+
readonly socketDirectory: string;
|
|
28
|
+
/** Hangs up and removes what was made on this machine. Safe to call more than once. */
|
|
29
|
+
close(): Promise<void>;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* 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
|
|
33
|
+
* 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
|
|
34
|
+
* 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
|
|
35
|
+
* 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).
|
|
36
|
+
* The result is a RemoteConnectionError that tells the caller to stop and report it.
|
|
37
|
+
*/
|
|
38
|
+
export declare function connectSsh(options: SshOptions): Promise<SshConnection>;
|
|
39
|
+
/** What the model is told about where its tools run, over ssh. */
|
|
40
|
+
export declare function describeSsh(host: string, cwd: string): string;
|
|
41
|
+
/** An environment whose tools run on another machine over ssh. Close it (or dispose of the agent) to hang up. */
|
|
42
|
+
export declare function createSshEnvironment(options: SshOptions & {
|
|
43
|
+
cwd: string;
|
|
44
|
+
workDir?: string;
|
|
45
|
+
}): Promise<import("../../kernel/src/index.js").Environment>;
|