@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.
Files changed (69) hide show
  1. package/README.md +66 -0
  2. package/dist/application/capabilities.d.ts +6 -0
  3. package/dist/application/capabilities.js +37 -0
  4. package/dist/application/errors.d.ts +31 -0
  5. package/dist/application/errors.js +51 -0
  6. package/dist/application/fleet-query-service.d.ts +31 -0
  7. package/dist/application/fleet-query-service.js +180 -0
  8. package/dist/application/log-service.d.ts +28 -0
  9. package/dist/application/log-service.js +146 -0
  10. package/dist/application/role-command-service.d.ts +37 -0
  11. package/dist/application/role-command-service.js +82 -0
  12. package/dist/application/role-creation-service.d.ts +142 -0
  13. package/dist/application/role-creation-service.js +374 -0
  14. package/dist/application/role-repository.d.ts +20 -0
  15. package/dist/application/role-repository.js +168 -0
  16. package/dist/application/session-control.d.ts +55 -0
  17. package/dist/application/session-control.js +115 -0
  18. package/dist/application/types.d.ts +156 -0
  19. package/dist/application/types.js +1 -0
  20. package/dist/cli.js +141 -0
  21. package/dist/config.d.ts +7 -2
  22. package/dist/config.js +18 -4
  23. package/dist/creation.d.ts +11 -0
  24. package/dist/creation.js +22 -5
  25. package/dist/docs.d.ts +1 -1
  26. package/dist/docs.js +34 -0
  27. package/dist/index.d.ts +10 -1
  28. package/dist/index.js +9 -1
  29. package/dist/runner.js +10 -2
  30. package/dist/session/control.d.ts +4 -2
  31. package/dist/session/control.js +45 -13
  32. package/dist/spawn.d.ts +20 -2
  33. package/dist/spawn.js +94 -24
  34. package/dist/supervisor/launchd.js +17 -0
  35. package/dist/supervisor/none.js +17 -0
  36. package/dist/supervisor/systemd.js +4 -0
  37. package/dist/supervisor/types.d.ts +6 -0
  38. package/dist/tmux.d.ts +2 -0
  39. package/dist/tmux.js +8 -0
  40. package/dist/web/audit.d.ts +22 -0
  41. package/dist/web/audit.js +54 -0
  42. package/dist/web/auth.d.ts +61 -0
  43. package/dist/web/auth.js +186 -0
  44. package/dist/web/control.d.ts +14 -0
  45. package/dist/web/control.js +110 -0
  46. package/dist/web/device-store.d.ts +27 -0
  47. package/dist/web/device-store.js +155 -0
  48. package/dist/web/events.d.ts +15 -0
  49. package/dist/web/events.js +34 -0
  50. package/dist/web/lock.d.ts +5 -0
  51. package/dist/web/lock.js +69 -0
  52. package/dist/web/runtime.d.ts +12 -0
  53. package/dist/web/runtime.js +170 -0
  54. package/dist/web/server.d.ts +35 -0
  55. package/dist/web/server.js +261 -0
  56. package/dist/web/service.d.ts +42 -0
  57. package/dist/web/service.js +180 -0
  58. package/dist/web/terminal/bridge.d.ts +27 -0
  59. package/dist/web/terminal/bridge.js +317 -0
  60. package/dist/web-app/assets/TerminalView-DcImdrI1.js +9 -0
  61. package/dist/web-app/assets/index-BokQN1Ao.js +9 -0
  62. package/dist/web-app/assets/index-lAXzaOZM.css +1 -0
  63. package/dist/web-app/icons/ours-fleet-maskable.svg +4 -0
  64. package/dist/web-app/icons/ours-fleet.svg +4 -0
  65. package/dist/web-app/index.html +17 -0
  66. package/dist/web-app/manifest.webmanifest +15 -0
  67. package/dist/web-app/offline.html +18 -0
  68. package/dist/web-app/sw.js +51 -0
  69. 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 roleFromOpts(o, defaultHarness) {
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?.trim())
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
- if (o.bioFile)
64
- r.bio = readFileSync(o.bioFile, 'utf8').trim();
65
- if (o.personaFile)
66
- r.persona = readFileSync(o.personaFile, 'utf8').trim();
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 = roleFromOpts(o, cfg.defaults.harness);
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: raw.harness ?? cfg.defaults.harness ?? 'claude-code',
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: raw.model ?? cfg.defaults.model,
165
- model_chain: resolveModelChain(raw.model ?? cfg.defaults.model, raw.model_chain ?? cfg.defaults.model_chain),
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: provenanceOf(o.model?.trim(), defaults.model, undefined),
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
- const guarantee = await ensureIdentity(effectiveIdentity(o), { bio: o.bioFile ? readFileSync(o.bioFile, 'utf8').trim() : undefined,
234
- persona: o.personaFile ? readFileSync(o.personaFile, 'utf8').trim() : undefined }, creation.identityProvisioner ?? daemonIdentityProvisioner(), deps.log);
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]: roleFromOpts(o, cfg.defaults.harness) },
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), { bio: o.bioFile ? readFileSync(o.bioFile, 'utf8').trim() : undefined,
304
- persona: o.personaFile ? readFileSync(o.personaFile, 'utf8').trim() : undefined }, creation.identityProvisioner ?? daemonIdentityProvisioner(), creation.log);
305
- return spawnTempInner(o, binPath, launch, tx, guarantee);
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 = roleFromOpts(o, defaultHarness);
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: o.harness ?? defaultHarness ?? 'claude-code',
384
+ harness,
320
385
  session: o.session ?? cfg.defaults.session ?? 'tmux',
321
386
  identity: o.identity ?? o.name,
322
- model: o.model?.trim() || cfg.defaults.model,
323
- model_chain: resolveModelChain(o.model?.trim() || cfg.defaults.model, fromOpts.model_chain ?? cfg.defaults.model_chain),
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
@@ -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 {};