@ours.network/fleet 0.17.7 → 0.18.0-nightly.2

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.
@@ -5,7 +5,7 @@ import { agentDir, home } from '../paths.js';
5
5
  import { realExec } from '../exec.js';
6
6
  import { registerAdapter } from './registry.js';
7
7
  import { harnessRuntimeDir } from '../isolation/policy.js';
8
- import { resolveBundledAcpAgent, } from './acp-agent.js';
8
+ import { bundledAcpAgent, resolveBundledAcpAgent } from './acp-agent.js';
9
9
  const OPTION_KEYS = [
10
10
  'launcher', 'sandbox', 'approval', 'permission_mode', 'search', 'profile', 'config', 'add_dirs',
11
11
  'monitor',
@@ -51,7 +51,9 @@ function sandboxMode(role) {
51
51
  throw new Error(`invalid harness_options.sandbox "${s}"; allowed: ${SANDBOX_MODES.join(', ')}`);
52
52
  return s;
53
53
  }
54
- function modeForSandbox(sandbox) {
54
+ /** codex-acp exposes the same sandbox postures as named ACP agent modes. */
55
+ function acpAgentMode(role) {
56
+ const sandbox = sandboxMode(role);
55
57
  if (sandbox === 'read-only')
56
58
  return 'read-only';
57
59
  if (sandbox === 'workspace-write')
@@ -60,26 +62,6 @@ function modeForSandbox(sandbox) {
60
62
  return 'agent-full-access';
61
63
  return undefined;
62
64
  }
63
- /**
64
- * Resolve the coupled Codex ACP mode.
65
- *
66
- * The portable approval contract owns the default mode selection: `allow`
67
- * means the adapter's fully non-interactive yolo preset and `auto` means its
68
- * ordinary agent preset. This intentionally means that Codex ACP cannot retain
69
- * an independent neutral filesystem posture for those two modes. An explicit
70
- * native sandbox remains authoritative and selects its corresponding preset.
71
- */
72
- function acpAgentMode(role) {
73
- const explicitSandbox = role.harness_options?.sandbox;
74
- if (explicitSandbox != null)
75
- return modeForSandbox(sandboxMode(role));
76
- if (role.permissions?.approval === 'allow')
77
- return 'agent-full-access';
78
- if (role.permissions?.approval === 'auto')
79
- return 'agent';
80
- const sandbox = sandboxMode(role);
81
- return modeForSandbox(sandbox);
82
- }
83
65
  function acpModePermissions(mode) {
84
66
  if (mode === 'read-only')
85
67
  return { approval: 'on-request', sandbox: 'read-only' };
@@ -87,10 +69,6 @@ function acpModePermissions(mode) {
87
69
  return { approval: 'never', sandbox: 'danger-full-access' };
88
70
  return { approval: 'on-request', sandbox: 'workspace-write' };
89
71
  }
90
- /** The sandbox Codex will actually receive from the selected coupled ACP mode. */
91
- function acpRuntimeSandbox(role) {
92
- return acpModePermissions(acpAgentMode(role)).sandbox;
93
- }
94
72
  function fleetModeForApproval(nativeMode) {
95
73
  if (nativeMode === 'never')
96
74
  return 'allow';
@@ -124,18 +102,6 @@ function launcherMode(role) {
124
102
  function bundledCodexAcp() {
125
103
  return resolveBundledAcpAgent(CODEX_ACP_PACKAGE, 'codex-acp', 'codex-acp');
126
104
  }
127
- /** Bind launch argv and metadata provenance to one already-completed resolution. */
128
- export function codexAcpLaunchForResolution(resolution) {
129
- const permissionMetadataSource = resolution.bundled
130
- && resolution.version === BUNDLED_CODEX_ACP_VERSION
131
- && resolution.manifestPath !== undefined
132
- ? 'codex-acp'
133
- : undefined;
134
- return {
135
- argv: [...resolution.argv],
136
- ...(permissionMetadataSource ? { permissionMetadataSource } : {}),
137
- };
138
- }
139
105
  function canOverrideBundledAcpApproval() {
140
106
  const resolution = bundledCodexAcp();
141
107
  return resolution.bundled && resolution.version === BUNDLED_CODEX_ACP_VERSION
@@ -181,7 +147,7 @@ function codexAcpEnvironment(role, dirs) {
181
147
  return {
182
148
  CODEX_PATH: command,
183
149
  [CODEX_PROXY_APPROVAL_ENV]: approvalPolicy(role) ?? 'on-request',
184
- [CODEX_PROXY_SANDBOX_ENV]: acpRuntimeSandbox(role),
150
+ [CODEX_PROXY_SANDBOX_ENV]: sandboxMode(role) ?? 'workspace-write',
185
151
  [CODEX_PROXY_MANIFEST_ENV]: resolution.manifestPath,
186
152
  ...(process.env.CODEX_PATH ? { [CODEX_PROXY_REAL_PATH_ENV]: process.env.CODEX_PATH } : {}),
187
153
  };
@@ -333,30 +299,17 @@ export function makeCodexAdapter(exec = realExec) {
333
299
  },
334
300
  buildAcpLaunch(role, prep) {
335
301
  const configured = role.session_options?.acp?.command;
336
- // Resolve once: both argv and permission-metadata provenance must describe
337
- // the same artifact. A bare PATH fallback is launchable for compatibility,
338
- // but is never authenticated for protected-MCP auto-approval.
339
- const resolved = configured == null
340
- ? codexAcpLaunchForResolution(bundledCodexAcp())
341
- : undefined;
342
302
  const argv = Array.isArray(configured)
343
303
  ? [...configured]
344
304
  : typeof configured === 'string'
345
305
  ? ['sh', '-c', configured]
346
- : resolved.argv;
306
+ : bundledAcpAgent(CODEX_ACP_PACKAGE, 'codex-acp', 'codex-acp');
347
307
  const initialMode = acpAgentMode(role);
348
308
  return {
349
309
  argv,
350
310
  env: initialMode ? { ...prep.env, INITIAL_AGENT_MODE: initialMode } : prep.env,
351
- ...(resolved?.permissionMetadataSource
352
- ? { permissionMetadataSource: resolved.permissionMetadataSource } : {}),
353
311
  };
354
312
  },
355
- // INITIAL_AGENT_MODE covers session/new in codex-acp; session/set_mode
356
- // keeps resumed/loaded sessions and live status on the identical mode.
357
- acpPermissionModeId(role) {
358
- return acpAgentMode(role);
359
- },
360
313
  isolationPaths(role, _dirs) {
361
314
  const codexHome = join(home(), '.codex');
362
315
  const profile = role.harness_options?.profile;
@@ -409,9 +362,7 @@ export function makeCodexAdapter(exec = realExec) {
409
362
  const mode = acpAgentMode(role) ?? 'agent';
410
363
  const configured = role.session_options?.acp?.command;
411
364
  const overrideAvailable = configured == null && canOverrideBundledAcpApproval();
412
- const actual = overrideAvailable
413
- ? { approval, sandbox: acpRuntimeSandbox(role) }
414
- : acpModePermissions(mode);
365
+ const actual = overrideAvailable ? { approval, sandbox } : acpModePermissions(mode);
415
366
  const exact = actual.approval === approval && actual.sandbox === sandbox;
416
367
  return {
417
368
  ...translated,
@@ -421,9 +372,10 @@ export function makeCodexAdapter(exec = realExec) {
421
372
  ? `custom ACP command cannot be verified against approval=${approval} sandbox=${sandbox}; `
422
373
  + `its '${mode}' mode is conservatively treated as approval=${actual.approval} `
423
374
  + `sandbox=${actual.sandbox}`
424
- : `Codex ACP mode '${mode}' couples approval and filesystem as `
425
- + `approval=${actual.approval} sandbox=${actual.sandbox}; this does not exactly `
426
- + `represent approval=${approval} sandbox=${sandbox}`],
375
+ : `codex-acp mode '${mode}' actually uses approval=${actual.approval} `
376
+ + `sandbox=${actual.sandbox}, and the bundled ${BUNDLED_CODEX_ACP_VERSION} `
377
+ + `app-server override is unavailable; this does not exactly represent `
378
+ + `approval=${approval} sandbox=${sandbox}`],
427
379
  capabilities: codexCapabilities(actual.approval, actual.sandbox),
428
380
  };
429
381
  }
@@ -26,9 +26,9 @@ export interface Launch {
26
26
  argv: string[];
27
27
  env: Record<string, string>;
28
28
  }
29
- export interface AcpLaunch extends Launch {
30
- /** Metadata vocabulary authenticated by the exact ACP artifact in argv. */
31
- permissionMetadataSource?: 'codex-acp';
29
+ export interface AcpLaunch {
30
+ argv: string[];
31
+ env: Record<string, string>;
32
32
  }
33
33
  /**
34
34
  * The result of expressing neutral `permissions:` in a harness's own terms.
@@ -68,32 +68,8 @@ export declare class ScheduledLoopManager implements ScheduledLoopManagerHandle
68
68
  private armAbandon;
69
69
  private finish;
70
70
  private advance;
71
- /**
72
- * Coalesce a backlog into one skip. The counters alone say how many
73
- * occurrences were lost but never when or for how long, so the window is
74
- * recorded too and carried on the state until a run is actually told about it
75
- * — a dropped pass has to stay visible to the next one, not just to whoever
76
- * was reading the log at the time.
77
- */
78
71
  private skipMissed;
79
- /**
80
- * Restart is not, by itself, a reason to lose an occurrence a running manager
81
- * would still have run. `poll` tolerates lateness up to one full interval and
82
- * runs the tick late; this path used to drop anything already due however
83
- * recently, so a role restarted seconds after its own tick came due lost it
84
- * outright. For an oversight role that is precisely the pass which would have
85
- * recorded why it restarted, so the failure erased its own witness.
86
- *
87
- * The tolerance is the only thing shared with `poll`. A backlog at least one
88
- * interval deep is still coalesced into a single skip and never replayed —
89
- * after a long outage exactly one occurrence survives, and `schedule` then
90
- * arms it through the ordinary path rather than firing a burst here.
91
- *
92
- * Running the survivor late cannot outpace the configured cadence: `advance`
93
- * moves the cursor by exactly one `intervalMs` per occurrence from the nominal
94
- * time, so a loop that keeps restarting still runs at most once per interval.
95
- */
96
- private skipRestartBacklog;
72
+ private skipRestartMisses;
97
73
  /**
98
74
  * A run the store could not record is dropped, not retried: the cursor has
99
75
  * already moved, so this can never become a busy loop, and the outage is
@@ -110,10 +86,5 @@ export declare class ScheduledLoopManager implements ScheduledLoopManagerHandle
110
86
  * until the process was restarted.
111
87
  */
112
88
  private recover;
113
- /**
114
- * The envelope is the only channel a scheduled pass has for learning about
115
- * the passes that did not happen. A gap stated here is what lets an oversight
116
- * role report its own outage instead of resuming as if nothing was missed.
117
- */
118
89
  private envelope;
119
90
  }
@@ -39,7 +39,7 @@ export class ScheduledLoopManager {
39
39
  }
40
40
  start() {
41
41
  if (!this.store.fresh)
42
- this.skipRestartBacklog();
42
+ this.skipRestartMisses();
43
43
  this.schedule();
44
44
  }
45
45
  async stop() {
@@ -143,15 +143,10 @@ export class ScheduledLoopManager {
143
143
  async attempt(definition, state, scheduledAt) {
144
144
  const runId = `sl_${randomUUID()}`;
145
145
  const origin = { kind: 'scheduled-loop', loop: definition.name, runId };
146
- // The gap is read here and cleared only if the turn is actually admitted:
147
- // an attempt that ends `skipped_busy` or `unavailable` reported it to
148
- // nobody, so it has to still be there for the attempt that succeeds.
149
- const gap = state.missedGap;
150
- const prompt = this.envelope(definition, runId, scheduledAt, gap);
146
+ const prompt = this.envelope(definition, runId, scheduledAt);
151
147
  let claimed = false;
152
148
  const result = await this.arbiter.tryScheduled(prompt, origin, () => {
153
149
  claimed = true;
154
- state.missedGap = null;
155
150
  state.activeRunId = runId;
156
151
  state.lastRunId = runId;
157
152
  state.lastStartedAt = new Date(this.deps.now()).toISOString();
@@ -270,15 +265,7 @@ export class ScheduledLoopManager {
270
265
  state.nextScheduledAt = new Date(next).toISOString();
271
266
  state.nextDueAt = new Date(next + deterministicJitter(this.role, definition.name, next, definition.jitterMs)).toISOString();
272
267
  }
273
- /**
274
- * Coalesce a backlog into one skip. The counters alone say how many
275
- * occurrences were lost but never when or for how long, so the window is
276
- * recorded too and carried on the state until a run is actually told about it
277
- * — a dropped pass has to stay visible to the next one, not just to whoever
278
- * was reading the log at the time.
279
- */
280
268
  skipMissed(definition, state, now) {
281
- const from = state.nextScheduledAt;
282
269
  let missed = 0;
283
270
  while (Date.parse(state.nextDueAt) <= now) {
284
271
  this.advance(definition, state);
@@ -288,43 +275,14 @@ export class ScheduledLoopManager {
288
275
  state.counts.skippedMissed = increment(state.counts.skippedMissed, missed);
289
276
  state.lastOutcome = 'skipped_missed';
290
277
  state.lastFinishedAt = new Date(now).toISOString();
291
- // Successive outages before any run lands merge into one gap: the earliest
292
- // start wins, so the window always spans the whole silence.
293
- const previous = state.missedGap;
294
- state.missedGap = {
295
- count: increment(previous?.count ?? 0, missed),
296
- fromAt: previous?.fromAt ?? from,
297
- throughAt: state.lastScheduledAt ?? from,
298
- detectedAt: new Date(now).toISOString(),
299
- };
300
278
  this.store.persist();
301
- this.deps.log(`[${this.role}] loop ${definition.name} skipped_missed count=${missed} `
302
- + `gap=${from}..${state.missedGap.throughAt} `
303
- + `unreported=${state.missedGap.count}`);
279
+ this.deps.log(`[${this.role}] loop ${definition.name} skipped_missed count=${missed}`);
304
280
  }
305
- /**
306
- * Restart is not, by itself, a reason to lose an occurrence a running manager
307
- * would still have run. `poll` tolerates lateness up to one full interval and
308
- * runs the tick late; this path used to drop anything already due however
309
- * recently, so a role restarted seconds after its own tick came due lost it
310
- * outright. For an oversight role that is precisely the pass which would have
311
- * recorded why it restarted, so the failure erased its own witness.
312
- *
313
- * The tolerance is the only thing shared with `poll`. A backlog at least one
314
- * interval deep is still coalesced into a single skip and never replayed —
315
- * after a long outage exactly one occurrence survives, and `schedule` then
316
- * arms it through the ordinary path rather than firing a burst here.
317
- *
318
- * Running the survivor late cannot outpace the configured cadence: `advance`
319
- * moves the cursor by exactly one `intervalMs` per occurrence from the nominal
320
- * time, so a loop that keeps restarting still runs at most once per interval.
321
- */
322
- skipRestartBacklog() {
281
+ skipRestartMisses() {
323
282
  const now = this.deps.now();
324
283
  for (const definition of this.definitions.values()) {
325
284
  const state = this.store.state.loops[definition.name];
326
- if (definition.enabled && !state.operatorDisabled
327
- && now >= Date.parse(state.nextDueAt) + definition.intervalMs)
285
+ if (definition.enabled && !state.operatorDisabled && Date.parse(state.nextDueAt) <= now)
328
286
  this.skipMissed(definition, state, now);
329
287
  }
330
288
  }
@@ -382,34 +340,17 @@ export class ScheduledLoopManager {
382
340
  this.deps.clearTimer(this.timer);
383
341
  this.arm(backoffMs(this.pollFailures));
384
342
  }
385
- /**
386
- * The envelope is the only channel a scheduled pass has for learning about
387
- * the passes that did not happen. A gap stated here is what lets an oversight
388
- * role report its own outage instead of resuming as if nothing was missed.
389
- */
390
- envelope(definition, runId, scheduledAt, gap) {
391
- const lateBy = Math.max(0, this.deps.now() - scheduledAt);
343
+ envelope(definition, runId, scheduledAt) {
392
344
  return [
393
345
  '[fleet-loop]',
394
346
  `loop: ${definition.name}`,
395
347
  `run: ${runId}`,
396
348
  `scheduled_at: ${new Date(scheduledAt).toISOString()}`,
397
- ...(lateBy > 0 ? [`started_late_by_ms: ${lateBy}`] : []),
398
- ...(gap ? [
399
- `missed_occurrences: ${gap.count}`,
400
- `missed_window: ${gap.fromAt}..${gap.throughAt}`,
401
- `missed_gap_ms: ${Math.max(0, Date.parse(gap.detectedAt) - Date.parse(gap.fromAt))}`,
402
- ] : []),
403
349
  'origin: local-trusted-config',
404
350
  '',
405
351
  'This is a scheduled internal maintenance turn, not an owner message and not ordinary ours mail.',
406
352
  'Perform one bounded pass. Do not wait for the next tick. Do not report to an owner unless your',
407
353
  'configured policy and an existing authenticated proactive-report route authorize a material report.',
408
- ...(gap ? ['',
409
- 'This loop did not run for the window above: those occurrences were coalesced away while the role',
410
- 'was unavailable, and this pass is the first since. Treat the gap as part of what you are reporting',
411
- 'on — it is the record of your own outage, and no later pass will be told about it.',
412
- ] : []),
413
354
  '',
414
355
  definition.prompt,
415
356
  ].join('\n');
@@ -8,29 +8,11 @@ export interface LoopCounts {
8
8
  skippedBusy: number;
9
9
  skippedMissed: number;
10
10
  }
11
- /**
12
- * A coalesced run of occurrences that were never submitted, held until a run
13
- * actually starts and can be told about it. Without it a dropped occurrence
14
- * survives only as a counter, which says how many were lost but never when or
15
- * for how long — and an oversight role cannot report an outage it cannot date.
16
- */
17
- export interface LoopMissedGap {
18
- /** Occurrences coalesced away, summed across every skip since the last run. */
19
- count: number;
20
- /** Nominal time of the earliest occurrence in the gap. */
21
- fromAt: string;
22
- /** Nominal time of the latest occurrence in the gap. */
23
- throughAt: string;
24
- /** When the manager noticed — the end of the outage, not of the last skip. */
25
- detectedAt: string;
26
- }
27
11
  export interface LoopRuntimeState {
28
12
  definitionHash: string;
29
13
  promptHash: string;
30
14
  enabled: boolean;
31
15
  operatorDisabled: boolean;
32
- /** Unreported gap, cleared by the first run that carries it. */
33
- missedGap: LoopMissedGap | null;
34
16
  nextScheduledAt: string;
35
17
  nextDueAt: string;
36
18
  lastScheduledAt: string | null;
@@ -100,9 +100,6 @@ export class ScheduledLoopStateStore {
100
100
  if (old?.definitionHash === definition.definitionHash) {
101
101
  next[definition.name] = {
102
102
  ...old, promptHash: definition.promptHash, enabled: definition.enabled,
103
- // A file written before this field existed restores as undefined; an
104
- // unreported gap is absent, not lost, so normalize rather than trust.
105
- missedGap: old.missedGap ?? null,
106
103
  };
107
104
  }
108
105
  else {
@@ -122,7 +119,6 @@ export class ScheduledLoopStateStore {
122
119
  activeRunId: old?.activeRunId ?? null,
123
120
  counts: old?.counts ?? zeroCounts(), lastError: old?.lastError ?? null,
124
121
  operatorDisabled: old?.operatorDisabled ?? false,
125
- missedGap: old?.missedGap ?? null,
126
122
  };
127
123
  }
128
124
  if (recoverActive && next[definition.name].activeRunId) {
@@ -1,4 +1,6 @@
1
+ import { type FileHandle } from 'node:fs/promises';
1
2
  import type { OwnerAttachmentConfig } from '../config.js';
3
+ import type { OursIncomingFile, OursRetrievedFiles } from './ours-client.js';
2
4
  export interface AttachmentReplyRef {
3
5
  wire_id: string;
4
6
  sentence?: number;
@@ -42,8 +44,14 @@ export interface AdmittedAttachment {
42
44
  kind: 'file' | 'voice_message';
43
45
  transcription?: Omit<VoiceTranscription, 'audioPath'>;
44
46
  }
45
- export declare function parseIncomingAttachments(raw: unknown): IncomingAttachment[];
46
- export declare function parseRetrievedAttachments(raw: unknown, expected: IncomingAttachment[], recovered?: boolean): RetrievedAttachment[];
47
+ /**
48
+ * Admit the daemon's file listing. The rows are typed now, but every field is
49
+ * still re-validated here: sender CID, wire id, sizes and ids all cross the
50
+ * trust boundary and decide routing, so a daemon-side shape change must drop a
51
+ * row rather than produce a half-built attachment.
52
+ */
53
+ export declare function parseIncomingAttachments(raw: OursIncomingFile[] | undefined): IncomingAttachment[];
54
+ export declare function parseRetrievedAttachments(raw: OursRetrievedFiles | undefined, expected: IncomingAttachment[], recovered?: boolean): RetrievedAttachment[];
47
55
  export declare function validateAttachmentSelection(files: IncomingAttachment[], config: OwnerAttachmentConfig): string | undefined;
48
56
  /**
49
57
  * Managed-agent -> owner egress limits. This intentionally does not consult
@@ -54,6 +62,21 @@ export declare function prepareAttachmentDirectory(root: string, requestId: stri
54
62
  export declare function admitAttachments(files: RetrievedAttachment[], dir: string, config: OwnerAttachmentConfig, options?: {
55
63
  mimePolicy?: 'strict' | 'report-only';
56
64
  }): Promise<AdmittedAttachment[]>;
65
+ /** Injectable short-write seam, so partial writes are provably handled. */
66
+ export interface AttachmentWriteDeps {
67
+ write?(handle: FileHandle, bytes: Uint8Array, offset: number): Promise<number>;
68
+ }
69
+ /**
70
+ * Land crash-recovered file bytes inside an already-prepared request directory.
71
+ *
72
+ * The MCP path handed the daemon a `dest_path` and let its connector write the
73
+ * file. Nothing writes on our behalf any more, so this owns both halves of that
74
+ * contract: the destination is DERIVED from a validated wire id inside `dir`
75
+ * rather than accepted from a caller, and the file is published by link-after-
76
+ * fsync, so a crash or a short write can never leave a partial file where the
77
+ * admission step would read it as complete.
78
+ */
79
+ export declare function writeRecoveredAttachment(dir: string, wireId: string, bytes: Uint8Array, deps?: AttachmentWriteDeps): Promise<string>;
57
80
  export declare function recoveredAttachment(file: IncomingAttachment, path: string): Promise<RetrievedAttachment>;
58
81
  export declare function removeRequestDirectory(path: string): Promise<void>;
59
82
  export declare function cleanupAttachmentRoot(root: string, now: number, retentionMs: number, limit?: number): Promise<number>;
@@ -6,8 +6,14 @@ import { replaceFileAtomically } from '../atomic-file.js';
6
6
  const WIRE = /^[A-Fa-f0-9]{64}$/;
7
7
  const CID = /^[A-Fa-f0-9]{64}$/;
8
8
  const MAX_PENDING_REQUESTS = 32;
9
+ /**
10
+ * Admit the daemon's file listing. The rows are typed now, but every field is
11
+ * still re-validated here: sender CID, wire id, sizes and ids all cross the
12
+ * trust boundary and decide routing, so a daemon-side shape change must drop a
13
+ * row rather than produce a half-built attachment.
14
+ */
9
15
  export function parseIncomingAttachments(raw) {
10
- const values = raw?.files;
16
+ const values = raw;
11
17
  if (!Array.isArray(values))
12
18
  return [];
13
19
  const out = [];
@@ -40,12 +46,12 @@ export function parseIncomingAttachments(raw) {
40
46
  export function parseRetrievedAttachments(raw, expected, recovered = false) {
41
47
  const values = raw?.files;
42
48
  if (!Array.isArray(values) || values.length !== expected.length)
43
- throw new Error('ours-mcp returned an incomplete selected attachment set');
49
+ throw new Error('the ours daemon returned an incomplete selected attachment set');
44
50
  const byWire = new Map(expected.map(file => [file.wireId, file]));
45
51
  const out = [];
46
52
  for (const value of values) {
47
53
  if (!value || typeof value !== 'object')
48
- throw new Error('ours-mcp returned invalid attachment metadata');
54
+ throw new Error('the ours daemon returned invalid attachment metadata');
49
55
  const file = value;
50
56
  const wireId = String(file.wire_id ?? '');
51
57
  const listed = byWire.get(wireId);
@@ -58,7 +64,7 @@ export function parseRetrievedAttachments(raw, expected, recovered = false) {
58
64
  || !Number.isSafeInteger(size) || size !== listed.size || mime !== listed.mime
59
65
  || kind !== listed.kind || !/^[a-f0-9]{64}$/.test(sha256)
60
66
  || typeof file.path !== 'string' || !file.path)
61
- throw new Error('ours-mcp selected attachment provenance or integrity metadata mismatched');
67
+ throw new Error('selected attachment provenance or integrity metadata mismatched');
62
68
  out.push({
63
69
  ...listed, filename: safeField(file.filename, 255), mime, size, path: file.path, sha256, kind,
64
70
  ...(recovered ? {} : parseTranscription(file.transcription, wireId)),
@@ -66,7 +72,7 @@ export function parseRetrievedAttachments(raw, expected, recovered = false) {
66
72
  byWire.delete(wireId);
67
73
  }
68
74
  if (byWire.size)
69
- throw new Error('ours-mcp omitted a selected attachment');
75
+ throw new Error('the ours daemon omitted a selected attachment');
70
76
  return out;
71
77
  }
72
78
  function parseTranscription(value, wireId) {
@@ -219,6 +225,56 @@ export async function admitAttachments(files, dir, config, options = {}) {
219
225
  }
220
226
  return admitted;
221
227
  }
228
+ /**
229
+ * Land crash-recovered file bytes inside an already-prepared request directory.
230
+ *
231
+ * The MCP path handed the daemon a `dest_path` and let its connector write the
232
+ * file. Nothing writes on our behalf any more, so this owns both halves of that
233
+ * contract: the destination is DERIVED from a validated wire id inside `dir`
234
+ * rather than accepted from a caller, and the file is published by link-after-
235
+ * fsync, so a crash or a short write can never leave a partial file where the
236
+ * admission step would read it as complete.
237
+ */
238
+ export async function writeRecoveredAttachment(dir, wireId, bytes, deps = {}) {
239
+ if (!WIRE.test(wireId))
240
+ throw new Error('recovered attachment wire id is not a 64-hex value');
241
+ const dirStat = await lstat(dir);
242
+ if (!dirStat.isDirectory() || dirStat.isSymbolicLink())
243
+ throw new Error('recovered attachment directory is not a safe directory');
244
+ const write = deps.write
245
+ ?? ((handle, buffer, offset) => handle.write(buffer, offset, buffer.length - offset)
246
+ .then(result => result.bytesWritten));
247
+ const finalPath = join(dir, `.recovered-${wireId}-${randomUUID()}`);
248
+ const tmp = join(dir, `.${basename(finalPath)}.${randomUUID()}.tmp`);
249
+ const handle = await open(tmp, 'wx', 0o600);
250
+ try {
251
+ for (let written = 0; written < bytes.length;) {
252
+ const advanced = await write(handle, bytes, written);
253
+ if (advanced <= 0)
254
+ throw new Error(`recovered attachment write made no progress at byte ${written}`);
255
+ written += advanced;
256
+ }
257
+ await handle.sync();
258
+ }
259
+ catch (error) {
260
+ await handle.close().catch(() => undefined);
261
+ await rm(tmp, { force: true });
262
+ throw error;
263
+ }
264
+ await handle.close();
265
+ // link publishes the finished bytes under a name that never existed in a
266
+ // partial state; the temp is only ever removed after it succeeded.
267
+ try {
268
+ await link(tmp, finalPath);
269
+ }
270
+ catch (error) {
271
+ await rm(tmp, { force: true });
272
+ throw error;
273
+ }
274
+ await rm(tmp, { force: true });
275
+ await chmod(finalPath, 0o600);
276
+ return finalPath;
277
+ }
222
278
  export async function recoveredAttachment(file, path) {
223
279
  const stat = await lstat(path);
224
280
  if (!stat.isFile() || stat.isSymbolicLink() || stat.size !== file.size)
@@ -4,7 +4,7 @@ import { type FetchLike } from '../monitor.js';
4
4
  import { type SessionHandle } from '../session/types.js';
5
5
  import { type OwnerFleetOps } from './commands.js';
6
6
  import type { ManagedFleetSpawnResult } from '../fleet-proxy.js';
7
- import { type OursToolClient } from './mcp.js';
7
+ import { type OursOps } from './ours-client.js';
8
8
  import { type OwnerUpdatePhase } from './notices.js';
9
9
  import { type OwnerEntry } from './state.js';
10
10
  import { type OwnerTaskPhase } from './tasks.js';
@@ -17,9 +17,13 @@ export interface OwnerChannelOptions {
17
17
  session: SessionHandle;
18
18
  stateDir: string;
19
19
  env?: Record<string, string>;
20
+ /**
21
+ * `ours-mcp` binary for the legacy `watch` child process only. Daemon
22
+ * operations no longer go through it; they use the ours SDK client.
23
+ */
20
24
  command?: string;
21
25
  log(line: string): void;
22
- client?: OursToolClient;
26
+ client?: OursOps;
23
27
  /** Legacy child-process test seam; production uses the direct notification API. */
24
28
  watch?: (identity: string) => ChildProcessWithoutNullStreams;
25
29
  /** Test seam for the production direct notification long-poll. */
@@ -118,7 +122,13 @@ export type { OwnerUpdatePhase } from './notices.js';
118
122
  export interface OwnerContact {
119
123
  cid: string;
120
124
  name: string;
125
+ /** Structural, from which daemon collection the row came: established or pending. */
121
126
  status: string;
127
+ /**
128
+ * Retained for the `ours-fleet owner contact list` column. The daemon's typed
129
+ * contact view has no such field, so it is always absent; it is not inferred
130
+ * from anything a contact controls.
131
+ */
122
132
  kind?: string;
123
133
  human?: {
124
134
  cid?: string;
@@ -174,6 +184,12 @@ export declare class OwnerChannel implements OwnerChannelHandle {
174
184
  manage(request: OwnerChannelManagementRequest): Promise<OwnerChannelManagementResult>;
175
185
  notifyFleetSpawn(event: ManagedFleetSpawnResult): Promise<void>;
176
186
  private manageNow;
187
+ /**
188
+ * The daemon reports established contacts and pending introductions as two
189
+ * separate collections, so the status is structural rather than a word parsed
190
+ * out of a rendered line. Nothing here can be spoofed by a contact's own
191
+ * display name.
192
+ */
177
193
  private contacts;
178
194
  private contact;
179
195
  private assertCid;