@ours.network/fleet 0.9.4 → 0.9.7
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 +148 -30
- package/dist/atomic-file.d.ts +30 -0
- package/dist/atomic-file.js +86 -0
- package/dist/briefing.d.ts +6 -0
- package/dist/briefing.js +41 -11
- package/dist/cli.js +238 -26
- package/dist/config.d.ts +39 -1
- package/dist/config.js +126 -3
- package/dist/creation.d.ts +179 -0
- package/dist/creation.js +254 -0
- package/dist/docs.d.ts +34 -0
- package/dist/docs.js +309 -0
- package/dist/doctor.js +123 -21
- package/dist/harness/acp-agent.d.ts +11 -0
- package/dist/harness/acp-agent.js +27 -0
- package/dist/harness/claude-code.d.ts +39 -3
- package/dist/harness/claude-code.js +145 -13
- package/dist/harness/codex.d.ts +7 -1
- package/dist/harness/codex.js +89 -4
- package/dist/harness/registry.d.ts +2 -0
- package/dist/harness/registry.js +19 -0
- package/dist/harness/types.d.ts +59 -1
- package/dist/index.d.ts +6 -3
- package/dist/index.js +3 -1
- package/dist/isolation/bubblewrap.js +7 -1
- package/dist/isolation/policy.d.ts +34 -5
- package/dist/isolation/policy.js +114 -7
- package/dist/isolation/resources.d.ts +6 -3
- package/dist/isolation/resources.js +6 -3
- package/dist/isolation/types.d.ts +19 -1
- package/dist/monitor.d.ts +44 -2
- package/dist/monitor.js +177 -42
- package/dist/ops.d.ts +15 -2
- package/dist/ops.js +32 -9
- package/dist/permissions.d.ts +70 -0
- package/dist/permissions.js +97 -0
- package/dist/runner.d.ts +65 -2
- package/dist/runner.js +307 -32
- package/dist/session/acp.d.ts +70 -0
- package/dist/session/acp.js +364 -0
- package/dist/session/control.d.ts +89 -0
- package/dist/session/control.js +322 -0
- package/dist/session/events.d.ts +14 -0
- package/dist/session/events.js +67 -0
- package/dist/session/tmux.d.ts +27 -0
- package/dist/session/tmux.js +76 -0
- package/dist/session/types.d.ts +138 -0
- package/dist/session/types.js +42 -0
- package/dist/spawn.d.ts +32 -2
- package/dist/spawn.js +177 -16
- package/dist/supervisor/launchd.d.ts +50 -0
- package/dist/supervisor/launchd.js +121 -4
- package/dist/supervisor/none.js +22 -4
- package/dist/supervisor/systemd.d.ts +8 -1
- package/dist/supervisor/systemd.js +94 -4
- package/dist/supervisor/types.d.ts +36 -3
- package/dist/tmux.d.ts +34 -2
- package/dist/tmux.js +48 -11
- package/package.json +7 -2
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { type LockDeps } from './atomic-file.js';
|
|
2
|
+
import { type FetchLike } from './monitor.js';
|
|
3
|
+
export type ReservationKind = 'role' | 'identity';
|
|
4
|
+
export interface Reservation {
|
|
5
|
+
kind: ReservationKind;
|
|
6
|
+
name: string;
|
|
7
|
+
}
|
|
8
|
+
export declare class CreationConflictError extends Error {
|
|
9
|
+
constructor(message: string);
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Artifacts a transaction created, newest last. Rollback walks this in reverse,
|
|
13
|
+
* and may only delete what THIS transaction made — an object that already
|
|
14
|
+
* existed is never touched.
|
|
15
|
+
*/
|
|
16
|
+
export interface JournalEntry {
|
|
17
|
+
stage: string;
|
|
18
|
+
undo(): void | Promise<void>;
|
|
19
|
+
}
|
|
20
|
+
export interface CreationDeps {
|
|
21
|
+
lock?: LockDeps;
|
|
22
|
+
log?(line: string): void;
|
|
23
|
+
/** Reserve the ours identity name. Injectable so tests need no daemon. */
|
|
24
|
+
identityRegistry?: IdentityRegistry;
|
|
25
|
+
/** Verify/create the ours identity. Injectable so tests need no daemon. */
|
|
26
|
+
identityProvisioner?: IdentityProvisioner;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* The contract the ours daemon must satisfy for identity names to be reserved
|
|
30
|
+
* atomically across ALL of its clients, not just across fleet processes.
|
|
31
|
+
*
|
|
32
|
+
* `check-then-create` is not atomic across processes, which is the whole point:
|
|
33
|
+
* two spawns can both observe a free identity name and both create it. The
|
|
34
|
+
* daemon is the only component that sees every client, so only the daemon can
|
|
35
|
+
* make the reservation authoritative.
|
|
36
|
+
*/
|
|
37
|
+
export interface IdentityRegistry {
|
|
38
|
+
/** Claim `name`. Returns false if it is already taken or reserved. */
|
|
39
|
+
reserve(name: string): Promise<boolean>;
|
|
40
|
+
/** Give the claim back (rollback). Must be safe to call on an unheld name. */
|
|
41
|
+
release(name: string): Promise<void>;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Host-local identity reservation: atomic across every ours-fleet process on
|
|
45
|
+
* this host, because it is taken under the same host-wide creation lock as the
|
|
46
|
+
* role name.
|
|
47
|
+
*
|
|
48
|
+
* It is NOT atomic against other clients of the same ours daemon — another tool
|
|
49
|
+
* creating the identity between our reservation and our creation would still
|
|
50
|
+
* win. Closing that needs a reserve/commit/release operation in the daemon
|
|
51
|
+
* itself; see the release notes.
|
|
52
|
+
*/
|
|
53
|
+
export declare const hostLocalIdentityRegistry: IdentityRegistry;
|
|
54
|
+
export interface CreationTransaction {
|
|
55
|
+
/** Record an artifact this transaction created, with how to undo it. */
|
|
56
|
+
record(entry: JournalEntry): void;
|
|
57
|
+
/** Stages recorded so far, in order. */
|
|
58
|
+
readonly stages: string[];
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Run `body` inside a creation transaction.
|
|
62
|
+
*
|
|
63
|
+
* Under one host-wide lock: both names are reserved, then `body` builds the
|
|
64
|
+
* role. If anything throws, every recorded stage is undone in reverse order and
|
|
65
|
+
* both reservations are released, so the names can be reused immediately. On
|
|
66
|
+
* success the reservations are released too — the role's own config and state
|
|
67
|
+
* are the durable record from then on.
|
|
68
|
+
*
|
|
69
|
+
* Rollback errors are collected and reported, never allowed to hide the failure
|
|
70
|
+
* that caused the rollback.
|
|
71
|
+
*/
|
|
72
|
+
export declare function withCreationTransaction<T>(names: {
|
|
73
|
+
role: string;
|
|
74
|
+
identity: string;
|
|
75
|
+
}, body: (tx: CreationTransaction) => Promise<T>, deps?: CreationDeps): Promise<T>;
|
|
76
|
+
/** Forget reservations left behind by a process that died mid-transaction. */
|
|
77
|
+
export declare function clearStaleReservations(olderThanMs?: number, now?: number): number;
|
|
78
|
+
/**
|
|
79
|
+
* Identity provisioning (7.3). The fleet must know — before the harness starts
|
|
80
|
+
* — whether the role's identity exists, and create it when it does not.
|
|
81
|
+
*
|
|
82
|
+
* `exists()` is answerable today: the daemon's authenticated `/identities`
|
|
83
|
+
* endpoint is already used by doctor. `create()` is NOT: `ours-mcp` exposes only
|
|
84
|
+
* `create-root`, and role identities are minted through the MCP `create_identity`
|
|
85
|
+
* tool inside an agent session. So creation is a seam, injected by whoever can
|
|
86
|
+
* satisfy it, and its absence is reported rather than papered over.
|
|
87
|
+
*/
|
|
88
|
+
export interface IdentityProvisioner {
|
|
89
|
+
/** Does this identity exist? `unknown` when the daemon could not be asked. */
|
|
90
|
+
exists(name: string): Promise<boolean | 'unknown'>;
|
|
91
|
+
/** Create it, publishing bio/persona through the same path. Absent = cannot. */
|
|
92
|
+
create?(name: string, profile: {
|
|
93
|
+
bio?: string;
|
|
94
|
+
persona?: string;
|
|
95
|
+
}): Promise<void>;
|
|
96
|
+
/**
|
|
97
|
+
* Undo a `create` during rollback. Only ever called for an identity THIS
|
|
98
|
+
* transaction created; absent means "cannot", and the orphan is reported.
|
|
99
|
+
*/
|
|
100
|
+
remove?(name: string): Promise<void>;
|
|
101
|
+
}
|
|
102
|
+
export type IdentityGuarantee = {
|
|
103
|
+
state: 'verified';
|
|
104
|
+
detail: string;
|
|
105
|
+
} | {
|
|
106
|
+
state: 'created';
|
|
107
|
+
detail: string;
|
|
108
|
+
} | {
|
|
109
|
+
state: 'unverified';
|
|
110
|
+
detail: string;
|
|
111
|
+
};
|
|
112
|
+
/**
|
|
113
|
+
* Establish the identity before the role's service is enabled.
|
|
114
|
+
*
|
|
115
|
+
* Returns what was actually GUARANTEED, so the generated briefing can say
|
|
116
|
+
* something true instead of asserting a "predefined" identity nobody checked —
|
|
117
|
+
* the failure a real agent hit on its first boot, having been told to bind an
|
|
118
|
+
* identity that did not exist.
|
|
119
|
+
*/
|
|
120
|
+
export declare function ensureIdentity(name: string, profile: {
|
|
121
|
+
bio?: string;
|
|
122
|
+
persona?: string;
|
|
123
|
+
}, provisioner: IdentityProvisioner | undefined, log?: (line: string) => void): Promise<IdentityGuarantee>;
|
|
124
|
+
/**
|
|
125
|
+
* Ask the running ours daemon whether an identity exists, over the same
|
|
126
|
+
* authenticated endpoint doctor already probes. Answers `unknown` rather than
|
|
127
|
+
* guessing when the daemon cannot be reached — an unreachable daemon is not
|
|
128
|
+
* evidence that the identity is missing.
|
|
129
|
+
*
|
|
130
|
+
* It deliberately has no `create()`: role identities are minted through the MCP
|
|
131
|
+
* `create_identity` tool, and inventing a daemon endpoint we cannot test is the
|
|
132
|
+
* failure mode this release exists to stop.
|
|
133
|
+
*/
|
|
134
|
+
export declare function daemonIdentityProvisioner(env?: NodeJS.ProcessEnv, fetchImpl?: FetchLike): IdentityProvisioner;
|
|
135
|
+
/** Atomically write a role's fleet.d file, journalling it for rollback. */
|
|
136
|
+
export declare function writeRoleFile(tx: CreationTransaction, file: string, contents: string): void;
|
|
137
|
+
/** Where a setting's effective value came from. */
|
|
138
|
+
export type ProvenanceSource = 'cli' | 'fleet-default' | 'built-in';
|
|
139
|
+
export interface ProvenanceEntry {
|
|
140
|
+
value: unknown;
|
|
141
|
+
source: ProvenanceSource;
|
|
142
|
+
}
|
|
143
|
+
export interface CreationProvenance {
|
|
144
|
+
version: 1;
|
|
145
|
+
/** The command that created the role, without its arguments. */
|
|
146
|
+
command: string;
|
|
147
|
+
fleetVersion: string;
|
|
148
|
+
createdAt: string;
|
|
149
|
+
lifetime: 'permanent' | 'temporary';
|
|
150
|
+
role: string;
|
|
151
|
+
/** Effective settings, each tagged with where its value came from. */
|
|
152
|
+
settings: Record<string, ProvenanceEntry>;
|
|
153
|
+
}
|
|
154
|
+
export declare const CREATION_PROVENANCE_FILE = "creation.json";
|
|
155
|
+
/**
|
|
156
|
+
* Record HOW a role was created, so nobody has to remember.
|
|
157
|
+
*
|
|
158
|
+
* Six months on, "why does this role have `approval: allow`?" is unanswerable:
|
|
159
|
+
* the resolved config shows the value but not whether an operator typed it, a
|
|
160
|
+
* fleet default supplied it, or it fell through to a built-in. Those have very
|
|
161
|
+
* different implications for whether it is safe to change.
|
|
162
|
+
*
|
|
163
|
+
* Deliberately excluded: `env`, `bio`, `persona`, and `harness_options`. The
|
|
164
|
+
* first two can carry credentials, and this file exists to be read — it must
|
|
165
|
+
* never become a place secrets accumulate.
|
|
166
|
+
*/
|
|
167
|
+
export declare function buildProvenance(o: {
|
|
168
|
+
role: string;
|
|
169
|
+
lifetime: 'permanent' | 'temporary';
|
|
170
|
+
fleetVersion: string;
|
|
171
|
+
now?: Date;
|
|
172
|
+
settings: Record<string, ProvenanceEntry>;
|
|
173
|
+
}): CreationProvenance;
|
|
174
|
+
/** Write the provenance record atomically, before the role is started. */
|
|
175
|
+
export declare function writeProvenance(stateDir: string, p: CreationProvenance): void;
|
|
176
|
+
/** One concise line per non-built-in setting, for the post-creation summary. */
|
|
177
|
+
export declare function formatProvenance(p: CreationProvenance): string[];
|
|
178
|
+
/** Classify one setting: an explicit CLI value, a fleet default, or built-in. */
|
|
179
|
+
export declare function provenanceOf(cliValue: unknown, fleetDefault: unknown, builtIn?: unknown): ProvenanceEntry;
|
package/dist/creation.js
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { replaceFileAtomically, withFileLock } from './atomic-file.js';
|
|
4
|
+
import { stateRoot } from './paths.js';
|
|
5
|
+
import { resolveEndpoint } from './monitor.js';
|
|
6
|
+
/**
|
|
7
|
+
* One creation transaction: role name and ours identity reserved together,
|
|
8
|
+
* every artifact journalled, and everything undone in reverse on failure.
|
|
9
|
+
*
|
|
10
|
+
* The problem this replaces is a check followed by a create. `assertNameFree()`
|
|
11
|
+
* read the config and the agent dirs, returned, and only then did the caller
|
|
12
|
+
* start writing — so two concurrent spawns of the same name both passed the
|
|
13
|
+
* check, both wrote, and the second silently overwrote the first's fleet.d file
|
|
14
|
+
* while inheriting its half-built state. Nothing was atomic and nothing was
|
|
15
|
+
* undone.
|
|
16
|
+
*/
|
|
17
|
+
/** Where host-wide creation state lives. One directory, so it is easy to inspect. */
|
|
18
|
+
const creationRoot = () => join(stateRoot(), 'creation');
|
|
19
|
+
const creationLock = () => join(creationRoot(), '.lock');
|
|
20
|
+
const reservationsDir = () => join(creationRoot(), 'reservations');
|
|
21
|
+
/** A reservation is one file; its existence IS the claim. */
|
|
22
|
+
const reservationPath = (kind, name) => join(reservationsDir(), `${kind}-${encodeURIComponent(name)}`);
|
|
23
|
+
export class CreationConflictError extends Error {
|
|
24
|
+
constructor(message) {
|
|
25
|
+
super(message);
|
|
26
|
+
this.name = 'CreationConflictError';
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Host-local identity reservation: atomic across every ours-fleet process on
|
|
31
|
+
* this host, because it is taken under the same host-wide creation lock as the
|
|
32
|
+
* role name.
|
|
33
|
+
*
|
|
34
|
+
* It is NOT atomic against other clients of the same ours daemon — another tool
|
|
35
|
+
* creating the identity between our reservation and our creation would still
|
|
36
|
+
* win. Closing that needs a reserve/commit/release operation in the daemon
|
|
37
|
+
* itself; see the release notes.
|
|
38
|
+
*/
|
|
39
|
+
export const hostLocalIdentityRegistry = {
|
|
40
|
+
async reserve(name) {
|
|
41
|
+
const p = reservationPath('identity', name);
|
|
42
|
+
if (existsSync(p))
|
|
43
|
+
return false;
|
|
44
|
+
mkdirSync(reservationsDir(), { recursive: true });
|
|
45
|
+
writeFileSync(p, `${process.pid} ${new Date().toISOString()}\n`);
|
|
46
|
+
return true;
|
|
47
|
+
},
|
|
48
|
+
async release(name) {
|
|
49
|
+
rmSync(reservationPath('identity', name), { force: true });
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
/** Is this role name already reserved by an in-flight transaction? */
|
|
53
|
+
const roleReserved = (name) => existsSync(reservationPath('role', name));
|
|
54
|
+
/**
|
|
55
|
+
* Run `body` inside a creation transaction.
|
|
56
|
+
*
|
|
57
|
+
* Under one host-wide lock: both names are reserved, then `body` builds the
|
|
58
|
+
* role. If anything throws, every recorded stage is undone in reverse order and
|
|
59
|
+
* both reservations are released, so the names can be reused immediately. On
|
|
60
|
+
* success the reservations are released too — the role's own config and state
|
|
61
|
+
* are the durable record from then on.
|
|
62
|
+
*
|
|
63
|
+
* Rollback errors are collected and reported, never allowed to hide the failure
|
|
64
|
+
* that caused the rollback.
|
|
65
|
+
*/
|
|
66
|
+
export async function withCreationTransaction(names, body, deps = {}) {
|
|
67
|
+
const log = deps.log ?? (() => { });
|
|
68
|
+
const registry = deps.identityRegistry ?? hostLocalIdentityRegistry;
|
|
69
|
+
mkdirSync(creationRoot(), { recursive: true });
|
|
70
|
+
const journal = [];
|
|
71
|
+
const tx = {
|
|
72
|
+
record: entry => { journal.push(entry); },
|
|
73
|
+
get stages() { return journal.map(e => e.stage); },
|
|
74
|
+
};
|
|
75
|
+
// Both names, one boundary. Reserving the role and then the identity without
|
|
76
|
+
// a shared lock would let two spawns each win one.
|
|
77
|
+
const held = await withFileLock(creationLock(), async () => {
|
|
78
|
+
if (roleReserved(names.role))
|
|
79
|
+
throw new CreationConflictError(`role '${names.role}' is being created by another process right now`);
|
|
80
|
+
if (!await registry.reserve(names.identity))
|
|
81
|
+
throw new CreationConflictError(`ours identity '${names.identity}' is already taken or being created right now`);
|
|
82
|
+
mkdirSync(reservationsDir(), { recursive: true });
|
|
83
|
+
writeFileSync(reservationPath('role', names.role), `${process.pid} ${new Date().toISOString()}\n`);
|
|
84
|
+
return true;
|
|
85
|
+
}, deps.lock);
|
|
86
|
+
if (!held)
|
|
87
|
+
throw new CreationConflictError('could not take the creation lock');
|
|
88
|
+
const releaseAll = async () => {
|
|
89
|
+
rmSync(reservationPath('role', names.role), { force: true });
|
|
90
|
+
await registry.release(names.identity).catch(() => undefined);
|
|
91
|
+
};
|
|
92
|
+
try {
|
|
93
|
+
const result = await body(tx);
|
|
94
|
+
await releaseAll();
|
|
95
|
+
return result;
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
const rollbackFailures = [];
|
|
99
|
+
for (const entry of [...journal].reverse()) {
|
|
100
|
+
try {
|
|
101
|
+
await entry.undo();
|
|
102
|
+
}
|
|
103
|
+
catch (e) {
|
|
104
|
+
rollbackFailures.push(`${entry.stage}: ${e.message}`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
await releaseAll();
|
|
108
|
+
const original = error instanceof Error ? error : new Error(String(error));
|
|
109
|
+
if (rollbackFailures.length) {
|
|
110
|
+
log(`creation rollback incomplete: ${rollbackFailures.join('; ')}`);
|
|
111
|
+
original.message += ` (rollback also failed: ${rollbackFailures.join('; ')})`;
|
|
112
|
+
}
|
|
113
|
+
throw original;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/** Forget reservations left behind by a process that died mid-transaction. */
|
|
117
|
+
export function clearStaleReservations(olderThanMs = 60_000, now = Date.now()) {
|
|
118
|
+
const dir = reservationsDir();
|
|
119
|
+
if (!existsSync(dir))
|
|
120
|
+
return 0;
|
|
121
|
+
let cleared = 0;
|
|
122
|
+
for (const f of readdirSyncSafe(dir)) {
|
|
123
|
+
const p = join(dir, f);
|
|
124
|
+
try {
|
|
125
|
+
const stamp = Date.parse(readFileSync(p, 'utf8').trim().split(/\s+/)[1] ?? '');
|
|
126
|
+
if (Number.isFinite(stamp) && now - stamp > olderThanMs) {
|
|
127
|
+
rmSync(p, { force: true });
|
|
128
|
+
cleared++;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
catch { /* unreadable: leave it for a human */ }
|
|
132
|
+
}
|
|
133
|
+
return cleared;
|
|
134
|
+
}
|
|
135
|
+
function readdirSyncSafe(dir) {
|
|
136
|
+
try {
|
|
137
|
+
return readdirSync(dir);
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
return [];
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Establish the identity before the role's service is enabled.
|
|
145
|
+
*
|
|
146
|
+
* Returns what was actually GUARANTEED, so the generated briefing can say
|
|
147
|
+
* something true instead of asserting a "predefined" identity nobody checked —
|
|
148
|
+
* the failure a real agent hit on its first boot, having been told to bind an
|
|
149
|
+
* identity that did not exist.
|
|
150
|
+
*/
|
|
151
|
+
export async function ensureIdentity(name, profile, provisioner, log = () => { }) {
|
|
152
|
+
if (!provisioner)
|
|
153
|
+
return { state: 'unverified', detail: 'no identity provisioner is configured' };
|
|
154
|
+
let present;
|
|
155
|
+
try {
|
|
156
|
+
present = await provisioner.exists(name);
|
|
157
|
+
}
|
|
158
|
+
catch (e) {
|
|
159
|
+
present = 'unknown';
|
|
160
|
+
log(`identity '${name}': could not be verified (${e.message})`);
|
|
161
|
+
}
|
|
162
|
+
if (present === true)
|
|
163
|
+
return { state: 'verified', detail: 'the ours daemon reports it exists' };
|
|
164
|
+
if (present === 'unknown')
|
|
165
|
+
return { state: 'unverified', detail: 'the ours daemon could not be asked' };
|
|
166
|
+
if (!provisioner.create) {
|
|
167
|
+
// Loud, and named. The briefing will tell the agent to mint it — which is
|
|
168
|
+
// what actually happens today — but nobody is told it was "predefined".
|
|
169
|
+
log(`identity '${name}' does not exist and this host cannot create one automatically — `
|
|
170
|
+
+ `the role will be told to mint it on first boot. Create it in advance to avoid that.`);
|
|
171
|
+
return { state: 'unverified', detail: 'it does not exist and cannot be created here' };
|
|
172
|
+
}
|
|
173
|
+
await provisioner.create(name, profile);
|
|
174
|
+
return { state: 'created', detail: 'created during spawn, with its bio and persona published' };
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Ask the running ours daemon whether an identity exists, over the same
|
|
178
|
+
* authenticated endpoint doctor already probes. Answers `unknown` rather than
|
|
179
|
+
* guessing when the daemon cannot be reached — an unreachable daemon is not
|
|
180
|
+
* evidence that the identity is missing.
|
|
181
|
+
*
|
|
182
|
+
* It deliberately has no `create()`: role identities are minted through the MCP
|
|
183
|
+
* `create_identity` tool, and inventing a daemon endpoint we cannot test is the
|
|
184
|
+
* failure mode this release exists to stop.
|
|
185
|
+
*/
|
|
186
|
+
export function daemonIdentityProvisioner(env = process.env, fetchImpl = (u, i) => globalThis.fetch(u, i)) {
|
|
187
|
+
return {
|
|
188
|
+
async exists(name) {
|
|
189
|
+
const ep = resolveEndpoint(env);
|
|
190
|
+
const resp = await fetchImpl(`${ep.origin}/identities`, { headers: ep.headers });
|
|
191
|
+
if (!resp.ok)
|
|
192
|
+
return 'unknown';
|
|
193
|
+
const body = await resp.json();
|
|
194
|
+
if (!Array.isArray(body.identities))
|
|
195
|
+
return 'unknown';
|
|
196
|
+
return body.identities.some(i => (typeof i === 'string' ? i : i?.name) === name);
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
/** Atomically write a role's fleet.d file, journalling it for rollback. */
|
|
201
|
+
export function writeRoleFile(tx, file, contents) {
|
|
202
|
+
const existed = existsSync(file);
|
|
203
|
+
replaceFileAtomically(file, contents, 0o644);
|
|
204
|
+
tx.record({
|
|
205
|
+
stage: `fleet.d file ${file}`,
|
|
206
|
+
// Only remove what THIS transaction created; never delete a file the
|
|
207
|
+
// operator already had.
|
|
208
|
+
undo: () => { if (!existed)
|
|
209
|
+
rmSync(file, { force: true }); },
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
export const CREATION_PROVENANCE_FILE = 'creation.json';
|
|
213
|
+
/**
|
|
214
|
+
* Record HOW a role was created, so nobody has to remember.
|
|
215
|
+
*
|
|
216
|
+
* Six months on, "why does this role have `approval: allow`?" is unanswerable:
|
|
217
|
+
* the resolved config shows the value but not whether an operator typed it, a
|
|
218
|
+
* fleet default supplied it, or it fell through to a built-in. Those have very
|
|
219
|
+
* different implications for whether it is safe to change.
|
|
220
|
+
*
|
|
221
|
+
* Deliberately excluded: `env`, `bio`, `persona`, and `harness_options`. The
|
|
222
|
+
* first two can carry credentials, and this file exists to be read — it must
|
|
223
|
+
* never become a place secrets accumulate.
|
|
224
|
+
*/
|
|
225
|
+
export function buildProvenance(o) {
|
|
226
|
+
return {
|
|
227
|
+
version: 1,
|
|
228
|
+
command: 'ours-fleet spawn',
|
|
229
|
+
fleetVersion: o.fleetVersion,
|
|
230
|
+
createdAt: (o.now ?? new Date()).toISOString(),
|
|
231
|
+
lifetime: o.lifetime,
|
|
232
|
+
role: o.role,
|
|
233
|
+
settings: o.settings,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
/** Write the provenance record atomically, before the role is started. */
|
|
237
|
+
export function writeProvenance(stateDir, p) {
|
|
238
|
+
replaceFileAtomically(join(stateDir, CREATION_PROVENANCE_FILE), JSON.stringify(p, null, 2) + '\n', 0o600);
|
|
239
|
+
}
|
|
240
|
+
/** One concise line per non-built-in setting, for the post-creation summary. */
|
|
241
|
+
export function formatProvenance(p) {
|
|
242
|
+
const mark = { cli: 'explicit', 'fleet-default': 'fleet default', 'built-in': 'built-in' };
|
|
243
|
+
return Object.entries(p.settings)
|
|
244
|
+
.filter(([, e]) => e.value !== undefined)
|
|
245
|
+
.map(([k, e]) => ` ${k.padEnd(12)} ${String(e.value)} (${mark[e.source]})`);
|
|
246
|
+
}
|
|
247
|
+
/** Classify one setting: an explicit CLI value, a fleet default, or built-in. */
|
|
248
|
+
export function provenanceOf(cliValue, fleetDefault, builtIn) {
|
|
249
|
+
if (cliValue !== undefined && cliValue !== null && cliValue !== '')
|
|
250
|
+
return { value: cliValue, source: 'cli' };
|
|
251
|
+
if (fleetDefault !== undefined && fleetDefault !== null)
|
|
252
|
+
return { value: fleetDefault, source: 'fleet-default' };
|
|
253
|
+
return { value: builtIn, source: 'built-in' };
|
|
254
|
+
}
|
package/dist/docs.d.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stable, AI-friendly CLI and configuration reference.
|
|
3
|
+
*
|
|
4
|
+
* Keep this concise enough to place directly in an agent context. Unlike
|
|
5
|
+
* Commander's per-command help, this describes how the pieces compose.
|
|
6
|
+
*/
|
|
7
|
+
export declare const AI_DOCS = "# ours-fleet reference\n\nours-fleet runs persistent or temporary, identity-bound AI roles. A role selects\na harness independently from its session backend:\n\n- harness: `claude-code` or `codex`\n- session: `tmux` (default) or `acp`\n- lifetime: permanent (supervised, restartable) or `spawn --temp`\n\n## Discover and validate\n\n```sh\nours-fleet docs # this complete reference (`man` is an alias)\nours-fleet help <command> # exact flags for one command\nours-fleet config [-c FILE] # validate and print the merged plan; no changes\nours-fleet doctor [-c FILE] [--harness codex|claude-code]\n```\n\nDefault configuration is `~/fleet.yaml` plus sorted `~/fleet.d/*.yaml` role\ndrop-ins. An explicit `-c FILE` replaces `~/fleet.yaml`; fleet.d still adds\nroles. Validate with `config` and `doctor` before starting or restarting.\n\n## Lifecycle and console commands\n\n```sh\nours-fleet init\nours-fleet up|down [Name...]\nours-fleet restart [Name...] # preserve/resume harness context\nours-fleet force-restart [Name...] # fresh context; briefing is reloaded\nours-fleet ls\nours-fleet status|peek|attach|logs Name\nours-fleet logs -f Name\nours-fleet send Name \"prompt\"\nours-fleet send Name --key Enter # tmux only\nours-fleet rm Name\n```\n\n`peek`, `attach`, and text `send` work with tmux and ACP. ACP attachment\nalso accepts `/permit <permission-id> <option-id>`, `/interrupt`, and\n`/detach`. Raw `--key` input is tmux-only.\n\n## Spawn\n\n```sh\nours-fleet spawn [--temp] Name \\\n --harness codex|claude-code --session tmux|acp \\\n --mission \"one line\" --cwd /absolute/path --identity Identity \\\n --coordinator Coordinator --model MODEL \\\n --approval ask|allow|deny \\\n --filesystem read-only|workspace|unrestricted \\\n --unattended deny|wait \\\n --bio-file /path/bio.md --persona-file /path/persona.md\n```\n\nPermanent spawn writes `~/fleet.d/Name.yaml` and starts a supervised role.\n`--temp` writes ephemeral state, starts a detached supervisor, and removes the\nrole after exit/reboot. Both lifetimes support `--session acp`.\n\nCodex-specific spawn flags: `--sandbox`, `--permission-mode`, `--launcher`,\n`--profile`, `--search`, repeatable `--codex-config key=value`, repeatable\n`--add-dir`, and `--monitor`. Run `ours-fleet help spawn` for exact values.\n\n## fleet.yaml\n\n```yaml\nvars:\n work_root: /home/me/work\nstart_stagger_ms: 0\ndefaults:\n harness: codex\n session: acp\n model: gpt-model-id\n permissions:\n approval: ask\n filesystem: workspace\n unattended: deny\n monitor:\n enabled: true\nroles:\n Coordinator:\n harness: codex\n session: acp\n identity: Coordinator\n cwd: ${work_root}/project\n mission: Coordinate work and delegate implementation.\n model: gpt-model-id\n permissions:\n approval: ask\n filesystem: workspace\n unattended: deny\n session_options: # advanced overrides; normally omit\n # acp:\n # command: [/custom/codex-acp, --flag]\n tmux:\n boot_grace_ms: 10000\n monitor:\n enabled: true\n wake_sources: [message_received, file_received, local_contact_request, pending_message]\n batch_ms: 2000\n inject: notification\n turn_fail_threshold: 3\n harness_options:\n launcher: auto\n sandbox: workspace-write\n approval: on-request\n search: false\n profile: fleet\n add_dirs: [/data/shared]\n config:\n model_reasoning_effort: high\n bio: Public role card and when peers should engage it.\n persona: Local operating contract, boundaries, and escalation policy.\n briefing_file: /absolute/custom-briefing.md\n coordinator: AnotherCoordinator\n env:\n KEY: value\n oversee:\n - { role: Worker, interval: 5m }\n```\n\nRole values override defaults. `${name}` substitutes entries from `vars`.\nOther role fields include `max_tokens`, `autocompact_pct`, and `isolation`.\nUse README.md for the complete isolation policy and resource-cap schema.\n\n## Permissions\n\nPrefer the harness-neutral `permissions` block:\n\n- `approval: ask|allow|deny`: whether actions may request or receive approval\n- `filesystem: read-only|workspace|unrestricted`: filesystem intent\n- `unattended: deny|wait`: what ACP does when no console can answer a request\n\nThe backend translates this common intent. Harness-native settings in\n`harness_options` take precedence where supplied. Do not choose\n`allow`/`unrestricted`, Codex `never`/`danger-full-access`, or Claude\n`bypassPermissions` without explicit authorization.\n\n### Creation-time isolation\n\n`ours-fleet spawn --isolation-file <path>` supplies a role's sandbox policy at\ncreation, so the FIRST launch is already confined \u2014 a role that only gains\n`isolation:` on a later `up` ran unsandboxed until then.\n\nThe file holds exactly the `isolation:` mapping documented above and nothing\nelse \u2014 the same schema, validated by the same code, so a policy written here\ncannot mean something different from the identical block in fleet.yaml:\n\n```yaml\nnetwork: deny\nfs:\n read: [/opt/reference]\nresources:\n mem: 2G\n```\n\nInvalid files are rejected before anything is created: no config, no state\ndirectory, no identity reservation. Works for both permanent and `--temp` roles.\n\n### Never-prompt failure\n\nThe failure this section exists to prevent leaves no error message anywhere.\n\nAn unattended role has no console. When the harness needs a permission decision\nthere is nobody to ask, so the request is refused INSIDE the harness \u2014 no\nprompt, no error, no log line. The agent simply does less than its briefing told\nit to, reports success, and nothing distinguishes that from having done the\nwork. Two settings produce it:\n\n1. a permission mode that suppresses the prompt without granting the action\n (Claude `dontAsk`, which is why neutral `allow` maps to\n `bypassPermissions` instead); and\n2. `unattended: deny`, which refuses every request that reaches it.\n\n**Automatic decisions are now recorded.** Every permission request decided\nwithout a human emits a completed event into\n`~/.ours-fleet/agents/<Name>/.session-events.jsonl` carrying the decision,\nwhether policy or a person made it, the policy that produced it\n(`permissions.unattended=deny` vs `permissions.approval=deny`/`=allow`),\nthe reason, and the option selected. `ours-fleet peek` and `attach` render\nthem. Automatic denial asks for a one-shot rejection, never a standing one, so a\nsingle unattended refusal cannot disable a tool for the rest of the session.\n\nA role that can auto-deny logs one line at startup saying so.\n\nTo detect an under-permissioned role BEFORE it runs, use the capability floor\nbelow: `ours-fleet doctor` fails such a role rather than letting it discover\nthe problem silently at work.\n\n### The unattended capability floor\n\nAn unattended role has no console, so a permission request cannot be answered \u2014\nit is refused, silently, inside the harness. The agent then does less than it\nwas told to and reports no error. To make that visible before launch,\n`ours-fleet config` and `ours-fleet doctor` resolve each role's neutral\npermissions through its harness and check the result against a fixed floor:\n\n- `read-state` \u2014 read its briefing, ROUTINES.md, and WORKLOG.md\n- `write-state` \u2014 append its WORKLOG and its own state files\n- `messaging` \u2014 bind its identity, send and receive ours mail\n- `monitor` \u2014 arm and observe its mail monitor\n- `workspace-edit` \u2014 edit and test files in its working directory\n- `status-commands` \u2014 run the inspection commands its briefing prescribes\n\n`doctor` reports this per role as `unattended floor: <Role>`. A role with\n`unattended: deny` that cannot meet the floor FAILS doctor, because it will\ndeny those requests with nobody to see it; with `unattended: wait` it warns,\nbecause a human can still attach and answer.\n\nSecurity meaning: `approval: allow` maps to Claude's `bypassPermissions`,\nwhich genuinely permits the actions the role was authorized to take \u2014\n`dontAsk` only suppresses the prompt while still refusing the action. Nothing\nother than an explicit `allow` is elevated: `ask` stays on Claude's default\nmode and `deny` maps to `plan`. `allow` is therefore a real grant and\nrequires explicit authorization; per-role `isolation:` remains the outer\nboundary that a permission mode cannot cross.\n\nSee also: `spawn --approval/--filesystem/--unattended` set this intent at\ncreation, and `ours-fleet config` prints each role's neutral settings, their\nnative translation, and any warning \u2014 the same text `doctor` reports.\n\nClaude `harness_options`: `permission_mode` (default, acceptEdits, plan,\ndontAsk, bypassPermissions), `plugins`, `mem_palace`, and\n`mem_palace_midsession_autosave`.\n\nCodex `harness_options`: `launcher` (auto, ours-codex, codex), `sandbox`\n(read-only, workspace-write, danger-full-access), `approval` or\n`permission_mode` (untrusted, on-request, never), `profile`, `search`,\n`config`, `add_dirs`, and `monitor`.\n\n## ACP adapters\n\nThe maintained `@agentclientprotocol/codex-acp` and\n`@agentclientprotocol/claude-agent-acp` runtimes are bundled automatically as\noptional ours-fleet dependencies. The supervisor resolves their executable\nentrypoints internally, so default ACP roles do not depend on global PATH.\nThe maintained Claude adapter requires Node 22; tmux and Codex ACP continue to\nwork on the ours-fleet core minimum of Node 20.\n\nOverride an adapter only when necessary with `session_options.acp.command`\n(string or argv list). If optional dependencies were deliberately omitted,\nours-fleet falls back to a compatible globally installed `codex-acp` or\n`claude-agent-acp`. `ours-fleet doctor -c FILE` verifies the resolved adapter.\n\n## Reliable mail wake\n\nThe supervisor monitor is enabled by default. It consumes body-free daemon\nevents and advances its durable cursor only after delivery is accepted. ACP uses\na structured `session/prompt`; tmux uses verified console injection. Message\nbodies are released only when the role calls the ours `get_messages` tool.\n\nSet `monitor.enabled: false` only to retain legacy in-session monitoring.\nInspect `ours-fleet status Name`, `peek Name`, role logs, and\n`~/.ours-fleet/agents/Name/.monitor-status` when diagnosing delivery.\n";
|
|
8
|
+
/**
|
|
9
|
+
* What every shipped spawn-skill variant must say, and must not say (7.1).
|
|
10
|
+
*
|
|
11
|
+
* The skills are separate markdown files in two published plugins, written for
|
|
12
|
+
* two different harnesses, so they cannot literally be one file. This is the
|
|
13
|
+
* source of truth they are all written from, and a test holds each variant to
|
|
14
|
+
* it — including the CLI reference above, so a skill and \`ours-fleet docs\`
|
|
15
|
+
* cannot name different permission settings.
|
|
16
|
+
*
|
|
17
|
+
* \`forbidden\` is the more important half. The old skills prescribed
|
|
18
|
+
* \`--approval ask --filesystem workspace --unattended deny\` as a blanket
|
|
19
|
+
* default while also telling the agent to stop at a failed doctor check — and
|
|
20
|
+
* that combination is exactly what \`doctor\` FAILS, because \`ask\` grants an
|
|
21
|
+
* unattended role nothing but \`read-state\` and \`deny\` makes the shortfall
|
|
22
|
+
* fatal. Following the skill produced a role the CLI then refused.
|
|
23
|
+
*/
|
|
24
|
+
export declare const SPAWN_SKILL_CONTRACT: {
|
|
25
|
+
/** Substrings every variant must contain (whitespace-normalised). */
|
|
26
|
+
readonly required: readonly ["ours-fleet docs", "ours-fleet doctor", "dontAsk", "bypassPermissions", "unattended capability floor", "unattended floor:", "--approval allow", "--unattended wait", "--isolation-file"];
|
|
27
|
+
/**
|
|
28
|
+
* Substrings no variant may contain. Deliberately short: the real guard is
|
|
29
|
+
* the acceptance test, which runs every spawn command a variant prints
|
|
30
|
+
* through the same analysis `doctor` uses and fails if doctor would fail it.
|
|
31
|
+
* This list only pins the specific claim that was wrong.
|
|
32
|
+
*/
|
|
33
|
+
readonly forbidden: readonly ["--approval ask --filesystem workspace --unattended deny", "[TODO:"];
|
|
34
|
+
};
|