@bitkyc08/opencodex 2.11.1 → 2.12.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 +1 -1
- package/bin/ocx.mjs +4 -0
- package/gui/dist/assets/index-BNVYzdn0.css +1 -0
- package/gui/dist/assets/index-Cw1_mxO8.js +70 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/command-code.ts +94 -5
- package/src/adapters/cursor/live-transport.ts +14 -1
- package/src/adapters/google-tool-schema.ts +80 -15
- package/src/adapters/google.ts +24 -7
- package/src/adapters/openai-chat.ts +233 -305
- package/src/adapters/openai-responses.ts +62 -0
- package/src/adapters/tool-catalog-nudge.ts +2 -11
- package/src/claude/outbound.ts +42 -14
- package/src/cli/claude.ts +61 -9
- package/src/cli/codex-shim-autorestore.ts +2 -0
- package/src/cli/codex-shim-readiness.ts +8 -1
- package/src/cli/config-command.ts +4 -1
- package/src/cli/doctor.ts +10 -0
- package/src/cli/help.ts +15 -0
- package/src/cli/index.ts +27 -11
- package/src/cli/lab.ts +367 -0
- package/src/cli/provider.ts +9 -1
- package/src/clients/config-export.ts +100 -3
- package/src/codex/app-server-processes.ts +11 -8
- package/src/codex/auth-api.ts +92 -28
- package/src/codex/auth-collision.ts +5 -3
- package/src/codex/catalog/metadata.ts +7 -3
- package/src/codex/catalog/provider-fetch.ts +191 -47
- package/src/codex/catalog.ts +2 -2
- package/src/codex/history-job.ts +83 -18
- package/src/codex/history-migration-guardian.ts +9 -16
- package/src/codex/history-provider.ts +178 -1
- package/src/codex/history-transition.ts +5 -5
- package/src/codex/history-worker.ts +22 -2
- package/src/codex/native-profile-store.ts +1 -1
- package/src/codex/plan.ts +15 -0
- package/src/codex/quota.ts +6 -6
- package/src/codex/routing.ts +3 -3
- package/src/codex/subagent-model-fallback.ts +246 -22
- package/src/codex/user-identity.ts +63 -12
- package/src/config.ts +265 -32
- package/src/images/loop.ts +3 -1
- package/src/images/plan.ts +23 -13
- package/src/integrations/omp-yaml-source.ts +225 -0
- package/src/integrations/ownership.ts +5 -5
- package/src/integrations/state.ts +45 -6
- package/src/integrations/writer.ts +38 -3
- package/src/lab/artifacts/sanitize.ts +586 -0
- package/src/lab/artifacts/secure-fs.ts +475 -0
- package/src/lab/artifacts/store.ts +310 -0
- package/src/lab/conformance/assertion.ts +325 -0
- package/src/lab/conformance/digest.ts +22 -0
- package/src/lab/conformance/executor.ts +741 -0
- package/src/lab/conformance/fixture-provider.ts +27 -0
- package/src/lab/conformance/fixtures/live-v1-cases.json +175 -0
- package/src/lab/conformance/fixtures/protocol-v1-cases.json +461 -0
- package/src/lab/conformance/harness-budget.ts +47 -0
- package/src/lab/conformance/index.ts +5 -0
- package/src/lab/conformance/jcs.ts +24 -0
- package/src/lab/conformance/json-pointer.ts +39 -0
- package/src/lab/conformance/manifest.ts +180 -0
- package/src/lab/conformance/mcp-stub.ts +179 -0
- package/src/lab/conformance/negative-controls.ts +160 -0
- package/src/lab/conformance/observation.ts +355 -0
- package/src/lab/conformance/runner.ts +57 -0
- package/src/lab/conformance/sse-normalize.ts +59 -0
- package/src/lab/conformance/suite-manifest.ts +78 -0
- package/src/lab/conformance/types.ts +191 -0
- package/src/lab/constants.ts +126 -0
- package/src/lab/digest.ts +64 -0
- package/src/lab/events/errors.ts +9 -0
- package/src/lab/events/limits.ts +117 -0
- package/src/lab/events/types.ts +228 -0
- package/src/lab/events/validate.ts +780 -0
- package/src/lab/index.ts +36 -0
- package/src/lab/ledger/artifact-refs.ts +127 -0
- package/src/lab/ledger/invalidation.ts +136 -0
- package/src/lab/ledger/purge.ts +241 -0
- package/src/lab/ledger/store.ts +263 -0
- package/src/lab/live/credential-lease.ts +53 -0
- package/src/lab/live/destination.ts +155 -0
- package/src/lab/live/executor.ts +311 -0
- package/src/lab/live/inert-tools.ts +56 -0
- package/src/lab/live/manifest.ts +85 -0
- package/src/lab/live/mcp-loopback.ts +57 -0
- package/src/lab/live/runner.ts +19 -0
- package/src/lab/live/sandbox.ts +61 -0
- package/src/lab/live/suite-manifest.ts +41 -0
- package/src/lab/live/transport.ts +116 -0
- package/src/lab/live/types.ts +197 -0
- package/src/lab/observe/from-conformance.ts +344 -0
- package/src/lab/observe/from-live.ts +103 -0
- package/src/lab/paths.ts +65 -0
- package/src/lab/projection/rebuild.ts +477 -0
- package/src/lab/projection/schema.ts +122 -0
- package/src/lab/projection/verdicts.ts +438 -0
- package/src/lab/projection/verification.ts +332 -0
- package/src/lab/query/catalog.ts +72 -0
- package/src/lab/query/connection.ts +107 -0
- package/src/lab/query/constants.ts +4 -0
- package/src/lab/query/cursor.ts +132 -0
- package/src/lab/query/dto-map.ts +277 -0
- package/src/lab/query/errors.ts +22 -0
- package/src/lab/query/index.ts +34 -0
- package/src/lab/query/queries.ts +444 -0
- package/src/lab/query/types.ts +266 -0
- package/src/lab/subject/behavior-fingerprint.ts +77 -0
- package/src/lab/subject/installation-salt.ts +81 -0
- package/src/lab/subject/route-subject.ts +73 -0
- package/src/lib/bounded-body.ts +104 -0
- package/src/lib/lab-live-execution-authority.ts +13 -0
- package/src/lib/lab-live-host.ts +30 -0
- package/src/lib/lab-live-pinned-sender.ts +45 -0
- package/src/lib/pinned-http.ts +36 -7
- package/src/oauth/index.ts +20 -1
- package/src/oauth/local-token-detect.ts +11 -2
- package/src/oauth/login-cli.ts +38 -4
- package/src/providers/alibaba-region-migration.ts +1 -1
- package/src/providers/codex-capacity.ts +7 -4
- package/src/providers/command-code-efforts.ts +5 -2
- package/src/providers/derive.ts +105 -0
- package/src/providers/label.ts +1 -1
- package/src/providers/openai-tiers.ts +59 -3
- package/src/providers/opencode-zen-rate-limit.ts +102 -0
- package/src/providers/quota.ts +67 -21
- package/src/providers/registry.ts +118 -5
- package/src/router.ts +15 -1
- package/src/routing/analytics.ts +8 -7
- package/src/routing/evaluator.ts +51 -5
- package/src/routing/profile.ts +10 -1
- package/src/routing/trace.ts +42 -6
- package/src/server/auth-cors.ts +15 -0
- package/src/server/background-lifecycle.ts +169 -0
- package/src/server/chat-completions.ts +18 -3
- package/src/server/claude-messages.ts +1 -0
- package/src/server/images.ts +78 -11
- package/src/server/index.ts +72 -18
- package/src/server/lifecycle.ts +6 -0
- package/src/server/live.ts +5 -1
- package/src/server/management/agent-settings-routes.ts +41 -24
- package/src/server/management/body.ts +6 -0
- package/src/server/management/config-routes.ts +51 -8
- package/src/server/management/lab-routes.ts +388 -0
- package/src/server/management/logs-usage-routes.ts +19 -1
- package/src/server/management/model-rows.ts +25 -11
- package/src/server/management/native-integration-routes.ts +3 -3
- package/src/server/management/provider-routes.ts +5 -0
- package/src/server/management/shared.ts +8 -1
- package/src/server/management/usage-summary-cache.ts +2 -0
- package/src/server/management/vision-sidecar-options.ts +125 -0
- package/src/server/management-api.ts +2 -0
- package/src/server/request-decompress.ts +106 -5
- package/src/server/responses/collaboration.ts +4 -1
- package/src/server/responses/core.ts +117 -31
- package/src/server/responses-json-events.ts +50 -12
- package/src/server/search.ts +23 -7
- package/src/server/ws-bridge.ts +3 -1
- package/src/service.ts +40 -20
- package/src/types.ts +74 -0
- package/src/update/job.ts +10 -2
- package/src/usage/cost.ts +0 -0
- package/src/usage/expected-prices.ts +9 -0
- package/src/usage/log.ts +9 -9
- package/src/usage/user-cost-overlay-reconciler.ts +313 -0
- package/src/usage/user-cost-overlays.ts +314 -0
- package/src/vision/describe.ts +2 -2
- package/src/vision/eligibility.ts +229 -0
- package/src/vision/index.ts +20 -2
- package/src/web-search/executor.ts +2 -2
- package/src/web-search/index.ts +4 -2
- package/src/web-search/loop.ts +3 -1
- package/src/web-search/parse.ts +72 -3
- package/gui/dist/assets/index-BA1dgo4q.css +0 -1
- package/gui/dist/assets/index-DDZpgzKk.js +0 -70
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import {
|
|
2
|
+
closeSync,
|
|
3
|
+
existsSync,
|
|
4
|
+
fsyncSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
openSync,
|
|
7
|
+
readSync,
|
|
8
|
+
statSync,
|
|
9
|
+
writeSync,
|
|
10
|
+
} from "node:fs";
|
|
11
|
+
import { dirname } from "node:path";
|
|
12
|
+
import { jcsStringify } from "../digest";
|
|
13
|
+
import { MAX_SERIALIZED_EVENT_BYTES } from "../constants";
|
|
14
|
+
import type { LabEvent, LedgerCorruption, ReplayResult } from "../events/types";
|
|
15
|
+
import { LabValidationError, validateLabEvent } from "../events/validate";
|
|
16
|
+
import { ensureLabDirs, labLedgerPath } from "../paths";
|
|
17
|
+
|
|
18
|
+
export interface LedgerStore {
|
|
19
|
+
path: string;
|
|
20
|
+
append(event: LabEvent): void;
|
|
21
|
+
replay(): ReplayResult;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Durable append of one validated event as a single JSONL line + fsync. */
|
|
25
|
+
export function appendLabEvent(ledgerPath: string, event: LabEvent): void {
|
|
26
|
+
const validated = validateLabEvent(event);
|
|
27
|
+
mkdirSync(dirname(ledgerPath), { recursive: true, mode: 0o700 });
|
|
28
|
+
const line = `${jcsStringify(validated)}\n`;
|
|
29
|
+
const bytes = new TextEncoder().encode(line);
|
|
30
|
+
const fd = openSync(ledgerPath, "a", 0o600);
|
|
31
|
+
try {
|
|
32
|
+
let written = 0;
|
|
33
|
+
while (written < bytes.byteLength) {
|
|
34
|
+
const n = writeSync(fd, bytes, written, bytes.byteLength - written);
|
|
35
|
+
if (n <= 0) {
|
|
36
|
+
throw new LabValidationError("short_write", "ledger append made no progress");
|
|
37
|
+
}
|
|
38
|
+
written += n;
|
|
39
|
+
}
|
|
40
|
+
fsyncSync(fd);
|
|
41
|
+
} finally {
|
|
42
|
+
closeSync(fd);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function processLine(
|
|
47
|
+
line: string,
|
|
48
|
+
lineNumber: number,
|
|
49
|
+
lineHasTrailingNewline: boolean,
|
|
50
|
+
events: LabEvent[],
|
|
51
|
+
seenIds: Set<string>,
|
|
52
|
+
corruptions: LedgerCorruption[],
|
|
53
|
+
): void {
|
|
54
|
+
if (!lineHasTrailingNewline) {
|
|
55
|
+
corruptions.push({
|
|
56
|
+
kind: "partial_line",
|
|
57
|
+
lineNumber,
|
|
58
|
+
detail: "partial final JSONL line (missing trailing newline)",
|
|
59
|
+
});
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (line.trim() === "") {
|
|
63
|
+
corruptions.push({ kind: "malformed_line", lineNumber, detail: "empty line" });
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
let parsed: unknown;
|
|
68
|
+
try {
|
|
69
|
+
parsed = JSON.parse(line);
|
|
70
|
+
} catch {
|
|
71
|
+
corruptions.push({ kind: "malformed_line", lineNumber, detail: "JSON parse failed" });
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
let event: LabEvent;
|
|
76
|
+
try {
|
|
77
|
+
event = validateLabEvent(parsed);
|
|
78
|
+
} catch (err) {
|
|
79
|
+
corruptions.push({
|
|
80
|
+
kind: "invalid_event",
|
|
81
|
+
lineNumber,
|
|
82
|
+
detail: err instanceof Error ? err.message : String(err),
|
|
83
|
+
});
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (seenIds.has(event.eventId)) {
|
|
88
|
+
corruptions.push({
|
|
89
|
+
kind: "duplicate_event",
|
|
90
|
+
lineNumber,
|
|
91
|
+
eventId: event.eventId,
|
|
92
|
+
detail: "duplicate eventId",
|
|
93
|
+
});
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
seenIds.add(event.eventId);
|
|
97
|
+
events.push(event);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function processBufferedLines(
|
|
101
|
+
buf: Buffer,
|
|
102
|
+
state: {
|
|
103
|
+
lineNumber: number;
|
|
104
|
+
totalLineCount: number;
|
|
105
|
+
skippingOversizedLine: boolean;
|
|
106
|
+
events: LabEvent[];
|
|
107
|
+
seenIds: Set<string>;
|
|
108
|
+
corruptions: LedgerCorruption[];
|
|
109
|
+
},
|
|
110
|
+
): { carry: Buffer; skippingOversizedLine: boolean } {
|
|
111
|
+
let start = 0;
|
|
112
|
+
let skipping = state.skippingOversizedLine;
|
|
113
|
+
|
|
114
|
+
while (start < buf.length) {
|
|
115
|
+
const newlineIdx = buf.indexOf(0x0a, start);
|
|
116
|
+
if (newlineIdx < 0) {
|
|
117
|
+
const tail = buf.subarray(start);
|
|
118
|
+
if (skipping) {
|
|
119
|
+
return { carry: Buffer.alloc(0), skippingOversizedLine: true };
|
|
120
|
+
}
|
|
121
|
+
if (tail.length > MAX_SERIALIZED_EVENT_BYTES) {
|
|
122
|
+
state.lineNumber += 1;
|
|
123
|
+
state.totalLineCount += 1;
|
|
124
|
+
state.corruptions.push({
|
|
125
|
+
kind: "malformed_line",
|
|
126
|
+
lineNumber: state.lineNumber,
|
|
127
|
+
detail: `line exceeds ${MAX_SERIALIZED_EVENT_BYTES} bytes without newline`,
|
|
128
|
+
});
|
|
129
|
+
// Drop the oversized prefix immediately. Keeping it would defeat the
|
|
130
|
+
// replay memory bound and count the same line again at EOF.
|
|
131
|
+
return { carry: Buffer.alloc(0), skippingOversizedLine: true };
|
|
132
|
+
}
|
|
133
|
+
return { carry: tail, skippingOversizedLine: false };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const lineBytes = buf.subarray(start, newlineIdx);
|
|
137
|
+
start = newlineIdx + 1;
|
|
138
|
+
|
|
139
|
+
if (skipping) {
|
|
140
|
+
skipping = false;
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
state.lineNumber += 1;
|
|
145
|
+
state.totalLineCount += 1;
|
|
146
|
+
// Decode only complete JSONL lines. Keeping incomplete line bytes as Buffer
|
|
147
|
+
// carry naturally preserves UTF-8 code points split across read chunks.
|
|
148
|
+
processLine(lineBytes.toString("utf8"), state.lineNumber, true, state.events, state.seenIds, state.corruptions);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return { carry: Buffer.alloc(0), skippingOversizedLine: skipping };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Replay the JSONL ledger using chunked reads (no whole-file string buffer).
|
|
156
|
+
* Malformed or partial lines contribute no evidence and are reported as corruption.
|
|
157
|
+
*/
|
|
158
|
+
export function replayLabLedger(ledgerPath: string): ReplayResult {
|
|
159
|
+
const corruptions: LedgerCorruption[] = [];
|
|
160
|
+
const events: LabEvent[] = [];
|
|
161
|
+
if (!existsSync(ledgerPath)) {
|
|
162
|
+
return { events, corruptions, validLineCount: 0, totalLineCount: 0 };
|
|
163
|
+
}
|
|
164
|
+
const size = statSync(ledgerPath).size;
|
|
165
|
+
if (size === 0) {
|
|
166
|
+
return {
|
|
167
|
+
events,
|
|
168
|
+
corruptions: [{ kind: "empty_ledger", detail: "ledger file is empty" }],
|
|
169
|
+
validLineCount: 0,
|
|
170
|
+
totalLineCount: 0,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const fd = openSync(ledgerPath, "r");
|
|
175
|
+
const chunkSize = 64 * 1024;
|
|
176
|
+
const chunk = Buffer.alloc(chunkSize);
|
|
177
|
+
let carry: Buffer = Buffer.alloc(0);
|
|
178
|
+
let lineNumber = 0;
|
|
179
|
+
let totalLineCount = 0;
|
|
180
|
+
const seenIds = new Set<string>();
|
|
181
|
+
let offset = 0;
|
|
182
|
+
let skippingOversizedLine = false;
|
|
183
|
+
|
|
184
|
+
const state = {
|
|
185
|
+
lineNumber,
|
|
186
|
+
totalLineCount,
|
|
187
|
+
skippingOversizedLine,
|
|
188
|
+
events,
|
|
189
|
+
seenIds,
|
|
190
|
+
corruptions,
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
try {
|
|
194
|
+
while (offset < size) {
|
|
195
|
+
const toRead = Math.min(chunkSize, size - offset);
|
|
196
|
+
const n = readSync(fd, chunk, 0, toRead, offset);
|
|
197
|
+
if (n <= 0) break;
|
|
198
|
+
offset += n;
|
|
199
|
+
|
|
200
|
+
// `chunk` is reused by readSync, so any bytes retained beyond this
|
|
201
|
+
// iteration must be detached from it before the next read.
|
|
202
|
+
const combined = carry.length > 0
|
|
203
|
+
? Buffer.concat([carry, chunk.subarray(0, n)])
|
|
204
|
+
: Buffer.from(chunk.subarray(0, n));
|
|
205
|
+
const result = processBufferedLines(combined, state);
|
|
206
|
+
carry = result.carry.length > 0 ? Buffer.from(result.carry) : Buffer.alloc(0);
|
|
207
|
+
skippingOversizedLine = result.skippingOversizedLine;
|
|
208
|
+
state.skippingOversizedLine = skippingOversizedLine;
|
|
209
|
+
lineNumber = state.lineNumber;
|
|
210
|
+
totalLineCount = state.totalLineCount;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (carry.length > 0) {
|
|
214
|
+
if (skippingOversizedLine) {
|
|
215
|
+
lineNumber += 1;
|
|
216
|
+
totalLineCount += 1;
|
|
217
|
+
corruptions.push({
|
|
218
|
+
kind: "malformed_line",
|
|
219
|
+
lineNumber,
|
|
220
|
+
detail: `oversized line exceeds ${MAX_SERIALIZED_EVENT_BYTES} bytes`,
|
|
221
|
+
});
|
|
222
|
+
} else if (carry.length > MAX_SERIALIZED_EVENT_BYTES) {
|
|
223
|
+
lineNumber += 1;
|
|
224
|
+
totalLineCount += 1;
|
|
225
|
+
corruptions.push({
|
|
226
|
+
kind: "malformed_line",
|
|
227
|
+
lineNumber,
|
|
228
|
+
detail: `partial line exceeds ${MAX_SERIALIZED_EVENT_BYTES} bytes`,
|
|
229
|
+
});
|
|
230
|
+
} else {
|
|
231
|
+
lineNumber += 1;
|
|
232
|
+
totalLineCount += 1;
|
|
233
|
+
processLine(carry.toString("utf8"), lineNumber, false, events, seenIds, corruptions);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
} finally {
|
|
237
|
+
closeSync(fd);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return {
|
|
241
|
+
events,
|
|
242
|
+
corruptions,
|
|
243
|
+
validLineCount: events.length,
|
|
244
|
+
totalLineCount,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export function openLedgerStore(configDir?: string): LedgerStore {
|
|
249
|
+
const paths = ensureLabDirs(configDir);
|
|
250
|
+
return {
|
|
251
|
+
path: paths.ledgerPath,
|
|
252
|
+
append(event: LabEvent) {
|
|
253
|
+
appendLabEvent(paths.ledgerPath, event);
|
|
254
|
+
},
|
|
255
|
+
replay() {
|
|
256
|
+
return replayLabLedger(paths.ledgerPath);
|
|
257
|
+
},
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export function defaultLedgerPath(configDir?: string): string {
|
|
262
|
+
return labLedgerPath(configDir);
|
|
263
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { LAB_CREDENTIAL_LEASE, type LabCredentialLeaseV1, type LabDestinationV1 } from "./types";
|
|
2
|
+
|
|
3
|
+
export class LabCredentialError extends Error {
|
|
4
|
+
override readonly name = "LabCredentialError";
|
|
5
|
+
constructor(message: string, readonly code: "harness_failure" | "budget_exhausted") { super(message); }
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
interface LeaseState { destinationFingerprint: string; transportId: string; remaining: number }
|
|
9
|
+
const LEASE_STATE = new WeakMap<object, LeaseState>();
|
|
10
|
+
type InternalLease = LabCredentialLeaseV1 & { toJSON(): never };
|
|
11
|
+
|
|
12
|
+
export interface CreateLeaseOptions {
|
|
13
|
+
destination: LabDestinationV1;
|
|
14
|
+
transportId?: string;
|
|
15
|
+
budget?: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Trusted credential owner may associate secret state with this opaque capability outside Lab. */
|
|
19
|
+
export function createCredentialLease(opts: CreateLeaseOptions): LabCredentialLeaseV1 {
|
|
20
|
+
const budget = opts.budget ?? 1;
|
|
21
|
+
if (!Number.isInteger(budget) || budget <= 0) throw new LabCredentialError("invalid credential lease budget", "harness_failure");
|
|
22
|
+
let lease!: InternalLease;
|
|
23
|
+
lease = {
|
|
24
|
+
[LAB_CREDENTIAL_LEASE]: true,
|
|
25
|
+
get remainingRequests(): number { return LEASE_STATE.get(lease)?.remaining ?? 0; },
|
|
26
|
+
consume(): void {
|
|
27
|
+
const state = LEASE_STATE.get(lease);
|
|
28
|
+
if (!state) throw new LabCredentialError("unknown credential lease", "harness_failure");
|
|
29
|
+
if (state.remaining <= 0) throw new LabCredentialError("credential lease exhausted", "budget_exhausted");
|
|
30
|
+
state.remaining -= 1;
|
|
31
|
+
},
|
|
32
|
+
toJSON(): never { throw new LabCredentialError("credential lease is non-serializable", "harness_failure"); },
|
|
33
|
+
};
|
|
34
|
+
LEASE_STATE.set(lease, {
|
|
35
|
+
destinationFingerprint: opts.destination.fingerprint,
|
|
36
|
+
transportId: opts.transportId ?? "default",
|
|
37
|
+
remaining: budget,
|
|
38
|
+
});
|
|
39
|
+
return Object.freeze(lease);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function isCredentialLease(value: unknown): value is LabCredentialLeaseV1 {
|
|
43
|
+
return typeof value === "object" && value !== null && LEASE_STATE.has(value as object)
|
|
44
|
+
&& (value as LabCredentialLeaseV1)[LAB_CREDENTIAL_LEASE] === true;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function assertLeaseScope(lease: LabCredentialLeaseV1, destination: LabDestinationV1, transportId = "default"): void {
|
|
48
|
+
const state = LEASE_STATE.get(lease as object);
|
|
49
|
+
if (!state) throw new LabCredentialError("unknown credential lease", "harness_failure");
|
|
50
|
+
if (state.destinationFingerprint !== destination.fingerprint || state.transportId !== transportId) {
|
|
51
|
+
throw new LabCredentialError("credential lease scope mismatch", "harness_failure");
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { isIP } from "node:net";
|
|
2
|
+
import { assessUrlDestination, resolvePublicAddresses } from "../../lib/destination-policy";
|
|
3
|
+
import { localFingerprint } from "../digest";
|
|
4
|
+
import { readInstallationSalt } from "../subject/installation-salt";
|
|
5
|
+
import { TransportError } from "./transport";
|
|
6
|
+
import type { DnsResolver, LabDestinationV1 } from "./types";
|
|
7
|
+
|
|
8
|
+
export class LabDestinationError extends Error {
|
|
9
|
+
override readonly name = "LabDestinationError";
|
|
10
|
+
constructor(message: string, readonly code: string) { super(message); }
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function normalizeBasePath(pathname: string): string {
|
|
14
|
+
if (!pathname || pathname === "/") return "";
|
|
15
|
+
const trimmed = pathname.endsWith("/") ? pathname.slice(0, -1) : pathname;
|
|
16
|
+
return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function addressUrl(scheme: "http" | "https", address: string): string {
|
|
20
|
+
return `${scheme}://${isIP(address) === 6 ? `[${address}]` : address}/`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function validateResolvedAddress(
|
|
24
|
+
scheme: "http" | "https",
|
|
25
|
+
address: { address: string; family: 4 | 6 },
|
|
26
|
+
allowPrivateNetwork: boolean,
|
|
27
|
+
labRunApproval: boolean,
|
|
28
|
+
): boolean {
|
|
29
|
+
const actualFamily = isIP(address.address);
|
|
30
|
+
if ((actualFamily !== 4 && actualFamily !== 6) || actualFamily !== address.family) {
|
|
31
|
+
throw new LabDestinationError("resolver returned an invalid address/family", "harness_failure");
|
|
32
|
+
}
|
|
33
|
+
const assessment = assessUrlDestination(addressUrl(scheme, address.address));
|
|
34
|
+
if (!assessment) throw new LabDestinationError("resolver address could not be classified", "harness_failure");
|
|
35
|
+
if (["metadata", "link-local", "unspecified"].includes(assessment.kind)) {
|
|
36
|
+
throw new LabDestinationError("blocked resolved destination", "harness_failure");
|
|
37
|
+
}
|
|
38
|
+
const privateAnswer = ["private", "loopback", "localhost"].includes(assessment.kind);
|
|
39
|
+
if (privateAnswer && (!allowPrivateNetwork || !labRunApproval)) {
|
|
40
|
+
throw new LabDestinationError("resolved private network requires allowPrivateNetwork and labRunApproval", "harness_failure");
|
|
41
|
+
}
|
|
42
|
+
return privateAnswer;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function canonicalAddresses(addresses: ReadonlyArray<{ address: string; family: 4 | 6 }>): Array<{ address: string; family: 4 | 6 }> {
|
|
46
|
+
const unique = new Map<string, { address: string; family: 4 | 6 }>();
|
|
47
|
+
for (const row of addresses) unique.set(`${row.family}:${row.address}`, { ...row });
|
|
48
|
+
return [...unique.values()].sort((a, b) => a.family - b.family || (a.address < b.address ? -1 : a.address > b.address ? 1 : 0));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function addressSetKey(addresses: ReadonlyArray<{ address: string; family: 4 | 6 }>): string {
|
|
52
|
+
return canonicalAddresses(addresses).map((row) => `${row.family}:${row.address}`).join(",");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function withResolutionTimeout<T>(timeoutMs: number, operation: () => Promise<T>): Promise<T> {
|
|
56
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
57
|
+
try {
|
|
58
|
+
return await Promise.race([
|
|
59
|
+
operation(),
|
|
60
|
+
new Promise<T>((_, reject) => {
|
|
61
|
+
timer = setTimeout(() => reject(new TransportError("connect_timeout", "destination resolution timed out")), timeoutMs);
|
|
62
|
+
}),
|
|
63
|
+
]);
|
|
64
|
+
} finally {
|
|
65
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface CreateDestinationOptions {
|
|
70
|
+
baseUrl: string;
|
|
71
|
+
allowPrivateNetwork?: boolean;
|
|
72
|
+
labRunApproval?: boolean;
|
|
73
|
+
resolve?: DnsResolver;
|
|
74
|
+
connectTimeoutMs?: number;
|
|
75
|
+
configDir?: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Resolve once, policy-check every answer, then freeze the exact snapshot used by all later stages. */
|
|
79
|
+
export async function createLabDestination(opts: CreateDestinationOptions): Promise<LabDestinationV1> {
|
|
80
|
+
let parsed: URL;
|
|
81
|
+
try { parsed = new URL(opts.baseUrl); } catch { throw new LabDestinationError("invalid provider baseUrl", "harness_failure"); }
|
|
82
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new LabDestinationError("unsupported URL scheme", "harness_failure");
|
|
83
|
+
if (parsed.username || parsed.password || parsed.search || parsed.hash) {
|
|
84
|
+
throw new LabDestinationError("provider destination may not contain userinfo, query, or fragment", "harness_failure");
|
|
85
|
+
}
|
|
86
|
+
const scheme = parsed.protocol === "https:" ? "https" as const : "http" as const;
|
|
87
|
+
const literal = assessUrlDestination(parsed.toString());
|
|
88
|
+
if (literal && ["metadata", "link-local", "unspecified"].includes(literal.kind)) {
|
|
89
|
+
throw new LabDestinationError("blocked destination", "harness_failure");
|
|
90
|
+
}
|
|
91
|
+
const allowPrivate = opts.allowPrivateNetwork === true;
|
|
92
|
+
const approved = opts.labRunApproval === true;
|
|
93
|
+
const literalPrivate = Boolean(literal && ["private", "loopback", "localhost"].includes(literal.kind));
|
|
94
|
+
if (literalPrivate && (!allowPrivate || !approved)) {
|
|
95
|
+
throw new LabDestinationError("private network requires allowPrivateNetwork and labRunApproval", "harness_failure");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const connectTimeoutMs = opts.connectTimeoutMs ?? 10_000;
|
|
99
|
+
if (!Number.isInteger(connectTimeoutMs) || connectTimeoutMs <= 0) {
|
|
100
|
+
throw new LabDestinationError("invalid connect timeout", "harness_failure");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
let addresses: Array<{ address: string; family: 4 | 6 }>;
|
|
104
|
+
let privateNetwork = literalPrivate;
|
|
105
|
+
if (opts.resolve) {
|
|
106
|
+
try { addresses = await withResolutionTimeout(connectTimeoutMs, () => opts.resolve!(parsed.hostname)); }
|
|
107
|
+
catch (error) {
|
|
108
|
+
if (error instanceof TransportError || error instanceof LabDestinationError) throw error;
|
|
109
|
+
throw new LabDestinationError("DNS resolution failed", "network_blocked");
|
|
110
|
+
}
|
|
111
|
+
if (addresses.length === 0) throw new LabDestinationError("DNS resolution returned no addresses", "network_blocked");
|
|
112
|
+
for (const row of addresses) privateNetwork = validateResolvedAddress(scheme, row, allowPrivate, approved) || privateNetwork;
|
|
113
|
+
} else {
|
|
114
|
+
try {
|
|
115
|
+
const resolved = await withResolutionTimeout(connectTimeoutMs, () => resolvePublicAddresses(parsed.toString(), {
|
|
116
|
+
context: "Lab provider destination",
|
|
117
|
+
allowPrivateNetwork: allowPrivate && approved,
|
|
118
|
+
}));
|
|
119
|
+
addresses = resolved.addresses.map((row) => ({ address: row.address, family: row.family === 6 ? 6 as const : 4 as const }));
|
|
120
|
+
privateNetwork = resolved.privateNetwork || privateNetwork;
|
|
121
|
+
} catch (error) {
|
|
122
|
+
if (error instanceof TransportError || error instanceof LabDestinationError) throw error;
|
|
123
|
+
throw new LabDestinationError("DNS destination policy failed", "network_blocked");
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
const frozenAddresses = Object.freeze(canonicalAddresses(addresses).map((row) => Object.freeze(row)));
|
|
127
|
+
const port = parsed.port ? Number(parsed.port) : scheme === "https" ? 443 : 80;
|
|
128
|
+
if (!Number.isInteger(port) || port <= 0 || port > 65535) throw new LabDestinationError("invalid destination port", "harness_failure");
|
|
129
|
+
const snapshot = {
|
|
130
|
+
scheme,
|
|
131
|
+
host: parsed.hostname.toLowerCase(),
|
|
132
|
+
port,
|
|
133
|
+
basePath: normalizeBasePath(parsed.pathname),
|
|
134
|
+
sniHost: parsed.hostname.toLowerCase(),
|
|
135
|
+
addresses: frozenAddresses,
|
|
136
|
+
privateNetwork,
|
|
137
|
+
};
|
|
138
|
+
const salt = readInstallationSalt(opts.configDir);
|
|
139
|
+
const fingerprint = localFingerprint("endpoint", {
|
|
140
|
+
scheme: snapshot.scheme, host: snapshot.host, port: snapshot.port, basePath: snapshot.basePath,
|
|
141
|
+
}, salt);
|
|
142
|
+
return Object.freeze({ ...snapshot, fingerprint });
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Detect any attempted replacement/re-resolution of the approved immutable address set. */
|
|
146
|
+
export function assertDestinationAddressSet(destination: LabDestinationV1, addresses: Array<{ address: string; family: 4 | 6 }>): void {
|
|
147
|
+
if (addressSetKey(destination.addresses) !== addressSetKey(addresses)) {
|
|
148
|
+
throw new LabDestinationError("destination address set mismatch", "destination_mismatch");
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function assertHostSniMatch(destination: LabDestinationV1, host: string, sni?: string): void {
|
|
153
|
+
if (host.toLowerCase() !== destination.host) throw new LabDestinationError("host mismatch", "host_sni_mismatch");
|
|
154
|
+
if (sni !== undefined && sni.toLowerCase() !== destination.sniHost) throw new LabDestinationError("SNI mismatch", "host_sni_mismatch");
|
|
155
|
+
}
|