@dsh-enhanced/assistant-actions 0.1.30

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 (76) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +247 -0
  3. package/cordis.patch.yml +3 -0
  4. package/lib/broker-cli.d.ts +70 -0
  5. package/lib/broker-cli.d.ts.map +1 -0
  6. package/lib/broker-cli.js +255 -0
  7. package/lib/broker-cli.js.map +1 -0
  8. package/lib/broker-client.d.ts +54 -0
  9. package/lib/broker-client.d.ts.map +1 -0
  10. package/lib/broker-client.js +266 -0
  11. package/lib/broker-client.js.map +1 -0
  12. package/lib/broker-core.d.ts +51 -0
  13. package/lib/broker-core.d.ts.map +1 -0
  14. package/lib/broker-core.js +441 -0
  15. package/lib/broker-core.js.map +1 -0
  16. package/lib/broker-ledger.d.ts +131 -0
  17. package/lib/broker-ledger.d.ts.map +1 -0
  18. package/lib/broker-ledger.js +925 -0
  19. package/lib/broker-ledger.js.map +1 -0
  20. package/lib/broker-peer-linux.d.ts +23 -0
  21. package/lib/broker-peer-linux.d.ts.map +1 -0
  22. package/lib/broker-peer-linux.js +68 -0
  23. package/lib/broker-peer-linux.js.map +1 -0
  24. package/lib/broker-protocol.d.ts +419 -0
  25. package/lib/broker-protocol.d.ts.map +1 -0
  26. package/lib/broker-protocol.js +786 -0
  27. package/lib/broker-protocol.js.map +1 -0
  28. package/lib/broker-server.d.ts +77 -0
  29. package/lib/broker-server.d.ts.map +1 -0
  30. package/lib/broker-server.js +467 -0
  31. package/lib/broker-server.js.map +1 -0
  32. package/lib/cli.d.ts +3 -0
  33. package/lib/cli.d.ts.map +1 -0
  34. package/lib/cli.js +36 -0
  35. package/lib/cli.js.map +1 -0
  36. package/lib/config.d.ts +54 -0
  37. package/lib/config.d.ts.map +1 -0
  38. package/lib/config.js +165 -0
  39. package/lib/config.js.map +1 -0
  40. package/lib/external-delivery-receipt.d.ts +11 -0
  41. package/lib/external-delivery-receipt.d.ts.map +1 -0
  42. package/lib/external-delivery-receipt.js +25 -0
  43. package/lib/external-delivery-receipt.js.map +1 -0
  44. package/lib/github.d.ts +78 -0
  45. package/lib/github.d.ts.map +1 -0
  46. package/lib/github.js +611 -0
  47. package/lib/github.js.map +1 -0
  48. package/lib/index.d.ts +19 -0
  49. package/lib/index.d.ts.map +1 -0
  50. package/lib/index.js +23 -0
  51. package/lib/index.js.map +1 -0
  52. package/lib/ledger.d.ts +78 -0
  53. package/lib/ledger.d.ts.map +1 -0
  54. package/lib/ledger.js +1064 -0
  55. package/lib/ledger.js.map +1 -0
  56. package/lib/repository-readback.d.ts +34 -0
  57. package/lib/repository-readback.d.ts.map +1 -0
  58. package/lib/repository-readback.js +193 -0
  59. package/lib/repository-readback.js.map +1 -0
  60. package/lib/service.d.ts +86 -0
  61. package/lib/service.d.ts.map +1 -0
  62. package/lib/service.js +1259 -0
  63. package/lib/service.js.map +1 -0
  64. package/lib/types.d.ts +174 -0
  65. package/lib/types.d.ts.map +1 -0
  66. package/lib/types.js +2 -0
  67. package/lib/types.js.map +1 -0
  68. package/lib/verified-delivery.d.ts +104 -0
  69. package/lib/verified-delivery.d.ts.map +1 -0
  70. package/lib/verified-delivery.js +262 -0
  71. package/lib/verified-delivery.js.map +1 -0
  72. package/lib/version.d.ts +2 -0
  73. package/lib/version.d.ts.map +1 -0
  74. package/lib/version.js +2 -0
  75. package/lib/version.js.map +1 -0
  76. package/package.json +123 -0
package/lib/github.js ADDED
@@ -0,0 +1,611 @@
1
+ import { request as httpsRequest } from 'node:https';
2
+ const GITHUB_GRAPHQL_URL = new URL('https://api.github.com/graphql');
3
+ const RESPONSE_LIMIT = 16_384;
4
+ const REQUEST_TIMEOUT_MS = 30_000;
5
+ const UNKNOWN_REASON = 'github-commit-unknown';
6
+ const COMPENSATION_UNKNOWN_REASON = 'github-compensation-unknown';
7
+ const PREIMAGE_FILE_LIMIT = 1_048_576;
8
+ const PREIMAGE_FILE_COUNT_LIMIT = 32;
9
+ const PREIMAGE_TOTAL_LIMIT = 1_048_576;
10
+ const REST_RESPONSE_LIMIT = 262_144;
11
+ const CONTENT_RESPONSE_LIMIT = 1_500_000;
12
+ const TREE_RESPONSE_LIMIT = 1_500_000;
13
+ const API = 'https://api.github.com/repos/';
14
+ function unknown(actionId) {
15
+ return { actionId, status: 'unknown', reason: UNKNOWN_REASON };
16
+ }
17
+ function validHeaderToken(token) {
18
+ return token.length > 0 && token.length <= 8192 && /^[\x21-\x7e]+$/.test(token);
19
+ }
20
+ function validActionId(actionId) {
21
+ return actionId.length > 0 && actionId.length <= 256 && !/\p{Cc}/u.test(actionId);
22
+ }
23
+ function validRepository(repository) {
24
+ return repository.length <= 256 && /^[A-Za-z0-9][A-Za-z0-9-]*\/[A-Za-z0-9][A-Za-z0-9._-]*$/.test(repository);
25
+ }
26
+ function validBranch(branch) {
27
+ return branch.length > 0 && branch.length <= 255 && !branch.startsWith('refs/') && !/\p{Cc}/u.test(branch);
28
+ }
29
+ function validScopedPath(path) {
30
+ return path.length > 0 && path.length <= 1024 && !/[\\\p{Cc}]/u.test(path)
31
+ && path.split('/').every(part => part !== '' && part !== '.' && part !== '..' && part.toLowerCase() !== '.git');
32
+ }
33
+ function validInput(input) {
34
+ const { actionId, grant, request } = input;
35
+ return validActionId(actionId)
36
+ && validRepository(grant.repository)
37
+ && validBranch(grant.branch)
38
+ && /^[0-9a-f]{40,128}$/i.test(request.expectedHeadOid)
39
+ && request.files.length > 0
40
+ && request.files.every((file) => validScopedPath(file.path));
41
+ }
42
+ function makePayload(input) {
43
+ const clientMutationId = `dsh-action:${input.actionId}`;
44
+ return JSON.stringify({
45
+ query: `mutation CreateCommitOnBranch($input: CreateCommitOnBranchInput!) {
46
+ createCommitOnBranch(input: $input) {
47
+ clientMutationId
48
+ commit { oid parents(first: 2) { nodes { oid } } repository { nameWithOwner } }
49
+ ref { name target { oid } }
50
+ }
51
+ }`,
52
+ variables: {
53
+ input: {
54
+ branch: {
55
+ repositoryNameWithOwner: input.grant.repository,
56
+ branchName: input.grant.branch,
57
+ },
58
+ expectedHeadOid: input.request.expectedHeadOid,
59
+ message: { headline: input.request.headline, body: `dsh-action:${input.actionId}` },
60
+ fileChanges: {
61
+ additions: input.request.files.map((file) => ({
62
+ path: file.path,
63
+ contents: Buffer.from(file.content, 'utf8').toString('base64'),
64
+ })),
65
+ },
66
+ clientMutationId,
67
+ },
68
+ },
69
+ });
70
+ }
71
+ function matchingCommitOid(response, expected) {
72
+ if (response.errors !== undefined || response.data === undefined)
73
+ return undefined;
74
+ const result = response.data.createCommitOnBranch;
75
+ const commit = result?.commit;
76
+ const parents = commit?.parents?.nodes;
77
+ const oid = commit?.oid;
78
+ const refOid = result?.ref?.target?.oid;
79
+ if (result?.clientMutationId !== expected.clientMutationId
80
+ || commit?.repository?.nameWithOwner !== expected.repository
81
+ || result?.ref?.name !== expected.branch
82
+ || !Array.isArray(parents)
83
+ || parents.length !== 1
84
+ || parents[0]?.oid !== expected.parentOid
85
+ || typeof oid !== 'string'
86
+ || !/^[0-9a-f]{40,128}$/i.test(oid)
87
+ || refOid !== oid)
88
+ return undefined;
89
+ return oid;
90
+ }
91
+ function responseMatches(response, input) {
92
+ return matchingCommitOid(response, {
93
+ clientMutationId: `dsh-action:${input.actionId}`,
94
+ repository: input.grant.repository,
95
+ branch: input.grant.branch,
96
+ parentOid: input.request.expectedHeadOid,
97
+ });
98
+ }
99
+ async function graphql(payload, token, signal, transport) {
100
+ if (!validHeaderToken(token) || signal.aborted)
101
+ return undefined;
102
+ return await new Promise((resolve) => {
103
+ let settled = false;
104
+ let timer;
105
+ let request;
106
+ const finish = (result) => {
107
+ if (settled)
108
+ return;
109
+ settled = true;
110
+ if (timer !== undefined)
111
+ clearTimeout(timer);
112
+ signal.removeEventListener('abort', onAbort);
113
+ resolve(result);
114
+ };
115
+ const onAbort = () => { request?.destroy(); finish(undefined); };
116
+ try {
117
+ request = transport(GITHUB_GRAPHQL_URL, {
118
+ method: 'POST',
119
+ agent: false,
120
+ signal,
121
+ headers: {
122
+ accept: 'application/json',
123
+ authorization: `Bearer ${token}`,
124
+ 'content-type': 'application/json',
125
+ 'content-length': Buffer.byteLength(payload),
126
+ 'user-agent': 'dsh-enhanced-assistant-actions',
127
+ },
128
+ }, (response) => {
129
+ let size = 0;
130
+ const chunks = [];
131
+ response.on('data', (chunk) => {
132
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
133
+ size += buffer.length;
134
+ if (size > RESPONSE_LIMIT) {
135
+ response.destroy();
136
+ request?.destroy();
137
+ finish(undefined);
138
+ }
139
+ else
140
+ chunks.push(buffer);
141
+ });
142
+ response.once('error', () => finish(undefined));
143
+ response.once('aborted', () => finish(undefined));
144
+ response.once('end', () => {
145
+ if (settled || signal.aborted || response.statusCode === undefined || response.statusCode < 200 || response.statusCode >= 300) {
146
+ finish(undefined);
147
+ return;
148
+ }
149
+ try {
150
+ const raw = Buffer.concat(chunks).toString('utf8');
151
+ const parsed = JSON.parse(raw);
152
+ finish(raw.includes(token) || JSON.stringify(parsed).includes(token) ? undefined : parsed);
153
+ }
154
+ catch {
155
+ finish(undefined);
156
+ }
157
+ });
158
+ });
159
+ request.once('error', () => finish(undefined));
160
+ signal.addEventListener('abort', onAbort, { once: true });
161
+ if (signal.aborted) {
162
+ onAbort();
163
+ return;
164
+ }
165
+ timer = setTimeout(onAbort, REQUEST_TIMEOUT_MS);
166
+ timer.unref();
167
+ request.end(payload);
168
+ }
169
+ catch {
170
+ request?.destroy();
171
+ finish(undefined);
172
+ }
173
+ });
174
+ }
175
+ /** Commit exactly the approved files through GitHub's atomic expected-head mutation. */
176
+ export async function commitOnGitHub(input, transport = httpsRequest) {
177
+ if (input.signal.aborted)
178
+ return { actionId: input.actionId, status: 'failed', reason: 'dispatch-aborted' };
179
+ if (!validHeaderToken(input.token) || !validInput(input))
180
+ return unknown(input.actionId);
181
+ const payload = makePayload(input);
182
+ const response = await graphql(payload, input.token, input.signal, transport);
183
+ const resultOid = response === undefined ? undefined : responseMatches(response, input);
184
+ return resultOid === undefined ? unknown(input.actionId) : { actionId: input.actionId, status: 'succeeded', commitOid: resultOid };
185
+ }
186
+ const record = (value) => value !== null && typeof value === 'object' && !Array.isArray(value) ? value : undefined;
187
+ const oid = (value) => typeof value === 'string' && /^[0-9a-f]{40}$/i.test(value);
188
+ const positiveInteger = (value) => typeof value === 'number' && Number.isSafeInteger(value) && value > 0;
189
+ async function rest(path, method, token, body, signal, transport = httpsRequest, acceptedStatuses, responseLimit = REST_RESPONSE_LIMIT) {
190
+ if (!validHeaderToken(token) || signal.aborted || !path.startsWith('/'))
191
+ return undefined;
192
+ const payload = body === undefined ? undefined : JSON.stringify(body);
193
+ return await new Promise(resolve => {
194
+ let done = false;
195
+ let timer;
196
+ let request;
197
+ const finish = (value) => {
198
+ if (done)
199
+ return;
200
+ done = true;
201
+ if (timer)
202
+ clearTimeout(timer);
203
+ signal.removeEventListener('abort', onAbort);
204
+ resolve(value);
205
+ };
206
+ const onAbort = () => { request?.destroy(); finish(undefined); };
207
+ try {
208
+ request = transport(new URL(`${API}${path.slice(1)}`), { method, agent: false, headers: {
209
+ accept: 'application/vnd.github+json', authorization: `Bearer ${token}`, 'content-type': 'application/json',
210
+ ...(payload ? { 'content-length': Buffer.byteLength(payload) } : {}),
211
+ 'user-agent': 'dsh-enhanced-assistant-actions', 'x-github-api-version': '2022-11-28',
212
+ } }, response => {
213
+ let bytes = 0;
214
+ const chunks = [];
215
+ response.on('data', (chunk) => {
216
+ const value = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
217
+ bytes += value.length;
218
+ if (bytes > responseLimit) {
219
+ response.destroy();
220
+ request?.destroy();
221
+ finish(undefined);
222
+ }
223
+ else
224
+ chunks.push(value);
225
+ });
226
+ response.once('error', () => finish(undefined));
227
+ response.once('aborted', () => finish(undefined));
228
+ response.once('end', () => {
229
+ if (done || signal.aborted || !response.statusCode
230
+ || (response.statusCode < 200 || response.statusCode >= 300) && !acceptedStatuses?.includes(response.statusCode)) {
231
+ finish(undefined);
232
+ return;
233
+ }
234
+ try {
235
+ const raw = Buffer.concat(chunks).toString('utf8');
236
+ if (raw.includes(token)) {
237
+ finish(undefined);
238
+ return;
239
+ }
240
+ if (acceptedStatuses?.includes(response.statusCode) && (response.statusCode < 200 || response.statusCode >= 300)) {
241
+ finish({ status: response.statusCode, body: undefined, hasNextPage: false });
242
+ return;
243
+ }
244
+ const parsed = JSON.parse(raw);
245
+ // Never release a credential echoed anywhere in a remote response.
246
+ if (JSON.stringify(parsed).includes(token)) {
247
+ finish(undefined);
248
+ return;
249
+ }
250
+ finish({ status: response.statusCode, body: parsed, hasNextPage: /\brel="?next\b/i.test(String(response.headers.link ?? '')) });
251
+ }
252
+ catch {
253
+ finish(undefined);
254
+ }
255
+ });
256
+ });
257
+ request.once('error', () => finish(undefined));
258
+ signal.addEventListener('abort', onAbort, { once: true });
259
+ if (signal.aborted) {
260
+ onAbort();
261
+ return;
262
+ }
263
+ timer = setTimeout(onAbort, REQUEST_TIMEOUT_MS);
264
+ timer.unref();
265
+ request.end(payload);
266
+ }
267
+ catch {
268
+ request?.destroy();
269
+ finish(undefined);
270
+ }
271
+ });
272
+ }
273
+ const repoPath = (grant) => `${encodeURIComponent(grant.repository.split('/')[0])}/${encodeURIComponent(grant.repository.split('/')[1])}`;
274
+ const contentPath = (grant, path, ref) => `/${repoPath(grant)}/contents/${path.split('/').map(encodeURIComponent).join('/')}?ref=${encodeURIComponent(ref)}`;
275
+ function decodedFile(value, expectedPath, token) {
276
+ const body = record(value);
277
+ if (!body || body.path !== expectedPath || body.type !== 'file' || body.encoding !== 'base64'
278
+ || typeof body.content !== 'string' || !oid(body.sha) || !Number.isSafeInteger(body.size)
279
+ || body.size < 0 || body.size > PREIMAGE_FILE_LIMIT || body.truncated === true)
280
+ return undefined;
281
+ const encoded = body.content.replace(/\n/g, '');
282
+ const content = Buffer.from(encoded, 'base64');
283
+ const text = content.toString('utf8');
284
+ if (content.length !== body.size || content.toString('base64') !== encoded
285
+ || Buffer.from(text, 'utf8').compare(content) !== 0 || text.includes(token))
286
+ return undefined;
287
+ return Object.freeze({ path: expectedPath, state: 'present', blobOid: body.sha, content: text, size: content.length });
288
+ }
289
+ const BLOB_MODES = new Set(['100644', '100755', '120000']);
290
+ const TREE_MODE = '040000';
291
+ const COMMIT_MODE = '160000';
292
+ /**
293
+ * Index every entry in the immutable parent commit's (possibly recursive) tree
294
+ * via the Git Data API. Contents 404s are cross-checked against this index so
295
+ * that an existing-but-unreadable path (an oversized blob, a directory or
296
+ * submodule, or an endpoint that hides the path from this credential) can never
297
+ * be misread as "absent" and turned into a destructive deletion. Returns
298
+ * undefined whenever the index cannot be established completely; callers must
299
+ * then abort the compensation.
300
+ */
301
+ async function readGitHubTreeIndex(grant, commitOid, token, signal, transport) {
302
+ const commitReply = await rest(`/${repoPath(grant)}/git/commits/${encodeURIComponent(commitOid)}`, 'GET', token, undefined, signal, transport);
303
+ if (commitReply?.status !== 200)
304
+ return undefined;
305
+ const commitBody = record(commitReply.body);
306
+ const tree = record(commitBody?.tree);
307
+ const treeSha = typeof tree?.sha === 'string' ? tree.sha : undefined;
308
+ if (commitBody?.sha !== commitOid || !oid(treeSha))
309
+ return undefined;
310
+ const treeReply = await rest(`/${repoPath(grant)}/git/trees/${encodeURIComponent(treeSha)}?recursive=1`, 'GET', token, undefined, signal, transport, undefined, TREE_RESPONSE_LIMIT);
311
+ if (treeReply?.status !== 200)
312
+ return undefined;
313
+ const treeBody = record(treeReply.body);
314
+ const entries = treeBody?.tree;
315
+ if (treeBody?.sha !== treeSha || !Array.isArray(entries) || treeBody.truncated === true)
316
+ return undefined;
317
+ const index = new Map();
318
+ for (const value of entries) {
319
+ const entry = record(value);
320
+ if (entry === undefined || typeof entry.path !== 'string' || typeof entry.type !== 'string' || typeof entry.mode !== 'string')
321
+ return undefined;
322
+ // GitHub tree type/mode pairs are fixed. A mismatch (or a type/mode we do
323
+ // not recognize) means the response is not the tree shape compensations are
324
+ // proven against: fail closed rather than silently ignoring the entry.
325
+ let parsed;
326
+ if (entry.type === 'blob') {
327
+ if (!BLOB_MODES.has(entry.mode) || !oid(entry.sha))
328
+ return undefined;
329
+ parsed = Object.freeze({ type: 'blob', mode: entry.mode, oid: entry.sha });
330
+ }
331
+ else if (entry.type === 'tree') {
332
+ if (entry.mode !== TREE_MODE)
333
+ return undefined;
334
+ parsed = Object.freeze({ type: 'tree', mode: entry.mode, oid: oid(entry.sha) ? entry.sha : undefined });
335
+ }
336
+ else if (entry.type === 'commit') {
337
+ if (entry.mode !== COMMIT_MODE)
338
+ return undefined;
339
+ parsed = Object.freeze({ type: 'commit', mode: entry.mode, oid: oid(entry.sha) ? entry.sha : undefined });
340
+ }
341
+ else
342
+ return undefined;
343
+ const prior = index.get(entry.path);
344
+ if (prior !== undefined && (prior.type !== parsed.type || prior.mode !== parsed.mode || prior.oid !== parsed.oid))
345
+ return undefined;
346
+ index.set(entry.path, parsed);
347
+ }
348
+ return index;
349
+ }
350
+ /** Read exact allowed file states at one immutable Git commit without following redirects. */
351
+ export async function readGitHubPreimage(input, transport) {
352
+ if (!validRepository(input.grant.repository) || !validBranch(input.grant.branch) || !oid(input.commitOid)
353
+ || !Array.isArray(input.paths) || input.paths.length < 1 || input.paths.length > PREIMAGE_FILE_COUNT_LIMIT
354
+ || new Set(input.paths).size !== input.paths.length
355
+ || input.paths.some(path => !validScopedPath(path) || !input.grant.paths.includes(path)))
356
+ return undefined;
357
+ const resolvedTransport = transport ?? httpsRequest;
358
+ // Bind the capture to the immutable parent commit: its tree is the authority
359
+ // on which grant paths exist. A contents 404 contradicted by this tree, or a
360
+ // contents blob whose OID differs from the tree entry, aborts the capture.
361
+ const index = await readGitHubTreeIndex(input.grant, input.commitOid, input.token, input.signal, resolvedTransport);
362
+ if (!index)
363
+ return undefined;
364
+ const files = [];
365
+ let total = 0;
366
+ for (const path of input.paths) {
367
+ total += Buffer.byteLength(path);
368
+ if (total > PREIMAGE_TOTAL_LIMIT)
369
+ return undefined;
370
+ const reply = await rest(contentPath(input.grant, path, input.commitOid), 'GET', input.token, undefined, input.signal, resolvedTransport, [404], CONTENT_RESPONSE_LIMIT);
371
+ const indexed = index.get(path);
372
+ if (reply?.status === 404) {
373
+ // The parent tree lists any entry (blob, directory tree, or submodule)
374
+ // at this path; a 404 then means an unreadable/hidden path, not absence.
375
+ if (indexed !== undefined)
376
+ return undefined;
377
+ files.push(Object.freeze({ path, state: 'absent' }));
378
+ continue;
379
+ }
380
+ if (reply?.status !== 200)
381
+ return undefined;
382
+ const file = decodedFile(reply.body, path, input.token);
383
+ // A 200 is a file only when the tree agrees it is a blob with the same OID;
384
+ // a directory/submodule entry masquerading as contents must abort the capture.
385
+ if (!file || indexed?.type !== 'blob' || indexed.oid !== file.blobOid)
386
+ return undefined;
387
+ total += file.size;
388
+ if (total > PREIMAGE_TOTAL_LIMIT)
389
+ return undefined;
390
+ files.push(file);
391
+ }
392
+ return Object.freeze({ repository: input.grant.repository, branch: input.grant.branch, commitOid: input.commitOid, files: Object.freeze(files) });
393
+ }
394
+ /** Inspect only the grant-fixed branch head and bind the observation to its scope. */
395
+ export async function inspectGitHubBranchHead(input, transport) {
396
+ if (!validRepository(input.grant.repository) || !validBranch(input.grant.branch))
397
+ return undefined;
398
+ const reply = await rest(`/${repoPath(input.grant)}/branches/${encodeURIComponent(input.grant.branch)}`, 'GET', input.token, undefined, input.signal, transport);
399
+ const body = record(reply?.body);
400
+ const headOid = record(body?.commit)?.sha;
401
+ return reply?.status === 200 && body?.name === input.grant.branch && oid(headOid)
402
+ ? Object.freeze({ repository: input.grant.repository, branch: input.grant.branch, headOid }) : undefined;
403
+ }
404
+ function validPreimage(input) {
405
+ const { preimage, grant } = input;
406
+ if (!validActionId(input.actionId) || !validRepository(grant.repository) || !validBranch(grant.branch) || !oid(input.forwardCommitOid)
407
+ || preimage.repository !== grant.repository || preimage.branch !== grant.branch || !oid(preimage.commitOid)
408
+ || !Array.isArray(preimage.files) || preimage.files.length < 1 || preimage.files.length > PREIMAGE_FILE_COUNT_LIMIT
409
+ || new Set(preimage.files.map(file => file.path)).size !== preimage.files.length)
410
+ return false;
411
+ let total = 0;
412
+ for (const file of preimage.files) {
413
+ if (!validScopedPath(file.path) || !grant.paths.includes(file.path))
414
+ return false;
415
+ total += Buffer.byteLength(file.path);
416
+ if (file.state === 'present') {
417
+ const bytes = Buffer.from(file.content, 'utf8');
418
+ if (!oid(file.blobOid) || !Number.isSafeInteger(file.size) || file.size < 0 || file.size > PREIMAGE_FILE_LIMIT
419
+ || bytes.length !== file.size || bytes.toString('utf8') !== file.content || file.content.includes(input.token))
420
+ return false;
421
+ total += bytes.length;
422
+ }
423
+ else if (file.state !== 'absent')
424
+ return false;
425
+ }
426
+ return total <= PREIMAGE_TOTAL_LIMIT;
427
+ }
428
+ function compensationPayload(input, actionMarker) {
429
+ const additions = input.preimage.files.filter((file) => file.state === 'present')
430
+ .map(file => ({ path: file.path, contents: Buffer.from(file.content, 'utf8').toString('base64') }));
431
+ const deletions = input.preimage.files.filter(file => file.state === 'absent').map(file => ({ path: file.path }));
432
+ return JSON.stringify({
433
+ query: `mutation CreateCommitOnBranch($input: CreateCommitOnBranchInput!) {
434
+ createCommitOnBranch(input: $input) {
435
+ clientMutationId
436
+ commit { oid parents(first: 2) { nodes { oid } } repository { nameWithOwner } }
437
+ ref { name target { oid } }
438
+ }
439
+ }`,
440
+ variables: { input: {
441
+ branch: { repositoryNameWithOwner: input.grant.repository, branchName: input.grant.branch },
442
+ expectedHeadOid: input.forwardCommitOid,
443
+ message: { headline: `Compensate ${input.actionId}`, body: actionMarker },
444
+ fileChanges: { additions, deletions },
445
+ clientMutationId: actionMarker,
446
+ } },
447
+ });
448
+ }
449
+ /** Restore a captured preimage through an expected-head commit; never force-pushes. */
450
+ export async function createCompensatingCommitOnGitHub(input, transports = {}) {
451
+ const actionMarker = `dsh-compensation:${input.actionId}`;
452
+ const base = { actionId: input.actionId, repository: input.grant.repository, branch: input.grant.branch, parentOid: input.forwardCommitOid, actionMarker };
453
+ if (input.signal.aborted)
454
+ return { ...base, status: 'failed', reason: 'dispatch-aborted' };
455
+ if (!validHeaderToken(input.token) || !validPreimage(input))
456
+ return { ...base, status: 'unknown', reason: COMPENSATION_UNKNOWN_REASON };
457
+ const head = await inspectGitHubBranchHead(input, transports.rest);
458
+ if (!head)
459
+ return { ...base, status: 'unknown', reason: COMPENSATION_UNKNOWN_REASON };
460
+ if (head.headOid !== input.forwardCommitOid)
461
+ return { ...base, status: 'failed', reason: 'github-compensation-head-conflict' };
462
+ const response = await graphql(compensationPayload(input, actionMarker), input.token, input.signal, transports.graphql ?? httpsRequest);
463
+ // GraphQL may return both `errors` and a populated `data`. Any error entry is
464
+ // treated as a hard rejection (fail closed) even when a commit OID is also
465
+ // present; the commit is never reported as succeeded from a partial response.
466
+ if (response?.errors !== undefined)
467
+ return { ...base, status: 'failed', reason: 'github-compensation-rejected' };
468
+ const resultOid = response === undefined ? undefined : matchingCommitOid(response, {
469
+ clientMutationId: actionMarker, repository: input.grant.repository, branch: input.grant.branch, parentOid: input.forwardCommitOid,
470
+ });
471
+ return resultOid === undefined ? { ...base, status: 'unknown', reason: COMPENSATION_UNKNOWN_REASON } : { ...base, status: 'succeeded', resultOid };
472
+ }
473
+ function scopedPr(value, grant, number, headOid) {
474
+ const body = record(value), head = record(body?.head), base = record(body?.base);
475
+ if (!body || !grant.repoWorkflow || !positiveInteger(body.number) || (number !== undefined && body.number !== number)
476
+ || head?.ref !== grant.branch || !oid(head.sha) || (headOid !== undefined && head.sha !== headOid)
477
+ || base?.ref !== grant.repoWorkflow.baseBranch || record(head.repo)?.full_name !== grant.repository || record(base.repo)?.full_name !== grant.repository)
478
+ return undefined;
479
+ return body;
480
+ }
481
+ /**
482
+ * Project a GitHub pull-request response onto the exact fields consumed by the
483
+ * broker and repository-readback reducer. Never return the raw response: it
484
+ * can contain bodies, URLs, user objects, permissions, or future fields that
485
+ * are outside this capability.
486
+ */
487
+ function pullRequestObservation(value, grant, number, headOid) {
488
+ const body = scopedPr(value, grant, number, headOid);
489
+ if (!body)
490
+ return undefined;
491
+ const head = record(body.head);
492
+ const base = record(body.base);
493
+ const headRepository = record(head.repo);
494
+ const baseRepository = record(base.repo);
495
+ if (body.state !== undefined && !['open', 'closed'].includes(String(body.state)))
496
+ return undefined;
497
+ if (body.merged !== undefined && typeof body.merged !== 'boolean')
498
+ return undefined;
499
+ return {
500
+ number: body.number,
501
+ ...(body.state === undefined ? {} : { state: body.state }),
502
+ ...(body.merged === undefined ? {} : { merged: body.merged }),
503
+ head: { ref: head.ref, sha: head.sha, repo: { full_name: headRepository.full_name } },
504
+ base: { ref: base.ref, repo: { full_name: baseRepository.full_name } },
505
+ untrusted: true,
506
+ };
507
+ }
508
+ function boundedObservationText(value, maximum = 256) {
509
+ return typeof value === 'string' && value.length > 0 && value.length <= maximum && !/\p{Cc}/u.test(value);
510
+ }
511
+ function checkObservation(value, headOid) {
512
+ const item = record(value);
513
+ if (!item || !positiveInteger(item.id) || !boundedObservationText(item.name) || item.head_sha !== headOid
514
+ || !['queued', 'in_progress', 'completed', 'waiting', 'requested', 'pending'].includes(String(item.status))
515
+ || (item.conclusion !== null && !boundedObservationText(item.conclusion)))
516
+ return undefined;
517
+ const app = item.app === undefined ? undefined : record(item.app);
518
+ if (item.app !== undefined && (!app || !positiveInteger(app.id)))
519
+ return undefined;
520
+ return { id: item.id, name: item.name, ...(app ? { app: { id: app.id } } : {}), head_sha: headOid, status: item.status, conclusion: item.conclusion };
521
+ }
522
+ function reviewObservation(value) {
523
+ const item = record(value);
524
+ if (!item || !positiveInteger(item.id) || !oid(item.commit_id)
525
+ || !['APPROVED', 'CHANGES_REQUESTED', 'COMMENTED', 'DISMISSED', 'PENDING'].includes(String(item.state)))
526
+ return undefined;
527
+ const user = item.user === undefined ? undefined : record(item.user);
528
+ if (item.user !== undefined && (!user || !positiveInteger(user.id)))
529
+ return undefined;
530
+ if (item.submitted_at !== undefined && (!boundedObservationText(item.submitted_at, 64) || Number.isNaN(Date.parse(item.submitted_at))))
531
+ return undefined;
532
+ return { id: item.id, ...(user ? { user: { id: user.id } } : {}), commit_id: item.commit_id, state: item.state,
533
+ ...(item.submitted_at === undefined ? {} : { submitted_at: item.submitted_at }) };
534
+ }
535
+ export async function createBranchOnGitHub(input, transport) {
536
+ const workflow = input.grant.repoWorkflow;
537
+ if (!workflow?.allowBranchCreate || !oid(input.baseHeadOid))
538
+ return unknown(input.actionId);
539
+ const base = await rest(`/${repoPath(input.grant)}/branches/${encodeURIComponent(workflow.baseBranch)}`, 'GET', input.token, undefined, input.signal, transport);
540
+ const baseBody = record(base?.body);
541
+ if (base?.status !== 200 || baseBody?.name !== workflow.baseBranch || record(baseBody.commit)?.sha !== input.baseHeadOid)
542
+ return { actionId: input.actionId, status: 'failed', reason: 'github-base-head-unconfirmed' };
543
+ const reply = await rest(`/${repoPath(input.grant)}/git/refs`, 'POST', input.token, { ref: `refs/heads/${input.grant.branch}`, sha: input.baseHeadOid }, input.signal, transport);
544
+ const body = record(reply?.body);
545
+ return reply?.status === 201 && body?.ref === `refs/heads/${input.grant.branch}` && record(body.object)?.sha === input.baseHeadOid
546
+ ? { actionId: input.actionId, status: 'succeeded', branch: input.grant.branch } : unknown(input.actionId);
547
+ }
548
+ export async function createPullRequestOnGitHub(input, transport) {
549
+ const workflow = input.grant.repoWorkflow;
550
+ if (!workflow?.allowPullRequest || !oid(input.expectedHeadOid))
551
+ return unknown(input.actionId);
552
+ // GitHub's PR API offers no atomic expected-head CAS. A mismatched receipt
553
+ // stays unknown; it must not be retried as if no PR had been created.
554
+ const reply = await rest(`/${repoPath(input.grant)}/pulls`, 'POST', input.token, { title: input.title, body: input.body, head: input.grant.branch, base: workflow.baseBranch }, input.signal, transport);
555
+ const body = scopedPr(reply?.body, input.grant, undefined, input.expectedHeadOid);
556
+ return reply?.status === 201 && body ? { actionId: input.actionId, status: 'succeeded', pullRequestNumber: body.number } : unknown(input.actionId);
557
+ }
558
+ export async function inspectGitHub(input, transport) {
559
+ const isPr = ['pull-request', 'checks', 'reviews'].includes(input.kind);
560
+ const suffix = input.kind === 'repository' ? '' : input.kind === 'branch' ? `/branches/${encodeURIComponent(input.grant.branch)}`
561
+ : input.kind === 'file' && input.path && input.grant.paths.includes(input.path) ? `/contents/${input.path.split('/').map(encodeURIComponent).join('/')}?ref=${encodeURIComponent(input.grant.branch)}`
562
+ : isPr && input.grant.repoWorkflow && positiveInteger(input.pullRequestNumber) ? `/pulls/${input.pullRequestNumber}` : undefined;
563
+ if (suffix === undefined)
564
+ return undefined;
565
+ const reply = await rest(`/${repoPath(input.grant)}${suffix}`, 'GET', input.token, undefined, input.signal, transport);
566
+ const body = record(reply?.body);
567
+ if (reply?.status !== 200 || !body)
568
+ return undefined;
569
+ if (input.kind === 'repository')
570
+ return body.full_name === input.grant.repository
571
+ ? { observed: { full_name: input.grant.repository, untrusted: true } } : undefined;
572
+ if (input.kind === 'branch') {
573
+ const commit = record(body.commit);
574
+ return body.name === input.grant.branch && oid(commit?.sha)
575
+ ? { observed: { name: input.grant.branch, commit: { sha: commit.sha }, untrusted: true } } : undefined;
576
+ }
577
+ const pullRequest = isPr ? pullRequestObservation(body, input.grant, input.pullRequestNumber) : undefined;
578
+ if (isPr && !pullRequest)
579
+ return undefined;
580
+ if (input.kind === 'file') {
581
+ if (body.path !== input.path || body.type !== 'file' || body.encoding !== 'base64' || typeof body.content !== 'string' || !oid(body.sha) || typeof body.size !== 'number' || body.size < 0 || body.size > 65_536)
582
+ return undefined;
583
+ const encoded = body.content.replace(/\n/g, '');
584
+ const content = Buffer.from(encoded, 'base64');
585
+ if (content.length !== body.size || content.toString('base64') !== encoded || Buffer.from(content.toString('utf8')).compare(content) !== 0 || content.toString('utf8').includes(input.token))
586
+ return undefined;
587
+ return { observed: { path: body.path, sha: body.sha, content: content.toString('utf8'), untrusted: true } };
588
+ }
589
+ if (input.kind === 'pull-request')
590
+ return { observed: pullRequest };
591
+ if (input.kind === 'checks' || input.kind === 'reviews') {
592
+ const sha = pullRequest.head.sha;
593
+ const maximum = input.kind === 'checks' ? 20 : 30;
594
+ const tail = input.kind === 'checks' ? `/commits/${sha}/check-runs?per_page=${maximum}` : `/pulls/${input.pullRequestNumber}/reviews?per_page=${maximum}`;
595
+ const list = await rest(`/${repoPath(input.grant)}${tail}`, 'GET', input.token, undefined, input.signal, transport);
596
+ if (list?.status !== 200)
597
+ return undefined;
598
+ const items = input.kind === 'checks' ? record(list.body)?.check_runs : list.body;
599
+ if (!Array.isArray(items) || items.length > maximum)
600
+ return undefined;
601
+ const total = input.kind === 'checks' ? record(list.body)?.total_count : undefined;
602
+ if (input.kind === 'checks' && (typeof total !== 'number' || !Number.isSafeInteger(total) || total < items.length))
603
+ return undefined;
604
+ const projected = input.kind === 'checks' ? items.map(item => checkObservation(item, sha)) : items.map(reviewObservation);
605
+ if (projected.some(item => item === undefined))
606
+ return undefined;
607
+ return { observed: { pullRequest, headOid: sha, items: projected, truncated: list.hasNextPage || (typeof total === 'number' && total > items.length) || items.length === maximum, untrusted: true } };
608
+ }
609
+ return undefined;
610
+ }
611
+ //# sourceMappingURL=github.js.map