@ours.network/fleet 0.10.2 → 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/acp.js +5 -1
- 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
package/dist/spawn.js
CHANGED
|
@@ -4,18 +4,33 @@ import { join } from 'node:path';
|
|
|
4
4
|
import { parse, stringify } from 'yaml';
|
|
5
5
|
import { agentDir, fleetDDir } from './paths.js';
|
|
6
6
|
import { validateIsolationConfig } from './isolation/policy.js';
|
|
7
|
-
import { loadConfig, resolveAuthProxy, resolveModelChain, resolveMonitorConfig, resolvePermissions, resolveWorklogPolicy, } from './config.js';
|
|
7
|
+
import { loadConfig, resolveAuthProxy, resolveModelChain, resolveMonitorConfig, resolvePermissions, resolveRoleModel, resolveWorklogPolicy, validateMonitorConfig, } from './config.js';
|
|
8
8
|
import { applyRole, up } from './ops.js';
|
|
9
9
|
import { START_STAGGER_FILE } from './runner.js';
|
|
10
10
|
import { buildProvenance, daemonIdentityProvisioner, ensureIdentity, provenanceOf, withCreationTransaction, writeProvenance, writeRoleFile, } from './creation.js';
|
|
11
11
|
import { VERSION } from './version.js';
|
|
12
|
+
import './harness/claude-code.js';
|
|
13
|
+
import './harness/codex.js';
|
|
12
14
|
import { getAdapter } from './harness/registry.js';
|
|
13
15
|
/**
|
|
14
16
|
* The provenance record written by the most recent spawn in this process, so
|
|
15
17
|
* the CLI can print the same summary it persisted rather than rebuilding it.
|
|
16
18
|
*/
|
|
17
19
|
export let lastProvenance;
|
|
18
|
-
function
|
|
20
|
+
export function profileValues(o) {
|
|
21
|
+
if (o.bio !== undefined && o.bioFile)
|
|
22
|
+
throw new Error('bio and bioFile are mutually exclusive');
|
|
23
|
+
if (o.persona !== undefined && o.personaFile)
|
|
24
|
+
throw new Error('persona and personaFile are mutually exclusive');
|
|
25
|
+
return {
|
|
26
|
+
bio: o.bio !== undefined ? o.bio.trim()
|
|
27
|
+
: o.bioFile ? readFileSync(o.bioFile, 'utf8').trim() : undefined,
|
|
28
|
+
persona: o.persona !== undefined ? o.persona.trim()
|
|
29
|
+
: o.personaFile ? readFileSync(o.personaFile, 'utf8').trim() : undefined,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
/** Pure option-to-role mapping shared by CLI and application services. */
|
|
33
|
+
export function buildRoleConfig(o, defaultHarness) {
|
|
19
34
|
const r = {};
|
|
20
35
|
if (o.harness)
|
|
21
36
|
r.harness = o.harness;
|
|
@@ -31,7 +46,9 @@ function roleFromOpts(o, defaultHarness) {
|
|
|
31
46
|
r.mission = readMissionFile(o.missionFile);
|
|
32
47
|
else if (o.mission !== undefined)
|
|
33
48
|
r.mission = o.mission;
|
|
34
|
-
if (o.model
|
|
49
|
+
if (o.model === null)
|
|
50
|
+
r.model = null;
|
|
51
|
+
else if (o.model?.trim())
|
|
35
52
|
r.model = o.model.trim();
|
|
36
53
|
const harness = o.harness ?? defaultHarness;
|
|
37
54
|
const harnessOptions = {};
|
|
@@ -60,12 +77,15 @@ function roleFromOpts(o, defaultHarness) {
|
|
|
60
77
|
...(o.unattended ? { unattended: o.unattended } : {}),
|
|
61
78
|
};
|
|
62
79
|
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
80
|
+
const profile = profileValues(o);
|
|
81
|
+
if (profile.bio)
|
|
82
|
+
r.bio = profile.bio;
|
|
83
|
+
if (profile.persona)
|
|
84
|
+
r.persona = profile.persona;
|
|
67
85
|
if (o.isolationFile)
|
|
68
86
|
r.isolation = readIsolationFile(o.isolationFile);
|
|
87
|
+
if (o.monitorConfig)
|
|
88
|
+
r.monitor = { ...o.monitorConfig };
|
|
69
89
|
return r;
|
|
70
90
|
}
|
|
71
91
|
/**
|
|
@@ -93,7 +113,7 @@ export function readIsolationFile(path) {
|
|
|
93
113
|
throw new Error(`--isolation-file ${path}: ${problems.join('; ')}`);
|
|
94
114
|
return cfg;
|
|
95
115
|
}
|
|
96
|
-
function validateSpawnOpts(o) {
|
|
116
|
+
export function validateSpawnOpts(o) {
|
|
97
117
|
if (o.mission !== undefined && o.missionFile)
|
|
98
118
|
throw new Error('--mission and --mission-file are mutually exclusive');
|
|
99
119
|
if (o.session && !['tmux', 'acp'].includes(o.session))
|
|
@@ -104,6 +124,17 @@ function validateSpawnOpts(o) {
|
|
|
104
124
|
throw new Error(`invalid --filesystem '${o.filesystem}'; allowed: read-only, workspace, unrestricted`);
|
|
105
125
|
if (o.unattended && !['deny', 'wait'].includes(o.unattended))
|
|
106
126
|
throw new Error(`invalid --unattended '${o.unattended}'; allowed: deny, wait`);
|
|
127
|
+
if (!/^[A-Za-z0-9_-]+$/.test(o.name))
|
|
128
|
+
throw new Error(`invalid role name '${o.name}'`);
|
|
129
|
+
if (o.identity && !/^[A-Za-z0-9_-]+$/.test(o.identity))
|
|
130
|
+
throw new Error(`invalid identity name '${o.identity}'`);
|
|
131
|
+
if (o.model && (o.model.length > 128 || /[\0-\x1f\x7f]/.test(o.model)))
|
|
132
|
+
throw new Error('model must be printable and at most 128 characters');
|
|
133
|
+
if (o.monitorConfig) {
|
|
134
|
+
const problems = validateMonitorConfig(o.monitorConfig);
|
|
135
|
+
if (problems.length)
|
|
136
|
+
throw new Error(problems.join('; '));
|
|
137
|
+
}
|
|
107
138
|
}
|
|
108
139
|
/** Read mission text without trimming or newline rewriting. */
|
|
109
140
|
export function readMissionFile(path) {
|
|
@@ -146,23 +177,29 @@ export function spawnDryRun(o) {
|
|
|
146
177
|
readMissionFile(o.missionFile);
|
|
147
178
|
assertNameFree(o);
|
|
148
179
|
const cfg = loadConfig(o.configPath);
|
|
149
|
-
const raw =
|
|
180
|
+
const raw = buildRoleConfig(o, cfg.defaults.harness);
|
|
150
181
|
const harnessOptions = {
|
|
151
182
|
...(cfg.defaults.harness_options ?? {}),
|
|
152
183
|
...(raw.harness_options ?? {}),
|
|
153
184
|
};
|
|
185
|
+
const harness = raw.harness ?? cfg.defaults.harness ?? 'claude-code';
|
|
186
|
+
const defaultHarness = cfg.defaults.harness ?? 'claude-code';
|
|
187
|
+
const inheritsModelDefaults = harness === defaultHarness && raw.model !== null;
|
|
188
|
+
const model = resolveRoleModel(raw.model, raw.harness, cfg.defaults);
|
|
154
189
|
const resolvedRole = {
|
|
155
190
|
...raw,
|
|
156
191
|
name: o.name,
|
|
157
192
|
sourceFile: o.temp ? '(temp dry-run)' : join(fleetDDir(), `${o.name}.yaml`),
|
|
158
|
-
harness
|
|
193
|
+
harness,
|
|
159
194
|
session: raw.session ?? cfg.defaults.session ?? 'tmux',
|
|
160
195
|
session_options: raw.session_options,
|
|
161
196
|
permissions: resolvePermissions(cfg.defaults.permissions, raw.permissions),
|
|
162
197
|
permissionsDeclared: raw.permissions !== undefined || cfg.defaults.permissions !== undefined,
|
|
163
198
|
identity: effectiveIdentity(o),
|
|
164
|
-
model
|
|
165
|
-
model_chain: resolveModelChain(
|
|
199
|
+
model,
|
|
200
|
+
model_chain: resolveModelChain(model, raw.model_chain ?? (inheritsModelDefaults
|
|
201
|
+
? cfg.defaults.model_chain
|
|
202
|
+
: undefined)),
|
|
166
203
|
harness_options: Object.keys(harnessOptions).length ? harnessOptions : undefined,
|
|
167
204
|
isolation: raw.isolation ?? cfg.defaults.isolation,
|
|
168
205
|
monitor: resolveMonitorConfig(cfg.defaults.monitor, raw.monitor),
|
|
@@ -199,6 +236,8 @@ export function spawnDryRun(o) {
|
|
|
199
236
|
*/
|
|
200
237
|
function provenanceSettings(o, defaults) {
|
|
201
238
|
const perms = (defaults.permissions ?? {});
|
|
239
|
+
const explicitModel = typeof o.model === 'string' ? o.model.trim() : undefined;
|
|
240
|
+
const inheritedModel = resolveRoleModel(undefined, o.harness, defaults);
|
|
202
241
|
return {
|
|
203
242
|
harness: provenanceOf(o.harness, defaults.harness, 'claude-code'),
|
|
204
243
|
session: provenanceOf(o.session, defaults.session, 'tmux'),
|
|
@@ -206,7 +245,11 @@ function provenanceSettings(o, defaults) {
|
|
|
206
245
|
? { value: o.identity, source: 'cli' }
|
|
207
246
|
: { value: o.name, source: 'built-in' }, // defaults to the role name
|
|
208
247
|
cwd: provenanceOf(o.cwd, undefined, undefined),
|
|
209
|
-
model:
|
|
248
|
+
model: o.model === null
|
|
249
|
+
? { value: undefined, source: 'cli' }
|
|
250
|
+
: explicitModel
|
|
251
|
+
? { value: explicitModel, source: 'cli' }
|
|
252
|
+
: { value: inheritedModel, source: inheritedModel ? 'fleet-default' : 'built-in' },
|
|
210
253
|
coordinator: provenanceOf(o.coordinator, undefined, undefined),
|
|
211
254
|
approval: provenanceOf(o.approval, perms.approval, 'ask'),
|
|
212
255
|
filesystem: provenanceOf(o.filesystem, perms.filesystem, 'workspace'),
|
|
@@ -214,6 +257,7 @@ function provenanceSettings(o, defaults) {
|
|
|
214
257
|
isolation: o.isolationFile
|
|
215
258
|
? { value: 'declared via --isolation-file', source: 'cli' }
|
|
216
259
|
: { value: defaults.isolation ? 'from fleet defaults' : undefined, source: defaults.isolation ? 'fleet-default' : 'built-in' },
|
|
260
|
+
monitor: provenanceOf(o.monitorConfig, defaults.monitor, { mode: 'fleet' }),
|
|
217
261
|
};
|
|
218
262
|
}
|
|
219
263
|
/** Permanent spawn: persist to ~/fleet.d/<Name>.yaml, then bring it up. */
|
|
@@ -225,13 +269,17 @@ export async function spawnPermanent(o, deps, creation = {}) {
|
|
|
225
269
|
readMissionFile(o.missionFile); // fail before reserving
|
|
226
270
|
// Name AND identity reserved together, before anything is written or started
|
|
227
271
|
// (6.4). A loser of the race creates no config, no state, no service.
|
|
272
|
+
creation.onStage?.('reserving');
|
|
228
273
|
return withCreationTransaction({ role: o.name, identity: effectiveIdentity(o) }, async (tx) => {
|
|
229
274
|
assertNameFree(o);
|
|
230
275
|
const cfg = loadConfig(o.configPath);
|
|
231
276
|
// Establish the identity BEFORE the service is enabled (7.3), and record
|
|
232
277
|
// what was actually guaranteed so the briefing can say something true.
|
|
233
|
-
|
|
234
|
-
|
|
278
|
+
creation.onStage?.('checking_identity');
|
|
279
|
+
const guarantee = await ensureIdentity(effectiveIdentity(o), profileValues(o), creation.identityProvisioner ?? daemonIdentityProvisioner(), deps.log);
|
|
280
|
+
creation.onStage?.('checking_identity', {
|
|
281
|
+
result: guarantee.evidence, guarantee: guarantee.state,
|
|
282
|
+
});
|
|
235
283
|
if (guarantee.state === 'created')
|
|
236
284
|
// We minted it; a failed creation must not leave an orphan identity
|
|
237
285
|
// behind. Only ever removes an identity THIS transaction created.
|
|
@@ -242,9 +290,10 @@ export async function spawnPermanent(o, deps, creation = {}) {
|
|
|
242
290
|
},
|
|
243
291
|
});
|
|
244
292
|
mkdirSync(fleetDDir(), { recursive: true });
|
|
293
|
+
creation.onStage?.('writing_role');
|
|
245
294
|
const file = join(fleetDDir(), `${o.name}.yaml`);
|
|
246
295
|
writeRoleFile(tx, file, stringify({
|
|
247
|
-
roles: { [o.name]:
|
|
296
|
+
roles: { [o.name]: buildRoleConfig(o, cfg.defaults.harness) },
|
|
248
297
|
}));
|
|
249
298
|
// `up` materialises the state dir and registers the service. Journal the
|
|
250
299
|
// dir before it exists so a failure leaves the name genuinely reusable
|
|
@@ -272,9 +321,11 @@ export async function spawnPermanent(o, deps, creation = {}) {
|
|
|
272
321
|
const provenance = buildProvenance({
|
|
273
322
|
role: o.name, lifetime: 'permanent', fleetVersion: VERSION,
|
|
274
323
|
settings: provenanceSettings(o, cfg.defaults),
|
|
324
|
+
surface: o.surface, creationActionId: o.creationActionId,
|
|
275
325
|
});
|
|
276
326
|
mkdirSync(agentDir(o.name), { recursive: true });
|
|
277
327
|
writeProvenance(agentDir(o.name), provenance);
|
|
328
|
+
creation.onStage?.('registering_supervisor');
|
|
278
329
|
await up(loadConfig(o.configPath), [o.name], { ...deps, onInstalled: outcome => registered.push(outcome.role) }, o.configPath, guarantee.state);
|
|
279
330
|
lastProvenance = provenance;
|
|
280
331
|
return file;
|
|
@@ -298,29 +349,45 @@ export async function spawnTemp(o, binPath, launch = detachedSupervisor, creatio
|
|
|
298
349
|
readMissionFile(o.missionFile); // fail before reserving
|
|
299
350
|
// Temporary roles go through the SAME reservation boundary as permanent ones
|
|
300
351
|
// (6.4): a temp agent competes for the same names.
|
|
352
|
+
creation.onStage?.('reserving');
|
|
301
353
|
return withCreationTransaction({ role: o.name, identity: effectiveIdentity(o) }, async (tx) => {
|
|
354
|
+
creation.onStage?.('checking_identity');
|
|
302
355
|
assertNameFree(o);
|
|
303
|
-
const guarantee = await ensureIdentity(effectiveIdentity(o),
|
|
304
|
-
|
|
305
|
-
|
|
356
|
+
const guarantee = await ensureIdentity(effectiveIdentity(o), profileValues(o), creation.identityProvisioner ?? daemonIdentityProvisioner(), creation.log);
|
|
357
|
+
creation.onStage?.('checking_identity', {
|
|
358
|
+
result: guarantee.evidence, guarantee: guarantee.state,
|
|
359
|
+
});
|
|
360
|
+
if (guarantee.state === 'created')
|
|
361
|
+
tx.record({
|
|
362
|
+
stage: `ours identity ${effectiveIdentity(o)}`,
|
|
363
|
+
undo: async () => {
|
|
364
|
+
await creation.identityProvisioner?.remove?.(effectiveIdentity(o));
|
|
365
|
+
},
|
|
366
|
+
});
|
|
367
|
+
return spawnTempInner(o, binPath, launch, tx, guarantee, creation.onStage);
|
|
306
368
|
}, creation);
|
|
307
369
|
}
|
|
308
|
-
async function spawnTempInner(o, binPath, launch, tx, guarantee) {
|
|
370
|
+
async function spawnTempInner(o, binPath, launch, tx, guarantee, onStage) {
|
|
309
371
|
const cfg = loadConfig(o.configPath);
|
|
310
372
|
const defaultHarness = cfg.defaults.harness;
|
|
311
|
-
const fromOpts =
|
|
373
|
+
const fromOpts = buildRoleConfig(o, defaultHarness);
|
|
312
374
|
const mergedHarnessOptions = {
|
|
313
375
|
...(cfg.defaults.harness_options ?? {}),
|
|
314
376
|
...(fromOpts.harness_options ?? {}),
|
|
315
377
|
};
|
|
378
|
+
const harness = o.harness ?? defaultHarness ?? 'claude-code';
|
|
379
|
+
const inheritsModelDefaults = harness === (defaultHarness ?? 'claude-code') && o.model !== null;
|
|
380
|
+
const model = resolveRoleModel(o.model, o.harness, cfg.defaults);
|
|
316
381
|
const role = {
|
|
317
382
|
...fromOpts, // includes `isolation` when --isolation-file was given
|
|
318
383
|
name: o.name,
|
|
319
|
-
harness
|
|
384
|
+
harness,
|
|
320
385
|
session: o.session ?? cfg.defaults.session ?? 'tmux',
|
|
321
386
|
identity: o.identity ?? o.name,
|
|
322
|
-
model
|
|
323
|
-
model_chain: resolveModelChain(
|
|
387
|
+
model,
|
|
388
|
+
model_chain: resolveModelChain(model, fromOpts.model_chain ?? (inheritsModelDefaults
|
|
389
|
+
? cfg.defaults.model_chain
|
|
390
|
+
: undefined)),
|
|
324
391
|
harness_options: Object.keys(mergedHarnessOptions).length ? mergedHarnessOptions : undefined,
|
|
325
392
|
permissions: resolvePermissions(cfg.defaults.permissions, fromOpts.permissions),
|
|
326
393
|
permissionsDeclared: fromOpts.permissions !== undefined || cfg.defaults.permissions !== undefined,
|
|
@@ -337,10 +404,12 @@ async function spawnTempInner(o, binPath, launch, tx, guarantee) {
|
|
|
337
404
|
};
|
|
338
405
|
if (role.auth_proxy && role.harness !== 'claude-code')
|
|
339
406
|
throw new Error('auth_proxy is supported only by claude-code');
|
|
407
|
+
onStage?.('writing_role');
|
|
340
408
|
const dir = applyRole(role, { temp: true, identityGuarantee: guarantee.state });
|
|
341
409
|
const provenance = buildProvenance({
|
|
342
410
|
role: o.name, lifetime: 'temporary', fleetVersion: VERSION,
|
|
343
411
|
settings: provenanceSettings(o, cfg.defaults),
|
|
412
|
+
surface: o.surface, creationActionId: o.creationActionId,
|
|
344
413
|
});
|
|
345
414
|
writeProvenance(dir, provenance);
|
|
346
415
|
lastProvenance = provenance;
|
|
@@ -356,6 +425,7 @@ async function spawnTempInner(o, binPath, launch, tx, guarantee) {
|
|
|
356
425
|
// agent itself; a supervisor sharing that session name would SIGHUP its own
|
|
357
426
|
// process before the agent ever launches. Detaching mirrors how systemd hosts
|
|
358
427
|
// the supervisor for permanent roles, leaving runOnce to own the <name> session.
|
|
428
|
+
onStage?.('starting_temp');
|
|
359
429
|
launch(binPath, ['_run-temp', o.name], dir);
|
|
360
430
|
return dir;
|
|
361
431
|
}
|
|
@@ -177,6 +177,23 @@ export function makeLaunchdBackend(exec = realExec, uid = process.getuid?.() ??
|
|
|
177
177
|
return { state: 'stopped', detail: `not loaded (${labelFor(name)})` };
|
|
178
178
|
return { state: 'unknown', detail: job.failure ?? `launchctl print ${labelFor(name)} failed` };
|
|
179
179
|
},
|
|
180
|
+
async inspect(name) {
|
|
181
|
+
const job = await printJob(name);
|
|
182
|
+
if (job.loaded)
|
|
183
|
+
return {
|
|
184
|
+
backend: 'launchd', state: 'running',
|
|
185
|
+
nativeState: job.state, detail: job.state ? `loaded (state = ${job.state})` : 'loaded',
|
|
186
|
+
};
|
|
187
|
+
if (job.notFound)
|
|
188
|
+
return {
|
|
189
|
+
backend: 'launchd', state: 'stopped',
|
|
190
|
+
nativeState: 'not-loaded', detail: `not loaded (${labelFor(name)})`,
|
|
191
|
+
};
|
|
192
|
+
return {
|
|
193
|
+
backend: 'launchd', state: 'unknown',
|
|
194
|
+
detail: job.failure ?? `launchctl print ${labelFor(name)} failed`,
|
|
195
|
+
};
|
|
196
|
+
},
|
|
180
197
|
async uninstall(name) {
|
|
181
198
|
const existed = existsSync(plistPath(name));
|
|
182
199
|
await exec('launchctl', ['bootout', `${domain}/${labelFor(name)}`]); // idempotent
|
package/dist/supervisor/none.js
CHANGED
|
@@ -31,6 +31,23 @@ export function makeNoneBackend(exec = realExec) {
|
|
|
31
31
|
return { state: 'stopped', detail: `no tmux session '${name}'` };
|
|
32
32
|
return { state: 'unknown', detail: `tmux has-session '${name}' failed (${r.code}): ${r.stderr.trim() || 'no output'}` };
|
|
33
33
|
},
|
|
34
|
+
async inspect(name) {
|
|
35
|
+
const r = await exec('tmux', tmuxArgs(name, ['has-session', '-t', name]));
|
|
36
|
+
if (r.code === 0)
|
|
37
|
+
return {
|
|
38
|
+
backend: 'none', state: 'running',
|
|
39
|
+
nativeState: 'tmux-present', detail: `tmux session '${name}' exists`,
|
|
40
|
+
};
|
|
41
|
+
if (r.code === 1)
|
|
42
|
+
return {
|
|
43
|
+
backend: 'none', state: 'stopped',
|
|
44
|
+
nativeState: 'tmux-absent', detail: `no tmux session '${name}'`,
|
|
45
|
+
};
|
|
46
|
+
return {
|
|
47
|
+
backend: 'none', state: 'unknown',
|
|
48
|
+
detail: `tmux has-session '${name}' failed (${r.code}): ${r.stderr.trim() || 'no output'}`,
|
|
49
|
+
};
|
|
50
|
+
},
|
|
34
51
|
async uninstall(name) {
|
|
35
52
|
const killed = await tmux.kill(name); // idempotent
|
|
36
53
|
return killed
|
|
@@ -148,6 +148,10 @@ WantedBy=default.target
|
|
|
148
148
|
return r.stdout || r.stderr;
|
|
149
149
|
},
|
|
150
150
|
liveness(name) { return probeLiveness(ctl, name); },
|
|
151
|
+
async inspect(name) {
|
|
152
|
+
const live = await probeLiveness(ctl, name);
|
|
153
|
+
return { backend: 'systemd', ...live, nativeState: live.detail.split(/\s/)[0] };
|
|
154
|
+
},
|
|
151
155
|
async uninstall(name) {
|
|
152
156
|
const before = await ctl('is-enabled', unitFor(name));
|
|
153
157
|
const wasEnabled = before.stdout.trim() === 'enabled';
|
|
@@ -19,6 +19,10 @@ export interface UninstallOutcome {
|
|
|
19
19
|
removed: boolean;
|
|
20
20
|
detail: string;
|
|
21
21
|
}
|
|
22
|
+
export interface SupervisorInspection extends Liveness {
|
|
23
|
+
backend: 'systemd' | 'launchd' | 'none';
|
|
24
|
+
nativeState?: string;
|
|
25
|
+
}
|
|
22
26
|
export interface SupervisorBackend {
|
|
23
27
|
id: 'systemd' | 'launchd' | 'none';
|
|
24
28
|
/** One-time host setup (unit template / dirs / linger). Returns human-readable messages. */
|
|
@@ -40,6 +44,8 @@ export interface SupervisorBackend {
|
|
|
40
44
|
* probe is `unknown` with the failure in `detail`.
|
|
41
45
|
*/
|
|
42
46
|
liveness(name: string): Promise<Liveness>;
|
|
47
|
+
/** Structured machine-derived status for application-service consumers. */
|
|
48
|
+
inspect?(name: string): Promise<SupervisorInspection>;
|
|
43
49
|
/** Remove the registration. Idempotent; reports whether anything was there. */
|
|
44
50
|
uninstall(name: string): Promise<UninstallOutcome>;
|
|
45
51
|
/** Command the CLI execs (stdio inherited) to show logs. */
|
package/dist/tmux.d.ts
CHANGED
|
@@ -33,6 +33,8 @@ export declare class Tmux {
|
|
|
33
33
|
*/
|
|
34
34
|
kill(name: string): Promise<boolean>;
|
|
35
35
|
capture(name: string, lines?: number): Promise<string>;
|
|
36
|
+
/** Bounded ANSI/history seed for the browser terminal projection. */
|
|
37
|
+
captureHistory(name: string, lines?: number): Promise<string>;
|
|
36
38
|
panePid(name: string): Promise<number | null>;
|
|
37
39
|
/**
|
|
38
40
|
* List the live sessions among `names`, asking each server in turn.
|
package/dist/tmux.js
CHANGED
|
@@ -49,6 +49,14 @@ export class Tmux {
|
|
|
49
49
|
const all = r.stdout.replace(/\n+$/, '').split('\n');
|
|
50
50
|
return all.slice(-lines).join('\n');
|
|
51
51
|
}
|
|
52
|
+
/** Bounded ANSI/history seed for the browser terminal projection. */
|
|
53
|
+
async captureHistory(name, lines = 5_000) {
|
|
54
|
+
const bounded = Math.min(Math.max(Math.trunc(lines), 1), 20_000);
|
|
55
|
+
const r = await this.exec('tmux', tmuxArgs(name, ['capture-pane', '-t', name, '-p', '-e', '-J', '-S', `-${bounded}`]));
|
|
56
|
+
if (r.code !== 0)
|
|
57
|
+
throw new Error(`tmux capture-pane '${name}' failed: ${r.stderr.trim()}`);
|
|
58
|
+
return Buffer.from(r.stdout).subarray(0, 4 * 1024 * 1024).toString();
|
|
59
|
+
}
|
|
52
60
|
async panePid(name) {
|
|
53
61
|
const r = await this.exec('tmux', tmuxArgs(name, ['list-panes', '-t', name, '-F', '#{pane_pid}']));
|
|
54
62
|
if (r.code !== 0)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export interface AuditEvent {
|
|
2
|
+
at?: string;
|
|
3
|
+
requestId?: string;
|
|
4
|
+
browser?: string;
|
|
5
|
+
roleId?: string;
|
|
6
|
+
action: string;
|
|
7
|
+
result: string;
|
|
8
|
+
errorCode?: string;
|
|
9
|
+
latencyMs?: number;
|
|
10
|
+
bytes?: number;
|
|
11
|
+
digest?: string;
|
|
12
|
+
}
|
|
13
|
+
export declare class AuditSink {
|
|
14
|
+
readonly dir: string;
|
|
15
|
+
private readonly file;
|
|
16
|
+
private readonly memory;
|
|
17
|
+
degraded?: string;
|
|
18
|
+
constructor(dir?: string);
|
|
19
|
+
record(event: AuditEvent): Promise<void>;
|
|
20
|
+
list(limit?: number): AuditEvent[];
|
|
21
|
+
private prune;
|
|
22
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { mkdirSync, statSync } from 'node:fs';
|
|
2
|
+
import { appendFile, chmod, readdir, rename, unlink } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { stateRoot } from '../paths.js';
|
|
5
|
+
import { safeLine } from '../application/errors.js';
|
|
6
|
+
export class AuditSink {
|
|
7
|
+
dir;
|
|
8
|
+
file;
|
|
9
|
+
memory = [];
|
|
10
|
+
degraded;
|
|
11
|
+
constructor(dir = join(stateRoot(), 'web', 'audit')) {
|
|
12
|
+
this.dir = dir;
|
|
13
|
+
this.file = join(dir, 'audit.jsonl');
|
|
14
|
+
try {
|
|
15
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
16
|
+
}
|
|
17
|
+
catch (error) {
|
|
18
|
+
this.degraded = error.message;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
async record(event) {
|
|
22
|
+
const safe = {
|
|
23
|
+
at: event.at ?? new Date().toISOString(),
|
|
24
|
+
requestId: event.requestId, browser: event.browser?.slice(0, 12),
|
|
25
|
+
roleId: event.roleId, action: safeLine(event.action, 128),
|
|
26
|
+
result: safeLine(event.result, 128), errorCode: event.errorCode,
|
|
27
|
+
latencyMs: event.latencyMs, bytes: event.bytes, digest: event.digest,
|
|
28
|
+
};
|
|
29
|
+
this.memory.push(safe);
|
|
30
|
+
if (this.memory.length > 1_000)
|
|
31
|
+
this.memory.shift();
|
|
32
|
+
if (this.degraded)
|
|
33
|
+
return;
|
|
34
|
+
try {
|
|
35
|
+
try {
|
|
36
|
+
if (statSync(this.file).size > 2 * 1024 * 1024)
|
|
37
|
+
await rename(this.file, join(this.dir, `audit-${Date.now()}.jsonl`));
|
|
38
|
+
}
|
|
39
|
+
catch { /* first write */ }
|
|
40
|
+
await appendFile(this.file, JSON.stringify(safe) + '\n', { mode: 0o600 });
|
|
41
|
+
await chmod(this.file, 0o600);
|
|
42
|
+
await this.prune();
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
this.degraded = error.message;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
list(limit = 200) { return this.memory.slice(-Math.min(limit, 1_000)).reverse(); }
|
|
49
|
+
async prune() {
|
|
50
|
+
const files = (await readdir(this.dir)).filter(file => /^audit-\d+\.jsonl$/.test(file)).sort();
|
|
51
|
+
for (const file of files.slice(0, -10))
|
|
52
|
+
await unlink(join(this.dir, file)).catch(() => undefined);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { FastifyRequest } from 'fastify';
|
|
2
|
+
import type { WebSocket } from 'ws';
|
|
3
|
+
import { TrustedDeviceStore, type TrustedDeviceIssue } from './device-store.js';
|
|
4
|
+
export interface BrowserSession {
|
|
5
|
+
id: string;
|
|
6
|
+
csrf: string;
|
|
7
|
+
createdAt: number;
|
|
8
|
+
lastSeenAt: number;
|
|
9
|
+
absoluteExpiresAt: number;
|
|
10
|
+
}
|
|
11
|
+
export interface AuthResult {
|
|
12
|
+
session: BrowserSession;
|
|
13
|
+
device: TrustedDeviceIssue;
|
|
14
|
+
}
|
|
15
|
+
interface Ticket {
|
|
16
|
+
value: string;
|
|
17
|
+
sessionId: string;
|
|
18
|
+
purpose: 'events' | 'terminal';
|
|
19
|
+
roleId?: string;
|
|
20
|
+
expiresAt: number;
|
|
21
|
+
}
|
|
22
|
+
export declare class WebAuth {
|
|
23
|
+
private _origin;
|
|
24
|
+
private _host;
|
|
25
|
+
private readonly now;
|
|
26
|
+
private readonly devices;
|
|
27
|
+
private _bootstrapSecret;
|
|
28
|
+
private bootstrapExpiresAt;
|
|
29
|
+
private bootstrapUsed;
|
|
30
|
+
private readonly sessions;
|
|
31
|
+
private readonly sessionDevices;
|
|
32
|
+
private readonly tickets;
|
|
33
|
+
private readonly rates;
|
|
34
|
+
private readonly sockets;
|
|
35
|
+
constructor(_origin: string, _host: string, now?: () => number, devices?: TrustedDeviceStore);
|
|
36
|
+
get bootstrapSecret(): string;
|
|
37
|
+
get origin(): string;
|
|
38
|
+
get host(): string;
|
|
39
|
+
setBoundary(origin: string, host: string): void;
|
|
40
|
+
/** Mint a replacement for an operator-triggered reauthentication ceremony. */
|
|
41
|
+
mintBootstrap(): string;
|
|
42
|
+
validateBoundary(request: FastifyRequest, requireOrigin: boolean): void;
|
|
43
|
+
exchange(request: FastifyRequest): AuthResult;
|
|
44
|
+
resume(request: FastifyRequest): AuthResult;
|
|
45
|
+
authenticate(request: FastifyRequest, mutation?: boolean): BrowserSession;
|
|
46
|
+
logout(request: FastifyRequest): void;
|
|
47
|
+
mintTicket(request: FastifyRequest, purpose: Ticket['purpose'], roleId?: string): {
|
|
48
|
+
ticket: string;
|
|
49
|
+
expiresAt: string;
|
|
50
|
+
};
|
|
51
|
+
consumeTicket(request: FastifyRequest, value: string, purpose: Ticket['purpose'], roleId?: string): BrowserSession;
|
|
52
|
+
bindSocket(sessionId: string, socket: WebSocket): void;
|
|
53
|
+
clearSessions(): void;
|
|
54
|
+
revokeAllTrustedDevices(): number;
|
|
55
|
+
shutdown(): void;
|
|
56
|
+
private createSession;
|
|
57
|
+
private removeSession;
|
|
58
|
+
private consumeRate;
|
|
59
|
+
}
|
|
60
|
+
export declare function parseCookies(cookie: string): Record<string, string>;
|
|
61
|
+
export {};
|