@ours.network/fleet 0.10.0 → 0.10.2

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 CHANGED
@@ -581,3 +581,52 @@ separate **commercial licence** from Adapt Framework Solutions Ltd — see
581
581
  **Audit status.** The core has not yet had an independent security audit. We're raising funding to commission one from a recognized firm and prove these guarantees, and we'll open-source the full core once it passes. Until then it's source-available and documented, but not independently audited — run anything critical on it at your own risk.
582
582
 
583
583
  Copyright 2026 Adapt Framework Solutions Ltd.
584
+ # Open-issues configuration contracts
585
+
586
+ `ours-fleet config --json` emits a deterministic, versioned, secret-safe
587
+ resolved plan (`schemaVersion: 1`). Environment keys are visible for policy
588
+ checks but values are always redacted. Human output remains the default.
589
+
590
+ The YAML loader explicitly rejects duplicate keys. During the compatibility
591
+ rollout, anchors, aliases, explicit tags, non-scalar keys, and multiple documents
592
+ produce source-positioned warnings; opt into enforcement with
593
+ `--yaml-mode strict`. Strict mode will become the next-major default.
594
+
595
+ Long-running roles may opt into bounded durable logs:
596
+
597
+ ```yaml
598
+ worklog:
599
+ max_kb: 1024
600
+ keep_tail_kb: 256
601
+ max_archives: 12
602
+ ```
603
+
604
+ Rotation is conservative: a concurrent change aborts the attempt and retries at
605
+ a later fleet lifecycle point. Archives remain in the role state directory and
606
+ may contain the same sensitive material as `WORKLOG.md`.
607
+
608
+ Claude roles can use a credential-free loopback proxy:
609
+
610
+ ```yaml
611
+ auth_proxy:
612
+ kind: anthropic
613
+ base_url: http://127.0.0.1:9411
614
+ required: true
615
+ health_url: http://127.0.0.1:9411/healthz
616
+ ```
617
+
618
+ Only `ANTHROPIC_BASE_URL` is injected. Do not put provider credentials in fleet
619
+ configuration. See `contrib/anthropic-auth-proxy.mjs` for the separately
620
+ deployed, dedicated-service-account reference and its 0600 token-file contract.
621
+
622
+ Approved automatic recovery is opt-in:
623
+
624
+ ```yaml
625
+ model: primary-model
626
+ model_chain: [primary-model, approved-fallback]
627
+ ```
628
+
629
+ Only sustained, high-confidence model entitlement/quota 429 evidence advances
630
+ the chain. Generic rate limits, overload, authentication, policy, and unknown
631
+ errors remain detection-only. Exhaustion holds the role down; fleet never edits
632
+ human-owned YAML or selects a model outside the declared chain.
@@ -0,0 +1,85 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Minimal credential-isolating Anthropic reverse proxy.
4
+ *
5
+ * Run as a dedicated service account. Configuration is environment-only:
6
+ * FLEET_PROXY_TOKEN_FILE=/run/secrets/anthropic-token (root-owned 0600)
7
+ * FLEET_PROXY_UPSTREAM=https://api.anthropic.com
8
+ * FLEET_PROXY_PORT=9411
9
+ *
10
+ * Deploy one loopback listener per role with OS-level access controls. This
11
+ * companion deliberately has no install/sudo lifecycle in ours-fleet.
12
+ */
13
+ import http from 'node:http';
14
+ import https from 'node:https';
15
+ import { readFileSync, statSync } from 'node:fs';
16
+
17
+ const tokenFile = process.env.FLEET_PROXY_TOKEN_FILE;
18
+ const upstream = new URL(process.env.FLEET_PROXY_UPSTREAM ?? 'https://api.anthropic.com');
19
+ const port = Number(process.env.FLEET_PROXY_PORT ?? 9411);
20
+ if (!tokenFile) throw new Error('FLEET_PROXY_TOKEN_FILE is required');
21
+ if (upstream.protocol !== 'https:') throw new Error('FLEET_PROXY_UPSTREAM must use https');
22
+ if (upstream.username || upstream.password) throw new Error('upstream URL must not contain credentials');
23
+ if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error('invalid FLEET_PROXY_PORT');
24
+
25
+ const secret = () => {
26
+ const stat = statSync(tokenFile);
27
+ if ((stat.mode & 0o077) !== 0) throw new Error('token file must not be group/world accessible');
28
+ return readFileSync(tokenFile, 'utf8').trim();
29
+ };
30
+ const sanitizedHeaders = headers => {
31
+ const next = { ...headers };
32
+ for (const key of Object.keys(next))
33
+ if (/^(authorization|proxy-authorization|x-api-key|anthropic-api-key|host)$/i.test(key))
34
+ delete next[key];
35
+ next.authorization = `Bearer ${secret()}`;
36
+ next.host = upstream.host;
37
+ return next;
38
+ };
39
+ const log = (status, started) => {
40
+ process.stdout.write(`${JSON.stringify({
41
+ timestamp: new Date().toISOString(),
42
+ upstreamStatus: status,
43
+ latencyMs: Date.now() - started,
44
+ })}\n`);
45
+ };
46
+
47
+ http.createServer((req, res) => {
48
+ const started = Date.now();
49
+ if (req.method === 'CONNECT') {
50
+ res.writeHead(405).end();
51
+ return;
52
+ }
53
+ if (req.url === '/healthz') {
54
+ res.setHeader('content-type', 'application/json');
55
+ res.end('{"schemaVersion":1,"kind":"anthropic-auth-proxy","ok":true}\n');
56
+ return;
57
+ }
58
+ const target = new URL(req.url ?? '/', upstream);
59
+ if (target.origin !== upstream.origin) {
60
+ res.writeHead(403).end();
61
+ return;
62
+ }
63
+ const outbound = https.request(target, {
64
+ method: req.method,
65
+ headers: sanitizedHeaders(req.headers),
66
+ }, upstreamResponse => {
67
+ const headers = { ...upstreamResponse.headers };
68
+ delete headers.location; // never allow an upstream redirect to retarget credentials
69
+ res.writeHead(upstreamResponse.statusCode ?? 502, headers);
70
+ upstreamResponse.pipe(res);
71
+ log(upstreamResponse.statusCode ?? 502, started);
72
+ });
73
+ outbound.on('error', error => {
74
+ if (!res.headersSent) res.writeHead(502);
75
+ res.end();
76
+ process.stderr.write(`${JSON.stringify({
77
+ timestamp: new Date().toISOString(),
78
+ errorClass: error.code ?? 'upstream-error',
79
+ latencyMs: Date.now() - started,
80
+ })}\n`);
81
+ });
82
+ req.pipe(outbound);
83
+ }).listen(port, '127.0.0.1', () => {
84
+ process.stderr.write(`anthropic auth proxy listening on 127.0.0.1:${port}\n`);
85
+ });
package/dist/briefing.js CHANGED
@@ -99,6 +99,11 @@ export function generateBriefing(role, v, opts) {
99
99
  L.push('', '## Durable log');
100
100
  L.push(`Append important commands / decisions / results to \`${opts.worklogPath}\` as you go —`);
101
101
  L.push('it survives restarts.');
102
+ if (role.worklog) {
103
+ L.push(`Fleet rotates it above ${role.worklog.max_kb} KiB, keeps approximately the newest ` +
104
+ `${role.worklog.keep_tail_kb} KiB here, and retains ${role.worklog.max_archives} archives ` +
105
+ 'beside it. Continue writing only WORKLOG.md.');
106
+ }
102
107
  L.push('', '## Routines');
103
108
  L.push(`If \`${opts.routinesPath}\` exists, re-read it at the START of every wake — before acting`);
104
109
  L.push('on messages, timers, or prompts — and follow it for recurring or scheduled work. It may');
package/dist/cli.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawn as spawnChild } from 'node:child_process';
3
- import { existsSync, mkdirSync, readdirSync } from 'node:fs';
3
+ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync } from 'node:fs';
4
4
  import { realpathSync } from 'node:fs';
5
5
  import { join as joinPath } from 'node:path';
6
6
  import { createInterface } from 'node:readline';
@@ -8,11 +8,14 @@ import { Command } from 'commander';
8
8
  import { VERSION } from './version.js';
9
9
  import { agentDir, agentsRoot, tmpRoot, logsRoot, deriveXdgRuntimeDir } from './paths.js';
10
10
  import { loadConfig } from './config.js';
11
+ import { resolvedPlan } from './resolved-plan.js';
11
12
  import { Tmux, tmuxArgs } from './tmux.js';
12
13
  import { pickBackend } from './supervisor/index.js';
13
14
  import { up, down, restartRoles, rmRole } from './ops.js';
14
15
  import { readRestartLedger, runSupervised, runTemp } from './runner.js';
15
- import { lastProvenance, spawnPermanent, spawnTemp } from './spawn.js';
16
+ import { lastProvenance, spawnDryRun, spawnPermanent, spawnTemp, } from './spawn.js';
17
+ import { stringify } from 'yaml';
18
+ import { resolvedRolePlan } from './resolved-plan.js';
16
19
  import { formatProvenance } from './creation.js';
17
20
  import { doctor } from './doctor.js';
18
21
  import { allWarnings, analyzeFleetPermissions, formatNative } from './permissions.js';
@@ -116,10 +119,22 @@ function parseCodexConfig(values) {
116
119
  return out;
117
120
  }
118
121
  cOpt(program.command('config').description('validate + print the merged plan (no side effects)'))
122
+ .option('--json', 'emit the stable, versioned, secret-safe resolved plan')
123
+ .option('--yaml-mode <mode>', 'non-plain YAML policy: compat|strict', 'compat')
119
124
  .action(opts => {
120
125
  try {
121
- const cfg = loadConfig(opts.configuration);
126
+ if (!['compat', 'strict'].includes(opts.yamlMode))
127
+ throw new Error(`invalid --yaml-mode '${opts.yamlMode}'; allowed: compat, strict`);
128
+ const cfg = loadConfig(opts.configuration, { yamlMode: opts.yamlMode });
129
+ if (opts.json) {
130
+ for (const diagnostic of cfg.diagnostics)
131
+ console.error(`warning: ${diagnostic.message}`);
132
+ process.stdout.write(`${JSON.stringify(resolvedPlan(cfg), null, 2)}\n`);
133
+ return;
134
+ }
122
135
  console.log(`config: ${cfg.files.join(' + ') || '(none)'}`);
136
+ for (const diagnostic of cfg.diagnostics)
137
+ console.log(`warning: ${diagnostic.message}`);
123
138
  const analyses = analyzeFleetPermissions(cfg.roles);
124
139
  for (const r of cfg.roles) {
125
140
  const perms = analyses.find(a => a.role === r.name);
@@ -353,6 +368,20 @@ program.command('status <name>').description('unit/agent state')
353
368
  else if (ledger.consecutiveImmediateFailures > 0)
354
369
  console.log(`restarts: ${ledger.consecutiveImmediateFailures} consecutive immediate `
355
370
  + `failures, next delay ${ledger.nextDelayMs}ms (${ledger.lastReason})`);
371
+ const modelStatus = joinPath(agentDir(name), '.model-status');
372
+ if (existsSync(modelStatus)) {
373
+ try {
374
+ const status = JSON.parse(readFileSync(modelStatus, 'utf8'));
375
+ console.log(`model: declared=${status.declaredModel} effective=${status.effectiveModel}`
376
+ + `${status.heldDown ? ' HELD DOWN (chain exhausted)' : ' (runtime drift)'}`);
377
+ }
378
+ catch {
379
+ console.log('model: recovery status unreadable (fail-closed)');
380
+ }
381
+ }
382
+ const worklog = joinPath(agentDir(name), 'WORKLOG.md');
383
+ if (existsSync(worklog))
384
+ console.log(`worklog: ${statSync(worklog).size} bytes`);
356
385
  const stateDir = acpStateDir(name);
357
386
  if (stateDir) {
358
387
  try {
@@ -379,6 +408,7 @@ cOpt(program.command('spawn <name>').description('spawn a new agent (permanent b
379
408
  .option('--harness <id>', 'harness adapter (default: defaults.harness)')
380
409
  .option('--session <backend>', 'session backend: tmux|acp (default: defaults.session or tmux)')
381
410
  .option('--mission <text>', 'one-line mission')
411
+ .option('--mission-file <path>', 'UTF-8 mission text (mutually exclusive with --mission)')
382
412
  .option('--identity <name>', 'ours identity to bind (default: role name)')
383
413
  .option('--cwd <dir>', 'working directory')
384
414
  .option('--coordinator <name>', 'announce target')
@@ -397,10 +427,13 @@ cOpt(program.command('spawn <name>').description('spawn a new agent (permanent b
397
427
  .option('--bio-file <file>', 'public bio (file)')
398
428
  .option('--persona-file <file>', 'persona / operating contract (file)')
399
429
  .option('--isolation-file <path>', 'file holding an isolation: mapping (same schema as fleet.yaml)')
430
+ .option('--dry-run', 'validate and print without reserving or creating anything')
431
+ .option('--json', 'with --dry-run, emit a stable secret-safe JSON result')
400
432
  .action(async (name, opts) => {
401
433
  try {
402
434
  const o = {
403
435
  name, temp: opts.temp, harness: opts.harness, session: opts.session, mission: opts.mission,
436
+ missionFile: opts.missionFile,
404
437
  identity: opts.identity, cwd: opts.cwd, coordinator: opts.coordinator,
405
438
  model: opts.model,
406
439
  permissionMode: opts.permissionMode, approval: opts.approval,
@@ -410,7 +443,26 @@ cOpt(program.command('spawn <name>').description('spawn a new agent (permanent b
410
443
  codexConfig: parseCodexConfig(opts.codexConfig), addDirs: opts.addDir, monitor: opts.monitor,
411
444
  bioFile: opts.bioFile, personaFile: opts.personaFile,
412
445
  isolationFile: opts.isolationFile, configPath: opts.configuration,
446
+ dryRun: opts.dryRun, json: opts.json,
413
447
  };
448
+ if (o.json && !o.dryRun)
449
+ throw new Error('--json is currently valid only with --dry-run');
450
+ if (o.dryRun) {
451
+ const result = spawnDryRun(o);
452
+ if (o.json) {
453
+ process.stdout.write(`${JSON.stringify({
454
+ schemaVersion: result.schemaVersion,
455
+ warning: result.warning,
456
+ roleDocument: result.roleDocument,
457
+ resolvedRole: resolvedRolePlan(result.resolvedRole),
458
+ }, null, 2)}\n`);
459
+ }
460
+ else {
461
+ console.log(`# dry-run: ${result.warning}`);
462
+ process.stdout.write(stringify(result.roleDocument));
463
+ }
464
+ return;
465
+ }
414
466
  if (o.temp) {
415
467
  const dir = await spawnTemp(o, binPath);
416
468
  console.log(`spawned temp agent '${name}' (state: ${dir}; gone on exit/reboot)`);
@@ -435,8 +487,15 @@ cOpt(program.command('spawn <name>').description('spawn a new agent (permanent b
435
487
  });
436
488
  cOpt(program.command('doctor').description('prerequisite report'))
437
489
  .option('--harness <id>', 'check one harness explicitly')
490
+ .option('--yaml-mode <mode>', 'non-plain YAML policy: compat|strict', 'compat')
438
491
  .action(async (opts) => {
439
- const rep = await doctor({ harness: opts.harness, configPath: opts.configuration });
492
+ if (!['compat', 'strict'].includes(opts.yamlMode))
493
+ die(`invalid --yaml-mode '${opts.yamlMode}'; allowed: compat, strict`);
494
+ const rep = await doctor({
495
+ harness: opts.harness,
496
+ configPath: opts.configuration,
497
+ yamlMode: opts.yamlMode,
498
+ });
440
499
  for (const c of rep.checks)
441
500
  console.log(`${c.ok ? 'ok ' : 'MISS'} ${c.name.padEnd(22)} ${c.detail}`);
442
501
  process.exit(rep.ok ? 0 : 1);
@@ -0,0 +1,20 @@
1
+ export type YamlMode = 'compat' | 'strict';
2
+ export type ConfigDiagnosticKind = 'anchor' | 'alias' | 'explicit-tag' | 'non-scalar-key' | 'multiple-documents';
3
+ export interface ConfigDiagnostic {
4
+ severity: 'warning';
5
+ kind: ConfigDiagnosticKind;
6
+ file: string;
7
+ line: number;
8
+ column: number;
9
+ message: string;
10
+ }
11
+ export interface ParsedFleetDocument {
12
+ value: Record<string, unknown>;
13
+ diagnostics: ConfigDiagnostic[];
14
+ }
15
+ /**
16
+ * Parse fleet-owned YAML with an explicit, shared contract. Duplicate keys and
17
+ * syntax errors always fail. YAML graph/type features are warning-first in
18
+ * compat mode and fail under strict mode.
19
+ */
20
+ export declare function parseFleetDocument(file: string, text: string, yamlMode?: YamlMode): ParsedFleetDocument;
@@ -0,0 +1,76 @@
1
+ import { LineCounter, isAlias, isMap, isPair, isScalar, isSeq, parseAllDocuments, } from 'yaml';
2
+ const position = (counter, node) => {
3
+ const start = node?.range?.[0] ?? 0;
4
+ const at = counter.linePos(start);
5
+ return { line: at.line, column: at.col };
6
+ };
7
+ /**
8
+ * Parse fleet-owned YAML with an explicit, shared contract. Duplicate keys and
9
+ * syntax errors always fail. YAML graph/type features are warning-first in
10
+ * compat mode and fail under strict mode.
11
+ */
12
+ export function parseFleetDocument(file, text, yamlMode = 'compat') {
13
+ const lineCounter = new LineCounter();
14
+ const documents = parseAllDocuments(text, {
15
+ strict: true,
16
+ uniqueKeys: true,
17
+ lineCounter,
18
+ prettyErrors: true,
19
+ });
20
+ const parseErrors = documents.flatMap(document => document.errors);
21
+ if (parseErrors.length)
22
+ throw new Error(`${file}: ${parseErrors.map(error => error.message).join('; ')}`);
23
+ const diagnostics = [];
24
+ const add = (kind, node, description) => {
25
+ const at = position(lineCounter, node);
26
+ diagnostics.push({
27
+ severity: 'warning',
28
+ kind,
29
+ file,
30
+ ...at,
31
+ message: `${file}:${at.line}:${at.column}: non-plain YAML ${description}`,
32
+ });
33
+ };
34
+ if (documents.length > 1)
35
+ add('multiple-documents', documents[1]?.contents, 'multiple documents');
36
+ const walk = (node) => {
37
+ if (!node || typeof node !== 'object')
38
+ return;
39
+ const tagged = node;
40
+ if (tagged.anchor)
41
+ add('anchor', node, `anchor '&${tagged.anchor}'`);
42
+ if (tagged.tag)
43
+ add('explicit-tag', node, `explicit tag '${tagged.tag}'`);
44
+ if (isAlias(node)) {
45
+ add('alias', node, `alias '*${String(node.source ?? '')}'`);
46
+ return;
47
+ }
48
+ if (isMap(node)) {
49
+ for (const item of node.items) {
50
+ if (!isPair(item))
51
+ continue;
52
+ if (!isScalar(item.key))
53
+ add('non-scalar-key', item.key, 'non-scalar mapping key');
54
+ walk(item.key);
55
+ walk(item.value);
56
+ }
57
+ return;
58
+ }
59
+ if (isSeq(node))
60
+ for (const item of node.items)
61
+ walk(item);
62
+ };
63
+ for (const document of documents)
64
+ walk(document.contents);
65
+ const unique = diagnostics.filter((diagnostic, index, all) => all.findIndex(other => other.kind === diagnostic.kind
66
+ && other.line === diagnostic.line && other.column === diagnostic.column) === index);
67
+ if (yamlMode === 'strict' && unique.length)
68
+ throw new Error(unique.map(diagnostic => diagnostic.message).join('; '));
69
+ const first = documents[0];
70
+ const value = first?.contents == null
71
+ ? {}
72
+ : first.toJS({ maxAliasCount: 100 });
73
+ if (value === null || typeof value !== 'object' || Array.isArray(value))
74
+ throw new Error(`${file}: top-level YAML document must be a mapping`);
75
+ return { value, diagnostics: unique };
76
+ }
package/dist/config.d.ts CHANGED
@@ -1,8 +1,20 @@
1
+ import { type ConfigDiagnostic, type YamlMode } from './config-yaml.js';
1
2
  import type { IsolationConfig, WrapContext } from './isolation/types.js';
2
3
  export interface OverseeEntry {
3
4
  role: string;
4
5
  interval: string;
5
6
  }
7
+ export interface WorklogPolicy {
8
+ max_kb: number;
9
+ keep_tail_kb: number;
10
+ max_archives: number;
11
+ }
12
+ export interface AuthProxyConfig {
13
+ kind: 'anthropic';
14
+ base_url: string;
15
+ required: boolean;
16
+ health_url: string;
17
+ }
6
18
  /** The 8 content-free event types the ours daemon appends to notifications.log. */
7
19
  export declare const NOTIFY_EVENT_TYPES: readonly ["message_received", "file_received", "sibling_contact_added", "local_contact_request", "pending_message", "contact_restored", "inbound_error", "state_import_failed"];
8
20
  export type NotifyEventType = (typeof NOTIFY_EVENT_TYPES)[number];
@@ -62,6 +74,7 @@ export interface RoleConfig {
62
74
  bio?: string;
63
75
  briefing_file?: string;
64
76
  model?: string;
77
+ model_chain?: string[];
65
78
  max_tokens?: number;
66
79
  autocompact_pct?: number;
67
80
  env?: Record<string, string>;
@@ -69,6 +82,8 @@ export interface RoleConfig {
69
82
  harness_options?: Record<string, unknown>;
70
83
  isolation?: IsolationConfig;
71
84
  monitor?: Partial<MonitorConfig>;
85
+ worklog?: WorklogPolicy;
86
+ auth_proxy?: Partial<AuthProxyConfig>;
72
87
  }
73
88
  export interface ResolvedRole extends RoleConfig {
74
89
  name: string;
@@ -85,6 +100,8 @@ export interface ResolvedRole extends RoleConfig {
85
100
  identity: string;
86
101
  sourceFile: string;
87
102
  monitor: MonitorConfig;
103
+ worklog?: WorklogPolicy;
104
+ auth_proxy?: AuthProxyConfig;
88
105
  }
89
106
  export interface FleetConfig {
90
107
  roles: ResolvedRole[];
@@ -93,6 +110,8 @@ export interface FleetConfig {
93
110
  files: string[];
94
111
  /** Fleet-wide delay (ms) enforced between agent launches to avoid boot bursts (0 = none). */
95
112
  startStaggerMs: number;
113
+ /** Warning-first non-plain YAML migration diagnostics, in source order. */
114
+ diagnostics: ConfigDiagnostic[];
96
115
  }
97
116
  export declare class ConfigError extends Error {
98
117
  }
@@ -104,7 +123,12 @@ export declare class ConfigError extends Error {
104
123
  */
105
124
  export declare function isolationContextFor(role: ResolvedRole): WrapContext;
106
125
  /** Load ~/fleet.yaml (or an explicit path) merged with ~/fleet.d/*.yaml drop-ins. */
107
- export declare function loadConfig(configPath?: string): FleetConfig;
126
+ export declare function loadConfig(configPath?: string, options?: {
127
+ yamlMode?: YamlMode;
128
+ }): FleetConfig;
129
+ export declare function resolveModelChain(model: string | undefined, chain: string[] | undefined, file?: string, name?: string): string[] | undefined;
130
+ export declare function resolveAuthProxy(defaults: unknown, role: Partial<AuthProxyConfig> | undefined, file?: string, name?: string): AuthProxyConfig | undefined;
131
+ export declare function resolveWorklogPolicy(defaults: unknown, role: WorklogPolicy | undefined, file?: string, name?: string): WorklogPolicy | undefined;
108
132
  export declare function resolvePermissions(defaults: unknown, role: Partial<CommonPermissions> | undefined, file?: string, name?: string): CommonPermissions;
109
133
  /**
110
134
  * Merge `defaults.monitor` under the role's own `monitor:` key-by-key, validate the
package/dist/config.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { existsSync, readFileSync, readdirSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
- import { parse } from 'yaml';
4
3
  import { agentDir, defaultConfigPath, fleetDDir, home } from './paths.js';
4
+ import { parseFleetDocument, } from './config-yaml.js';
5
5
  import { harnessRuntimeDir, resolveIsolation, validateIsolationConfig, } from './isolation/policy.js';
6
6
  import { getAdapter } from './harness/registry.js';
7
7
  /** The 8 content-free event types the ours daemon appends to notifications.log. */
@@ -94,8 +94,8 @@ export function isolationContextFor(role) {
94
94
  const NAME_RE = /^[A-Za-z0-9_-]+$/;
95
95
  const ROLE_KEYS = [
96
96
  'harness', 'session', 'session_options', 'permissions', 'identity', 'cwd', 'coordinator', 'mission', 'persona', 'bio',
97
- 'briefing_file', 'model', 'max_tokens', 'autocompact_pct', 'env', 'oversee', 'harness_options',
98
- 'isolation', 'monitor',
97
+ 'briefing_file', 'model', 'model_chain', 'max_tokens', 'autocompact_pct', 'env', 'oversee', 'harness_options',
98
+ 'isolation', 'monitor', 'worklog', 'auth_proxy',
99
99
  ];
100
100
  function deepSub(v, vars) {
101
101
  if (typeof v === 'string')
@@ -107,12 +107,15 @@ function deepSub(v, vars) {
107
107
  return v;
108
108
  }
109
109
  /** Load ~/fleet.yaml (or an explicit path) merged with ~/fleet.d/*.yaml drop-ins. */
110
- export function loadConfig(configPath) {
110
+ export function loadConfig(configPath, options = {}) {
111
111
  const base = configPath ?? defaultConfigPath();
112
112
  const files = [];
113
+ const diagnostics = [];
113
114
  const docs = [];
114
115
  if (existsSync(base)) {
115
- docs.push({ file: base, doc: (parse(readFileSync(base, 'utf8')) ?? {}) });
116
+ const parsed = parseFleetDocument(base, readFileSync(base, 'utf8'), options.yamlMode);
117
+ docs.push({ file: base, doc: parsed.value });
118
+ diagnostics.push(...parsed.diagnostics);
116
119
  files.push(base);
117
120
  }
118
121
  else if (configPath) {
@@ -122,7 +125,9 @@ export function loadConfig(configPath) {
122
125
  if (existsSync(dd)) {
123
126
  for (const f of readdirSync(dd).filter(f => f.endsWith('.yaml') || f.endsWith('.yml')).sort()) {
124
127
  const p = join(dd, f);
125
- const doc = (parse(readFileSync(p, 'utf8')) ?? {});
128
+ const parsed = parseFleetDocument(p, readFileSync(p, 'utf8'), options.yamlMode);
129
+ const doc = parsed.value;
130
+ diagnostics.push(...parsed.diagnostics);
126
131
  const extra = Object.keys(doc).filter(k => k !== 'roles');
127
132
  if (extra.length)
128
133
  throw new ConfigError(`${p}: fleet.d files may only define roles: (found: ${extra.join(', ')})`);
@@ -170,21 +175,37 @@ export function loadConfig(configPath) {
170
175
  throw new ConfigError(`${file}: role '${name}' ${problems.join('; ')}`);
171
176
  }
172
177
  const monitor = resolveMonitorConfig(defaults.monitor, r.monitor, { base, file, name });
178
+ const worklog = resolveWorklogPolicy(defaults.worklog, r.worklog, file, name);
179
+ const authProxy = resolveAuthProxy(defaults.auth_proxy, r.auth_proxy, file, name);
180
+ const model = r.model ?? defaults.model;
181
+ const modelChain = resolveModelChain(model, r.model_chain ?? defaults.model_chain, file, name);
182
+ const harness = r.harness ?? defaults.harness ?? 'claude-code';
183
+ if (authProxy && harness !== 'claude-code')
184
+ throw new ConfigError(`${file}: role '${name}' auth_proxy is supported only by claude-code`);
185
+ const env = {
186
+ ...(defaults.env ?? {}),
187
+ ...(r.env ?? {}),
188
+ ...(authProxy ? { ANTHROPIC_BASE_URL: authProxy.base_url } : {}),
189
+ };
173
190
  roles.push({
174
191
  ...r,
175
192
  name,
176
193
  sourceFile: file,
177
- harness: r.harness ?? defaults.harness ?? 'claude-code',
194
+ harness,
178
195
  session,
179
196
  session_options: sessionOptions,
180
197
  permissions,
181
198
  permissionsDeclared,
182
199
  identity: r.identity ?? name,
183
- model: r.model ?? defaults.model,
200
+ model,
201
+ model_chain: modelChain,
184
202
  max_tokens: r.max_tokens ?? defaults.max_tokens,
185
203
  harness_options: harnessOptions,
186
204
  isolation,
187
205
  monitor,
206
+ worklog,
207
+ auth_proxy: authProxy,
208
+ env: Object.keys(env).length ? env : undefined,
188
209
  });
189
210
  // Forbidden-path enforcement (5.2): a mount that would breach the policy
190
211
  // is a configuration error, caught by `config` rather than at launch.
@@ -199,7 +220,92 @@ export function loadConfig(configPath) {
199
220
  }
200
221
  }
201
222
  }
202
- return { roles, vars, defaults, files, startStaggerMs };
223
+ return { roles, vars, defaults, files, startStaggerMs, diagnostics };
224
+ }
225
+ export function resolveModelChain(model, chain, file = 'config', name = 'role') {
226
+ if (chain === undefined)
227
+ return undefined;
228
+ if (!Array.isArray(chain) || chain.length === 0)
229
+ throw new ConfigError(`${file}: role '${name}' model_chain must be a non-empty list`);
230
+ if (chain.some(entry => typeof entry !== 'string' || entry.trim() === ''))
231
+ throw new ConfigError(`${file}: role '${name}' model_chain entries must be non-blank strings`);
232
+ const normalized = chain.map(entry => entry.trim());
233
+ if (new Set(normalized).size !== normalized.length)
234
+ throw new ConfigError(`${file}: role '${name}' model_chain must not contain duplicates`);
235
+ if (model !== undefined && model !== normalized[0])
236
+ throw new ConfigError(`${file}: role '${name}' model must equal model_chain[0]`);
237
+ return normalized;
238
+ }
239
+ export function resolveAuthProxy(defaults, role, file = 'config', name = 'role') {
240
+ if (defaults === undefined && role === undefined)
241
+ return undefined;
242
+ if (defaults !== undefined && !isPlainObject(defaults))
243
+ throw new ConfigError(`${file}: defaults.auth_proxy must be a map`);
244
+ if (role !== undefined && !isPlainObject(role))
245
+ throw new ConfigError(`${file}: role '${name}' auth_proxy must be a map`);
246
+ const merged = {
247
+ ...(defaults ?? {}),
248
+ ...(role ?? {}),
249
+ };
250
+ const bad = Object.keys(merged).filter(key => !['kind', 'base_url', 'required', 'health_url'].includes(key));
251
+ if (bad.length)
252
+ throw new ConfigError(`${file}: role '${name}' auth_proxy: unknown key(s) ${bad.join(', ')}`);
253
+ if (merged.kind !== 'anthropic')
254
+ throw new ConfigError(`${file}: role '${name}' auth_proxy.kind must be 'anthropic'`);
255
+ if (typeof merged.base_url !== 'string')
256
+ throw new ConfigError(`${file}: role '${name}' auth_proxy.base_url is required`);
257
+ const checked = (label, raw) => {
258
+ let url;
259
+ try {
260
+ url = new URL(raw);
261
+ }
262
+ catch {
263
+ throw new ConfigError(`${file}: role '${name}' auth_proxy.${label} must be a valid URL`);
264
+ }
265
+ if (!['http:', 'https:'].includes(url.protocol))
266
+ throw new ConfigError(`${file}: role '${name}' auth_proxy.${label} must use http or https`);
267
+ if (!['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname))
268
+ throw new ConfigError(`${file}: role '${name}' auth_proxy.${label} must be loopback-only`);
269
+ if (url.username || url.password)
270
+ throw new ConfigError(`${file}: role '${name}' auth_proxy.${label} must not contain credentials`);
271
+ return url;
272
+ };
273
+ const base = checked('base_url', merged.base_url);
274
+ const healthRaw = merged.health_url ?? new URL('/healthz', base).toString();
275
+ checked('health_url', healthRaw);
276
+ if (merged.required !== undefined && typeof merged.required !== 'boolean')
277
+ throw new ConfigError(`${file}: role '${name}' auth_proxy.required must be true or false`);
278
+ return {
279
+ kind: 'anthropic',
280
+ base_url: base.toString().replace(/\/$/, ''),
281
+ required: merged.required ?? true,
282
+ health_url: healthRaw,
283
+ };
284
+ }
285
+ export function resolveWorklogPolicy(defaults, role, file = 'config', name = 'role') {
286
+ if (defaults === undefined && role === undefined)
287
+ return undefined;
288
+ if (defaults !== undefined && !isPlainObject(defaults))
289
+ throw new ConfigError(`${file}: defaults.worklog must be a map`);
290
+ if (role !== undefined && !isPlainObject(role))
291
+ throw new ConfigError(`${file}: role '${name}' worklog must be a map`);
292
+ const merged = {
293
+ ...(defaults ?? {}),
294
+ ...(role ?? {}),
295
+ };
296
+ const bad = Object.keys(merged).filter(key => !['max_kb', 'keep_tail_kb', 'max_archives'].includes(key));
297
+ if (bad.length)
298
+ throw new ConfigError(`${file}: role '${name}' worklog: unknown key(s) ${bad.join(', ')}`);
299
+ for (const key of ['max_kb', 'keep_tail_kb', 'max_archives']) {
300
+ const value = merged[key];
301
+ if (!Number.isInteger(value) || value <= 0)
302
+ throw new ConfigError(`${file}: role '${name}' worklog.${key} must be a positive integer`);
303
+ }
304
+ if (merged.max_archives > 1000)
305
+ throw new ConfigError(`${file}: role '${name}' worklog.max_archives must be at most 1000`);
306
+ if (merged.keep_tail_kb >= merged.max_kb)
307
+ throw new ConfigError(`${file}: role '${name}' worklog.keep_tail_kb must be less than max_kb`);
308
+ return merged;
203
309
  }
204
310
  function resolveSession(raw, file, name) {
205
311
  const value = raw ?? 'tmux';