wowbagger 0.1.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/CHANGELOG.md +94 -0
  2. package/LICENSE +201 -0
  3. package/README.md +464 -0
  4. package/adapters/claude-code/entrypoint.js +19 -0
  5. package/adapters/claude-code/wowbagger-adapter.json +25 -0
  6. package/adapters/codex/entrypoint.js +11 -0
  7. package/adapters/codex/wowbagger-adapter.json +25 -0
  8. package/adapters/opencode/entrypoint.js +11 -0
  9. package/adapters/opencode/wowbagger-adapter.json +25 -0
  10. package/bin/wowbagger.js +7 -0
  11. package/package.json +51 -0
  12. package/skills/wowbagger/SKILL.md +136 -0
  13. package/src/adapter/approval.js +135 -0
  14. package/src/adapter/bootstrap.js +43 -0
  15. package/src/adapter/context.js +34 -0
  16. package/src/adapter/core-probe.js +231 -0
  17. package/src/adapter/describe.js +383 -0
  18. package/src/adapter/entrypoint-main.js +335 -0
  19. package/src/adapter/entrypoint-path.js +103 -0
  20. package/src/adapter/handoff.js +124 -0
  21. package/src/adapter/instructions.js +106 -0
  22. package/src/adapter/invoke.js +294 -0
  23. package/src/adapter/limits.js +26 -0
  24. package/src/adapter/manifest.js +93 -0
  25. package/src/adapter/messages.js +15 -0
  26. package/src/adapter/paths.js +88 -0
  27. package/src/adapter/process-outcome.js +1116 -0
  28. package/src/adapter/schema-helpers.js +60 -0
  29. package/src/claim-capabilities.js +54 -0
  30. package/src/claim-coordinator.js +85 -0
  31. package/src/claim-journal.js +236 -0
  32. package/src/claim-operations.js +138 -0
  33. package/src/claim-publication.js +739 -0
  34. package/src/claim-request.js +140 -0
  35. package/src/claim-store.js +198 -0
  36. package/src/cli.js +1130 -0
  37. package/src/dependencies.js +3 -0
  38. package/src/git-reconciliation.js +62 -0
  39. package/src/ledger.js +296 -0
  40. package/src/mint.js +32 -0
  41. package/src/mutation.js +1979 -0
  42. package/src/namespace.js +35 -0
  43. package/src/ready.js +85 -0
  44. package/src/request.js +246 -0
  45. package/src/schema-migration.js +300 -0
  46. package/src/validate.js +1208 -0
@@ -0,0 +1,335 @@
1
+ import { lstat, readFile } from 'node:fs/promises';
2
+ import { spawn } from 'node:child_process';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { readBootstrapRequest, writeBootstrapResponse } from './bootstrap.js';
6
+ import { CORE_COMMAND_ORDER, CORE_CONTRACT_VERSION } from './core-probe.js';
7
+ import { ADAPTER_CONTRACT_VERSION, describeAdapter } from './describe.js';
8
+ import { invokeAdapter } from './invoke.js';
9
+ import { validateAdapterManifest } from './manifest.js';
10
+ import { INVOKE_MESSAGES } from './messages.js';
11
+ import { isSafeLogicalPath } from './paths.js';
12
+ import { normalizeJsonValue, parseJsonRequest } from '../request.js';
13
+
14
+ // The launch discipline every current adapter package shares: argv-array
15
+ // core launch without a shell, guarded-relative workspace selection,
16
+ // host-provided instructions, consumer-only trusted approval. Each adapter
17
+ // still owns its declaration — it calls this factory and may override any
18
+ // member before returning, so a harness whose guarantees genuinely differ
19
+ // diverges deliberately instead of by missed edit.
20
+ const STANDARD_LIMITS = Object.freeze({
21
+ max_request_bytes: 65536,
22
+ max_context_bytes: 65536,
23
+ max_stdout_bytes: 1048576,
24
+ max_stderr_bytes: 65536,
25
+ max_timeout_ms: 30000,
26
+ });
27
+ const PROBE_LIVE_CORE = Symbol('probe-live-core');
28
+
29
+ export function standardDynamicResult(manifest, coreProbe) {
30
+ return {
31
+ ok: true,
32
+ bootstrap_wire_version: 1,
33
+ selected_adapter_contract_version: ADAPTER_CONTRACT_VERSION,
34
+ adapter_id: manifest.adapter_id,
35
+ adapter_version: manifest.adapter_version,
36
+ core: {
37
+ required_core_contract_version: CORE_CONTRACT_VERSION,
38
+ commands: [...CORE_COMMAND_ORDER],
39
+ },
40
+ host: {
41
+ command_execution: {
42
+ supported: true,
43
+ arguments_array: true,
44
+ shell: false,
45
+ stdio: true,
46
+ process_tree_containment: true,
47
+ orphan_detection: true,
48
+ timeout_enforcement: true,
49
+ stdout_limit: true,
50
+ stderr_limit: true,
51
+ },
52
+ filesystem: {
53
+ workspace_selection: 'guarded-relative',
54
+ no_follow_resolution: true,
55
+ stable_identity: true,
56
+ component_walk: true,
57
+ },
58
+ model_transport: { available: true, protocol: 'openai-compatible' },
59
+ instruction_input: { mode: 'host-provided', max_sources: 8, max_bytes: 65536 },
60
+ handoff: { supported: true, persistence: 'explicit-only' },
61
+ trusted_approval: { supported: true, sources: ['consumer'] },
62
+ integration_mechanisms: { hooks: false, slash_commands: false, mcp: false, daemon: false },
63
+ },
64
+ optional_features: { claims: coreProbe?.result?.operations?.work_claim?.supported === true, policy: false },
65
+ limits: { ...STANDARD_LIMITS },
66
+ platforms: manifest.platforms,
67
+ };
68
+ }
69
+
70
+ function appendBounded(chunks, state, chunk, limit, terminate) {
71
+ const remaining = Math.max(0, limit - state.length);
72
+ if (remaining > 0) chunks.push(chunk.subarray(0, remaining));
73
+ state.length += chunk.length;
74
+ if (state.length > limit && !state.exceeded) {
75
+ state.exceeded = true;
76
+ terminate();
77
+ }
78
+ }
79
+
80
+ export function launchCoreProcess({ executable, argv, cwd, input, limits }) {
81
+ return new Promise((resolve) => {
82
+ const detached = process.platform !== 'win32';
83
+ const child = spawn(process.execPath, [executable, ...argv], {
84
+ cwd,
85
+ detached,
86
+ shell: false,
87
+ stdio: ['pipe', 'pipe', 'pipe'],
88
+ });
89
+ const stdoutChunks = [];
90
+ const stderrChunks = [];
91
+ const stdoutState = { length: 0, exceeded: false };
92
+ const stderrState = { length: 0, exceeded: false };
93
+ let started = false;
94
+ let spawnError = false;
95
+ let timedOut = false;
96
+
97
+ const terminate = () => {
98
+ if (child.exitCode !== null || child.signalCode !== null) return;
99
+ try {
100
+ if (detached && child.pid) process.kill(-child.pid, 'SIGKILL');
101
+ else child.kill('SIGKILL');
102
+ } catch {
103
+ child.kill('SIGKILL');
104
+ }
105
+ };
106
+ const timer = setTimeout(() => {
107
+ timedOut = true;
108
+ terminate();
109
+ }, limits.timeout_ms);
110
+
111
+ child.once('spawn', () => {
112
+ started = true;
113
+ child.stdin.end(input);
114
+ });
115
+ child.once('error', () => { spawnError = true; });
116
+ child.stdout.on('data', (chunk) => {
117
+ appendBounded(stdoutChunks, stdoutState, chunk, limits.stdout_bytes, terminate);
118
+ });
119
+ child.stderr.on('data', (chunk) => {
120
+ appendBounded(stderrChunks, stderrState, chunk, limits.stderr_bytes, terminate);
121
+ });
122
+ child.once('close', (code, signal) => {
123
+ clearTimeout(timer);
124
+ const outputExceeded = stdoutState.exceeded || stderrState.exceeded;
125
+ const deliberatelyTerminated = outputExceeded || timedOut;
126
+ resolve({
127
+ started: started && !spawnError,
128
+ process_tree_contained: true,
129
+ orphaned: false,
130
+ exit_code: deliberatelyTerminated || !Number.isInteger(code) ? null : code,
131
+ signal: deliberatelyTerminated ? null : signal,
132
+ timed_out: timedOut,
133
+ stdout_complete: !stdoutState.exceeded,
134
+ stderr_complete: !stderrState.exceeded,
135
+ stdout_base64: Buffer.concat(stdoutChunks).toString('base64'),
136
+ stderr_base64: Buffer.concat(stderrChunks).toString('base64'),
137
+ });
138
+ });
139
+ });
140
+ }
141
+
142
+ async function probeCore(coreExecutable, packageRoot) {
143
+ const observation = await launchCoreProcess({
144
+ executable: coreExecutable,
145
+ argv: ['capabilities', '--json'],
146
+ cwd: packageRoot,
147
+ input: Buffer.alloc(0),
148
+ limits: {
149
+ stdout_bytes: STANDARD_LIMITS.max_stdout_bytes,
150
+ stderr_bytes: STANDARD_LIMITS.max_stderr_bytes,
151
+ timeout_ms: STANDARD_LIMITS.max_timeout_ms,
152
+ },
153
+ });
154
+ if (!observation.started || observation.exit_code !== 0
155
+ || !observation.stdout_complete || !observation.stderr_complete) return undefined;
156
+ const parsed = parseJsonRequest(Buffer.from(observation.stdout_base64, 'base64'));
157
+ return parsed.issues.length === 0 ? normalizeJsonValue(parsed.value) : undefined;
158
+ }
159
+
160
+ // The installed package's own manifest file is read as bytes and parsed
161
+ // with the same strict-JSON parser used for the wire request (section 3.1
162
+ // declares the manifest is "strict JSON", the same standard section 10
163
+ // holds the fixtures to). A missing/unreadable file, syntactically invalid
164
+ // JSON, or a duplicate top-level member (e.g. a hostile second adapter_id
165
+ // that a lenient last-wins parser would silently accept) all resolve to
166
+ // `undefined` rather than throwing; `validateAdapterManifest(undefined)`
167
+ // already refuses with `invalid-adapter-manifest`, so the caller does not
168
+ // need a separate load-failure branch.
169
+ async function loadManifest(manifestUrl) {
170
+ let bytes;
171
+ try {
172
+ bytes = await readFile(fileURLToPath(manifestUrl));
173
+ } catch {
174
+ return undefined;
175
+ }
176
+ const parsed = parseJsonRequest(bytes);
177
+ if (parsed.issues.length > 0) {
178
+ return undefined;
179
+ }
180
+ return normalizeJsonValue(parsed.value);
181
+ }
182
+
183
+ async function loadWorkspaceRoots(workspaceConfigUrl) {
184
+ if (!workspaceConfigUrl) return Object.create(null);
185
+ let parsed;
186
+ try {
187
+ parsed = parseJsonRequest(await readFile(fileURLToPath(workspaceConfigUrl)));
188
+ } catch {
189
+ return Object.create(null);
190
+ }
191
+ if (parsed.issues.length > 0) return Object.create(null);
192
+ const roots = normalizeJsonValue(parsed.value);
193
+ if (roots === null || typeof roots !== 'object' || Array.isArray(roots)) {
194
+ return Object.create(null);
195
+ }
196
+ return roots;
197
+ }
198
+
199
+ function logicalComponents(logicalPath) {
200
+ if (!isSafeLogicalPath(logicalPath) || logicalPath === '.') return [];
201
+ const segments = logicalPath.split('/');
202
+ return segments.map((_, index) => segments.slice(0, index + 1).join('/'));
203
+ }
204
+
205
+ async function pathSnapshot(absolutePath) {
206
+ try {
207
+ const stats = await lstat(absolutePath);
208
+ const kind = stats.isDirectory()
209
+ ? 'directory'
210
+ : stats.isSymbolicLink() ? 'symbolic-link' : stats.isFile() ? 'regular-file' : 'special';
211
+ return { kind, identity: { dev: stats.dev, ino: stats.ino } };
212
+ } catch {
213
+ return { kind: 'missing', identity: 'missing' };
214
+ }
215
+ }
216
+
217
+ async function captureWorkspace(root, request) {
218
+ const snapshot = { '.': await pathSnapshot(root) };
219
+ const components = new Set([
220
+ ...logicalComponents(request.workspace.cwd ?? '.'),
221
+ ...logicalComponents(request.core_request.ledger),
222
+ ]);
223
+ for (const component of components) {
224
+ snapshot[component] = await pathSnapshot(path.join(root, ...component.split('/')));
225
+ }
226
+ return snapshot;
227
+ }
228
+
229
+ async function invocationWorkspaces(request, workspaceRoots) {
230
+ if (request?.core_request?.command === 'capabilities' || !request?.workspace) return {};
231
+ const root = workspaceRoots[request.workspace.workspace_id];
232
+ if (typeof root !== 'string' || !path.isAbsolute(root)) return {};
233
+ const approvedRoot = path.resolve(root);
234
+ const before = await captureWorkspace(approvedRoot, request);
235
+ const after = await captureWorkspace(approvedRoot, request);
236
+ return {
237
+ [request.workspace.workspace_id]: { root: approvedRoot, before, after },
238
+ };
239
+ }
240
+
241
+ // The shared §3.3 entrypoint flow every adapter package runs: load and
242
+ // validate its own manifest, read one bootstrap request, answer describe or
243
+ // refuse. Each adapter supplies only its manifest location and its honest
244
+ // host declaration through `dynamicResult(manifest)`.
245
+ export async function runAdapterEntrypoint({
246
+ manifestUrl,
247
+ dynamicResult,
248
+ packageRoot = fileURLToPath(new URL('../../', manifestUrl)),
249
+ coreExecutable = fileURLToPath(new URL('../../bin/wowbagger.js', import.meta.url)),
250
+ workspaceConfigUrl,
251
+ coreProbe: suppliedCoreProbe = PROBE_LIVE_CORE,
252
+ launch: suppliedLaunch,
253
+ argv = process.argv,
254
+ }) {
255
+ const [operation] = argv.slice(2);
256
+ const manifest = await loadManifest(manifestUrl);
257
+
258
+ // §3.1: the package's own manifest is validated before it is advertised.
259
+ const validated = validateAdapterManifest(manifest);
260
+ if (!validated.ok) {
261
+ await writeBootstrapResponse(process.stdout, { ok: false, error: { code: validated.error_code } });
262
+ return;
263
+ }
264
+
265
+ const coreProbe = suppliedCoreProbe === PROBE_LIVE_CORE
266
+ ? await probeCore(coreExecutable, packageRoot)
267
+ : suppliedCoreProbe;
268
+ const dynamic = dynamicResult(manifest, coreProbe);
269
+
270
+ // §3.3 (item 39): an unknown operation is refused without reading stdin.
271
+ // Reading would otherwise wait on a host that never closes stdin, and a
272
+ // malformed read would misreport as a describe error instead of an
273
+ // invalid-invocation. Only describe and invoke are valid bootstrap ops.
274
+ if (operation !== 'describe' && operation !== 'invoke') {
275
+ await writeBootstrapResponse(process.stdout, { ok: false, error: { code: 'invalid-invocation' } });
276
+ return;
277
+ }
278
+
279
+ // Both describe and invoke reads share the configured request byte ceiling
280
+ // (item 39, D1). describe was previously unbounded — the first call any
281
+ // host makes is the more exposed surface, so it must be bounded too.
282
+ const incoming = await readBootstrapRequest(process.stdin, {
283
+ maxBytes: dynamic.limits.max_request_bytes,
284
+ errorCode: operation === 'invoke' ? 'invalid-invocation' : 'invalid-describe-request',
285
+ });
286
+ if (!incoming.ok) {
287
+ const response = operation === 'invoke'
288
+ ? {
289
+ ok: false,
290
+ adapter_contract_version: ADAPTER_CONTRACT_VERSION,
291
+ request_id: null,
292
+ error: {
293
+ code: incoming.error_code,
294
+ message: INVOKE_MESSAGES[incoming.error_code]
295
+ ?? 'The adapter invocation is invalid.',
296
+ details: incoming.detail,
297
+ },
298
+ }
299
+ : { ok: false, error: { code: incoming.error_code } };
300
+ await writeBootstrapResponse(process.stdout, response);
301
+ return;
302
+ }
303
+
304
+ if (operation === 'describe') {
305
+ const described = describeAdapter(incoming.request, manifest, dynamic);
306
+ await writeBootstrapResponse(
307
+ process.stdout,
308
+ described.ok ? described.result : { ok: false, error: { code: described.error_code } },
309
+ );
310
+ return;
311
+ }
312
+
313
+ if (operation === 'invoke') {
314
+ const workspaceRoots = await loadWorkspaceRoots(workspaceConfigUrl);
315
+ const workspaces = await invocationWorkspaces(incoming.request, workspaceRoots);
316
+ const response = await invokeAdapter(incoming.bytes, {
317
+ max_request_bytes: dynamic.limits.max_request_bytes,
318
+ describe_request: {
319
+ bootstrap_wire_version: 1,
320
+ supported_adapter_contract_versions: [ADAPTER_CONTRACT_VERSION],
321
+ request_id: 'entrypoint-invoke-describe',
322
+ },
323
+ manifest,
324
+ dynamic,
325
+ core_probe: coreProbe,
326
+ platform: process.platform,
327
+ package_root: packageRoot,
328
+ workspaces,
329
+ launch: suppliedLaunch
330
+ ?? ((request) => launchCoreProcess({ executable: coreExecutable, ...request })),
331
+ });
332
+ await writeBootstrapResponse(process.stdout, response);
333
+ return;
334
+ }
335
+ }
@@ -0,0 +1,103 @@
1
+ import { hasControlCharacter, isSafeRelativeExecutable } from './manifest.js';
2
+
3
+ function rejected() {
4
+ return { ok: false, error_code: 'path-rejected' };
5
+ }
6
+
7
+ function replaced() {
8
+ return { ok: false, error_code: 'path-replaced' };
9
+ }
10
+
11
+ // The package root plus every cumulative parent segment (directories), then
12
+ // the full executable path (the final regular file), per contract section
13
+ // 3.1 / 4: "the package root, every parent component, and the final regular
14
+ // file are resolved no-follow and their stable identities are rechecked
15
+ // immediately before launch."
16
+ function componentsFor(executable) {
17
+ const segments = executable.split('/');
18
+ const components = ['.'];
19
+ for (let index = 1; index < segments.length; index += 1) {
20
+ components.push(segments.slice(0, index).join('/'));
21
+ }
22
+ components.push(executable);
23
+ return components;
24
+ }
25
+
26
+ function isControlFreeNonEmptyString(value) {
27
+ return typeof value === 'string' && value.length > 0 && !hasControlCharacter(value);
28
+ }
29
+
30
+ function isIdentityMember(value) {
31
+ return isControlFreeNonEmptyString(value) || (Number.isSafeInteger(value) && value >= 0);
32
+ }
33
+
34
+ // `identity` is a nonempty control-free opaque token, an exact POSIX
35
+ // { dev, ino } object, or an exact Windows { volume_id, file_id } object.
36
+ function isValidIdentity(identity) {
37
+ if (isControlFreeNonEmptyString(identity)) {
38
+ return true;
39
+ }
40
+ if (identity === null || typeof identity !== 'object' || Array.isArray(identity)) {
41
+ return false;
42
+ }
43
+ const members = Object.keys(identity).sort().join(',');
44
+ if (members === 'dev,ino') {
45
+ return isIdentityMember(identity.dev) && isIdentityMember(identity.ino);
46
+ }
47
+ if (members === 'file_id,volume_id') {
48
+ return isIdentityMember(identity.volume_id) && isIdentityMember(identity.file_id);
49
+ }
50
+ return false;
51
+ }
52
+
53
+ function identitiesEqual(a, b) {
54
+ if (typeof a === 'string' || typeof b === 'string') {
55
+ return a === b;
56
+ }
57
+ const keys = Object.keys(a);
58
+ return keys.length === Object.keys(b).length && keys.every((key) => a[key] === b[key]);
59
+ }
60
+
61
+ // Every before/after snapshot is the exact object { kind, identity }. `kind`
62
+ // is the required portable kind for that position: `directory` for the
63
+ // package root and every parent, `regular-file` for the command executable.
64
+ function isValidSnapshot(snapshot, expectedKind) {
65
+ if (snapshot === null || typeof snapshot !== 'object' || Array.isArray(snapshot)) {
66
+ return false;
67
+ }
68
+ if (Object.keys(snapshot).sort().join(',') !== 'identity,kind') {
69
+ return false;
70
+ }
71
+ if (snapshot.kind !== expectedKind) {
72
+ return false;
73
+ }
74
+ return isValidIdentity(snapshot.identity);
75
+ }
76
+
77
+ export function resolveEntrypointPath({ package_root: packageRoot, executable, before, after }) {
78
+ if (typeof packageRoot !== 'string' || packageRoot === '') {
79
+ return rejected();
80
+ }
81
+ if (!isSafeRelativeExecutable(executable)) {
82
+ return rejected();
83
+ }
84
+ if (before === null || typeof before !== 'object' || after === null || typeof after !== 'object') {
85
+ return rejected();
86
+ }
87
+
88
+ const components = componentsFor(executable);
89
+ for (const component of components) {
90
+ const expectedKind = component === executable ? 'regular-file' : 'directory';
91
+ if (!isValidSnapshot(before[component], expectedKind) || !isValidSnapshot(after[component], expectedKind)) {
92
+ return rejected();
93
+ }
94
+ }
95
+
96
+ for (const component of components) {
97
+ if (!identitiesEqual(before[component].identity, after[component].identity)) {
98
+ return replaced();
99
+ }
100
+ }
101
+
102
+ return { ok: true, path: `${packageRoot}/${executable}` };
103
+ }
@@ -0,0 +1,124 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ import { normalizeJsonValue, parseJsonRequest } from '../request.js';
4
+ import { hasExactMembers, isNonNegativeSafeInteger } from './schema-helpers.js';
5
+
6
+ const DIGEST = /^sha256:[a-f0-9]{64}$/;
7
+ const WOWBAGGER_ID = /^wb_[0-7][0-9A-HJKMNP-TV-Z]{25}$/;
8
+
9
+ function refuse(error_code, detail = {}) {
10
+ return { ok: false, error_code, detail };
11
+ }
12
+
13
+ function digest(bytes) {
14
+ return `sha256:${createHash('sha256').update(bytes).digest('hex')}`;
15
+ }
16
+
17
+ function decodeCanonicalBase64(value) {
18
+ if (typeof value !== 'string' || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) {
19
+ return null;
20
+ }
21
+ const bytes = Buffer.from(value, 'base64');
22
+ return bytes.toString('base64') === value ? bytes : null;
23
+ }
24
+
25
+ export function validateHandoffCarrier(carrier, options) {
26
+ if (!hasExactMembers(carrier, [
27
+ 'handoff_carrier_version', 'workspace_id', 'content_encoding', 'content_base64',
28
+ 'byte_length', 'sha256', 'resume_request',
29
+ ])
30
+ || carrier.handoff_carrier_version !== 1
31
+ || carrier.content_encoding !== 'base64'
32
+ || !isNonNegativeSafeInteger(carrier.byte_length)
33
+ || !DIGEST.test(carrier.sha256)) {
34
+ return refuse('invalid-handoff-carrier');
35
+ }
36
+ if (carrier.workspace_id !== options.workspace_id) {
37
+ return refuse('handoff-workspace-mismatch', { member: 'carrier.workspace_id' });
38
+ }
39
+ if (!hasExactMembers(carrier.resume_request, [
40
+ 'item_id', 'expected_revision', 'instruction_set_digest',
41
+ ])
42
+ || !WOWBAGGER_ID.test(carrier.resume_request.item_id)
43
+ || !DIGEST.test(carrier.resume_request.expected_revision)
44
+ || !DIGEST.test(carrier.resume_request.instruction_set_digest)) {
45
+ return refuse('invalid-handoff-resume-request');
46
+ }
47
+
48
+ const bytes = decodeCanonicalBase64(carrier.content_base64);
49
+ if (bytes === null || bytes.length !== carrier.byte_length || digest(bytes) !== carrier.sha256) {
50
+ return refuse('invalid-handoff-bytes');
51
+ }
52
+ if (bytes.length > options.max_bytes) return refuse('handoff-limit-exceeded');
53
+ const parsed = parseJsonRequest(bytes);
54
+ if (parsed.issues.length > 0) return refuse('invalid-handoff-json');
55
+ const handoff = normalizeJsonValue(parsed.value);
56
+ if (!hasExactMembers(handoff, [
57
+ 'handoff_version', 'workspace_id', 'instruction_set_digest', 'item',
58
+ ])
59
+ || handoff.handoff_version !== 1
60
+ || !DIGEST.test(handoff.instruction_set_digest)
61
+ || !hasExactMembers(handoff.item, ['id', 'revision'])
62
+ || !WOWBAGGER_ID.test(handoff.item.id)
63
+ || !DIGEST.test(handoff.item.revision)) {
64
+ return refuse('invalid-handoff-object');
65
+ }
66
+ if (handoff.workspace_id !== options.workspace_id || handoff.workspace_id !== carrier.workspace_id) {
67
+ return refuse('handoff-workspace-mismatch', { member: 'handoff.workspace_id' });
68
+ }
69
+ const request = carrier.resume_request;
70
+ if (request.item_id !== handoff.item.id
71
+ || request.expected_revision !== handoff.item.revision
72
+ || request.instruction_set_digest !== handoff.instruction_set_digest) {
73
+ return refuse('handoff-resume-binding-mismatch');
74
+ }
75
+ if (request.instruction_set_digest !== options.current.instruction_set_digest) {
76
+ return refuse('handoff-instruction-set-mismatch');
77
+ }
78
+ if (request.item_id !== options.current.item_id) return refuse('handoff-item-mismatch');
79
+ if (request.expected_revision !== options.current.revision) {
80
+ return refuse('handoff-stale-item-revision', {
81
+ expected: request.expected_revision,
82
+ current: options.current.revision,
83
+ });
84
+ }
85
+ return { ok: true, byte_length: bytes.length, handoff };
86
+ }
87
+
88
+ export function buildResumePlan(carrier, options) {
89
+ const validated = validateHandoffCarrier(carrier, options);
90
+ if (!validated.ok) return validated;
91
+ return {
92
+ ok: true,
93
+ must_invoke: ['describe', 'validate', 'inspect'],
94
+ must_compare: ['instruction-set-digest', 'item-revision'],
95
+ forbidden_automatic_actions: ['claim-renewal', 'create', 'git-commit', 'git-push', 'transition'],
96
+ };
97
+ }
98
+
99
+ export function validateHandoffResume({
100
+ handoff_bytes: handoffBytes,
101
+ handoff_digest: handoffDigest,
102
+ resume_request: resumeRequest,
103
+ current,
104
+ max_bytes: maxBytes,
105
+ }) {
106
+ if (!(handoffBytes instanceof Uint8Array) || handoffBytes.length > maxBytes) {
107
+ return refuse('handoff-limit-exceeded');
108
+ }
109
+ if (digest(handoffBytes) !== handoffDigest) return refuse('handoff-digest-mismatch');
110
+ const parsedResumeBytes = parseJsonRequest(handoffBytes);
111
+ if (parsedResumeBytes.issues.length > 0) return refuse('invalid-handoff-json');
112
+ if (!WOWBAGGER_ID.test(resumeRequest?.item_id)) return refuse('invalid-handoff-resume-request');
113
+ if (resumeRequest.instruction_set_digest !== current.instruction_set_digest) {
114
+ return refuse('handoff-instruction-set-mismatch');
115
+ }
116
+ if (resumeRequest.item_id !== current.item_id) return refuse('handoff-item-mismatch');
117
+ if (resumeRequest.expected_revision !== current.revision) {
118
+ return refuse('handoff-stale-item-revision', {
119
+ expected: resumeRequest.expected_revision,
120
+ current: current.revision,
121
+ });
122
+ }
123
+ return { ok: true };
124
+ }
@@ -0,0 +1,106 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ import { isSafeLogicalPath } from './paths.js';
4
+ import { hasExactMembers, isNonNegativeSafeInteger } from './schema-helpers.js';
5
+
6
+ const DIGEST = /^sha256:[a-f0-9]{64}$/;
7
+ const SAFE_ID = /^[A-Za-z0-9._-]{1,128}$/;
8
+ const ORIGIN_PRECEDENCE = new Map([
9
+ ['consumer', 0],
10
+ ['repository', 1],
11
+ ['harness', 2],
12
+ ['user', 3],
13
+ ['adapter', 4],
14
+ ]);
15
+
16
+ function refuse(error_code, detail = {}) {
17
+ return { ok: false, error_code, detail };
18
+ }
19
+
20
+ function digest(bytes) {
21
+ return `sha256:${createHash('sha256').update(bytes).digest('hex')}`;
22
+ }
23
+
24
+ function canonicalJson(value) {
25
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
26
+ if (value !== null && typeof value === 'object') {
27
+ return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(',')}}`;
28
+ }
29
+ return JSON.stringify(value);
30
+ }
31
+
32
+ function decodeCanonicalBase64(value) {
33
+ if (typeof value !== 'string' || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) {
34
+ return null;
35
+ }
36
+ const bytes = Buffer.from(value, 'base64');
37
+ return bytes.toString('base64') === value ? bytes : null;
38
+ }
39
+
40
+ export function validateInstructionInput(input, limits) {
41
+ if (!hasExactMembers(input, ['instruction_input_version', 'required', 'sources'])
42
+ || input.instruction_input_version !== 1
43
+ || typeof input.required !== 'boolean'
44
+ || !Array.isArray(input.sources)) {
45
+ return refuse('invalid-instruction-input');
46
+ }
47
+ if (input.required && input.sources.length === 0) {
48
+ return refuse('required-instruction-input-missing');
49
+ }
50
+ if (input.sources.length > limits.max_sources) {
51
+ return refuse('instruction-source-limit-exceeded');
52
+ }
53
+
54
+ const seen = new Set();
55
+ const summary = [];
56
+ const diagnostics = [];
57
+ let totalBytes = 0;
58
+ for (const [ordinal, source] of input.sources.entries()) {
59
+ if (!hasExactMembers(source, [
60
+ 'source_id', 'origin', 'content_encoding', 'content_base64', 'sha256', 'byte_length',
61
+ ], ['logical_path'])
62
+ || typeof source.source_id !== 'string'
63
+ || !SAFE_ID.test(source.source_id)
64
+ || !ORIGIN_PRECEDENCE.has(source.origin)
65
+ || source.content_encoding !== 'base64'
66
+ || !DIGEST.test(source.sha256)
67
+ || !isNonNegativeSafeInteger(source.byte_length)
68
+ || (Object.hasOwn(source, 'logical_path') && !isSafeLogicalPath(source.logical_path))) {
69
+ return refuse('invalid-instruction-source', { source_id: source?.source_id ?? null });
70
+ }
71
+ if (seen.has(source.source_id)) {
72
+ return refuse('duplicate-instruction-source-id', { source_id: source.source_id });
73
+ }
74
+ seen.add(source.source_id);
75
+ const bytes = decodeCanonicalBase64(source.content_base64);
76
+ if (bytes === null || bytes.length !== source.byte_length || digest(bytes) !== source.sha256) {
77
+ return refuse('invalid-instruction-source', { source_id: source.source_id });
78
+ }
79
+ totalBytes += bytes.length;
80
+ const record = {
81
+ source_id: source.source_id,
82
+ origin: source.origin,
83
+ sha256: source.sha256,
84
+ byte_length: source.byte_length,
85
+ };
86
+ summary.push(record);
87
+ diagnostics.push({
88
+ ordinal,
89
+ source_id: source.source_id,
90
+ origin: source.origin,
91
+ precedence: ORIGIN_PRECEDENCE.get(source.origin),
92
+ logical_path: source.logical_path ?? null,
93
+ byte_length: source.byte_length,
94
+ sha256: source.sha256,
95
+ });
96
+ }
97
+ if (totalBytes > limits.max_bytes) return refuse('instruction-byte-limit-exceeded');
98
+
99
+ return {
100
+ ok: true,
101
+ ordered_sources: summary.map(({ source_id }) => source_id),
102
+ total_bytes: totalBytes,
103
+ instruction_set_digest: digest(Buffer.from(canonicalJson(summary))),
104
+ diagnostics,
105
+ };
106
+ }