@ravefox/space 0.2.1 → 0.3.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.
Files changed (163) hide show
  1. package/LICENSE +21 -205
  2. package/README.md +80 -31
  3. package/bin/space.js +106 -0
  4. package/lib/agents.js +119 -0
  5. package/lib/credentials.js +95 -0
  6. package/lib/daemon.js +294 -0
  7. package/lib/harness.js +166 -0
  8. package/lib/http.js +101 -0
  9. package/lib/login.js +68 -0
  10. package/lib/mcp.js +34 -0
  11. package/lib/runtime.js +241 -0
  12. package/lib/store.js +96 -0
  13. package/lib/tools.js +93 -0
  14. package/package.json +27 -46
  15. package/NOTICE +0 -55
  16. package/assets/webhook-sdk/python/bot.py +0 -63
  17. package/assets/webhook-sdk/python/commonly.py +0 -232
  18. package/node_modules/@commonlyai/seats/README.md +0 -25
  19. package/node_modules/@commonlyai/seats/package.json +0 -22
  20. package/node_modules/@commonlyai/seats/src/adapters/hermes.js +0 -264
  21. package/node_modules/@commonlyai/seats/src/adapters/stub.js +0 -24
  22. package/node_modules/@commonlyai/seats/src/contract.js +0 -48
  23. package/node_modules/@commonlyai/seats/src/failures.js +0 -44
  24. package/node_modules/@commonlyai/seats/src/harness-plan.js +0 -329
  25. package/node_modules/@commonlyai/seats/src/index.js +0 -22
  26. package/node_modules/@commonlyai/seats/src/process-group.js +0 -269
  27. package/node_modules/@commonlyai/seats/src/record/format.js +0 -737
  28. package/node_modules/@commonlyai/seats/src/record/otel.js +0 -238
  29. package/node_modules/@commonlyai/seats/src/runtime-observation.js +0 -29
  30. package/node_modules/@commonlyai/seats/src/seat-model.js +0 -17
  31. package/node_modules/@commonlyai/seats/src/supervisor.js +0 -308
  32. package/node_modules/@commonlyai/seats/src/trace/events.js +0 -46
  33. package/node_modules/@commonlyai/seats/src/trace/store.js +0 -276
  34. package/node_modules/@commonlyai/seats/src/turn-processes.js +0 -226
  35. package/node_modules/@commonlyai/seats/src/upstream-refusal.js +0 -418
  36. package/node_modules/@commonlyai/seats/src/valet/env.js +0 -201
  37. package/node_modules/@commonlyai/seats/src/valet/grant.js +0 -123
  38. package/node_modules/@commonlyai/seats/src/valet/permissions.js +0 -213
  39. package/node_modules/@commonlyai/seats/src/wire/provider-proxy.js +0 -529
  40. package/skills/commonly/SKILL.md +0 -233
  41. package/src/commands/agent.js +0 -6334
  42. package/src/commands/daemon.js +0 -933
  43. package/src/commands/dev.js +0 -153
  44. package/src/commands/doctor.js +0 -141
  45. package/src/commands/inbox.js +0 -330
  46. package/src/commands/login.js +0 -194
  47. package/src/commands/mcp.js +0 -56
  48. package/src/commands/pod.js +0 -120
  49. package/src/commands/task.js +0 -279
  50. package/src/commands/verify.js +0 -48
  51. package/src/index.js +0 -99
  52. package/src/lib/adapter-login.js +0 -76
  53. package/src/lib/adapters/broker-bridge-entry.js +0 -76
  54. package/src/lib/adapters/claude.js +0 -2207
  55. package/src/lib/adapters/codex-broker-bridge.mjs +0 -298
  56. package/src/lib/adapters/codex.js +0 -1898
  57. package/src/lib/adapters/dsh.js +0 -513
  58. package/src/lib/adapters/goose.js +0 -510
  59. package/src/lib/adapters/hermes.js +0 -18
  60. package/src/lib/adapters/index.js +0 -68
  61. package/src/lib/adapters/opencode.js +0 -1681
  62. package/src/lib/adapters/pi-commonly-mcp.mjs +0 -71
  63. package/src/lib/adapters/pi-mcp-client.mjs +0 -617
  64. package/src/lib/adapters/pi-shell-env.mjs +0 -41
  65. package/src/lib/adapters/pi.js +0 -1025
  66. package/src/lib/adapters/qwen.js +0 -523
  67. package/src/lib/adapters/seat-model.js +0 -8
  68. package/src/lib/adapters/stub.js +0 -1
  69. package/src/lib/adapters/tested-version.js +0 -106
  70. package/src/lib/api.js +0 -205
  71. package/src/lib/claim-outcome.js +0 -128
  72. package/src/lib/claimed-work-gate.js +0 -73
  73. package/src/lib/cli-hint.js +0 -33
  74. package/src/lib/commit-attribution.js +0 -391
  75. package/src/lib/config.js +0 -155
  76. package/src/lib/content-refusal.js +0 -94
  77. package/src/lib/credential-file.js +0 -208
  78. package/src/lib/daemon-agent-states.js +0 -95
  79. package/src/lib/daemon-hint.js +0 -18
  80. package/src/lib/daemon-logs.js +0 -43
  81. package/src/lib/daemon-service-windows.js +0 -263
  82. package/src/lib/daemon-service.js +0 -486
  83. package/src/lib/daemon-state.js +0 -84
  84. package/src/lib/daemon-store.js +0 -119
  85. package/src/lib/daemon-supervisor.js +0 -629
  86. package/src/lib/daemon-watch.js +0 -115
  87. package/src/lib/declared-mcp-guard.js +0 -284
  88. package/src/lib/default-environment.js +0 -207
  89. package/src/lib/device-login.js +0 -126
  90. package/src/lib/digest-frame.js +0 -151
  91. package/src/lib/doctor.js +0 -291
  92. package/src/lib/egress-proxy.js +0 -160
  93. package/src/lib/enforcement.js +0 -946
  94. package/src/lib/environment.js +0 -681
  95. package/src/lib/first-output-watchdog.js +0 -191
  96. package/src/lib/git-readonly-server.js +0 -138
  97. package/src/lib/git-readonly.js +0 -463
  98. package/src/lib/git-remote-commonly.mjs +0 -100
  99. package/src/lib/grant-broker-guard.js +0 -345
  100. package/src/lib/hooks-config.js +0 -238
  101. package/src/lib/host-gh-broker.js +0 -152
  102. package/src/lib/host-gh-shim.mjs +0 -98
  103. package/src/lib/inbox.js +0 -483
  104. package/src/lib/instance-proxy.js +0 -76
  105. package/src/lib/machine-host.js +0 -29
  106. package/src/lib/mcp-credential-delivery.js +0 -121
  107. package/src/lib/mcp-first-run.js +0 -50
  108. package/src/lib/mcp-home.js +0 -644
  109. package/src/lib/mcp-ready.js +0 -68
  110. package/src/lib/mcp-server-version.js +0 -123
  111. package/src/lib/mcp-token.js +0 -36
  112. package/src/lib/mcp-warm-child.mjs +0 -412
  113. package/src/lib/memory-bridge.js +0 -191
  114. package/src/lib/memory-import.js +0 -156
  115. package/src/lib/missing-harness.js +0 -31
  116. package/src/lib/model-unavailable.js +0 -154
  117. package/src/lib/native-runtime-inventory.js +0 -40
  118. package/src/lib/outsider-grant-mcp.js +0 -82
  119. package/src/lib/own-key-launch.js +0 -42
  120. package/src/lib/pod-catch-up.js +0 -55
  121. package/src/lib/pod-focus.js +0 -224
  122. package/src/lib/pod-tail.js +0 -68
  123. package/src/lib/poll-liveness.js +0 -103
  124. package/src/lib/poll-retry.js +0 -81
  125. package/src/lib/poller.js +0 -200
  126. package/src/lib/provider-proxy.js +0 -42
  127. package/src/lib/public-seat-mcp-flag.js +0 -36
  128. package/src/lib/public-tool-error.js +0 -60
  129. package/src/lib/public-withheld-tools.js +0 -48
  130. package/src/lib/reply-receipt.js +0 -55
  131. package/src/lib/resolve-on-path.js +0 -22
  132. package/src/lib/runtime-inventory.js +0 -56
  133. package/src/lib/runtime-observation.js +0 -37
  134. package/src/lib/sandbox/bwrap.js +0 -275
  135. package/src/lib/sandbox/codex-perimeter.js +0 -16
  136. package/src/lib/sandbox/host-check.js +0 -268
  137. package/src/lib/sandbox/mode.js +0 -49
  138. package/src/lib/sandbox/opencode-loopback.mjs +0 -68
  139. package/src/lib/sandbox/seatbelt.js +0 -684
  140. package/src/lib/sandbox/turn-dir.js +0 -56
  141. package/src/lib/sandbox/windows-unsandboxed.js +0 -183
  142. package/src/lib/sandbox/workspace-git-config.js +0 -243
  143. package/src/lib/sandbox/workspace-pin.js +0 -158
  144. package/src/lib/seat-runtime-controls.js +0 -61
  145. package/src/lib/seat-stop.js +0 -97
  146. package/src/lib/server-skills.js +0 -32
  147. package/src/lib/session-recycle.js +0 -219
  148. package/src/lib/session-store.js +0 -215
  149. package/src/lib/skill-prompt.js +0 -74
  150. package/src/lib/skills-import.js +0 -113
  151. package/src/lib/spawn-credential.js +0 -402
  152. package/src/lib/spawn-retry.js +0 -311
  153. package/src/lib/stable-install.js +0 -176
  154. package/src/lib/supervised-turn.js +0 -352
  155. package/src/lib/task.js +0 -235
  156. package/src/lib/turn-failure-notice.js +0 -177
  157. package/src/lib/unconfined-seat-guard.js +0 -94
  158. package/src/lib/update-check.js +0 -192
  159. package/src/lib/upstream-refusal.js +0 -1
  160. package/src/lib/webhook-server.js +0 -109
  161. package/src/lib/windows-command.js +0 -393
  162. package/src/lib/workspace-git.js +0 -87
  163. package/src/lib/workspace-trust.js +0 -162
@@ -1,737 +0,0 @@
1
- /**
2
- * The run record, format v1: one canonical JSON object per line, each line
3
- * hash-chained to the one before it. docs/record-v1.md is the specification;
4
- * this file is its reference writer and verifier, and nothing here imports
5
- * the store, so another tool can vendor it alone.
6
- */
7
- import { createHash } from 'node:crypto';
8
- import { isIP } from 'node:net';
9
- import { FAILURE_KINDS } from '../failures.js';
10
- import { SWITCH_REASONS, entryLabel, parseEntry } from '../harness-plan.js';
11
-
12
- export const RECORD_VERSION = 1;
13
- export const RECORD_SCHEMA = 'seats.record';
14
-
15
- export const ACTOR_TYPES = Object.freeze(['agent', 'human', 'policy', 'system']);
16
- // Who decides an approval today is the owner of the credential it would use;
17
- // `decision-owner` is reserved for a separately assigned decider.
18
- export const DECIDED_BY_ROLES = Object.freeze(['credential-owner', 'decision-owner', 'agent-owner', 'pod-member', 'policy']);
19
- // A parked tool call is decided by an owner or by policy, never by a pod member
20
- // who owns neither the credential nor the asking agent (ADR-033 D5).
21
- export const PARKED_CALL_ROLES = Object.freeze(DECIDED_BY_ROLES.filter((role) => role !== 'pod-member'));
22
- export const TOOL_SURFACES = Object.freeze(['connector', 'commonly', 'native']);
23
- export const GRANT_ENDS_ON = Object.freeze(['task.done', 'expiresAt']);
24
- export const REVOKE_REASONS = Object.freeze(['task.done', 'expired', 'cascade', 'manual']);
25
- export const WRITE_MODES = Object.freeze(['read', 'write', 'write-with-confirm']);
26
- export const GRANT_TARGET_KINDS = Object.freeze(['pod', 'seat']);
27
- export const OUTCOMES = Object.freeze(['ok', 'failed', 'stuck', 'cancelled']);
28
- export const RESULT_STATUSES = Object.freeze(['done', 'partial', 'blocked', 'failed']);
29
- // Whose request an agent acts on: 'requester' sent the message that started
30
- // the run, 'assigner' assigned its task. Absent when no human asked.
31
- export const ON_BEHALF_ROLES = Object.freeze(['requester', 'assigner']);
32
- // Why the egress proxy refused a request. The first two can leave host and port null.
33
- export const EGRESS_DENIED_REASONS = Object.freeze([
34
- 'bad_request', 'not_connect', 'ip_literal', 'not_allowed', 'unresolved', 'private_address', 'unreachable',
35
- ]);
36
- const EGRESS_TARGETLESS = new Set(['bad_request', 'not_connect']);
37
- export const SANDBOXES = Object.freeze(['seatbelt', 'bwrap', 'none']);
38
- export const SESSION_METHODS = Object.freeze(['new', 'resume', 'load']);
39
- export const RETRY_STATES = Object.freeze(['scheduled', 'started']);
40
- /**
41
- * agent.retry.errorType. The provider error types opencode 2.1.4 reports on a
42
- * retry, read from its error formatter, plus `other` for any type outside
43
- * them. A closed set, so no agent text can ride in this field.
44
- */
45
- export const RETRY_ERROR_TYPES = Object.freeze([
46
- 'provider.auth',
47
- 'provider.content-filter',
48
- 'provider.free-limit',
49
- 'provider.internal',
50
- 'provider.invalid-output',
51
- 'provider.invalid-request',
52
- 'provider.no-route',
53
- 'provider.quota',
54
- 'provider.rate-limit',
55
- 'provider.timeout',
56
- 'provider.transport',
57
- 'provider.unknown',
58
- 'provider.unsupported-operation',
59
- 'other',
60
- ]);
61
- // Why a requested session was not reopened: the two forms an ACP adapter
62
- // writes. The code is the JSON-RPC error's, an integer by JSON-RPC 2.0.
63
- const SESSION_FALLBACK = /^(?:agent supports neither session\/resume nor session\/load|session\/(?:resume|load) answered error -?\d{1,16})$/;
64
- export const REDACTED = '[redacted]';
65
-
66
- /** The actors a writer records for its own bookkeeping, and for the board. */
67
- export const SYSTEM_ACTOR = Object.freeze({ type: 'system', id: 'seats' });
68
- export const BOARD_ACTOR = Object.freeze({ type: 'system', id: 'board' });
69
-
70
- /**
71
- * Every v1 kind. `actor` is who a writer records when the emitter names
72
- * nobody: 'run' (the run's own actor), 'system', or derived from the data
73
- * ('issuer', 'decider'). `trace` is the EVENT_TYPES name, when it differs.
74
- */
75
- export const KINDS = Object.freeze({
76
- 'task.claimed': Object.freeze({ group: 'claim', actor: 'run' }),
77
- 'task.released': Object.freeze({ group: 'claim', actor: 'run' }),
78
- 'task.done': Object.freeze({ group: 'claim', actor: 'board' }),
79
- 'grant.issued': Object.freeze({ group: 'grant', actor: 'issuer' }),
80
- 'grant.revoked': Object.freeze({ group: 'grant', actor: 'system' }),
81
- 'permission.requested': Object.freeze({ group: 'approval', actor: 'run' }),
82
- 'permission.decided': Object.freeze({ group: 'approval', actor: 'decider' }),
83
- 'decision.ruled': Object.freeze({ group: 'approval', actor: 'decider' }),
84
- 'attempt.started': Object.freeze({ group: 'attempt', actor: 'run', trace: 'run.started' }),
85
- 'session.opened': Object.freeze({ group: 'attempt', actor: 'system' }),
86
- 'attempt.settled': Object.freeze({ group: 'attempt', actor: 'system' }),
87
- 'run.settled': Object.freeze({ group: 'attempt', actor: 'system' }),
88
- // The attempt that follows runs on another harness entry than the seat was on.
89
- 'harness.switched': Object.freeze({ group: 'attempt', actor: 'system' }),
90
- 'tool.call': Object.freeze({ group: 'tool', actor: 'run' }),
91
- 'tool.update': Object.freeze({ group: 'tool', actor: 'run' }),
92
- 'credential.granted': Object.freeze({ group: 'credential', actor: 'system' }),
93
- 'credential.denied': Object.freeze({ group: 'credential', actor: 'system' }),
94
- 'result.reported': Object.freeze({ group: 'result', actor: 'run' }),
95
- 'turn.prompt_sent': Object.freeze({ group: 'activity', actor: 'system' }),
96
- 'agent.message_chunk': Object.freeze({ group: 'activity', actor: 'run' }),
97
- 'agent.thought_chunk': Object.freeze({ group: 'activity', actor: 'run' }),
98
- 'agent.retry': Object.freeze({ group: 'activity', actor: 'run' }),
99
- plan: Object.freeze({ group: 'activity', actor: 'run' }),
100
- usage: Object.freeze({ group: 'activity', actor: 'run' }),
101
- 'liveness.stalled': Object.freeze({ group: 'activity', actor: 'system' }),
102
- 'liveness.recovery': Object.freeze({ group: 'activity', actor: 'system' }),
103
- 'process.exited': Object.freeze({ group: 'activity', actor: 'system' }),
104
- // The run's confinement, recorded once per spawn, beside its egress.
105
- 'sandbox.applied': Object.freeze({ group: 'sandbox', actor: 'system' }),
106
- 'egress.allowed': Object.freeze({ group: 'egress', actor: 'system' }),
107
- 'egress.denied': Object.freeze({ group: 'egress', actor: 'system' }),
108
- });
109
-
110
- const TRACE_TO_KIND = new Map(Object.entries(KINDS).filter(([, spec]) => spec.trace).map(([kind, spec]) => [spec.trace, kind]));
111
-
112
- /** Record kind for an EVENT_TYPES name or a kind; null when it is neither. */
113
- export const kindOfType = (type) => TRACE_TO_KIND.get(type) || (Object.hasOwn(KINDS, type) ? type : null);
114
- /** The EVENT_TYPES name readRun reports for a kind (unknown kinds pass through). */
115
- export const traceTypeOf = (kind) => KINDS[kind]?.trace || kind;
116
-
117
- const fail = (message) => { throw new Error(`record: ${message}`); };
118
- const isPlainObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
119
- const isText = (value) => typeof value === 'string' && value.length > 0;
120
- const ENV_NAME = /^[A-Z_][A-Z0-9_]*$/;
121
- const RUN_ID = /^[A-Za-z0-9_-]{6,64}$/;
122
- // A user id is an opaque token from the instance: no spaces, so a display
123
- // name can't pass for one. Display names go in actor.name.
124
- const USER_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
125
- export const isUserId = (value) => typeof value === 'string' && USER_ID.test(value);
126
- const TOOL_NAME = /^[^\s*?]{1,256}$/;
127
- const RFC3339_UTC = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?Z$/;
128
- const KIND_SHAPE = /^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)*$/;
129
- const isTimestamp = (value) => typeof value === 'string' && RFC3339_UTC.test(value) && !Number.isNaN(Date.parse(value));
130
- // Section 8: 128 random bits make a line's hash a commitment nobody can guess at.
131
- const SALT = /^[0-9a-f]{32}$/;
132
- const isSalt = (value) => typeof value === 'string' && SALT.test(value);
133
- const LINE_HASH = /^[0-9a-f]{64}$/;
134
- // Section 9: an id held beside the line. It is a valid id token, so every
135
- // writer check and every older reader takes it as an opaque id.
136
- const COMMITMENT = /^c1:[0-9a-f]{64}$/;
137
- export const isCommitment = (value) => typeof value === 'string' && COMMITMENT.test(value);
138
- const isOpenings = (value) => isPlainObject(value) && Object.entries(value).every(([token, opening]) => isCommitment(token)
139
- && isPlainObject(opening) && isText(opening.id) && isSalt(opening.salt) && Object.keys(opening).length === 2);
140
- // Every string on a line, at any depth: where a commitment can sit.
141
- const idTokens = (value, found = new Set()) => {
142
- if (typeof value === 'string') found.add(value);
143
- else if (Array.isArray(value)) value.forEach((item) => idTokens(item, found));
144
- else if (isPlainObject(value)) Object.values(value).forEach((item) => idTokens(item, found));
145
- return found;
146
- };
147
- const isRedaction = (value) => isPlainObject(value) && Array.isArray(value.fields) && value.fields.every(isText)
148
- && (value.hash === null || (typeof value.hash === 'string' && LINE_HASH.test(value.hash)));
149
-
150
- /**
151
- * RFC 8785 (JCS) serialization. For the JSON values a record holds, JCS is
152
- * JSON.stringify with object keys sorted by UTF-16 code units, which is what
153
- * Array.prototype.sort does by default.
154
- *
155
- * `strict` also refuses a lone surrogate in any string or key, as RFC 8785
156
- * requires. Lines are not strict: agent text from a stream cut mid-pair must
157
- * still record, and JSON.stringify writes the lone half as a \u escape.
158
- */
159
- const canonicalString = (text, strict) => {
160
- if (strict && !text.isWellFormed()) fail('strings must be well-formed UTF-16 (no lone surrogates)');
161
- return JSON.stringify(text);
162
- };
163
- const canonical = (value, strict = false) => {
164
- if (value !== null && typeof value === 'object' && typeof value.toJSON === 'function') return canonical(value.toJSON(), strict);
165
- if (value === null) return 'null';
166
- switch (typeof value) {
167
- case 'string': return canonicalString(value, strict);
168
- case 'boolean': return value ? 'true' : 'false';
169
- case 'number':
170
- if (!Number.isFinite(value)) fail('numbers must be finite');
171
- return JSON.stringify(value);
172
- case 'object':
173
- if (Array.isArray(value)) return `[${value.map((item) => (item === undefined ? 'null' : canonical(item, strict))).join(',')}]`;
174
- return `{${Object.keys(value).filter((key) => value[key] !== undefined).sort()
175
- .map((key) => `${canonicalString(key, strict)}:${canonical(value[key], strict)}`).join(',')}}`;
176
- default: return fail(`cannot serialize a ${typeof value}`);
177
- }
178
- };
179
-
180
- /** The canonical line for an entry, without its trailing newline. */
181
- export const canonicalLine = (entry) => {
182
- if (!isPlainObject(entry)) fail('an entry is a JSON object');
183
- return canonical(entry);
184
- };
185
-
186
- /** Lowercase hex SHA-256 over a line's UTF-8 bytes: what the next `prev` holds. */
187
- export const hashLine = (line) => createHash('sha256').update(String(line), 'utf8').digest('hex');
188
-
189
- /**
190
- * A tool call's arguments as a digest, never the values: `sha256:` + the
191
- * lowercase hex SHA-256 of the arguments' RFC 8785 form. The same arguments
192
- * give the same digest wherever they are seen, so a reader can match a
193
- * tool.call to the permission entries that approved it. Returns undefined
194
- * when there are no arguments, or they are not JSON (a function, NaN, a
195
- * string or key holding a lone surrogate): a writer then omits argsDigest.
196
- */
197
- const ARGS_DIGEST = /^sha256:[0-9a-f]{64}$/;
198
- export const argsDigestOf = (args) => {
199
- if (args === undefined) return undefined;
200
- try { return `sha256:${hashLine(canonical(args, true))}`; } catch { return undefined; }
201
- };
202
- export const isArgsDigest = (value) => typeof value === 'string' && ARGS_DIGEST.test(value);
203
-
204
- /**
205
- * What a line holds in place of an id it may later have to forget: nobody can
206
- * test a guess at the id without the salt, which is kept beside the record.
207
- */
208
- export const commitId = (id, salt) => {
209
- if (!isSalt(salt)) fail('salt must be 32 lowercase hex characters');
210
- return `c1:${hashLine(`seats.record/commitment/v1\n${salt}\n${id}`)}`;
211
- };
212
-
213
- /** null when the actor is well formed, else the reason. */
214
- const actorProblem = (actor) => {
215
- if (actor === null) return null;
216
- if (!isPlainObject(actor)) return 'actor must be an object or null';
217
- if (!ACTOR_TYPES.includes(actor.type)) return `actor.type must be one of ${ACTOR_TYPES.join(', ')}`;
218
- if (!isUserId(actor.id)) return 'actor.id must be an id token (a user id for agent and human actors), never a display name';
219
- if (actor.owner !== undefined && (actor.type !== 'agent' || !isUserId(actor.owner))) return 'actor.owner is a user id, on agent actors only';
220
- if (actor.name !== undefined && !isText(actor.name)) return 'actor.name must be a non-empty string';
221
- if (actor.onBehalfOf !== undefined) {
222
- const behalf = actor.onBehalfOf;
223
- if (actor.type !== 'agent') return 'actor.onBehalfOf is on agent actors only';
224
- if (!isPlainObject(behalf)) return 'actor.onBehalfOf must be {id, role}';
225
- const extra = Object.keys(behalf).find((key) => key !== 'id' && key !== 'role');
226
- if (extra !== undefined) return `unknown field actor.onBehalfOf.${extra}`;
227
- if (!isUserId(behalf.id)) return 'actor.onBehalfOf.id must be an id token (a user id), never a display name';
228
- if (!ON_BEHALF_ROLES.includes(behalf.role)) return `actor.onBehalfOf.role must be one of ${ON_BEHALF_ROLES.join(', ')}`;
229
- }
230
- return null;
231
- };
232
-
233
- export const validateActor = (actor) => {
234
- const problem = actorProblem(actor);
235
- if (problem) fail(problem);
236
- if (actor === null) return null;
237
- return Object.freeze({
238
- type: actor.type,
239
- id: actor.id,
240
- ...(actor.owner ? { owner: actor.owner } : {}),
241
- ...(actor.onBehalfOf ? { onBehalfOf: Object.freeze({ id: actor.onBehalfOf.id, role: actor.onBehalfOf.role }) } : {}),
242
- ...(actor.name ? { name: actor.name } : {}),
243
- });
244
- };
245
-
246
- const refuseUnknownKeys = (value, allowed, where) => {
247
- for (const key of Object.keys(value)) if (!allowed.includes(key)) fail(`unknown field ${where}${key}`);
248
- };
249
- const envNames = (names, where) => {
250
- if (!Array.isArray(names) || !names.every((name) => typeof name === 'string' && ENV_NAME.test(name))) {
251
- fail(`${where} must be credential NAMES ([A-Z_][A-Z0-9_]*), never values`);
252
- }
253
- return [...names];
254
- };
255
- const optionalText = (value, where) => {
256
- if (value !== undefined && value !== null && !isText(value)) fail(`${where} must be a non-empty string`);
257
- };
258
- const userIdOrPolicy = (value, where) => {
259
- if (value !== undefined && value !== null && value !== 'policy' && !isUserId(value)) fail(`${where} must be a user id or "policy"`);
260
- };
261
- // Only the digest form: raw arguments would carry values.
262
- const optionalArgsDigest = (data, kind) => {
263
- if (data.argsDigest !== undefined && !isArgsDigest(data.argsDigest)) fail(`${kind}.argsDigest must be sha256:<64 hex>, a digest of the arguments, never the arguments`);
264
- };
265
- // A host as the egress proxy traces it: a lowercase DNS name, an IP literal
266
- // (IPv6 without brackets), or [redacted] when it held a run credential.
267
- const DNS_LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
268
- const isEgressHost = (host) => typeof host === 'string' && (host === REDACTED || isIP(host) !== 0
269
- || (host.length <= 253 && host.split('.').every((label) => DNS_LABEL.test(label))));
270
- const isPort = (port) => Number.isInteger(port) && port >= 1 && port <= 65535;
271
- const checkEgressTarget = (data, kind, { nullable }) => {
272
- if (data.host === null && data.port === null && nullable) return;
273
- if (data.host === null || data.port === null) {
274
- fail(nullable ? `${kind}: host and port are null together, or both set` : `${kind} needs a host and a port`);
275
- }
276
- if (!isEgressHost(data.host)) fail(`${kind}.host must be a lowercase DNS name, an IP literal or "${REDACTED}"`);
277
- if (!isPort(data.port)) fail(`${kind}.port must be an integer from 1 to 65535`);
278
- };
279
-
280
- const checkRole = (role, where, roles = DECIDED_BY_ROLES) => {
281
- if (!roles.includes(role)) fail(`${where} must be one of ${roles.join(', ')}`);
282
- };
283
-
284
- /** A grant's scope, in the shape of a room grant: exact tool names and a connection id. */
285
- const checkScope = (scope) => {
286
- if (!isPlainObject(scope)) fail('grant.issued needs a scope');
287
- refuseUnknownKeys(scope, ['connectionId', 'tools', 'writeMode', 'budget', 'audience'], 'grant.issued.scope.');
288
- optionalText(scope.connectionId, 'grant.issued.scope.connectionId');
289
- const tools = scope.tools ?? [];
290
- if (!Array.isArray(tools) || !tools.every((name) => typeof name === 'string' && TOOL_NAME.test(name))) {
291
- fail('grant.issued.scope.tools must be exact tool names (no spaces, no * or ?), not patterns');
292
- }
293
- if (scope.writeMode !== undefined && !WRITE_MODES.includes(scope.writeMode)) fail(`grant.issued.scope.writeMode must be one of ${WRITE_MODES.join(', ')}`);
294
- let budget;
295
- if (scope.budget !== undefined) {
296
- if (!isPlainObject(scope.budget)) fail('grant.issued.scope.budget must be an object');
297
- refuseUnknownKeys(scope.budget, ['calls', 'windowMs'], 'grant.issued.scope.budget.');
298
- if (scope.budget.calls !== undefined && !(Number.isInteger(scope.budget.calls) && scope.budget.calls >= 0)) fail('grant.issued.scope.budget.calls must be an integer >= 0');
299
- if (scope.budget.windowMs !== undefined && !(Number.isInteger(scope.budget.windowMs) && scope.budget.windowMs >= 1)) fail('grant.issued.scope.budget.windowMs must be an integer >= 1');
300
- budget = { ...scope.budget };
301
- }
302
- const audience = scope.audience ?? [];
303
- if (!Array.isArray(audience) || !audience.every(isUserId)) fail('grant.issued.scope.audience must be user ids');
304
- return {
305
- ...(scope.connectionId ? { connectionId: scope.connectionId } : {}),
306
- tools: [...tools],
307
- ...(scope.writeMode ? { writeMode: scope.writeMode } : {}),
308
- ...(budget ? { budget } : {}),
309
- audience: [...audience],
310
- };
311
- };
312
-
313
- // Per-kind checks, applied by every writer. They are strictest where a value
314
- // could ride in: a grant's scope, target and binding refuse fields they don't define.
315
- const CHECKS = {
316
- 'grant.issued': (data, envelope) => {
317
- refuseUnknownKeys(data, ['grantId', 'parentGrantId', 'scope', 'target', 'issuedBy', 'expiresAt', 'binding'], 'grant.issued.');
318
- if (!isText(data.grantId)) fail('grant.issued needs a grantId');
319
- optionalText(data.parentGrantId, 'grant.issued.parentGrantId');
320
- const scope = checkScope(data.scope);
321
- let target;
322
- if (data.target !== undefined) {
323
- if (!isPlainObject(data.target)) fail('grant.issued.target must be an object');
324
- refuseUnknownKeys(data.target, ['kind', 'id'], 'grant.issued.target.');
325
- if (!GRANT_TARGET_KINDS.includes(data.target.kind) || !isText(data.target.id)) fail('grant.issued.target is {kind: pod|seat, id}');
326
- target = { kind: data.target.kind, id: data.target.id };
327
- }
328
- userIdOrPolicy(data.issuedBy, 'grant.issued.issuedBy');
329
- if (data.expiresAt !== undefined && !isTimestamp(data.expiresAt)) fail('grant.issued.expiresAt must be an RFC 3339 UTC timestamp');
330
- let binding;
331
- if (data.binding !== undefined) {
332
- if (!isPlainObject(data.binding)) fail('grant.issued.binding must be an object');
333
- refuseUnknownKeys(data.binding, ['taskId', 'podId', 'endsOn'], 'grant.issued.binding.');
334
- if (!isText(data.binding.taskId) || !isText(data.binding.podId)) fail('grant.issued.binding needs taskId and podId: task ids repeat across pods');
335
- if (data.binding.taskId !== envelope.taskId || data.binding.podId !== envelope.podId) fail('grant.issued.binding must name the run\'s own taskId and podId');
336
- if (!GRANT_ENDS_ON.includes(data.binding.endsOn)) fail(`grant.issued.binding.endsOn must be one of ${GRANT_ENDS_ON.join(', ')}`);
337
- if (data.binding.endsOn === 'expiresAt' && data.expiresAt === undefined) fail('a grant that ends on expiresAt needs an expiresAt');
338
- binding = { taskId: data.binding.taskId, podId: data.binding.podId, endsOn: data.binding.endsOn };
339
- }
340
- return {
341
- ...data,
342
- ...(data.parentGrantId === undefined ? {} : { parentGrantId: data.parentGrantId }),
343
- scope,
344
- ...(target ? { target } : {}),
345
- ...(binding ? { binding } : {}),
346
- };
347
- },
348
- 'grant.revoked': (data) => {
349
- if (!Array.isArray(data.grantIds) || data.grantIds.length === 0 || !data.grantIds.every(isText)) fail('grant.revoked needs grantIds: a non-empty array');
350
- if (new Set(data.grantIds).size !== data.grantIds.length) fail('grant.revoked.grantIds repeats an id');
351
- if (!REVOKE_REASONS.includes(data.reason)) fail(`grant.revoked.reason must be one of ${REVOKE_REASONS.join(', ')}`);
352
- return { ...data, grantIds: [...data.grantIds] };
353
- },
354
- 'task.done': (data, envelope) => {
355
- if (!envelope.taskId) fail('task.done needs a run with a taskId');
356
- userIdOrPolicy(data.doneBy, 'task.done.doneBy');
357
- return data;
358
- },
359
- 'permission.requested': (data) => {
360
- if (!isText(data.question)) fail('permission.requested needs a question: the human-readable ask');
361
- if (!isPlainObject(data.askedOf)) fail('permission.requested needs askedOf: {id, role}');
362
- refuseUnknownKeys(data.askedOf, ['id', 'role'], 'permission.requested.askedOf.');
363
- if (!isUserId(data.askedOf.id)) fail('permission.requested.askedOf.id must be an id token, never a display name');
364
- checkRole(data.askedOf.role, 'permission.requested.askedOf.role', PARKED_CALL_ROLES);
365
- optionalText(data.grantId, 'permission.requested.grantId');
366
- optionalText(data.decisionCardId, 'permission.requested.decisionCardId');
367
- optionalArgsDigest(data, 'permission.requested');
368
- return { ...data, askedOf: { id: data.askedOf.id, role: data.askedOf.role } };
369
- },
370
- 'decision.ruled': (data) => {
371
- if (!isText(data.decisionCardId)) fail('decision.ruled needs a decisionCardId');
372
- const { value } = data;
373
- if (!(typeof value === 'string' || typeof value === 'boolean' || (typeof value === 'number' && Number.isFinite(value)))) {
374
- fail('decision.ruled.value must be a string, number or boolean');
375
- }
376
- if (data.decidedBy !== 'policy' && !isUserId(data.decidedBy)) fail('decision.ruled.decidedBy must be a user id or "policy"');
377
- checkRole(data.decidedByRole, 'decision.ruled.decidedByRole');
378
- return data;
379
- },
380
- 'permission.decided': (data) => {
381
- optionalArgsDigest(data, 'permission.decided');
382
- // A rule-decided call never had a person in the loop; a writer says so
383
- // rather than leave a reader to infer it from rule.list.
384
- // A decision always names who made it: no writer may record one nobody
385
- // can be held to.
386
- const out = { ...data };
387
- if (!('decidedBy' in out) && !('decidedByRole' in out) && out.rule && out.rule.list !== 'ask') {
388
- out.decidedBy = 'policy';
389
- out.decidedByRole = 'policy';
390
- }
391
- if (out.decidedBy === undefined || out.decidedBy === null) fail('permission.decided.decidedBy is required: a user id, or "policy"');
392
- userIdOrPolicy(out.decidedBy, 'permission.decided.decidedBy');
393
- checkRole(out.decidedByRole, 'permission.decided.decidedByRole', PARKED_CALL_ROLES);
394
- if ((out.decidedBy === 'policy') !== (out.decidedByRole === 'policy')) {
395
- fail('permission.decided.decidedBy is "policy" exactly when decidedByRole is "policy"');
396
- }
397
- optionalText(out.decisionCardId, 'permission.decided.decisionCardId');
398
- return out;
399
- },
400
- 'tool.call': (data) => {
401
- if (data.surface !== undefined && !TOOL_SURFACES.includes(data.surface)) fail(`tool.call.surface must be one of ${TOOL_SURFACES.join(', ')}`);
402
- optionalArgsDigest(data, 'tool.call');
403
- return data;
404
- },
405
- 'credential.granted': (data) => {
406
- const names = envNames(data.names ?? [], 'credential.granted.names');
407
- if (data.injected === undefined) return { ...data, names };
408
- const injected = envNames(data.injected, 'credential.granted.injected');
409
- if (!injected.every((name) => names.includes(name))) fail('credential.granted.injected must be a subset of names');
410
- return { ...data, names, injected };
411
- },
412
- 'credential.denied': (data) => {
413
- envNames([data.name], 'credential.denied.name');
414
- return data;
415
- },
416
- 'attempt.settled': (data) => {
417
- if (!Number.isInteger(data.attempt) || data.attempt < 1) fail('attempt.settled.attempt must be a positive integer');
418
- if (!OUTCOMES.includes(data.outcome)) fail(`attempt.settled.outcome must be one of ${OUTCOMES.join(', ')}`);
419
- if (data.failure !== undefined && data.failure !== null) {
420
- const { kind } = data.failure;
421
- if (kind !== null && kind !== undefined && !FAILURE_KINDS[kind]) fail(`attempt.settled.failure.kind ${kind} is not a FAILURE_KINDS key`);
422
- }
423
- return data;
424
- },
425
- 'session.opened': (data) => {
426
- refuseUnknownKeys(data, ['method', 'sessionId', 'requestedSessionId', 'fallback'], 'session.opened.');
427
- if (!SESSION_METHODS.includes(data.method)) fail(`session.opened.method must be one of ${SESSION_METHODS.join(', ')}`);
428
- if (!isText(data.sessionId)) fail('session.opened needs a sessionId');
429
- if ((data.requestedSessionId === undefined) !== (data.fallback === undefined)) {
430
- fail('session.opened.requestedSessionId and fallback come together: a session was asked for and not reopened');
431
- }
432
- if (data.requestedSessionId !== undefined) {
433
- if (data.method !== 'new') fail('session.opened.requestedSessionId is set only when method is new');
434
- if (!isText(data.requestedSessionId)) fail('session.opened.requestedSessionId must be a non-empty string');
435
- if (typeof data.fallback !== 'string' || !SESSION_FALLBACK.test(data.fallback)) {
436
- fail('session.opened.fallback must be "agent supports neither session/resume nor session/load" or "session/<resume|load> answered error <code>"');
437
- }
438
- }
439
- return data;
440
- },
441
- 'harness.switched': (data) => {
442
- refuseUnknownKeys(data, ['from', 'to', 'reason'], 'harness.switched.');
443
- for (const key of ['from', 'to']) {
444
- let entry = null;
445
- try { entry = typeof data[key] === 'string' ? parseEntry(data[key]) : null; } catch { entry = null; }
446
- // The canonical label only, so one entry is always written one way.
447
- if (!entry || entryLabel(entry) !== data[key]) fail(`harness.switched.${key} must be an entry label harness/model[@effort]`);
448
- }
449
- if (data.from === data.to) fail('harness.switched.from and to must differ');
450
- if (!SWITCH_REASONS.includes(data.reason)) fail(`harness.switched.reason must be one of ${SWITCH_REASONS.join(', ')}`);
451
- return data;
452
- },
453
- 'agent.retry': (data) => {
454
- refuseUnknownKeys(data, ['state', 'attempt', 'nextRetryAt', 'status', 'errorType'], 'agent.retry.');
455
- if (!RETRY_STATES.includes(data.state)) fail(`agent.retry.state must be one of ${RETRY_STATES.join(', ')}`);
456
- // Every other field is absent when unknown, never null.
457
- if ('attempt' in data && !(Number.isInteger(data.attempt) && data.attempt >= 1)) fail('agent.retry.attempt must be an integer >= 1, or absent');
458
- if ('nextRetryAt' in data && !isTimestamp(data.nextRetryAt)) fail('agent.retry.nextRetryAt must be an RFC 3339 UTC timestamp, or absent');
459
- if ('status' in data && !(Number.isInteger(data.status) && data.status >= 100 && data.status <= 599)) fail('agent.retry.status must be an integer HTTP status (100-599), or absent');
460
- if ('errorType' in data && !RETRY_ERROR_TYPES.includes(data.errorType)) fail(`agent.retry.errorType must be one of ${RETRY_ERROR_TYPES.join(', ')}, or absent`);
461
- return data;
462
- },
463
- usage: (data) => {
464
- for (const key of ['contextTokens', 'contextSize']) {
465
- const value = data[key];
466
- if (value !== undefined && value !== null && !(Number.isInteger(value) && value >= 0)) fail(`usage.${key} must be an integer >= 0, or null when unknown`);
467
- }
468
- return data;
469
- },
470
- 'sandbox.applied': (data) => {
471
- refuseUnknownKeys(data, ['sandbox', 'reason'], 'sandbox.applied.');
472
- if (!SANDBOXES.includes(data.sandbox)) fail(`sandbox.applied.sandbox must be one of ${SANDBOXES.join(', ')}`);
473
- if (data.reason !== undefined) {
474
- if (data.sandbox !== 'none') fail('sandbox.applied.reason is set only when sandbox is none');
475
- if (!isText(data.reason)) fail('sandbox.applied.reason must be a non-empty string');
476
- }
477
- return data;
478
- },
479
- 'egress.allowed': (data) => {
480
- refuseUnknownKeys(data, ['host', 'port'], 'egress.allowed.');
481
- checkEgressTarget(data, 'egress.allowed', { nullable: false });
482
- return data;
483
- },
484
- 'egress.denied': (data) => {
485
- refuseUnknownKeys(data, ['host', 'port', 'reason'], 'egress.denied.');
486
- if (!EGRESS_DENIED_REASONS.includes(data.reason)) fail(`egress.denied.reason must be one of ${EGRESS_DENIED_REASONS.join(', ')}`);
487
- checkEgressTarget(data, 'egress.denied', { nullable: EGRESS_TARGETLESS.has(data.reason) });
488
- return data;
489
- },
490
- 'result.reported': (data) => {
491
- if (!RESULT_STATUSES.includes(data.status)) fail(`result.reported.status must be one of ${RESULT_STATUSES.join(', ')}`);
492
- if (data.refs !== undefined && !(Array.isArray(data.refs) && data.refs.every((ref) => isPlainObject(ref) && isText(ref.type) && isText(ref.id)))) {
493
- fail('result.reported.refs must be [{type, id}]');
494
- }
495
- return data;
496
- },
497
- };
498
-
499
- /**
500
- * Validate one kind's data and return the copy a writer records. `envelope`
501
- * is {taskId, podId}, for checks that tie data to the run it sits in.
502
- */
503
- export const normalizeData = (kind, data = {}, envelope = {}) => {
504
- if (!isPlainObject(data)) fail(`${kind} data must be an object`);
505
- const check = CHECKS[kind];
506
- return check ? check({ ...data }, envelope) : { ...data };
507
- };
508
-
509
- /** A grant's rules acting on their own: named by the grant when its id is a token. */
510
- export const policyActorId = (grantId) => (isText(grantId) && isUserId(`grant:${grantId}`) ? `grant:${grantId}` : 'policy');
511
- const policyActor = (grantId) => ({ type: 'policy', id: policyActorId(grantId) });
512
-
513
- /** The actor a writer records when the emitter names none. */
514
- export const defaultActor = (kind, data = {}, runActor = null) => {
515
- switch (KINDS[kind]?.actor) {
516
- case 'run': return runActor;
517
- case 'system': return SYSTEM_ACTOR;
518
- case 'board': return BOARD_ACTOR;
519
- case 'issuer':
520
- if (data.issuedBy === 'policy') return policyActor(data.grantId);
521
- return isUserId(data.issuedBy) ? { type: 'human', id: data.issuedBy } : null;
522
- case 'decider':
523
- if (data.decidedByRole === 'policy' || data.decidedBy === 'policy') return policyActor(data.grantId);
524
- return isUserId(data.decidedBy) ? { type: 'human', id: data.decidedBy } : null;
525
- default: return null;
526
- }
527
- };
528
-
529
- const parseLine = (line) => {
530
- let entry;
531
- try { entry = JSON.parse(line); } catch { return fail('the previous line is not JSON'); }
532
- if (!isPlainObject(entry) || !Number.isInteger(entry.seq)) fail('the previous line is not a record entry');
533
- return entry;
534
- };
535
-
536
- /**
537
- * appendEntry(prevLine, fields, { runActor }?) → the next line, canonical,
538
- * without its newline.
539
- *
540
- * fields: { runId, taskId?, podId?, ts, kind, actor?, data, seq?, salt? }. seq
541
- * and prev come from prevLine (null for the first line); a given seq must agree.
542
- * An absent actor takes the kind's default; null records "unknown".
543
- */
544
- export const appendEntry = (prevLine, fields = {}, { runActor = null } = {}) => {
545
- let seq = 1;
546
- let prev = null;
547
- if (prevLine !== null && prevLine !== undefined) {
548
- const before = parseLine(prevLine);
549
- seq = before.seq + 1;
550
- prev = hashLine(prevLine);
551
- if (before.runId !== fields.runId) fail('one record holds one run');
552
- if (before.taskId !== fields.taskId || before.podId !== fields.podId) fail('taskId and podId are fixed for the whole run');
553
- if (before.kind === 'run.settled') fail('nothing is written after run.settled');
554
- }
555
- if (fields.seq !== undefined && fields.seq !== seq) fail(`seq ${fields.seq} does not follow ${seq - 1}`);
556
- if (!RUN_ID.test(String(fields.runId))) fail('runId must be 6-64 characters of [A-Za-z0-9_-]');
557
- optionalText(fields.taskId, 'taskId');
558
- optionalText(fields.podId, 'podId');
559
- if (fields.salt !== undefined && !isSalt(fields.salt)) fail('salt must be 32 lowercase hex characters');
560
- if (fields.taskId && !fields.podId) fail('a taskId needs a podId: task ids repeat across pods');
561
- const ts = fields.ts instanceof Date ? fields.ts.toISOString() : fields.ts;
562
- if (!isTimestamp(ts)) fail('ts must be an RFC 3339 UTC timestamp');
563
- if (!Object.hasOwn(KINDS, fields.kind)) fail(`unknown record kind: ${fields.kind}`);
564
- const data = normalizeData(fields.kind, fields.data ?? {}, { taskId: fields.taskId, podId: fields.podId });
565
- const actor = validateActor(fields.actor === undefined ? defaultActor(fields.kind, data, runActor) : fields.actor);
566
- return canonicalLine({
567
- v: RECORD_VERSION,
568
- schema: RECORD_SCHEMA,
569
- runId: fields.runId,
570
- ...(fields.taskId ? { taskId: fields.taskId } : {}),
571
- ...(fields.podId ? { podId: fields.podId } : {}),
572
- ...(fields.salt ? { salt: fields.salt } : {}),
573
- seq,
574
- ts,
575
- kind: fields.kind,
576
- actor,
577
- data,
578
- prev,
579
- });
580
- };
581
-
582
- /** Split a record file's text into lines; a last line with no newline is torn. */
583
- export const splitRecord = (text) => {
584
- if (!text) return { lines: [], torn: null };
585
- const parts = String(text).split('\n');
586
- const tail = parts.pop();
587
- return { lines: parts, torn: tail === '' ? null : tail };
588
- };
589
-
590
- const envelopeProblem = (entry) => {
591
- if (!Number.isInteger(entry.v) || entry.v < 1) return 'v';
592
- if (entry.schema !== RECORD_SCHEMA) return 'schema';
593
- if (!RUN_ID.test(String(entry.runId))) return 'runId';
594
- if (entry.taskId !== undefined && !isText(entry.taskId)) return 'taskId';
595
- if (entry.podId !== undefined && !isText(entry.podId)) return 'podId';
596
- if (entry.taskId !== undefined && entry.podId === undefined) return 'podId';
597
- if (!Number.isInteger(entry.seq) || entry.seq < 1) return 'seq';
598
- if (!isTimestamp(entry.ts)) return 'ts';
599
- if (typeof entry.kind !== 'string' || !KIND_SHAPE.test(entry.kind)) return 'kind';
600
- if (actorProblem(entry.actor)) return 'actor';
601
- if (!isPlainObject(entry.data)) return 'data';
602
- if (entry.prev !== null && !(typeof entry.prev === 'string' && LINE_HASH.test(entry.prev))) return 'prev';
603
- if (entry.salt !== undefined && !isSalt(entry.salt)) return 'salt';
604
- if (entry.redaction !== undefined && !isRedaction(entry.redaction)) return 'redaction';
605
- if (entry.openings !== undefined && !isOpenings(entry.openings)) return 'openings';
606
- return null;
607
- };
608
-
609
- /**
610
- * verifyRecord(text | lines) → { ok, errors: [{seq, reason, detail?}], entries, head, settled }
611
- *
612
- * Text is a whole file, so a torn final line (no newline) can be seen; an
613
- * array is taken as complete lines. Each problem is reported at the seq of
614
- * the line where it was observed:
615
- * torn the last line was cut short by a crash mid-write
616
- * unparseable not JSON
617
- * bad_envelope an envelope field is missing or malformed (detail names it)
618
- * unsupported_version v is newer than this reader
619
- * not_canonical the bytes are not the canonical form of what they parse to
620
- * seq_mismatch seq is not the previous seq + 1 (a deleted or moved line)
621
- * prev_mismatch prev is not the hash of the line before it: that line, or
622
- * this one's prev, was changed, removed or moved
623
- * run_mismatch runId, taskId or podId differs from the first line
624
- * after_settled a line follows run.settled
625
- * bad_opening an opening does not match its commitment
626
- * stray_opening an opening's commitment is not on the line
627
- * The chain re-syncs after each problem, so one edit is reported once.
628
- *
629
- * A redacted view (spec section 8) passes this check by what it can show. A
630
- * line with a `redaction` marker is not the stored line: its marker names the
631
- * stored line's hash, which the next line is checked against, or null when
632
- * that hash is withheld and the next line links to nothing. Only a view adds
633
- * `partial: true`, `redacted` (the seqs whose content the chain doesn't cover)
634
- * and `unlinked` (the subset with no hash to link through); `ok` then says no
635
- * tampering was found in what could be checked, not that every line was.
636
- *
637
- * A line with `openings` (section 9) is checked as the stored line it was
638
- * built from: the map comes off, and the bytes left are what `prev` covers. A
639
- * commitment nobody opened is not a problem. A deleted id looks exactly so.
640
- */
641
- export const verifyRecord = (input) => {
642
- const { lines, torn } = Array.isArray(input) ? { lines: input.map(String), torn: null } : splitRecord(input);
643
- const errors = [];
644
- const report = (seq, reason, detail) => errors.push(detail ? { seq, reason, detail } : { seq, reason });
645
- let expected = 1;
646
- let prevHash = null;
647
- let first = null;
648
- let settled = false;
649
- const redacted = [];
650
- const unlinked = [];
651
- for (const line of lines) {
652
- let entry;
653
- try { entry = JSON.parse(line); } catch { entry = undefined; }
654
- if (!isPlainObject(entry)) {
655
- report(expected, 'unparseable');
656
- prevHash = hashLine(line);
657
- expected += 1;
658
- continue;
659
- }
660
- const seq = Number.isInteger(entry.seq) ? entry.seq : expected;
661
- if (Number.isInteger(entry.v) && entry.v > RECORD_VERSION) report(seq, 'unsupported_version', `v${entry.v}`);
662
- const problem = envelopeProblem(entry);
663
- if (problem) report(seq, 'bad_envelope', problem);
664
- let canonicalForm = null;
665
- try { canonicalForm = canonicalLine(entry); } catch { /* reported below */ }
666
- if (canonicalForm !== line) report(seq, 'not_canonical');
667
- if (seq !== expected) report(seq, 'seq_mismatch', `expected ${expected}`);
668
- if (entry.prev !== prevHash) report(seq, 'prev_mismatch');
669
- if (first === null) first = entry;
670
- else if (entry.runId !== first.runId || entry.taskId !== first.taskId || entry.podId !== first.podId) report(seq, 'run_mismatch');
671
- if (settled) report(seq, 'after_settled');
672
- if (entry.kind === 'run.settled') settled = true;
673
- const { openings, ...stored } = entry;
674
- if (isOpenings(openings)) {
675
- const onLine = idTokens(stored);
676
- for (const [token, { id, salt }] of Object.entries(openings)) {
677
- if (commitId(id, salt) !== token) report(seq, 'bad_opening');
678
- if (!onLine.has(token)) report(seq, 'stray_opening');
679
- }
680
- }
681
- const marker = isRedaction(entry.redaction) ? entry.redaction : null;
682
- if (marker) {
683
- redacted.push(seq);
684
- if (marker.hash === null) unlinked.push(seq);
685
- }
686
- if (marker) prevHash = marker.hash;
687
- else if (openings === undefined) prevHash = hashLine(line);
688
- else {
689
- // What can't be serialized was reported as not_canonical above.
690
- try { prevHash = hashLine(canonicalLine(stored)); } catch { prevHash = hashLine(line); }
691
- }
692
- expected = seq + 1;
693
- }
694
- if (torn !== null) report(expected, 'torn');
695
- return {
696
- ok: errors.length === 0,
697
- errors,
698
- entries: lines.length,
699
- head: prevHash,
700
- settled,
701
- ...(redacted.length > 0 ? { partial: true, redacted, unlinked } : {}),
702
- };
703
- };
704
-
705
- /** An entry with each opened commitment replaced by its id, and no `openings`. */
706
- const openEntry = ({ openings, ...entry }) => {
707
- if (!isOpenings(openings)) return entry;
708
- const open = (value) => {
709
- if (typeof value === 'string') return Object.hasOwn(openings, value) ? openings[value].id : value;
710
- if (Array.isArray(value)) return value.map(open);
711
- return isPlainObject(value) ? Object.fromEntries(Object.entries(value).map(([key, item]) => [key, open(item)])) : value;
712
- };
713
- return open(entry);
714
- };
715
-
716
- /**
717
- * Parse a record's text into entries, skipping a torn tail and unparseable
718
- * lines. Opened ids read in place; a commitment nobody opened stays as it is.
719
- */
720
- export const readRecord = (text) => splitRecord(text).lines.flatMap((line) => {
721
- try {
722
- const entry = JSON.parse(line);
723
- return isPlainObject(entry) ? [openEntry(entry)] : [];
724
- } catch { return []; }
725
- });
726
-
727
- /**
728
- * Which surface a tool name belongs to, for an adapter that knows the real
729
- * tool name (an MCP server/tool pair, say). Never apply it to a title: a
730
- * title is the agent's own text and can claim any prefix.
731
- */
732
- export const toolSurfaceOf = (toolName) => {
733
- const name = String(toolName || '');
734
- if (/^(?:mcp__commonly__|commonly_)/.test(name)) return 'commonly';
735
- if (/^mcp__[^_]/.test(name)) return 'connector';
736
- return 'native';
737
- };