@ours.network/fleet 1.2.0-nightly.52 → 1.2.0-nightly.54
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 +2 -0
- package/dist/agent-ours/bridge.js +10 -3
- package/dist/agent-ours/file-rpc.d.ts +8 -0
- package/dist/agent-ours/file-rpc.js +11 -1
- package/dist/agent-ours/harness.js +47 -3
- package/dist/agent-ours/mcp-endpoint.d.ts +5 -0
- package/dist/agent-ours/mcp-endpoint.js +4 -0
- package/dist/agent-ours/runtime.d.ts +3 -1
- package/dist/agent-ours/runtime.js +14 -2
- package/dist/agent-ours/service.d.ts +2 -0
- package/dist/agent-ours/service.js +44 -2
- package/dist/application/conversation-history.d.ts +2 -0
- package/dist/application/conversation-history.js +25 -2
- package/dist/application/supervisor-ours-tools.js +3 -1
- package/dist/briefing.d.ts +1 -1
- package/dist/briefing.js +52 -29
- package/dist/build-info.json +4 -4
- package/dist/config.d.ts +9 -0
- package/dist/config.js +13 -2
- package/dist/file-delivery/reader.d.ts +17 -0
- package/dist/file-delivery/reader.js +94 -0
- package/dist/file-delivery/store.d.ts +15 -0
- package/dist/file-delivery/store.js +124 -0
- package/dist/file-delivery/types.d.ts +19 -0
- package/dist/file-delivery/types.js +1 -0
- package/dist/harness/claude-code.js +11 -10
- package/dist/harness/codex-app-server-proxy.js +6 -2
- package/dist/harness/codex-file-inventory.d.ts +15 -0
- package/dist/harness/codex-file-inventory.js +113 -0
- package/dist/harness/codex.js +9 -5
- package/dist/isolation/policy.js +4 -1
- package/dist/monitor.d.ts +7 -0
- package/dist/monitor.js +65 -1
- package/dist/rooms-tasks/cli.js +12 -2
- package/dist/rooms-tasks/cowork-http.js +29 -1
- package/dist/rooms-tasks/erasure.js +5 -3
- package/dist/rooms-tasks/workspace-artifacts.js +1 -1
- package/dist/rooms-tasks/workspace.d.ts +8 -1
- package/dist/rooms-tasks/workspace.js +168 -43
- package/dist/runner.js +32 -9
- package/dist/session/acp.d.ts +4 -0
- package/dist/session/acp.js +61 -8
- package/dist/session/conversation-types.d.ts +4 -2
- package/dist/session/types.d.ts +1 -0
- package/dist/startup-prompt.d.ts +4 -0
- package/dist/startup-prompt.js +36 -0
- package/dist/startup-telemetry.d.ts +16 -0
- package/dist/startup-telemetry.js +46 -0
- package/dist/web/server.js +24 -0
- package/docs/acp-files.md +102 -0
- package/docs/task-workspaces.md +31 -0
- package/package.json +4 -3
package/dist/monitor.js
CHANGED
|
@@ -325,6 +325,7 @@ export class Monitor {
|
|
|
325
325
|
stopped = false;
|
|
326
326
|
bootDeadline = 0;
|
|
327
327
|
currentAbort = null;
|
|
328
|
+
initialBacklogPending = true;
|
|
328
329
|
// Refusal-wedge detector (issue #19): consecutive delivered wakes whose turn
|
|
329
330
|
// ended in an API error with no completed turn in between.
|
|
330
331
|
apiErrorStreak = 0;
|
|
@@ -389,6 +390,41 @@ export class Monitor {
|
|
|
389
390
|
this.degrade('offline', 'session offline');
|
|
390
391
|
return;
|
|
391
392
|
}
|
|
393
|
+
// The native SessionStart hook is disabled for managed sessions. Check
|
|
394
|
+
// existing unread work AFTER the ready turn, without a model inbox ritual
|
|
395
|
+
// or touching read status. Keep retrying a failed probe/delivery before
|
|
396
|
+
// committing stream cursors, including on supervisor restarts.
|
|
397
|
+
if (this.initialBacklogPending) {
|
|
398
|
+
try {
|
|
399
|
+
const body = await this.readInitialUnread();
|
|
400
|
+
if (!Array.isArray(body.identities))
|
|
401
|
+
throw new Error('unread summary is malformed');
|
|
402
|
+
const row = body.identities.find(row => row.name === this.identity);
|
|
403
|
+
if (row && (!Number.isSafeInteger(row.count) || Number(row.count) < 0
|
|
404
|
+
|| !Number.isSafeInteger(row.files) || Number(row.files) < 0))
|
|
405
|
+
throw new Error('unread identity summary is malformed');
|
|
406
|
+
const messages = this.cfg.wake_sources.includes('message_received') ? Number(row?.count ?? 0) : 0;
|
|
407
|
+
const files = this.cfg.wake_sources.includes('file_received') ? Number(row?.files ?? 0) : 0;
|
|
408
|
+
if ((messages || files) && !await this.deliverLine(`${PREFIX} unread backlog: ${messages} messages, ${files} files — run get_messages; drain remaining unread batches and authorize files before retrieving them`)) {
|
|
409
|
+
await this.deps.sleep(BACKOFF_MAX_MS);
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
412
|
+
this.initialBacklogPending = false;
|
|
413
|
+
this.recover('connectivity', 'auth');
|
|
414
|
+
}
|
|
415
|
+
catch (e) {
|
|
416
|
+
if (this.stopped)
|
|
417
|
+
return;
|
|
418
|
+
if (e instanceof AuthError)
|
|
419
|
+
this.degrade('auth', e.message);
|
|
420
|
+
else
|
|
421
|
+
this.degrade('connectivity', 'initial unread check unavailable; retrying');
|
|
422
|
+
await this.deps.sleep(BACKOFF_MAX_MS);
|
|
423
|
+
continue;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
if (this.stopped)
|
|
427
|
+
return;
|
|
392
428
|
let body;
|
|
393
429
|
try {
|
|
394
430
|
body = await this.doFetch(String(this.cursor ?? 0), LONGPOLL_STALL_MS, 'stall');
|
|
@@ -463,7 +499,9 @@ export class Monitor {
|
|
|
463
499
|
catch { /* no stragglers / abort — deliver what we have */ }
|
|
464
500
|
}
|
|
465
501
|
async deliver(pid, batch) {
|
|
466
|
-
|
|
502
|
+
return this.deliverLine(formatNotificationLine(batch));
|
|
503
|
+
}
|
|
504
|
+
async deliverLine(line) {
|
|
467
505
|
if (!this.deps.delivery) {
|
|
468
506
|
this.degrade('delivery', 'structured agent-session delivery is unavailable');
|
|
469
507
|
return false;
|
|
@@ -551,6 +589,32 @@ export class Monitor {
|
|
|
551
589
|
}
|
|
552
590
|
return this.profileClientPromise;
|
|
553
591
|
}
|
|
592
|
+
async readInitialUnread() {
|
|
593
|
+
const ctrl = new AbortController();
|
|
594
|
+
this.currentAbort = ctrl;
|
|
595
|
+
const timer = this.deps.timers.set(() => ctrl.abort(), 5_000);
|
|
596
|
+
let client;
|
|
597
|
+
try {
|
|
598
|
+
const { endpoint, expectedInstanceId, credentialPath } = this.profile;
|
|
599
|
+
const options = {
|
|
600
|
+
endpoint, expectedInstanceId, credentialPath,
|
|
601
|
+
sessionMode: 'external', leaseToken: this.monitorLeaseToken, env: {},
|
|
602
|
+
requestSignal: ctrl.signal,
|
|
603
|
+
};
|
|
604
|
+
client = await (this.deps.attachClient?.(options) ?? attachOursClient(options));
|
|
605
|
+
return await client.unread();
|
|
606
|
+
}
|
|
607
|
+
catch (error) {
|
|
608
|
+
if (/(?:HTTP\s*401|unauthori[sz]ed|API token)/i.test(msg(error)))
|
|
609
|
+
throw new AuthError('daemon rejected the API token (401); initial unread check unavailable; retrying');
|
|
610
|
+
throw error;
|
|
611
|
+
}
|
|
612
|
+
finally {
|
|
613
|
+
this.deps.timers.clear(timer);
|
|
614
|
+
this.currentAbort = null;
|
|
615
|
+
await client?.close().catch(() => undefined);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
554
618
|
async disposeProfileClient() {
|
|
555
619
|
const pending = this.profileClientPromise;
|
|
556
620
|
this.profileClientPromise = undefined;
|
package/dist/rooms-tasks/cli.js
CHANGED
|
@@ -247,6 +247,8 @@ const taskListMarkdown = (tasks, title = 'Tasks') => renderMarkdownList({
|
|
|
247
247
|
icon: '📋', title, empty: 'No tasks found.',
|
|
248
248
|
records: tasks.map(t => `${taskStatus(t.state)} ${markdownCode(t.task_id)} — ${markdownProse(t.title)}`
|
|
249
249
|
+ ` — List ${markdownCode(t.list_name ?? 'default')}`
|
|
250
|
+
+ (t.deletion?.status === 'pending' ? ' — Deletion pending'
|
|
251
|
+
+ (t.deletion.error ? `: ${markdownProse(t.deletion.error)}` : '') : '')
|
|
250
252
|
+ (t.blocked ? ` — 🚧 Blocked: ${markdownProse(t.blocked.reason)}` : '')),
|
|
251
253
|
});
|
|
252
254
|
const taskActionMarkdown = (title, task, fields = []) => {
|
|
@@ -714,6 +716,7 @@ export function registerTaskCommands(parent, cOpt) {
|
|
|
714
716
|
.option('--state <state>', 'filter by state (backlog|active|provisioning|review|done|cancelled|failed|all)')
|
|
715
717
|
.option('--list <name>', 'filter by task list')
|
|
716
718
|
.option('--group-by-list', 'group deterministic results by list (JSON)')
|
|
719
|
+
.option('--include-deleting', 'include tasks whose accepted deletion is still pending')
|
|
717
720
|
.option('--json', 'JSON output')
|
|
718
721
|
.action(async (opts) => {
|
|
719
722
|
try {
|
|
@@ -722,7 +725,8 @@ export function registerTaskCommands(parent, cOpt) {
|
|
|
722
725
|
stateFilter = opts.state;
|
|
723
726
|
}
|
|
724
727
|
const service = taskRoomService(opts.configuration);
|
|
725
|
-
const filter = { ...(stateFilter ? { state: stateFilter } : {}), ...(opts.list ? { list: opts.list } : {})
|
|
728
|
+
const filter = { ...(stateFilter ? { state: stateFilter } : {}), ...(opts.list ? { list: opts.list } : {}),
|
|
729
|
+
...(opts.includeDeleting ? { includeDeleting: true } : {}) };
|
|
726
730
|
const tasks = service.listTasks(filter);
|
|
727
731
|
if (opts.json) {
|
|
728
732
|
console.log(JSON.stringify(opts.groupByList
|
|
@@ -839,7 +843,8 @@ export function registerTaskCommands(parent, cOpt) {
|
|
|
839
843
|
try {
|
|
840
844
|
const service = taskRoomService(opts.configuration);
|
|
841
845
|
const { task: t, orchestration: room } = service.getTask(id);
|
|
842
|
-
const readiness = t.state === 'active'
|
|
846
|
+
const readiness = t.state === 'active' && t.deletion?.status !== 'pending'
|
|
847
|
+
? await service.observeTaskReadiness(id) : undefined;
|
|
843
848
|
if (opts.json) {
|
|
844
849
|
console.log(JSON.stringify({
|
|
845
850
|
schema_version: 1, task: t, orchestration: room ?? null,
|
|
@@ -853,6 +858,11 @@ export function registerTaskCommands(parent, cOpt) {
|
|
|
853
858
|
{ label: 'ID', value: t.task_id, kind: 'code' },
|
|
854
859
|
{ label: 'Title', value: t.title },
|
|
855
860
|
{ label: 'Status', value: taskStatus(t.state), kind: 'markdown' },
|
|
861
|
+
...(t.deletion?.status === 'pending' ? [
|
|
862
|
+
{ label: 'Deletion', value: 'Pending — cleanup is not complete' },
|
|
863
|
+
...(t.deletion.error ? [{ label: 'Cleanup error', value: t.deletion.error }] : []),
|
|
864
|
+
{ label: 'Retry', value: `ours-fleet task delete ${t.task_id} ${t.task_id}`, kind: 'code' },
|
|
865
|
+
] : []),
|
|
856
866
|
...(readiness ? [{ label: 'Readiness', value: readiness.state },
|
|
857
867
|
...('reason' in readiness ? [{ label: 'Reason', value: readiness.reason }] : [])] : []),
|
|
858
868
|
...(t.blocked ? [{ label: 'Blocked', value: t.blocked.reason }] : []),
|
|
@@ -2,6 +2,26 @@ import { randomUUID } from 'node:crypto';
|
|
|
2
2
|
import { attachOursClient } from '@ours.network/sdk/client';
|
|
3
3
|
import { readPrivateFile } from '@ours.network/sdk/connector';
|
|
4
4
|
import { CoworkProtocolError, CoworkUnavailableError } from './cowork-adapter.js';
|
|
5
|
+
const TRANSPORT_CODES = new Set(['EACCES', 'ENOENT', 'ECONNREFUSED', 'ECONNRESET',
|
|
6
|
+
'ETIMEDOUT', 'ENOTFOUND', 'EAI_AGAIN', 'UND_ERR_CONNECT_TIMEOUT', 'UND_ERR_SOCKET',
|
|
7
|
+
'CERT_HAS_EXPIRED', 'ERR_TLS_CERT_ALTNAME_INVALID']);
|
|
8
|
+
function safeFailureKind(error, signal) {
|
|
9
|
+
if (signal.aborted)
|
|
10
|
+
return 'timeout';
|
|
11
|
+
for (const value of [error, error instanceof Error ? error.cause : undefined]) {
|
|
12
|
+
if (!value || typeof value !== 'object')
|
|
13
|
+
continue;
|
|
14
|
+
const code = value.code;
|
|
15
|
+
if (typeof code === 'string' && TRANSPORT_CODES.has(code))
|
|
16
|
+
return code;
|
|
17
|
+
const name = value.name;
|
|
18
|
+
if (name === 'TimeoutError')
|
|
19
|
+
return 'timeout';
|
|
20
|
+
if (name === 'AbortError')
|
|
21
|
+
return 'aborted';
|
|
22
|
+
}
|
|
23
|
+
return 'transport_error';
|
|
24
|
+
}
|
|
5
25
|
/** Same RPC protocol as the local socket; never replay a possibly delivered mutation. */
|
|
6
26
|
export async function coworkHttpCall(profile, method, params, timeoutMs) {
|
|
7
27
|
const id = randomUUID();
|
|
@@ -9,11 +29,15 @@ export async function coworkHttpCall(profile, method, params, timeoutMs) {
|
|
|
9
29
|
const body = JSON.stringify({ version: 1, id, method, params });
|
|
10
30
|
if (Buffer.byteLength(body) > 1024 * 1024)
|
|
11
31
|
throw new CoworkProtocolError(method, 'request exceeded 1 MiB');
|
|
32
|
+
let stage = 'server verification';
|
|
12
33
|
try {
|
|
13
34
|
const client = await attachOursClient({ endpoint: profile.endpoint, expectedInstanceId: profile.expectedInstanceId,
|
|
14
35
|
credentialPath: profile.credentialPath, sessionMode: 'external', leaseToken: `fleet-cowork-check-${id}`, env: {}, requestSignal: signal });
|
|
36
|
+
stage = 'verification cleanup';
|
|
15
37
|
await client.close();
|
|
38
|
+
stage = 'credential read';
|
|
16
39
|
const token = readPrivateFile(profile.credentialPath, 4096).toString('utf8').trim();
|
|
40
|
+
stage = 'management request';
|
|
17
41
|
const response = await fetch(profile.serverUrl + '/cowork/management/rpc', {
|
|
18
42
|
method: 'POST', redirect: 'error', signal,
|
|
19
43
|
headers: { 'content-type': 'application/json', 'x-ours-api-token': token },
|
|
@@ -26,6 +50,7 @@ export async function coworkHttpCall(profile, method, params, timeoutMs) {
|
|
|
26
50
|
const reader = response.body?.getReader();
|
|
27
51
|
if (!reader)
|
|
28
52
|
throw new CoworkProtocolError(method, 'empty HTTP response');
|
|
53
|
+
stage = 'management response';
|
|
29
54
|
const chunks = [];
|
|
30
55
|
let size = 0;
|
|
31
56
|
try {
|
|
@@ -65,6 +90,9 @@ export async function coworkHttpCall(profile, method, params, timeoutMs) {
|
|
|
65
90
|
catch (error) {
|
|
66
91
|
if (error instanceof CoworkProtocolError)
|
|
67
92
|
throw error;
|
|
68
|
-
|
|
93
|
+
// Durable cleanup records retain only .message. Keep the RPC step and a
|
|
94
|
+
// bounded allowlisted category there, never raw URLs, credentials or cause text.
|
|
95
|
+
const operation = /^[a-z][a-z0-9_.-]{0,79}$/.test(method) ? method : 'management';
|
|
96
|
+
throw new CoworkUnavailableError(`Cowork HTTP ${operation} failed during ${stage} (${safeFailureKind(error, signal)})`, { cause: error });
|
|
69
97
|
}
|
|
70
98
|
}
|
|
@@ -6,7 +6,7 @@ import { parse } from 'yaml';
|
|
|
6
6
|
import { replaceFileAtomically } from '../atomic-file.js';
|
|
7
7
|
import { agentDir, stateRoot } from '../paths.js';
|
|
8
8
|
import { readTempSupervisor, tempSupervisorLiveness, eraseTerminationEvents } from '../temp-lifecycle.js';
|
|
9
|
-
import { assertSafeAncestors, auditWorkspaceGit } from './workspace.js';
|
|
9
|
+
import { assertSafeAncestors, auditWorkspaceGit, prepareOwnedDeletionTree } from './workspace.js';
|
|
10
10
|
function entries(path) {
|
|
11
11
|
try {
|
|
12
12
|
return readdirSync(path);
|
|
@@ -98,7 +98,7 @@ export async function eraseMemberArtifacts(owner, id, seats, roomIds) {
|
|
|
98
98
|
throw new Error('Archive writer is not stopped');
|
|
99
99
|
if (creation?.role === supervisor.role && typeof creation.creationActionId === 'string')
|
|
100
100
|
actions.get(supervisor.role).add(creation.creationActionId);
|
|
101
|
-
auditWorkspaceGit(path);
|
|
101
|
+
auditWorkspaceGit(path, { forDeletion: true });
|
|
102
102
|
launches.push({ role: supervisor.role, launchId: supervisor.launchId });
|
|
103
103
|
paths.push(path);
|
|
104
104
|
}
|
|
@@ -127,7 +127,7 @@ export async function eraseMemberArtifacts(owner, id, seats, roomIds) {
|
|
|
127
127
|
const state = json(join(path, 'state.json')), instance = json(join(path, 'instance.json'));
|
|
128
128
|
if (state?.lifetime === 'temporary' && actions.get(state.name)?.has(state.action)
|
|
129
129
|
&& instance?.role === state.name && instance?.temporary === true && instance?.instance === state.instance) {
|
|
130
|
-
auditWorkspaceGit(path);
|
|
130
|
+
auditWorkspaceGit(path, { forDeletion: true });
|
|
131
131
|
paths.push(path);
|
|
132
132
|
}
|
|
133
133
|
}
|
|
@@ -158,6 +158,8 @@ export async function eraseMemberArtifacts(owner, id, seats, roomIds) {
|
|
|
158
158
|
}
|
|
159
159
|
manifest.phases[path] = 'renamed';
|
|
160
160
|
replaceFileAtomically(manifestPath, JSON.stringify(manifest));
|
|
161
|
+
if (existsSync(tombstone) && lstatSync(tombstone).isDirectory())
|
|
162
|
+
prepareOwnedDeletionTree(tombstone);
|
|
161
163
|
rmSync(tombstone, { recursive: true, force: true });
|
|
162
164
|
manifest.phases[path] = 'removed';
|
|
163
165
|
replaceFileAtomically(manifestPath, JSON.stringify(manifest));
|
|
@@ -66,7 +66,7 @@ export function collectWorkspaceArchives(workspace) {
|
|
|
66
66
|
});
|
|
67
67
|
if (!terminated || entry.name.endsWith('.retiring'))
|
|
68
68
|
throw new WorkspaceError('workspace archive retirement is incomplete');
|
|
69
|
-
auditWorkspaceGit(path);
|
|
69
|
+
auditWorkspaceGit(path, { forDeletion: true });
|
|
70
70
|
owned.push(path);
|
|
71
71
|
}
|
|
72
72
|
// Preflight the entire set before mutation; task/room record remains until all succeed.
|
|
@@ -11,13 +11,20 @@ export declare class WorkspaceError extends Error {
|
|
|
11
11
|
export declare function assertSafeAncestors(path: string): void;
|
|
12
12
|
export declare function planWorkspace(owner: OwnedWorkspace['owner'], id: string): OwnedWorkspace;
|
|
13
13
|
export declare function validateWorkspace(w: OwnedWorkspace, owner?: "task" | "room", id?: string): void;
|
|
14
|
+
/** Caller must first prove the exact workspace marker or retired archive
|
|
15
|
+
* provenance, and hold its lifecycle lock with all managed writers retired.
|
|
16
|
+
* Directories need owner rwx for recursive removal; ordinary files do not.
|
|
17
|
+
*/
|
|
18
|
+
export declare function prepareOwnedDeletionTree(path: string): void;
|
|
14
19
|
/** Atomic directory publication; retry uses the durable token, never adopts another folder. */
|
|
15
20
|
export declare function ensureWorkspace(w: OwnedWorkspace): string;
|
|
16
21
|
/** Audit both directions of Git worktree registration, without executing repository config/hooks.
|
|
17
22
|
* All registrations and common dirs must be inside this workspace. Removing the complete
|
|
18
23
|
* workspace then removes their registries atomically with the owned repositories; never prune.
|
|
19
24
|
*/
|
|
20
|
-
export declare function auditWorkspaceGit(path: string
|
|
25
|
+
export declare function auditWorkspaceGit(path: string, options?: {
|
|
26
|
+
forDeletion?: boolean;
|
|
27
|
+
}): void;
|
|
21
28
|
/** Called only after all managed writers retire, under the owner's lifecycle lock.
|
|
22
29
|
* The tombstone is derived from the durable unguessable token. A crash during rm
|
|
23
30
|
* resumes that exact tombstone even if rm already removed its marker.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import { lstatSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { chmodSync, closeSync, constants, fchmodSync, fstatSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
4
4
|
import { stateRoot } from '../paths.js';
|
|
5
5
|
const MARKER = '.fleet-workspace.json';
|
|
@@ -57,6 +57,104 @@ function marked(path, w) {
|
|
|
57
57
|
function staging(w, phase) {
|
|
58
58
|
return join(dirname(w.path), `.${w.id}.${w.token}.${phase}`);
|
|
59
59
|
}
|
|
60
|
+
/** Recover only owner access on a pinned, real entry. Never chmod a symlink
|
|
61
|
+
* target or a hardlinked file through an alias outside the deletion tree.
|
|
62
|
+
* Linux O_PATH can pin an entry even when a same-owner fixture removed every
|
|
63
|
+
* permission; /proc/self/fd then addresses that inode rather than its old name.
|
|
64
|
+
*/
|
|
65
|
+
function withOwnerPermissions(path, before, bits, work, rollback = false) {
|
|
66
|
+
if (before.isSymbolicLink() || (!before.isDirectory() && !before.isFile()))
|
|
67
|
+
fail(`unsafe permission repair ${path}`);
|
|
68
|
+
const changed = (before.mode & bits) !== bits;
|
|
69
|
+
const uid = process.geteuid?.();
|
|
70
|
+
if (changed && (uid === undefined || before.uid !== uid))
|
|
71
|
+
fail(`cannot restore owner access to ${path}: owned by uid ${before.uid}`);
|
|
72
|
+
if (changed && before.isFile() && before.nlink !== 1)
|
|
73
|
+
fail(`cannot restore owner access to hardlinked Git metadata ${path}`);
|
|
74
|
+
let descriptor, pathDescriptor = false;
|
|
75
|
+
try {
|
|
76
|
+
descriptor = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
if (process.platform !== 'linux' || error.code !== 'EACCES')
|
|
80
|
+
throw error;
|
|
81
|
+
// O_PATH is Linux-specific and is not exposed in Node's fs.constants.
|
|
82
|
+
descriptor = openSync(path, 0x200000 | constants.O_NOFOLLOW);
|
|
83
|
+
pathDescriptor = true;
|
|
84
|
+
}
|
|
85
|
+
const setMode = (mode) => pathDescriptor
|
|
86
|
+
? chmodSync(`/proc/self/fd/${descriptor}`, mode)
|
|
87
|
+
: fchmodSync(descriptor, mode);
|
|
88
|
+
try {
|
|
89
|
+
const pinned = fstatSync(descriptor);
|
|
90
|
+
if (pinned.dev !== before.dev || pinned.ino !== before.ino || pinned.uid !== before.uid
|
|
91
|
+
|| pinned.mode !== before.mode || pinned.nlink !== before.nlink)
|
|
92
|
+
fail(`entry changed before permission repair ${path}`);
|
|
93
|
+
if (changed)
|
|
94
|
+
setMode((before.mode & 0o7777) | bits);
|
|
95
|
+
try {
|
|
96
|
+
const assertNamedEntry = () => {
|
|
97
|
+
const named = lstatSync(path);
|
|
98
|
+
if (named.dev !== before.dev || named.ino !== before.ino || named.uid !== before.uid
|
|
99
|
+
|| named.isSymbolicLink() || named.isDirectory() !== before.isDirectory())
|
|
100
|
+
fail(`entry changed during permission repair ${path}`);
|
|
101
|
+
};
|
|
102
|
+
assertNamedEntry();
|
|
103
|
+
const result = work(process.platform === 'linux' ? `/proc/self/fd/${descriptor}` : path);
|
|
104
|
+
assertNamedEntry();
|
|
105
|
+
return result;
|
|
106
|
+
}
|
|
107
|
+
catch (error) {
|
|
108
|
+
if (rollback && changed)
|
|
109
|
+
setMode(before.mode & 0o7777);
|
|
110
|
+
throw error;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
finally {
|
|
114
|
+
closeSync(descriptor);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
/** Caller must first prove the exact workspace marker or retired archive
|
|
118
|
+
* provenance, and hold its lifecycle lock with all managed writers retired.
|
|
119
|
+
* Directories need owner rwx for recursive removal; ordinary files do not.
|
|
120
|
+
*/
|
|
121
|
+
export function prepareOwnedDeletionTree(path) {
|
|
122
|
+
assertSafeAncestors(path);
|
|
123
|
+
const uid = process.geteuid?.();
|
|
124
|
+
if (uid === undefined || lstatSync(path).uid !== uid)
|
|
125
|
+
fail(`deletion root is not owned by the current uid: ${path}`);
|
|
126
|
+
const device = lstatSync(dirname(path)).dev;
|
|
127
|
+
const visit = (dir) => {
|
|
128
|
+
const entry = lstatSync(dir);
|
|
129
|
+
if (!entry.isDirectory() || entry.isSymbolicLink())
|
|
130
|
+
fail(`unsafe deletion directory ${dir}`);
|
|
131
|
+
if (entry.dev !== device)
|
|
132
|
+
fail(`foreign mounted directory ${dir}`);
|
|
133
|
+
withOwnerPermissions(dir, entry, 0o700, accessDir => {
|
|
134
|
+
for (const child of readdirSync(accessDir, { withFileTypes: true })) {
|
|
135
|
+
if (process.platform !== 'linux') {
|
|
136
|
+
const named = lstatSync(dir);
|
|
137
|
+
if (named.dev !== entry.dev || named.ino !== entry.ino || named.isSymbolicLink())
|
|
138
|
+
fail(`deletion directory changed during traversal ${dir}`);
|
|
139
|
+
}
|
|
140
|
+
if (child.isDirectory() && !child.isSymbolicLink())
|
|
141
|
+
visit(join(accessDir, child.name));
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
};
|
|
145
|
+
visit(path);
|
|
146
|
+
}
|
|
147
|
+
function markedForDeletion(w) {
|
|
148
|
+
assertSafeAncestors(w.path);
|
|
149
|
+
const before = lstatSync(w.path);
|
|
150
|
+
if (process.geteuid?.() !== before.uid)
|
|
151
|
+
fail(`workspace root is not owned by the current uid: ${w.path}`);
|
|
152
|
+
if (before.dev !== lstatSync(dirname(w.path)).dev)
|
|
153
|
+
fail(`foreign mounted directory ${w.path}`);
|
|
154
|
+
// The durable exact path plus current-UID real root permit temporary owner
|
|
155
|
+
// access to read the marker, but never substitute for the marker itself.
|
|
156
|
+
withOwnerPermissions(w.path, before, 0o700, () => marked(w.path, w), true);
|
|
157
|
+
}
|
|
60
158
|
/** Atomic directory publication; retry uses the durable token, never adopts another folder. */
|
|
61
159
|
export function ensureWorkspace(w) {
|
|
62
160
|
validateWorkspace(w);
|
|
@@ -105,45 +203,70 @@ function ownedPointer(base, from, value) {
|
|
|
105
203
|
* All registrations and common dirs must be inside this workspace. Removing the complete
|
|
106
204
|
* workspace then removes their registries atomically with the owned repositories; never prune.
|
|
107
205
|
*/
|
|
108
|
-
export function auditWorkspaceGit(path) {
|
|
206
|
+
export function auditWorkspaceGit(path, options = {}) {
|
|
207
|
+
const rootEntry = lstatSync(path);
|
|
208
|
+
if (options.forDeletion)
|
|
209
|
+
prepareOwnedDeletionTree(path);
|
|
109
210
|
const device = lstatSync(path).dev;
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
211
|
+
if (options.forDeletion && (lstatSync(path).ino !== rootEntry.ino || device !== rootEntry.dev || lstatSync(path).isSymbolicLink()))
|
|
212
|
+
fail(`workspace changed before Git audit ${path}`);
|
|
213
|
+
const readPointer = (file) => {
|
|
214
|
+
if (!options.forDeletion)
|
|
215
|
+
return readFileSync(file, 'utf8');
|
|
216
|
+
const before = lstatSync(file);
|
|
217
|
+
if (before.dev !== device)
|
|
218
|
+
fail(`foreign mounted Git metadata ${file}`);
|
|
219
|
+
return withOwnerPermissions(file, before, 0o400, accessFile => readFileSync(accessFile, 'utf8'));
|
|
220
|
+
};
|
|
221
|
+
const visit = (dir, accessDir = dir, admin = false) => {
|
|
222
|
+
const before = lstatSync(accessDir);
|
|
223
|
+
const inspect = (pinnedDir) => {
|
|
224
|
+
admin ||= Boolean(stat(join(pinnedDir, 'HEAD')) && (stat(join(pinnedDir, 'objects')) || stat(join(pinnedDir, 'commondir'))));
|
|
225
|
+
for (const entry of readdirSync(pinnedDir, { withFileTypes: true })) {
|
|
226
|
+
if (options.forDeletion && process.platform !== 'linux') {
|
|
227
|
+
const named = lstatSync(accessDir);
|
|
228
|
+
if (named.dev !== before.dev || named.ino !== before.ino || named.isSymbolicLink())
|
|
229
|
+
fail(`directory changed during Git audit ${dir}`);
|
|
230
|
+
}
|
|
231
|
+
const p = join(pinnedDir, entry.name);
|
|
232
|
+
if (entry.isSymbolicLink()) {
|
|
233
|
+
if (admin || entry.name === '.git' || entry.name === 'commondir' || entry.name === 'gitdir')
|
|
234
|
+
fail(`symlink git metadata ${p}`);
|
|
235
|
+
continue; // rm unlinks ordinary artifact symlinks, never follows them.
|
|
236
|
+
}
|
|
237
|
+
if (entry.isDirectory()) {
|
|
238
|
+
if (lstatSync(p).dev !== device)
|
|
239
|
+
fail(`foreign mounted directory ${p}`);
|
|
240
|
+
visit(join(dir, entry.name), p, admin || entry.name === '.git');
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
if (!entry.isFile())
|
|
244
|
+
continue; // sockets/FIFOs in retired runtime state are unlinked, never opened
|
|
245
|
+
if (entry.name === '.git') {
|
|
246
|
+
const value = readPointer(p).trim();
|
|
247
|
+
if (!value.startsWith('gitdir: '))
|
|
248
|
+
fail(`invalid git file ${p}`);
|
|
249
|
+
const gitdir = ownedPointer(path, dir, value.slice(8));
|
|
250
|
+
if (!stat(gitdir)?.isDirectory())
|
|
251
|
+
fail(`missing git directory ${gitdir}`);
|
|
252
|
+
}
|
|
253
|
+
// Git admin files, including bare repositories and submodules.
|
|
254
|
+
if (entry.name === 'commondir' && (admin || stat(join(pinnedDir, 'HEAD')))) {
|
|
255
|
+
const common = ownedPointer(path, dir, readPointer(p));
|
|
256
|
+
if (!stat(common)?.isDirectory())
|
|
257
|
+
fail(`invalid git common directory ${common}`);
|
|
258
|
+
}
|
|
259
|
+
if (entry.name === 'gitdir' && (admin || stat(join(pinnedDir, 'commondir')))) {
|
|
260
|
+
const target = ownedPointer(path, dir, readPointer(p));
|
|
261
|
+
if (stat(target) && !stat(target)?.isFile())
|
|
262
|
+
fail(`invalid worktree backlink ${target}`);
|
|
263
|
+
}
|
|
145
264
|
}
|
|
146
|
-
}
|
|
265
|
+
};
|
|
266
|
+
if (options.forDeletion)
|
|
267
|
+
withOwnerPermissions(accessDir, before, 0o700, inspect);
|
|
268
|
+
else
|
|
269
|
+
inspect(accessDir);
|
|
147
270
|
};
|
|
148
271
|
visit(path);
|
|
149
272
|
}
|
|
@@ -156,8 +279,8 @@ export function assertWorkspaceDeletable(w, owner, id) {
|
|
|
156
279
|
if (stat(w.path)) {
|
|
157
280
|
if (stat(staging(w, 'deleting')))
|
|
158
281
|
fail('both live and deleting workspace exist');
|
|
159
|
-
|
|
160
|
-
auditWorkspaceGit(w.path);
|
|
282
|
+
markedForDeletion(w);
|
|
283
|
+
auditWorkspaceGit(w.path, { forDeletion: true });
|
|
161
284
|
}
|
|
162
285
|
const pending = staging(w, 'provisioning');
|
|
163
286
|
if (stat(pending)) {
|
|
@@ -177,8 +300,8 @@ export function deleteWorkspace(w, owner, id) {
|
|
|
177
300
|
if (stat(w.path)) {
|
|
178
301
|
if (stat(tomb))
|
|
179
302
|
fail('both live and deleting workspace exist');
|
|
180
|
-
|
|
181
|
-
auditWorkspaceGit(w.path);
|
|
303
|
+
markedForDeletion(w);
|
|
304
|
+
auditWorkspaceGit(w.path, { forDeletion: true });
|
|
182
305
|
renameSync(w.path, tomb);
|
|
183
306
|
}
|
|
184
307
|
if (stat(pending)) {
|
|
@@ -189,7 +312,9 @@ export function deleteWorkspace(w, owner, id) {
|
|
|
189
312
|
rmSync(pending, { recursive: true });
|
|
190
313
|
}
|
|
191
314
|
if (stat(tomb)) {
|
|
192
|
-
|
|
315
|
+
// This exact token-derived tombstone has already passed the marker and
|
|
316
|
+
// Git audit; partial removal may have consumed those proofs on disk.
|
|
317
|
+
prepareOwnedDeletionTree(tomb);
|
|
193
318
|
rmSync(tomb, { recursive: true });
|
|
194
319
|
}
|
|
195
320
|
}
|
package/dist/runner.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { StartupTelemetry } from './startup-telemetry.js';
|
|
2
|
+
import { managedStartupPrompt, managedTaskPrompt } from './startup-prompt.js';
|
|
1
3
|
import { createLayoutControl } from './rooms-tasks/layout-control.js';
|
|
2
4
|
import { TemporaryChatIdle } from './temp-idle.js';
|
|
3
5
|
import { prepareManagedAgent, releaseManagedAgent } from './agent-ours/service.js';
|
|
@@ -469,6 +471,8 @@ export function isRecoverableTempStartupCancellation(temp, result) {
|
|
|
469
471
|
/** One session lifecycle. `runSupervised` (or a one-shot caller) drives it. */
|
|
470
472
|
export async function runOnce(name, opts = {}, partialDeps = {}) {
|
|
471
473
|
const deps = { ...defaultDeps(), ...partialDeps };
|
|
474
|
+
const startupTelemetry = new StartupTelemetry(deps.now, line => deps.log(`[${name}] ${line}`));
|
|
475
|
+
startupTelemetry.mark('supervisor_started');
|
|
472
476
|
const temp = opts.temp === true;
|
|
473
477
|
const dir = agentDir(name, temp);
|
|
474
478
|
const configPath = temp ? opts.configPath : resolveConfigPath(dir, opts.configPath);
|
|
@@ -572,7 +576,9 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
|
|
|
572
576
|
if (rprefix.length)
|
|
573
577
|
wrappedArgv = [...rprefix, ...wrappedArgv];
|
|
574
578
|
}
|
|
579
|
+
startupTelemetry.mark('identity_prepare_started');
|
|
575
580
|
const managedService = await deps.prepareAgentOurs(role, dir, opts.identityLifetime ? opts.identityLifetime === 'temporary' : temp);
|
|
581
|
+
startupTelemetry.mark('identity_ready');
|
|
576
582
|
let layoutRetiring = false;
|
|
577
583
|
try {
|
|
578
584
|
const managedHarness = prepareManagedHarness(role, dir, runCwd, managedService.descriptor, { ...harnessChildEnv(role, launch.env, dir), ...subscription?.env });
|
|
@@ -591,10 +597,9 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
|
|
|
591
597
|
}
|
|
592
598
|
}
|
|
593
599
|
// Prime the supervisor mail monitor's notification cursor at the
|
|
594
|
-
// stream tip BEFORE the session launches so no arrival is missed during boot
|
|
595
|
-
//
|
|
596
|
-
//
|
|
597
|
-
// treated as native (monitor may be undefined on an old role.yaml).
|
|
600
|
+
// stream tip BEFORE the session launches so no arrival is missed during boot.
|
|
601
|
+
// Monitor.run checks the body-free initial unread summary after readiness;
|
|
602
|
+
// managed native hooks do not own backlog delivery.
|
|
598
603
|
let sessionHandle;
|
|
599
604
|
let modelRecovery;
|
|
600
605
|
const resolvedMonitorDeps = monitorDeps(deps, role.env);
|
|
@@ -662,6 +667,7 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
|
|
|
662
667
|
if (perms.unattended === 'deny')
|
|
663
668
|
deps.log(`[${name}] permission policy: unattended=deny — with no console attached, ` +
|
|
664
669
|
`permission requests are automatically denied once each (reject_once) and the turn continues`);
|
|
670
|
+
startupTelemetry.mark('harness_starting');
|
|
665
671
|
agentSession = await managedService.runtime.startHarness(() => deps.startAgentSession(adapter.agentSession, {
|
|
666
672
|
managedOurs: managedHarness.ours,
|
|
667
673
|
role, prep,
|
|
@@ -669,6 +675,8 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
|
|
|
669
675
|
cwd: runCwd, stateDir: dir, mode, permissions: perms,
|
|
670
676
|
permissionMode: effectivePermissionMode(role), log: deps.log,
|
|
671
677
|
}));
|
|
678
|
+
managedService.setFileDelivery?.(agentSession.sendFileToChat?.bind(agentSession));
|
|
679
|
+
startupTelemetry.mark('harness_ready');
|
|
672
680
|
pid = agentSession.pid;
|
|
673
681
|
arbiter = new RoleTurnArbiter(agentSession);
|
|
674
682
|
sessionHandle = arbiter;
|
|
@@ -679,6 +687,7 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
|
|
|
679
687
|
if (evidence)
|
|
680
688
|
resolvedMonitorDeps.onFailureEvidence?.(evidence);
|
|
681
689
|
});
|
|
690
|
+
const unsubscribeTelemetry = agentSession.subscribeConversation?.(event => startupTelemetry.observe(event));
|
|
682
691
|
const unsubscribeStartup = agentSession.subscribeConversation?.(event => {
|
|
683
692
|
if (event.kind !== 'turn.completed' || !('outcome' in event.payload) || event.payload.outcome !== 'completed')
|
|
684
693
|
return;
|
|
@@ -688,7 +697,7 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
|
|
|
688
697
|
startMonitorAfterStartup();
|
|
689
698
|
}
|
|
690
699
|
});
|
|
691
|
-
unsubscribeRecovery = () => { unsubscribeFailure(); unsubscribeStartup?.(); };
|
|
700
|
+
unsubscribeRecovery = () => { unsubscribeFailure(); unsubscribeStartup?.(); unsubscribeTelemetry?.(); };
|
|
692
701
|
if (role.owner_channel) {
|
|
693
702
|
try {
|
|
694
703
|
ownerBinder = await deps.acquireOwnerBinder(dir, name, role.owner_channel.identity);
|
|
@@ -805,12 +814,11 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
|
|
|
805
814
|
};
|
|
806
815
|
},
|
|
807
816
|
};
|
|
808
|
-
const firstPrompt = mode
|
|
809
|
-
? `Read and follow ${join(dir, 'briefing.md')} now.`
|
|
810
|
-
: `Your supervisor has verified your assigned identity and room readiness. Read ${join(dir, 'WORKLOG.md')} and ${join(dir, 'briefing.md')}, then continue using the available ours tools.`;
|
|
817
|
+
const firstPrompt = managedStartupPrompt(dir, mode, booted);
|
|
811
818
|
// Wait for the first turn's TERMINAL result. An agent that accepts the
|
|
812
819
|
// startup prompt and then refuses it has not started; logging the role as
|
|
813
|
-
// up would hide a role that never
|
|
820
|
+
// up would hide a role that never completed its readiness turn.
|
|
821
|
+
startupTelemetry.mark('startup_submitted');
|
|
814
822
|
const starting = arbiter.submitPrompt(firstPrompt, { origin: { kind: 'startup' } });
|
|
815
823
|
// Keep the early cursor baseline; do not deliver wakes into the protected
|
|
816
824
|
// first turn. Steering can pre-empt startup just like cancellation.
|
|
@@ -835,6 +843,7 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
|
|
|
835
843
|
}
|
|
836
844
|
}
|
|
837
845
|
const started = await starting;
|
|
846
|
+
startupTelemetry.finish();
|
|
838
847
|
// A temporary role's first turn can be the active turn when an ours wake
|
|
839
848
|
// needs immediate attention. A typed console/monitor cancellation ends
|
|
840
849
|
// only that turn: the already-live agent session and any queued wake remain
|
|
@@ -875,6 +884,20 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
|
|
|
875
884
|
+ 'keeping temporary supervisor alive');
|
|
876
885
|
startupRecoveryAllowed = interruptedForWake;
|
|
877
886
|
sessionStartupComplete = started.succeeded || (interruptedForWake && successfulTurnObserved);
|
|
887
|
+
if (started.succeeded)
|
|
888
|
+
startupTelemetry.mark('readiness_turn_completed');
|
|
889
|
+
// Readiness is now complete. Configured work (or retained continuity) is
|
|
890
|
+
// admitted as a distinct turn; its terminal result must not block console
|
|
891
|
+
// readiness, mail delivery, or supervisor liveness. Empty fresh agents wait
|
|
892
|
+
// for authenticated console input or the monitor's unread/arrival wake.
|
|
893
|
+
if (started.succeeded && !deps.shouldStop?.() && (role.mission?.trim() || role.roomMemberStartup?.task?.trim() || role.briefing_file?.trim() || booted)) {
|
|
894
|
+
const queued = await arbiter.queuePrompt(managedTaskPrompt(dir, mode, booted), {
|
|
895
|
+
origin: { kind: 'startup' },
|
|
896
|
+
});
|
|
897
|
+
void queued.completion.then(result => {
|
|
898
|
+
deps.log(`[${name}] configured task turn ${result.outcome}`);
|
|
899
|
+
}, () => deps.log(`[${name}] configured task turn failed`));
|
|
900
|
+
}
|
|
878
901
|
reloadLoopConfig = async () => {
|
|
879
902
|
const nextRole = findRole(loadConfig(configPath), name);
|
|
880
903
|
const definitions = nextRole.loops ?? [];
|