@ours.network/fleet 1.2.0-nightly.1 → 1.2.0-nightly.10

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 (86) hide show
  1. package/README.md +95 -21
  2. package/dist/agent-ours/bridge.d.ts +3 -0
  3. package/dist/agent-ours/bridge.js +178 -0
  4. package/dist/agent-ours/controller.d.ts +14 -0
  5. package/dist/agent-ours/controller.js +60 -0
  6. package/dist/agent-ours/file-rpc.d.ts +23 -0
  7. package/dist/agent-ours/file-rpc.js +123 -0
  8. package/dist/agent-ours/harness.d.ts +14 -0
  9. package/dist/agent-ours/harness.js +64 -0
  10. package/dist/agent-ours/mcp-endpoint.d.ts +15 -0
  11. package/dist/agent-ours/mcp-endpoint.js +111 -0
  12. package/dist/agent-ours/runtime.d.ts +53 -0
  13. package/dist/agent-ours/runtime.js +247 -0
  14. package/dist/agent-ours/service.d.ts +23 -0
  15. package/dist/agent-ours/service.js +364 -0
  16. package/dist/agent-ours/state.d.ts +45 -0
  17. package/dist/agent-ours/state.js +95 -0
  18. package/dist/agent-ours/wire.d.ts +14 -0
  19. package/dist/agent-ours/wire.js +55 -0
  20. package/dist/agent-recovery-gate.js +1 -1
  21. package/dist/application/legacy-supervisor-identity.d.ts +7 -0
  22. package/dist/application/legacy-supervisor-identity.js +43 -0
  23. package/dist/application/supervisor-ours-tools.d.ts +159 -0
  24. package/dist/application/supervisor-ours-tools.js +121 -0
  25. package/dist/application/task-room-service.js +55 -19
  26. package/dist/briefing.js +10 -59
  27. package/dist/build-info.json +5 -4
  28. package/dist/capabilities.d.ts +2 -1
  29. package/dist/capabilities.js +2 -0
  30. package/dist/cli.js +63 -2
  31. package/dist/client-profile.d.ts +1 -0
  32. package/dist/client-profile.js +18 -3
  33. package/dist/config.d.ts +1 -0
  34. package/dist/creation.d.ts +1 -1
  35. package/dist/creation.js +3 -14
  36. package/dist/docs.d.ts +3 -3
  37. package/dist/docs.js +32 -23
  38. package/dist/doctor.d.ts +1 -1
  39. package/dist/doctor.js +29 -10
  40. package/dist/fleet-command-audit.js +3 -2
  41. package/dist/harness/agent-session.d.ts +2 -0
  42. package/dist/harness/claude-code-session.js +4 -2
  43. package/dist/harness/codex-session.js +9 -2
  44. package/dist/harness/codex.d.ts +3 -2
  45. package/dist/harness/codex.js +4 -3
  46. package/dist/harness/hermes-session.js +7 -1
  47. package/dist/owner-channel/channel.d.ts +3 -0
  48. package/dist/owner-channel/channel.js +24 -4
  49. package/dist/rooms-tasks/archived-absence.d.ts +8 -0
  50. package/dist/rooms-tasks/archived-absence.js +48 -0
  51. package/dist/rooms-tasks/cli.js +1 -1
  52. package/dist/rooms-tasks/close.d.ts +3 -0
  53. package/dist/rooms-tasks/close.js +95 -17
  54. package/dist/rooms-tasks/cowork-adapter.d.ts +4 -0
  55. package/dist/rooms-tasks/cowork-adapter.js +10 -2
  56. package/dist/rooms-tasks/cowork-http.d.ts +3 -0
  57. package/dist/rooms-tasks/cowork-http.js +70 -0
  58. package/dist/rooms-tasks/deletion.js +40 -3
  59. package/dist/rooms-tasks/member-startup.d.ts +1 -0
  60. package/dist/rooms-tasks/member-startup.js +2 -0
  61. package/dist/rooms-tasks/provision.d.ts +11 -0
  62. package/dist/rooms-tasks/provision.js +138 -26
  63. package/dist/rooms-tasks/room-state.js +13 -0
  64. package/dist/rooms-tasks/task-state.d.ts +13 -3
  65. package/dist/rooms-tasks/task-state.js +42 -6
  66. package/dist/rooms-tasks/types.d.ts +12 -0
  67. package/dist/rooms-tasks/workspace-artifacts.d.ts +6 -0
  68. package/dist/rooms-tasks/workspace-artifacts.js +93 -0
  69. package/dist/rooms-tasks/workspace.d.ts +26 -0
  70. package/dist/rooms-tasks/workspace.js +195 -0
  71. package/dist/runner.d.ts +14 -0
  72. package/dist/runner.js +527 -495
  73. package/dist/session/codex-app-server-transport.js +1 -1
  74. package/dist/spawn.js +19 -16
  75. package/dist/startup-readiness.d.ts +13 -0
  76. package/dist/startup-readiness.js +38 -0
  77. package/dist/supervisor/systemd.js +5 -0
  78. package/dist/temp-supervisor-recovery.d.ts +3 -0
  79. package/dist/temp-supervisor-recovery.js +46 -0
  80. package/dist/watchdog/briefing.js +3 -16
  81. package/dist/watchdog/run.js +19 -5
  82. package/dist/web/server.d.ts +2 -0
  83. package/dist/web/server.js +18 -1
  84. package/docs/task-workspaces.md +98 -0
  85. package/package.json +6 -3
  86. package/presets/fleet/roles/Coordinator.yaml +22 -0
@@ -3,6 +3,7 @@ import { join } from 'node:path';
3
3
  import { randomUUID } from 'node:crypto';
4
4
  import { replaceFileAtomically } from '../atomic-file.js';
5
5
  import { stateRoot } from '../paths.js';
6
+ import { planWorkspace, ensureWorkspace } from './workspace.js';
6
7
  import { storedRoomLaunchPolicy, TASK_TERMINAL_STATES, TASK_CANCELLABLE_STATES } from './types.js';
7
8
  import { DEFAULT_TASK_LIST_ID, readTaskLists } from './task-lists.js';
8
9
  export const tasksDir = () => join(stateRoot(), 'tasks');
@@ -127,7 +128,7 @@ export function assertNoPendingDeletion(task) {
127
128
  }
128
129
  export function createTask(input) {
129
130
  const key = input.idempotency_key ?? randomUUID();
130
- const existing = findByIdempotencyKey(key);
131
+ const existing = listTasks({ includeDeleting: true }).find(task => task.idempotency_key === key);
131
132
  if (existing) {
132
133
  const existingPlan = existing.execution_plan?.plan_hash;
133
134
  const requestedPlan = input.execution_plan?.plan_hash;
@@ -135,7 +136,13 @@ export function createTask(input) {
135
136
  const requestedPolicy = storedRoomLaunchPolicy(input.execution_plan?.room_policy);
136
137
  if (existingPlan !== requestedPlan || JSON.stringify(existingPolicy) !== JSON.stringify(requestedPolicy))
137
138
  throw new TaskStateError(`idempotency key '${key}' was already used with a different execution plan`);
138
- return existing;
139
+ return withTaskLock(existing.task_id, () => {
140
+ const fresh = readTask(existing.task_id);
141
+ assertNoPendingDeletion(fresh);
142
+ if (fresh.workspace)
143
+ ensureWorkspace(fresh.workspace);
144
+ return fresh;
145
+ });
139
146
  }
140
147
  const state = input.start === false ? 'backlog' : 'provisioning';
141
148
  const record = {
@@ -155,7 +162,9 @@ export function createTask(input) {
155
162
  created_at: new Date().toISOString(),
156
163
  started_at: state === 'provisioning' ? new Date().toISOString() : undefined,
157
164
  };
165
+ record.workspace = planWorkspace('task', record.task_id);
158
166
  writeTask(record);
167
+ ensureWorkspace(record.workspace);
159
168
  return readTask(record.task_id);
160
169
  }
161
170
  export function getTask(id) { return withTaskLock(id, () => readTask(id)); }
@@ -505,7 +514,8 @@ function writeDeletionReceiptForIntent(stored) {
505
514
  actor: deletion.actor,
506
515
  original_state: stored.state,
507
516
  room_id: deletion.room_id,
508
- member_count: deletion.members.length,
517
+ member_count: deletion.members.length + (deletion.archived_absences?.length ?? 0),
518
+ ...(deletion.archived_absences?.length ? { archived_absences: deletion.archived_absences } : {}),
509
519
  };
510
520
  replaceFileAtomically(deletionReceiptPath(stored.task_id), JSON.stringify(receipt, null, 2) + '\n');
511
521
  }
@@ -688,10 +698,10 @@ export function upsertTaskDeletionMembersFromSeats(id, seats) {
688
698
  *
689
699
  * The room saga is trusted for phase jumps, but partial or corrupt retained
690
700
  * records must not become false success: every seat must be identity_absent;
691
- * a real launch requires archive evidence; an identity-less seat is accepted
692
- * only via the never-launched proof.
701
+ * a real launch requires archive evidence; an identity-less seat requires
702
+ * never-launched proof or a freshly verified archived-absence checkpoint.
693
703
  */
694
- export function importTaskDeletionRetirementEvidence(id, seats) {
704
+ export function importTaskDeletionRetirementEvidence(id, seats, proofs = []) {
695
705
  return withTaskLock(id, () => {
696
706
  assertCanonicalTaskId(id);
697
707
  const stored = JSON.parse(readFileSync(taskPath(id), 'utf8'));
@@ -708,6 +718,18 @@ export function importTaskDeletionRetirementEvidence(id, seats) {
708
718
  if (!seat.identity_cid) {
709
719
  if (neverLaunched)
710
720
  continue; // provably never held a managed identity
721
+ const proof = proofs.find(value => value.name === seat.role_name);
722
+ if (proof && proof.launch_id === evidence.launch_id && proof.archive_path === evidence.archive_path
723
+ && proof.launch_id === seat.launch?.launch_id && proof.action_id === seat.launch?.action_id) {
724
+ const existing = stored.deletion.archived_absences?.find(value => value.name === proof.name);
725
+ if (existing && (existing.launch_id !== proof.launch_id || existing.action_id !== proof.action_id
726
+ || existing.archive_path !== proof.archive_path))
727
+ throw new TaskStateError('Archived absence checkpoint ownership is immutable');
728
+ stored.deletion.archived_absences ??= [];
729
+ if (!existing)
730
+ stored.deletion.archived_absences.push({ ...proof });
731
+ continue;
732
+ }
711
733
  throw new TaskStateError(`task ${id} deletion member '${seat.role_name}' has retirement evidence but no identity CID`);
712
734
  }
713
735
  let cursor = findCursorForSeat(id, stored, seat);
@@ -729,9 +751,23 @@ export function importTaskDeletionRetirementEvidence(id, seats) {
729
751
  cursor.updated_at = now;
730
752
  }
731
753
  writeTask(stored);
754
+ writeDeletionReceiptForIntent(stored);
732
755
  return presentTaskLenient(stored);
733
756
  });
734
757
  }
758
+ /** Settlement-only checkpoint, under the task-operation lock after fresh archive verification.
759
+ * From here retries still verify live absence, but owned archives may be consumed.
760
+ */
761
+ export function beginTaskWorkspaceCleanup(id) {
762
+ withTaskLock(id, () => {
763
+ const stored = JSON.parse(readFileSync(taskPath(id), 'utf8'));
764
+ if (stored.deletion?.status !== 'pending' || !stored.workspace
765
+ || stored.deletion.members.some(member => member.phase !== 'identity_absent'))
766
+ throw new TaskStateError('Task is not ready for workspace cleanup');
767
+ stored.deletion.workspace_cleanup_started_at ??= new Date().toISOString();
768
+ writeTask(stored);
769
+ });
770
+ }
735
771
  /**
736
772
  * Physically remove a deletion-pending task record. Settlement-only: callers
737
773
  * must have completed member retirement and room cleanup first. Missing
@@ -73,6 +73,13 @@ export interface TaskDeletionMemberCursor {
73
73
  * evidence. While pending, the task is hidden from normal operation and every
74
74
  * lifecycle mutation or room publication is rejected.
75
75
  */
76
+ export interface ArchivedMemberAbsence {
77
+ name: string;
78
+ launch_id: string;
79
+ action_id: string;
80
+ archive_path: string;
81
+ checked_at: string;
82
+ }
76
83
  export interface TaskDeletionIntent {
77
84
  status: 'pending';
78
85
  accepted_at: string;
@@ -80,6 +87,9 @@ export interface TaskDeletionIntent {
80
87
  room_id?: string;
81
88
  /** Snapshot of managed members at acceptance; the missing-room retirement evidence. */
82
89
  members: TaskDeletionMemberCursor[];
90
+ archived_absences?: ArchivedMemberAbsence[];
91
+ /** Archive proofs were verified before owned workspace cleanup may consume them. */
92
+ workspace_cleanup_started_at?: string;
83
93
  error?: string;
84
94
  error_at?: string;
85
95
  recovery_hint?: string;
@@ -87,6 +97,7 @@ export interface TaskDeletionIntent {
87
97
  first_recovery_hint?: string;
88
98
  }
89
99
  export interface TaskRecord {
100
+ workspace?: import('./workspace.js').OwnedWorkspace;
90
101
  task_id: string;
91
102
  /** Stable organizational list identifier. Missing legacy values mean `default`. */
92
103
  list_id: string;
@@ -227,6 +238,7 @@ export interface RoomMemberSeat {
227
238
  retirement?: MemberRetirement;
228
239
  }
229
240
  export interface RoomOrchestrationRecord {
241
+ workspace?: import('./workspace.js').OwnedWorkspace;
230
242
  room_id: string;
231
243
  room_identity_cid?: string;
232
244
  room_name: string;
@@ -0,0 +1,6 @@
1
+ import { type OwnedWorkspace } from './workspace.js';
2
+ /** Runtime control state stays in Fleet's live roster and recovery store. Its
3
+ * durable launch descriptor, not a name prefix or caller-supplied path, proves
4
+ * artifact ownership. Audit receipts/journals intentionally outlive deletion.
5
+ */
6
+ export declare function collectWorkspaceArchives(workspace: OwnedWorkspace): void;
@@ -0,0 +1,93 @@
1
+ import { lstatSync, mkdirSync, readdirSync, readFileSync, renameSync } from 'node:fs';
2
+ import { basename, join } from 'node:path';
3
+ import { parse } from 'yaml';
4
+ import { stateRoot } from '../paths.js';
5
+ import { assertSafeAncestors, auditWorkspaceGit, validateWorkspace, ensureWorkspace, WorkspaceError } from './workspace.js';
6
+ /** Runtime control state stays in Fleet's live roster and recovery store. Its
7
+ * durable launch descriptor, not a name prefix or caller-supplied path, proves
8
+ * artifact ownership. Audit receipts/journals intentionally outlive deletion.
9
+ */
10
+ export function collectWorkspaceArchives(workspace) {
11
+ validateWorkspace(workspace);
12
+ const root = join(stateRoot(), 'recovery', 'temporary');
13
+ assertSafeAncestors(root);
14
+ let entries;
15
+ try {
16
+ entries = readdirSync(root, { withFileTypes: true });
17
+ }
18
+ catch (e) {
19
+ if (e.code === 'ENOENT')
20
+ return;
21
+ throw e;
22
+ }
23
+ const owned = [];
24
+ for (const entry of entries) {
25
+ if (!entry.isDirectory() || entry.isSymbolicLink())
26
+ continue;
27
+ const path = join(root, entry.name);
28
+ const roleFile = join(path, 'role.yaml');
29
+ let role;
30
+ try {
31
+ const s = lstatSync(roleFile);
32
+ if (!s.isFile() || s.isSymbolicLink())
33
+ continue;
34
+ role = parse(readFileSync(roleFile, 'utf8'));
35
+ }
36
+ catch {
37
+ continue;
38
+ } // no ownership proof: never adopt a foreign archive
39
+ const recorded = role?.roomMemberStartup?.workspace;
40
+ if (recorded?.owner !== workspace.owner || recorded.id !== workspace.id)
41
+ continue;
42
+ if (JSON.stringify(recorded) !== JSON.stringify(workspace))
43
+ throw new WorkspaceError('workspace archive ownership mismatch');
44
+ assertSafeAncestors(path);
45
+ const termination = join(path, 'termination.jsonl');
46
+ if (!lstatSync(termination).isFile() || lstatSync(termination).isSymbolicLink())
47
+ throw new WorkspaceError('unsafe workspace archive termination evidence');
48
+ const supervisorPath = join(path, '.temp-supervisor.json');
49
+ let supervisor;
50
+ try {
51
+ if (!lstatSync(supervisorPath).isFile() || lstatSync(supervisorPath).isSymbolicLink())
52
+ throw new WorkspaceError('unsafe workspace archive supervisor evidence');
53
+ supervisor = JSON.parse(readFileSync(supervisorPath, 'utf8'));
54
+ }
55
+ catch (e) {
56
+ if (e.code !== 'ENOENT')
57
+ throw e;
58
+ }
59
+ if (supervisor && (supervisor.role !== role.name || !supervisor.launchId))
60
+ throw new WorkspaceError('workspace archive launch ownership mismatch');
61
+ const terminated = readFileSync(termination, 'utf8').trim().split('\n').some(line => {
62
+ const value = JSON.parse(line);
63
+ return value.version === 1 && value.role === role.name
64
+ && value.launchId === supervisor?.launchId
65
+ && ['retired', 'reclaimed', 'failed'].includes(value.outcome);
66
+ });
67
+ if (!terminated || entry.name.endsWith('.retiring'))
68
+ throw new WorkspaceError('workspace archive retirement is incomplete');
69
+ auditWorkspaceGit(path);
70
+ owned.push(path);
71
+ }
72
+ // Preflight the entire set before mutation; task/room record remains until all succeed.
73
+ if (!owned.length)
74
+ return;
75
+ ensureWorkspace(workspace);
76
+ const destination = join(workspace.path, '.fleet-retired-agents');
77
+ assertSafeAncestors(destination);
78
+ mkdirSync(destination, { recursive: true, mode: 0o700 });
79
+ for (const path of owned) {
80
+ const target = join(destination, basename(path));
81
+ try {
82
+ lstatSync(target);
83
+ throw new WorkspaceError('archive collection destination exists');
84
+ }
85
+ catch (e) {
86
+ if (e.code !== 'ENOENT')
87
+ throw e;
88
+ }
89
+ // Atomic transfer before workspace deletion: a crash never loses ownership
90
+ // evidence halfway through recursively removing a global recovery archive.
91
+ renameSync(path, target);
92
+ }
93
+ }
@@ -0,0 +1,26 @@
1
+ /** Persisted before allocation. Never infer ownership from an Agent's cwd. */
2
+ export interface OwnedWorkspace {
3
+ version: 1;
4
+ owner: 'task' | 'room';
5
+ id: string;
6
+ path: string;
7
+ token: string;
8
+ }
9
+ export declare class WorkspaceError extends Error {
10
+ }
11
+ export declare function assertSafeAncestors(path: string): void;
12
+ export declare function planWorkspace(owner: OwnedWorkspace['owner'], id: string): OwnedWorkspace;
13
+ export declare function validateWorkspace(w: OwnedWorkspace, owner?: "task" | "room", id?: string): void;
14
+ /** Atomic directory publication; retry uses the durable token, never adopts another folder. */
15
+ export declare function ensureWorkspace(w: OwnedWorkspace): string;
16
+ /** Audit both directions of Git worktree registration, without executing repository config/hooks.
17
+ * All registrations and common dirs must be inside this workspace. Removing the complete
18
+ * workspace then removes their registries atomically with the owned repositories; never prune.
19
+ */
20
+ export declare function auditWorkspaceGit(path: string): void;
21
+ /** Called only after all managed writers retire, under the owner's lifecycle lock.
22
+ * The tombstone is derived from the durable unguessable token. A crash during rm
23
+ * resumes that exact tombstone even if rm already removed its marker.
24
+ */
25
+ export declare function assertWorkspaceDeletable(w: OwnedWorkspace, owner: OwnedWorkspace['owner'], id: string): void;
26
+ export declare function deleteWorkspace(w: OwnedWorkspace, owner: OwnedWorkspace['owner'], id: string): void;
@@ -0,0 +1,195 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { lstatSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
4
+ import { stateRoot } from '../paths.js';
5
+ const MARKER = '.fleet-workspace.json';
6
+ export class WorkspaceError extends Error {
7
+ }
8
+ function fail(message) { throw new WorkspaceError(`workspace: ${message}`); }
9
+ function stat(path) {
10
+ try {
11
+ return lstatSync(path);
12
+ }
13
+ catch (e) {
14
+ if (e.code === 'ENOENT')
15
+ return undefined;
16
+ throw e;
17
+ }
18
+ }
19
+ export function assertSafeAncestors(path) {
20
+ const absolute = resolve(path);
21
+ let part = absolute;
22
+ for (;;) {
23
+ const s = stat(part);
24
+ if (s && (s.isSymbolicLink() || !s.isDirectory()))
25
+ fail(`unsafe directory ${part}`);
26
+ const parent = dirname(part);
27
+ if (parent === part)
28
+ break;
29
+ part = parent;
30
+ }
31
+ }
32
+ function root(owner) {
33
+ return resolve(stateRoot(), 'workspaces', owner === 'task' ? 'tasks' : 'rooms');
34
+ }
35
+ export function planWorkspace(owner, id) {
36
+ if (!/^[a-zA-Z0-9_-]{1,80}$/.test(id))
37
+ fail('invalid owner ID');
38
+ return { version: 1, owner, id, path: join(root(owner), id), token: randomUUID() };
39
+ }
40
+ export function validateWorkspace(w, owner = w.owner, id = w.id) {
41
+ if (w.version !== 1 || !['task', 'room'].includes(w.owner) || w.owner !== owner || w.id !== id
42
+ || !/^[a-zA-Z0-9_-]{1,80}$/.test(w.id)
43
+ || !/^[a-f0-9-]{36}$/.test(w.token) || !isAbsolute(w.path)
44
+ || w.path !== join(root(w.owner), w.id))
45
+ fail('invalid recorded ownership/path');
46
+ assertSafeAncestors(dirname(w.path));
47
+ }
48
+ function marked(path, w) {
49
+ assertSafeAncestors(path);
50
+ const marker = join(path, MARKER);
51
+ if (!stat(marker)?.isFile() || stat(marker)?.isSymbolicLink())
52
+ fail('missing/unsafe ownership marker');
53
+ const actual = JSON.parse(readFileSync(marker, 'utf8'));
54
+ if (JSON.stringify(actual) !== JSON.stringify(w))
55
+ fail('ownership marker mismatch');
56
+ }
57
+ function staging(w, phase) {
58
+ return join(dirname(w.path), `.${w.id}.${w.token}.${phase}`);
59
+ }
60
+ /** Atomic directory publication; retry uses the durable token, never adopts another folder. */
61
+ export function ensureWorkspace(w) {
62
+ validateWorkspace(w);
63
+ if (stat(staging(w, 'deleting')))
64
+ fail('deletion already started');
65
+ if (stat(w.path)) {
66
+ marked(w.path, w);
67
+ return w.path;
68
+ }
69
+ mkdirSync(dirname(w.path), { recursive: true, mode: 0o700 });
70
+ assertSafeAncestors(dirname(w.path));
71
+ const stage = staging(w, 'provisioning');
72
+ if (!stat(stage))
73
+ mkdirSync(stage, { mode: 0o700 });
74
+ assertSafeAncestors(stage);
75
+ if (readdirSync(stage).length === 0)
76
+ writeFileSync(join(stage, MARKER), JSON.stringify(w), { flag: 'wx', mode: 0o600 });
77
+ marked(stage, w);
78
+ try {
79
+ renameSync(stage, w.path);
80
+ }
81
+ catch (error) {
82
+ // Another allocator may have published this exact durable intent.
83
+ if (!stat(w.path))
84
+ throw error;
85
+ marked(w.path, w);
86
+ }
87
+ return w.path;
88
+ }
89
+ function inside(base, path) {
90
+ const rel = relative(base, path);
91
+ return rel !== '' && rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
92
+ }
93
+ function ownedPointer(base, from, value) {
94
+ if (!value.trim() || /[\r\n]/.test(value.trim()))
95
+ fail('malformed git pointer');
96
+ const target = resolve(from, value.trim());
97
+ if (!inside(base, target))
98
+ fail(`foreign git metadata ${target}`);
99
+ assertSafeAncestors(dirname(target));
100
+ if (stat(target)?.isSymbolicLink())
101
+ fail(`symlink git metadata ${target}`);
102
+ return target;
103
+ }
104
+ /** Audit both directions of Git worktree registration, without executing repository config/hooks.
105
+ * All registrations and common dirs must be inside this workspace. Removing the complete
106
+ * workspace then removes their registries atomically with the owned repositories; never prune.
107
+ */
108
+ export function auditWorkspaceGit(path) {
109
+ const device = lstatSync(path).dev;
110
+ const visit = (dir, admin = false) => {
111
+ admin ||= Boolean(stat(join(dir, 'HEAD')) && (stat(join(dir, 'objects')) || stat(join(dir, 'commondir'))));
112
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
113
+ const p = join(dir, entry.name);
114
+ if (entry.isSymbolicLink()) {
115
+ if (admin || entry.name === '.git' || entry.name === 'commondir' || entry.name === 'gitdir')
116
+ fail(`symlink git metadata ${p}`);
117
+ continue; // rm unlinks ordinary artifact symlinks, never follows them.
118
+ }
119
+ if (entry.isDirectory()) {
120
+ if (lstatSync(p).dev !== device)
121
+ fail(`foreign mounted directory ${p}`);
122
+ visit(p, admin || entry.name === '.git');
123
+ continue;
124
+ }
125
+ if (!entry.isFile())
126
+ continue; // sockets/FIFOs in retired runtime state are unlinked, never opened
127
+ if (entry.name === '.git') {
128
+ const value = readFileSync(p, 'utf8').trim();
129
+ if (!value.startsWith('gitdir: '))
130
+ fail(`invalid git file ${p}`);
131
+ const gitdir = ownedPointer(path, dir, value.slice(8));
132
+ if (!stat(gitdir)?.isDirectory())
133
+ fail(`missing git directory ${gitdir}`);
134
+ }
135
+ // Git admin files, including bare repositories and submodules.
136
+ if (entry.name === 'commondir' && (admin || stat(join(dir, 'HEAD')))) {
137
+ const common = ownedPointer(path, dir, readFileSync(p, 'utf8'));
138
+ if (!stat(common)?.isDirectory())
139
+ fail(`invalid git common directory ${common}`);
140
+ }
141
+ if (entry.name === 'gitdir' && (admin || stat(join(dir, 'commondir')))) {
142
+ const target = ownedPointer(path, dir, readFileSync(p, 'utf8'));
143
+ if (stat(target) && !stat(target)?.isFile())
144
+ fail(`invalid worktree backlink ${target}`);
145
+ }
146
+ }
147
+ };
148
+ visit(path);
149
+ }
150
+ /** Called only after all managed writers retire, under the owner's lifecycle lock.
151
+ * The tombstone is derived from the durable unguessable token. A crash during rm
152
+ * resumes that exact tombstone even if rm already removed its marker.
153
+ */
154
+ export function assertWorkspaceDeletable(w, owner, id) {
155
+ validateWorkspace(w, owner, id);
156
+ if (stat(w.path)) {
157
+ if (stat(staging(w, 'deleting')))
158
+ fail('both live and deleting workspace exist');
159
+ marked(w.path, w);
160
+ auditWorkspaceGit(w.path);
161
+ }
162
+ const pending = staging(w, 'provisioning');
163
+ if (stat(pending)) {
164
+ assertSafeAncestors(pending);
165
+ if (readdirSync(pending).length)
166
+ marked(pending, w);
167
+ if (readdirSync(pending).some(name => name !== MARKER))
168
+ fail('unexpected provisioning artifacts');
169
+ }
170
+ if (stat(staging(w, 'deleting')))
171
+ assertSafeAncestors(staging(w, 'deleting'));
172
+ }
173
+ export function deleteWorkspace(w, owner, id) {
174
+ assertWorkspaceDeletable(w, owner, id);
175
+ const tomb = staging(w, 'deleting');
176
+ const pending = staging(w, 'provisioning');
177
+ if (stat(w.path)) {
178
+ if (stat(tomb))
179
+ fail('both live and deleting workspace exist');
180
+ marked(w.path, w);
181
+ auditWorkspaceGit(w.path);
182
+ renameSync(w.path, tomb);
183
+ }
184
+ if (stat(pending)) {
185
+ if (readdirSync(pending).length)
186
+ marked(pending, w);
187
+ if (readdirSync(pending).some(name => name !== MARKER))
188
+ fail('unexpected provisioning artifacts');
189
+ rmSync(pending, { recursive: true });
190
+ }
191
+ if (stat(tomb)) {
192
+ assertSafeAncestors(tomb);
193
+ rmSync(tomb, { recursive: true });
194
+ }
195
+ }
package/dist/runner.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { prepareManagedAgent, releaseManagedAgent } from './agent-ours/service.js';
1
2
  import { type ResolvedRole } from './config.js';
2
3
  import { type MonitorHandle, type MonitorOpts, type FetchLike } from './monitor.js';
3
4
  import { type DaemonGenerationProbe } from './daemon-recovery.js';
@@ -9,6 +10,8 @@ import { type OwnerChannelHandle, type OwnerChannelOptions } from './owner-chann
9
10
  import { type OwnerBinderLease } from './owner-channel/binder.js';
10
11
  import { ScheduledLoopManager, type ScheduledLoopManagerHandle } from './loops/manager.js';
11
12
  export interface RunnerDeps {
13
+ prepareAgentOurs: typeof prepareManagedAgent;
14
+ releaseAgentOurs: typeof releaseManagedAgent;
12
15
  exec: Exec;
13
16
  cpuDelegated(): boolean;
14
17
  isAlive(pid: number): boolean;
@@ -146,6 +149,16 @@ export declare const START_STAGGER_FILE = ".start-stagger-ms";
146
149
  export declare function reserveLaunchSlot(root: string, staggerMs: number, deps: Pick<RunnerDeps, 'now' | 'sleep' | 'log'>): Promise<number>;
147
150
  /** Read a temp role's config snapshot written by spawnTemp. */
148
151
  export declare function loadTempRole(name: string): ResolvedRole;
152
+ /**
153
+ * Fall back to the config path applyRole() recorded at the last up/restart/spawn
154
+ * for this role. systemd's shared unit template (`ours-fleet-agent@.service`)
155
+ * execs `_run <name>` with no -c on every restart, so a role brought up from a
156
+ * non-default config (`ours-fleet up -c custom.yaml`) would otherwise silently
157
+ * resolve against the default ~/fleet.yaml on its very first restart and fail
158
+ * with "no such role". An empty/missing marker means "use the default", same as
159
+ * no -c was ever given.
160
+ */
161
+ export declare function resolveConfigPath(dir: string, explicit?: string): string | undefined;
149
162
  /** What one child session did, so the supervising loop can decide what follows. */
150
163
  export interface AttemptResult {
151
164
  elapsedSecs: number;
@@ -168,6 +181,7 @@ export declare function runOnce(name: string, opts?: {
168
181
  temp?: boolean;
169
182
  configPath?: string;
170
183
  allowResumeRotation?: boolean;
184
+ identityLifetime?: 'permanent' | 'temporary';
171
185
  }, partialDeps?: Partial<RunnerDeps>): Promise<AttemptResult>;
172
186
  /**
173
187
  * The persistent supervisor for one permanent role: run child sessions in a