@commonlyai/cli 0.1.173 → 0.1.174

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