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
@@ -106,6 +106,7 @@ class TokenVerifier {
106
106
  ? [groups]
107
107
  : [],
108
108
  claims,
109
+ ...(typeof payload.email_verified === 'boolean' ? { emailVerified: payload.email_verified } : {}),
109
110
  };
110
111
  }
111
112
  async findKey(issuer, algorithm, kid) {
@@ -12,8 +12,11 @@ const reference_parser_1 = require("../schema/reference-parser");
12
12
  const work_scope_parser_1 = require("../schema/work-scope-parser");
13
13
  const work_task_1 = require("../planner/work-task");
14
14
  const work_service_1 = require("../planner/work-service");
15
+ const hosted_volumes_1 = require("../planner/utils/hosted-volumes");
15
16
  const log_1 = require("../log");
16
17
  const parse_error_1 = require("../schema/parse-error");
18
+ const task_arguments_1 = require("../planner/utils/task-arguments");
19
+ const work_network_1 = require("../planner/work-network");
17
20
  class DefinitionError extends Error {
18
21
  }
19
22
  exports.DefinitionError = DefinitionError;
@@ -155,6 +158,27 @@ function findPolicyProblems(items, policy, root) {
155
158
  problems.push(`${item.name}: runs on the ${runtime} runtime, which this server does not allow (runtimes: ${policy.runtimes.join(', ')})`);
156
159
  }
157
160
  const data = item.data;
161
+ // what a task reads and writes is the bundle's: `src: ../..` would have the server
162
+ // copy its own files (its credentials, other callers' caches) into the task
163
+ for (const source of 'src' in data ? data.src : []) {
164
+ if (!source.inherited && insideBundle(root, data.cwd, source.absolutePath).length === 0) {
165
+ problems.push(`${item.name}: src ${source.source} is outside of the bundle`);
166
+ }
167
+ }
168
+ for (const generate of (0, work_task_1.isWorkTask)(data) ? data.generates : []) {
169
+ if (!generate.inherited && insideBundle(root, data.cwd, generate.path).length === 0) {
170
+ problems.push(`${item.name}: generates ${generate.path}, which is outside of the bundle`);
171
+ }
172
+ }
173
+ const ceiling = policy.network;
174
+ if (ceiling && (0, work_network_1.isLimitedNetwork)(ceiling)) {
175
+ if (data.type === 'local-task') {
176
+ problems.push(`${item.name}: is a local task, which can't be limited to the network this server allows (runs.network: ${(0, work_network_1.formatNetwork)(ceiling)})`);
177
+ }
178
+ else if ('network' in data && (0, work_network_1.exceedsNetwork)(data.network, ceiling)) {
179
+ problems.push(`${item.name}: asks for network ${(0, work_network_1.formatNetwork)(data.network)}, more than this server allows (runs.network: ${(0, work_network_1.formatNetwork)(ceiling)})`);
180
+ }
181
+ }
158
182
  if ((0, work_task_1.isContainerWorkTask)(data) || (0, work_service_1.isContainerWorkService)(data)) {
159
183
  for (const mount of data.mounts) {
160
184
  // a path of the server is not the build file's to mount: the docker socket, /etc, a home directory
@@ -163,8 +187,31 @@ function findPolicyProblems(items, policy, root) {
163
187
  }
164
188
  }
165
189
  }
190
+ for (const volume of (0, work_service_1.isContainerWorkService)(data) ? data.volumes : []) {
191
+ // docker takes anything else for a path of the host, such as the docker socket
192
+ if (!(0, hosted_volumes_1.isVolumeName)(volume.name)) {
193
+ problems.push(`${item.name}: volume ${volume.name} is not a volume name, a path of the server can't be mounted`);
194
+ }
195
+ }
166
196
  for (const secret of secretsOf(item)) {
167
197
  const source = secret.source;
198
+ if (policy.backend === 'kubernetes' && source.type === 'provider') {
199
+ // the server reads no secret of a provider on a cluster: the Job of a task does
200
+ if (!(0, work_task_1.isWorkTask)(item.data)) {
201
+ problems.push(`${item.name}: the secret ${describeSecret(secret).target} is read from ${source.provider}, which ` +
202
+ 'this server does for tasks only, a service on its cluster gets no provider secret yet');
203
+ }
204
+ else if (secret.cache) {
205
+ problems.push(`${item.name}: the secret ${describeSecret(secret).target} has cache: true, its digest would need ` +
206
+ 'the value before the Job of the task reads it, which this server does not support yet');
207
+ }
208
+ else if (item.data.network.type !== 'open') {
209
+ // the init containers that read it share the pod's NetworkPolicy
210
+ problems.push(`${item.name}: the secret ${describeSecret(secret).target} is read from ${source.provider} in the ` +
211
+ `Job of the task, which network ${(0, work_network_1.formatNetwork)(item.data.network)} would cut off from it, ` +
212
+ 'a task that reads secrets of a provider keeps an open network on this server');
213
+ }
214
+ }
168
215
  if (source.type === 'provider' && policy.reserved?.includes(source.account)) {
169
216
  problems.push(`${item.name}: the secret ${describeSecret(secret).target} is read as ${source.account}, which the server uses to read repositories`);
170
217
  }
@@ -172,7 +219,7 @@ function findPolicyProblems(items, policy, root) {
172
219
  problems.push(`${item.name}: a secret can not read a file of the server (file:), use a secret provider`);
173
220
  }
174
221
  else if (source.type === 'env' && !policy.envs.includes(source.name)) {
175
- problems.push(`${item.name}: the secret ${describeSecret(secret).target} reads env:${source.name}, which the server does not list in runs.env.allow`);
222
+ problems.push(`${item.name}: the secret ${describeSecret(secret).target} reads env:${source.name}, which the server does not list in ${policy.envsFrom ?? 'runs.env.allow'}`);
176
223
  }
177
224
  }
178
225
  }
@@ -205,10 +252,13 @@ function toPlan(root, item) {
205
252
  runtime: data.type === 'local-task' ? 'local' : 'docker',
206
253
  ...((0, work_task_1.isContainerWorkTask)(data) || (0, work_service_1.isContainerWorkService)(data) ? { image: data.image } : {}),
207
254
  cmds: 'cmds' in data ? data.cmds.map((cmd) => cmd.cmd) : 'cmd' in data && data.cmd ? [data.cmd.cmd] : [],
255
+ command: (0, task_arguments_1.getArgumentsTarget)(data)?.cmd ?? null,
256
+ network: (0, work_network_1.toNetworkSchema)('network' in data ? data.network : { type: 'open' }),
208
257
  deps: item.deps.map((dep) => dep.name).sort(),
209
258
  needs: item.needs.map((need) => need.name).sort(),
210
259
  accounts,
211
260
  secrets,
261
+ serverEnvs: 'envs' in data ? data.envs.processNames ?? [] : [],
212
262
  mounts,
213
263
  ports,
214
264
  generates: 'generates' in data ? data.generates.map((generate) => generate.path) : [],
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.decideRun = exports.getGrant = exports.usedAccounts = exports.mayYank = exports.canUse = exports.maySeeDefinitions = exports.mayPublish = exports.mayPropose = exports.mayRun = exports.matches = void 0;
3
+ exports.decideRun = exports.sandboxRepos = exports.decideSandbox = exports.sandboxPushBranches = exports.sandboxBranchType = exports.getGrant = exports.usedAccounts = exports.mayYank = exports.canUse = exports.maySeeDefinitions = exports.mayPublish = exports.mayPropose = exports.mayRun = exports.matches = void 0;
4
4
  const repo_1 = require("./repo");
5
5
  const BUILDS_ORDER = ['run', 'propose', 'publish'];
6
6
  function matches(entitlement, principal) {
@@ -54,6 +54,7 @@ function getGrant(entitlements, principal) {
54
54
  const matched = [];
55
55
  const accounts = {};
56
56
  const repos = [];
57
+ const sandboxes = [];
57
58
  let builds = null;
58
59
  entitlements.forEach((entitlement, index) => {
59
60
  if (!matches(entitlement, principal)) {
@@ -74,10 +75,99 @@ function getGrant(entitlements, principal) {
74
75
  (builds === null || BUILDS_ORDER.indexOf(entitlement.builds) > BUILDS_ORDER.indexOf(builds))) {
75
76
  builds = entitlement.builds;
76
77
  }
78
+ for (const sandbox of entitlement.sandboxes ?? []) {
79
+ let known = sandboxes.find((entry) => entry.repo === sandbox.repo);
80
+ if (!known) {
81
+ known = { repo: sandbox.repo, branches: {} };
82
+ sandboxes.push(known);
83
+ }
84
+ for (const [pattern, type] of Object.entries(sandbox.branches)) {
85
+ known.branches[pattern] = higherType(known.branches[pattern], type);
86
+ }
87
+ }
77
88
  });
78
- return { accounts, repos, builds, matched };
89
+ return { accounts, repos, builds, sandboxes, matched };
79
90
  }
80
91
  exports.getGrant = getGrant;
92
+ const BRANCH_TYPES = ['pull', 'push'];
93
+ function higherType(a, b) {
94
+ return a !== undefined && BRANCH_TYPES.indexOf(a) > BRANCH_TYPES.indexOf(b) ? a : b;
95
+ }
96
+ // What the caller may do with a branch of a repository in a sandbox: the higher type of the
97
+ // branch patterns that match it, in the entries whose `repo` covers the repository. The
98
+ // branches of an entry never count for another repository. Null for nothing.
99
+ function sandboxBranchType(grant, repo, branch) {
100
+ let found = null;
101
+ for (const sandbox of grant.sandboxes) {
102
+ if (!(0, repo_1.matchesRepo)(sandbox.repo, repo)) {
103
+ continue;
104
+ }
105
+ for (const [pattern, type] of Object.entries(sandbox.branches)) {
106
+ if ((0, repo_1.matchesRef)([pattern], branch)) {
107
+ found = higherType(found ?? undefined, type);
108
+ }
109
+ }
110
+ }
111
+ return found;
112
+ }
113
+ exports.sandboxBranchType = sandboxBranchType;
114
+ // The branch patterns the caller may push to from a sandbox of a repository: those of type
115
+ // `push` in the entries of their `sandboxes` that cover the repository. A pattern of one
116
+ // repository never applies to another.
117
+ function sandboxPushBranches(grant, repo) {
118
+ const branches = grant.sandboxes
119
+ .filter((sandbox) => (0, repo_1.matchesRepo)(sandbox.repo, repo))
120
+ .flatMap((sandbox) => Object.entries(sandbox.branches))
121
+ .filter(([, type]) => type === 'push')
122
+ .map(([pattern]) => pattern);
123
+ return [...new Set(branches)];
124
+ }
125
+ exports.sandboxPushBranches = sandboxPushBranches;
126
+ // Whether the caller may start a sandbox of a repository from a branch, or fetch a branch
127
+ // into one: a `pull` or `push` type for the branch in an entry of their `sandboxes` that
128
+ // covers the repository, and the server able to read it. The answer is the reason when not.
129
+ function decideSandbox(grant, repo, branch, readable) {
130
+ if (grant.sandboxes.length === 0) {
131
+ return { status: 403, error: 'you are not entitled to keep sandboxes on this server' };
132
+ }
133
+ return checkSandbox(grant, repo, branch, readable, 'keep a sandbox of');
134
+ }
135
+ exports.decideSandbox = decideSandbox;
136
+ function checkSandbox(grant, repo, branch, readable, what) {
137
+ const repoProblem = checkRepo(sandboxRepos(grant), repo, readable, what);
138
+ if (repoProblem) {
139
+ return repoProblem;
140
+ }
141
+ if (sandboxBranchType(grant, repo, branch) === null) {
142
+ const branches = grant.sandboxes
143
+ .filter((sandbox) => (0, repo_1.matchesRepo)(sandbox.repo, repo))
144
+ .flatMap((sandbox) => Object.keys(sandbox.branches));
145
+ return {
146
+ status: 403,
147
+ error: `you may not ${what} ${repo} from ${branch}, your branches of it are ${[...new Set(branches)].join(', ')}`,
148
+ };
149
+ }
150
+ return null;
151
+ }
152
+ function sandboxRepos(grant) {
153
+ return grant.sandboxes.map((sandbox) => sandbox.repo);
154
+ }
155
+ exports.sandboxRepos = sandboxRepos;
156
+ // the caller's own list first: what they may not name is not told whether the server can read it
157
+ function checkRepo(repos, repo, readable, what) {
158
+ if (!(0, repo_1.matchesAnyRepo)(repos, repo)) {
159
+ return {
160
+ status: 403,
161
+ error: repos.length > 0
162
+ ? `you may not ${what} ${repo}, your repositories are ${repos.join(', ')}`
163
+ : `you may not ${what} ${repo}, no entitlement of yours lists a repository`,
164
+ };
165
+ }
166
+ if (!readable(repo)) {
167
+ return { status: 400, error: `this server can not read ${repo}` };
168
+ }
169
+ return null;
170
+ }
81
171
  const COMMIT = /^[0-9a-f]{40}([0-9a-f]{24})?$/i;
82
172
  // The single place that decides what happens to a run that was submitted: it
83
173
  // starts, waits for an approval, or is refused with the reason.
@@ -93,18 +183,15 @@ function decideRun(input) {
93
183
  error: 'runs from an upload are not supported by this server yet, use a git ref',
94
184
  };
95
185
  }
96
- // the caller's own list first: what they may not name is not told whether the server can read it
97
- if (!(0, repo_1.matchesAnyRepo)(grant.repos, source.repo)) {
98
- return {
99
- type: 'refuse',
100
- status: 403,
101
- error: grant.repos.length > 0
102
- ? `you may not run from ${source.repo}, your repositories are ${grant.repos.join(', ')}`
103
- : `you may not run from ${source.repo}, no entitlement of yours lists a repository`,
104
- };
186
+ if (source.type === 'sandbox' && grant.sandboxes.length === 0) {
187
+ return { type: 'refuse', status: 403, error: 'you are not entitled to keep sandboxes on this server' };
105
188
  }
106
- if (!readable(source.repo)) {
107
- return { type: 'refuse', status: 400, error: `this server can not read ${source.repo}` };
189
+ // runs on a sandbox: the repositories and branches in the `sandboxes` of the entitlements
190
+ const repoProblem = source.type === 'sandbox'
191
+ ? checkSandbox(grant, source.repo, source.branch, readable, 'run on a sandbox of')
192
+ : checkRepo(grant.repos, source.repo, readable, 'run from');
193
+ if (repoProblem) {
194
+ return { type: 'refuse', ...repoProblem };
108
195
  }
109
196
  const request = [];
110
197
  for (const account of accounts) {
@@ -113,10 +200,12 @@ function decideRun(input) {
113
200
  return { type: 'refuse', status: 403, error: `you may not use the account ${account}, it needs use or request` };
114
201
  }
115
202
  const rules = catalog[account];
116
- if (rules?.source && !rules.source.includes(source.type)) {
203
+ // a sandbox holds changes nobody reviewed: an account takes its runs only when it says so
204
+ const sources = rules?.source ?? ['ref', 'upload'];
205
+ if (!sources.includes(source.type)) {
117
206
  return { type: 'refuse', status: 403, error: `the account ${account} is not used by runs from ${source.type}` };
118
207
  }
119
- if (rules?.refs) {
208
+ if (rules?.refs && source.type === 'ref') {
120
209
  if (COMMIT.test(source.ref) || !(0, repo_1.matchesRef)(rules.refs, source.ref)) {
121
210
  return {
122
211
  type: 'refuse',
@@ -7,29 +7,20 @@ const definitions_1 = require("./definitions");
7
7
  const http_1 = require("./http");
8
8
  const oauth_1 = require("./oauth");
9
9
  const policy_1 = require("./policy");
10
- const proposals_1 = require("./proposals");
11
- const server_config_1 = require("./server-config");
12
10
  const decisionSchema = zod_1.z.object({ reason: zod_1.z.string().max(500).optional() }).strict();
13
11
  const same = (a, b) => a.iss === b.iss && a.sub === b.sub;
14
12
  // Whoever approves publishes it, and uses the accounts its proposer did not: a
15
13
  // version that names an account is approved by somebody the account trusts.
16
14
  const mayApprove = (grant, proposal) => (0, policy_1.mayPublish)(grant) && proposal.missing.every((account) => (0, policy_1.canUse)(grant, account));
17
15
  exports.mayApprove = mayApprove;
18
- // What the caller is shown of a proposal: what it is, who proposed it and the
19
- // version it would replace, so the files can be compared.
20
- function toView(store, proposal, principal, grant) {
21
- const latest = store.find(proposal.name, 'latest')?.hash ?? null;
22
- const stale = proposal.state === 'waiting' && latest !== proposal.base;
16
+ // What the caller is shown of a proposal: what it is and who proposed it.
17
+ function toView(proposal, principal, grant) {
23
18
  return {
24
19
  ...proposal,
25
- // the version it follows is fixed when it is proposed; if another one was
26
- // published since, it can only be rejected
27
- latest,
28
- stale,
29
20
  mine: same(proposal.proposedBy, principal),
30
21
  // approving needs the permission to publish, the accounts the proposer lacked, and
31
22
  // somebody else's proposal
32
- canDecide: proposal.state === 'waiting' && !stale && (0, exports.mayApprove)(grant, proposal) && !same(proposal.proposedBy, principal),
23
+ canDecide: proposal.state === 'waiting' && (0, exports.mayApprove)(grant, proposal) && !same(proposal.proposedBy, principal),
33
24
  };
34
25
  }
35
26
  /**
@@ -38,22 +29,22 @@ function toView(store, proposal, principal, grant) {
38
29
  * do not use, puts one here; someone who may publish, and uses those accounts,
39
30
  * approves it, and it is published then.
40
31
  *
41
- * GET /v1/proposals the ones the caller proposed or may decide
42
- * GET /v1/proposals/<name>/<hash> one of them
43
- * GET /v1/proposals/<name>/<hash>/files its files, for who decides
44
- * POST /v1/proposals/<name>/<hash>/approve publish it (body: optional {reason})
45
- * POST /v1/proposals/<name>/<hash>/reject drop it
32
+ * GET /v1/proposals the ones the caller proposed or may decide
33
+ * GET /v1/proposals/<hash> one of them
34
+ * GET /v1/proposals/<hash>/files its files, for who decides
35
+ * POST /v1/proposals/<hash>/approve publish it (body: optional {reason})
36
+ * POST /v1/proposals/<hash>/reject drop it
46
37
  */
47
38
  async function handleProposals(config, store, proposals, log, principal, req, res, url) {
48
- const match = /^\/v1\/proposals(?:\/([^/]+)\/([^/]+)(?:\/(files|approve|reject))?)?$/.exec(url.pathname);
39
+ const match = /^\/v1\/proposals(?:\/([^/]+)(?:\/(files|approve|reject))?)?$/.exec(url.pathname);
49
40
  if (!match) {
50
41
  return false;
51
42
  }
52
- const [, name, hash, action] = match;
43
+ const [, hash, action] = match;
53
44
  const grant = (0, policy_1.getGrant)(config.entitlements, principal);
54
45
  // what the caller neither proposed nor may decide does not exist for them
55
46
  const visible = (proposal) => same(proposal.proposedBy, principal) || (0, policy_1.mayPublish)(grant);
56
- if (name === undefined) {
47
+ if (hash === undefined) {
57
48
  if (req.method !== 'GET') {
58
49
  (0, http_1.send)(res, 405, { error: 'method not allowed' }, { allow: 'GET' });
59
50
  return true;
@@ -62,22 +53,22 @@ async function handleProposals(config, store, proposals, log, principal, req, re
62
53
  proposals: proposals
63
54
  .all()
64
55
  .filter(visible)
65
- .map((proposal) => toView(store, proposal, principal, grant)),
56
+ .map((proposal) => toView(proposal, principal, grant)),
66
57
  });
67
58
  return true;
68
59
  }
69
- if (!server_config_1.definitionName.safeParse(name).success || !bundle_1.HASH_PATTERN.test(hash)) {
60
+ if (!bundle_1.HASH_PATTERN.test(hash)) {
70
61
  (0, http_1.send)(res, 404, { error: 'not found' });
71
62
  return true;
72
63
  }
73
- const proposal = proposals.get(name, hash);
64
+ const proposal = proposals.get(hash);
74
65
  if (!action) {
75
66
  if (req.method !== 'GET') {
76
67
  (0, http_1.send)(res, 405, { error: 'method not allowed' }, { allow: 'GET' });
77
68
  return true;
78
69
  }
79
70
  proposal && visible(proposal)
80
- ? (0, http_1.send)(res, 200, toView(store, proposal, principal, grant))
71
+ ? (0, http_1.send)(res, 200, toView(proposal, principal, grant))
81
72
  : (0, http_1.send)(res, 404, { error: 'not found' });
82
73
  return true;
83
74
  }
@@ -87,7 +78,7 @@ async function handleProposals(config, store, proposals, log, principal, req, re
87
78
  return true;
88
79
  }
89
80
  // the files are for who decides, and for who wrote them
90
- const bundle = proposal && visible(proposal) ? proposals.bundle(name, hash) : undefined;
81
+ const bundle = proposal && visible(proposal) ? proposals.bundle(hash) : undefined;
91
82
  bundle ? (0, http_1.send)(res, 200, bundle) : (0, http_1.send)(res, 404, { error: 'not found' });
92
83
  return true;
93
84
  }
@@ -103,19 +94,17 @@ async function handleProposals(config, store, proposals, log, principal, req, re
103
94
  }
104
95
  const outcome = action === 'approve' ? 'approved' : 'rejected';
105
96
  try {
106
- const result = await proposals.decide(principal, name, hash, outcome, (proposed) => (0, exports.mayApprove)(grant, proposed), async (bundle) => {
107
- const proposed = proposals.get(name, hash);
108
- // looked at again here, just before it is published
109
- const latest = store.find(name, 'latest')?.hash ?? null;
110
- if (proposed && latest !== proposed.base) {
111
- throw new proposals_1.StaleProposalError(proposed.base, latest);
112
- }
113
- await store.publish(name, bundle, proposed?.proposedBy ?? principal, principal);
97
+ const result = await proposals.decide(principal, hash, outcome, (proposed) => (0, exports.mayApprove)(grant, proposed), async (bundle) => {
98
+ const proposed = proposals.get(hash);
99
+ await store.publish(bundle, proposed?.proposedBy ?? principal, {
100
+ approvedBy: principal,
101
+ annotations: proposed?.annotations,
102
+ });
114
103
  }, body.data.reason);
115
- log(`${action} proposal=${name}@${hash} iss=${principal.iss} sub=${principal.sub} result=${result.status}${body.data.reason ? ` reason=${(0, oauth_1.quote)(body.data.reason)}` : ''}`);
104
+ log(`${action} proposal=${hash} iss=${principal.iss} sub=${principal.sub} result=${result.status}${body.data.reason ? ` reason=${(0, oauth_1.quote)(body.data.reason)}` : ''}`);
116
105
  switch (result.status) {
117
106
  case 'ok':
118
- (0, http_1.send)(res, 200, toView(store, result.proposal, principal, grant));
107
+ (0, http_1.send)(res, 200, toView(result.proposal, principal, grant));
119
108
  break;
120
109
  case 'own-proposal':
121
110
  (0, http_1.send)(res, 403, { error: 'a version is approved by somebody else than who proposed it' });
@@ -128,11 +117,6 @@ async function handleProposals(config, store, proposals, log, principal, req, re
128
117
  }
129
118
  }
130
119
  catch (e) {
131
- if (e instanceof proposals_1.StaleProposalError) {
132
- log(`${action} proposal=${name}@${hash} iss=${principal.iss} sub=${principal.sub} result=stale`);
133
- (0, http_1.send)(res, 409, { error: e.message });
134
- return true;
135
- }
136
120
  if (e instanceof definitions_1.DefinitionError) {
137
121
  (0, http_1.send)(res, 400, { error: `the build file does not load: ${e.message}` });
138
122
  return true;
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.ProposalRegistry = exports.StaleProposalError = exports.CooldownError = exports.ProposalError = void 0;
3
+ exports.ProposalRegistry = exports.CooldownError = exports.ProposalError = void 0;
4
4
  const units_1 = require("../utils/units");
5
5
  class ProposalError extends Error {
6
6
  constructor() {
@@ -18,15 +18,6 @@ class CooldownError extends ProposalError {
18
18
  }
19
19
  }
20
20
  exports.CooldownError = CooldownError;
21
- // The build file moved on since the proposal was made
22
- class StaleProposalError extends Error {
23
- constructor(base, latest) {
24
- super(`the build file changed since this was proposed (it follows ${base ?? 'nothing'}, the latest is ${latest ?? 'nothing'}), propose it again on top of the latest`);
25
- this.base = base;
26
- this.latest = latest;
27
- }
28
- }
29
- exports.StaleProposalError = StaleProposalError;
30
21
  // The proposals of the server, in memory: a restart forgets what waits, and
31
22
  // whoever proposed it proposes it again. Nothing in here is runnable.
32
23
  class ProposalRegistry {
@@ -40,11 +31,11 @@ class ProposalRegistry {
40
31
  this.now = options.now ?? Date.now;
41
32
  }
42
33
  // Proposing what already waits is not an error and changes nothing.
43
- propose(version, bundle, proposer, missing = [], base = null) {
34
+ propose(version, bundle, proposer, missing = []) {
44
35
  if (this.closed) {
45
36
  throw new ProposalError('the server is shutting down');
46
37
  }
47
- const key = this.key(version.name, version.hash);
38
+ const key = version.hash;
48
39
  const existing = this.entries.get(key);
49
40
  if (existing && existing.proposal.state === 'waiting') {
50
41
  return { proposal: { ...existing.proposal }, created: false };
@@ -54,7 +45,6 @@ class ProposalRegistry {
54
45
  throw new ProposalError('too many build file versions are waiting for an approval');
55
46
  }
56
47
  const proposal = {
57
- name: version.name,
58
48
  hash: version.hash,
59
49
  entry: version.entry,
60
50
  tasks: version.tasks,
@@ -62,7 +52,7 @@ class ProposalRegistry {
62
52
  accounts: version.accounts ?? [],
63
53
  missing,
64
54
  plan: version.plan ?? {},
65
- base,
55
+ ...(version.annotations ? { annotations: { ...version.annotations } } : {}),
66
56
  proposedBy: { iss: proposer.iss, sub: proposer.sub },
67
57
  createdAt: new Date(this.now()).toISOString(),
68
58
  expiresAt: new Date(this.now() + this.timeout).toISOString(),
@@ -83,19 +73,19 @@ class ProposalRegistry {
83
73
  all() {
84
74
  return [...this.entries.values()].map((e) => ({ ...e.proposal })).reverse();
85
75
  }
86
- get(name, hash) {
87
- const entry = this.entries.get(this.key(name, hash));
76
+ get(hash) {
77
+ const entry = this.entries.get(hash);
88
78
  return entry ? { ...entry.proposal } : undefined;
89
79
  }
90
80
  // The files of one that still waits, for whoever decides on it.
91
- bundle(name, hash) {
92
- return this.entries.get(this.key(name, hash))?.bundle;
81
+ bundle(hash) {
82
+ return this.entries.get(hash)?.bundle;
93
83
  }
94
84
  // An approval publishes the files first (`publish`), and only then is the
95
85
  // proposal approved, so it never claims a version that is not there. Nobody
96
86
  // decides on their own proposal.
97
- async decide(decider, name, hash, outcome, mayDecide, publish, reason) {
98
- const entry = this.entries.get(this.key(name, hash));
87
+ async decide(decider, hash, outcome, mayDecide, publish, reason) {
88
+ const entry = this.entries.get(hash);
99
89
  if (!entry || !mayDecide(entry.proposal)) {
100
90
  return { status: 'not-found' };
101
91
  }
@@ -147,14 +137,14 @@ class ProposalRegistry {
147
137
  return;
148
138
  }
149
139
  for (const { proposal } of this.entries.values()) {
150
- if (proposal.state !== 'rejected' || proposal.name !== version.name) {
140
+ if (proposal.state !== 'rejected') {
151
141
  continue;
152
142
  }
153
143
  const left = Date.parse(proposal.decidedAt ?? '') + this.cooldown - this.now();
154
144
  const sameVersion = proposal.hash === version.hash;
155
145
  const sameProposer = proposal.proposedBy.iss === proposer.iss && proposal.proposedBy.sub === proposer.sub;
156
146
  if (left > 0 && (sameVersion || sameProposer)) {
157
- throw new CooldownError(`${sameVersion ? 'this version was' : 'a version of this build file by you was'} rejected, ` +
147
+ throw new CooldownError(`${sameVersion ? 'this version was' : 'a version you proposed was'} rejected, ` +
158
148
  `propose again in ${(0, units_1.formatDuration)(Math.ceil(left / 1000) * 1000)}`);
159
149
  }
160
150
  }
@@ -165,9 +155,6 @@ class ProposalRegistry {
165
155
  this.entries.delete(key);
166
156
  }
167
157
  }
168
- key(name, hash) {
169
- return `${name}@${hash}`;
170
- }
171
158
  }
172
159
  exports.ProposalRegistry = ProposalRegistry;
173
160
  //# sourceMappingURL=proposals.js.map
@@ -40,16 +40,17 @@ class RepoAccess {
40
40
  url(repo) {
41
41
  return this.options.url ? this.options.url(repo) : `https://${repo}.git`;
42
42
  }
43
- // The commit a ref points to now, or an error that says what is wrong with the ref.
44
- async resolve(repo, ref, signal) {
43
+ // The commit a ref points to now, or an error that says what is wrong with the ref. With
44
+ // `branch`, only a branch.
45
+ async resolve(repo, ref, signal, only) {
45
46
  const timeout = AbortSignal.timeout(RESOLVE_TIMEOUT_MS);
46
47
  const abort = signal ? AbortSignal.any([signal, timeout]) : timeout;
47
48
  if (this.options.resolve) {
48
- return this.options.resolve(repo, ref, abort);
49
+ return this.options.resolve(repo, ref, abort, only);
49
50
  }
50
51
  const credentials = await this.credentials(repo);
51
52
  try {
52
- return await (0, git_checkout_1.resolveRef)(this.url(repo), ref, credentials.env, abort);
53
+ return await (0, git_checkout_1.resolveRef)(this.url(repo), ref, credentials.env, abort, only);
53
54
  }
54
55
  catch (e) {
55
56
  throw this.scrub(e, credentials.values);
@@ -79,6 +80,31 @@ class RepoAccess {
79
80
  env.GIT_CONFIG_VALUE_0 = header;
80
81
  return { env, values: [token, header] };
81
82
  }
83
+ /**
84
+ * The same for pushing a sandbox to the repository, with the source's `secret`. A
85
+ * repository the server reads without one is not pushed to: a push never falls back to
86
+ * a credential git finds on the server.
87
+ */
88
+ async pushCredentials(repo) {
89
+ if (!this.source(repo)?.secret) {
90
+ throw new Error(`this server does not push to ${repo}`);
91
+ }
92
+ const credentials = await this.credentials(repo);
93
+ // nothing but that token: no configuration, credential helper, .netrc or ssh agent of
94
+ // the server's user, which would push as whoever that user is
95
+ const env = { ...credentials.env };
96
+ for (const key of ['HOME', 'XDG_CONFIG_HOME', 'SSH_AUTH_SOCK', 'GIT_ASKPASS', 'SSH_ASKPASS', 'GIT_SSH_COMMAND']) {
97
+ delete env[key];
98
+ }
99
+ const count = Number(env.GIT_CONFIG_COUNT ?? '0');
100
+ env[`GIT_CONFIG_KEY_${count}`] = 'credential.helper';
101
+ env[`GIT_CONFIG_VALUE_${count}`] = '';
102
+ env.GIT_CONFIG_COUNT = String(count + 1);
103
+ env.GIT_CONFIG_NOSYSTEM = '1';
104
+ // an empty file on every platform: git for Windows opens `/dev/null` as `nul`
105
+ env.GIT_CONFIG_GLOBAL = '/dev/null';
106
+ return { env, values: credentials.values };
107
+ }
82
108
  async token(secret) {
83
109
  const key = `${secret.account}\0${secret.from}`;
84
110
  const known = this.tokens.get(key);
@@ -118,8 +144,13 @@ class RepoAccess {
118
144
  scrub(error, values) {
119
145
  const registry = (0, redact_1.createSecretRegistry)();
120
146
  values.forEach((value) => registry.register(value));
121
- const message = error instanceof Error ? error.message : String(error);
122
- const scrubbed = new (error instanceof Error ? error.constructor : Error)(registry.redact(message));
147
+ const scrubbed = new Error(registry.redact(error instanceof Error ? error.message : String(error)));
148
+ if (error instanceof Error) {
149
+ // its class, so a caller still tells a RefError apart, but none of its fields: a
150
+ // constructor may take more than the message (SandboxPushFailed), a field may hold the token
151
+ Object.setPrototypeOf(scrubbed, Object.getPrototypeOf(error));
152
+ scrubbed.name = error.name;
153
+ }
123
154
  return scrubbed;
124
155
  }
125
156
  }