@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.
- package/README.md +95 -21
- package/dist/agent-ours/bridge.d.ts +3 -0
- package/dist/agent-ours/bridge.js +178 -0
- package/dist/agent-ours/controller.d.ts +14 -0
- package/dist/agent-ours/controller.js +60 -0
- package/dist/agent-ours/file-rpc.d.ts +23 -0
- package/dist/agent-ours/file-rpc.js +123 -0
- package/dist/agent-ours/harness.d.ts +14 -0
- package/dist/agent-ours/harness.js +64 -0
- package/dist/agent-ours/mcp-endpoint.d.ts +15 -0
- package/dist/agent-ours/mcp-endpoint.js +111 -0
- package/dist/agent-ours/runtime.d.ts +53 -0
- package/dist/agent-ours/runtime.js +247 -0
- package/dist/agent-ours/service.d.ts +23 -0
- package/dist/agent-ours/service.js +364 -0
- package/dist/agent-ours/state.d.ts +45 -0
- package/dist/agent-ours/state.js +95 -0
- package/dist/agent-ours/wire.d.ts +14 -0
- package/dist/agent-ours/wire.js +55 -0
- package/dist/agent-recovery-gate.js +1 -1
- package/dist/application/legacy-supervisor-identity.d.ts +7 -0
- package/dist/application/legacy-supervisor-identity.js +43 -0
- package/dist/application/supervisor-ours-tools.d.ts +159 -0
- package/dist/application/supervisor-ours-tools.js +121 -0
- package/dist/application/task-room-service.js +55 -19
- package/dist/briefing.js +10 -59
- package/dist/build-info.json +5 -4
- package/dist/capabilities.d.ts +2 -1
- package/dist/capabilities.js +2 -0
- package/dist/cli.js +63 -2
- package/dist/client-profile.d.ts +1 -0
- package/dist/client-profile.js +18 -3
- package/dist/config.d.ts +1 -0
- package/dist/creation.d.ts +1 -1
- package/dist/creation.js +3 -14
- package/dist/docs.d.ts +3 -3
- package/dist/docs.js +32 -23
- package/dist/doctor.d.ts +1 -1
- package/dist/doctor.js +29 -10
- package/dist/fleet-command-audit.js +3 -2
- package/dist/harness/agent-session.d.ts +2 -0
- package/dist/harness/claude-code-session.js +4 -2
- package/dist/harness/codex-session.js +9 -2
- package/dist/harness/codex.d.ts +3 -2
- package/dist/harness/codex.js +4 -3
- package/dist/harness/hermes-session.js +7 -1
- package/dist/owner-channel/channel.d.ts +3 -0
- package/dist/owner-channel/channel.js +24 -4
- package/dist/rooms-tasks/archived-absence.d.ts +8 -0
- package/dist/rooms-tasks/archived-absence.js +48 -0
- package/dist/rooms-tasks/cli.js +1 -1
- package/dist/rooms-tasks/close.d.ts +3 -0
- package/dist/rooms-tasks/close.js +95 -17
- package/dist/rooms-tasks/cowork-adapter.d.ts +4 -0
- package/dist/rooms-tasks/cowork-adapter.js +10 -2
- package/dist/rooms-tasks/cowork-http.d.ts +3 -0
- package/dist/rooms-tasks/cowork-http.js +70 -0
- package/dist/rooms-tasks/deletion.js +40 -3
- package/dist/rooms-tasks/member-startup.d.ts +1 -0
- package/dist/rooms-tasks/member-startup.js +2 -0
- package/dist/rooms-tasks/provision.d.ts +11 -0
- package/dist/rooms-tasks/provision.js +138 -26
- package/dist/rooms-tasks/room-state.js +13 -0
- package/dist/rooms-tasks/task-state.d.ts +13 -3
- package/dist/rooms-tasks/task-state.js +42 -6
- package/dist/rooms-tasks/types.d.ts +12 -0
- package/dist/rooms-tasks/workspace-artifacts.d.ts +6 -0
- package/dist/rooms-tasks/workspace-artifacts.js +93 -0
- package/dist/rooms-tasks/workspace.d.ts +26 -0
- package/dist/rooms-tasks/workspace.js +195 -0
- package/dist/runner.d.ts +14 -0
- package/dist/runner.js +527 -495
- package/dist/session/codex-app-server-transport.js +1 -1
- package/dist/spawn.js +19 -16
- package/dist/startup-readiness.d.ts +13 -0
- package/dist/startup-readiness.js +38 -0
- package/dist/supervisor/systemd.js +5 -0
- package/dist/temp-supervisor-recovery.d.ts +3 -0
- package/dist/temp-supervisor-recovery.js +46 -0
- package/dist/watchdog/briefing.js +3 -16
- package/dist/watchdog/run.js +19 -5
- package/dist/web/server.d.ts +2 -0
- package/dist/web/server.js +18 -1
- package/docs/task-workspaces.md +98 -0
- package/package.json +6 -3
- 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 =
|
|
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
|
|
692
|
-
*
|
|
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
|