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.
- package/CHANGELOG.md +94 -0
- package/LICENSE +201 -0
- package/README.md +464 -0
- package/adapters/claude-code/entrypoint.js +19 -0
- package/adapters/claude-code/wowbagger-adapter.json +25 -0
- package/adapters/codex/entrypoint.js +11 -0
- package/adapters/codex/wowbagger-adapter.json +25 -0
- package/adapters/opencode/entrypoint.js +11 -0
- package/adapters/opencode/wowbagger-adapter.json +25 -0
- package/bin/wowbagger.js +7 -0
- package/package.json +51 -0
- package/skills/wowbagger/SKILL.md +136 -0
- package/src/adapter/approval.js +135 -0
- package/src/adapter/bootstrap.js +43 -0
- package/src/adapter/context.js +34 -0
- package/src/adapter/core-probe.js +231 -0
- package/src/adapter/describe.js +383 -0
- package/src/adapter/entrypoint-main.js +335 -0
- package/src/adapter/entrypoint-path.js +103 -0
- package/src/adapter/handoff.js +124 -0
- package/src/adapter/instructions.js +106 -0
- package/src/adapter/invoke.js +294 -0
- package/src/adapter/limits.js +26 -0
- package/src/adapter/manifest.js +93 -0
- package/src/adapter/messages.js +15 -0
- package/src/adapter/paths.js +88 -0
- package/src/adapter/process-outcome.js +1116 -0
- package/src/adapter/schema-helpers.js +60 -0
- package/src/claim-capabilities.js +54 -0
- package/src/claim-coordinator.js +85 -0
- package/src/claim-journal.js +236 -0
- package/src/claim-operations.js +138 -0
- package/src/claim-publication.js +739 -0
- package/src/claim-request.js +140 -0
- package/src/claim-store.js +198 -0
- package/src/cli.js +1130 -0
- package/src/dependencies.js +3 -0
- package/src/git-reconciliation.js +62 -0
- package/src/ledger.js +296 -0
- package/src/mint.js +32 -0
- package/src/mutation.js +1979 -0
- package/src/namespace.js +35 -0
- package/src/ready.js +85 -0
- package/src/request.js +246 -0
- package/src/schema-migration.js +300 -0
- package/src/validate.js +1208 -0
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import { ADAPTER_CONTRACT_VERSION, describeAdapter } from './describe.js';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { verifyMutationAuthority } from './approval.js';
|
|
4
|
+
import { validateInvokeContext } from './context.js';
|
|
5
|
+
import { verifyCoreProbe } from './core-probe.js';
|
|
6
|
+
import { validateInvocationLimits } from './limits.js';
|
|
7
|
+
import { resolveInvocationPaths } from './paths.js';
|
|
8
|
+
import { mapProcessOutcome } from './process-outcome.js';
|
|
9
|
+
import { hasExactMembers, isPositiveSafeInteger } from './schema-helpers.js';
|
|
10
|
+
import { normalizeJsonValue, parseJsonRequest } from '../request.js';
|
|
11
|
+
import { INVOKE_MESSAGES } from './messages.js';
|
|
12
|
+
|
|
13
|
+
const SAFE_ID = /^[A-Za-z0-9._-]{1,128}$/;
|
|
14
|
+
const WOWBAGGER_ID = /^wb_[0-7][0-9A-HJKMNP-TV-Z]{25}$/;
|
|
15
|
+
const DIGEST = /^sha256:[a-f0-9]{64}$/;
|
|
16
|
+
|
|
17
|
+
function isMutationCommand(command) {
|
|
18
|
+
return command === 'create' || command === 'transition' || command === 'patch';
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const MESSAGES = INVOKE_MESSAGES;
|
|
22
|
+
|
|
23
|
+
function coreRequestIssue(coreRequest) {
|
|
24
|
+
if (coreRequest?.command === 'capabilities') {
|
|
25
|
+
return hasExactMembers(coreRequest, ['command']) ? null : 'core_request';
|
|
26
|
+
}
|
|
27
|
+
if (coreRequest?.command === 'validate') {
|
|
28
|
+
return hasExactMembers(coreRequest, ['command', 'ledger']) && typeof coreRequest.ledger === 'string'
|
|
29
|
+
? null : 'core_request';
|
|
30
|
+
}
|
|
31
|
+
if (coreRequest?.command === 'ready') {
|
|
32
|
+
return hasExactMembers(coreRequest, ['command', 'ledger', 'as_of'])
|
|
33
|
+
&& typeof coreRequest.ledger === 'string' && typeof coreRequest.as_of === 'string'
|
|
34
|
+
? null : 'core_request';
|
|
35
|
+
}
|
|
36
|
+
if (coreRequest?.command === 'inspect') {
|
|
37
|
+
return hasExactMembers(coreRequest, ['command', 'ledger', 'id'])
|
|
38
|
+
&& typeof coreRequest.ledger === 'string' && typeof coreRequest.id === 'string'
|
|
39
|
+
? null : 'core_request';
|
|
40
|
+
}
|
|
41
|
+
if (isMutationCommand(coreRequest?.command)) {
|
|
42
|
+
return hasExactMembers(coreRequest, ['command', 'ledger', 'input_base64'])
|
|
43
|
+
&& typeof coreRequest.ledger === 'string'
|
|
44
|
+
&& typeof coreRequest.input_base64 === 'string'
|
|
45
|
+
? null : 'core_request';
|
|
46
|
+
}
|
|
47
|
+
return 'core_request';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function argumentVector(coreRequest, ledger) {
|
|
51
|
+
switch (coreRequest.command) {
|
|
52
|
+
case 'capabilities': return ['capabilities', '--json'];
|
|
53
|
+
case 'validate': return ['validate', '--ledger', ledger, '--json'];
|
|
54
|
+
case 'ready': return ['ready', '--ledger', ledger, '--as-of', coreRequest.as_of, '--json'];
|
|
55
|
+
case 'inspect': return ['inspect', '--ledger', ledger, '--id', coreRequest.id, '--json'];
|
|
56
|
+
case 'create': return ['create', '--ledger', ledger, '--input', '-', '--json'];
|
|
57
|
+
case 'patch': return ['patch', '--ledger', ledger, '--input', '-', '--json'];
|
|
58
|
+
case 'transition': return ['transition', '--ledger', ledger, '--input', '-', '--json'];
|
|
59
|
+
default: throw new TypeError('unsupported core command');
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function launchFailureObservation() {
|
|
64
|
+
return {
|
|
65
|
+
started: false,
|
|
66
|
+
process_tree_contained: true,
|
|
67
|
+
orphaned: false,
|
|
68
|
+
exit_code: null,
|
|
69
|
+
signal: null,
|
|
70
|
+
timed_out: false,
|
|
71
|
+
stdout_complete: true,
|
|
72
|
+
stderr_complete: true,
|
|
73
|
+
stdout_base64: '',
|
|
74
|
+
stderr_base64: '',
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function streamEnvelope(bytes) {
|
|
79
|
+
return {
|
|
80
|
+
encoding: 'base64',
|
|
81
|
+
data: bytes.toString('base64'),
|
|
82
|
+
sha256: `sha256:${createHash('sha256').update(bytes).digest('hex')}`,
|
|
83
|
+
byte_length: bytes.length,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function refusal(requestId, code, details) {
|
|
88
|
+
return {
|
|
89
|
+
ok: false,
|
|
90
|
+
adapter_contract_version: ADAPTER_CONTRACT_VERSION,
|
|
91
|
+
request_id: requestId,
|
|
92
|
+
error: {
|
|
93
|
+
code,
|
|
94
|
+
message: MESSAGES[code] ?? `The adapter refused the operation (${code}).`,
|
|
95
|
+
details,
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export async function invokeAdapter(requestBytes, runtime) {
|
|
101
|
+
if (!(requestBytes instanceof Uint8Array) || !isPositiveSafeInteger(runtime?.max_request_bytes)) {
|
|
102
|
+
return refusal(null, 'invalid-invocation', { member: 'request' });
|
|
103
|
+
}
|
|
104
|
+
if (requestBytes.byteLength > runtime.max_request_bytes) {
|
|
105
|
+
return refusal(null, 'invalid-invocation', {
|
|
106
|
+
member: 'request', reason: 'byte-limit-exceeded', limit_bytes: runtime.max_request_bytes,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
const parsed = parseJsonRequest(requestBytes);
|
|
110
|
+
if (parsed.issues.length > 0) {
|
|
111
|
+
return refusal(null, 'invalid-invocation', { member: 'request_json' });
|
|
112
|
+
}
|
|
113
|
+
const request = normalizeJsonValue(parsed.value);
|
|
114
|
+
const requestId = typeof request?.request_id === 'string' && SAFE_ID.test(request.request_id)
|
|
115
|
+
? request.request_id
|
|
116
|
+
: null;
|
|
117
|
+
if (!hasExactMembers(request, [
|
|
118
|
+
'adapter_contract_version', 'request_id', 'core_request', 'instruction_input', 'handoff_carrier', 'limits',
|
|
119
|
+
], ['workspace']) || requestId === null) {
|
|
120
|
+
return refusal(requestId, 'invalid-invocation', { member: 'request' });
|
|
121
|
+
}
|
|
122
|
+
const described = describeAdapter(runtime.describe_request, runtime.manifest, runtime.dynamic);
|
|
123
|
+
if (!described.ok) {
|
|
124
|
+
return refusal(requestId, described.error_code, described.detail);
|
|
125
|
+
}
|
|
126
|
+
if (described.result.limits.max_request_bytes > runtime.max_request_bytes) {
|
|
127
|
+
return refusal(requestId, 'invalid-describe-result', { member: 'limits.max_request_bytes' });
|
|
128
|
+
}
|
|
129
|
+
if (requestBytes.byteLength > described.result.limits.max_request_bytes) {
|
|
130
|
+
return refusal(requestId, 'invalid-invocation', {
|
|
131
|
+
member: 'request', reason: 'byte-limit-exceeded', limit_bytes: described.result.limits.max_request_bytes,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
if (request.adapter_contract_version !== described.result.selected_adapter_contract_version) {
|
|
135
|
+
return refusal(requestId, 'adapter-contract-selection-mismatch', {
|
|
136
|
+
expected: described.result.selected_adapter_contract_version,
|
|
137
|
+
received: request.adapter_contract_version,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
const command = request.core_request?.command;
|
|
141
|
+
const mutation = isMutationCommand(command);
|
|
142
|
+
const required = ['command-execution'];
|
|
143
|
+
if (command !== 'capabilities') required.push('guarded-filesystem');
|
|
144
|
+
const available = [];
|
|
145
|
+
if (described.result.host.command_execution.supported) available.push('command-execution');
|
|
146
|
+
if (described.result.host.filesystem.workspace_selection === 'guarded-relative') {
|
|
147
|
+
available.push('guarded-filesystem');
|
|
148
|
+
}
|
|
149
|
+
const missing = required.filter((capability) => !available.includes(capability));
|
|
150
|
+
if (missing.length > 0) return refusal(requestId, 'capability-unavailable', { missing });
|
|
151
|
+
|
|
152
|
+
const activePlatform = runtime.platform ?? process.platform;
|
|
153
|
+
const platformStatus = described.result.platforms[activePlatform] ?? 'unknown';
|
|
154
|
+
if (platformStatus !== 'supported') {
|
|
155
|
+
return refusal(requestId, 'adapter-platform-mismatch', {
|
|
156
|
+
platform: activePlatform, status: platformStatus, required: 'supported',
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
const probed = verifyCoreProbe(described.result, runtime.core_probe);
|
|
160
|
+
if (!probed.ok) return refusal(requestId, probed.error_code, probed.detail);
|
|
161
|
+
const limits = validateInvocationLimits(request.limits, described.result.limits);
|
|
162
|
+
if (!limits.ok) return refusal(requestId, limits.error_code, limits.detail);
|
|
163
|
+
const requestIssue = coreRequestIssue(request.core_request);
|
|
164
|
+
if (requestIssue) return refusal(requestId, 'invalid-invocation', { member: requestIssue });
|
|
165
|
+
if (request.handoff_carrier !== null && described.result.host.handoff.supported !== true) {
|
|
166
|
+
return refusal(requestId, 'capability-unavailable', { missing: ['handoff'] });
|
|
167
|
+
}
|
|
168
|
+
const context = validateInvokeContext({
|
|
169
|
+
instruction_input: request.instruction_input,
|
|
170
|
+
handoff_carrier: request.handoff_carrier,
|
|
171
|
+
context_bytes: request.limits.context_bytes,
|
|
172
|
+
instruction_limits: described.result.host.instruction_input,
|
|
173
|
+
handoff_options: {
|
|
174
|
+
workspace_id: request.workspace?.workspace_id,
|
|
175
|
+
max_bytes: request.limits.context_bytes,
|
|
176
|
+
current: runtime.handoff_current,
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
if (!context.ok) {
|
|
180
|
+
return refusal(requestId, context.error_code, context.detail);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
let workspace = null;
|
|
184
|
+
if (command !== 'capabilities') {
|
|
185
|
+
if (!hasExactMembers(request.workspace, ['workspace_id'], ['cwd'])
|
|
186
|
+
|| typeof request.workspace.workspace_id !== 'string') {
|
|
187
|
+
return refusal(requestId, 'invalid-invocation', { member: 'workspace' });
|
|
188
|
+
}
|
|
189
|
+
const configured = runtime.workspaces?.[request.workspace.workspace_id];
|
|
190
|
+
if (!configured) {
|
|
191
|
+
return refusal(requestId, 'path-rejected', {
|
|
192
|
+
path_role: 'workspace', kind: 'unconfigured-workspace',
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
const resolved = resolveInvocationPaths({
|
|
196
|
+
workspace_root: configured.root,
|
|
197
|
+
cwd: request.workspace.cwd ?? '.',
|
|
198
|
+
ledger: request.core_request.ledger,
|
|
199
|
+
before: configured.before,
|
|
200
|
+
after: configured.after,
|
|
201
|
+
});
|
|
202
|
+
if (!resolved.ok) return refusal(requestId, resolved.error_code, resolved.detail);
|
|
203
|
+
workspace = resolved;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const argv = argumentVector(request.core_request, workspace?.ledger);
|
|
207
|
+
const mutationInput = mutation
|
|
208
|
+
? Buffer.from(request.core_request.input_base64, 'base64')
|
|
209
|
+
: Buffer.alloc(0);
|
|
210
|
+
const parsedMutationRequest = mutation ? parseJsonRequest(mutationInput) : null;
|
|
211
|
+
const mutationRequest = parsedMutationRequest?.issues.length === 0
|
|
212
|
+
? normalizeJsonValue(parsedMutationRequest.value)
|
|
213
|
+
: null;
|
|
214
|
+
if (mutation) {
|
|
215
|
+
if (described.result.host.trusted_approval?.supported !== true) {
|
|
216
|
+
return refusal(requestId, 'capability-unavailable', { missing: ['trusted-approval'] });
|
|
217
|
+
}
|
|
218
|
+
const authority = verifyMutationAuthority({
|
|
219
|
+
command,
|
|
220
|
+
approval: runtime.approval,
|
|
221
|
+
approvalOptions: {
|
|
222
|
+
binding: {
|
|
223
|
+
request_id: requestId,
|
|
224
|
+
adapter: {
|
|
225
|
+
id: described.result.adapter_id,
|
|
226
|
+
version: described.result.adapter_version,
|
|
227
|
+
contract_version: described.result.selected_adapter_contract_version,
|
|
228
|
+
},
|
|
229
|
+
core: {
|
|
230
|
+
executable_identity: runtime.core_executable_identity,
|
|
231
|
+
contract_version: described.result.core.required_core_contract_version,
|
|
232
|
+
argv,
|
|
233
|
+
input_base64: request.core_request.input_base64,
|
|
234
|
+
},
|
|
235
|
+
workspace: {
|
|
236
|
+
id: request.workspace.workspace_id,
|
|
237
|
+
root: runtime.workspaces[request.workspace.workspace_id].root,
|
|
238
|
+
cwd: workspace.cwd,
|
|
239
|
+
ledger: workspace.ledger,
|
|
240
|
+
},
|
|
241
|
+
limits: request.limits,
|
|
242
|
+
instruction_set_digest: context.instructions.instruction_set_digest,
|
|
243
|
+
handoff_digest: request.handoff_carrier?.sha256 ?? null,
|
|
244
|
+
},
|
|
245
|
+
now: runtime.now,
|
|
246
|
+
redeemedNonces: runtime.redeemed_nonces,
|
|
247
|
+
trustedSources: new Set(described.result.host.trusted_approval.sources),
|
|
248
|
+
},
|
|
249
|
+
});
|
|
250
|
+
if (!authority.ok) return refusal(requestId, authority.error_code, authority.detail);
|
|
251
|
+
}
|
|
252
|
+
let processObservation;
|
|
253
|
+
try {
|
|
254
|
+
processObservation = await runtime.launch({
|
|
255
|
+
command,
|
|
256
|
+
argv,
|
|
257
|
+
cwd: workspace?.cwd ?? runtime.package_root,
|
|
258
|
+
input: mutationInput,
|
|
259
|
+
limits: request.limits,
|
|
260
|
+
});
|
|
261
|
+
} catch {
|
|
262
|
+
processObservation = launchFailureObservation();
|
|
263
|
+
}
|
|
264
|
+
const processFailure = mapProcessOutcome({
|
|
265
|
+
adapter_contract_version: ADAPTER_CONTRACT_VERSION,
|
|
266
|
+
request_id: requestId,
|
|
267
|
+
command,
|
|
268
|
+
core_request: request.core_request,
|
|
269
|
+
mutation_request: mutationRequest,
|
|
270
|
+
mutation_input: mutation ? mutationInput : undefined,
|
|
271
|
+
item_id: WOWBAGGER_ID.test(mutationRequest?.id) ? mutationRequest.id : null,
|
|
272
|
+
expected_revision: DIGEST.test(mutationRequest?.expected_revision)
|
|
273
|
+
? mutationRequest.expected_revision
|
|
274
|
+
: null,
|
|
275
|
+
stdout_limit_bytes: request.limits.stdout_bytes,
|
|
276
|
+
stderr_limit_bytes: request.limits.stderr_bytes,
|
|
277
|
+
process: processObservation,
|
|
278
|
+
});
|
|
279
|
+
if (processFailure) return processFailure;
|
|
280
|
+
|
|
281
|
+
const stdout = Buffer.from(processObservation.stdout_base64, 'base64');
|
|
282
|
+
const stderr = Buffer.from(processObservation.stderr_base64, 'base64');
|
|
283
|
+
return {
|
|
284
|
+
ok: true,
|
|
285
|
+
adapter_contract_version: ADAPTER_CONTRACT_VERSION,
|
|
286
|
+
request_id: requestId,
|
|
287
|
+
result: {
|
|
288
|
+
core_command: command,
|
|
289
|
+
core_exit_code: processObservation.exit_code,
|
|
290
|
+
stdout: streamEnvelope(stdout),
|
|
291
|
+
stderr: streamEnvelope(stderr),
|
|
292
|
+
},
|
|
293
|
+
};
|
|
294
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { hasExactMembers, isNonNegativeSafeInteger, isPositiveSafeInteger } from './schema-helpers.js';
|
|
2
|
+
|
|
3
|
+
function refuse(error_code, detail) {
|
|
4
|
+
return { ok: false, error_code, detail };
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function validateInvocationLimits(requested, advertised) {
|
|
8
|
+
if (!hasExactMembers(requested, ['context_bytes', 'stdout_bytes', 'stderr_bytes', 'timeout_ms'])) {
|
|
9
|
+
return refuse('invalid-invocation', { member: 'limits' });
|
|
10
|
+
}
|
|
11
|
+
const limits = [
|
|
12
|
+
['context_bytes', 'max_context_bytes', isNonNegativeSafeInteger, 'context-limit-exceeded'],
|
|
13
|
+
['stdout_bytes', 'max_stdout_bytes', isNonNegativeSafeInteger, 'output-limit-exceeded'],
|
|
14
|
+
['stderr_bytes', 'max_stderr_bytes', isNonNegativeSafeInteger, 'output-limit-exceeded'],
|
|
15
|
+
['timeout_ms', 'max_timeout_ms', isPositiveSafeInteger, 'timeout-limit-exceeded'],
|
|
16
|
+
];
|
|
17
|
+
for (const [requestedKey, maximumKey, valid, exceededCode] of limits) {
|
|
18
|
+
if (!valid(requested[requestedKey]) || !valid(advertised?.[maximumKey])) {
|
|
19
|
+
return refuse('invalid-invocation', { member: `limits.${requestedKey}` });
|
|
20
|
+
}
|
|
21
|
+
if (requested[requestedKey] > advertised[maximumKey]) {
|
|
22
|
+
return refuse(exceededCode, { member: requestedKey });
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return { ok: true };
|
|
26
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
const ROOT_MEMBERS = Object.freeze([
|
|
2
|
+
'adapter_manifest_version', 'adapter_id', 'adapter_version',
|
|
3
|
+
'adapter_contract_versions', 'bootstrap_wire_version',
|
|
4
|
+
'required_core_contract_version', 'entrypoints', 'platforms',
|
|
5
|
+
]);
|
|
6
|
+
|
|
7
|
+
function refuse(detail) {
|
|
8
|
+
return { ok: false, error_code: 'invalid-adapter-manifest', detail };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// C0 controls (below U+0020) and DEL (U+007F). U+0020 and U+007E are the
|
|
12
|
+
// inclusive edges of the accepted printable range; the boundary is exact.
|
|
13
|
+
export function hasControlCharacter(value) {
|
|
14
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
15
|
+
const code = value.charCodeAt(index);
|
|
16
|
+
if (code < 32 || code === 127) {
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function isSafeRelativeExecutable(value) {
|
|
24
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
if (hasControlCharacter(value)) {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
if (value.includes('\\') || value.startsWith('/')) {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
if (/^[A-Za-z]:/.test(value)) {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
if (/^volume\{[^/]*\}(\/|$)/i.test(value)) {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
const segments = value.split('/');
|
|
40
|
+
return segments.every((segment) => segment !== '' && segment !== '.' && segment !== '..');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function validateAdapterManifest(value) {
|
|
44
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
45
|
+
return refuse('manifest is not an object');
|
|
46
|
+
}
|
|
47
|
+
const present = Object.keys(value);
|
|
48
|
+
for (const member of present) {
|
|
49
|
+
if (!ROOT_MEMBERS.includes(member)) {
|
|
50
|
+
return refuse(`unknown root member ${member}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
for (const member of ROOT_MEMBERS) {
|
|
54
|
+
if (!present.includes(member)) {
|
|
55
|
+
return refuse(`missing root member ${member}`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (value.entrypoints === null || typeof value.entrypoints !== 'object' || Array.isArray(value.entrypoints)) {
|
|
59
|
+
return refuse('entrypoints is not an object');
|
|
60
|
+
}
|
|
61
|
+
const entrypointMembers = Object.keys(value.entrypoints);
|
|
62
|
+
if (entrypointMembers.length !== 2 || !entrypointMembers.includes('describe') || !entrypointMembers.includes('invoke')) {
|
|
63
|
+
return refuse('entrypoints does not have exactly describe and invoke');
|
|
64
|
+
}
|
|
65
|
+
for (const key of ['describe', 'invoke']) {
|
|
66
|
+
const entrypoint = value.entrypoints?.[key];
|
|
67
|
+
if (entrypoint === null || typeof entrypoint !== 'object' || Array.isArray(entrypoint)) {
|
|
68
|
+
return refuse(`entrypoint ${key} is not an object`);
|
|
69
|
+
}
|
|
70
|
+
if (entrypoint.kind === 'host-tool') {
|
|
71
|
+
const members = Object.keys(entrypoint).sort();
|
|
72
|
+
if (members.join(',') !== 'kind,name' || typeof entrypoint.name !== 'string' || entrypoint.name === '') {
|
|
73
|
+
return refuse(`entrypoint ${key} host-tool schema is not exact`);
|
|
74
|
+
}
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (entrypoint.kind !== 'command') {
|
|
78
|
+
return refuse(`entrypoint ${key} kind is unknown`);
|
|
79
|
+
}
|
|
80
|
+
const members = Object.keys(entrypoint).sort();
|
|
81
|
+
if (members.join(',') !== 'executable,fixed_args,kind') {
|
|
82
|
+
return refuse(`entrypoint ${key} command schema is not exact`);
|
|
83
|
+
}
|
|
84
|
+
if (!isSafeRelativeExecutable(entrypoint.executable)) {
|
|
85
|
+
return refuse(`entrypoint ${key} executable is unsafe`);
|
|
86
|
+
}
|
|
87
|
+
if (!Array.isArray(entrypoint.fixed_args)
|
|
88
|
+
|| entrypoint.fixed_args.some((arg) => typeof arg !== 'string' || hasControlCharacter(arg))) {
|
|
89
|
+
return refuse(`entrypoint ${key} fixed_args are invalid`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return { ok: true, manifest: value };
|
|
93
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Single source of truth for the ship-side Invoke refusal messages. The
|
|
2
|
+
// reference model's `OUTER_ERROR_MESSAGES` in spec/adapter-reference.js
|
|
3
|
+
// documents the same text; a test pins the two sides so a divergence turns
|
|
4
|
+
// red instead of silently diverging the wire from the reference model.
|
|
5
|
+
// (Item 39: the message used to exist as a bare literal in the entrypoint
|
|
6
|
+
// and in two tables, with nothing pinning it.)
|
|
7
|
+
export const INVOKE_MESSAGES = Object.freeze({
|
|
8
|
+
'capability-unavailable': 'The configured host cannot invoke the Wowbagger core.',
|
|
9
|
+
'consumer-approval-required': 'The consumer must approve this ledger mutation.',
|
|
10
|
+
'invalid-invocation': 'The adapter invocation is invalid.',
|
|
11
|
+
'mutation-outcome-unknown': 'The mutation may have been applied; inspect current state before retrying.',
|
|
12
|
+
'output-limit-exceeded': 'The core output exceeded the requested bound.',
|
|
13
|
+
'path-rejected': 'The requested ledger path is not a guarded real directory.',
|
|
14
|
+
'path-replaced': 'A guarded path component changed before core launch.',
|
|
15
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { hasControlCharacter } from './manifest.js';
|
|
3
|
+
import { hasExactMembers, isNonNegativeSafeInteger, sameJson } from './schema-helpers.js';
|
|
4
|
+
|
|
5
|
+
function refuse(error_code, detail) {
|
|
6
|
+
return { ok: false, error_code, detail };
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function isSafeLogicalPath(value) {
|
|
10
|
+
return value === '.' || (typeof value === 'string'
|
|
11
|
+
&& value.length > 0
|
|
12
|
+
&& !value.startsWith('/')
|
|
13
|
+
&& !value.includes('\\')
|
|
14
|
+
&& !value.includes('\0')
|
|
15
|
+
&& !/^[A-Za-z]:/.test(value)
|
|
16
|
+
&& !/^volume\{[^}]+\}(?:\/|$)/i.test(value)
|
|
17
|
+
&& value.split('/').every((segment) => segment !== '' && segment !== '.' && segment !== '..'));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function cumulativeComponents(logicalPath) {
|
|
21
|
+
if (logicalPath === '.') return [];
|
|
22
|
+
const segments = logicalPath.split('/');
|
|
23
|
+
return segments.map((_, index) => segments.slice(0, index + 1).join('/'));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function isIdentityMember(value) {
|
|
27
|
+
return (typeof value === 'string' && value.length > 0 && !hasControlCharacter(value))
|
|
28
|
+
|| isNonNegativeSafeInteger(value);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function isValidIdentity(value) {
|
|
32
|
+
if (typeof value === 'string') return isIdentityMember(value);
|
|
33
|
+
if (hasExactMembers(value, ['dev', 'ino'])) {
|
|
34
|
+
return isIdentityMember(value.dev) && isIdentityMember(value.ino);
|
|
35
|
+
}
|
|
36
|
+
if (hasExactMembers(value, ['volume_id', 'file_id'])) {
|
|
37
|
+
return isIdentityMember(value.volume_id) && isIdentityMember(value.file_id);
|
|
38
|
+
}
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function snapshotIssue(snapshot) {
|
|
43
|
+
if (snapshot === null || typeof snapshot !== 'object' || Array.isArray(snapshot)) return 'missing';
|
|
44
|
+
if (!Object.hasOwn(snapshot, 'kind')) return 'invalid-kind';
|
|
45
|
+
if (snapshot.kind !== 'directory') return snapshot.kind;
|
|
46
|
+
if (!Object.hasOwn(snapshot, 'identity') || !isValidIdentity(snapshot.identity)) return 'invalid-identity';
|
|
47
|
+
if (!hasExactMembers(snapshot, ['kind', 'identity'])) return 'invalid-snapshot';
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function snapshotRefusal(pathRole, component, kind, snapshot) {
|
|
52
|
+
const detail = { path_role: pathRole, component, kind };
|
|
53
|
+
if (kind.startsWith('invalid-')) detail.snapshot = snapshot;
|
|
54
|
+
return refuse('path-rejected', detail);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function resolveInvocationPaths({ workspace_root: workspaceRoot, cwd, ledger, before, after }) {
|
|
58
|
+
for (const [role, logicalPath] of [['cwd', cwd], ['ledger', ledger]]) {
|
|
59
|
+
if (!isSafeLogicalPath(logicalPath)) {
|
|
60
|
+
return refuse('path-rejected', { path_role: role, kind: 'invalid-logical-path' });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const components = [
|
|
65
|
+
{ role: 'workspace', logical: '.' },
|
|
66
|
+
...cumulativeComponents(cwd).map((logical) => ({ role: 'cwd', logical })),
|
|
67
|
+
...cumulativeComponents(ledger).map((logical) => ({ role: 'ledger', logical })),
|
|
68
|
+
];
|
|
69
|
+
const seen = new Set();
|
|
70
|
+
for (const { role, logical } of components) {
|
|
71
|
+
if (seen.has(logical)) continue;
|
|
72
|
+
seen.add(logical);
|
|
73
|
+
const initialIssue = snapshotIssue(before?.[logical]);
|
|
74
|
+
if (initialIssue) return snapshotRefusal(role, logical, initialIssue, 'initial');
|
|
75
|
+
const finalIssue = snapshotIssue(after?.[logical]);
|
|
76
|
+
if (finalIssue) return snapshotRefusal(role, logical, finalIssue, 'final');
|
|
77
|
+
if (!sameJson(before[logical].identity, after[logical].identity)) {
|
|
78
|
+
return refuse('path-replaced', { path_role: role, component: logical });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
ok: true,
|
|
84
|
+
workspace_root: workspaceRoot,
|
|
85
|
+
cwd: cwd === '.' ? workspaceRoot : path.posix.join(workspaceRoot, cwd),
|
|
86
|
+
ledger: ledger === '.' ? workspaceRoot : path.posix.join(workspaceRoot, ledger),
|
|
87
|
+
};
|
|
88
|
+
}
|