hammerkit 1.26.0 → 1.27.0-next.10

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 (128) hide show
  1. package/build.schema.json +62 -1
  2. package/dist/cache/backends/local-cache-backend.js +10 -2
  3. package/dist/cache/backends/registry-cache-backend.js +2 -2
  4. package/dist/cache/explain.js +13 -11
  5. package/dist/cli.js +3 -0
  6. package/dist/docker/item-network.js +471 -0
  7. package/dist/docker/pull.js +4 -2
  8. package/dist/docker/remove-orphaned-containers.js +13 -6
  9. package/dist/environment/replace-env-variables.js +8 -6
  10. package/dist/executer/await-completed-dependencies.js +2 -1
  11. package/dist/executer/check-readiness.js +2 -2
  12. package/dist/executer/docker-service.js +24 -13
  13. package/dist/executer/docker-task.js +18 -29
  14. package/dist/executer/event-cache.js +24 -2
  15. package/dist/executer/execute-command.js +18 -5
  16. package/dist/executer/execute-docker.js +4 -1
  17. package/dist/executer/execution-steps.js +33 -7
  18. package/dist/executer/get-docker-executor.js +14 -7
  19. package/dist/executer/scheduler/enqueue-next.js +17 -3
  20. package/dist/executer/watch-loop.js +2 -2
  21. package/dist/executer/watch-state-key.js +1 -1
  22. package/dist/file/within-root.js +42 -0
  23. package/dist/harness/connect.js +99 -0
  24. package/dist/harness/format.js +73 -0
  25. package/dist/harness/mcp-command.js +59 -0
  26. package/dist/harness/mcp.js +432 -0
  27. package/dist/harness/opencode.js +492 -0
  28. package/dist/harness/pi.js +217 -0
  29. package/dist/harness/sandbox-client.js +411 -0
  30. package/dist/index.js +33 -36
  31. package/dist/kubernetes/apply.js +42 -1
  32. package/dist/kubernetes/await-running-state.js +33 -1
  33. package/dist/kubernetes/claim-layout.js +71 -0
  34. package/dist/kubernetes/ensure-http-route.js +1 -1
  35. package/dist/kubernetes/ensure-kubernetes-deployment-exists.js +46 -28
  36. package/dist/kubernetes/ensure-kubernetes-persistent-volume-claim-exists.js +132 -5
  37. package/dist/kubernetes/ensure-kubernetes-service-exists.js +27 -11
  38. package/dist/kubernetes/ensure-persistent-data.js +75 -24
  39. package/dist/kubernetes/get-service-ip.js +16 -2
  40. package/dist/kubernetes/job-placement.js +99 -0
  41. package/dist/kubernetes/job-secrets.js +320 -0
  42. package/dist/kubernetes/kept-claims.js +203 -0
  43. package/dist/kubernetes/kubernetes-instance.js +10 -2
  44. package/dist/kubernetes/network-gate.js +71 -0
  45. package/dist/kubernetes/network-policy.js +583 -0
  46. package/dist/kubernetes/remove-persistent-data.js +3 -1
  47. package/dist/kubernetes/resources.js +37 -4
  48. package/dist/kubernetes/restore-kubernetes-data.js +28 -7
  49. package/dist/kubernetes/secrets.js +7 -9
  50. package/dist/kubernetes/store-kubernetes-data.js +1 -1
  51. package/dist/kubernetes/volumes.js +104 -12
  52. package/dist/log.js +1 -1
  53. package/dist/main.js +41 -0
  54. package/dist/network/egress-proxy.js +407 -0
  55. package/dist/network/port-relay.js +35 -0
  56. package/dist/optimizer/get-work-cache-stats.js +16 -0
  57. package/dist/optimizer/work-task-cache-description.js +5 -2
  58. package/dist/planner/service-definition.js +5 -0
  59. package/dist/planner/utils/append-work-dependencies.js +5 -2
  60. package/dist/planner/utils/append-work-environment.js +2 -0
  61. package/dist/planner/utils/append-work-service.js +5 -0
  62. package/dist/planner/utils/append-work-task.js +20 -1
  63. package/dist/planner/utils/assert-item-paths.js +50 -0
  64. package/dist/planner/utils/hosted-volumes.js +35 -0
  65. package/dist/planner/utils/task-arguments.js +3 -0
  66. package/dist/planner/work-network.js +193 -0
  67. package/dist/planner/work-runtime-docker.js +43 -1
  68. package/dist/planner/work-runtime-kubernetes.js +370 -111
  69. package/dist/planner/work-runtime-local.js +2 -1
  70. package/dist/program.js +33 -5
  71. package/dist/remote/annotations.js +115 -0
  72. package/dist/remote/bundle-text.js +24 -0
  73. package/dist/remote/bundle.js +9 -1
  74. package/dist/remote/remote-commands.js +15 -3
  75. package/dist/remote/remote-work-commands.js +272 -71
  76. package/dist/remote/server-client.js +18 -5
  77. package/dist/remote/source-manifest.js +15 -2
  78. package/dist/remote/token-source.js +67 -0
  79. package/dist/schema/build-file-base-task-schema.js +7 -0
  80. package/dist/schema/build-file-container-service-schema.js +8 -0
  81. package/dist/schema/build-file-container-task-schema.js +3 -0
  82. package/dist/schema/build-file-environment-schema.js +9 -0
  83. package/dist/schema/network-schema.js +78 -0
  84. package/dist/schema/reference-parser.js +2 -0
  85. package/dist/schema/schema-parser.js +16 -3
  86. package/dist/schema/work-scope-parser.js +5 -0
  87. package/dist/secrets/command-secret-provider.js +73 -10
  88. package/dist/secrets/github-app-token-command.js +26 -0
  89. package/dist/secrets/github-app-token.js +191 -0
  90. package/dist/server/accounts-api.js +9 -2
  91. package/dist/server/definitions-api.js +69 -47
  92. package/dist/server/definitions.js +76 -51
  93. package/dist/server/executor.js +156 -41
  94. package/dist/server/git-checkout.js +72 -8
  95. package/dist/server/kubernetes-backend.js +603 -0
  96. package/dist/server/kubernetes-sandbox-storage.js +997 -0
  97. package/dist/server/oauth.js +12 -0
  98. package/dist/server/plan.js +88 -2
  99. package/dist/server/policy.js +104 -15
  100. package/dist/server/proposals-api.js +24 -40
  101. package/dist/server/proposals.js +12 -25
  102. package/dist/server/repo-access.js +37 -6
  103. package/dist/server/run-logs.js +217 -0
  104. package/dist/server/run-plan.js +48 -2
  105. package/dist/server/run-request.js +29 -10
  106. package/dist/server/run-store.js +5 -0
  107. package/dist/server/run-worker-command.js +13 -1
  108. package/dist/server/run-worker.js +121 -24
  109. package/dist/server/runs.js +191 -10
  110. package/dist/server/sandbox-docker.js +62 -0
  111. package/dist/server/sandbox-files-api.js +269 -0
  112. package/dist/server/sandbox-files.js +1054 -0
  113. package/dist/server/sandbox-patch.js +481 -0
  114. package/dist/server/sandbox-pod-command.js +15 -0
  115. package/dist/server/sandbox-pod.js +146 -0
  116. package/dist/server/sandbox-storage.js +462 -0
  117. package/dist/server/sandboxes-api.js +216 -0
  118. package/dist/server/sandboxes.js +519 -0
  119. package/dist/server/server-command.js +39 -20
  120. package/dist/server/server-config.js +443 -22
  121. package/dist/server/server.js +219 -40
  122. package/dist/utils/abort-on-signals.js +46 -6
  123. package/dist/utils/run-id.js +12 -0
  124. package/dist/utils/tar.js +25 -0
  125. package/harness/opencode.ts +11 -0
  126. package/harness/pi.ts +7 -0
  127. package/package.json +8 -2
  128. 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,411 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.quotePosix = exports.fetchFailure = exports.toSandboxPath = exports.checkSandboxPath = exports.exitCodeOf = exports.followRun = exports.submitRun = exports.FINAL_RUN_STATES = 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
+ // a cancelled run waits this long for the server to take the cancel, before it gives up
11
+ const CANCEL_WAIT_MS = 10000;
12
+ class SandboxClientError extends Error {
13
+ constructor(message, status) {
14
+ super(message);
15
+ this.status = status;
16
+ }
17
+ }
18
+ exports.SandboxClientError = SandboxClientError;
19
+ /**
20
+ * A caller's sandbox on a hammerkit server, over its HTTP API: the raw read, write and
21
+ * stat that harnesses plug their own tools into, the typed endpoints for clients without
22
+ * tools of their own, and published tasks run on the files. A shell is such a task: the
23
+ * build file names a task whose last command is `sh -c`, and the command is its argument.
24
+ */
25
+ class SandboxClient {
26
+ constructor(connection, sandbox,
27
+ // the hash of the published version of the build file whose tasks run on the sandbox, and
28
+ // its shell task
29
+ build) {
30
+ this.connection = connection;
31
+ this.sandbox = sandbox;
32
+ this.build = build;
33
+ }
34
+ get id() {
35
+ return this.sandbox.id;
36
+ }
37
+ static async create(connection, repo, ref) {
38
+ return json(await call(connection, 'POST', '/v1/sandboxes', { body: { repo, ref } }));
39
+ }
40
+ static async get(connection, id) {
41
+ return json(await call(connection, 'GET', `/v1/sandboxes/${encodeURIComponent(id)}`));
42
+ }
43
+ static async list(connection) {
44
+ return (await json(await call(connection, 'GET', '/v1/sandboxes'))).sandboxes;
45
+ }
46
+ // checks out the ref again, what changed in the sandbox is gone
47
+ static async fetch(connection, id, ref) {
48
+ return json(await call(connection, 'POST', `/v1/sandboxes/${encodeURIComponent(id)}/fetch`, { body: { ref } }));
49
+ }
50
+ async stat(path) {
51
+ if (checkSandboxPath(path) === '') {
52
+ // the root is there as long as the sandbox is
53
+ return { path: '', type: 'directory', size: 0, exec: false, modifiedAt: new Date(0).toISOString() };
54
+ }
55
+ const response = await call(this.connection, 'GET', this.file(path), { query: { stat: '' }, missing: true });
56
+ return response.status === 404 ? undefined : json(response);
57
+ }
58
+ async readRaw(path) {
59
+ const response = await call(this.connection, 'GET', this.file(path), { query: { raw: '' } });
60
+ if (response.headers.get('x-hammerkit-type') === 'directory') {
61
+ throw new SandboxClientError(`${path} is a directory`, 400);
62
+ }
63
+ return Buffer.from(await response.arrayBuffer());
64
+ }
65
+ async writeRaw(path, content) {
66
+ return json(await call(this.connection, 'PUT', this.file(path), { raw: content }));
67
+ }
68
+ // numbered lines, like `cat -n`
69
+ async read(path, offset, limit) {
70
+ return json(await call(this.connection, 'GET', this.file(path), { query: { offset, limit } }));
71
+ }
72
+ async tree(query) {
73
+ return json(await call(this.connection, 'GET', `${this.base()}/tree`, { query: { ...query } }));
74
+ }
75
+ async search(query) {
76
+ return json(await call(this.connection, 'POST', `${this.base()}/search`, { body: query }));
77
+ }
78
+ // all or nothing
79
+ async edit(ops, dryRun = false) {
80
+ const answer = await call(this.connection, 'POST', `${this.base()}/edits`, { body: { dryRun, ops } });
81
+ return (await json(answer)).files;
82
+ }
83
+ async changes() {
84
+ return json(await call(this.connection, 'GET', `${this.base()}/changes`));
85
+ }
86
+ async diff(format) {
87
+ return (await call(this.connection, 'GET', `${this.base()}/changes`, { query: { format } })).text();
88
+ }
89
+ /**
90
+ * Commits the changes of the sandbox and pushes them to a branch of its repository, one
91
+ * the operator lets the caller push to. The sandbox is at the pushed commit then.
92
+ */
93
+ async push(branch, message) {
94
+ const pushed = await json(await call(this.connection, 'POST', `${this.base()}/push`, { body: { branch, message } }));
95
+ return { branch: pushed.branch, commit: pushed.commit };
96
+ }
97
+ async getRun(id) {
98
+ return json(await call(this.connection, 'GET', `/v1/runs/${encodeURIComponent(id)}`));
99
+ }
100
+ // the log of a run: `task`, `tail`, `from`/`limit`, `grep` with `i` and `context`
101
+ async logs(id, query) {
102
+ return json(await call(this.connection, 'GET', `/v1/runs/${encodeURIComponent(id)}/logs`, { query: { ...query } }));
103
+ }
104
+ /**
105
+ * Runs a task of the published build file on the sandbox and follows it to its end: the
106
+ * lines it prints, its result and exit code. A signal cancels the run.
107
+ */
108
+ async runTask(task, options = {}) {
109
+ if (options.signal?.aborted) {
110
+ // cancelled while it waited for its turn
111
+ throw new SandboxClientError('the run was cancelled');
112
+ }
113
+ const runOptions = {};
114
+ if (options.args) {
115
+ runOptions.args = options.args;
116
+ }
117
+ if (options.timeoutSeconds) {
118
+ runOptions.timeout = `${Math.max(1, Math.ceil(options.timeoutSeconds))}s`;
119
+ }
120
+ const submitted = await submitRun(this.connection, {
121
+ definition: this.build.hash,
122
+ tasks: [task],
123
+ source: { type: 'sandbox', id: this.id },
124
+ options: runOptions,
125
+ });
126
+ const followed = await followRun(this.connection, submitted, {
127
+ task,
128
+ signal: options.signal,
129
+ onLine: options.onLine ? (line) => options.onLine?.(line.text) : undefined,
130
+ onWaiting: options.onWaiting,
131
+ });
132
+ const result = followed.run.results?.[task];
133
+ return {
134
+ run: followed.run,
135
+ result,
136
+ output: followed.lines.map((line) => line.text).join('\n'),
137
+ truncated: followed.truncated,
138
+ exitCode: exitCodeOf(result),
139
+ };
140
+ }
141
+ /**
142
+ * A shell command on the sandbox: the shell task of the build file with the command as
143
+ * its argument. `cwd` is a directory of the sandbox, `''` for its root.
144
+ */
145
+ async exec(command, cwd, options = {}) {
146
+ // on its own line: `cd dir && a; b` would run b in the root when dir is missing
147
+ const script = cwd && cwd !== '.' ? `cd -- ${quotePosix(cwd)} || exit 1\n${command}` : command;
148
+ // a shell task in a container without `mounts: [.]` runs in an empty directory: the
149
+ // command would see none of the files and its writes would be lost, so it does not run
150
+ const onLine = options.onLine;
151
+ const guarded = await this.runTask(this.build.shell, {
152
+ ...options,
153
+ args: [`${EMPTY_GUARD}\n${script}`],
154
+ onLine: onLine && ((line) => line !== EMPTY_MARKER && onLine(line)),
155
+ });
156
+ if (guarded.exitCode !== EMPTY_EXIT || !guarded.output.split('\n').includes(EMPTY_MARKER)) {
157
+ return guarded;
158
+ }
159
+ if ((await this.tree({ depth: 1, limit: 1 })).entries.length > 0) {
160
+ throw new SandboxClientError(`the shell task ${this.build.shell} of ${this.build.hash} sees none of the files of the sandbox, ` +
161
+ `a shell task in a container needs mounts: [.] to work on them`);
162
+ }
163
+ // the sandbox is empty indeed
164
+ return this.runTask(this.build.shell, { ...options, args: [script] });
165
+ }
166
+ base() {
167
+ return `/v1/sandboxes/${encodeURIComponent(this.id)}`;
168
+ }
169
+ file(path) {
170
+ const clean = checkSandboxPath(path);
171
+ if (!clean) {
172
+ throw new SandboxClientError('a file needs a path below the root of the sandbox', 400);
173
+ }
174
+ return `${this.base()}/files/${clean.split('/').map(encodeURIComponent).join('/')}`;
175
+ }
176
+ }
177
+ exports.SandboxClient = SandboxClient;
178
+ // The first lines of every shell command: it stops when the working directory is empty,
179
+ // links included, without any tool but the shell itself.
180
+ const EMPTY_MARKER = 'hammerkit: the shell sees no files';
181
+ const EMPTY_EXIT = 125;
182
+ const EMPTY_GUARD = [
183
+ 'hk_seen=',
184
+ 'for hk_f in * .[!.]* ..?*; do if [ -e "$hk_f" ] || [ -L "$hk_f" ]; then hk_seen=1; break; fi; done',
185
+ `[ -n "$hk_seen" ] || { echo '${EMPTY_MARKER}'; exit ${EMPTY_EXIT}; }`,
186
+ 'unset hk_seen hk_f',
187
+ ].join('\n');
188
+ // The exit code of a task as a shell reports it: 0 for a task that succeeded or came
189
+ // from the cache, its own when it failed, none when it never ran or ended without one.
190
+ /**
191
+ * Runs the calls one at a time, in the order they came. Agents send tool calls in parallel,
192
+ * and the server refuses edits while a run holds the sandbox: an edit sent along with a
193
+ * command lands before the command starts, instead of being refused.
194
+ */
195
+ function oneAtATime() {
196
+ let last = Promise.resolve();
197
+ return (call) => {
198
+ const next = last.then(call);
199
+ last = next.catch(() => undefined);
200
+ return next;
201
+ };
202
+ }
203
+ exports.oneAtATime = oneAtATime;
204
+ // a run that is over, its state does not change any more
205
+ exports.FINAL_RUN_STATES = ['succeeded', 'failed', 'cancelled', 'rejected'];
206
+ // Submits a run, as `POST /v1/runs` takes it, and answers it as the server queued it or
207
+ // holds it for an approval.
208
+ async function submitRun(connection, request) {
209
+ return json(await call(connection, 'POST', '/v1/runs', { body: request }));
210
+ }
211
+ exports.submitRun = submitRun;
212
+ /**
213
+ * Follows a run to its end: its log as it grows, and its state. A signal cancels the run
214
+ * on the server and fails the follow.
215
+ */
216
+ async function followRun(connection, submitted, options = {}) {
217
+ const path = `/v1/runs/${encodeURIComponent(submitted.id)}`;
218
+ let cancelling;
219
+ const cancel = () => {
220
+ cancelling ?? (cancelling = call(connection, 'POST', `${path}/cancel`).catch(() => undefined));
221
+ };
222
+ options.signal?.addEventListener('abort', cancel, { once: true });
223
+ const lines = [];
224
+ let from = 1;
225
+ let truncated = false;
226
+ const add = (line) => {
227
+ lines.push(line);
228
+ options.onLine?.(line);
229
+ };
230
+ const readLog = async () => {
231
+ for (;;) {
232
+ const page = await json(await call(connection, 'GET', `${path}/logs`, { query: { task: options.task, from, limit: 1000 } }));
233
+ truncated = page.truncated;
234
+ for (const line of page.lines) {
235
+ add(line);
236
+ from = line.line + 1;
237
+ }
238
+ if (!page.next) {
239
+ return;
240
+ }
241
+ }
242
+ };
243
+ try {
244
+ let run = submitted;
245
+ let waitingSaid = false;
246
+ for (;;) {
247
+ if (options.signal?.aborted) {
248
+ // the process may exit after this, so the cancel has to reach the server first
249
+ cancel();
250
+ let timer;
251
+ await Promise.race([cancelling, new Promise((resolve) => (timer = setTimeout(resolve, CANCEL_WAIT_MS)))]);
252
+ clearTimeout(timer);
253
+ throw new SandboxClientError('the run was cancelled');
254
+ }
255
+ if (run.state === 'awaiting-approval' && !waitingSaid) {
256
+ waitingSaid = true;
257
+ options.onWaiting?.(run);
258
+ }
259
+ await readLog();
260
+ if (exports.FINAL_RUN_STATES.includes(run.state)) {
261
+ break;
262
+ }
263
+ run = await json(await call(connection, 'GET', path, { query: { wait: POLL_WAIT } }));
264
+ }
265
+ if (truncated) {
266
+ // the end of a long log is kept in the results
267
+ const tasks = options.task !== undefined ? [options.task] : Object.keys(run.results ?? {});
268
+ for (const task of tasks) {
269
+ const result = run.results?.[task];
270
+ if (!result) {
271
+ continue;
272
+ }
273
+ const said = options.task !== undefined ? 'the last lines were' : `the last lines of ${task} were`;
274
+ for (const text of [`… the server cut the log off at its limit, ${said}:`, ...result.lastLog]) {
275
+ add({ line: 0, task, stream: 'stdout', text });
276
+ }
277
+ }
278
+ }
279
+ return { run, lines, truncated };
280
+ }
281
+ finally {
282
+ options.signal?.removeEventListener('abort', cancel);
283
+ }
284
+ }
285
+ exports.followRun = followRun;
286
+ function exitCodeOf(result) {
287
+ if (!result) {
288
+ return null;
289
+ }
290
+ if (result.status === 'succeeded' || result.status === 'cached') {
291
+ return 0;
292
+ }
293
+ return result.exitCode ?? null;
294
+ }
295
+ exports.exitCodeOf = exitCodeOf;
296
+ // A path of the sandbox as the API takes it: relative, `/` separated, no `..`.
297
+ function checkSandboxPath(path) {
298
+ // normalize leaves no `./` but a lone `.`, and `..` only at the start
299
+ const normal = path_1.posix.normalize(path.replace(/\\/g, '/')).replace(/\/$/, '');
300
+ if (normal.startsWith('/') || normal === '..' || normal.startsWith('../')) {
301
+ throw new SandboxClientError(`${path} is outside of the sandbox`, 400);
302
+ }
303
+ return normal === '.' ? '' : normal;
304
+ }
305
+ exports.checkSandboxPath = checkSandboxPath;
306
+ /**
307
+ * Maps a path of the harness onto the sandbox. Harnesses resolve paths against their own
308
+ * working directory, which stands for the root of the sandbox: `<root>/src/a.ts` is
309
+ * `src/a.ts` there. Anything outside of it is refused.
310
+ */
311
+ function toSandboxPath(root, path) {
312
+ const relativePath = (0, path_1.isAbsolute)(path) ? (0, path_1.relative)(root, path) : path;
313
+ if ((0, path_1.isAbsolute)(relativePath)) {
314
+ throw new SandboxClientError(`${path} is outside of the sandbox at ${root}`, 400);
315
+ }
316
+ try {
317
+ return checkSandboxPath(relativePath.split(path_1.sep).join('/'));
318
+ }
319
+ catch {
320
+ throw new SandboxClientError(`${path} is outside of the sandbox at ${root}`, 400);
321
+ }
322
+ }
323
+ exports.toSandboxPath = toSandboxPath;
324
+ // The certificate errors of a server this machine does not trust
325
+ const UNTRUSTED_CERTIFICATE = [
326
+ 'DEPTH_ZERO_SELF_SIGNED_CERT',
327
+ 'SELF_SIGNED_CERT_IN_CHAIN',
328
+ 'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
329
+ 'UNABLE_TO_GET_ISSUER_CERT',
330
+ 'UNABLE_TO_GET_ISSUER_CERT_LOCALLY',
331
+ 'CERT_UNTRUSTED',
332
+ ];
333
+ /**
334
+ * Why a fetch failed: node's fetch says only "fetch failed" and keeps the reason, such as a
335
+ * refused connection or an untrusted certificate, as its cause.
336
+ */
337
+ function fetchFailure(e) {
338
+ if (!(e instanceof Error)) {
339
+ return String(e);
340
+ }
341
+ const cause = e.cause;
342
+ if (!(cause instanceof Error)) {
343
+ return e.message;
344
+ }
345
+ const code = cause.code;
346
+ const reason = `${e.message}: ${cause.message}${code && !cause.message.includes(code) ? ` (${code})` : ''}`;
347
+ return code && UNTRUSTED_CERTIFICATE.includes(code)
348
+ ? `${reason}, this machine does not trust the certificate of the server: name its CA in NODE_EXTRA_CA_CERTS`
349
+ : reason;
350
+ }
351
+ exports.fetchFailure = fetchFailure;
352
+ function redact(message, token) {
353
+ return message.split(token).join('***');
354
+ }
355
+ function quotePosix(value) {
356
+ return `'${value.replace(/'/g, `'\\''`)}'`;
357
+ }
358
+ exports.quotePosix = quotePosix;
359
+ function json(response) {
360
+ return response.json();
361
+ }
362
+ async function call(connection, method, path, options = {}) {
363
+ const url = new URL(`${connection.url.replace(/\/$/, '')}${path}`);
364
+ for (const [key, value] of Object.entries(options.query ?? {})) {
365
+ if (value !== undefined) {
366
+ url.searchParams.set(key, String(value));
367
+ }
368
+ }
369
+ const send = async () => {
370
+ const token = await connection.token();
371
+ if (!(0, token_source_1.isToken)(token)) {
372
+ // a header refuses such a value and would quote it whole in its error
373
+ throw new SandboxClientError('the token is not a single line of visible characters');
374
+ }
375
+ return fetch(url, {
376
+ method,
377
+ headers: {
378
+ authorization: `Bearer ${token}`,
379
+ ...(options.body !== undefined ? { 'content-type': 'application/json' } : {}),
380
+ ...(options.raw !== undefined ? { 'content-type': 'application/octet-stream' } : {}),
381
+ },
382
+ body: options.body !== undefined ? JSON.stringify(options.body) : options.raw,
383
+ signal: AbortSignal.timeout(TIMEOUT_MS),
384
+ // an answer from anywhere else is not the server's, and a PUT would send its file on
385
+ redirect: 'error',
386
+ }).catch((e) => {
387
+ throw new SandboxClientError(`unable to reach ${connection.url}: ${redact(fetchFailure(e), token)}`);
388
+ });
389
+ };
390
+ let response = await send();
391
+ if (response.status === 401 && connection.renew) {
392
+ await connection.renew();
393
+ response = await send();
394
+ }
395
+ if (response.ok || (options.missing && response.status === 404)) {
396
+ return response;
397
+ }
398
+ let said = '';
399
+ try {
400
+ const body = await json(response);
401
+ said = typeof body?.error === 'string' ? body.error : '';
402
+ }
403
+ catch {
404
+ // no JSON
405
+ }
406
+ if (response.status === 401) {
407
+ throw new SandboxClientError(`${connection.url} did not accept the token`, 401);
408
+ }
409
+ throw new SandboxClientError(said || `${connection.url} answered ${response.status}`, response.status);
410
+ }
411
+ //# sourceMappingURL=sandbox-client.js.map