@ours.network/fleet 0.10.1 → 0.10.3
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 +49 -0
- package/contrib/anthropic-auth-proxy.mjs +85 -0
- package/dist/briefing.js +5 -0
- package/dist/cli.js +63 -4
- package/dist/config-yaml.d.ts +20 -0
- package/dist/config-yaml.js +76 -0
- package/dist/config.d.ts +25 -1
- package/dist/config.js +115 -9
- package/dist/docs.d.ts +1 -1
- package/dist/docs.js +34 -0
- package/dist/doctor.d.ts +2 -0
- package/dist/doctor.js +42 -4
- package/dist/harness/claude-code.js +11 -0
- package/dist/harness/codex.js +12 -0
- package/dist/harness/types.d.ts +2 -0
- package/dist/model-recovery.d.ts +42 -0
- package/dist/model-recovery.js +120 -0
- package/dist/monitor.d.ts +3 -0
- package/dist/monitor.js +13 -7
- package/dist/permissions.d.ts +3 -1
- package/dist/permissions.js +80 -6
- package/dist/resolved-plan.d.ts +5 -0
- package/dist/resolved-plan.js +66 -0
- package/dist/runner.d.ts +1 -0
- package/dist/runner.js +84 -2
- package/dist/session/acp.js +5 -1
- package/dist/spawn.d.ts +19 -1
- package/dist/spawn.js +91 -3
- package/dist/worklog.d.ts +25 -0
- package/dist/worklog.js +99 -0
- package/package.json +2 -1
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
194
|
+
harness,
|
|
178
195
|
session,
|
|
179
196
|
session_options: sessionOptions,
|
|
180
197
|
permissions,
|
|
181
198
|
permissionsDeclared,
|
|
182
199
|
identity: r.identity ?? name,
|
|
183
|
-
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';
|