@haystackeditor/cli 0.17.1 → 0.18.0
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/README.md +206 -24
- package/dist/commands/case-batch-contract.js +2 -2
- package/dist/commands/case-batch.js +7 -17
- package/dist/commands/crawl-contract.js +3 -0
- package/dist/commands/db-profile-contract.js +419 -0
- package/dist/commands/db-profile-database.js +289 -0
- package/dist/commands/db-profile-distinct.js +97 -0
- package/dist/commands/db-profile-owners.js +94 -0
- package/dist/commands/db-profile-privacy.js +450 -0
- package/dist/commands/db-profile-show.js +218 -0
- package/dist/commands/db-profile-sql-literals.js +140 -0
- package/dist/commands/db-profile-sql.js +362 -0
- package/dist/commands/db-profile-upload.js +88 -0
- package/dist/commands/db-profile.js +1020 -0
- package/dist/commands/fleet-policy-contract.js +28 -0
- package/dist/commands/fleet-policy.js +305 -0
- package/dist/commands/precompute-delivery-worker.js +17 -3
- package/dist/commands/precompute-delivery.js +16 -6
- package/dist/commands/verify-hosted.js +20 -0
- package/dist/commands/verify-precompute.js +140 -95
- package/dist/commands/verify.js +523 -52
- package/dist/index.js +172 -20
- package/dist/schema.js +1 -0
- package/dist/utils/verify-base.js +31 -0
- package/package.json +4 -1
- package/schemas/verify.v1.json +217 -0
- package/dist/commands/combination-search-hook-contract.js +0 -1
- package/dist/commands/combination-search-hook.js +0 -139
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** FIXED CONTRACT — FLEET PHASE 1 (2026-09-27): mirror of the fleet policy
|
|
2
|
+
* admin API for the published CLI, whose build cannot import repository files
|
|
3
|
+
* outside `src`. The authority is
|
|
4
|
+
* `infra/lambda/haystack-design-verifier-shared/fleet_policy_authority_contracts.ts`;
|
|
5
|
+
* this file restates only what `haystack admin fleet-policy` sends and reads.
|
|
6
|
+
* Policies are opaque JSON here: the worker is the only parser.
|
|
7
|
+
*
|
|
8
|
+
* The CLI carries no fleet credential. Its only credential is the operator's
|
|
9
|
+
* Cloudflare Access token, sent as `cf-access-token`.
|
|
10
|
+
*/
|
|
11
|
+
export const FLEET_POLICY_ADMIN_ROUTES = {
|
|
12
|
+
show: '/admin/fleet-policy/show',
|
|
13
|
+
set: '/admin/fleet-policy/set',
|
|
14
|
+
revoke: '/admin/fleet-policy/revoke',
|
|
15
|
+
history: '/admin/fleet-policy/history',
|
|
16
|
+
importKv: '/admin/fleet-policy/import-kv',
|
|
17
|
+
};
|
|
18
|
+
export const FLEET_POLICY_CLI_VERBS = ['show', 'diff', 'set', 'revoke', 'history', 'import-kv'];
|
|
19
|
+
export const FLEET_POLICY_CLI_EXIT = { ok: 0, error: 1, conflict: 3 };
|
|
20
|
+
export const FLEET_POLICY_DEFAULT_WORKER_URL = 'https://haystack-agent.akshaysg.workers.dev';
|
|
21
|
+
export const FLEET_POLICY_WORKER_URL_ENV = 'HAYSTACK_ADMIN_WORKER_URL';
|
|
22
|
+
export const FLEET_POLICY_ACCESS_HEADER = 'cf-access-token';
|
|
23
|
+
/** `owner/name` or `github.com:<id>`. */
|
|
24
|
+
export const FLEET_POLICY_REPOSITORY_ARGUMENT = /^(?:github\.com:[1-9][0-9]{0,19}|[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)$/;
|
|
25
|
+
export const FLEET_POLICY_ADMIN_ERROR_CODES = [
|
|
26
|
+
'invalid-request', 'invalid-policy', 'tenant-required', 'operator-identity-required', 'scope-not-found',
|
|
27
|
+
'epoch-conflict', 'scope-not-active', 'installation-required', 'binding-mismatch', 'github-unavailable',
|
|
28
|
+
];
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `haystack admin fleet-policy show|diff|set|revoke|history|import-kv`: the
|
|
3
|
+
* operator command for the fleet policy authority in D1 (FLEET PHASE 1,
|
|
4
|
+
* design Appendix A). The only credential is the operator's Cloudflare Access
|
|
5
|
+
* token from `cloudflared access token -app=<worker URL>`, sent as
|
|
6
|
+
* `cf-access-token`. Exit codes: 0 ok, 1 error, 3 on a 409 (epoch conflict).
|
|
7
|
+
*/
|
|
8
|
+
import { execFileSync } from 'node:child_process';
|
|
9
|
+
import { readFileSync } from 'node:fs';
|
|
10
|
+
import chalk from 'chalk';
|
|
11
|
+
import { FLEET_POLICY_ACCESS_HEADER, FLEET_POLICY_ADMIN_ROUTES, FLEET_POLICY_CLI_EXIT, FLEET_POLICY_DEFAULT_WORKER_URL, FLEET_POLICY_REPOSITORY_ARGUMENT, FLEET_POLICY_WORKER_URL_ENV, } from './fleet-policy-contract.js';
|
|
12
|
+
class UsageError extends Error {
|
|
13
|
+
}
|
|
14
|
+
function workerOrigin(options) {
|
|
15
|
+
const raw = options.workerUrl ?? process.env[FLEET_POLICY_WORKER_URL_ENV] ?? FLEET_POLICY_DEFAULT_WORKER_URL;
|
|
16
|
+
let url;
|
|
17
|
+
try {
|
|
18
|
+
url = new URL(raw);
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
throw new UsageError(`worker URL is not a URL: ${raw}`);
|
|
22
|
+
}
|
|
23
|
+
if (url.protocol !== 'https:' && url.hostname !== 'localhost' && url.hostname !== '127.0.0.1') {
|
|
24
|
+
throw new UsageError(`worker URL must be https: ${raw}`);
|
|
25
|
+
}
|
|
26
|
+
return url.origin;
|
|
27
|
+
}
|
|
28
|
+
/** Non-interactive: never opens a browser. A missing token means the operator
|
|
29
|
+
* logs in once with the printed command. */
|
|
30
|
+
function accessToken(origin) {
|
|
31
|
+
let output = '';
|
|
32
|
+
try {
|
|
33
|
+
output = execFileSync('cloudflared', ['access', 'token', `-app=${origin}`], {
|
|
34
|
+
encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 30_000,
|
|
35
|
+
}).trim();
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
output = '';
|
|
39
|
+
}
|
|
40
|
+
if (!/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(output)) {
|
|
41
|
+
throw new UsageError(`No Cloudflare Access token for ${origin}. Log in once, then re-run:\n cloudflared access login ${origin}`);
|
|
42
|
+
}
|
|
43
|
+
return output;
|
|
44
|
+
}
|
|
45
|
+
async function post(origin, route, body, token) {
|
|
46
|
+
const response = await fetch(`${origin}${route}`, {
|
|
47
|
+
method: 'POST',
|
|
48
|
+
headers: {
|
|
49
|
+
'Content-Type': 'application/json',
|
|
50
|
+
Accept: 'application/json',
|
|
51
|
+
'User-Agent': 'Haystack-CLI',
|
|
52
|
+
[FLEET_POLICY_ACCESS_HEADER]: token ?? accessToken(origin),
|
|
53
|
+
},
|
|
54
|
+
body: JSON.stringify(body),
|
|
55
|
+
redirect: 'manual',
|
|
56
|
+
signal: AbortSignal.timeout(120_000),
|
|
57
|
+
});
|
|
58
|
+
if (response.status >= 300 && response.status < 400) {
|
|
59
|
+
throw new UsageError(`Cloudflare Access refused the token. Log in again:\n cloudflared access login ${origin}`);
|
|
60
|
+
}
|
|
61
|
+
const text = await response.text();
|
|
62
|
+
let parsed = null;
|
|
63
|
+
try {
|
|
64
|
+
parsed = text ? JSON.parse(text) : null;
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
parsed = null;
|
|
68
|
+
}
|
|
69
|
+
if (response.status === 200 && parsed !== null)
|
|
70
|
+
return { kind: 'ok', body: parsed };
|
|
71
|
+
if (response.status === 409 && parsed !== null)
|
|
72
|
+
return { kind: 'conflict', body: parsed };
|
|
73
|
+
const record = parsed && typeof parsed === 'object' ? parsed : {};
|
|
74
|
+
return {
|
|
75
|
+
kind: 'error',
|
|
76
|
+
status: response.status,
|
|
77
|
+
error: typeof record.error === 'string' ? record.error : text.slice(0, 200) || `HTTP ${response.status}`,
|
|
78
|
+
...(typeof record.detail === 'string' ? { detail: record.detail } : {}),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
function reportFailure(result) {
|
|
82
|
+
if (result.kind === 'conflict') {
|
|
83
|
+
const c = result.body;
|
|
84
|
+
console.error(chalk.red(`${c.error}: ${c.tenantId} ${c.repositoryId} expected epoch ${c.expectedEpoch}, current epoch ${c.currentEpoch}`));
|
|
85
|
+
console.error(` active version: ${c.activeVersion ?? 'none'}`);
|
|
86
|
+
console.error(` active digest: ${c.activePolicySha256 ?? 'none'}`);
|
|
87
|
+
console.error(` revocation generation: ${c.revocationGeneration}`);
|
|
88
|
+
return FLEET_POLICY_CLI_EXIT.conflict;
|
|
89
|
+
}
|
|
90
|
+
console.error(chalk.red(`${result.error} (HTTP ${result.status})${result.detail ? `: ${result.detail}` : ''}`));
|
|
91
|
+
return FLEET_POLICY_CLI_EXIT.error;
|
|
92
|
+
}
|
|
93
|
+
function selector(repository, options) {
|
|
94
|
+
if (!FLEET_POLICY_REPOSITORY_ARGUMENT.test(repository)) {
|
|
95
|
+
throw new UsageError(`repository must be owner/name or github.com:<id> (got "${repository}")`);
|
|
96
|
+
}
|
|
97
|
+
return { repository, ...(options.tenant === undefined ? {} : { tenant: options.tenant }) };
|
|
98
|
+
}
|
|
99
|
+
function epochOption(value) {
|
|
100
|
+
if (value === undefined)
|
|
101
|
+
throw new UsageError('--expected-epoch is required (read it with `show`)');
|
|
102
|
+
if (!/^(0|[1-9][0-9]{0,15})$/.test(value))
|
|
103
|
+
throw new UsageError(`--expected-epoch must be a non-negative integer (got "${value}")`);
|
|
104
|
+
return Number(value);
|
|
105
|
+
}
|
|
106
|
+
function reasonOption(value) {
|
|
107
|
+
if (value === undefined || value.trim() === '')
|
|
108
|
+
throw new UsageError('--reason is required');
|
|
109
|
+
return value;
|
|
110
|
+
}
|
|
111
|
+
function policyFile(file) {
|
|
112
|
+
if (!file)
|
|
113
|
+
throw new UsageError('--file <policy.json> is required');
|
|
114
|
+
try {
|
|
115
|
+
return JSON.parse(readFileSync(file, 'utf8'));
|
|
116
|
+
}
|
|
117
|
+
catch (error) {
|
|
118
|
+
throw new UsageError(`cannot read ${file} as JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
function bindingText(binding) {
|
|
122
|
+
return binding
|
|
123
|
+
? `installation ${binding.installationId}, account ${binding.accountId}, repository ${binding.githubRepositoryId}`
|
|
124
|
+
: 'none';
|
|
125
|
+
}
|
|
126
|
+
async function run(action) {
|
|
127
|
+
try {
|
|
128
|
+
return await action();
|
|
129
|
+
}
|
|
130
|
+
catch (error) {
|
|
131
|
+
console.error(chalk.red(error instanceof Error ? error.message : String(error)));
|
|
132
|
+
return FLEET_POLICY_CLI_EXIT.error;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
function printScope(view) {
|
|
136
|
+
console.log(chalk.bold(`${view.tenantId} ${view.repositoryId}${view.repositoryFullName ? ` (${view.repositoryFullName})` : ''}`));
|
|
137
|
+
console.log(` status: ${view.status} epoch: ${view.epoch} revocation generation: ${view.revocationGeneration}`);
|
|
138
|
+
if (!view.active)
|
|
139
|
+
return;
|
|
140
|
+
console.log(` version: ${view.active.version} kind: ${view.active.scopeKind} binding: ${bindingText(view.active.binding)}`);
|
|
141
|
+
console.log(` digest: ${view.active.policySha256}`);
|
|
142
|
+
console.log(` set by ${view.active.actor} at ${view.active.createdAt}: ${view.active.reason}`);
|
|
143
|
+
console.log(JSON.stringify(view.active.policy, null, 2).split('\n').map(line => ` ${line}`).join('\n'));
|
|
144
|
+
}
|
|
145
|
+
export function fleetPolicyShowCommand(repository, options) {
|
|
146
|
+
return run(async () => {
|
|
147
|
+
const result = await post(workerOrigin(options), FLEET_POLICY_ADMIN_ROUTES.show, { scope: selector(repository, options) });
|
|
148
|
+
if (result.kind !== 'ok')
|
|
149
|
+
return reportFailure(result);
|
|
150
|
+
if (options.json)
|
|
151
|
+
console.log(JSON.stringify(result.body, null, 2));
|
|
152
|
+
else
|
|
153
|
+
result.body.scopes.forEach(printScope);
|
|
154
|
+
return FLEET_POLICY_CLI_EXIT.ok;
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
/** Dotted paths of every leaf, for the field diff. */
|
|
158
|
+
function leaves(value, path = '', out = new Map()) {
|
|
159
|
+
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
|
|
160
|
+
for (const key of Object.keys(value).sort())
|
|
161
|
+
leaves(value[key], path ? `${path}.${key}` : key, out);
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
164
|
+
out.set(path, JSON.stringify(value));
|
|
165
|
+
}
|
|
166
|
+
return out;
|
|
167
|
+
}
|
|
168
|
+
export function fleetPolicyDiffCommand(repository, options) {
|
|
169
|
+
return run(async () => {
|
|
170
|
+
const origin = workerOrigin(options);
|
|
171
|
+
const scope = selector(repository, options);
|
|
172
|
+
const policy = policyFile(options.file);
|
|
173
|
+
const token = accessToken(origin);
|
|
174
|
+
const dryRun = (expectedEpoch) => post(origin, FLEET_POLICY_ADMIN_ROUTES.set, {
|
|
175
|
+
scope, policy, expectedEpoch, reason: 'diff (dry run)', dryRun: true,
|
|
176
|
+
}, token);
|
|
177
|
+
// The dry run resolves the repository live, so it names the exact scope a
|
|
178
|
+
// set would write; the comparison then reads that scope, never a name match.
|
|
179
|
+
let proposed = await dryRun(0);
|
|
180
|
+
if (proposed.kind === 'error')
|
|
181
|
+
return reportFailure(proposed);
|
|
182
|
+
const target = proposed.body;
|
|
183
|
+
const shown = await post(origin, FLEET_POLICY_ADMIN_ROUTES.show, { scope: { repository: target.repositoryId, tenant: target.tenantId } }, token);
|
|
184
|
+
let current = null;
|
|
185
|
+
if (shown.kind === 'ok')
|
|
186
|
+
current = shown.body.scopes[0] ?? null;
|
|
187
|
+
else if (!(shown.kind === 'error' && shown.error === 'scope-not-found'))
|
|
188
|
+
return reportFailure(shown);
|
|
189
|
+
if (proposed.kind === 'conflict' || (current && current.epoch !== 0))
|
|
190
|
+
proposed = await dryRun(current?.epoch ?? 0);
|
|
191
|
+
if (proposed.kind !== 'ok')
|
|
192
|
+
return reportFailure(proposed);
|
|
193
|
+
const next = proposed.body;
|
|
194
|
+
if (next.tenantId !== target.tenantId || next.repositoryId !== target.repositoryId) {
|
|
195
|
+
throw new UsageError('the repository resolved to another scope between reads; run diff again');
|
|
196
|
+
}
|
|
197
|
+
console.log(chalk.bold(`${next.tenantId} ${next.repositoryId} (${next.repositoryFullName})`));
|
|
198
|
+
console.log(` epoch ${next.epoch}: version ${current?.active?.version ?? 'none'} -> ${next.version}`);
|
|
199
|
+
console.log(` digest: ${current?.active?.policySha256 ?? 'none'} -> ${next.policySha256}`);
|
|
200
|
+
console.log(` kind: ${current?.active?.scopeKind ?? 'none'} -> ${next.scopeKind}`);
|
|
201
|
+
console.log(` binding: ${current?.active ? bindingText(current.active.binding) : 'none'} -> ${bindingText(next.binding)}`);
|
|
202
|
+
if (current?.active?.policySha256 === next.policySha256) {
|
|
203
|
+
console.log(' no field changes');
|
|
204
|
+
return FLEET_POLICY_CLI_EXIT.ok;
|
|
205
|
+
}
|
|
206
|
+
const before = current?.active ? leaves(current.active.policy) : new Map();
|
|
207
|
+
const after = leaves(JSON.parse(next.canonicalPolicy));
|
|
208
|
+
for (const path of [...new Set([...before.keys(), ...after.keys()])].sort()) {
|
|
209
|
+
const old = before.get(path);
|
|
210
|
+
const now = after.get(path);
|
|
211
|
+
if (old === now)
|
|
212
|
+
continue;
|
|
213
|
+
if (old === undefined)
|
|
214
|
+
console.log(chalk.green(` + ${path}: ${now}`));
|
|
215
|
+
else if (now === undefined)
|
|
216
|
+
console.log(chalk.red(` - ${path}: ${old}`));
|
|
217
|
+
else
|
|
218
|
+
console.log(chalk.yellow(` ~ ${path}: ${old} -> ${now}`));
|
|
219
|
+
}
|
|
220
|
+
return FLEET_POLICY_CLI_EXIT.ok;
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
export function fleetPolicySetCommand(repository, options) {
|
|
224
|
+
return run(async () => {
|
|
225
|
+
const body = {
|
|
226
|
+
scope: selector(repository, options),
|
|
227
|
+
policy: policyFile(options.file),
|
|
228
|
+
expectedEpoch: epochOption(options.expectedEpoch),
|
|
229
|
+
reason: reasonOption(options.reason),
|
|
230
|
+
};
|
|
231
|
+
const result = await post(workerOrigin(options), FLEET_POLICY_ADMIN_ROUTES.set, body);
|
|
232
|
+
if (result.kind !== 'ok')
|
|
233
|
+
return reportFailure(result);
|
|
234
|
+
const set = result.body;
|
|
235
|
+
console.log(chalk.green(`set ${set.tenantId} ${set.repositoryId} (${set.repositoryFullName}): version ${set.version}, epoch ${set.epoch}`));
|
|
236
|
+
console.log(` digest: ${set.policySha256}`);
|
|
237
|
+
console.log(` kind: ${set.scopeKind} binding: ${bindingText(set.binding)}`);
|
|
238
|
+
console.log(` previous: ${set.previous ? `version ${set.previous.version} ${set.previous.policySha256}` : 'none'}`);
|
|
239
|
+
return FLEET_POLICY_CLI_EXIT.ok;
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
export function fleetPolicyRevokeCommand(repository, options) {
|
|
243
|
+
return run(async () => {
|
|
244
|
+
const body = {
|
|
245
|
+
scope: selector(repository, options),
|
|
246
|
+
expectedEpoch: epochOption(options.expectedEpoch),
|
|
247
|
+
reason: reasonOption(options.reason),
|
|
248
|
+
};
|
|
249
|
+
const result = await post(workerOrigin(options), FLEET_POLICY_ADMIN_ROUTES.revoke, body);
|
|
250
|
+
if (result.kind !== 'ok')
|
|
251
|
+
return reportFailure(result);
|
|
252
|
+
const revoked = result.body;
|
|
253
|
+
console.log(chalk.green(`revoked ${revoked.tenantId} ${revoked.repositoryId}: version ${revoked.revokedVersion}, now epoch ${revoked.epoch}`));
|
|
254
|
+
console.log(` revocation generation: ${revoked.revocationGeneration} stopped attempts: ${revoked.stoppedAttempts}`);
|
|
255
|
+
return FLEET_POLICY_CLI_EXIT.ok;
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
export function fleetPolicyHistoryCommand(repository, options) {
|
|
259
|
+
return run(async () => {
|
|
260
|
+
let limit;
|
|
261
|
+
if (options.limit !== undefined) {
|
|
262
|
+
if (!/^[1-9][0-9]{0,2}$/.test(options.limit) || Number(options.limit) > 500)
|
|
263
|
+
throw new UsageError('--limit must be 1 to 500');
|
|
264
|
+
limit = Number(options.limit);
|
|
265
|
+
}
|
|
266
|
+
const result = await post(workerOrigin(options), FLEET_POLICY_ADMIN_ROUTES.history, { scope: selector(repository, options), ...(limit === undefined ? {} : { limit }) });
|
|
267
|
+
if (result.kind !== 'ok')
|
|
268
|
+
return reportFailure(result);
|
|
269
|
+
const { events, versions, tenantId, repositoryId } = result.body;
|
|
270
|
+
console.log(chalk.bold(`${tenantId} ${repositoryId}`));
|
|
271
|
+
console.log(' events (newest first):');
|
|
272
|
+
const version = (value) => value === null ? 'none' : `v${value}`;
|
|
273
|
+
for (const event of events) {
|
|
274
|
+
console.log(` epoch ${event.fromEpoch}->${event.toEpoch} ${event.action} ${version(event.fromVersion)}->${version(event.toVersion)} `
|
|
275
|
+
+ `${event.toPolicySha256 ?? ''} by ${event.actor} at ${event.createdAt}: ${event.reason}`);
|
|
276
|
+
}
|
|
277
|
+
console.log(' versions (newest first):');
|
|
278
|
+
for (const version of versions) {
|
|
279
|
+
console.log(` v${version.version} ${version.policySha256} ${version.scopeKind} (${version.repositoryFullName}) `
|
|
280
|
+
+ `by ${version.actor} at ${version.createdAt}`);
|
|
281
|
+
}
|
|
282
|
+
return FLEET_POLICY_CLI_EXIT.ok;
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
export function fleetPolicyImportKvCommand(options) {
|
|
286
|
+
return run(async () => {
|
|
287
|
+
const dryRun = options.dryRun === true;
|
|
288
|
+
const result = await post(workerOrigin(options), FLEET_POLICY_ADMIN_ROUTES.importKv, { dryRun });
|
|
289
|
+
if (result.kind !== 'ok')
|
|
290
|
+
return reportFailure(result);
|
|
291
|
+
const { policies, overrides } = result.body;
|
|
292
|
+
console.log(chalk.bold(`${dryRun ? 'dry run: ' : ''}${policies.length} policy keys, ${overrides.length} tenant override keys`));
|
|
293
|
+
for (const item of policies) {
|
|
294
|
+
const scope = item.scope ? ` ${item.scope.tenantId} ${item.scope.repositoryId}` : '';
|
|
295
|
+
console.log(` ${item.outcome} ${item.key}${scope}${item.repositoryFullName ? ` (${item.repositoryFullName})` : ''}`
|
|
296
|
+
+ `${item.scopeKind ? ` ${item.scopeKind}` : ''}${item.binding ? ` [${bindingText(item.binding)}]` : ''}`
|
|
297
|
+
+ `${item.policySha256 ? ` ${item.policySha256}` : ''}${item.detail ? `: ${item.detail}` : ''}`);
|
|
298
|
+
}
|
|
299
|
+
for (const item of overrides) {
|
|
300
|
+
console.log(` ${item.outcome} ${item.key}${item.tenantId ? ` -> ${item.tenantId}` : ''}${item.detail ? `: ${item.detail}` : ''}`);
|
|
301
|
+
}
|
|
302
|
+
const failed = [...policies, ...overrides].filter(item => item.outcome === 'conflict' || item.outcome === 'invalid');
|
|
303
|
+
return failed.length === 0 ? FLEET_POLICY_CLI_EXIT.ok : FLEET_POLICY_CLI_EXIT.error;
|
|
304
|
+
});
|
|
305
|
+
}
|
|
@@ -52,8 +52,22 @@ try {
|
|
|
52
52
|
}
|
|
53
53
|
if (accessRejected)
|
|
54
54
|
terminal('failed', 'captured-request-access-rejected');
|
|
55
|
-
|
|
56
|
-
|
|
55
|
+
// A momentary 429/5xx or a dropped connection (a server or runner restarting) is retried inside the same deadline:
|
|
56
|
+
// admission is idempotent for one capture, so a repeat can only find what the first created.
|
|
57
|
+
const pauses = [2_000, 5_000, 10_000];
|
|
58
|
+
for (let attempt = 0;; attempt += 1) {
|
|
59
|
+
try {
|
|
60
|
+
const acknowledgment = await deliverPrecomputeRequest(delivery.request, controller.signal);
|
|
61
|
+
finish({ status: 'acknowledged', acknowledgment });
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
const transient = error instanceof HaystackApiError ? [429, 502, 503, 504].includes(error.status)
|
|
65
|
+
: !(error instanceof NotLoggedInError) && !controller.signal.aborted;
|
|
66
|
+
if (!transient || attempt >= pauses.length)
|
|
67
|
+
throw error;
|
|
68
|
+
await new Promise(resolve => setTimeout(resolve, pauses[attempt]));
|
|
69
|
+
}
|
|
70
|
+
}
|
|
57
71
|
}
|
|
58
72
|
catch (error) {
|
|
59
73
|
if (finished)
|
|
@@ -63,6 +77,6 @@ catch (error) {
|
|
|
63
77
|
if (error instanceof NotLoggedInError)
|
|
64
78
|
terminal('failed', 'authentication-required');
|
|
65
79
|
if (error instanceof HaystackApiError)
|
|
66
|
-
terminal('failed', `server-http-${error.status}`);
|
|
80
|
+
terminal('failed', `server-http-${error.status}: ${error.message.slice(0, 400)}`);
|
|
67
81
|
terminal('failed', 'delivery-not-acknowledged; remote-acceptance-unknown');
|
|
68
82
|
}
|
|
@@ -3,7 +3,7 @@ import { createHash, randomUUID } from 'node:crypto';
|
|
|
3
3
|
import { closeSync, constants, fchmodSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync, } from 'node:fs';
|
|
4
4
|
import { basename, dirname, join, resolve } from 'node:path';
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
|
-
import {
|
|
6
|
+
import { CRAWL_TITLE_MAX_CHARS } from './crawl-contract.js';
|
|
7
7
|
const requestFile = 'request.json';
|
|
8
8
|
const receiptFile = 'receipt.json';
|
|
9
9
|
const deliverySchema = 'precompute-delivery-v1';
|
|
@@ -127,7 +127,7 @@ function validateRequest(value) {
|
|
|
127
127
|
const request = value.request;
|
|
128
128
|
const component = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_.-';
|
|
129
129
|
if (!onlyKeys(request, ['owner', 'repository', 'baseCommit', 'workCommit', 'observedAt',
|
|
130
|
-
'cacheKey', 'patchGzBase64', 'patchSha256',
|
|
130
|
+
'cacheKey', 'patchGzBase64', 'patchSha256', 'crawl'])
|
|
131
131
|
|| !characters(request.owner, component) || request.owner.length > 100
|
|
132
132
|
|| !characters(request.repository, component) || request.repository.length > 100
|
|
133
133
|
|| !characters(request.baseCommit, '0123456789abcdef', 40)
|
|
@@ -142,10 +142,20 @@ function validateRequest(value) {
|
|
|
142
142
|
|| createHash('sha256').update(patch).digest('hex') !== request.patchSha256) {
|
|
143
143
|
throw new Error('Captured patch identity does not match.');
|
|
144
144
|
}
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
145
|
+
// The crawl's tree is the captured tree the cache key names (its third field). A capture of the base itself (no change)
|
|
146
|
+
// carries no crawl: there is nothing to compare.
|
|
147
|
+
const crawl = request.crawl;
|
|
148
|
+
if (crawl === undefined) {
|
|
149
|
+
if (request.workCommit !== request.baseCommit)
|
|
150
|
+
throw new Error('Invalid captured crawl submission.');
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
if (!object(crawl) || !onlyKeys(crawl, ['treeSha', 'changeTitle'])
|
|
154
|
+
|| !characters(crawl.treeSha, '0123456789abcdef', 40)
|
|
155
|
+
|| request.cacheKey.split('-')[2] !== crawl.treeSha.slice(0, 16)
|
|
156
|
+
|| typeof crawl.changeTitle !== 'string' || crawl.changeTitle.length > CRAWL_TITLE_MAX_CHARS) {
|
|
157
|
+
throw new Error('Invalid captured crawl submission.');
|
|
158
|
+
}
|
|
149
159
|
}
|
|
150
160
|
export class PrecomputeDeliveryAccessError extends Error {
|
|
151
161
|
delivery;
|
|
@@ -126,6 +126,8 @@ export function parseHostedRunState(value, expectedRunId) {
|
|
|
126
126
|
if (fleetResult.savedChecks !== undefined) {
|
|
127
127
|
parseHostedSavedChecks(fleetResult.savedChecks, runId, headCommit);
|
|
128
128
|
}
|
|
129
|
+
if (fleetResult.databaseStandIn !== undefined)
|
|
130
|
+
hostedDatabaseStandInLine(fleetResult.databaseStandIn);
|
|
129
131
|
requireString(provenance, 'intent', /^(user|inferred)$/);
|
|
130
132
|
if (status === 'complete' && fleetResult.lifecycle !== 'complete') {
|
|
131
133
|
throw new Error('Hosted fleet lifecycle disagrees with its completed job.');
|
|
@@ -244,6 +246,21 @@ function parseHostedSavedChecks(value, runId, headSha) {
|
|
|
244
246
|
}
|
|
245
247
|
return saved;
|
|
246
248
|
}
|
|
249
|
+
/** The result's `databaseStandIn`, present only when the run's environment was
|
|
250
|
+
* onboarded with an uploaded database profile: which profile the stand-in
|
|
251
|
+
* database was built from and for which run-map dependency, or why onboarding
|
|
252
|
+
* built none (the reason names the run map's dependency ids). */
|
|
253
|
+
function hostedDatabaseStandInLine(value) {
|
|
254
|
+
const standIn = requireRecord(value, 'Hosted stand-in database');
|
|
255
|
+
if (standIn.status === 'present') {
|
|
256
|
+
const used = requireRecord(standIn.value, 'Hosted stand-in database');
|
|
257
|
+
const sha256 = requireString(used, 'profileSha256', /^[0-9a-f]{64}$/);
|
|
258
|
+
return `built from your uploaded profile ${sha256.slice(0, 12)} for ${savedCheckDisplay(requireString(used, 'dependencyId'))}`;
|
|
259
|
+
}
|
|
260
|
+
if (standIn.status !== 'absent')
|
|
261
|
+
throw new Error('Hosted stand-in database has an invalid status.');
|
|
262
|
+
return `not used: ${savedCheckDisplay(requireString(standIn, 'reason'))}`;
|
|
263
|
+
}
|
|
247
264
|
function savedCheckDisplay(value) {
|
|
248
265
|
// eslint-disable-next-line no-control-regex -- rejecting/stripping control characters is the point
|
|
249
266
|
return String(value).replace(/[\u0000-\u001f\u007f-\u009f]/g, '');
|
|
@@ -484,6 +501,9 @@ function printHostedState(operation, state, options) {
|
|
|
484
501
|
console.log(` ${chalk.dim('Run health:')} ${result.runHealth}`);
|
|
485
502
|
console.log(` ${chalk.dim('Intent:')} ${provenance.intent}`);
|
|
486
503
|
console.log(` ${chalk.dim('Cleanup:')} ${cleanup.status} (${cleanup.pending.length} pending)`);
|
|
504
|
+
if (result.databaseStandIn !== undefined) {
|
|
505
|
+
console.log(` ${chalk.dim('Stand-in database:')} ${hostedDatabaseStandInLine(result.databaseStandIn)}`);
|
|
506
|
+
}
|
|
487
507
|
for (const value of result.findings) {
|
|
488
508
|
const finding = requireRecord(value, 'Hosted fleet finding');
|
|
489
509
|
console.log(` ${chalk.dim('Finding:')} ${finding.findingId} / ${finding.caseId}: ${finding.judgment}`);
|