hammerkit 1.26.0 → 1.27.0-next.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/build.schema.json +53 -1
  2. package/dist/cache/backends/local-cache-backend.js +10 -2
  3. package/dist/cache/explain.js +13 -11
  4. package/dist/cli.js +3 -0
  5. package/dist/docker/item-network.js +471 -0
  6. package/dist/docker/pull.js +4 -2
  7. package/dist/docker/remove-orphaned-containers.js +13 -6
  8. package/dist/environment/replace-env-variables.js +8 -6
  9. package/dist/executer/await-completed-dependencies.js +2 -1
  10. package/dist/executer/check-readiness.js +2 -2
  11. package/dist/executer/docker-service.js +24 -13
  12. package/dist/executer/docker-task.js +18 -29
  13. package/dist/executer/event-cache.js +4 -0
  14. package/dist/executer/execute-command.js +18 -5
  15. package/dist/executer/execute-docker.js +4 -1
  16. package/dist/executer/execution-steps.js +33 -7
  17. package/dist/executer/get-docker-executor.js +14 -7
  18. package/dist/executer/scheduler/enqueue-next.js +17 -3
  19. package/dist/executer/watch-loop.js +2 -2
  20. package/dist/executer/watch-state-key.js +1 -1
  21. package/dist/file/within-root.js +42 -0
  22. package/dist/harness/connect.js +99 -0
  23. package/dist/harness/format.js +73 -0
  24. package/dist/harness/mcp-command.js +59 -0
  25. package/dist/harness/mcp.js +432 -0
  26. package/dist/harness/opencode.js +492 -0
  27. package/dist/harness/pi.js +217 -0
  28. package/dist/harness/sandbox-client.js +373 -0
  29. package/dist/index.js +33 -36
  30. package/dist/kubernetes/apply.js +31 -1
  31. package/dist/kubernetes/await-running-state.js +33 -1
  32. package/dist/kubernetes/ensure-kubernetes-deployment-exists.js +43 -21
  33. package/dist/kubernetes/ensure-kubernetes-persistent-volume-claim-exists.js +131 -4
  34. package/dist/kubernetes/ensure-kubernetes-service-exists.js +25 -4
  35. package/dist/kubernetes/ensure-persistent-data.js +48 -7
  36. package/dist/kubernetes/get-service-ip.js +15 -1
  37. package/dist/kubernetes/job-placement.js +99 -0
  38. package/dist/kubernetes/job-secrets.js +306 -0
  39. package/dist/kubernetes/kept-claims.js +191 -0
  40. package/dist/kubernetes/kubernetes-instance.js +10 -2
  41. package/dist/kubernetes/network-gate.js +71 -0
  42. package/dist/kubernetes/network-policy.js +526 -0
  43. package/dist/kubernetes/resources.js +13 -2
  44. package/dist/kubernetes/restore-kubernetes-data.js +19 -4
  45. package/dist/kubernetes/secrets.js +2 -0
  46. package/dist/kubernetes/store-kubernetes-data.js +1 -1
  47. package/dist/kubernetes/volumes.js +100 -11
  48. package/dist/log.js +1 -1
  49. package/dist/main.js +41 -0
  50. package/dist/network/egress-proxy.js +368 -0
  51. package/dist/network/port-relay.js +35 -0
  52. package/dist/optimizer/get-work-cache-stats.js +16 -0
  53. package/dist/optimizer/work-task-cache-description.js +3 -2
  54. package/dist/planner/service-definition.js +5 -0
  55. package/dist/planner/utils/append-work-environment.js +2 -0
  56. package/dist/planner/utils/append-work-service.js +4 -0
  57. package/dist/planner/utils/append-work-task.js +17 -1
  58. package/dist/planner/utils/assert-item-paths.js +50 -0
  59. package/dist/planner/utils/hosted-volumes.js +35 -0
  60. package/dist/planner/work-network.js +193 -0
  61. package/dist/planner/work-runtime-docker.js +41 -0
  62. package/dist/planner/work-runtime-kubernetes.js +335 -102
  63. package/dist/program.js +23 -1
  64. package/dist/remote/annotations.js +103 -0
  65. package/dist/remote/bundle-text.js +24 -0
  66. package/dist/remote/bundle.js +9 -1
  67. package/dist/remote/remote-commands.js +4 -1
  68. package/dist/remote/remote-work-commands.js +182 -69
  69. package/dist/remote/server-client.js +18 -5
  70. package/dist/remote/source-manifest.js +15 -2
  71. package/dist/remote/token-source.js +67 -0
  72. package/dist/schema/build-file-base-task-schema.js +3 -0
  73. package/dist/schema/build-file-container-service-schema.js +6 -0
  74. package/dist/schema/build-file-container-task-schema.js +3 -0
  75. package/dist/schema/build-file-environment-schema.js +9 -0
  76. package/dist/schema/network-schema.js +78 -0
  77. package/dist/schema/reference-parser.js +1 -0
  78. package/dist/schema/schema-parser.js +16 -3
  79. package/dist/schema/work-scope-parser.js +5 -0
  80. package/dist/secrets/command-secret-provider.js +73 -10
  81. package/dist/secrets/github-app-token-command.js +26 -0
  82. package/dist/secrets/github-app-token.js +191 -0
  83. package/dist/server/accounts-api.js +9 -2
  84. package/dist/server/definitions-api.js +69 -47
  85. package/dist/server/definitions.js +76 -51
  86. package/dist/server/executor.js +156 -41
  87. package/dist/server/git-checkout.js +72 -8
  88. package/dist/server/kubernetes-backend.js +562 -0
  89. package/dist/server/kubernetes-sandbox-storage.js +996 -0
  90. package/dist/server/oauth.js +1 -0
  91. package/dist/server/plan.js +51 -1
  92. package/dist/server/policy.js +104 -15
  93. package/dist/server/proposals-api.js +24 -40
  94. package/dist/server/proposals.js +12 -25
  95. package/dist/server/repo-access.js +37 -6
  96. package/dist/server/run-logs.js +217 -0
  97. package/dist/server/run-plan.js +51 -2
  98. package/dist/server/run-request.js +23 -10
  99. package/dist/server/run-store.js +5 -0
  100. package/dist/server/run-worker-command.js +13 -1
  101. package/dist/server/run-worker.js +121 -24
  102. package/dist/server/runs.js +184 -10
  103. package/dist/server/sandbox-docker.js +62 -0
  104. package/dist/server/sandbox-files-api.js +269 -0
  105. package/dist/server/sandbox-files.js +1054 -0
  106. package/dist/server/sandbox-patch.js +481 -0
  107. package/dist/server/sandbox-pod-command.js +15 -0
  108. package/dist/server/sandbox-pod.js +146 -0
  109. package/dist/server/sandbox-storage.js +462 -0
  110. package/dist/server/sandboxes-api.js +210 -0
  111. package/dist/server/sandboxes.js +519 -0
  112. package/dist/server/server-config.js +412 -22
  113. package/dist/server/server.js +204 -39
  114. package/dist/utils/run-id.js +12 -0
  115. package/harness/opencode.ts +11 -0
  116. package/harness/pi.ts +7 -0
  117. package/package.json +8 -2
  118. 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.defineProperty(exports, "__esModule", { value: true });
4
- const log_1 = require("./log");
5
- const get_file_context_1 = require("./file/get-file-context");
6
- const work_item_status_1 = require("./planner/work-item-status");
7
- const redact_1 = require("./utils/redact");
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
- const secrets = (0, redact_1.createSecretRegistry)();
24
- (0, run_program_1.runProgram)({
25
- cwd: process.cwd(),
26
- abortCtrl,
27
- processEnvs: process.env,
28
- file: (0, get_file_context_1.getFileContext)(process.cwd()),
29
- console: (0, log_1.consoleContext)(process.stdout),
30
- status: (0, work_item_status_1.statusConsole)((0, empty_writable_1.emptyWritable)(), secrets),
31
- secrets,
32
- stdout: process.stdout,
33
- stderr: process.stderr,
34
- stdoutColumns: process.stdout.columns,
35
- }, process.argv, false).then(() => {
36
- settled = true;
37
- }, () => {
38
- settled = true;
39
- process.exit(1);
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