@ours.network/fleet 0.10.3 → 0.10.4
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 +66 -0
- package/dist/application/capabilities.d.ts +6 -0
- package/dist/application/capabilities.js +37 -0
- package/dist/application/errors.d.ts +31 -0
- package/dist/application/errors.js +51 -0
- package/dist/application/fleet-query-service.d.ts +31 -0
- package/dist/application/fleet-query-service.js +180 -0
- package/dist/application/log-service.d.ts +28 -0
- package/dist/application/log-service.js +146 -0
- package/dist/application/role-command-service.d.ts +37 -0
- package/dist/application/role-command-service.js +82 -0
- package/dist/application/role-creation-service.d.ts +142 -0
- package/dist/application/role-creation-service.js +374 -0
- package/dist/application/role-repository.d.ts +20 -0
- package/dist/application/role-repository.js +168 -0
- package/dist/application/session-control.d.ts +55 -0
- package/dist/application/session-control.js +115 -0
- package/dist/application/types.d.ts +156 -0
- package/dist/application/types.js +1 -0
- package/dist/cli.js +141 -0
- package/dist/config.d.ts +7 -2
- package/dist/config.js +18 -4
- package/dist/creation.d.ts +11 -0
- package/dist/creation.js +22 -5
- package/dist/docs.d.ts +1 -1
- package/dist/docs.js +34 -0
- package/dist/index.d.ts +10 -1
- package/dist/index.js +9 -1
- package/dist/runner.js +10 -2
- package/dist/session/control.d.ts +4 -2
- package/dist/session/control.js +45 -13
- package/dist/spawn.d.ts +20 -2
- package/dist/spawn.js +94 -24
- package/dist/supervisor/launchd.js +17 -0
- package/dist/supervisor/none.js +17 -0
- package/dist/supervisor/systemd.js +4 -0
- package/dist/supervisor/types.d.ts +6 -0
- package/dist/tmux.d.ts +2 -0
- package/dist/tmux.js +8 -0
- package/dist/web/audit.d.ts +22 -0
- package/dist/web/audit.js +54 -0
- package/dist/web/auth.d.ts +61 -0
- package/dist/web/auth.js +186 -0
- package/dist/web/control.d.ts +14 -0
- package/dist/web/control.js +110 -0
- package/dist/web/device-store.d.ts +27 -0
- package/dist/web/device-store.js +155 -0
- package/dist/web/events.d.ts +15 -0
- package/dist/web/events.js +34 -0
- package/dist/web/lock.d.ts +5 -0
- package/dist/web/lock.js +69 -0
- package/dist/web/runtime.d.ts +12 -0
- package/dist/web/runtime.js +170 -0
- package/dist/web/server.d.ts +35 -0
- package/dist/web/server.js +261 -0
- package/dist/web/service.d.ts +42 -0
- package/dist/web/service.js +180 -0
- package/dist/web/terminal/bridge.d.ts +27 -0
- package/dist/web/terminal/bridge.js +317 -0
- package/dist/web-app/assets/TerminalView-DcImdrI1.js +9 -0
- package/dist/web-app/assets/index-BokQN1Ao.js +9 -0
- package/dist/web-app/assets/index-lAXzaOZM.css +1 -0
- package/dist/web-app/icons/ours-fleet-maskable.svg +4 -0
- package/dist/web-app/icons/ours-fleet.svg +4 -0
- package/dist/web-app/index.html +17 -0
- package/dist/web-app/manifest.webmanifest +15 -0
- package/dist/web-app/offline.html +18 -0
- package/dist/web-app/sw.js +51 -0
- package/package.json +26 -3
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { loadConfig } from '../config.js';
|
|
3
|
+
import { up, restartRoles } from '../ops.js';
|
|
4
|
+
import { FleetError, normalizeError } from './errors.js';
|
|
5
|
+
export class RoleCommandService {
|
|
6
|
+
options;
|
|
7
|
+
receipts = new Map();
|
|
8
|
+
locks = new Map();
|
|
9
|
+
constructor(options) {
|
|
10
|
+
this.options = options;
|
|
11
|
+
}
|
|
12
|
+
async execute(input) {
|
|
13
|
+
const actionId = input.actionId ?? randomUUID();
|
|
14
|
+
const prior = this.receipts.get(actionId);
|
|
15
|
+
if (prior) {
|
|
16
|
+
if (prior.roleId !== input.roleId || prior.action !== input.action)
|
|
17
|
+
throw new FleetError('idempotency_conflict', 'action ID already belongs to another operation');
|
|
18
|
+
return prior;
|
|
19
|
+
}
|
|
20
|
+
const role = await this.options.repository.get(input.roleId);
|
|
21
|
+
if (!role)
|
|
22
|
+
throw new FleetError('role_not_found', `no such role '${input.roleId}'`);
|
|
23
|
+
if (role.lifetime !== 'permanent')
|
|
24
|
+
throw new FleetError('capability_unavailable', 'lifecycle is unavailable for temporary/orphan roles');
|
|
25
|
+
if (input.action === 'restart_fresh' && input.confirmation !== input.roleId)
|
|
26
|
+
throw new FleetError('invalid_request', 'fresh restart requires the exact role name');
|
|
27
|
+
const receipt = {
|
|
28
|
+
actionId, roleId: input.roleId, action: input.action,
|
|
29
|
+
acceptedAt: new Date().toISOString(), state: 'accepted',
|
|
30
|
+
};
|
|
31
|
+
this.receipts.set(actionId, receipt);
|
|
32
|
+
void this.serial(input.roleId, async () => this.run(receipt));
|
|
33
|
+
return receipt;
|
|
34
|
+
}
|
|
35
|
+
get(actionId) { return this.receipts.get(actionId); }
|
|
36
|
+
async run(receipt) {
|
|
37
|
+
receipt.state = 'running';
|
|
38
|
+
this.options.onProgress?.(structuredClone(receipt));
|
|
39
|
+
try {
|
|
40
|
+
const config = loadConfig(this.options.configPath);
|
|
41
|
+
if (receipt.action === 'start')
|
|
42
|
+
await up(config, [receipt.roleId], this.options.ops, this.options.configPath);
|
|
43
|
+
else if (receipt.action === 'stop')
|
|
44
|
+
await this.options.ops.backend.stop(receipt.roleId);
|
|
45
|
+
else
|
|
46
|
+
await restartRoles(config, [receipt.roleId], this.options.ops, receipt.action === 'restart_fresh' ? 'fresh' : 'keep', this.options.configPath);
|
|
47
|
+
try {
|
|
48
|
+
const status = await this.options.status(receipt.roleId);
|
|
49
|
+
receipt.postcondition = { overall: status.overall, observedAt: status.observedAt };
|
|
50
|
+
const expected = receipt.action === 'stop' ? status.supervisor.liveness === 'stopped'
|
|
51
|
+
: status.supervisor.liveness === 'running';
|
|
52
|
+
receipt.state = expected ? 'succeeded' : 'uncertain';
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
receipt.state = 'uncertain';
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
const normalized = normalizeError(error);
|
|
60
|
+
receipt.error = normalized.toJSON();
|
|
61
|
+
receipt.state = 'failed';
|
|
62
|
+
}
|
|
63
|
+
receipt.completedAt = new Date().toISOString();
|
|
64
|
+
this.options.onProgress?.(structuredClone(receipt));
|
|
65
|
+
}
|
|
66
|
+
async serial(roleId, operation) {
|
|
67
|
+
const prior = this.locks.get(roleId) ?? Promise.resolve();
|
|
68
|
+
let release;
|
|
69
|
+
const gate = new Promise(resolve => { release = resolve; });
|
|
70
|
+
const chain = prior.then(() => gate);
|
|
71
|
+
this.locks.set(roleId, chain);
|
|
72
|
+
await prior;
|
|
73
|
+
try {
|
|
74
|
+
return await operation();
|
|
75
|
+
}
|
|
76
|
+
finally {
|
|
77
|
+
release();
|
|
78
|
+
if (this.locks.get(roleId) === chain)
|
|
79
|
+
this.locks.delete(roleId);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { type CommonPermissions, type MonitorConfig, type NotifyEventType } from '../config.js';
|
|
2
|
+
import { type IdentityProvisioner } from '../creation.js';
|
|
3
|
+
import { type SupervisorLauncher } from '../spawn.js';
|
|
4
|
+
import type { OpsDeps } from '../ops.js';
|
|
5
|
+
import { FleetError } from './errors.js';
|
|
6
|
+
export interface CreateRoleSessionRequest {
|
|
7
|
+
name: string;
|
|
8
|
+
harness: 'codex' | 'claude-code';
|
|
9
|
+
/** null means the selected harness's own default; web blank fields send null. */
|
|
10
|
+
model?: string | null;
|
|
11
|
+
session: 'acp' | 'tmux';
|
|
12
|
+
cwd?: string;
|
|
13
|
+
lifetime: 'permanent' | 'temporary';
|
|
14
|
+
mission?: string;
|
|
15
|
+
coordinator?: string;
|
|
16
|
+
permissions: CommonPermissions;
|
|
17
|
+
bio?: string;
|
|
18
|
+
persona?: string;
|
|
19
|
+
monitor?: WebCreationMonitor;
|
|
20
|
+
openAfterCreate: boolean;
|
|
21
|
+
highRiskAcknowledged?: boolean;
|
|
22
|
+
reuseExistingIdentityAcknowledged?: boolean;
|
|
23
|
+
unverifiedIdentityAcknowledged?: boolean;
|
|
24
|
+
}
|
|
25
|
+
export type WebCreationMonitor = {
|
|
26
|
+
mode: 'native';
|
|
27
|
+
} | {
|
|
28
|
+
mode: 'fleet';
|
|
29
|
+
interrupt: boolean;
|
|
30
|
+
wake_sources: NotifyEventType[];
|
|
31
|
+
batch_ms: number;
|
|
32
|
+
inject: 'notification';
|
|
33
|
+
};
|
|
34
|
+
export interface CreationCapabilities {
|
|
35
|
+
available: boolean;
|
|
36
|
+
reasons: string[];
|
|
37
|
+
harnesses: Array<{
|
|
38
|
+
id: 'codex' | 'claude-code';
|
|
39
|
+
available: boolean;
|
|
40
|
+
sessions: Array<'acp' | 'tmux'>;
|
|
41
|
+
defaultModel?: string;
|
|
42
|
+
models: string[];
|
|
43
|
+
warnings: string[];
|
|
44
|
+
}>;
|
|
45
|
+
lifetimes: Array<'permanent' | 'temporary'>;
|
|
46
|
+
identityBootstrap: {
|
|
47
|
+
mode: 'current-fleet-first-boot';
|
|
48
|
+
existingIdentity: IdentityPreflight;
|
|
49
|
+
bindingEvidence: 'not-structured';
|
|
50
|
+
warnings: string[];
|
|
51
|
+
};
|
|
52
|
+
safePermissionSchemaVersion: 1;
|
|
53
|
+
monitor: {
|
|
54
|
+
modes: Array<'fleet' | 'native'>;
|
|
55
|
+
wakeSources: NotifyEventType[];
|
|
56
|
+
injectModes: ['notification'];
|
|
57
|
+
defaults: MonitorConfig;
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
export type IdentityPreflight = 'verified' | 'missing' | 'unknown';
|
|
61
|
+
export interface CreationPreview {
|
|
62
|
+
request: CreateRoleSessionRequest;
|
|
63
|
+
effective: {
|
|
64
|
+
name: string;
|
|
65
|
+
identity: string;
|
|
66
|
+
harness: string;
|
|
67
|
+
session: 'acp' | 'tmux';
|
|
68
|
+
model?: string;
|
|
69
|
+
cwd?: string;
|
|
70
|
+
lifetime: 'permanent' | 'temporary';
|
|
71
|
+
permissions: CommonPermissions;
|
|
72
|
+
monitor: MonitorConfig;
|
|
73
|
+
};
|
|
74
|
+
provenance: Record<string, 'request' | 'fleet-default' | 'built-in'>;
|
|
75
|
+
warnings: string[];
|
|
76
|
+
prerequisites: string[];
|
|
77
|
+
identityBootstrap: {
|
|
78
|
+
existingIdentity: IdentityPreflight;
|
|
79
|
+
derivedIdentity: string;
|
|
80
|
+
mode: 'current-fleet-first-boot';
|
|
81
|
+
bindingEvidence: 'not-structured';
|
|
82
|
+
};
|
|
83
|
+
previewHash: string;
|
|
84
|
+
}
|
|
85
|
+
export type CreationStage = 'validating' | 'reserving' | 'checking_identity' | 'writing_role' | 'registering_supervisor' | 'starting_temp' | 'launched' | 'identity_bootstrap_pending' | 'waiting_for_session' | 'session_reachable' | 'attention' | 'launched_unconfirmed' | 'failed' | 'rollback_incomplete';
|
|
86
|
+
export interface CreationAction {
|
|
87
|
+
actionId: string;
|
|
88
|
+
requestHash: string;
|
|
89
|
+
roleId: string;
|
|
90
|
+
session: 'acp' | 'tmux';
|
|
91
|
+
lifetime: 'permanent' | 'temporary';
|
|
92
|
+
state: CreationStage;
|
|
93
|
+
stages: Array<{
|
|
94
|
+
stage: CreationStage;
|
|
95
|
+
at: string;
|
|
96
|
+
detail?: string;
|
|
97
|
+
}>;
|
|
98
|
+
createdAt: string;
|
|
99
|
+
updatedAt: string;
|
|
100
|
+
error?: ReturnType<FleetError['toJSON']>;
|
|
101
|
+
openPath?: string;
|
|
102
|
+
identityCheck?: IdentityPreflight;
|
|
103
|
+
identityBindingEvidence: 'not-structured';
|
|
104
|
+
/** Hash only; the browser's raw idempotency key is never persisted. */
|
|
105
|
+
idempotencyHash?: string;
|
|
106
|
+
}
|
|
107
|
+
export interface RoleCreationServiceOptions {
|
|
108
|
+
configPath?: string;
|
|
109
|
+
ops: OpsDeps;
|
|
110
|
+
binPath: string;
|
|
111
|
+
/** Test seam for today's authenticated GET /identities existence check. */
|
|
112
|
+
identityProvisioner?: IdentityProvisioner;
|
|
113
|
+
tempLauncher?: SupervisorLauncher;
|
|
114
|
+
allowedCwdRoots?: string[];
|
|
115
|
+
journalDir?: string;
|
|
116
|
+
probeReady?: (name: string, session: 'acp' | 'tmux') => Promise<'ready' | 'attention' | 'unknown'>;
|
|
117
|
+
onProgress?: (action: CreationAction) => void;
|
|
118
|
+
}
|
|
119
|
+
export declare class RoleCreationService {
|
|
120
|
+
private readonly options;
|
|
121
|
+
private readonly actions;
|
|
122
|
+
private readonly idempotency;
|
|
123
|
+
private readonly inFlight;
|
|
124
|
+
private readonly journalDir;
|
|
125
|
+
private readonly identityProvisioner;
|
|
126
|
+
constructor(options: RoleCreationServiceOptions);
|
|
127
|
+
capabilities(): Promise<CreationCapabilities>;
|
|
128
|
+
preview(input: CreateRoleSessionRequest): Promise<CreationPreview>;
|
|
129
|
+
create(input: CreateRoleSessionRequest, previewHash: string, idempotencyKey: string, browserSession: string): Promise<CreationAction>;
|
|
130
|
+
get(actionId: string): CreationAction | undefined;
|
|
131
|
+
private run;
|
|
132
|
+
private waitForReady;
|
|
133
|
+
private validate;
|
|
134
|
+
private resolveCwd;
|
|
135
|
+
private spawnOptions;
|
|
136
|
+
private checkIdentity;
|
|
137
|
+
private coreStage;
|
|
138
|
+
private configFingerprint;
|
|
139
|
+
private stage;
|
|
140
|
+
private persist;
|
|
141
|
+
private restore;
|
|
142
|
+
}
|
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { mkdirSync, readFileSync, readdirSync, realpathSync, statSync } from 'node:fs';
|
|
3
|
+
import { isAbsolute, relative } from 'node:path';
|
|
4
|
+
import { loadConfig, NOTIFY_EVENT_TYPES, resolveMonitorConfig, resolveRoleModel, resolvePermissions, ROLE_NAME_RE, validateMonitorConfig, } from '../config.js';
|
|
5
|
+
import { daemonIdentityProvisioner, } from '../creation.js';
|
|
6
|
+
import { replaceFileAtomically } from '../atomic-file.js';
|
|
7
|
+
import { stateRoot } from '../paths.js';
|
|
8
|
+
import { buildRoleConfig, spawnPermanent, spawnTemp, validateSpawnOpts, } from '../spawn.js';
|
|
9
|
+
import { FleetError, normalizeError } from './errors.js';
|
|
10
|
+
const canonical = (value) => {
|
|
11
|
+
if (Array.isArray(value))
|
|
12
|
+
return `[${value.map(canonical).join(',')}]`;
|
|
13
|
+
if (value && typeof value === 'object')
|
|
14
|
+
return `{${Object.entries(value).sort(([a], [b]) => a.localeCompare(b))
|
|
15
|
+
.map(([key, item]) => `${JSON.stringify(key)}:${canonical(item)}`).join(',')}}`;
|
|
16
|
+
return JSON.stringify(value);
|
|
17
|
+
};
|
|
18
|
+
const hash = (value) => createHash('sha256').update(canonical(value)).digest('hex');
|
|
19
|
+
function bounded(value, name, max) {
|
|
20
|
+
if (value === undefined)
|
|
21
|
+
return;
|
|
22
|
+
if (value.length > max || /[\0\x08\x0b\x0c\x0e-\x1f\x7f]/.test(value))
|
|
23
|
+
throw new FleetError('invalid_request', `${name} is invalid or exceeds ${max} characters`);
|
|
24
|
+
}
|
|
25
|
+
export class RoleCreationService {
|
|
26
|
+
options;
|
|
27
|
+
actions = new Map();
|
|
28
|
+
idempotency = new Map();
|
|
29
|
+
inFlight = new Set();
|
|
30
|
+
journalDir;
|
|
31
|
+
identityProvisioner;
|
|
32
|
+
constructor(options) {
|
|
33
|
+
this.options = options;
|
|
34
|
+
this.journalDir = options.journalDir ?? `${stateRoot()}/web/creation-actions`;
|
|
35
|
+
mkdirSync(this.journalDir, { recursive: true, mode: 0o700 });
|
|
36
|
+
this.identityProvisioner = options.identityProvisioner ?? daemonIdentityProvisioner();
|
|
37
|
+
this.restore();
|
|
38
|
+
}
|
|
39
|
+
async capabilities() {
|
|
40
|
+
const reasons = [];
|
|
41
|
+
let defaults = {};
|
|
42
|
+
let roles = [];
|
|
43
|
+
try {
|
|
44
|
+
const config = loadConfig(this.options.configPath);
|
|
45
|
+
defaults = config.defaults;
|
|
46
|
+
roles = config.roles;
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
reasons.push(`configuration is invalid: ${error.message}`);
|
|
50
|
+
}
|
|
51
|
+
const modelsFor = (harness, suggested) => {
|
|
52
|
+
const configured = roles.filter(role => role.harness === harness)
|
|
53
|
+
.flatMap(role => [role.model, ...(role.model_chain ?? [])])
|
|
54
|
+
.filter((model) => Boolean(model));
|
|
55
|
+
const inherited = resolveRoleModel(undefined, harness, defaults);
|
|
56
|
+
return [...new Set([...(inherited ? [inherited] : []), ...configured, ...suggested])];
|
|
57
|
+
};
|
|
58
|
+
return {
|
|
59
|
+
available: reasons.length === 0,
|
|
60
|
+
reasons,
|
|
61
|
+
harnesses: [
|
|
62
|
+
{
|
|
63
|
+
id: 'codex', available: true, sessions: ['acp', 'tmux'],
|
|
64
|
+
defaultModel: resolveRoleModel(undefined, 'codex', defaults),
|
|
65
|
+
models: modelsFor('codex', ['gpt-5.6', 'gpt-5.4']), warnings: [],
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
id: 'claude-code', available: true, sessions: ['acp', 'tmux'],
|
|
69
|
+
defaultModel: resolveRoleModel(undefined, 'claude-code', defaults),
|
|
70
|
+
models: modelsFor('claude-code', ['sonnet', 'opus', 'haiku']), warnings: [],
|
|
71
|
+
},
|
|
72
|
+
],
|
|
73
|
+
lifetimes: ['permanent', 'temporary'],
|
|
74
|
+
identityBootstrap: {
|
|
75
|
+
mode: 'current-fleet-first-boot',
|
|
76
|
+
existingIdentity: 'unknown',
|
|
77
|
+
bindingEvidence: 'not-structured',
|
|
78
|
+
warnings: [
|
|
79
|
+
'Identity binding is completed by the new harness from its generated first-boot briefing.',
|
|
80
|
+
],
|
|
81
|
+
},
|
|
82
|
+
safePermissionSchemaVersion: 1,
|
|
83
|
+
monitor: {
|
|
84
|
+
modes: ['fleet', 'native'], wakeSources: [...NOTIFY_EVENT_TYPES],
|
|
85
|
+
injectModes: ['notification'],
|
|
86
|
+
defaults: resolveMonitorConfig(defaults.monitor, undefined),
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
async preview(input) {
|
|
91
|
+
const request = this.validate(input);
|
|
92
|
+
const cfg = loadConfig(this.options.configPath);
|
|
93
|
+
const defaults = cfg.defaults;
|
|
94
|
+
const opts = this.spawnOptions(request);
|
|
95
|
+
validateSpawnOpts(opts);
|
|
96
|
+
buildRoleConfig(opts, defaults.harness);
|
|
97
|
+
const cwd = request.cwd ? this.resolveCwd(request.cwd) : undefined;
|
|
98
|
+
const effective = {
|
|
99
|
+
name: request.name, identity: request.name,
|
|
100
|
+
harness: request.harness ?? defaults.harness ?? 'claude-code',
|
|
101
|
+
session: request.session ?? defaults.session ?? 'tmux',
|
|
102
|
+
model: resolveRoleModel(request.model, request.harness, defaults),
|
|
103
|
+
cwd, lifetime: request.lifetime,
|
|
104
|
+
permissions: resolvePermissions(defaults.permissions, request.permissions),
|
|
105
|
+
monitor: resolveMonitorConfig(defaults.monitor, request.monitor),
|
|
106
|
+
};
|
|
107
|
+
const warnings = [];
|
|
108
|
+
if (request.permissions.approval === 'allow')
|
|
109
|
+
warnings.push('approval=allow maps to an elevated native permission mode');
|
|
110
|
+
if (request.permissions.filesystem === 'unrestricted')
|
|
111
|
+
warnings.push('filesystem=unrestricted grants access outside the workspace');
|
|
112
|
+
if (request.permissions.unattended === 'wait')
|
|
113
|
+
warnings.push('unattended permission requests may hold until a controller attaches');
|
|
114
|
+
if (request.lifetime === 'temporary')
|
|
115
|
+
warnings.push('temporary sessions are gone on exit or reboot');
|
|
116
|
+
const existingIdentity = await this.checkIdentity(request.name);
|
|
117
|
+
if (existingIdentity === 'verified')
|
|
118
|
+
warnings.push(`local identity '${request.name}' already exists and will be reused`);
|
|
119
|
+
else if (existingIdentity === 'missing')
|
|
120
|
+
warnings.push(`identity '${request.name}' will be created and bound by the harness on first boot`);
|
|
121
|
+
else
|
|
122
|
+
warnings.push('identity existence could not be verified; first-boot creation may be required');
|
|
123
|
+
if (warnings.some(warning => /elevated|outside/.test(warning)) && !request.highRiskAcknowledged)
|
|
124
|
+
throw new FleetError('invalid_request', 'high-risk permissions require explicit acknowledgment');
|
|
125
|
+
const capabilities = await this.capabilities();
|
|
126
|
+
const prerequisites = [...capabilities.reasons];
|
|
127
|
+
if (existingIdentity === 'verified' && !request.reuseExistingIdentityAcknowledged)
|
|
128
|
+
prerequisites.push('confirm reuse of the existing local identity');
|
|
129
|
+
if (existingIdentity === 'unknown' && !request.unverifiedIdentityAcknowledged)
|
|
130
|
+
prerequisites.push('confirm creation with an unverified identity preflight');
|
|
131
|
+
const provenance = {
|
|
132
|
+
harness: 'request', session: 'request', identity: 'built-in',
|
|
133
|
+
model: request.model !== undefined ? 'request'
|
|
134
|
+
: resolveRoleModel(undefined, request.harness, defaults) ? 'fleet-default' : 'built-in',
|
|
135
|
+
cwd: request.cwd ? 'request' : 'built-in', permissions: 'request',
|
|
136
|
+
monitor: request.monitor ? 'request' : defaults.monitor ? 'fleet-default' : 'built-in',
|
|
137
|
+
};
|
|
138
|
+
const fingerprint = this.configFingerprint(cfg.files);
|
|
139
|
+
const identityBootstrap = {
|
|
140
|
+
existingIdentity, derivedIdentity: request.name,
|
|
141
|
+
mode: 'current-fleet-first-boot',
|
|
142
|
+
bindingEvidence: 'not-structured',
|
|
143
|
+
};
|
|
144
|
+
const previewHash = hash({ request, effective, identityBootstrap, fingerprint });
|
|
145
|
+
return {
|
|
146
|
+
request, effective, provenance, warnings, prerequisites,
|
|
147
|
+
identityBootstrap, previewHash,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
async create(input, previewHash, idempotencyKey, browserSession) {
|
|
151
|
+
if (!/^[A-Za-z0-9_-]{22,256}$/.test(idempotencyKey))
|
|
152
|
+
throw new FleetError('invalid_request', 'Idempotency-Key must contain at least 128 bits');
|
|
153
|
+
const requestHash = hash(this.validate(input));
|
|
154
|
+
const key = hash(`${browserSession}\0${idempotencyKey}`);
|
|
155
|
+
const existing = this.idempotency.get(key);
|
|
156
|
+
if (existing) {
|
|
157
|
+
if (existing.requestHash !== requestHash)
|
|
158
|
+
throw new FleetError('idempotency_conflict', 'Idempotency-Key was already used for another request');
|
|
159
|
+
return this.actions.get(existing.actionId);
|
|
160
|
+
}
|
|
161
|
+
const preview = await this.preview(input);
|
|
162
|
+
if (preview.previewHash !== previewHash)
|
|
163
|
+
throw new FleetError('stale_state', 'preview is stale; review the effective plan again');
|
|
164
|
+
if (preview.prerequisites.length)
|
|
165
|
+
throw new FleetError('prerequisite_unavailable', preview.prerequisites.join('; '));
|
|
166
|
+
const capabilities = await this.capabilities();
|
|
167
|
+
if (!capabilities.available)
|
|
168
|
+
throw new FleetError('prerequisite_unavailable', capabilities.reasons.join('; '));
|
|
169
|
+
if (this.inFlight.has(preview.effective.name))
|
|
170
|
+
throw new FleetError('conflict', `role '${preview.effective.name}' is already being created`);
|
|
171
|
+
const now = new Date().toISOString();
|
|
172
|
+
const action = {
|
|
173
|
+
actionId: randomUUID(), requestHash, roleId: preview.effective.name,
|
|
174
|
+
session: preview.effective.session, lifetime: preview.effective.lifetime,
|
|
175
|
+
state: 'validating', stages: [{ stage: 'validating', at: now }],
|
|
176
|
+
createdAt: now, updatedAt: now,
|
|
177
|
+
idempotencyHash: key,
|
|
178
|
+
identityBindingEvidence: 'not-structured',
|
|
179
|
+
};
|
|
180
|
+
this.idempotency.set(key, { requestHash, actionId: action.actionId });
|
|
181
|
+
this.actions.set(action.actionId, action);
|
|
182
|
+
this.persist(action);
|
|
183
|
+
this.inFlight.add(action.roleId);
|
|
184
|
+
void this.run(action, preview).finally(() => this.inFlight.delete(action.roleId));
|
|
185
|
+
return action;
|
|
186
|
+
}
|
|
187
|
+
get(actionId) { return this.actions.get(actionId); }
|
|
188
|
+
async run(action, preview) {
|
|
189
|
+
try {
|
|
190
|
+
const latestIdentity = await this.checkIdentity(action.roleId);
|
|
191
|
+
if (latestIdentity === 'verified'
|
|
192
|
+
&& preview.identityBootstrap.existingIdentity !== 'verified'
|
|
193
|
+
&& !preview.request.reuseExistingIdentityAcknowledged) {
|
|
194
|
+
throw new FleetError('stale_state', `identity '${action.roleId}' appeared after preview; review and confirm reuse`);
|
|
195
|
+
}
|
|
196
|
+
const creation = {
|
|
197
|
+
// Deliberately strip any mutation methods from the test seam. The web
|
|
198
|
+
// uses today's read-only daemon preflight; first-boot owns create/bind.
|
|
199
|
+
identityProvisioner: { exists: name => this.identityProvisioner.exists(name) },
|
|
200
|
+
onStage: (stage, evidence) => this.coreStage(action, stage, evidence),
|
|
201
|
+
};
|
|
202
|
+
if (preview.effective.lifetime === 'permanent') {
|
|
203
|
+
await spawnPermanent(this.spawnOptions(preview.request, action.actionId), this.options.ops, creation);
|
|
204
|
+
}
|
|
205
|
+
else {
|
|
206
|
+
await spawnTemp(this.spawnOptions(preview.request, action.actionId), this.options.binPath, this.options.tempLauncher, creation);
|
|
207
|
+
}
|
|
208
|
+
this.stage(action, 'launched', 'launch accepted; readiness not yet confirmed');
|
|
209
|
+
this.stage(action, 'identity_bootstrap_pending', 'the harness must choose or create and bind its identity from the generated briefing');
|
|
210
|
+
this.stage(action, 'waiting_for_session');
|
|
211
|
+
const ready = await this.waitForReady(action.roleId, action.session);
|
|
212
|
+
if (ready === 'ready') {
|
|
213
|
+
action.openPath = action.session === 'acp'
|
|
214
|
+
? `/roles/${encodeURIComponent(action.roleId)}/activity`
|
|
215
|
+
: `/roles/${encodeURIComponent(action.roleId)}/terminal`;
|
|
216
|
+
this.stage(action, 'session_reachable', 'session is reachable; identity binding has no structured evidence in this version');
|
|
217
|
+
}
|
|
218
|
+
else if (ready === 'attention') {
|
|
219
|
+
action.openPath = `/roles/${encodeURIComponent(action.roleId)}`;
|
|
220
|
+
this.stage(action, 'attention', 'session evidence needs attention; first-boot identity binding remains unconfirmed');
|
|
221
|
+
}
|
|
222
|
+
else {
|
|
223
|
+
action.openPath = `/roles/${encodeURIComponent(action.roleId)}`;
|
|
224
|
+
this.stage(action, 'launched_unconfirmed', 'launch committed; session readiness timed out');
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
catch (error) {
|
|
228
|
+
const fleetError = normalizeError(error);
|
|
229
|
+
const rollbackIncomplete = /rollback also failed/i.test(fleetError.message);
|
|
230
|
+
action.error = new FleetError(rollbackIncomplete ? 'rollback_incomplete' : fleetError.code, fleetError.message, { retryable: fleetError.retryable, provesOffline: fleetError.provesOffline }).toJSON();
|
|
231
|
+
this.stage(action, rollbackIncomplete ? 'rollback_incomplete' : 'failed');
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
async waitForReady(role, session) {
|
|
235
|
+
if (!this.options.probeReady)
|
|
236
|
+
return 'unknown';
|
|
237
|
+
for (let attempt = 0; attempt < 20; attempt++) {
|
|
238
|
+
const state = await this.options.probeReady(role, session).catch(() => 'unknown');
|
|
239
|
+
if (state !== 'unknown')
|
|
240
|
+
return state;
|
|
241
|
+
await new Promise(resolve => setTimeout(resolve, 250));
|
|
242
|
+
}
|
|
243
|
+
return 'unknown';
|
|
244
|
+
}
|
|
245
|
+
validate(input) {
|
|
246
|
+
if (!ROLE_NAME_RE.test(input.name))
|
|
247
|
+
throw new FleetError('invalid_request', 'invalid role name');
|
|
248
|
+
if (!['codex', 'claude-code'].includes(input.harness))
|
|
249
|
+
throw new FleetError('invalid_request', 'unsupported harness');
|
|
250
|
+
if (!['acp', 'tmux'].includes(input.session))
|
|
251
|
+
throw new FleetError('invalid_request', 'unsupported session backend');
|
|
252
|
+
if (!['permanent', 'temporary'].includes(input.lifetime))
|
|
253
|
+
throw new FleetError('invalid_request', 'unsupported lifetime');
|
|
254
|
+
bounded(input.model ?? undefined, 'model', 128);
|
|
255
|
+
bounded(input.mission, 'mission', 4_096);
|
|
256
|
+
bounded(input.coordinator, 'coordinator', 128);
|
|
257
|
+
bounded(input.bio, 'bio', 8_192);
|
|
258
|
+
bounded(input.persona, 'persona', 16_384);
|
|
259
|
+
resolvePermissions(undefined, input.permissions);
|
|
260
|
+
if (input.monitor) {
|
|
261
|
+
const problems = validateMonitorConfig(input.monitor);
|
|
262
|
+
if (problems.length)
|
|
263
|
+
throw new FleetError('invalid_request', problems.join('; '));
|
|
264
|
+
if ('inject' in input.monitor && input.monitor.inject !== 'notification')
|
|
265
|
+
throw new FleetError('invalid_request', 'web creation supports monitor.inject=notification only');
|
|
266
|
+
}
|
|
267
|
+
return {
|
|
268
|
+
...input, model: input.model === null ? null : input.model?.trim() || undefined,
|
|
269
|
+
mission: input.mission?.trim() || undefined, coordinator: input.coordinator?.trim() || undefined,
|
|
270
|
+
bio: input.bio?.trim() || undefined, persona: input.persona?.trim() || undefined,
|
|
271
|
+
monitor: input.monitor ? structuredClone(input.monitor) : undefined,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
resolveCwd(path) {
|
|
275
|
+
if (!isAbsolute(path))
|
|
276
|
+
throw new FleetError('invalid_request', 'cwd must be absolute');
|
|
277
|
+
let canonicalPath;
|
|
278
|
+
try {
|
|
279
|
+
canonicalPath = realpathSync(path);
|
|
280
|
+
if (!statSync(canonicalPath).isDirectory())
|
|
281
|
+
throw new Error('not a directory');
|
|
282
|
+
}
|
|
283
|
+
catch (error) {
|
|
284
|
+
throw new FleetError('invalid_request', `cwd must be an existing directory: ${error.message}`);
|
|
285
|
+
}
|
|
286
|
+
const roots = this.options.allowedCwdRoots ?? [realpathSync(process.env.OURS_FLEET_HOME ?? process.cwd())];
|
|
287
|
+
if (!roots.some(root => {
|
|
288
|
+
const rel = relative(realpathSync(root), canonicalPath);
|
|
289
|
+
return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
|
|
290
|
+
}))
|
|
291
|
+
throw new FleetError('forbidden', 'cwd is outside configured roots');
|
|
292
|
+
return canonicalPath;
|
|
293
|
+
}
|
|
294
|
+
spawnOptions(request, creationActionId) {
|
|
295
|
+
return {
|
|
296
|
+
name: request.name, identity: request.name, harness: request.harness,
|
|
297
|
+
model: request.model, session: request.session, cwd: request.cwd,
|
|
298
|
+
mission: request.mission, coordinator: request.coordinator,
|
|
299
|
+
approval: request.permissions.approval, filesystem: request.permissions.filesystem,
|
|
300
|
+
unattended: request.permissions.unattended, bio: request.bio, persona: request.persona,
|
|
301
|
+
monitorConfig: request.monitor,
|
|
302
|
+
configPath: this.options.configPath,
|
|
303
|
+
surface: creationActionId ? 'web' : undefined, creationActionId,
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
async checkIdentity(name) {
|
|
307
|
+
try {
|
|
308
|
+
const present = await this.identityProvisioner.exists(name);
|
|
309
|
+
return present === true ? 'verified' : present === false ? 'missing' : 'unknown';
|
|
310
|
+
}
|
|
311
|
+
catch {
|
|
312
|
+
return 'unknown';
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
coreStage(action, stage, evidence) {
|
|
316
|
+
if (stage === 'checking_identity' && evidence?.result) {
|
|
317
|
+
action.identityCheck = evidence.result;
|
|
318
|
+
this.stage(action, stage, `${evidence.result}; host guarantee ${String(evidence.guarantee ?? 'unverified')}`);
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
this.stage(action, stage);
|
|
322
|
+
}
|
|
323
|
+
configFingerprint(files) {
|
|
324
|
+
return files.map(file => {
|
|
325
|
+
const stat = statSync(file);
|
|
326
|
+
return { file, mtimeMs: stat.mtimeMs, size: stat.size };
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
stage(action, stage, detail) {
|
|
330
|
+
action.state = stage;
|
|
331
|
+
action.updatedAt = new Date().toISOString();
|
|
332
|
+
const previous = action.stages.at(-1);
|
|
333
|
+
if (previous?.stage === stage) {
|
|
334
|
+
previous.at = action.updatedAt;
|
|
335
|
+
previous.detail = detail ?? previous.detail;
|
|
336
|
+
}
|
|
337
|
+
else {
|
|
338
|
+
action.stages.push({ stage, at: action.updatedAt, detail });
|
|
339
|
+
}
|
|
340
|
+
this.persist(action);
|
|
341
|
+
this.options.onProgress?.(structuredClone(action));
|
|
342
|
+
}
|
|
343
|
+
persist(action) {
|
|
344
|
+
replaceFileAtomically(`${this.journalDir}/${action.actionId}.json`, JSON.stringify(action, null, 2) + '\n', 0o600);
|
|
345
|
+
}
|
|
346
|
+
restore() {
|
|
347
|
+
try {
|
|
348
|
+
for (const file of readdirSync(this.journalDir).filter(name => /^[0-9a-f-]+\.json$/.test(name))) {
|
|
349
|
+
try {
|
|
350
|
+
const action = JSON.parse(readFileSync(`${this.journalDir}/${file}`, 'utf8'));
|
|
351
|
+
action.identityBindingEvidence ??= 'not-structured';
|
|
352
|
+
if (![
|
|
353
|
+
'session_reachable', 'attention', 'launched_unconfirmed',
|
|
354
|
+
'failed', 'rollback_incomplete',
|
|
355
|
+
].includes(action.state)) {
|
|
356
|
+
action.state = 'launched_unconfirmed';
|
|
357
|
+
action.updatedAt = new Date().toISOString();
|
|
358
|
+
action.stages.push({
|
|
359
|
+
stage: 'launched_unconfirmed', at: action.updatedAt,
|
|
360
|
+
detail: 'server restarted; artifacts require reconciliation',
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
this.actions.set(action.actionId, action);
|
|
364
|
+
if (action.idempotencyHash)
|
|
365
|
+
this.idempotency.set(action.idempotencyHash, {
|
|
366
|
+
requestHash: action.requestHash, actionId: action.actionId,
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
catch { /* isolate corrupt journal entry */ }
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
catch { /* no journal yet */ }
|
|
373
|
+
}
|
|
374
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { RoleRecord } from './types.js';
|
|
2
|
+
export interface RoleRepositoryOptions {
|
|
3
|
+
configPath?: string;
|
|
4
|
+
permanentRoot?: string;
|
|
5
|
+
temporaryRoot?: string;
|
|
6
|
+
probeBackend?: (name: string, intended?: 'acp' | 'tmux') => Promise<{
|
|
7
|
+
acp: boolean;
|
|
8
|
+
tmux: boolean;
|
|
9
|
+
}>;
|
|
10
|
+
concurrency?: number;
|
|
11
|
+
timeoutMs?: number;
|
|
12
|
+
}
|
|
13
|
+
export declare class RoleRepository {
|
|
14
|
+
private readonly options;
|
|
15
|
+
constructor(options?: RoleRepositoryOptions);
|
|
16
|
+
list(): Promise<RoleRecord[]>;
|
|
17
|
+
get(id: string): Promise<RoleRecord | undefined>;
|
|
18
|
+
stateDir(record: RoleRecord): string | undefined;
|
|
19
|
+
private detect;
|
|
20
|
+
}
|