@haystackeditor/cli 0.15.29 → 0.15.31
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.
- package/dist/assets/telemetry/browser-runtime.js +148 -2
- package/dist/commands/design-verify.d.ts +2 -0
- package/dist/commands/design-verify.js +27 -1
- package/dist/commands/precompute-delivery-contract.d.ts +68 -0
- package/dist/commands/precompute-delivery-contract.js +1 -0
- package/dist/commands/precompute-delivery-worker.d.ts +1 -0
- package/dist/commands/precompute-delivery-worker.js +68 -0
- package/dist/commands/precompute-delivery.d.ts +20 -0
- package/dist/commands/precompute-delivery.js +319 -0
- package/dist/commands/verify-explore.d.ts +14 -0
- package/dist/commands/verify-explore.js +180 -0
- package/dist/commands/verify-history.d.ts +14 -0
- package/dist/commands/verify-history.js +152 -0
- package/dist/commands/verify-hosted.d.ts +12 -2
- package/dist/commands/verify-hosted.js +305 -50
- package/dist/commands/verify-precompute.d.ts +12 -1
- package/dist/commands/verify-precompute.js +135 -27
- package/dist/commands/verify.d.ts +6 -0
- package/dist/commands/verify.js +22 -0
- package/dist/index.js +71 -5
- package/dist/types/verify-history.d.ts +38 -0
- package/dist/types/verify-history.js +1 -0
- package/dist/utils/design-verifier-api.d.ts +8 -0
- package/dist/utils/design-verifier-api.js +23 -0
- package/dist/utils/design-verifier-history.d.ts +46 -0
- package/dist/utils/design-verifier-history.js +79 -0
- package/dist/utils/design-verifier-result.d.ts +17 -0
- package/dist/utils/design-verifier-result.js +80 -1
- package/dist/utils/git.js +5 -1
- package/dist/utils/telemetry.js +8 -2
- package/package.json +1 -1
- package/schemas/cloud-verifier.v1.json +108 -45
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
import { execFileSync, spawn } from 'node:child_process';
|
|
2
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
3
|
+
import { closeSync, constants, fchmodSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync, } from 'node:fs';
|
|
4
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
const requestFile = 'request.json';
|
|
7
|
+
const receiptFile = 'receipt.json';
|
|
8
|
+
const deliverySchema = 'precompute-delivery-v1';
|
|
9
|
+
const windows = process.platform === 'win32';
|
|
10
|
+
// Windows mode bits do not represent access control. Pass paths as data to a
|
|
11
|
+
// fixed script; never interpolate repository paths into PowerShell source.
|
|
12
|
+
function windowsAccess(paths, operation) {
|
|
13
|
+
const script = `
|
|
14
|
+
$ErrorActionPreference = 'Stop'
|
|
15
|
+
# A parent pwsh process can export incompatible PowerShell 7 module paths.
|
|
16
|
+
$env:PSModulePath = [System.IO.Path]::Combine($PSHOME, 'Modules')
|
|
17
|
+
$sid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User
|
|
18
|
+
$paths = ConvertFrom-Json -InputObject $env:HAYSTACK_DELIVERY_ACL_PATHS
|
|
19
|
+
foreach ($path in $paths) {
|
|
20
|
+
$item = Get-Item -LiteralPath $path -Force
|
|
21
|
+
if (($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { throw 'Reparse point rejected' }
|
|
22
|
+
if ($env:HAYSTACK_DELIVERY_ACL_OPERATION -eq 'protect') {
|
|
23
|
+
if (-not $item.PSIsContainer) { throw 'Expected directory' }
|
|
24
|
+
$acl = New-Object System.Security.AccessControl.DirectorySecurity
|
|
25
|
+
$acl.SetOwner($sid)
|
|
26
|
+
$acl.SetAccessRuleProtection($true, $false)
|
|
27
|
+
$rule = New-Object System.Security.AccessControl.FileSystemAccessRule($sid, 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow')
|
|
28
|
+
$acl.AddAccessRule($rule)
|
|
29
|
+
Set-Acl -LiteralPath $path -AclObject $acl
|
|
30
|
+
}
|
|
31
|
+
if ($env:HAYSTACK_DELIVERY_ACL_OPERATION -eq 'own') {
|
|
32
|
+
$acl = Get-Acl -LiteralPath $path
|
|
33
|
+
$acl.SetOwner($sid)
|
|
34
|
+
Set-Acl -LiteralPath $path -AclObject $acl
|
|
35
|
+
}
|
|
36
|
+
$acl = Get-Acl -LiteralPath $path
|
|
37
|
+
if ($acl.GetOwner([System.Security.Principal.SecurityIdentifier]).Value -ne $sid.Value) { throw 'Unexpected owner' }
|
|
38
|
+
if ($item.PSIsContainer -and -not $acl.AreAccessRulesProtected) { throw 'Unprotected directory' }
|
|
39
|
+
$allowed = $false
|
|
40
|
+
foreach ($rule in $acl.GetAccessRules($true, $true, [System.Security.Principal.SecurityIdentifier])) {
|
|
41
|
+
if ($rule.AccessControlType -eq 'Allow') {
|
|
42
|
+
if ($rule.IdentityReference.Value -ne $sid.Value) { throw 'Unexpected access rule' }
|
|
43
|
+
if (($rule.FileSystemRights -band [System.Security.AccessControl.FileSystemRights]::ReadData) -ne 0) { $allowed = $true }
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if (-not $allowed) { throw 'Missing owner access' }
|
|
47
|
+
}
|
|
48
|
+
`;
|
|
49
|
+
execFileSync('powershell.exe', ['-NoLogo', '-NoProfile', '-NonInteractive',
|
|
50
|
+
'-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')], {
|
|
51
|
+
env: { ...process.env, HAYSTACK_DELIVERY_ACL_PATHS: JSON.stringify(paths),
|
|
52
|
+
HAYSTACK_DELIVERY_ACL_OPERATION: operation },
|
|
53
|
+
stdio: 'ignore', windowsHide: true, timeout: 2000,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
function privateDirectory(path) {
|
|
57
|
+
mkdirSync(path, { recursive: true, mode: 0o700 });
|
|
58
|
+
if (!lstatSync(path).isDirectory())
|
|
59
|
+
throw new Error('Delivery directory is unavailable.');
|
|
60
|
+
// The complete directory chain receives one protected ACL before capture.
|
|
61
|
+
if (windows)
|
|
62
|
+
return;
|
|
63
|
+
const descriptor = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
64
|
+
try {
|
|
65
|
+
fchmodSync(descriptor, 0o700);
|
|
66
|
+
fsyncSync(descriptor);
|
|
67
|
+
}
|
|
68
|
+
finally {
|
|
69
|
+
closeSync(descriptor);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
function syncDirectory(path) {
|
|
73
|
+
// Node cannot flush a Windows directory through a read-only file descriptor.
|
|
74
|
+
if (windows)
|
|
75
|
+
return;
|
|
76
|
+
const descriptor = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
77
|
+
try {
|
|
78
|
+
fsyncSync(descriptor);
|
|
79
|
+
}
|
|
80
|
+
finally {
|
|
81
|
+
closeSync(descriptor);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
function publishJson(path, value, immutable = false) {
|
|
85
|
+
const temporary = join(dirname(path), `.${basename(path)}-${randomUUID()}.tmp`);
|
|
86
|
+
const descriptor = openSync(temporary, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600);
|
|
87
|
+
try {
|
|
88
|
+
try {
|
|
89
|
+
writeFileSync(descriptor, JSON.stringify(value) + '\n');
|
|
90
|
+
if (immutable && !windows)
|
|
91
|
+
fchmodSync(descriptor, 0o400);
|
|
92
|
+
fsyncSync(descriptor);
|
|
93
|
+
}
|
|
94
|
+
finally {
|
|
95
|
+
closeSync(descriptor);
|
|
96
|
+
}
|
|
97
|
+
renameSync(temporary, path);
|
|
98
|
+
syncDirectory(dirname(path));
|
|
99
|
+
}
|
|
100
|
+
finally {
|
|
101
|
+
rmSync(temporary, { force: true });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
function object(value) {
|
|
105
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
106
|
+
}
|
|
107
|
+
function onlyKeys(value, keys) {
|
|
108
|
+
return Object.keys(value).length === keys.length && keys.every(key => Object.hasOwn(value, key));
|
|
109
|
+
}
|
|
110
|
+
function characters(value, allowed, length) {
|
|
111
|
+
return typeof value === 'string' && value.length > 0
|
|
112
|
+
&& (length === undefined || value.length === length)
|
|
113
|
+
&& [...value].every(character => allowed.includes(character));
|
|
114
|
+
}
|
|
115
|
+
function isDeliveryId(value) {
|
|
116
|
+
if (typeof value !== 'string')
|
|
117
|
+
return false;
|
|
118
|
+
const parts = value.split('-');
|
|
119
|
+
return parts.length === 5 && parts.every((part, index) => characters(part, '0123456789abcdef', [8, 4, 4, 4, 12][index]));
|
|
120
|
+
}
|
|
121
|
+
function validateRequest(value) {
|
|
122
|
+
if (!object(value) || !onlyKeys(value, ['schemaVersion', 'deliveryId', 'request'])
|
|
123
|
+
|| value.schemaVersion !== deliverySchema || !isDeliveryId(value.deliveryId)
|
|
124
|
+
|| !object(value.request))
|
|
125
|
+
throw new Error('Invalid captured delivery.');
|
|
126
|
+
const request = value.request;
|
|
127
|
+
const component = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_.-';
|
|
128
|
+
if (!onlyKeys(request, ['owner', 'repository', 'baseCommit', 'workCommit', 'observedAt',
|
|
129
|
+
'cacheKey', 'patchGzBase64', 'patchSha256'])
|
|
130
|
+
|| !characters(request.owner, component) || request.owner.length > 100
|
|
131
|
+
|| !characters(request.repository, component) || request.repository.length > 100
|
|
132
|
+
|| !characters(request.baseCommit, '0123456789abcdef', 40)
|
|
133
|
+
|| !characters(request.workCommit, '0123456789abcdef', 40)
|
|
134
|
+
|| !characters(request.patchSha256, '0123456789abcdef', 64)
|
|
135
|
+
|| typeof request.observedAt !== 'number' || !Number.isSafeInteger(request.observedAt)
|
|
136
|
+
|| request.observedAt < 0 || typeof request.cacheKey !== 'string' || request.cacheKey.length === 0
|
|
137
|
+
|| typeof request.patchGzBase64 !== 'string')
|
|
138
|
+
throw new Error('Invalid captured request.');
|
|
139
|
+
const patch = Buffer.from(request.patchGzBase64, 'base64');
|
|
140
|
+
if (patch.toString('base64') !== request.patchGzBase64
|
|
141
|
+
|| createHash('sha256').update(patch).digest('hex') !== request.patchSha256) {
|
|
142
|
+
throw new Error('Captured patch identity does not match.');
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
export class PrecomputeDeliveryAccessError extends Error {
|
|
146
|
+
delivery;
|
|
147
|
+
constructor(delivery) {
|
|
148
|
+
super('Captured request access was rejected.');
|
|
149
|
+
this.delivery = delivery;
|
|
150
|
+
this.name = 'PrecomputeDeliveryAccessError';
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
export function readPrecomputeDelivery(requestPath) {
|
|
154
|
+
if (requestPath !== resolve(requestPath) || basename(requestPath) !== requestFile
|
|
155
|
+
|| !isDeliveryId(basename(dirname(requestPath))))
|
|
156
|
+
throw new Error('Invalid delivery path.');
|
|
157
|
+
// A redirected path must never carry authority to write a fallback receipt.
|
|
158
|
+
if (!lstatSync(dirname(requestPath)).isDirectory() || !lstatSync(requestPath).isFile()) {
|
|
159
|
+
throw new Error('Captured request path is unavailable.');
|
|
160
|
+
}
|
|
161
|
+
const descriptor = openSync(requestPath, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
162
|
+
let value;
|
|
163
|
+
let privateMode = false;
|
|
164
|
+
try {
|
|
165
|
+
const status = fstatSync(descriptor);
|
|
166
|
+
if (!status.isFile())
|
|
167
|
+
throw new Error('Captured request is unavailable.');
|
|
168
|
+
privateMode = (status.mode & 0o077) === 0;
|
|
169
|
+
value = JSON.parse(readFileSync(descriptor, 'utf8'));
|
|
170
|
+
}
|
|
171
|
+
finally {
|
|
172
|
+
closeSync(descriptor);
|
|
173
|
+
}
|
|
174
|
+
validateRequest(value);
|
|
175
|
+
if (value.deliveryId !== basename(dirname(requestPath)))
|
|
176
|
+
throw new Error('Delivery identity does not match.');
|
|
177
|
+
try {
|
|
178
|
+
if (windows)
|
|
179
|
+
windowsAccess([dirname(requestPath), requestPath], 'verify');
|
|
180
|
+
else if (!privateMode)
|
|
181
|
+
throw new Error('Captured request is not private.');
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
// This validated identity permits a failed receipt only. No caller may
|
|
185
|
+
// treat a rejected read as permission to send the captured request.
|
|
186
|
+
throw new PrecomputeDeliveryAccessError(value);
|
|
187
|
+
}
|
|
188
|
+
return value;
|
|
189
|
+
}
|
|
190
|
+
export function writePrecomputeDeliveryReceipt(requestPath, delivery, result) {
|
|
191
|
+
const receipt = {
|
|
192
|
+
schemaVersion: 'precompute-delivery-receipt-v1',
|
|
193
|
+
deliveryId: delivery.deliveryId,
|
|
194
|
+
repository: `${delivery.request.owner}/${delivery.request.repository}`,
|
|
195
|
+
sourceCommit: delivery.request.workCommit,
|
|
196
|
+
updatedAt: new Date().toISOString(),
|
|
197
|
+
requestIdentity: {
|
|
198
|
+
baseCommit: delivery.request.baseCommit, workCommit: delivery.request.workCommit,
|
|
199
|
+
observedAt: delivery.request.observedAt, cacheKey: delivery.request.cacheKey,
|
|
200
|
+
patchSha256: delivery.request.patchSha256,
|
|
201
|
+
},
|
|
202
|
+
...result,
|
|
203
|
+
};
|
|
204
|
+
publishJson(join(dirname(requestPath), receiptFile), receipt);
|
|
205
|
+
if (result.status !== 'pending') {
|
|
206
|
+
rmSync(requestPath, { force: true });
|
|
207
|
+
syncDirectory(dirname(requestPath));
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
function pruneTerminalDeliveries(metadata, deliveries) {
|
|
211
|
+
const pointer = JSON.parse(readFileSync(join(metadata, 'precompute-latest.json'), 'utf8'));
|
|
212
|
+
if (!object(pointer) || pointer.schemaVersion !== 'precompute-delivery-pointer-v1'
|
|
213
|
+
|| !isDeliveryId(pointer.deliveryId))
|
|
214
|
+
return;
|
|
215
|
+
const terminal = [];
|
|
216
|
+
for (const entry of readdirSync(deliveries, { withFileTypes: true })) {
|
|
217
|
+
if (!entry.isDirectory() || !isDeliveryId(entry.name) || entry.name === pointer.deliveryId)
|
|
218
|
+
continue;
|
|
219
|
+
const path = join(deliveries, entry.name);
|
|
220
|
+
try {
|
|
221
|
+
const descriptor = openSync(join(path, receiptFile), constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
222
|
+
let receipt;
|
|
223
|
+
try {
|
|
224
|
+
receipt = JSON.parse(readFileSync(descriptor, 'utf8'));
|
|
225
|
+
}
|
|
226
|
+
finally {
|
|
227
|
+
closeSync(descriptor);
|
|
228
|
+
}
|
|
229
|
+
if (!object(receipt) || receipt.schemaVersion !== 'precompute-delivery-receipt-v1'
|
|
230
|
+
|| receipt.deliveryId !== entry.name
|
|
231
|
+
|| !['acknowledged', 'failed', 'timed-out'].includes(String(receipt.status))
|
|
232
|
+
|| typeof receipt.updatedAt !== 'string')
|
|
233
|
+
continue;
|
|
234
|
+
const updatedAt = Date.parse(receipt.updatedAt);
|
|
235
|
+
if (!Number.isFinite(updatedAt))
|
|
236
|
+
continue;
|
|
237
|
+
terminal.push({ path, deliveryId: entry.name, updatedAt });
|
|
238
|
+
}
|
|
239
|
+
catch {
|
|
240
|
+
// Unreadable or concurrently changing records are never deletion evidence.
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
terminal.sort((left, right) => right.updatedAt - left.updatedAt || right.deliveryId.localeCompare(left.deliveryId));
|
|
244
|
+
for (const entry of terminal.slice(50))
|
|
245
|
+
rmSync(entry.path, { recursive: true, force: true });
|
|
246
|
+
if (terminal.length > 50)
|
|
247
|
+
syncDirectory(deliveries);
|
|
248
|
+
}
|
|
249
|
+
/** An exclusive durable marker prevents a second invocation from posting this delivery. */
|
|
250
|
+
export function claimPrecomputeDelivery(requestPath) {
|
|
251
|
+
const path = join(dirname(requestPath), 'sender-started');
|
|
252
|
+
const descriptor = openSync(path, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600);
|
|
253
|
+
try {
|
|
254
|
+
fsyncSync(descriptor);
|
|
255
|
+
}
|
|
256
|
+
finally {
|
|
257
|
+
closeSync(descriptor);
|
|
258
|
+
}
|
|
259
|
+
syncDirectory(dirname(path));
|
|
260
|
+
}
|
|
261
|
+
export const startPrecomputeDelivery = async (metadataDirectory, request) => {
|
|
262
|
+
const metadata = resolve(metadataDirectory);
|
|
263
|
+
privateDirectory(metadata);
|
|
264
|
+
syncDirectory(dirname(metadata));
|
|
265
|
+
const deliveries = join(metadata, 'precompute-deliveries');
|
|
266
|
+
privateDirectory(deliveries);
|
|
267
|
+
syncDirectory(metadata);
|
|
268
|
+
const deliveryId = randomUUID();
|
|
269
|
+
const delivery = { schemaVersion: deliverySchema, deliveryId, request };
|
|
270
|
+
validateRequest(delivery);
|
|
271
|
+
const staging = join(deliveries, `.pending-${deliveryId}`);
|
|
272
|
+
const published = join(deliveries, deliveryId);
|
|
273
|
+
const requestPath = join(published, requestFile);
|
|
274
|
+
privateDirectory(staging);
|
|
275
|
+
let publishedRequest = false;
|
|
276
|
+
try {
|
|
277
|
+
if (windows)
|
|
278
|
+
windowsAccess([metadata, deliveries, staging], 'protect');
|
|
279
|
+
publishJson(join(staging, requestFile), delivery, true);
|
|
280
|
+
// Elevated Windows tokens can default new file ownership to Administrators.
|
|
281
|
+
if (windows)
|
|
282
|
+
windowsAccess([join(staging, requestFile)], 'own');
|
|
283
|
+
writePrecomputeDeliveryReceipt(join(staging, requestFile), delivery, { status: 'pending' });
|
|
284
|
+
renameSync(staging, published);
|
|
285
|
+
syncDirectory(deliveries);
|
|
286
|
+
publishedRequest = true;
|
|
287
|
+
const pointer = {
|
|
288
|
+
schemaVersion: 'precompute-delivery-pointer-v1', deliveryId,
|
|
289
|
+
repository: `${request.owner}/${request.repository}`, sourceCommit: request.workCommit,
|
|
290
|
+
};
|
|
291
|
+
publishJson(join(metadata, 'precompute-latest.json'), pointer);
|
|
292
|
+
try {
|
|
293
|
+
pruneTerminalDeliveries(metadata, deliveries);
|
|
294
|
+
}
|
|
295
|
+
catch {
|
|
296
|
+
// Retain any undeletable old records. Housekeeping cannot prevent the
|
|
297
|
+
// newly captured request from reaching its sender.
|
|
298
|
+
}
|
|
299
|
+
const workerPath = fileURLToPath(new URL('./precompute-delivery-worker.js', import.meta.url));
|
|
300
|
+
if (!lstatSync(workerPath).isFile())
|
|
301
|
+
throw new Error('Detached sender is unavailable.');
|
|
302
|
+
await new Promise((resolveSpawn, rejectSpawn) => {
|
|
303
|
+
const child = spawn(process.execPath, [workerPath, requestPath], {
|
|
304
|
+
detached: true, stdio: 'ignore', env: process.env, windowsHide: true,
|
|
305
|
+
});
|
|
306
|
+
child.once('error', () => rejectSpawn(new Error('Detached sender could not start.')));
|
|
307
|
+
child.once('spawn', () => { child.unref(); resolveSpawn(); });
|
|
308
|
+
});
|
|
309
|
+
return { deliveryId, requestPath };
|
|
310
|
+
}
|
|
311
|
+
catch (error) {
|
|
312
|
+
if (publishedRequest)
|
|
313
|
+
writePrecomputeDeliveryReceipt(requestPath, delivery, { status: 'failed', reason: 'sender-not-started' });
|
|
314
|
+
throw error;
|
|
315
|
+
}
|
|
316
|
+
finally {
|
|
317
|
+
rmSync(staging, { recursive: true, force: true });
|
|
318
|
+
}
|
|
319
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export interface VerifyExploreOptions {
|
|
2
|
+
repo: string;
|
|
3
|
+
json?: boolean;
|
|
4
|
+
side?: string;
|
|
5
|
+
execJson?: string;
|
|
6
|
+
cwd?: string;
|
|
7
|
+
commandTimeout?: string;
|
|
8
|
+
close?: boolean;
|
|
9
|
+
action?: string;
|
|
10
|
+
requestId?: string;
|
|
11
|
+
wait?: boolean;
|
|
12
|
+
}
|
|
13
|
+
export declare function verifyCasesCommand(runId: string, options: VerifyExploreOptions): Promise<void>;
|
|
14
|
+
export declare function verifyExploreCommand(runId: string, caseId: string, options: VerifyExploreOptions): Promise<void>;
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { resolveAuthContext } from '../utils/auth.js';
|
|
3
|
+
import { haystackJson } from '../utils/haystack-api.js';
|
|
4
|
+
const RUN_ID = /^cv_[0-9a-f]{48}$/;
|
|
5
|
+
const CASE_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
|
6
|
+
const ACTION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
|
7
|
+
const TERMINAL = new Set(['complete', 'errored', 'uncertain']);
|
|
8
|
+
function object(value) {
|
|
9
|
+
return !!value && typeof value === 'object' && !Array.isArray(value);
|
|
10
|
+
}
|
|
11
|
+
function timestamp(value) {
|
|
12
|
+
return typeof value === 'string' && Number.isFinite(Date.parse(value));
|
|
13
|
+
}
|
|
14
|
+
function terminalSafe(value) {
|
|
15
|
+
return value.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]/g, '');
|
|
16
|
+
}
|
|
17
|
+
async function context(runId, repository) {
|
|
18
|
+
if (!RUN_ID.test(runId))
|
|
19
|
+
throw new Error('Use the exact cv_… run ID from verification.');
|
|
20
|
+
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository || '')) {
|
|
21
|
+
throw new Error('--repo must be owner/repository.');
|
|
22
|
+
}
|
|
23
|
+
const [owner, repo] = repository.split('/');
|
|
24
|
+
if ([owner, repo].some(part => part === '.' || part === '..'))
|
|
25
|
+
throw new Error('Invalid repository.');
|
|
26
|
+
const { token } = await resolveAuthContext({ owner, repo });
|
|
27
|
+
return {
|
|
28
|
+
token,
|
|
29
|
+
path: `/api/agent/cloud-verifier/runs/${runId}/cases`,
|
|
30
|
+
query: `?repository=${encodeURIComponent(repository)}`,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
export async function verifyCasesCommand(runId, options) {
|
|
34
|
+
const ctx = await context(runId, options.repo);
|
|
35
|
+
const payload = await haystackJson(ctx.path + ctx.query, ctx.token, {
|
|
36
|
+
signal: AbortSignal.timeout(30_000),
|
|
37
|
+
});
|
|
38
|
+
if (!object(payload) || !Array.isArray(payload.cases)
|
|
39
|
+
|| payload.cases.some(item => !object(item) || item.runId !== runId
|
|
40
|
+
|| typeof item.caseId !== 'string' || !CASE_ID.test(item.caseId)
|
|
41
|
+
|| (item.side !== 'base' && item.side !== 'head')
|
|
42
|
+
|| typeof item.revisionSha !== 'string' || !/^[0-9a-f]{40}$/.test(item.revisionSha)
|
|
43
|
+
|| !timestamp(item.expiresAt) || !Array.isArray(item.capabilities)
|
|
44
|
+
|| item.capabilities.join(',') !== 'open,exec,close')) {
|
|
45
|
+
throw new Error('Server returned an invalid retained-case catalog.');
|
|
46
|
+
}
|
|
47
|
+
const cases = payload.cases;
|
|
48
|
+
const seen = new Set();
|
|
49
|
+
for (const item of cases) {
|
|
50
|
+
const key = JSON.stringify([item.runId, item.caseId, item.side]);
|
|
51
|
+
if (seen.has(key))
|
|
52
|
+
throw new Error('Server returned duplicate case identities.');
|
|
53
|
+
seen.add(key);
|
|
54
|
+
}
|
|
55
|
+
if (options.json) {
|
|
56
|
+
console.log(JSON.stringify({ runId, cases }));
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (!cases.length) {
|
|
60
|
+
console.log('No saved cases are available. They appear after completion and expire 24 hours later.');
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
for (const item of cases)
|
|
64
|
+
console.log(`${item.caseId} ${item.side} — available until ${item.expiresAt}`);
|
|
65
|
+
console.log(`Open a copy: haystack verify explore ${runId} CASE_ID --repo ${options.repo} --side head`);
|
|
66
|
+
}
|
|
67
|
+
function parseAction(payload, runId, caseId, side, actionId) {
|
|
68
|
+
if (!object(payload) || !object(payload.action))
|
|
69
|
+
throw new Error('Invalid action response.');
|
|
70
|
+
const action = payload.action;
|
|
71
|
+
if (action.runId !== runId || action.caseId !== caseId || action.side !== side || action.actionId !== actionId
|
|
72
|
+
|| typeof action.state !== 'string'
|
|
73
|
+
|| !['queued', 'claimed', 'running', 'complete', 'errored', 'uncertain'].includes(action.state)
|
|
74
|
+
|| !timestamp(action.expiresAt)
|
|
75
|
+
|| (action.error !== null && typeof action.error !== 'string')
|
|
76
|
+
|| (action.output !== null && (!object(action.output)
|
|
77
|
+
|| typeof action.output.stdout !== 'string' || typeof action.output.stderr !== 'string'
|
|
78
|
+
|| typeof action.output.exitCode !== 'number' || !Number.isInteger(action.output.exitCode)))) {
|
|
79
|
+
throw new Error('Server returned an action for the wrong case or an invalid result.');
|
|
80
|
+
}
|
|
81
|
+
return action;
|
|
82
|
+
}
|
|
83
|
+
function newRequest(options, actionId) {
|
|
84
|
+
if (options.close) {
|
|
85
|
+
if (options.execJson !== undefined || options.cwd || options.commandTimeout) {
|
|
86
|
+
throw new Error('--close cannot be combined with a command.');
|
|
87
|
+
}
|
|
88
|
+
return { actionId, kind: 'close' };
|
|
89
|
+
}
|
|
90
|
+
if (options.execJson === undefined) {
|
|
91
|
+
if (options.cwd || options.commandTimeout)
|
|
92
|
+
throw new Error('--cwd and --command-timeout require --exec-json.');
|
|
93
|
+
return { actionId, kind: 'open' };
|
|
94
|
+
}
|
|
95
|
+
const argv = JSON.parse(options.execJson);
|
|
96
|
+
if (!Array.isArray(argv) || argv.length < 1 || argv.length > 128 || !argv[0]
|
|
97
|
+
|| argv.some(arg => typeof arg !== 'string' || arg.includes('\0'))) {
|
|
98
|
+
throw new Error('--exec-json must contain 1–128 string arguments with a non-empty executable.');
|
|
99
|
+
}
|
|
100
|
+
const cwd = options.cwd ?? '/home/user/pj';
|
|
101
|
+
const timeoutMs = Number(options.commandTimeout ?? '60') * 1_000;
|
|
102
|
+
if (!cwd.startsWith('/') || /[\0\r\n]/.test(cwd) || cwd.length > 4_096) {
|
|
103
|
+
throw new Error('--cwd must be an absolute path inside the sandbox.');
|
|
104
|
+
}
|
|
105
|
+
if (!Number.isInteger(timeoutMs) || timeoutMs < 1_000 || timeoutMs > 300_000) {
|
|
106
|
+
throw new Error('--command-timeout must be between 1 and 300 seconds.');
|
|
107
|
+
}
|
|
108
|
+
const request = { actionId, kind: 'exec', argv, cwd, timeoutMs };
|
|
109
|
+
if (Buffer.byteLength(JSON.stringify(request)) > 32 * 1_024)
|
|
110
|
+
throw new Error('Command request exceeds 32 KiB.');
|
|
111
|
+
return request;
|
|
112
|
+
}
|
|
113
|
+
export async function verifyExploreCommand(runId, caseId, options) {
|
|
114
|
+
if (!CASE_ID.test(caseId))
|
|
115
|
+
throw new Error('Use the exact case ID from `haystack verify cases`.');
|
|
116
|
+
const side = options.side ?? 'head';
|
|
117
|
+
if (side !== 'base' && side !== 'head')
|
|
118
|
+
throw new Error('--side must be base or head.');
|
|
119
|
+
if (options.action && (options.requestId || options.execJson !== undefined || options.close || options.cwd || options.commandTimeout)) {
|
|
120
|
+
throw new Error('--action reads an existing action; do not combine it with a new command.');
|
|
121
|
+
}
|
|
122
|
+
const actionId = options.action ?? options.requestId ?? randomUUID();
|
|
123
|
+
if (!ACTION_ID.test(actionId))
|
|
124
|
+
throw new Error('Action/request ID must be a UUID v4.');
|
|
125
|
+
const request = options.action ? null : newRequest(options, actionId);
|
|
126
|
+
const ctx = await context(runId, options.repo);
|
|
127
|
+
const actionPath = `${ctx.path}/${encodeURIComponent(caseId)}/${side}/actions`;
|
|
128
|
+
const pollCommand = `haystack verify explore ${runId} ${caseId} --repo ${options.repo} --side ${side} --action ${actionId}`;
|
|
129
|
+
const read = async () => parseAction(await haystackJson(`${actionPath}/${actionId}${ctx.query}`, ctx.token, {
|
|
130
|
+
signal: AbortSignal.timeout(30_000),
|
|
131
|
+
}), runId, caseId, side, actionId);
|
|
132
|
+
let action;
|
|
133
|
+
try {
|
|
134
|
+
action = request
|
|
135
|
+
? parseAction(await haystackJson(actionPath + ctx.query, ctx.token, {
|
|
136
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
137
|
+
body: JSON.stringify(request), signal: AbortSignal.timeout(30_000),
|
|
138
|
+
}), runId, caseId, side, actionId)
|
|
139
|
+
: await read();
|
|
140
|
+
}
|
|
141
|
+
catch (error) {
|
|
142
|
+
throw new Error(`${error instanceof Error ? error.message : String(error)}\nCheck whether it was accepted before sending another command:\n${pollCommand}`);
|
|
143
|
+
}
|
|
144
|
+
if (options.wait !== false) {
|
|
145
|
+
const deadline = Date.now() + 10 * 60_000;
|
|
146
|
+
while (!TERMINAL.has(action.state) && Date.now() < deadline) {
|
|
147
|
+
await new Promise(resolve => setTimeout(resolve, 2_000));
|
|
148
|
+
try {
|
|
149
|
+
action = await read();
|
|
150
|
+
}
|
|
151
|
+
catch (error) {
|
|
152
|
+
throw new Error(`${String(error)}\nThe command was not resubmitted. Check it with:\n${pollCommand}`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (options.json)
|
|
157
|
+
console.log(JSON.stringify({ action }));
|
|
158
|
+
else {
|
|
159
|
+
console.log(`${caseId}/${side}: ${action.state}; available until ${action.expiresAt}`);
|
|
160
|
+
if (action.output) {
|
|
161
|
+
process.stdout.write(terminalSafe(action.output.stdout));
|
|
162
|
+
process.stderr.write(terminalSafe(action.output.stderr));
|
|
163
|
+
console.error(`Exit code: ${action.output.exitCode}`);
|
|
164
|
+
}
|
|
165
|
+
if (action.error)
|
|
166
|
+
console.error(terminalSafe(action.error));
|
|
167
|
+
if (!TERMINAL.has(action.state))
|
|
168
|
+
console.log(`Follow this action:\n${pollCommand}`);
|
|
169
|
+
else if (action.state === 'complete' && !action.output) {
|
|
170
|
+
console.log(options.close ? 'Working copy discarded. The saved case is unchanged.'
|
|
171
|
+
: 'Your working copy is ready. Add --exec-json to run commands in it.');
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
if (action.state === 'errored' || action.state === 'uncertain')
|
|
175
|
+
process.exitCode = 2;
|
|
176
|
+
else if (action.output?.exitCode)
|
|
177
|
+
process.exitCode = 1;
|
|
178
|
+
else if (!TERMINAL.has(action.state) && options.wait !== false)
|
|
179
|
+
process.exitCode = 2;
|
|
180
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { VerifyHistoryReport } from '../types/verify-history.js';
|
|
2
|
+
export interface VerifyHistoryOptions {
|
|
3
|
+
limit?: string;
|
|
4
|
+
account?: string;
|
|
5
|
+
json?: boolean;
|
|
6
|
+
}
|
|
7
|
+
/** Reject contradictory or mismatched data before printing health or findings. */
|
|
8
|
+
export declare function parseVerifyHistoryReport(value: unknown, expected: {
|
|
9
|
+
owner: string;
|
|
10
|
+
name: string;
|
|
11
|
+
limit: number;
|
|
12
|
+
}): VerifyHistoryReport;
|
|
13
|
+
export declare function formatVerifyHistory(report: VerifyHistoryReport): string;
|
|
14
|
+
export declare function verifyHistoryCommand(repository: string, options: VerifyHistoryOptions): Promise<void>;
|