@aiwg/cli 2026.9.20 → 2026.9.21
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/agentic/code/providers/capability-matrix.yaml +58 -6
- package/agentic/code/providers/model-capabilities.v1.json +34 -0
- package/agentic/code/providers/model-catalog.v1.json +55 -13
- package/bin/aiwg.mjs +2 -0
- package/dist/src/api/index.d.ts +1 -0
- package/dist/src/api/index.js +1 -0
- package/dist/src/artifacts/enrichment/cli.js +1 -1
- package/dist/src/artifacts/views/cli.js +1 -1
- package/dist/src/audit/operator-decision.js +10 -0
- package/dist/src/auth/credential-store.js +127 -27
- package/dist/src/catalog/builtin-models.json +272 -40
- package/dist/src/catalog/cli.js +1 -1
- package/dist/src/catalog/cli.mjs +1 -1
- package/dist/src/cli/handlers/effect.js +872 -0
- package/dist/src/cli/handlers/help.js +2 -0
- package/dist/src/cli/handlers/index.js +4 -2
- package/dist/src/cli/handlers/models.js +41 -10
- package/dist/src/cli/handlers/ralph.js +1 -1
- package/dist/src/cli/handlers/sessions.js +113 -37
- package/dist/src/cli/handlers/subcommands.js +31 -0
- package/dist/src/cli/handlers/use.js +46 -9
- package/dist/src/cli/handlers/utilities.js +10 -0
- package/dist/src/cli/handlers/workspace.js +3 -1
- package/dist/src/cli/scope-resolver.js +112 -9
- package/dist/src/cli/services/deployment-verification.js +5 -0
- package/dist/src/config/aiwg-config.js +3 -0
- package/dist/src/decision/acceptance-rollout.js +93 -0
- package/dist/src/decision/acceptance.js +175 -0
- package/dist/src/decision/adapters/jev.js +234 -13
- package/dist/src/decision/adapters/llm-subagent.js +60 -12
- package/dist/src/decision/admission.js +550 -0
- package/dist/src/decision/batch-benchmark.js +47 -0
- package/dist/src/decision/batch-receipts/accounting.js +75 -0
- package/dist/src/decision/batch-receipts/export.js +40 -0
- package/dist/src/decision/batch-receipts/index.js +9 -0
- package/dist/src/decision/batch-receipts/protection.js +40 -0
- package/dist/src/decision/batch-receipts/receipt.js +44 -0
- package/dist/src/decision/batch-receipts/result-store.js +341 -0
- package/dist/src/decision/batch-receipts/store.js +362 -0
- package/dist/src/decision/batch-receipts/types.js +2 -0
- package/dist/src/decision/batch-receipts/validate.js +127 -0
- package/dist/src/decision/batch.js +68 -0
- package/dist/src/decision/calibration/index.js +4 -0
- package/dist/src/decision/calibration/receipts.js +130 -0
- package/dist/src/decision/calibration/registry.js +258 -0
- package/dist/src/decision/calibration/types.js +2 -0
- package/dist/src/decision/compile-cache/benchmark.js +62 -0
- package/dist/src/decision/compile-cache/compatibility.js +65 -0
- package/dist/src/decision/compile-cache/file-store.js +280 -0
- package/dist/src/decision/compile-cache/identity.js +26 -0
- package/dist/src/decision/compile-cache/index.js +9 -0
- package/dist/src/decision/compile-cache/prefix.js +21 -0
- package/dist/src/decision/compile-cache/records.js +86 -0
- package/dist/src/decision/compile-cache/runtime.js +68 -0
- package/dist/src/decision/compile-cache/store.js +108 -0
- package/dist/src/decision/compile-cache/types.js +2 -0
- package/dist/src/decision/context-plan.js +234 -0
- package/dist/src/decision/context-qualification.js +60 -0
- package/dist/src/decision/ensemble/aggregate.js +215 -0
- package/dist/src/decision/ensemble/contract.js +316 -0
- package/dist/src/decision/ensemble/drift.js +51 -0
- package/dist/src/decision/ensemble/index.js +6 -0
- package/dist/src/decision/ensemble/report.js +71 -0
- package/dist/src/decision/ensemble/types.js +2 -0
- package/dist/src/decision/evaluate.js +1024 -78
- package/dist/src/decision/file-lifecycle-store.js +168 -0
- package/dist/src/decision/graph-beam.js +40 -0
- package/dist/src/decision/graph-budget.js +110 -0
- package/dist/src/decision/graph-decision-bridge.js +93 -0
- package/dist/src/decision/graph-evidence.js +123 -0
- package/dist/src/decision/graph-flow-adapter.js +56 -0
- package/dist/src/decision/graph-flow.js +105 -0
- package/dist/src/decision/graph-parallel.js +79 -0
- package/dist/src/decision/graph-patterns.js +20 -0
- package/dist/src/decision/graph-run-store.js +66 -0
- package/dist/src/decision/graph-run.js +166 -0
- package/dist/src/decision/graph-skill-bridge.js +91 -0
- package/dist/src/decision/graph-templates.js +56 -0
- package/dist/src/decision/graph.js +115 -0
- package/dist/src/decision/index.js +35 -0
- package/dist/src/decision/job-accounting.js +93 -0
- package/dist/src/decision/job-contract.js +186 -0
- package/dist/src/decision/job-effects.js +95 -0
- package/dist/src/decision/job-evaluate.js +80 -0
- package/dist/src/decision/job-gateway.js +121 -0
- package/dist/src/decision/job-http.js +194 -0
- package/dist/src/decision/job-payload-store.js +258 -0
- package/dist/src/decision/job-poll-limiter.js +92 -0
- package/dist/src/decision/job-quota.js +158 -0
- package/dist/src/decision/job-runtime.js +240 -0
- package/dist/src/decision/job-scheduler.js +26 -0
- package/dist/src/decision/job-service.js +117 -0
- package/dist/src/decision/job-store.js +264 -0
- package/dist/src/decision/job-worker.js +141 -0
- package/dist/src/decision/lifecycle.js +148 -0
- package/dist/src/decision/patterns/artifacts.js +173 -0
- package/dist/src/decision/patterns/catalog.js +108 -0
- package/dist/src/decision/patterns/durable-review.js +237 -0
- package/dist/src/decision/patterns/index.js +8 -0
- package/dist/src/decision/patterns/playground.js +401 -0
- package/dist/src/decision/patterns/recorded.js +43 -0
- package/dist/src/decision/patterns/specs.js +174 -0
- package/dist/src/decision/patterns/types.js +2 -0
- package/dist/src/decision/portable-secrets.js +53 -0
- package/dist/src/decision/projection.js +210 -0
- package/dist/src/decision/qualification/capture.js +67 -0
- package/dist/src/decision/qualification/drift.js +68 -0
- package/dist/src/decision/qualification/gate-evidence.js +172 -0
- package/dist/src/decision/qualification/gates.js +83 -0
- package/dist/src/decision/qualification/index.js +12 -0
- package/dist/src/decision/qualification/load.js +275 -0
- package/dist/src/decision/qualification/manifest.js +37 -0
- package/dist/src/decision/qualification/privacy.js +41 -0
- package/dist/src/decision/qualification/quality.js +213 -0
- package/dist/src/decision/qualification/release.js +116 -0
- package/dist/src/decision/qualification/runner.js +408 -0
- package/dist/src/decision/qualification/types.js +2 -0
- package/dist/src/decision/receipts.js +30 -7
- package/dist/src/decision/result-cache/index.js +6 -0
- package/dist/src/decision/result-cache/integrity.js +28 -0
- package/dist/src/decision/result-cache/key.js +28 -0
- package/dist/src/decision/result-cache/service.js +92 -0
- package/dist/src/decision/result-cache/store.js +327 -0
- package/dist/src/decision/result-cache/types.js +3 -0
- package/dist/src/decision/review/authorization.js +110 -0
- package/dist/src/decision/review/effect-ledger-adapter.js +321 -0
- package/dist/src/decision/review/index.js +12 -0
- package/dist/src/decision/review/migrate.js +34 -0
- package/dist/src/decision/review/operator-audit.js +59 -0
- package/dist/src/decision/review/recovery-adapters.js +252 -0
- package/dist/src/decision/review/recovery.js +33 -0
- package/dist/src/decision/review/service.js +498 -0
- package/dist/src/decision/review/store.js +219 -0
- package/dist/src/decision/review/types.js +2 -0
- package/dist/src/decision/review/validate.js +205 -0
- package/dist/src/decision/scheduler.js +115 -0
- package/dist/src/decision/telemetry/context.js +42 -0
- package/dist/src/decision/telemetry/debug-sidecar.js +152 -0
- package/dist/src/decision/telemetry/exporter.js +77 -0
- package/dist/src/decision/telemetry/file-debug-backend.js +112 -0
- package/dist/src/decision/telemetry/index.js +13 -0
- package/dist/src/decision/telemetry/mapping.js +146 -0
- package/dist/src/decision/telemetry/metrics.js +164 -0
- package/dist/src/decision/telemetry/otlp-http.js +183 -0
- package/dist/src/decision/telemetry/redaction.js +93 -0
- package/dist/src/decision/telemetry/retention.js +109 -0
- package/dist/src/decision/telemetry/runtime.js +310 -0
- package/dist/src/decision/telemetry/trace.js +158 -0
- package/dist/src/decision/telemetry/types.js +3 -0
- package/dist/src/decision/validate.js +135 -5
- package/dist/src/effects/checkpoint-sinks.js +94 -0
- package/dist/src/effects/checkpoint.js +76 -0
- package/dist/src/effects/decision-links.js +54 -0
- package/dist/src/effects/errors.js +48 -0
- package/dist/src/effects/identity.js +178 -0
- package/dist/src/effects/index.js +25 -0
- package/dist/src/effects/keyring.js +160 -0
- package/dist/src/effects/keys.js +130 -0
- package/dist/src/effects/ledger.js +435 -0
- package/dist/src/effects/lifecycle.js +110 -0
- package/dist/src/effects/lock-recovery.js +269 -0
- package/dist/src/effects/reader.js +103 -0
- package/dist/src/effects/records.js +114 -0
- package/dist/src/effects/redaction.js +51 -0
- package/dist/src/effects/schema.js +73 -0
- package/dist/src/effects/store.js +239 -0
- package/dist/src/effects/types.js +49 -0
- package/dist/src/effects/verifiers/decision.js +168 -0
- package/dist/src/effects/verifiers/file.js +106 -0
- package/dist/src/effects/verifiers/git.js +206 -0
- package/dist/src/effects/verifiers/index.js +90 -0
- package/dist/src/effects/verifiers/registry.js +148 -0
- package/dist/src/effects/verifiers/review.js +123 -0
- package/dist/src/effects/verifiers/tracker.js +299 -0
- package/dist/src/effects/verifiers/types.js +21 -0
- package/dist/src/effects/verify.js +295 -0
- package/dist/src/evidence/bundle.js +2 -0
- package/dist/src/extensions/commands/definitions.js +28 -0
- package/dist/src/extensions/manifest.js +1 -0
- package/dist/src/marketplace/provenance.js +25 -27
- package/dist/src/mcp/cli.mjs +11 -1
- package/dist/src/mcp/grok-build-config.mjs +189 -0
- package/dist/src/mcp/registry.js +21 -1
- package/dist/src/mcp/registry.mjs +20 -1
- package/dist/src/models/grok-build-models.js +356 -0
- package/dist/src/models/index.js +1 -0
- package/dist/src/models/model-capabilities.v1.json +34 -0
- package/dist/src/models/model-catalog.v1.json +55 -13
- package/dist/src/models/model-discovery.js +41 -5
- package/dist/src/models/provider-policy.js +3 -2
- package/dist/src/plugin/skill-command-translator.js +1 -0
- package/dist/src/providers/capability-matrix.yaml +58 -6
- package/dist/src/providers/grok-build-acp.js +192 -0
- package/dist/src/providers/grok-build-dispatch.js +251 -0
- package/dist/src/providers/grok-build-headless.js +170 -0
- package/dist/src/providers/muse-paths.js +105 -0
- package/dist/src/providers/provider-definitions.js +93 -7
- package/dist/src/providers/provider-definitions.mjs +21 -1
- package/dist/src/providers/provider-inventory.js +3 -0
- package/dist/src/security/artifact-attestation.js +4 -8
- package/dist/src/security/signing.js +119 -0
- package/dist/src/sessions/adapters/grok-build.js +187 -0
- package/dist/src/sessions/adapters/muse.js +412 -0
- package/dist/src/sessions/contracts.js +1 -1
- package/dist/src/sessions/grok-build-cli.js +196 -0
- package/dist/src/sessions/index.js +3 -0
- package/dist/src/sessions/workspace-discovery.js +9 -5
- package/dist/src/skills/deployer.js +33 -5
- package/dist/src/smiths/context-pipeline/claude-hook.js +2 -0
- package/dist/src/smiths/context-pipeline/context-file-safety.js +18 -0
- package/dist/src/smiths/context-pipeline/finalization.js +1 -32
- package/dist/src/smiths/context-pipeline/generator.js +12 -0
- package/dist/src/smiths/context-pipeline/index.js +1 -1
- package/dist/src/smiths/context-pipeline/managed-hook.js +2 -0
- package/dist/src/smiths/context-pipeline/muse-bridge.js +50 -0
- package/dist/src/smiths/context-pipeline/provider-policy.js +5 -0
- package/dist/src/smiths/context-pipeline/workspace-context.js +22 -16
- package/dist/src/smiths/hook-bridge/grok-build-translator.js +64 -0
- package/dist/src/smiths/hook-bridge/index.js +3 -0
- package/dist/src/smiths/hook-bridge/shim.js +2 -0
- package/dist/src/smiths/hook-bridge/types.js +5 -0
- package/dist/src/smiths/skillsmith/collision-detector.js +1 -0
- package/dist/src/storage/protected-files.js +102 -0
- package/dist/src/tracker/forge-http.js +371 -0
- package/dist/src/tracker/project-inputs.js +41 -0
- package/package.json +2 -1
- package/schemas/decision/BatchReceipt.v1.schema.json +50 -0
- package/schemas/decision/CalibrationArtifact.v1.schema.json +76 -0
- package/schemas/decision/CalibrationCompatibility.v1.schema.json +18 -0
- package/schemas/decision/CalibrationGovernanceReceipt.v1.schema.json +44 -0
- package/schemas/decision/DecisionAdmissionEvidence.v1.schema.json +43 -0
- package/schemas/decision/DecisionBinding.schema.json +309 -0
- package/schemas/decision/DecisionBinding.v1alpha2.schema.json +369 -0
- package/schemas/decision/DecisionCacheTelemetry.v1.schema.json +20 -0
- package/schemas/decision/DecisionChampionChallenger.v1.schema.json +100 -0
- package/schemas/decision/DecisionCompileCacheEntry.v1.schema.json +35 -0
- package/schemas/decision/DecisionCompileCacheIdentity.v1.schema.json +26 -0
- package/schemas/decision/DecisionCompileCacheTombstone.v1.schema.json +31 -0
- package/schemas/decision/DecisionContextEvidence.v1.schema.json +136 -0
- package/schemas/decision/DecisionDefinition.schema.json +159 -0
- package/schemas/decision/DecisionDefinition.v1alpha2.schema.json +203 -0
- package/schemas/decision/DecisionDriftResponse.v1.schema.json +55 -0
- package/schemas/decision/DecisionEnsembleAggregate.v1.schema.json +86 -0
- package/schemas/decision/DecisionEnsembleIntegrityReport.v1.schema.json +64 -0
- package/schemas/decision/DecisionEnsemblePolicy.v1.schema.json +130 -0
- package/schemas/decision/DecisionGraph.v1.schema.json +77 -0
- package/schemas/decision/DecisionJob.v1.schema.json +71 -0
- package/schemas/decision/DecisionLifecyclePolicy.v1.schema.json +52 -0
- package/schemas/decision/DecisionProjectionPolicy.v1.schema.json +65 -0
- package/schemas/decision/DecisionProviderPrefixCompatibility.v1.schema.json +45 -0
- package/schemas/decision/DecisionProviderPrefixEvidence.v1.schema.json +17 -0
- package/schemas/decision/DecisionProviderPrefixIdentity.v1.schema.json +36 -0
- package/schemas/decision/DecisionResult.schema.json +524 -0
- package/schemas/decision/DecisionResult.v1alpha2.schema.json +619 -0
- package/schemas/decision/DecisionResultCacheEntry.v1.schema.json +53 -0
- package/schemas/decision/DecisionReview.schema.json +29 -0
- package/schemas/decision/DecisionRuleset.schema.json +313 -0
- package/schemas/decision/DecisionRuleset.v1alpha2.schema.json +313 -0
- package/schemas/decision/DecisionTelemetryTrace.v1.schema.json +95 -0
- package/schemas/decision/RulesetResult.schema.json +721 -0
- package/schemas/decision/RulesetResult.v1alpha2.schema.json +891 -0
- package/schemas/security/pr-evidence-receipt.v1.schema.json +77 -0
- package/schemas/security/threat-assessment-report.v1.schema.json +71 -1
- package/tools/agents/deploy-agents.mjs +21 -6
- package/tools/agents/providers/base.mjs +7 -4
- package/tools/agents/providers/codex.mjs +1 -1
- package/tools/agents/providers/grok-build.d.mts +7 -0
- package/tools/agents/providers/grok-build.mjs +249 -9
- package/tools/agents/providers/model-role.mjs +6 -3
- package/tools/agents/providers/muse-hooks.mjs +785 -0
- package/tools/agents/providers/muse-paths.mjs +112 -0
- package/tools/agents/providers/muse.mjs +336 -0
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { canonicalJson } from '../security/artifact-trust.js';
|
|
3
|
+
import { JobConflictError } from './job-store.js';
|
|
4
|
+
/** Host-only object authorization boundary. The key and actor come from the trusted host, never a request body. */
|
|
5
|
+
export class DecisionJobGateway {
|
|
6
|
+
runtime;
|
|
7
|
+
key;
|
|
8
|
+
now;
|
|
9
|
+
pollGate;
|
|
10
|
+
capabilities;
|
|
11
|
+
admitJob;
|
|
12
|
+
constructor(runtime, key, now = Date.now, pollGate, capabilities, admitJob) {
|
|
13
|
+
this.runtime = runtime;
|
|
14
|
+
this.key = key;
|
|
15
|
+
this.now = now;
|
|
16
|
+
this.pollGate = pollGate;
|
|
17
|
+
this.capabilities = capabilities;
|
|
18
|
+
this.admitJob = admitJob;
|
|
19
|
+
if (!Buffer.isBuffer(key) || key.length !== 32)
|
|
20
|
+
throw new JobConflictError('Invalid job handle key');
|
|
21
|
+
this.key = Buffer.from(key);
|
|
22
|
+
}
|
|
23
|
+
async submit(actor, job) {
|
|
24
|
+
this.admitJob?.(job);
|
|
25
|
+
const snapshot = await this.runtime.submit(job, actor);
|
|
26
|
+
if (snapshot.deleted || this.now() >= snapshot.job.expiresAtEpochMs)
|
|
27
|
+
throw new JobConflictError('Job unavailable');
|
|
28
|
+
return { handle: this.seal(snapshot), snapshot };
|
|
29
|
+
}
|
|
30
|
+
async poll(actor, handle) {
|
|
31
|
+
if (this.pollGate)
|
|
32
|
+
await this.pollGate(actor);
|
|
33
|
+
const id = this.unseal(actor, handle);
|
|
34
|
+
if (!id)
|
|
35
|
+
return null;
|
|
36
|
+
const snapshot = await this.runtime.poll(actor, id.id);
|
|
37
|
+
return snapshot && snapshot.job.fingerprint === id.fingerprint && snapshot.job.createdAtEpochMs === id.createdAtEpochMs &&
|
|
38
|
+
snapshot.job.expiresAtEpochMs === id.expiresAtEpochMs && this.now() < snapshot.job.expiresAtEpochMs ? snapshot : null;
|
|
39
|
+
}
|
|
40
|
+
async items(actor, handle, offset = 0, limit = 100) {
|
|
41
|
+
const snapshot = await this.poll(actor, handle);
|
|
42
|
+
return snapshot ? this.runtime.items(actor, snapshot.job.id, offset, limit) : null;
|
|
43
|
+
}
|
|
44
|
+
async cancel(actor, handle) {
|
|
45
|
+
const snapshot = await this.poll(actor, handle);
|
|
46
|
+
return snapshot ? this.runtime.cancel(actor, snapshot.job.id) : null;
|
|
47
|
+
}
|
|
48
|
+
async retry(actor, handle, itemId) {
|
|
49
|
+
const snapshot = await this.poll(actor, handle);
|
|
50
|
+
return snapshot ? this.runtime.retry(actor, snapshot.job.id, itemId) : null;
|
|
51
|
+
}
|
|
52
|
+
async remove(actor, handle) {
|
|
53
|
+
const snapshot = await this.poll(actor, handle);
|
|
54
|
+
return snapshot ? this.runtime.remove(actor, snapshot.job.id) : false;
|
|
55
|
+
}
|
|
56
|
+
async export(actor, handle) {
|
|
57
|
+
const snapshot = await this.poll(actor, handle);
|
|
58
|
+
if (!snapshot || !this.capabilities || !await this.capabilities.authorizeExport(actor, snapshot))
|
|
59
|
+
return null;
|
|
60
|
+
return snapshot;
|
|
61
|
+
}
|
|
62
|
+
async list(actor, cursor, limit = 100) {
|
|
63
|
+
if (!this.capabilities || !Number.isSafeInteger(limit) || limit < 1 || limit > 100)
|
|
64
|
+
return null;
|
|
65
|
+
if (this.pollGate)
|
|
66
|
+
await this.pollGate(actor);
|
|
67
|
+
let after = null;
|
|
68
|
+
if (cursor !== undefined) {
|
|
69
|
+
const position = this.open(actor, cursor, 'dc1_');
|
|
70
|
+
if (!position || typeof position.after !== 'string')
|
|
71
|
+
return null;
|
|
72
|
+
after = position.after;
|
|
73
|
+
}
|
|
74
|
+
const records = (await this.capabilities.listSnapshots())
|
|
75
|
+
.filter(record => !record.deleted && this.now() < record.job.expiresAtEpochMs &&
|
|
76
|
+
canonicalJson(record.job.scope) === canonicalJson(actor))
|
|
77
|
+
.sort((a, b) => a.job.id < b.job.id ? -1 : a.job.id > b.job.id ? 1 : 0);
|
|
78
|
+
if (after !== null && !records.some(record => record.job.id === after))
|
|
79
|
+
return null;
|
|
80
|
+
const candidates = records.filter(record => after === null || record.job.id > after);
|
|
81
|
+
const page = candidates.slice(0, limit);
|
|
82
|
+
return { handles: page.map(record => this.seal(record)),
|
|
83
|
+
next: candidates.length > page.length ? this.encrypt('dc1_', { scope: actor, after: page.at(-1).job.id }) : null };
|
|
84
|
+
}
|
|
85
|
+
seal(snapshot) {
|
|
86
|
+
const { scope, id, fingerprint, createdAtEpochMs, expiresAtEpochMs } = snapshot.job;
|
|
87
|
+
return this.encrypt('dj1_', { scope, id, fingerprint, createdAtEpochMs, expiresAtEpochMs });
|
|
88
|
+
}
|
|
89
|
+
encrypt(prefix, value) {
|
|
90
|
+
const nonce = randomBytes(12);
|
|
91
|
+
const cipher = createCipheriv('aes-256-gcm', this.key, nonce);
|
|
92
|
+
const ciphertext = Buffer.concat([cipher.update(canonicalJson(value), 'utf8'), cipher.final()]);
|
|
93
|
+
return `${prefix}${Buffer.concat([nonce, cipher.getAuthTag(), ciphertext]).toString('base64url')}`;
|
|
94
|
+
}
|
|
95
|
+
open(actor, handle, prefix) {
|
|
96
|
+
if (typeof handle !== 'string' || !handle.startsWith(prefix) || !/^[A-Za-z0-9_-]{40,2048}$/.test(handle.slice(4)))
|
|
97
|
+
return null;
|
|
98
|
+
try {
|
|
99
|
+
const bytes = Buffer.from(handle.slice(4), 'base64url');
|
|
100
|
+
const decipher = createDecipheriv('aes-256-gcm', this.key, bytes.subarray(0, 12));
|
|
101
|
+
decipher.setAuthTag(bytes.subarray(12, 28));
|
|
102
|
+
const decoded = JSON.parse(Buffer.concat([decipher.update(bytes.subarray(28)), decipher.final()]).toString('utf8'));
|
|
103
|
+
const expected = Buffer.from(canonicalJson(actor));
|
|
104
|
+
const actual = Buffer.from(canonicalJson(decoded.scope));
|
|
105
|
+
if (expected.length !== actual.length || !timingSafeEqual(expected, actual))
|
|
106
|
+
return null;
|
|
107
|
+
return decoded;
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
unseal(actor, handle) {
|
|
114
|
+
const decoded = this.open(actor, handle, 'dj1_');
|
|
115
|
+
if (!decoded || typeof decoded.id !== 'string' || typeof decoded.fingerprint !== 'string' ||
|
|
116
|
+
!Number.isSafeInteger(decoded.createdAtEpochMs) || !Number.isSafeInteger(decoded.expiresAtEpochMs))
|
|
117
|
+
return null;
|
|
118
|
+
return decoded;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
//# sourceMappingURL=job-gateway.js.map
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { JobConflictError } from './job-store.js';
|
|
2
|
+
/** Explicitly mounted host HTTP boundary. All identity comes from authenticate(), never JSON. */
|
|
3
|
+
export function createDecisionJobHttpHandler(options) {
|
|
4
|
+
if (options.enabled !== true || !options.service || ![options.authenticate, options.authorizeQueue, options.deleteJob,
|
|
5
|
+
options.authorizeResult].every(fn => typeof fn === 'function') ||
|
|
6
|
+
!Number.isSafeInteger(options.maxBodyBytes) || options.maxBodyBytes < 1 || options.maxBodyBytes > 1_048_576)
|
|
7
|
+
throw new JobConflictError('Authenticated job HTTP host required');
|
|
8
|
+
return async (req, res) => {
|
|
9
|
+
const send = (code, value) => {
|
|
10
|
+
res.writeHead(code, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store',
|
|
11
|
+
'x-content-type-options': 'nosniff' });
|
|
12
|
+
res.end(JSON.stringify(value));
|
|
13
|
+
};
|
|
14
|
+
try {
|
|
15
|
+
let actor = null;
|
|
16
|
+
try {
|
|
17
|
+
actor = await options.authenticate(req);
|
|
18
|
+
}
|
|
19
|
+
catch { /* authentication fails closed */ }
|
|
20
|
+
if (!actor || ![actor.tenantId, actor.projectId, actor.workspaceId, actor.principalId]
|
|
21
|
+
.every(value => typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(value))) {
|
|
22
|
+
send(401, { error: 'unauthorized' });
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
26
|
+
const parts = url.pathname.split('/').filter(Boolean);
|
|
27
|
+
if (parts[0] !== 'decision' || parts[1] !== 'jobs' || parts.length > 6) {
|
|
28
|
+
send(404, { error: 'unavailable' });
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
const method = req.method;
|
|
32
|
+
if (req.headers['content-length'] && (!/^[0-9]+$/.test(req.headers['content-length']) ||
|
|
33
|
+
Number(req.headers['content-length']) > options.maxBodyBytes)) {
|
|
34
|
+
send(413, { error: 'request too large' });
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
if (parts.length === 2 && method === 'POST') {
|
|
38
|
+
const body = await readBody(req, options.maxBodyBytes);
|
|
39
|
+
if (!body || typeof body !== 'object' || Array.isArray(body) || Object.hasOwn(body, 'scope')) {
|
|
40
|
+
send(400, { error: 'invalid job' });
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
const job = { ...body, scope: actor };
|
|
44
|
+
const { handle, snapshot } = await options.service.gateway.submit(actor, job);
|
|
45
|
+
send(202, { handle, state: snapshot.job.state, summary: snapshot.job.summary });
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
if (parts.length === 2 && method === 'GET') {
|
|
49
|
+
const limit = numberParam(url.searchParams.get('limit'), 100);
|
|
50
|
+
if (limit === null) {
|
|
51
|
+
send(400, { error: 'invalid page' });
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const page = await options.service.gateway.list(actor, url.searchParams.get('cursor') ?? undefined, limit);
|
|
55
|
+
send(page ? 200 : 404, page ?? { error: 'unavailable' });
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const handle = parts[2];
|
|
59
|
+
if (!handle || !/^dj1_[A-Za-z0-9_-]{40,2048}$/.test(handle)) {
|
|
60
|
+
send(404, { error: 'unavailable' });
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
if (parts.length === 3 && method === 'GET') {
|
|
64
|
+
const snapshot = await options.service.gateway.poll(actor, handle);
|
|
65
|
+
send(snapshot ? 200 : 404, snapshot ? summary(snapshot) : { error: 'unavailable' });
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (parts.length === 3 && method === 'DELETE') {
|
|
69
|
+
const snapshot = await options.service.gateway.poll(actor, handle);
|
|
70
|
+
if (!snapshot || !await options.deleteJob(actor, snapshot)) {
|
|
71
|
+
send(404, { error: 'unavailable' });
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
send(204, {});
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (parts.length === 4 && parts[3] === 'export' && method === 'GET') {
|
|
78
|
+
const snapshot = await options.service.gateway.export(actor, handle);
|
|
79
|
+
send(snapshot ? 200 : 404, snapshot ? summary(snapshot) : { error: 'unavailable' });
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
if (parts.length === 4 && parts[3] === 'items' && method === 'GET') {
|
|
83
|
+
const offset = numberParam(url.searchParams.get('offset'), 0);
|
|
84
|
+
const limit = numberParam(url.searchParams.get('limit'), 100);
|
|
85
|
+
if (offset === null || limit === null) {
|
|
86
|
+
send(400, { error: 'invalid page' });
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const items = await options.service.gateway.items(actor, handle, offset, limit);
|
|
90
|
+
send(items ? 200 : 404, items ? { items: items.map(item => ({ id: item.id, state: item.state,
|
|
91
|
+
...(item.resultDigest ? { resultDigest: item.resultDigest } : {}),
|
|
92
|
+
...(item.errorCode ? { errorCode: item.errorCode } : {}) })) } : { error: 'unavailable' });
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (parts.length === 4 && parts[3] === 'cancel' && method === 'POST') {
|
|
96
|
+
const snapshot = await options.service.gateway.poll(actor, handle);
|
|
97
|
+
if (!snapshot) {
|
|
98
|
+
send(404, { error: 'unavailable' });
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
const canceled = await options.service.worker.cancel(actor, snapshot.job.id);
|
|
102
|
+
send(canceled ? 200 : 404, canceled ? summary(canceled) : { error: 'unavailable' });
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (parts.length === 4 && parts[3] === 'queue' && method === 'POST') {
|
|
106
|
+
const snapshot = await options.service.gateway.poll(actor, handle);
|
|
107
|
+
if (!snapshot || snapshot.job.state !== 'validating') {
|
|
108
|
+
send(404, { error: 'unavailable' });
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
// Missing input is not guessed or sourced from model-visible state.
|
|
112
|
+
for (const item of snapshot.job.items)
|
|
113
|
+
if (await options.service.payloads.get(actor, snapshot.job.id, item.id, 'input') === null)
|
|
114
|
+
throw new JobConflictError('Job input unavailable');
|
|
115
|
+
if (!await options.authorizeQueue(actor, snapshot)) {
|
|
116
|
+
send(404, { error: 'unavailable' });
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const next = structuredClone(snapshot.job);
|
|
120
|
+
next.state = 'queued';
|
|
121
|
+
const queued = await options.service.runtime.advance(actor, snapshot.job.id, snapshot, next);
|
|
122
|
+
send(202, summary(queued));
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (parts.length === 5 && parts[3] === 'retry' && method === 'POST' && /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(parts[4])) {
|
|
126
|
+
const next = await options.service.gateway.retry(actor, handle, parts[4]);
|
|
127
|
+
send(next ? 200 : 404, next ? summary(next) : { error: 'unavailable' });
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (parts.length === 6 && parts[3] === 'items' && parts[5] === 'input' && method === 'PUT' &&
|
|
131
|
+
/^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(parts[4])) {
|
|
132
|
+
const snapshot = await options.service.gateway.poll(actor, handle);
|
|
133
|
+
if (!snapshot) {
|
|
134
|
+
send(404, { error: 'unavailable' });
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
await options.service.payloads.put(actor, snapshot.job.id, parts[4], 'input', await readBody(req, options.maxBodyBytes));
|
|
138
|
+
send(204, {});
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (parts.length === 6 && parts[3] === 'items' && parts[5] === 'result' && method === 'GET' &&
|
|
142
|
+
/^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(parts[4])) {
|
|
143
|
+
const snapshot = await options.service.gateway.poll(actor, handle);
|
|
144
|
+
if (!snapshot || !await options.authorizeResult(actor, snapshot, parts[4])) {
|
|
145
|
+
send(404, { error: 'unavailable' });
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
const value = await options.service.payloads.get(actor, snapshot.job.id, parts[4], 'result');
|
|
149
|
+
send(value === null ? 404 : 200, value === null ? { error: 'unavailable' } : { result: value });
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
send(404, { error: 'unavailable' });
|
|
153
|
+
}
|
|
154
|
+
catch (error) {
|
|
155
|
+
if (res.headersSent)
|
|
156
|
+
return;
|
|
157
|
+
const message = error instanceof Error ? error.message : '';
|
|
158
|
+
send(message.includes('throttled') || message.includes('ledger full') ? 429 :
|
|
159
|
+
message === 'Job unavailable' || message === 'Validated job result unavailable' ? 404 :
|
|
160
|
+
message.includes('body limit') ? 413 : error instanceof JobConflictError ? 409 : 500, { error: message.includes('throttled') ? 'rate limited' : 'unavailable' });
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
function summary(snapshot) {
|
|
165
|
+
const job = snapshot.job;
|
|
166
|
+
return { state: job.state, summary: job.summary, createdAtEpochMs: job.createdAtEpochMs,
|
|
167
|
+
expiresAtEpochMs: job.expiresAtEpochMs, revision: snapshot.revision };
|
|
168
|
+
}
|
|
169
|
+
function numberParam(value, fallback) {
|
|
170
|
+
if (value === null)
|
|
171
|
+
return fallback;
|
|
172
|
+
if (!/^(0|[1-9][0-9]*)$/.test(value))
|
|
173
|
+
return null;
|
|
174
|
+
const parsed = Number(value);
|
|
175
|
+
return Number.isSafeInteger(parsed) ? parsed : null;
|
|
176
|
+
}
|
|
177
|
+
async function readBody(req, max) {
|
|
178
|
+
const chunks = [];
|
|
179
|
+
let bytes = 0;
|
|
180
|
+
for await (const chunk of req) {
|
|
181
|
+
const data = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
182
|
+
bytes += data.length;
|
|
183
|
+
if (bytes > max)
|
|
184
|
+
throw new JobConflictError('Request body limit exceeded');
|
|
185
|
+
chunks.push(data);
|
|
186
|
+
}
|
|
187
|
+
try {
|
|
188
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
throw new JobConflictError('Invalid job JSON');
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
//# sourceMappingURL=job-http.js.map
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { access, link, lstat, mkdir, open, readFile, readdir, rm } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { acquireDirectoryLock } from '../artifacts/prebuilt-build-lock.js';
|
|
5
|
+
import { canonicalJson } from '../security/artifact-trust.js';
|
|
6
|
+
import { artifactDigest } from './validate.js';
|
|
7
|
+
import { JobConflictError } from './job-store.js';
|
|
8
|
+
const hash = (value) => createHash('sha256').update(canonicalJson(value)).digest('hex');
|
|
9
|
+
/** Host-only protected JSON value store, bound to job pins and an independent D10 authority. */
|
|
10
|
+
export class FileJobPayloadStore {
|
|
11
|
+
directory;
|
|
12
|
+
journal;
|
|
13
|
+
externallyDeleted;
|
|
14
|
+
limits;
|
|
15
|
+
now;
|
|
16
|
+
metadataBytes;
|
|
17
|
+
key;
|
|
18
|
+
constructor(directory, key, journal, externallyDeleted, limits, now = Date.now, metadataBytes) {
|
|
19
|
+
this.directory = directory;
|
|
20
|
+
this.journal = journal;
|
|
21
|
+
this.externallyDeleted = externallyDeleted;
|
|
22
|
+
this.limits = limits;
|
|
23
|
+
this.now = now;
|
|
24
|
+
this.metadataBytes = metadataBytes;
|
|
25
|
+
if (!Buffer.isBuffer(key) || key.length !== 32 || typeof externallyDeleted !== 'function' ||
|
|
26
|
+
![limits.itemBytes, limits.principalBytes, limits.projectBytes].every(n => Number.isSafeInteger(n) && n > 0) ||
|
|
27
|
+
limits.itemBytes > 1_048_576 || limits.itemBytes > limits.principalBytes || limits.principalBytes > limits.projectBytes)
|
|
28
|
+
throw new JobConflictError('Invalid protected job payload configuration');
|
|
29
|
+
this.key = Buffer.from(key);
|
|
30
|
+
}
|
|
31
|
+
async put(scope, jobId, itemId, kind, value) {
|
|
32
|
+
const digest = artifactDigest(value);
|
|
33
|
+
const plain = Buffer.from(canonicalJson(value), 'utf8');
|
|
34
|
+
if (plain.length > this.limits.itemBytes)
|
|
35
|
+
throw new JobConflictError('Protected job payload limit exceeded');
|
|
36
|
+
await this.ensurePrivate();
|
|
37
|
+
const release = await acquireDirectoryLock(join(this.directory, '.quota-lock'), { timeoutMs: 5000, pollMs: 20 });
|
|
38
|
+
try {
|
|
39
|
+
const job = await this.authorize(scope, jobId, itemId, kind, digest);
|
|
40
|
+
const file = this.file(scope, jobId, itemId, kind);
|
|
41
|
+
const existing = await this.readEnvelope(file);
|
|
42
|
+
if (existing) {
|
|
43
|
+
if (existing.digest !== digest || existing.expiresAtEpochMs !== job.expiresAtEpochMs ||
|
|
44
|
+
canonicalJson(await this.get(scope, jobId, itemId, kind)) !== plain.toString('utf8'))
|
|
45
|
+
throw new JobConflictError('Conflicting protected job payload');
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
const project = hash([scope.tenantId, scope.projectId]);
|
|
49
|
+
const principal = hash([scope.tenantId, scope.projectId, scope.workspaceId, scope.principalId]);
|
|
50
|
+
let totalProject = plain.length;
|
|
51
|
+
let totalPrincipal = plain.length;
|
|
52
|
+
for (const name of await readdir(this.directory)) {
|
|
53
|
+
if (!/^[a-f0-9]{64}\.[a-f0-9]{64}\.json$/.test(name))
|
|
54
|
+
continue;
|
|
55
|
+
const envelope = await this.readEnvelope(join(this.directory, name));
|
|
56
|
+
if (!envelope)
|
|
57
|
+
throw new JobConflictError('Protected job payload unavailable');
|
|
58
|
+
if (envelope.project === project)
|
|
59
|
+
totalProject += envelope.bytes;
|
|
60
|
+
if (envelope.principal === principal)
|
|
61
|
+
totalPrincipal += envelope.bytes;
|
|
62
|
+
}
|
|
63
|
+
if (this.metadataBytes) {
|
|
64
|
+
totalProject += await this.metadataBytes(scope, 'project');
|
|
65
|
+
totalPrincipal += await this.metadataBytes(scope, 'principal');
|
|
66
|
+
}
|
|
67
|
+
if (!Number.isSafeInteger(totalProject) || !Number.isSafeInteger(totalPrincipal) ||
|
|
68
|
+
totalProject > this.limits.projectBytes || totalPrincipal > this.limits.principalBytes)
|
|
69
|
+
throw new JobConflictError('Protected job payload limit exceeded');
|
|
70
|
+
const nonce = randomBytes(12);
|
|
71
|
+
const cipher = createCipheriv('aes-256-gcm', this.key, nonce);
|
|
72
|
+
cipher.setAAD(this.aad(scope, jobId, itemId, kind, digest, job.expiresAtEpochMs));
|
|
73
|
+
const encrypted = Buffer.concat([cipher.update(plain), cipher.final()]);
|
|
74
|
+
const fields = { version: 'decision-job-payload/v1', digest, expiresAtEpochMs: job.expiresAtEpochMs,
|
|
75
|
+
bytes: plain.length, project, principal, nonce: nonce.toString('base64url'),
|
|
76
|
+
tag: cipher.getAuthTag().toString('base64url'), ciphertext: encrypted.toString('base64url') };
|
|
77
|
+
const envelope = { ...fields, mac: this.mac(fields) };
|
|
78
|
+
const temporary = join(this.directory, `.${hash([scope, jobId])}.payload-${randomUUID()}.tmp`);
|
|
79
|
+
const handle = await open(temporary, 'wx', 0o600);
|
|
80
|
+
try {
|
|
81
|
+
await handle.writeFile(`${canonicalJson(envelope)}\n`);
|
|
82
|
+
await handle.sync();
|
|
83
|
+
}
|
|
84
|
+
finally {
|
|
85
|
+
await handle.close();
|
|
86
|
+
}
|
|
87
|
+
try {
|
|
88
|
+
await link(temporary, file);
|
|
89
|
+
const directory = await open(this.directory, 'r');
|
|
90
|
+
try {
|
|
91
|
+
await directory.sync();
|
|
92
|
+
}
|
|
93
|
+
finally {
|
|
94
|
+
await directory.close();
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
if (error.code === 'EEXIST')
|
|
99
|
+
throw new JobConflictError('Conflicting protected job payload');
|
|
100
|
+
throw error;
|
|
101
|
+
}
|
|
102
|
+
finally {
|
|
103
|
+
await rm(temporary, { force: true });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
finally {
|
|
107
|
+
await release();
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
async get(scope, jobId, itemId, kind) {
|
|
111
|
+
await this.ensurePrivate();
|
|
112
|
+
const job = await this.authorize(scope, jobId, itemId, kind);
|
|
113
|
+
const envelope = await this.readEnvelope(this.file(scope, jobId, itemId, kind));
|
|
114
|
+
if (!envelope)
|
|
115
|
+
return null;
|
|
116
|
+
if (envelope.expiresAtEpochMs !== job.expiresAtEpochMs)
|
|
117
|
+
throw new JobConflictError('Protected job payload expiry mismatch');
|
|
118
|
+
try {
|
|
119
|
+
const decipher = createDecipheriv('aes-256-gcm', this.key, Buffer.from(envelope.nonce, 'base64url'));
|
|
120
|
+
decipher.setAuthTag(Buffer.from(envelope.tag, 'base64url'));
|
|
121
|
+
decipher.setAAD(this.aad(scope, jobId, itemId, kind, envelope.digest, envelope.expiresAtEpochMs));
|
|
122
|
+
const value = JSON.parse(Buffer.concat([decipher.update(Buffer.from(envelope.ciphertext, 'base64url')), decipher.final()]).toString('utf8'));
|
|
123
|
+
if (envelope.project !== hash([scope.tenantId, scope.projectId]) ||
|
|
124
|
+
envelope.principal !== hash([scope.tenantId, scope.projectId, scope.workspaceId, scope.principalId]) ||
|
|
125
|
+
artifactDigest(value) !== envelope.digest || Buffer.byteLength(canonicalJson(value)) !== envelope.bytes ||
|
|
126
|
+
(kind === 'input' ? job.items.find(item => item.id === itemId)?.subjectDigest :
|
|
127
|
+
job.items.find(item => item.id === itemId)?.resultDigest) !== envelope.digest)
|
|
128
|
+
throw new JobConflictError('Protected job payload integrity mismatch');
|
|
129
|
+
return value;
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
throw new JobConflictError('Protected job payload integrity mismatch');
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/** Aggregate plaintext sizes for the quota journal, called while holding its shared lock. */
|
|
136
|
+
async usage(scope, tier) {
|
|
137
|
+
await this.ensurePrivate();
|
|
138
|
+
const expected = tier === 'project' ? hash([scope.tenantId, scope.projectId]) :
|
|
139
|
+
hash([scope.tenantId, scope.projectId, scope.workspaceId, scope.principalId]);
|
|
140
|
+
let total = 0;
|
|
141
|
+
for (const name of await readdir(this.directory)) {
|
|
142
|
+
if (!/^[a-f0-9]{64}\.[a-f0-9]{64}\.json$/.test(name))
|
|
143
|
+
continue;
|
|
144
|
+
const envelope = await this.readEnvelope(join(this.directory, name));
|
|
145
|
+
if (!envelope)
|
|
146
|
+
throw new JobConflictError('Protected job payload unavailable');
|
|
147
|
+
if ((tier === 'project' ? envelope.project : envelope.principal) === expected)
|
|
148
|
+
total += envelope.bytes;
|
|
149
|
+
}
|
|
150
|
+
if (!Number.isSafeInteger(total))
|
|
151
|
+
throw new JobConflictError('Protected job payload usage invalid');
|
|
152
|
+
return total;
|
|
153
|
+
}
|
|
154
|
+
/** Host-only D10 eraser; call after independently persisted tombstone and authorized job deletion. */
|
|
155
|
+
async purgeDeleted(scope, jobId) {
|
|
156
|
+
await this.ensurePrivate();
|
|
157
|
+
const current = await this.journal.read(scope, jobId);
|
|
158
|
+
if (!await this.externallyDeleted(scope, jobId) || current && (!current.deleted || current.legalHold))
|
|
159
|
+
throw new JobConflictError('Independent job tombstone required');
|
|
160
|
+
const release = await acquireDirectoryLock(join(this.directory, '.quota-lock'), { timeoutMs: 5000, pollMs: 20 });
|
|
161
|
+
try {
|
|
162
|
+
const marker = this.marker(scope, jobId);
|
|
163
|
+
try {
|
|
164
|
+
const handle = await open(marker, 'wx', 0o600);
|
|
165
|
+
try {
|
|
166
|
+
await handle.sync();
|
|
167
|
+
}
|
|
168
|
+
finally {
|
|
169
|
+
await handle.close();
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
catch (error) {
|
|
173
|
+
if (error.code !== 'EEXIST')
|
|
174
|
+
throw error;
|
|
175
|
+
}
|
|
176
|
+
const prefix = `${hash([scope, jobId])}.`;
|
|
177
|
+
for (const name of await readdir(this.directory))
|
|
178
|
+
if ((name.startsWith(prefix) && /^[a-f0-9]{64}\.json$/.test(name.slice(prefix.length))) ||
|
|
179
|
+
(name.startsWith(`.${prefix}payload-`) && /^payload-[0-9a-f-]{36}\.tmp$/.test(name.slice(prefix.length + 1))))
|
|
180
|
+
await rm(join(this.directory, name));
|
|
181
|
+
const directory = await open(this.directory, 'r');
|
|
182
|
+
try {
|
|
183
|
+
await directory.sync();
|
|
184
|
+
}
|
|
185
|
+
finally {
|
|
186
|
+
await directory.close();
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
finally {
|
|
190
|
+
await release();
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
async authorize(scope, jobId, itemId, kind, digest) {
|
|
194
|
+
if (!['input', 'result'].includes(kind) || typeof itemId !== 'string' || !itemId)
|
|
195
|
+
throw new JobConflictError('Job unavailable');
|
|
196
|
+
if (await this.externallyDeleted(scope, jobId))
|
|
197
|
+
throw new JobConflictError('Job unavailable');
|
|
198
|
+
try {
|
|
199
|
+
await access(this.marker(scope, jobId));
|
|
200
|
+
throw new JobConflictError('Job unavailable');
|
|
201
|
+
}
|
|
202
|
+
catch (error) {
|
|
203
|
+
if (error.code !== 'ENOENT')
|
|
204
|
+
throw error;
|
|
205
|
+
}
|
|
206
|
+
const record = await this.journal.read(scope, jobId);
|
|
207
|
+
if (!record || record.deleted || this.now() >= record.job.expiresAtEpochMs)
|
|
208
|
+
throw new JobConflictError('Job unavailable');
|
|
209
|
+
const item = record.job.items.find(entry => entry.id === itemId);
|
|
210
|
+
if (!item || kind === 'result' && (!['succeeded', 'review', 'abstained'].includes(item.state) ||
|
|
211
|
+
!item.attempts.some(attempt => attempt.outcome === 'succeeded' && attempt.receiptDigest)))
|
|
212
|
+
throw new JobConflictError('Job unavailable');
|
|
213
|
+
if (digest && (kind === 'input' ? item.subjectDigest : item.resultDigest) !== digest)
|
|
214
|
+
throw new JobConflictError('Protected job payload pin mismatch');
|
|
215
|
+
return record.job;
|
|
216
|
+
}
|
|
217
|
+
file(scope, id, itemId, kind) {
|
|
218
|
+
return join(this.directory, `${hash([scope, id])}.${hash([itemId, kind])}.json`);
|
|
219
|
+
}
|
|
220
|
+
marker(scope, id) { return join(this.directory, `${hash([scope, id])}.deleted`); }
|
|
221
|
+
aad(scope, id, item, kind, digest, expiry) {
|
|
222
|
+
return Buffer.from(canonicalJson({ scope, id, item, kind, digest, expiry }));
|
|
223
|
+
}
|
|
224
|
+
async ensurePrivate() {
|
|
225
|
+
await mkdir(this.directory, { recursive: true, mode: 0o700 });
|
|
226
|
+
const info = await lstat(this.directory);
|
|
227
|
+
if (!info.isDirectory() || info.isSymbolicLink() || (info.mode & 0o077) !== 0)
|
|
228
|
+
throw new JobConflictError('Protected job payload root must be private');
|
|
229
|
+
}
|
|
230
|
+
mac(fields) {
|
|
231
|
+
return createHmac('sha256', this.key).update('decision-job-payload-header/v1:').update(canonicalJson(fields)).digest('hex');
|
|
232
|
+
}
|
|
233
|
+
async readEnvelope(file) {
|
|
234
|
+
let info;
|
|
235
|
+
try {
|
|
236
|
+
info = await lstat(file);
|
|
237
|
+
}
|
|
238
|
+
catch (error) {
|
|
239
|
+
if (error.code === 'ENOENT')
|
|
240
|
+
return null;
|
|
241
|
+
throw error;
|
|
242
|
+
}
|
|
243
|
+
if (!info.isFile() || (info.mode & 0o077) !== 0 || info.size > this.limits.itemBytes * 2 + 1024)
|
|
244
|
+
throw new JobConflictError('Protected job payload file must be private');
|
|
245
|
+
const envelope = JSON.parse(await readFile(file, 'utf8'));
|
|
246
|
+
if (envelope?.version !== 'decision-job-payload/v1' || !/^sha256:[a-f0-9]{64}$/.test(envelope.digest) ||
|
|
247
|
+
!Number.isSafeInteger(envelope.expiresAtEpochMs) || !Number.isSafeInteger(envelope.bytes) || envelope.bytes < 0 ||
|
|
248
|
+
!/^[a-f0-9]{64}$/.test(envelope.project) || !/^[a-f0-9]{64}$/.test(envelope.principal) ||
|
|
249
|
+
![envelope.nonce, envelope.tag, envelope.ciphertext].every(part => typeof part === 'string' && /^[A-Za-z0-9_-]+$/.test(part)) ||
|
|
250
|
+
typeof envelope.mac !== 'string' || !/^[a-f0-9]{64}$/.test(envelope.mac))
|
|
251
|
+
throw new JobConflictError('Protected job payload invalid');
|
|
252
|
+
const { mac, ...fields } = envelope;
|
|
253
|
+
if (!timingSafeEqual(Buffer.from(mac, 'hex'), Buffer.from(this.mac(fields), 'hex')))
|
|
254
|
+
throw new JobConflictError('Protected job payload integrity mismatch');
|
|
255
|
+
return envelope;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
//# sourceMappingURL=job-payload-store.js.map
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { open, readFile, rename, rm, stat } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { acquireDirectoryLock } from '../artifacts/prebuilt-build-lock.js';
|
|
5
|
+
import { canonicalJson } from '../security/artifact-trust.js';
|
|
6
|
+
import { FileJobStore, JobConflictError } from './job-store.js';
|
|
7
|
+
/** Host-only bounded polling gate. Shared local filesystem, not a distributed service. */
|
|
8
|
+
export class FileJobPollLimiter {
|
|
9
|
+
directory;
|
|
10
|
+
windowMs;
|
|
11
|
+
perPrincipal;
|
|
12
|
+
perProject;
|
|
13
|
+
maxLanes;
|
|
14
|
+
now;
|
|
15
|
+
constructor(directory, windowMs, perPrincipal, perProject, maxLanes, now = Date.now) {
|
|
16
|
+
this.directory = directory;
|
|
17
|
+
this.windowMs = windowMs;
|
|
18
|
+
this.perPrincipal = perPrincipal;
|
|
19
|
+
this.perProject = perProject;
|
|
20
|
+
this.maxLanes = maxLanes;
|
|
21
|
+
this.now = now;
|
|
22
|
+
if (![windowMs, perPrincipal, perProject, maxLanes].every(value => Number.isSafeInteger(value) && value > 0) || maxLanes > 4096)
|
|
23
|
+
throw new JobConflictError('Invalid polling limit');
|
|
24
|
+
}
|
|
25
|
+
async check(actor) {
|
|
26
|
+
// Reuse the journal's root privacy gate. Never create a world-readable counter store.
|
|
27
|
+
await new FileJobStore(this.directory).read(actor, '_poll_root_');
|
|
28
|
+
const release = await acquireDirectoryLock(join(this.directory, '.quota-lock'), { timeoutMs: 5000, pollMs: 20 });
|
|
29
|
+
try {
|
|
30
|
+
const path = join(this.directory, '.poll-ledger.json');
|
|
31
|
+
let previous = {};
|
|
32
|
+
try {
|
|
33
|
+
if ((await stat(path)).size > this.maxLanes * 128 + 2)
|
|
34
|
+
throw new JobConflictError('Polling ledger full');
|
|
35
|
+
previous = JSON.parse(await readFile(path, 'utf8'));
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
if (error.code !== 'ENOENT')
|
|
39
|
+
throw error;
|
|
40
|
+
}
|
|
41
|
+
const now = this.now();
|
|
42
|
+
if (!Number.isSafeInteger(now) || now < 0 || !previous || typeof previous !== 'object' || Array.isArray(previous))
|
|
43
|
+
throw new JobConflictError('Invalid polling ledger');
|
|
44
|
+
const ledger = Object.create(null);
|
|
45
|
+
for (const [key, value] of Object.entries(previous)) {
|
|
46
|
+
if (!/^[a-f0-9]{64}$/.test(key) || !value || !Number.isSafeInteger(value.window) || !Number.isSafeInteger(value.count) ||
|
|
47
|
+
value.count < 0 || value.window > now)
|
|
48
|
+
throw new JobConflictError('Invalid polling ledger');
|
|
49
|
+
if (now - value.window < this.windowMs)
|
|
50
|
+
ledger[key] = value;
|
|
51
|
+
}
|
|
52
|
+
const keys = [canonicalJson([actor.tenantId, actor.projectId]),
|
|
53
|
+
canonicalJson([actor.tenantId, actor.projectId, actor.workspaceId, actor.principalId])]
|
|
54
|
+
.map(text => createHash('sha256').update(text).digest('hex'));
|
|
55
|
+
for (const [index, key] of keys.entries()) {
|
|
56
|
+
const count = ledger[key]?.count ?? 0;
|
|
57
|
+
if (count >= [this.perProject, this.perPrincipal][index])
|
|
58
|
+
throw new JobConflictError('Job request throttled');
|
|
59
|
+
}
|
|
60
|
+
if (new Set([...Object.keys(ledger), ...keys]).size > this.maxLanes)
|
|
61
|
+
throw new JobConflictError('Polling ledger full');
|
|
62
|
+
for (const key of keys)
|
|
63
|
+
ledger[key] = { window: ledger[key]?.window ?? now, count: (ledger[key]?.count ?? 0) + 1 };
|
|
64
|
+
const temporary = join(this.directory, `.poll-${randomUUID()}.tmp`);
|
|
65
|
+
try {
|
|
66
|
+
const file = await open(temporary, 'wx', 0o600);
|
|
67
|
+
try {
|
|
68
|
+
await file.writeFile(`${canonicalJson(ledger)}\n`);
|
|
69
|
+
await file.sync();
|
|
70
|
+
}
|
|
71
|
+
finally {
|
|
72
|
+
await file.close();
|
|
73
|
+
}
|
|
74
|
+
await rename(temporary, path);
|
|
75
|
+
const directory = await open(this.directory, 'r');
|
|
76
|
+
try {
|
|
77
|
+
await directory.sync();
|
|
78
|
+
}
|
|
79
|
+
finally {
|
|
80
|
+
await directory.close();
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
finally {
|
|
84
|
+
await rm(temporary, { force: true });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
finally {
|
|
88
|
+
await release();
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
//# sourceMappingURL=job-poll-limiter.js.map
|