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
@@ -13,7 +13,9 @@ const runs_1 = require("./runs");
13
13
  const git_checkout_1 = require("./git-checkout");
14
14
  const repo_access_1 = require("./repo-access");
15
15
  const executor_1 = require("./executor");
16
+ const kubernetes_backend_1 = require("./kubernetes-backend");
16
17
  const run_plan_1 = require("./run-plan");
18
+ const work_network_1 = require("../planner/work-network");
17
19
  const run_environment_1 = require("./run-environment");
18
20
  const accounts_api_1 = require("./accounts-api");
19
21
  const run_request_1 = require("./run-request");
@@ -25,7 +27,13 @@ const proposals_api_1 = require("./proposals-api");
25
27
  const proposals_1 = require("./proposals");
26
28
  const sources_1 = require("./sources");
27
29
  const sources_api_1 = require("./sources-api");
30
+ const sandboxes_1 = require("./sandboxes");
31
+ const sandbox_storage_1 = require("./sandbox-storage");
32
+ const kubernetes_sandbox_storage_1 = require("./kubernetes-sandbox-storage");
33
+ const sandbox_docker_1 = require("./sandbox-docker");
34
+ const sandboxes_api_1 = require("./sandboxes-api");
28
35
  const units_1 = require("../utils/units");
36
+ const run_logs_1 = require("./run-logs");
29
37
  const get_cache_directory_1 = require("../optimizer/get-cache-directory");
30
38
  const path_1 = require("path");
31
39
  var discovery_2 = require("./discovery");
@@ -42,11 +50,20 @@ function getDiscoveryDocument(config) {
42
50
  ...(i.clientId ? { clientId: i.clientId } : {}),
43
51
  scope: i.scope,
44
52
  })),
45
- backend: { type: config.backend.type, platform: config.backend.platform ?? (0, server_config_1.detectPlatform)() },
53
+ backend: config.backend.type === 'kubernetes'
54
+ ? {
55
+ type: 'kubernetes',
56
+ platform: config.backend.pools[0].platform,
57
+ pools: config.backend.pools.map((pool) => ({ name: pool.name, platform: pool.platform })),
58
+ }
59
+ : { type: 'host', platform: config.backend.platform ?? (0, server_config_1.detectPlatform)() },
46
60
  };
47
61
  }
48
62
  exports.getDiscoveryDocument = getDiscoveryDocument;
49
63
  const HEARTBEAT_MS = 15000;
64
+ // the longest `?wait=` of a run: below the 60 seconds after which a proxy in front of the
65
+ // server (nginx by default) closes a connection that says nothing
66
+ const MAX_WAIT_MS = 50000;
50
67
  // Server-sent events: `id` is the sequence number, so a client that lost the
51
68
  // connection continues with Last-Event-ID (or ?after=) and misses nothing.
52
69
  function streamEvents(registry, config, principal, id, req, res, url) {
@@ -79,8 +96,8 @@ function streamEvents(registry, config, principal, id, req, res, url) {
79
96
  watch.stop();
80
97
  });
81
98
  }
82
- async function handleRuns(config, registry, definitions, sources, planning, repos, log, principal, req, res, url) {
83
- const match = /^\/v1\/runs(?:\/([^/]+)(?:\/(cancel|events))?)?$/.exec(url.pathname);
99
+ async function handleRuns(config, registry, definitions, sources, sandboxes, planning, repos, log, principal, req, res, url) {
100
+ const match = /^\/v1\/runs(?:\/([^/]+)(?:\/(cancel|events|logs))?)?$/.exec(url.pathname);
84
101
  if (!match) {
85
102
  return false;
86
103
  }
@@ -88,10 +105,17 @@ async function handleRuns(config, registry, definitions, sources, planning, repo
88
105
  const method = req.method;
89
106
  if (!id) {
90
107
  if (method === 'GET') {
91
- (0, http_2.send)(res, 200, { runs: registry.list(principal).map(toRunResponse) });
108
+ // the results of each task are read one run at a time
109
+ (0, http_2.send)(res, 200, {
110
+ runs: registry.list(principal).map((run) => {
111
+ const { results, ...listed } = toRunResponse(run);
112
+ void results;
113
+ return listed;
114
+ }),
115
+ });
92
116
  }
93
117
  else if (method === 'POST') {
94
- await submitRun(config, registry, definitions, sources, planning, repos, log, principal, req, res);
118
+ await submitRun(config, registry, definitions, sources, sandboxes, planning, repos, log, principal, req, res);
95
119
  }
96
120
  else {
97
121
  (0, http_2.send)(res, 405, { error: 'method not allowed' }, { allow: 'GET, POST' });
@@ -116,9 +140,39 @@ async function handleRuns(config, registry, definitions, sources, planning, repo
116
140
  else if (action === 'events') {
117
141
  streamEvents(registry, config, principal, id, req, res, url);
118
142
  }
143
+ else if (action === 'logs') {
144
+ let query;
145
+ try {
146
+ query = (0, run_logs_1.parseLogQuery)(url.searchParams);
147
+ }
148
+ catch (e) {
149
+ (0, http_2.send)(res, 400, { error: e instanceof Error ? e.message : String(e) });
150
+ return true;
151
+ }
152
+ const logs = registry.logs(principal, id, accountsOf(config, principal));
153
+ logs
154
+ ? (0, http_2.send)(res, 200, (0, run_logs_1.readLogs)(logs.events, logs.index, query, logs.truncated))
155
+ : (0, http_2.send)(res, 404, { error: 'not found' });
156
+ }
119
157
  else {
120
- const run = registry.get(principal, id, accountsOf(config, principal));
121
- run ? (0, http_2.send)(res, 200, toRunResponse(run)) : (0, http_2.send)(res, 404, { error: 'not found' });
158
+ let wait = 0;
159
+ const waitParam = url.searchParams.get('wait');
160
+ if (waitParam !== null) {
161
+ try {
162
+ wait = Math.min((0, units_1.parseDuration)(waitParam), MAX_WAIT_MS);
163
+ }
164
+ catch {
165
+ (0, http_2.send)(res, 400, { error: `wait is a duration up to ${(0, units_1.formatDuration)(MAX_WAIT_MS)}, for example 30s` });
166
+ return true;
167
+ }
168
+ }
169
+ // a caller that hangs up stops waiting
170
+ const gone = new AbortController();
171
+ res.on('close', () => gone.abort());
172
+ const run = await registry.wait(principal, id, wait, accountsOf(config, principal), gone.signal);
173
+ if (!gone.signal.aborted) {
174
+ run ? (0, http_2.send)(res, 200, toRunResponse(run)) : (0, http_2.send)(res, 404, { error: 'not found' });
175
+ }
122
176
  }
123
177
  return true;
124
178
  }
@@ -130,9 +184,9 @@ function accountsOf(config, principal) {
130
184
  function toRunResponse(run) {
131
185
  const { owner, ...visible } = run;
132
186
  void owner;
133
- return visible;
187
+ return { ...visible, results: run.results ?? {} };
134
188
  }
135
- async function submitRun(config, registry, definitions, sources, planning, repos, log, principal, req, res) {
189
+ async function submitRun(config, registry, definitions, sources, sandboxes, planning, repos, log, principal, req, res) {
136
190
  const parsed = run_request_1.runRequestSchema.safeParse(await (0, http_2.readJson)(req));
137
191
  if (!parsed.success) {
138
192
  const issue = parsed.error.issues[0];
@@ -147,21 +201,26 @@ async function submitRun(config, registry, definitions, sources, planning, repos
147
201
  (0, http_2.send)(res, 403, { error: 'you are not entitled to run anything on this server' });
148
202
  return;
149
203
  }
150
- const version = definitions.find(request.definition.name, request.definition.hash);
204
+ const version = definitions.find(request.definition);
151
205
  if (!version) {
152
- (0, http_2.send)(res, 400, { error: `there is no build file ${request.definition.name} at ${request.definition.hash}` });
206
+ (0, http_2.send)(res, 400, { error: `there is no published build file ${request.definition}` });
153
207
  return;
154
208
  }
155
209
  if (version.yanked) {
156
210
  (0, http_2.send)(res, 409, {
157
- error: `${version.name} at ${version.hash} was yanked${version.yanked.reason ? `: ${version.yanked.reason}` : ''}`,
211
+ error: `${version.hash} was yanked${version.yanked.reason ? `: ${version.yanked.reason}` : ''}`,
158
212
  });
159
213
  return;
160
214
  }
161
215
  const known = [...version.tasks, ...version.services];
162
216
  const unknown = request.tasks.filter((task) => !known.includes(task));
163
217
  if (unknown.length > 0) {
164
- (0, http_2.send)(res, 400, { error: `${version.name} has no task ${unknown.map(oauth_1.quote).join(', ')}` });
218
+ (0, http_2.send)(res, 400, { error: `${version.hash} has no task ${unknown.map(oauth_1.quote).join(', ')}` });
219
+ return;
220
+ }
221
+ const argued = (request.options.args ?? []).length > 0 ? request.tasks[0] : undefined;
222
+ if (argued !== undefined && version.services.includes(argued)) {
223
+ (0, http_2.send)(res, 400, { error: `${argued} is a service, arguments go to a task` });
165
224
  return;
166
225
  }
167
226
  if (request.source.type === 'upload') {
@@ -180,29 +239,57 @@ async function submitRun(config, registry, definitions, sources, planning, repos
180
239
  return;
181
240
  }
182
241
  }
242
+ const sandbox = request.source.type === 'sandbox' ? sandboxes.registry.get(principal, request.source.id) : undefined;
243
+ if (request.source.type === 'sandbox') {
244
+ if (!sandbox) {
245
+ (0, http_2.send)(res, 400, { error: `there is no sandbox ${request.source.id}, create one with POST /v1/sandboxes` });
246
+ return;
247
+ }
248
+ if (sandboxes.policy.runtimes?.length === 0) {
249
+ (0, http_2.send)(res, 403, {
250
+ error: 'this server runs only local tasks, which it runs on no sandbox unless sandboxes.runtimes names local',
251
+ });
252
+ return;
253
+ }
254
+ await sandboxes.registry.measured(principal);
255
+ const over = sandboxes.registry.overLimit(principal);
256
+ if (over) {
257
+ (0, http_2.send)(res, 413, { error: over });
258
+ return;
259
+ }
260
+ }
183
261
  // the accounts of a run are read from the files it is pinned to, with the engine
184
262
  const bundle = version.plan ? await definitions.files(version) : undefined;
185
263
  if (!version.plan || !bundle) {
186
264
  (0, http_2.send)(res, 409, {
187
- error: `${version.name} at ${version.hash} was published before accounts were recorded, publish it again`,
265
+ error: `${version.hash} was published before accounts were recorded, publish it again`,
188
266
  });
189
267
  return;
190
268
  }
191
269
  let planned;
192
270
  try {
193
- planned = await (0, run_plan_1.planRun)(bundle, request, planning.environment, planning.policy);
271
+ // a run on a sandbox may only use the runtimes of `sandboxes.runtimes`
272
+ planned = await (0, run_plan_1.planRun)(bundle, request, planning.environment, sandbox ? sandboxes.policy : planning.policy);
194
273
  }
195
274
  catch (e) {
196
275
  (0, http_2.send)(res, 400, { error: `the run does not load: ${e instanceof Error ? e.message : String(e)}` });
197
276
  return;
198
277
  }
278
+ const refused = (0, run_plan_1.refuseArguments)(request, planned.plan, sandbox ? sandboxes.policy : planning.policy);
279
+ if (refused) {
280
+ log(`refuse run iss=${principal.iss} sub=${principal.sub}: ${refused}`);
281
+ (0, http_2.send)(res, 400, { error: refused });
282
+ return;
283
+ }
199
284
  const decision = (0, policy_1.decideRun)({
200
285
  grant,
201
286
  accounts: planned.accounts,
202
287
  catalog: config.accounts,
203
288
  source: request.source.type === 'ref'
204
289
  ? { type: 'ref', repo: request.source.repo, ref: request.source.ref }
205
- : { type: 'upload' },
290
+ : sandbox
291
+ ? { type: 'sandbox', repo: sandbox.repo, branch: sandbox.ref }
292
+ : { type: 'upload' },
206
293
  approvable: (account) => config.entitlements.some((entitlement) => entitlement.accounts[account] === 'use'),
207
294
  readable: (repo) => repos.readable(repo),
208
295
  });
@@ -217,6 +304,12 @@ async function submitRun(config, registry, definitions, sources, planning, repos
217
304
  if (request.source.type === 'ref') {
218
305
  try {
219
306
  const commit = await repos.resolve(request.source.repo, request.source.ref);
307
+ if (request.source.commit && commit !== request.source.commit) {
308
+ const error = `${request.source.ref} is at ${commit} now, not at ${request.source.commit}`;
309
+ log(`refuse run iss=${principal.iss} sub=${principal.sub}: ${error}`);
310
+ (0, http_2.send)(res, 409, { error });
311
+ return;
312
+ }
220
313
  source = { type: 'ref', repo: request.source.repo, ref: request.source.ref, commit };
221
314
  }
222
315
  catch (e) {
@@ -228,19 +321,34 @@ async function submitRun(config, registry, definitions, sources, planning, repos
228
321
  return;
229
322
  }
230
323
  }
231
- else {
324
+ else if (request.source.type === 'upload') {
232
325
  source = request.source;
233
326
  }
327
+ const submit = (runSource) => registry.submit(principal, {
328
+ definition: version.hash,
329
+ tasks: request.tasks,
330
+ accounts: planned.accounts,
331
+ plan: planned.plan,
332
+ source: runSource,
333
+ options: request.options,
334
+ }, approvals);
234
335
  try {
235
- const run = registry.submit(principal, {
236
- definition: { name: version.name, hash: version.hash },
237
- tasks: request.tasks,
238
- accounts: planned.accounts,
239
- plan: planned.plan,
240
- source,
241
- options: request.options,
242
- }, approvals);
243
- log(`run ${run.id} iss=${principal.iss} sub=${principal.sub} accounts=[${planned.accounts.join(',')}] source=${run.source.type} tasks=${run.tasks.length}${approvals.length > 0 ? ` approval=[${approvals.join(',')}]` : ''}`);
336
+ // a sandbox is read and the run submitted while nothing else changes it: the run
337
+ // records what the sandbox held, and holds it from now on
338
+ const run = source
339
+ ? submit(source)
340
+ : await sandboxes.registry.withSandbox(principal, sandbox.id, async (current) => {
341
+ const state = await sandboxes.registry.state(current.id);
342
+ return submit({
343
+ type: 'sandbox',
344
+ id: current.id,
345
+ repo: current.repo,
346
+ commit: state.commit,
347
+ tree: state.tree,
348
+ clean: state.clean,
349
+ });
350
+ });
351
+ log(`run ${run.id} iss=${principal.iss} sub=${principal.sub} accounts=[${planned.accounts.join(',')}] source=${run.source.type}${run.source.type === 'sandbox' ? ` sandbox=${run.source.id}` : ''} tasks=${run.tasks.length}${approvals.length > 0 ? ` approval=[${approvals.join(',')}]` : ''}`);
244
352
  (0, http_2.send)(res, 202, toRunResponse(run));
245
353
  }
246
354
  catch (e) {
@@ -248,6 +356,12 @@ async function submitRun(config, registry, definitions, sources, planning, repos
248
356
  (0, http_2.send)(res, e.status, { error: e.message });
249
357
  return;
250
358
  }
359
+ if (e instanceof sandboxes_1.SandboxError) {
360
+ (0, http_2.send)(res, e.status === 404 ? 400 : e.status, {
361
+ error: e.status === 404 ? `there is no sandbox ${sandbox.id}` : e.message,
362
+ });
363
+ return;
364
+ }
251
365
  throw e;
252
366
  }
253
367
  }
@@ -271,7 +385,7 @@ async function authenticate(verifier, log, req, res) {
271
385
  return null;
272
386
  }
273
387
  }
274
- async function handle(config, registry, definitions, proposals, sources, planning, repos, verifier, log, req, res) {
388
+ async function handle(config, registry, definitions, proposals, sources, sandboxes, planning, repos, verifier, log, req, res) {
275
389
  const url = new URL(req.url ?? '/', 'http://localhost');
276
390
  const path = url.pathname;
277
391
  if (path === discovery_1.DISCOVERY_PATH || path === '/healthz') {
@@ -295,7 +409,10 @@ async function handle(config, registry, definitions, proposals, sources, plannin
295
409
  if (await (0, sources_api_1.handleSources)(config, sources, log, principal, req, res, url)) {
296
410
  return;
297
411
  }
298
- if (await handleRuns(config, registry, definitions, sources, planning, repos, log, principal, req, res, url)) {
412
+ if (await (0, sandboxes_api_1.handleSandboxes)(config, sandboxes.registry, repos, log, principal, req, res, url)) {
413
+ return;
414
+ }
415
+ if (await handleRuns(config, registry, definitions, sources, sandboxes, planning, repos, log, principal, req, res, url)) {
299
416
  return;
300
417
  }
301
418
  if (await (0, approvals_api_1.handleApprovals)(config, registry, log, principal, req, res, url)) {
@@ -315,7 +432,7 @@ async function startServer(config, environment, options = {}) {
315
432
  const write = options.log ?? (() => undefined);
316
433
  // request urls and subjects come from callers: no control characters, so no forged log lines
317
434
  const log = (line) => write(line.replace(/\p{Cc}/gu, '?').slice(0, 1000));
318
- if (config.runtimes.includes('local')) {
435
+ if (config.runtimes.includes('local') && config.backend.type === 'host') {
319
436
  log('warning: the local runtime is allowed, local tasks run as the user of this server and read all it can read; ' +
320
437
  'an account is then an approval class, not a credential boundary');
321
438
  }
@@ -325,9 +442,23 @@ async function startServer(config, environment, options = {}) {
325
442
  environment: {
326
443
  ...environment,
327
444
  processEnvs: (0, run_environment_1.runEnvironment)(config, environment.processEnvs),
328
- hosted: { catalog: (0, server_config_1.getSecretCatalog)(config) },
445
+ // on a cluster a local task runs in a pod of a pool's tool image
446
+ hosted: { catalog: (0, server_config_1.getSecretCatalog)(config), localTasksInContainers: config.backend.type === 'kubernetes' },
447
+ },
448
+ policy: {
449
+ envs: config.runs.env.allow,
450
+ reserved: (0, server_config_1.getSourceAccounts)(config),
451
+ runtimes: config.runtimes,
452
+ backend: config.backend.type,
453
+ network: (0, work_network_1.parseWorkNetwork)(config.runs.network),
454
+ secretNetwork: (0, work_network_1.parseWorkNetwork)(config.runs.secretNetwork),
455
+ args: config.runs.args,
456
+ proxiedProviders: config.backend.type === 'kubernetes'
457
+ ? Object.entries(config.backend.providers)
458
+ .filter(([, provider]) => provider.allow)
459
+ .map(([name]) => name)
460
+ : [],
329
461
  },
330
- policy: { envs: config.runs.env.allow, reserved: (0, server_config_1.getSourceAccounts)(config), runtimes: config.runtimes },
331
462
  };
332
463
  const storage = config.storage.directory ?? (0, path_1.join)((0, get_cache_directory_1.getHammerkitDirectory)(), 'server');
333
464
  const definitions = new definitions_1.DefinitionStore((0, path_1.join)(storage, 'definitions'), planning.environment, {
@@ -335,15 +466,59 @@ async function startServer(config, environment, options = {}) {
335
466
  });
336
467
  await definitions.load();
337
468
  const repos = new repo_access_1.RepoAccess(config, planning.environment, options.repos);
338
- const executor = options.executor ??
339
- new executor_1.HostExecutor({
340
- definitions,
341
- planning,
469
+ const sandboxRuntimes = (0, server_config_1.getSandboxRuntimes)(config);
470
+ const sandboxLimits = {
471
+ count: config.sandboxes.limits.count,
472
+ bytes: (0, units_1.parseSize)(config.sandboxes.limits.bytes),
473
+ total: (0, units_1.parseSize)(config.sandboxes.total),
474
+ };
475
+ const sandboxes = {
476
+ registry: new sandboxes_1.SandboxRegistry({
477
+ // on a cluster a claim per sandbox in the caller's namespace, else a directory
478
+ storage: config.backend.type === 'kubernetes'
479
+ ? new kubernetes_sandbox_storage_1.KubernetesSandboxStorage({ ...config, backend: config.backend }, sandboxLimits, planning.environment.processEnvs, (0, path_1.join)(storage, 'sandboxes', 'staging'))
480
+ : new sandbox_storage_1.HostSandboxStorage((0, path_1.join)(storage, 'sandboxes', 'files'), planning.environment.processEnvs,
481
+ // what containers of runs leave behind is given back and removed with a container
482
+ sandboxRuntimes.includes('docker')
483
+ ? new sandbox_docker_1.DockerSandboxContainers(planning.environment.processEnvs.DOCKER_HOST)
484
+ : undefined),
485
+ records: (0, path_1.join)(storage, 'sandboxes', 'records'),
486
+ limits: sandboxLimits,
342
487
  repos,
343
- directory: (0, path_1.join)(storage, 'runs'),
344
- cache: (0, path_1.join)(storage, 'cache'),
345
- worker: options.worker,
346
- });
488
+ log,
489
+ }),
490
+ // a sandbox holds code nobody reviewed: only the variables of `sandboxes.env`
491
+ policy: {
492
+ ...planning.policy,
493
+ envs: config.sandboxes.env.allow,
494
+ envsFrom: 'sandboxes.env.allow',
495
+ runtimes: sandboxRuntimes,
496
+ },
497
+ };
498
+ if (sandboxRuntimes.length === 0 && config.entitlements.some((entitlement) => entitlement.sandboxes?.length)) {
499
+ log('warning: entitlements keep sandboxes, but this server runs only local tasks, which it runs on no sandbox; ' +
500
+ 'runs on sandboxes are refused until sandboxes.runtimes names a runtime');
501
+ }
502
+ // on a cluster that lists the claims of sandboxes in the namespaces of callers, which a
503
+ // server without sandboxes need not be allowed to
504
+ if (config.backend.type === 'host' || config.entitlements.some((entitlement) => entitlement.sandboxes?.length)) {
505
+ await sandboxes.registry.load();
506
+ }
507
+ const executorOptions = {
508
+ definitions,
509
+ planning,
510
+ repos,
511
+ directory: (0, path_1.join)(storage, 'runs'),
512
+ cache: (0, path_1.join)(storage, 'cache'),
513
+ worker: options.worker,
514
+ sandboxes: { ...sandboxes, network: config.sandboxes.network },
515
+ log,
516
+ };
517
+ const backend = config.backend;
518
+ const executor = options.executor ??
519
+ (backend.type === 'kubernetes'
520
+ ? new kubernetes_backend_1.KubernetesExecutor({ ...config, backend }, environment.processEnvs, executorOptions)
521
+ : new executor_1.HostExecutor(executorOptions));
347
522
  const sources = new sources_1.SourceStore((0, path_1.join)(storage, 'sources'));
348
523
  const registry = new runs_1.RunRegistry({
349
524
  executor,
@@ -358,13 +533,17 @@ async function startServer(config, environment, options = {}) {
358
533
  log,
359
534
  });
360
535
  await registry.restore();
536
+ sandboxes.registry.useRuns({
537
+ holder: (id) => registry.holder(id),
538
+ stop: (id) => registry.stopSandbox(id, 'the sandbox was deleted'),
539
+ });
361
540
  const proposals = new proposals_1.ProposalRegistry({
362
541
  maxWaiting: config.approvals.maxWaiting,
363
542
  timeout: (0, units_1.parseDuration)(config.approvals.timeout),
364
543
  cooldown: (0, units_1.parseDuration)(config.approvals.cooldown),
365
544
  });
366
545
  const listener = (req, res) => {
367
- handle(config, registry, definitions, proposals, sources, planning, repos, verifier, log, req, res).catch((e) => {
546
+ handle(config, registry, definitions, proposals, sources, sandboxes, planning, repos, verifier, log, req, res).catch((e) => {
368
547
  if (e instanceof http_2.HttpError) {
369
548
  (0, http_2.send)(res, e.status, { error: e.message });
370
549
  return;
@@ -1,17 +1,57 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.abortOnSignals = exports.ABORT_SIGNALS = void 0;
3
+ exports.takeOverSignal = exports.abortOnSignals = exports.ABORT_SIGNALS = void 0;
4
4
  // Ctrl-C (SIGINT), a CI job being cancelled or a supervisor stopping hammerkit
5
5
  // (SIGTERM) and a closed terminal (SIGHUP) all stop the run the same way: abort,
6
6
  // so services are removed and local tasks' process groups are killed. Without a
7
7
  // handler Node exits on SIGTERM/SIGHUP at once and leaves both behind.
8
8
  exports.ABORT_SIGNALS = ['SIGINT', 'SIGTERM', 'SIGHUP'];
9
- function abortOnSignals(source, abortCtrl) {
10
- for (const signal of exports.ABORT_SIGNALS) {
11
- source.on(signal, () => {
12
- abortCtrl.abort();
13
- });
9
+ // signals a running command gives a meaning of its own (the server reloads its
10
+ // entitlements on SIGHUP), per source and counted by how many hold them: they don't
11
+ // abort the run
12
+ const takenOver = new WeakMap();
13
+ function heldOn(source) {
14
+ let held = takenOver.get(source);
15
+ if (!held) {
16
+ held = new Map();
17
+ takenOver.set(source, held);
14
18
  }
19
+ return held;
20
+ }
21
+ /**
22
+ * Stops the run on the abort signals, all but those a command took over with
23
+ * `takeOverSignal`. Returns what removes the listeners again.
24
+ */
25
+ function abortOnSignals(source, abortCtrl) {
26
+ const listeners = exports.ABORT_SIGNALS.map((signal) => {
27
+ const listener = () => {
28
+ if (!takenOver.get(source)?.get(signal)) {
29
+ abortCtrl.abort();
30
+ }
31
+ };
32
+ source.on(signal, listener);
33
+ return { signal, listener };
34
+ });
35
+ return () => listeners.forEach(({ signal, listener }) => source.off(signal, listener));
15
36
  }
16
37
  exports.abortOnSignals = abortOnSignals;
38
+ /**
39
+ * Calls `handler` on `signal` instead of stopping the run, until the returned function
40
+ * gives the signal back.
41
+ */
42
+ function takeOverSignal(source, signal, handler) {
43
+ const held = heldOn(source);
44
+ held.set(signal, (held.get(signal) ?? 0) + 1);
45
+ source.on(signal, handler);
46
+ let released = false;
47
+ return () => {
48
+ if (released) {
49
+ return;
50
+ }
51
+ released = true;
52
+ source.off(signal, handler);
53
+ held.set(signal, (held.get(signal) ?? 1) - 1);
54
+ };
55
+ }
56
+ exports.takeOverSignal = takeOverSignal;
17
57
  //# sourceMappingURL=abort-on-signals.js.map
@@ -0,0 +1,12 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.getRunId = void 0;
4
+ const crypto_1 = require("crypto");
5
+ const runId = (0, crypto_1.randomBytes)(4).toString('hex');
6
+ // Tells this process's run apart from a concurrent one of the same items, on
7
+ // this machine or another one using the same daemon or cluster.
8
+ function getRunId() {
9
+ return runId;
10
+ }
11
+ exports.getRunId = getRunId;
12
+ //# sourceMappingURL=run-id.js.map
@@ -0,0 +1,25 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.packArchive = void 0;
4
+ const tar_1 = require("tar");
5
+ function packArchive(options, files) {
6
+ return (0, tar_1.create)({ ...options, jobs: 1, linkCache: new ExactLinkCache() }, files);
7
+ }
8
+ exports.packArchive = packArchive;
9
+ // node-tar packs a file with more than one link as a link to the first file it packed with
10
+ // the same `dev:ino`. On Windows a file id has 64 bits, and Node gives it as a number, so
11
+ // ids above 2^53 are rounded and files of two link groups can get the same one. Packed as
12
+ // a link, a file would be restored with the other file's content. This cache leaves out the
13
+ // ids that may be rounded, so those files are packed in full.
14
+ class ExactLinkCache extends Map {
15
+ get(key) {
16
+ return isExact(key) ? super.get(key) : undefined;
17
+ }
18
+ set(key, path) {
19
+ return isExact(key) ? super.set(key, path) : this;
20
+ }
21
+ }
22
+ function isExact(key) {
23
+ return key.split(':').every((part) => Number.isSafeInteger(Number(part)));
24
+ }
25
+ //# sourceMappingURL=tar.js.map
@@ -0,0 +1,11 @@
1
+ // The hammerkit plugin for opencode 2, and for opencode 1 by its `server`, re-exported by a plugin
2
+ // file of the project: `export { default } from 'hammerkit/harness/opencode'`, see
3
+ // https://hammerkit.dev/docs/remote-compute/agents
4
+ import { hammerkitOpencodeEntry } from '../dist/harness/opencode'
5
+
6
+ const plugin = hammerkitOpencodeEntry((specifier) => import(specifier))
7
+
8
+ export default plugin
9
+
10
+ // the name plugin files of opencode 1 re-exported, the same plugin so opencode 1 loads it once
11
+ export const HammerkitPlugin = plugin
package/harness/pi.ts ADDED
@@ -0,0 +1,7 @@
1
+ // The hammerkit extension for pi: `pi install npm:hammerkit`. pi loads this file with its
2
+ // own module, whose tools hammerkit gives a sandbox as their backend, see
3
+ // https://hammerkit.dev/docs/remote-compute/agents
4
+ import * as pi from '@earendil-works/pi-coding-agent'
5
+ import { hammerkitPiExtension } from '../dist/harness/pi'
6
+
7
+ export default hammerkitPiExtension(pi)
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "hammerkit",
3
3
  "description": "Containerized build tool with incremental caching that runs the same build file locally, in Docker and on Kubernetes.",
4
- "version": "1.26.0",
4
+ "version": "1.27.0-next.10",
5
5
  "keywords": [
6
6
  "build",
7
7
  "build-tool",
@@ -19,8 +19,14 @@
19
19
  "!dist/**/*.spec.*",
20
20
  "!dist/testing",
21
21
  "!dist/**/*.map",
22
- "build.schema.json"
22
+ "build.schema.json",
23
+ "harness"
23
24
  ],
25
+ "pi": {
26
+ "extensions": [
27
+ "./harness/pi.ts"
28
+ ]
29
+ },
24
30
  "bugs": {
25
31
  "url": "https://github.com/no0dles/hammerkit/issues"
26
32
  },