hammerkit 1.25.0 → 1.27.0-next.1
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/build.schema.json +53 -1
- package/dist/cache/backends/local-cache-backend.js +10 -2
- package/dist/cache/explain.js +13 -11
- package/dist/cli.js +3 -0
- package/dist/docker/item-network.js +471 -0
- package/dist/docker/pull.js +4 -2
- package/dist/docker/remove-orphaned-containers.js +13 -6
- package/dist/environment/replace-env-variables.js +8 -6
- package/dist/executer/await-completed-dependencies.js +2 -1
- package/dist/executer/check-readiness.js +2 -2
- package/dist/executer/container-task-state.js +8 -6
- package/dist/executer/docker-service.js +24 -13
- package/dist/executer/docker-task.js +18 -29
- package/dist/executer/event-cache.js +4 -0
- package/dist/executer/execute-command.js +18 -5
- package/dist/executer/execute-docker.js +4 -1
- package/dist/executer/execution-steps.js +33 -7
- package/dist/executer/get-docker-executor.js +14 -7
- package/dist/executer/scheduler/enqueue-next.js +17 -3
- package/dist/executer/watch-loop.js +2 -2
- package/dist/executer/watch-state-key.js +1 -1
- package/dist/file/within-root.js +42 -0
- package/dist/harness/connect.js +99 -0
- package/dist/harness/format.js +73 -0
- package/dist/harness/mcp-command.js +59 -0
- package/dist/harness/mcp.js +432 -0
- package/dist/harness/opencode.js +492 -0
- package/dist/harness/pi.js +217 -0
- package/dist/harness/sandbox-client.js +373 -0
- package/dist/index.js +33 -36
- package/dist/kubernetes/apply.js +31 -1
- package/dist/kubernetes/await-running-state.js +33 -1
- package/dist/kubernetes/ensure-kubernetes-deployment-exists.js +44 -22
- package/dist/kubernetes/ensure-kubernetes-persistent-volume-claim-exists.js +131 -4
- package/dist/kubernetes/ensure-kubernetes-service-exists.js +25 -4
- package/dist/kubernetes/ensure-persistent-data.js +48 -7
- package/dist/kubernetes/get-service-ip.js +15 -1
- package/dist/kubernetes/job-placement.js +99 -0
- package/dist/kubernetes/job-secrets.js +306 -0
- package/dist/kubernetes/kept-claims.js +191 -0
- package/dist/kubernetes/kubernetes-instance.js +10 -2
- package/dist/kubernetes/network-gate.js +71 -0
- package/dist/kubernetes/network-policy.js +526 -0
- package/dist/kubernetes/resources.js +13 -2
- package/dist/kubernetes/restore-kubernetes-data.js +19 -4
- package/dist/kubernetes/secrets.js +2 -0
- package/dist/kubernetes/store-kubernetes-data.js +1 -1
- package/dist/kubernetes/volumes.js +100 -11
- package/dist/main.js +41 -0
- package/dist/network/egress-proxy.js +368 -0
- package/dist/network/port-relay.js +35 -0
- package/dist/optimizer/get-work-cache-stats.js +16 -0
- package/dist/optimizer/work-task-cache-description.js +3 -2
- package/dist/planner/service-definition.js +8 -2
- package/dist/planner/utils/append-work-environment.js +2 -0
- package/dist/planner/utils/append-work-service.js +4 -0
- package/dist/planner/utils/append-work-task.js +17 -1
- package/dist/planner/utils/assert-item-paths.js +50 -0
- package/dist/planner/utils/hosted-volumes.js +35 -0
- package/dist/planner/utils/task-arguments.js +155 -0
- package/dist/planner/work-cache-id.js +15 -1
- package/dist/planner/work-network.js +193 -0
- package/dist/planner/work-runtime-docker.js +43 -0
- package/dist/planner/work-runtime-kubernetes.js +336 -102
- package/dist/planner/work-runtime-local.js +16 -8
- package/dist/planner/work-service.js +7 -3
- package/dist/planner/work-state-record.js +30 -0
- package/dist/program.js +80 -7
- package/dist/remote/annotations.js +103 -0
- package/dist/remote/bundle-text.js +24 -0
- package/dist/remote/bundle.js +9 -1
- package/dist/remote/remote-commands.js +4 -1
- package/dist/remote/remote-work-commands.js +182 -69
- package/dist/remote/server-client.js +18 -5
- package/dist/remote/source-manifest.js +15 -2
- package/dist/remote/token-source.js +67 -0
- package/dist/schema/build-file-base-task-schema.js +3 -0
- package/dist/schema/build-file-container-service-schema.js +6 -0
- package/dist/schema/build-file-container-task-schema.js +3 -0
- package/dist/schema/build-file-environment-schema.js +9 -0
- package/dist/schema/network-schema.js +78 -0
- package/dist/schema/reference-parser.js +1 -0
- package/dist/schema/schema-parser.js +16 -3
- package/dist/schema/work-scope-parser.js +13 -0
- package/dist/secrets/command-secret-provider.js +73 -10
- package/dist/secrets/github-app-token-command.js +23 -0
- package/dist/secrets/github-app-token.js +191 -0
- package/dist/server/accounts-api.js +9 -2
- package/dist/server/definitions-api.js +69 -47
- package/dist/server/definitions.js +76 -51
- package/dist/server/executor.js +156 -41
- package/dist/server/git-checkout.js +72 -8
- package/dist/server/kubernetes-backend.js +554 -0
- package/dist/server/kubernetes-sandbox-storage.js +996 -0
- package/dist/server/oauth.js +1 -0
- package/dist/server/plan.js +51 -1
- package/dist/server/policy.js +104 -15
- package/dist/server/proposals-api.js +24 -40
- package/dist/server/proposals.js +12 -25
- package/dist/server/repo-access.js +37 -6
- package/dist/server/run-logs.js +217 -0
- package/dist/server/run-plan.js +51 -2
- package/dist/server/run-request.js +23 -10
- package/dist/server/run-store.js +5 -0
- package/dist/server/run-worker-command.js +13 -1
- package/dist/server/run-worker.js +121 -24
- package/dist/server/runs.js +184 -10
- package/dist/server/sandbox-docker.js +62 -0
- package/dist/server/sandbox-files-api.js +269 -0
- package/dist/server/sandbox-files.js +1054 -0
- package/dist/server/sandbox-patch.js +481 -0
- package/dist/server/sandbox-pod-command.js +15 -0
- package/dist/server/sandbox-pod.js +146 -0
- package/dist/server/sandbox-storage.js +462 -0
- package/dist/server/sandboxes-api.js +210 -0
- package/dist/server/sandboxes.js +519 -0
- package/dist/server/server-config.js +412 -22
- package/dist/server/server.js +204 -39
- package/dist/utils/run-id.js +12 -0
- package/harness/opencode.ts +11 -0
- package/harness/pi.ts +7 -0
- package/package.json +8 -2
- package/dist/remote/text-diff.js +0 -89
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.hammerkitPiExtension = exports.createPiOperations = void 0;
|
|
4
|
+
const path_1 = require("path");
|
|
5
|
+
const connect_1 = require("./connect");
|
|
6
|
+
const format_1 = require("./format");
|
|
7
|
+
const sandbox_client_1 = require("./sandbox-client");
|
|
8
|
+
const IMAGE_TYPES = {
|
|
9
|
+
'.png': 'image/png',
|
|
10
|
+
'.jpg': 'image/jpeg',
|
|
11
|
+
'.jpeg': 'image/jpeg',
|
|
12
|
+
'.gif': 'image/gif',
|
|
13
|
+
'.webp': 'image/webp',
|
|
14
|
+
};
|
|
15
|
+
// a listing is read in pages of this many entries, up to the most a directory answers
|
|
16
|
+
const PAGE = 1000;
|
|
17
|
+
const MAX_ENTRIES = 20000;
|
|
18
|
+
function notFound(path) {
|
|
19
|
+
return Object.assign(new Error(`ENOENT: no such file or directory, '${path}'`), { code: 'ENOENT' });
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* The backends of pi's own tools on a hammerkit sandbox. pi resolves every path against its
|
|
23
|
+
* working directory (`root`), which stands for the root of the sandbox. The model keeps
|
|
24
|
+
* pi's tools, their parameters and their output; only where they read, write and run moves.
|
|
25
|
+
*/
|
|
26
|
+
function createPiOperations(client, root) {
|
|
27
|
+
const path = (absolute) => (0, sandbox_client_1.toSandboxPath)(root, absolute);
|
|
28
|
+
const stat = async (absolute) => (await client()).stat(path(absolute));
|
|
29
|
+
const readFile = async (absolute) => (await client()).readRaw(path(absolute));
|
|
30
|
+
const writeFile = async (absolute, content) => {
|
|
31
|
+
await (await client()).writeRaw(path(absolute), content);
|
|
32
|
+
};
|
|
33
|
+
const accessFile = async (absolute) => {
|
|
34
|
+
const found = await stat(absolute);
|
|
35
|
+
if (!found) {
|
|
36
|
+
throw notFound(absolute);
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
const exists = async (absolute) => (await stat(absolute)) !== undefined;
|
|
40
|
+
const read = {
|
|
41
|
+
readFile,
|
|
42
|
+
access: accessFile,
|
|
43
|
+
detectImageMimeType: async (absolute) => IMAGE_TYPES[(0, path_1.extname)(absolute).toLowerCase()] ?? null,
|
|
44
|
+
};
|
|
45
|
+
// a write makes the directories it needs
|
|
46
|
+
const write = { writeFile, mkdir: async () => undefined };
|
|
47
|
+
const edit = { readFile, writeFile, access: accessFile };
|
|
48
|
+
const bash = {
|
|
49
|
+
exec: async (command, cwd, { onData, signal, timeout }) => {
|
|
50
|
+
const sandbox = await client();
|
|
51
|
+
const ran = await sandbox.exec(command, path(cwd), {
|
|
52
|
+
signal,
|
|
53
|
+
timeoutSeconds: timeout,
|
|
54
|
+
onLine: (line) => onData(Buffer.from(`${line}\n`)),
|
|
55
|
+
onWaiting: (run) => onData(Buffer.from(`hammerkit: run ${run.id} waits for an approval of ${run.accounts.join(', ')}\n`)),
|
|
56
|
+
});
|
|
57
|
+
if (signal?.aborted) {
|
|
58
|
+
throw new Error('aborted');
|
|
59
|
+
}
|
|
60
|
+
if (timeout && ran.result?.error?.startsWith('timed out')) {
|
|
61
|
+
throw new Error(`timeout:${timeout}`);
|
|
62
|
+
}
|
|
63
|
+
if (ran.exitCode === null) {
|
|
64
|
+
// the task never ran or ended without an exit code: say why
|
|
65
|
+
const why = ran.result?.error ?? ran.run.error ?? `the run ended ${ran.run.state}`;
|
|
66
|
+
onData(Buffer.from(`hammerkit: ${why}\n`));
|
|
67
|
+
}
|
|
68
|
+
return { exitCode: ran.exitCode };
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
const ls = {
|
|
72
|
+
exists,
|
|
73
|
+
stat: async (absolute) => {
|
|
74
|
+
const found = await stat(absolute);
|
|
75
|
+
if (!found) {
|
|
76
|
+
throw notFound(absolute);
|
|
77
|
+
}
|
|
78
|
+
return { isDirectory: () => found.type === 'directory' };
|
|
79
|
+
},
|
|
80
|
+
readdir: async (absolute) => {
|
|
81
|
+
const sandbox = await client();
|
|
82
|
+
const directory = path(absolute);
|
|
83
|
+
const names = [];
|
|
84
|
+
let cursor;
|
|
85
|
+
do {
|
|
86
|
+
const page = await sandbox.tree({ path: directory || undefined, depth: 1, limit: PAGE, cursor });
|
|
87
|
+
names.push(...page.entries.map((entry) => (0, path_1.basename)(entry.path)));
|
|
88
|
+
cursor = page.cursor ?? undefined;
|
|
89
|
+
} while (cursor && names.length < MAX_ENTRIES);
|
|
90
|
+
return names;
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
const find = {
|
|
94
|
+
exists,
|
|
95
|
+
glob: async (pattern, cwd, { limit }) => {
|
|
96
|
+
const sandbox = await client();
|
|
97
|
+
const directory = path(cwd);
|
|
98
|
+
// as pi's own find (fd): a pattern matches at any depth below the directory
|
|
99
|
+
const anywhere = pattern === '**' || pattern.startsWith('**/') || pattern.startsWith('/');
|
|
100
|
+
const page = await sandbox.tree({
|
|
101
|
+
path: directory || undefined,
|
|
102
|
+
glob: anywhere ? pattern.replace(/^\/+/, '') : `**/${pattern}`,
|
|
103
|
+
limit: Math.min(Math.max(limit, 1), MAX_ENTRIES),
|
|
104
|
+
});
|
|
105
|
+
return page.entries.map((entry) => (0, path_1.join)(root, ...entry.path.split('/')));
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
// pi's grep runs ripgrep on the machine; this one runs it on the server
|
|
109
|
+
const grep = async (params) => {
|
|
110
|
+
const sandbox = await client();
|
|
111
|
+
const result = await sandbox.search({
|
|
112
|
+
pattern: params.pattern,
|
|
113
|
+
// pi takes `@src` for `src`, as its own grep does
|
|
114
|
+
path: params.path ? path(params.path.replace(/^@/, '')) || undefined : undefined,
|
|
115
|
+
glob: params.glob ? [params.glob] : undefined,
|
|
116
|
+
i: params.ignoreCase,
|
|
117
|
+
fixed_strings: params.literal,
|
|
118
|
+
C: params.context,
|
|
119
|
+
output_mode: 'content',
|
|
120
|
+
head_limit: params.limit ?? 100,
|
|
121
|
+
});
|
|
122
|
+
return { content: [{ type: 'text', text: (0, format_1.formatSearch)(result) }], details: undefined };
|
|
123
|
+
};
|
|
124
|
+
return { read, write, edit, bash, ls, find, grep };
|
|
125
|
+
}
|
|
126
|
+
exports.createPiOperations = createPiOperations;
|
|
127
|
+
/**
|
|
128
|
+
* The pi extension: pi's read, write, edit, bash, ls, find and grep tools, and the user's
|
|
129
|
+
* `!` commands, work on a hammerkit sandbox, and `push` takes the work out as a branch. Configured with the same variables as
|
|
130
|
+
* `hammerkit remote mcp` takes options: HAMMERKIT_SERVER, HAMMERKIT_SANDBOX (or HAMMERKIT_REPO and
|
|
131
|
+
* HAMMERKIT_REF), HAMMERKIT_BUILD and HAMMERKIT_SHELL.
|
|
132
|
+
*/
|
|
133
|
+
function hammerkitPiExtension(library, settings = {}) {
|
|
134
|
+
return (pi) => {
|
|
135
|
+
const root = settings.cwd ?? process.cwd();
|
|
136
|
+
const environment = settings.environment ?? (0, connect_1.getHarnessEnvironment)(root);
|
|
137
|
+
const options = settings.options ?? (0, connect_1.harnessOptionsFromEnv)(environment.processEnvs);
|
|
138
|
+
const notes = [];
|
|
139
|
+
let connecting;
|
|
140
|
+
const client = () => {
|
|
141
|
+
if (!connecting) {
|
|
142
|
+
connecting = settings.client
|
|
143
|
+
? settings.client()
|
|
144
|
+
: (0, connect_1.connectSandbox)(environment, options, (line) => notes.push(line));
|
|
145
|
+
// a failure is said by the call that needed it, the next one tries again
|
|
146
|
+
connecting.catch(() => (connecting = undefined));
|
|
147
|
+
}
|
|
148
|
+
return connecting;
|
|
149
|
+
};
|
|
150
|
+
const ops = createPiOperations(client, root);
|
|
151
|
+
// pi runs the tool calls of a turn in parallel, they reach the sandbox one at a time in their order
|
|
152
|
+
const inTurn = (0, sandbox_client_1.oneAtATime)();
|
|
153
|
+
const register = (tool) => pi.registerTool({ ...tool, execute: (...args) => inTurn(() => tool.execute(...args)) });
|
|
154
|
+
register(library.createReadToolDefinition(root, { operations: ops.read }));
|
|
155
|
+
register(library.createWriteToolDefinition(root, { operations: ops.write }));
|
|
156
|
+
register(library.createEditToolDefinition(root, { operations: ops.edit }));
|
|
157
|
+
register(library.createBashToolDefinition(root, { operations: ops.bash }));
|
|
158
|
+
register(library.createLsToolDefinition(root, { operations: ops.ls }));
|
|
159
|
+
register(library.createFindToolDefinition(root, { operations: ops.find }));
|
|
160
|
+
const grep = library.createGrepToolDefinition(root);
|
|
161
|
+
register({ ...grep, execute: (_id, params) => ops.grep(params) });
|
|
162
|
+
register({
|
|
163
|
+
name: 'push',
|
|
164
|
+
label: 'push',
|
|
165
|
+
description: 'Commit the changes of the hammerkit sandbox and push them to a branch of its repository, for review. ' +
|
|
166
|
+
'The server pushes only to the branches its operator allows; the sandbox is at the pushed commit afterwards.',
|
|
167
|
+
promptSnippet: 'Push the changes of the sandbox to a branch',
|
|
168
|
+
parameters: {
|
|
169
|
+
type: 'object',
|
|
170
|
+
properties: {
|
|
171
|
+
branch: { type: 'string', description: 'The branch to push to, created when it does not exist' },
|
|
172
|
+
message: { type: 'string', description: 'The commit message' },
|
|
173
|
+
},
|
|
174
|
+
required: ['branch', 'message'],
|
|
175
|
+
},
|
|
176
|
+
execute: async (_id, params) => {
|
|
177
|
+
const pushed = await (await client()).push(params.branch, params.message);
|
|
178
|
+
return { content: [{ type: 'text', text: `pushed ${pushed.commit} to ${pushed.branch}` }], details: pushed };
|
|
179
|
+
},
|
|
180
|
+
});
|
|
181
|
+
pi.on('session_start', async (_event, ctx) => {
|
|
182
|
+
try {
|
|
183
|
+
const sandbox = await client();
|
|
184
|
+
ctx?.ui?.setStatus?.('hammerkit', `hammerkit sandbox ${sandbox.id}`);
|
|
185
|
+
for (const note of notes.splice(0)) {
|
|
186
|
+
ctx?.ui?.notify?.(note, 'info');
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
catch (e) {
|
|
190
|
+
ctx?.ui?.notify?.(`hammerkit: ${e instanceof Error ? e.message : String(e)}`, 'error');
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
const userBash = { exec: (...args) => inTurn(() => ops.bash.exec(...args)) };
|
|
194
|
+
pi.on('user_bash', () => ({ operations: userBash }));
|
|
195
|
+
pi.on('before_agent_start', async (event) => {
|
|
196
|
+
let sandbox;
|
|
197
|
+
try {
|
|
198
|
+
sandbox = await client();
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
return undefined;
|
|
202
|
+
}
|
|
203
|
+
const where = `Files and commands are on hammerkit sandbox ${sandbox.id} of ${sandbox.sandbox.repo} ` +
|
|
204
|
+
`(fetched at ${sandbox.sandbox.commit}), ${root} stands for its root. Commands run as the task ` +
|
|
205
|
+
`${sandbox.build.shell} of the published build file ${sandbox.build.hash}, in its container, ` +
|
|
206
|
+
`one at a time, and start in the root of the sandbox, where ${root} does not exist: give them ` +
|
|
207
|
+
'relative paths. Nothing is installed on this machine.';
|
|
208
|
+
const line = `Current working directory: ${root}`;
|
|
209
|
+
const prompt = event.systemPrompt;
|
|
210
|
+
return {
|
|
211
|
+
systemPrompt: prompt.includes(line) ? prompt.replace(line, `${line}\n${where}`) : `${prompt}\n\n${where}`,
|
|
212
|
+
};
|
|
213
|
+
});
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
exports.hammerkitPiExtension = hammerkitPiExtension;
|
|
217
|
+
//# sourceMappingURL=pi.js.map
|
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.quotePosix = exports.fetchFailure = exports.toSandboxPath = exports.checkSandboxPath = exports.exitCodeOf = exports.oneAtATime = exports.SandboxClient = exports.SandboxClientError = void 0;
|
|
4
|
+
const path_1 = require("path");
|
|
5
|
+
const token_source_1 = require("../remote/token-source");
|
|
6
|
+
// a write carries up to 32 MiB
|
|
7
|
+
const TIMEOUT_MS = 60000;
|
|
8
|
+
// a run is asked for this long at a time, the log is read in between
|
|
9
|
+
const POLL_WAIT = '1s';
|
|
10
|
+
class SandboxClientError extends Error {
|
|
11
|
+
constructor(message, status) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.status = status;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
exports.SandboxClientError = SandboxClientError;
|
|
17
|
+
/**
|
|
18
|
+
* A caller's sandbox on a hammerkit server, over its HTTP API: the raw read, write and
|
|
19
|
+
* stat that harnesses plug their own tools into, the typed endpoints for clients without
|
|
20
|
+
* tools of their own, and published tasks run on the files. A shell is such a task: the
|
|
21
|
+
* build file names a task whose last command is `sh -c`, and the command is its argument.
|
|
22
|
+
*/
|
|
23
|
+
class SandboxClient {
|
|
24
|
+
constructor(connection, sandbox,
|
|
25
|
+
// the hash of the published version of the build file whose tasks run on the sandbox, and
|
|
26
|
+
// its shell task
|
|
27
|
+
build) {
|
|
28
|
+
this.connection = connection;
|
|
29
|
+
this.sandbox = sandbox;
|
|
30
|
+
this.build = build;
|
|
31
|
+
}
|
|
32
|
+
get id() {
|
|
33
|
+
return this.sandbox.id;
|
|
34
|
+
}
|
|
35
|
+
static async create(connection, repo, ref) {
|
|
36
|
+
return json(await call(connection, 'POST', '/v1/sandboxes', { body: { repo, ref } }));
|
|
37
|
+
}
|
|
38
|
+
static async get(connection, id) {
|
|
39
|
+
return json(await call(connection, 'GET', `/v1/sandboxes/${encodeURIComponent(id)}`));
|
|
40
|
+
}
|
|
41
|
+
static async list(connection) {
|
|
42
|
+
return (await json(await call(connection, 'GET', '/v1/sandboxes'))).sandboxes;
|
|
43
|
+
}
|
|
44
|
+
// checks out the ref again, what changed in the sandbox is gone
|
|
45
|
+
static async fetch(connection, id, ref) {
|
|
46
|
+
return json(await call(connection, 'POST', `/v1/sandboxes/${encodeURIComponent(id)}/fetch`, { body: { ref } }));
|
|
47
|
+
}
|
|
48
|
+
async stat(path) {
|
|
49
|
+
if (checkSandboxPath(path) === '') {
|
|
50
|
+
// the root is there as long as the sandbox is
|
|
51
|
+
return { path: '', type: 'directory', size: 0, exec: false, modifiedAt: new Date(0).toISOString() };
|
|
52
|
+
}
|
|
53
|
+
const response = await call(this.connection, 'GET', this.file(path), { query: { stat: '' }, missing: true });
|
|
54
|
+
return response.status === 404 ? undefined : json(response);
|
|
55
|
+
}
|
|
56
|
+
async readRaw(path) {
|
|
57
|
+
const response = await call(this.connection, 'GET', this.file(path), { query: { raw: '' } });
|
|
58
|
+
if (response.headers.get('x-hammerkit-type') === 'directory') {
|
|
59
|
+
throw new SandboxClientError(`${path} is a directory`, 400);
|
|
60
|
+
}
|
|
61
|
+
return Buffer.from(await response.arrayBuffer());
|
|
62
|
+
}
|
|
63
|
+
async writeRaw(path, content) {
|
|
64
|
+
return json(await call(this.connection, 'PUT', this.file(path), { raw: content }));
|
|
65
|
+
}
|
|
66
|
+
// numbered lines, like `cat -n`
|
|
67
|
+
async read(path, offset, limit) {
|
|
68
|
+
return json(await call(this.connection, 'GET', this.file(path), { query: { offset, limit } }));
|
|
69
|
+
}
|
|
70
|
+
async tree(query) {
|
|
71
|
+
return json(await call(this.connection, 'GET', `${this.base()}/tree`, { query: { ...query } }));
|
|
72
|
+
}
|
|
73
|
+
async search(query) {
|
|
74
|
+
return json(await call(this.connection, 'POST', `${this.base()}/search`, { body: query }));
|
|
75
|
+
}
|
|
76
|
+
// all or nothing
|
|
77
|
+
async edit(ops, dryRun = false) {
|
|
78
|
+
const answer = await call(this.connection, 'POST', `${this.base()}/edits`, { body: { dryRun, ops } });
|
|
79
|
+
return (await json(answer)).files;
|
|
80
|
+
}
|
|
81
|
+
async changes() {
|
|
82
|
+
return json(await call(this.connection, 'GET', `${this.base()}/changes`));
|
|
83
|
+
}
|
|
84
|
+
async diff(format) {
|
|
85
|
+
return (await call(this.connection, 'GET', `${this.base()}/changes`, { query: { format } })).text();
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Commits the changes of the sandbox and pushes them to a branch of its repository, one
|
|
89
|
+
* the operator lets the caller push to. The sandbox is at the pushed commit then.
|
|
90
|
+
*/
|
|
91
|
+
async push(branch, message) {
|
|
92
|
+
const pushed = await json(await call(this.connection, 'POST', `${this.base()}/push`, { body: { branch, message } }));
|
|
93
|
+
return { branch: pushed.branch, commit: pushed.commit };
|
|
94
|
+
}
|
|
95
|
+
async getRun(id) {
|
|
96
|
+
return json(await call(this.connection, 'GET', `/v1/runs/${encodeURIComponent(id)}`));
|
|
97
|
+
}
|
|
98
|
+
// the log of a run: `task`, `tail`, `from`/`limit`, `grep` with `i` and `context`
|
|
99
|
+
async logs(id, query) {
|
|
100
|
+
return json(await call(this.connection, 'GET', `/v1/runs/${encodeURIComponent(id)}/logs`, { query: { ...query } }));
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Runs a task of the published build file on the sandbox and follows it to its end: the
|
|
104
|
+
* lines it prints, its result and exit code. A signal cancels the run.
|
|
105
|
+
*/
|
|
106
|
+
async runTask(task, options = {}) {
|
|
107
|
+
if (options.signal?.aborted) {
|
|
108
|
+
// cancelled while it waited for its turn
|
|
109
|
+
throw new SandboxClientError('the run was cancelled');
|
|
110
|
+
}
|
|
111
|
+
const runOptions = {};
|
|
112
|
+
if (options.args) {
|
|
113
|
+
runOptions.args = options.args;
|
|
114
|
+
}
|
|
115
|
+
if (options.timeoutSeconds) {
|
|
116
|
+
runOptions.timeout = `${Math.max(1, Math.ceil(options.timeoutSeconds))}s`;
|
|
117
|
+
}
|
|
118
|
+
const submitted = await json(await call(this.connection, 'POST', '/v1/runs', {
|
|
119
|
+
body: {
|
|
120
|
+
definition: this.build.hash,
|
|
121
|
+
tasks: [task],
|
|
122
|
+
source: { type: 'sandbox', id: this.id },
|
|
123
|
+
options: runOptions,
|
|
124
|
+
},
|
|
125
|
+
}));
|
|
126
|
+
const path = `/v1/runs/${encodeURIComponent(submitted.id)}`;
|
|
127
|
+
const cancel = () => {
|
|
128
|
+
call(this.connection, 'POST', `${path}/cancel`).catch(() => undefined);
|
|
129
|
+
};
|
|
130
|
+
options.signal?.addEventListener('abort', cancel, { once: true });
|
|
131
|
+
const lines = [];
|
|
132
|
+
let from = 1;
|
|
133
|
+
let truncated = false;
|
|
134
|
+
const readLog = async () => {
|
|
135
|
+
for (;;) {
|
|
136
|
+
const page = await json(await call(this.connection, 'GET', `${path}/logs`, { query: { task, from, limit: 1000 } }));
|
|
137
|
+
truncated = page.truncated;
|
|
138
|
+
for (const line of page.lines) {
|
|
139
|
+
lines.push(line.text);
|
|
140
|
+
options.onLine?.(line.text);
|
|
141
|
+
from = line.line + 1;
|
|
142
|
+
}
|
|
143
|
+
if (!page.next) {
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
try {
|
|
149
|
+
let run = submitted;
|
|
150
|
+
let waitingSaid = false;
|
|
151
|
+
for (;;) {
|
|
152
|
+
if (options.signal?.aborted) {
|
|
153
|
+
throw new SandboxClientError('the run was cancelled');
|
|
154
|
+
}
|
|
155
|
+
if (run.state === 'awaiting-approval' && !waitingSaid) {
|
|
156
|
+
waitingSaid = true;
|
|
157
|
+
options.onWaiting?.(run);
|
|
158
|
+
}
|
|
159
|
+
await readLog();
|
|
160
|
+
if (['succeeded', 'failed', 'cancelled', 'rejected'].includes(run.state)) {
|
|
161
|
+
break;
|
|
162
|
+
}
|
|
163
|
+
run = await json(await call(this.connection, 'GET', path, { query: { wait: POLL_WAIT } }));
|
|
164
|
+
}
|
|
165
|
+
const result = run.results?.[task];
|
|
166
|
+
if (truncated && result) {
|
|
167
|
+
// the end of a long log is kept in the result
|
|
168
|
+
for (const line of ['… the server cut the log off at its limit, the last lines were:', ...result.lastLog]) {
|
|
169
|
+
lines.push(line);
|
|
170
|
+
options.onLine?.(line);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return {
|
|
174
|
+
run,
|
|
175
|
+
result,
|
|
176
|
+
output: lines.join('\n'),
|
|
177
|
+
truncated,
|
|
178
|
+
exitCode: exitCodeOf(result),
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
finally {
|
|
182
|
+
options.signal?.removeEventListener('abort', cancel);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* A shell command on the sandbox: the shell task of the build file with the command as
|
|
187
|
+
* its argument. `cwd` is a directory of the sandbox, `''` for its root.
|
|
188
|
+
*/
|
|
189
|
+
async exec(command, cwd, options = {}) {
|
|
190
|
+
// on its own line: `cd dir && a; b` would run b in the root when dir is missing
|
|
191
|
+
const script = cwd && cwd !== '.' ? `cd -- ${quotePosix(cwd)} || exit 1\n${command}` : command;
|
|
192
|
+
// a shell task in a container without `mounts: [.]` runs in an empty directory: the
|
|
193
|
+
// command would see none of the files and its writes would be lost, so it does not run
|
|
194
|
+
const onLine = options.onLine;
|
|
195
|
+
const guarded = await this.runTask(this.build.shell, {
|
|
196
|
+
...options,
|
|
197
|
+
args: [`${EMPTY_GUARD}\n${script}`],
|
|
198
|
+
onLine: onLine && ((line) => line !== EMPTY_MARKER && onLine(line)),
|
|
199
|
+
});
|
|
200
|
+
if (guarded.exitCode !== EMPTY_EXIT || !guarded.output.split('\n').includes(EMPTY_MARKER)) {
|
|
201
|
+
return guarded;
|
|
202
|
+
}
|
|
203
|
+
if ((await this.tree({ depth: 1, limit: 1 })).entries.length > 0) {
|
|
204
|
+
throw new SandboxClientError(`the shell task ${this.build.shell} of ${this.build.hash} sees none of the files of the sandbox, ` +
|
|
205
|
+
`a shell task in a container needs mounts: [.] to work on them`);
|
|
206
|
+
}
|
|
207
|
+
// the sandbox is empty indeed
|
|
208
|
+
return this.runTask(this.build.shell, { ...options, args: [script] });
|
|
209
|
+
}
|
|
210
|
+
base() {
|
|
211
|
+
return `/v1/sandboxes/${encodeURIComponent(this.id)}`;
|
|
212
|
+
}
|
|
213
|
+
file(path) {
|
|
214
|
+
const clean = checkSandboxPath(path);
|
|
215
|
+
if (!clean) {
|
|
216
|
+
throw new SandboxClientError('a file needs a path below the root of the sandbox', 400);
|
|
217
|
+
}
|
|
218
|
+
return `${this.base()}/files/${clean.split('/').map(encodeURIComponent).join('/')}`;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
exports.SandboxClient = SandboxClient;
|
|
222
|
+
// The first lines of every shell command: it stops when the working directory is empty,
|
|
223
|
+
// links included, without any tool but the shell itself.
|
|
224
|
+
const EMPTY_MARKER = 'hammerkit: the shell sees no files';
|
|
225
|
+
const EMPTY_EXIT = 125;
|
|
226
|
+
const EMPTY_GUARD = [
|
|
227
|
+
'hk_seen=',
|
|
228
|
+
'for hk_f in * .[!.]* ..?*; do if [ -e "$hk_f" ] || [ -L "$hk_f" ]; then hk_seen=1; break; fi; done',
|
|
229
|
+
`[ -n "$hk_seen" ] || { echo '${EMPTY_MARKER}'; exit ${EMPTY_EXIT}; }`,
|
|
230
|
+
'unset hk_seen hk_f',
|
|
231
|
+
].join('\n');
|
|
232
|
+
// The exit code of a task as a shell reports it: 0 for a task that succeeded or came
|
|
233
|
+
// from the cache, its own when it failed, none when it never ran or ended without one.
|
|
234
|
+
/**
|
|
235
|
+
* Runs the calls one at a time, in the order they came. Agents send tool calls in parallel,
|
|
236
|
+
* and the server refuses edits while a run holds the sandbox: an edit sent along with a
|
|
237
|
+
* command lands before the command starts, instead of being refused.
|
|
238
|
+
*/
|
|
239
|
+
function oneAtATime() {
|
|
240
|
+
let last = Promise.resolve();
|
|
241
|
+
return (call) => {
|
|
242
|
+
const next = last.then(call);
|
|
243
|
+
last = next.catch(() => undefined);
|
|
244
|
+
return next;
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
exports.oneAtATime = oneAtATime;
|
|
248
|
+
function exitCodeOf(result) {
|
|
249
|
+
if (!result) {
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
if (result.status === 'succeeded' || result.status === 'cached') {
|
|
253
|
+
return 0;
|
|
254
|
+
}
|
|
255
|
+
return result.exitCode ?? null;
|
|
256
|
+
}
|
|
257
|
+
exports.exitCodeOf = exitCodeOf;
|
|
258
|
+
// A path of the sandbox as the API takes it: relative, `/` separated, no `..`.
|
|
259
|
+
function checkSandboxPath(path) {
|
|
260
|
+
// normalize leaves no `./` but a lone `.`, and `..` only at the start
|
|
261
|
+
const normal = path_1.posix.normalize(path.replace(/\\/g, '/')).replace(/\/$/, '');
|
|
262
|
+
if (normal.startsWith('/') || normal === '..' || normal.startsWith('../')) {
|
|
263
|
+
throw new SandboxClientError(`${path} is outside of the sandbox`, 400);
|
|
264
|
+
}
|
|
265
|
+
return normal === '.' ? '' : normal;
|
|
266
|
+
}
|
|
267
|
+
exports.checkSandboxPath = checkSandboxPath;
|
|
268
|
+
/**
|
|
269
|
+
* Maps a path of the harness onto the sandbox. Harnesses resolve paths against their own
|
|
270
|
+
* working directory, which stands for the root of the sandbox: `<root>/src/a.ts` is
|
|
271
|
+
* `src/a.ts` there. Anything outside of it is refused.
|
|
272
|
+
*/
|
|
273
|
+
function toSandboxPath(root, path) {
|
|
274
|
+
const relativePath = (0, path_1.isAbsolute)(path) ? (0, path_1.relative)(root, path) : path;
|
|
275
|
+
if ((0, path_1.isAbsolute)(relativePath)) {
|
|
276
|
+
throw new SandboxClientError(`${path} is outside of the sandbox at ${root}`, 400);
|
|
277
|
+
}
|
|
278
|
+
try {
|
|
279
|
+
return checkSandboxPath(relativePath.split(path_1.sep).join('/'));
|
|
280
|
+
}
|
|
281
|
+
catch {
|
|
282
|
+
throw new SandboxClientError(`${path} is outside of the sandbox at ${root}`, 400);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
exports.toSandboxPath = toSandboxPath;
|
|
286
|
+
// The certificate errors of a server this machine does not trust
|
|
287
|
+
const UNTRUSTED_CERTIFICATE = [
|
|
288
|
+
'DEPTH_ZERO_SELF_SIGNED_CERT',
|
|
289
|
+
'SELF_SIGNED_CERT_IN_CHAIN',
|
|
290
|
+
'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
|
|
291
|
+
'UNABLE_TO_GET_ISSUER_CERT',
|
|
292
|
+
'UNABLE_TO_GET_ISSUER_CERT_LOCALLY',
|
|
293
|
+
'CERT_UNTRUSTED',
|
|
294
|
+
];
|
|
295
|
+
/**
|
|
296
|
+
* Why a fetch failed: node's fetch says only "fetch failed" and keeps the reason, such as a
|
|
297
|
+
* refused connection or an untrusted certificate, as its cause.
|
|
298
|
+
*/
|
|
299
|
+
function fetchFailure(e) {
|
|
300
|
+
if (!(e instanceof Error)) {
|
|
301
|
+
return String(e);
|
|
302
|
+
}
|
|
303
|
+
const cause = e.cause;
|
|
304
|
+
if (!(cause instanceof Error)) {
|
|
305
|
+
return e.message;
|
|
306
|
+
}
|
|
307
|
+
const code = cause.code;
|
|
308
|
+
const reason = `${e.message}: ${cause.message}${code && !cause.message.includes(code) ? ` (${code})` : ''}`;
|
|
309
|
+
return code && UNTRUSTED_CERTIFICATE.includes(code)
|
|
310
|
+
? `${reason}, this machine does not trust the certificate of the server: name its CA in NODE_EXTRA_CA_CERTS`
|
|
311
|
+
: reason;
|
|
312
|
+
}
|
|
313
|
+
exports.fetchFailure = fetchFailure;
|
|
314
|
+
function redact(message, token) {
|
|
315
|
+
return message.split(token).join('***');
|
|
316
|
+
}
|
|
317
|
+
function quotePosix(value) {
|
|
318
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
319
|
+
}
|
|
320
|
+
exports.quotePosix = quotePosix;
|
|
321
|
+
function json(response) {
|
|
322
|
+
return response.json();
|
|
323
|
+
}
|
|
324
|
+
async function call(connection, method, path, options = {}) {
|
|
325
|
+
const url = new URL(`${connection.url.replace(/\/$/, '')}${path}`);
|
|
326
|
+
for (const [key, value] of Object.entries(options.query ?? {})) {
|
|
327
|
+
if (value !== undefined) {
|
|
328
|
+
url.searchParams.set(key, String(value));
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
const send = async () => {
|
|
332
|
+
const token = await connection.token();
|
|
333
|
+
if (!(0, token_source_1.isToken)(token)) {
|
|
334
|
+
// a header refuses such a value and would quote it whole in its error
|
|
335
|
+
throw new SandboxClientError('the token is not a single line of visible characters');
|
|
336
|
+
}
|
|
337
|
+
return fetch(url, {
|
|
338
|
+
method,
|
|
339
|
+
headers: {
|
|
340
|
+
authorization: `Bearer ${token}`,
|
|
341
|
+
...(options.body !== undefined ? { 'content-type': 'application/json' } : {}),
|
|
342
|
+
...(options.raw !== undefined ? { 'content-type': 'application/octet-stream' } : {}),
|
|
343
|
+
},
|
|
344
|
+
body: options.body !== undefined ? JSON.stringify(options.body) : options.raw,
|
|
345
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
346
|
+
// an answer from anywhere else is not the server's, and a PUT would send its file on
|
|
347
|
+
redirect: 'error',
|
|
348
|
+
}).catch((e) => {
|
|
349
|
+
throw new SandboxClientError(`unable to reach ${connection.url}: ${redact(fetchFailure(e), token)}`);
|
|
350
|
+
});
|
|
351
|
+
};
|
|
352
|
+
let response = await send();
|
|
353
|
+
if (response.status === 401 && connection.renew) {
|
|
354
|
+
await connection.renew();
|
|
355
|
+
response = await send();
|
|
356
|
+
}
|
|
357
|
+
if (response.ok || (options.missing && response.status === 404)) {
|
|
358
|
+
return response;
|
|
359
|
+
}
|
|
360
|
+
let said = '';
|
|
361
|
+
try {
|
|
362
|
+
const body = await json(response);
|
|
363
|
+
said = typeof body?.error === 'string' ? body.error : '';
|
|
364
|
+
}
|
|
365
|
+
catch {
|
|
366
|
+
// no JSON
|
|
367
|
+
}
|
|
368
|
+
if (response.status === 401) {
|
|
369
|
+
throw new SandboxClientError(`${connection.url} did not accept the token`, 401);
|
|
370
|
+
}
|
|
371
|
+
throw new SandboxClientError(said || `${connection.url} answered ${response.status}`, response.status);
|
|
372
|
+
}
|
|
373
|
+
//# sourceMappingURL=sandbox-client.js.map
|
package/dist/index.js
CHANGED
|
@@ -1,41 +1,38 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
"use strict";
|
|
3
|
-
Object.
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
const empty_writable_1 = require("./utils/empty-writable");
|
|
9
|
-
const run_program_1 = require("./run-program");
|
|
10
|
-
const abort_on_signals_1 = require("./utils/abort-on-signals");
|
|
11
|
-
const abortCtrl = new AbortController();
|
|
12
|
-
(0, abort_on_signals_1.abortOnSignals)(process, abortCtrl);
|
|
13
|
-
let settled = false;
|
|
14
|
-
// A run that never settles (a task waiting on something that can't happen)
|
|
15
|
-
// lets the event loop drain, and Node then exits 0: CI would report success
|
|
16
|
-
// for a build that never finished. Fail loudly instead.
|
|
17
|
-
process.on('beforeExit', () => {
|
|
18
|
-
if (!settled) {
|
|
19
|
-
process.stderr.write('hammerkit stopped before the run finished\n');
|
|
20
|
-
process.exitCode = 1;
|
|
3
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
4
|
+
if (k2 === undefined) k2 = k;
|
|
5
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
6
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
7
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
21
8
|
}
|
|
9
|
+
Object.defineProperty(o, k2, desc);
|
|
10
|
+
}) : (function(o, m, k, k2) {
|
|
11
|
+
if (k2 === undefined) k2 = k;
|
|
12
|
+
o[k2] = m[k];
|
|
13
|
+
}));
|
|
14
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
15
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
16
|
+
}) : function(o, v) {
|
|
17
|
+
o["default"] = v;
|
|
22
18
|
});
|
|
23
|
-
|
|
24
|
-
(
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
19
|
+
var __importStar = (this && this.__importStar) || function (mod) {
|
|
20
|
+
if (mod && mod.__esModule) return mod;
|
|
21
|
+
var result = {};
|
|
22
|
+
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
|
|
23
|
+
__setModuleDefault(result, mod);
|
|
24
|
+
return result;
|
|
25
|
+
};
|
|
26
|
+
// A server on a cluster runs `hammerkit server-sandbox <directory>` in the pod of a
|
|
27
|
+
// sandbox's files for every request on them. It loads the code of sandboxes only: the
|
|
28
|
+
// whole CLI takes most of a second to load, and more on a pod with little CPU.
|
|
29
|
+
if (process.argv[2] === 'server-sandbox' && process.argv.length === 4) {
|
|
30
|
+
void Promise.resolve().then(() => __importStar(require('./server/sandbox-pod-command'))).then(({ runSandboxPodCommand }) => runSandboxPodCommand(process.argv[3], process.stdin, process.stdout).catch((e) => {
|
|
31
|
+
process.stderr.write(`${e instanceof Error ? e.message : String(e)}\n`);
|
|
32
|
+
process.exitCode = 1;
|
|
33
|
+
}));
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
void Promise.resolve().then(() => __importStar(require('./main')));
|
|
37
|
+
}
|
|
41
38
|
//# sourceMappingURL=index.js.map
|