@ours.network/fleet 1.0.5 → 1.1.0-nightly.10
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 +122 -133
- package/dist/agent-recovery-gate.d.ts +17 -0
- package/dist/agent-recovery-gate.js +135 -0
- package/dist/application/capabilities.d.ts +0 -1
- package/dist/application/capabilities.js +3 -13
- package/dist/application/fleet-query-service.d.ts +0 -3
- package/dist/application/fleet-query-service.js +14 -25
- package/dist/application/role-creation-service.d.ts +5 -24
- package/dist/application/role-creation-service.js +27 -67
- package/dist/application/role-removal-service.js +6 -3
- package/dist/application/role-repository.d.ts +1 -2
- package/dist/application/role-repository.js +7 -7
- package/dist/application/session-control.d.ts +0 -12
- package/dist/application/session-control.js +0 -42
- package/dist/application/session-mutations.d.ts +5 -5
- package/dist/application/task-room-service.d.ts +1 -1
- package/dist/application/task-room-service.js +154 -16
- package/dist/application/types.d.ts +1 -8
- package/dist/briefing.js +12 -11
- package/dist/build-info.json +5 -5
- package/dist/cli.js +143 -63
- package/dist/config-yaml.d.ts +1 -1
- package/dist/config.d.ts +66 -6
- package/dist/config.js +398 -43
- package/dist/creation.d.ts +1 -1
- package/dist/creation.js +2 -2
- package/dist/daemon-recovery.d.ts +93 -0
- package/dist/daemon-recovery.js +328 -0
- package/dist/docs.d.ts +1 -1
- package/dist/docs.js +103 -127
- package/dist/doctor.js +18 -11
- package/dist/fleet-command-audit.d.ts +160 -0
- package/dist/fleet-command-audit.js +433 -0
- package/dist/fleet-proxy.d.ts +3 -1
- package/dist/fleet-proxy.js +30 -8
- package/dist/generated-agent-source.d.ts +9 -0
- package/dist/generated-agent-source.js +53 -0
- package/dist/harness/acp-session-transport.d.ts +8 -0
- package/dist/harness/acp-session-transport.js +3 -0
- package/dist/harness/agent-session.d.ts +51 -0
- package/dist/harness/agent-session.js +1 -0
- package/dist/harness/claude-code-session.d.ts +24 -0
- package/dist/harness/claude-code-session.js +36 -0
- package/dist/harness/claude-code.d.ts +2 -1
- package/dist/harness/claude-code.js +41 -81
- package/dist/harness/codex-session.d.ts +24 -0
- package/dist/harness/codex-session.js +36 -0
- package/dist/harness/codex.d.ts +2 -1
- package/dist/harness/codex.js +55 -45
- package/dist/harness/registry.js +4 -0
- package/dist/harness/types.d.ts +6 -38
- package/dist/index.d.ts +4 -6
- package/dist/index.js +2 -4
- package/dist/init-guidance.d.ts +1 -0
- package/dist/init-guidance.js +6 -0
- package/dist/isolation/resources.d.ts +3 -7
- package/dist/isolation/resources.js +3 -7
- package/dist/model-env.d.ts +4 -6
- package/dist/model-env.js +5 -8
- package/dist/monitor.d.ts +1 -24
- package/dist/monitor.js +5 -140
- package/dist/ops.js +4 -2
- package/dist/owner-channel/channel.d.ts +58 -2
- package/dist/owner-channel/channel.js +269 -29
- package/dist/owner-channel/commands.d.ts +2 -0
- package/dist/owner-channel/commands.js +31 -13
- package/dist/owner-channel/state.d.ts +2 -0
- package/dist/owner-channel/state.js +8 -2
- package/dist/preset-bootstrap.d.ts +10 -0
- package/dist/preset-bootstrap.js +85 -0
- package/dist/reports/artifact.d.ts +10 -0
- package/dist/reports/artifact.js +32 -0
- package/dist/reports/index.d.ts +5 -0
- package/dist/reports/index.js +5 -0
- package/dist/reports/render.d.ts +6 -0
- package/dist/reports/render.js +240 -0
- package/dist/reports/service.d.ts +42 -0
- package/dist/reports/service.js +57 -0
- package/dist/reports/tasks.d.ts +52 -0
- package/dist/reports/tasks.js +177 -0
- package/dist/reports/types.d.ts +315 -0
- package/dist/reports/types.js +6 -0
- package/dist/resolved-plan.d.ts +3 -2
- package/dist/resolved-plan.js +21 -5
- package/dist/rooms-tasks/cli.js +225 -40
- package/dist/rooms-tasks/config.js +24 -13
- package/dist/rooms-tasks/external-worker.d.ts +1 -0
- package/dist/rooms-tasks/external-worker.js +14 -9
- package/dist/rooms-tasks/provision.js +98 -36
- package/dist/rooms-tasks/templates.d.ts +0 -1
- package/dist/rooms-tasks/templates.js +9 -76
- package/dist/rooms-tasks/types.d.ts +10 -16
- package/dist/rooms-tasks/types.js +1 -4
- package/dist/runner.d.ts +13 -16
- package/dist/runner.js +110 -122
- package/dist/sensitive-config.d.ts +2 -0
- package/dist/sensitive-config.js +4 -0
- package/dist/session/acp.d.ts +7 -2
- package/dist/session/acp.js +29 -6
- package/dist/session/activity.d.ts +2 -3
- package/dist/session/activity.js +2 -3
- package/dist/session/arbiter.d.ts +4 -4
- package/dist/session/control.d.ts +17 -4
- package/dist/session/control.js +35 -0
- package/dist/session/types.d.ts +11 -5
- package/dist/spawn.d.ts +8 -33
- package/dist/spawn.js +53 -213
- package/dist/supervisor/none.d.ts +1 -1
- package/dist/supervisor/none.js +2 -2
- package/dist/temp-lifecycle.d.ts +1 -1
- package/dist/tmux.d.ts +1 -14
- package/dist/tmux.js +1 -51
- package/dist/watchdog/config.d.ts +10 -7
- package/dist/watchdog/config.js +30 -18
- package/dist/watchdog/run.js +12 -36
- package/dist/web/auth.d.ts +1 -1
- package/dist/web/fleet-config-service.d.ts +11 -8
- package/dist/web/fleet-config-service.js +384 -183
- package/dist/web/runtime.js +4 -15
- package/dist/web/server.d.ts +1 -3
- package/dist/web/server.js +3 -14
- package/dist/web/topology-promote.js +26 -19
- package/dist/web/topology.js +2 -2
- package/dist/web/yaml-document-edit.js +2 -0
- package/dist/web-app/assets/index-59GF-gsZ.css +1 -0
- package/dist/web-app/assets/index-BbTc5KtZ.js +10 -0
- package/dist/web-app/index.html +2 -2
- package/examples/fleet/agents/Alice.yaml +11 -0
- package/examples/fleet/agents/FleetCoordinator.yaml +7 -0
- package/examples/fleet/brains/claude-default.yaml +5 -0
- package/examples/fleet/roles/coordinator.yaml +5 -0
- package/examples/fleet/roles/developer.yaml +3 -0
- package/examples/fleet.yaml +74 -0
- package/package.json +5 -8
- package/presets/fleet/agents/Agent.yaml +3 -0
- package/presets/fleet/agents/Architect.yaml +3 -0
- package/presets/fleet/agents/Critic.yaml +3 -0
- package/presets/fleet/agents/Developer.yaml +3 -0
- package/presets/fleet/agents/Secretary.yaml +3 -0
- package/presets/fleet/agents/Tester.yaml +3 -0
- package/presets/fleet/brains/claude-default.yaml +5 -0
- package/presets/fleet/roles/Agent.yaml +6 -0
- package/presets/fleet/roles/Architect.yaml +6 -0
- package/presets/fleet/roles/Critic.yaml +6 -0
- package/presets/fleet/roles/Developer.yaml +6 -0
- package/presets/fleet/roles/Secretary.yaml +6 -0
- package/presets/fleet/roles/Tester.yaml +6 -0
- package/presets/fleet/room_templates/pair.yaml +11 -0
- package/presets/fleet/room_templates/single.yaml +8 -0
- package/presets/fleet/room_templates/team.yaml +12 -0
- package/presets/fleet.yaml +7 -0
- package/presets/manifest.json +5 -0
- package/dist/session/tmux.d.ts +0 -28
- package/dist/session/tmux.js +0 -80
- package/dist/web/terminal/bridge.d.ts +0 -27
- package/dist/web/terminal/bridge.js +0 -317
- package/dist/web-app/assets/TerminalView-C_G1ID2P.js +0 -9
- package/dist/web-app/assets/index-BCBK78hw.js +0 -10
- package/dist/web-app/assets/index-DuC-xnX4.css +0 -1
|
@@ -1,240 +1,441 @@
|
|
|
1
|
-
import { createHash
|
|
2
|
-
import { chmodSync, existsSync, lstatSync, readFileSync, rmSync, } from 'node:fs';
|
|
3
|
-
import { basename, dirname, join } from 'node:path';
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, } from 'node:fs';
|
|
3
|
+
import { basename, dirname, extname, join } from 'node:path';
|
|
4
|
+
import { parse, stringify } from 'yaml';
|
|
4
5
|
import { replaceFileAtomically, withFileLock } from '../atomic-file.js';
|
|
5
|
-
import { loadConfig } from '../config.js';
|
|
6
|
+
import { loadConfig, ROLE_NAME_RE, splitRootFor } from '../config.js';
|
|
6
7
|
import { parseFleetDocument } from '../config-yaml.js';
|
|
7
8
|
import { defaultConfigPath } from '../paths.js';
|
|
8
9
|
import { FleetError } from '../application/errors.js';
|
|
9
10
|
import { redactSourceSecrets, renderModelOntoSource } from './yaml-document-edit.js';
|
|
11
|
+
import { isSensitiveConfigKey } from '../sensitive-config.js';
|
|
10
12
|
export const REDACTED_ENV_VALUE = '__OURS_FLEET_SECRET_REDACTED__';
|
|
11
13
|
const MAX_CONFIG_BYTES = 256 * 1024;
|
|
12
14
|
const DIFF_CONTEXT_LINES = 3;
|
|
15
|
+
const KINDS = ['agents', 'roles', 'brains', 'room_templates'];
|
|
13
16
|
const emptyReport = () => ({ ok: true, checks: [] });
|
|
14
17
|
export class FleetConfigService {
|
|
15
18
|
path;
|
|
16
19
|
preflight;
|
|
20
|
+
beforeMutation;
|
|
17
21
|
constructor(options = {}) {
|
|
18
22
|
this.path = options.configPath ?? defaultConfigPath();
|
|
19
23
|
this.preflight = options.preflight ?? (async () => emptyReport());
|
|
24
|
+
this.beforeMutation = options.beforeMutation;
|
|
20
25
|
}
|
|
21
26
|
read() {
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
27
|
+
const sources = readSources(this.path);
|
|
28
|
+
try {
|
|
29
|
+
loadConfig(this.path, { yamlMode: 'strict' });
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
throw safeError(error, sourceSensitiveValues(sources));
|
|
33
|
+
}
|
|
34
|
+
const raw = modelFrom(sources);
|
|
35
|
+
const redacted = redactModel(raw);
|
|
25
36
|
return {
|
|
26
|
-
path: basename(this.path), exists:
|
|
27
|
-
|
|
28
|
-
model: redacted.model, redactions: redacted.paths,
|
|
37
|
+
path: basename(this.path), exists: true, firstRun: false,
|
|
38
|
+
revision: revision(sources), model: redacted.model, redactions: redacted.paths,
|
|
29
39
|
};
|
|
30
40
|
}
|
|
31
|
-
async preview(baseRevision,
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
const
|
|
35
|
-
const
|
|
36
|
-
const
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
}
|
|
41
|
+
async preview(baseRevision, input) {
|
|
42
|
+
const current = readSources(this.path);
|
|
43
|
+
assertRevision(baseRevision, current);
|
|
44
|
+
const currentModel = modelFrom(current);
|
|
45
|
+
const next = restoreRedactions(assertModel(input), currentModel, new Set(redactModel(currentModel).paths));
|
|
46
|
+
const proposal = renderProposal(current, next);
|
|
47
|
+
const staged = stageProposal(this.path, current, proposal);
|
|
48
|
+
let preflight;
|
|
49
|
+
try {
|
|
50
|
+
loadConfig(staged.manifest, { yamlMode: 'strict' });
|
|
51
|
+
preflight = await this.preflight(staged.manifest);
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
throw safeError(error, [...sensitiveValues(next), ...sourceSensitiveValues(current)]);
|
|
55
|
+
}
|
|
56
|
+
finally {
|
|
57
|
+
staged.remove();
|
|
58
|
+
}
|
|
59
|
+
const redacted = redactModel(next);
|
|
60
|
+
return result(current, currentModel, next, proposal, redacted, preflight);
|
|
46
61
|
}
|
|
47
|
-
async write(baseRevision,
|
|
48
|
-
return withFileLock(`${this.path}.web-edit.lock`, async () => {
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
const
|
|
52
|
-
const
|
|
53
|
-
const
|
|
54
|
-
const
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
62
|
+
async write(baseRevision, input) {
|
|
63
|
+
return withFileLock(`${splitRootFor(this.path)}.web-edit.lock`, async () => {
|
|
64
|
+
const current = readSources(this.path);
|
|
65
|
+
assertRevision(baseRevision, current);
|
|
66
|
+
const currentModel = modelFrom(current);
|
|
67
|
+
const next = restoreRedactions(assertModel(input), currentModel, new Set(redactModel(currentModel).paths));
|
|
68
|
+
const proposal = renderProposal(current, next);
|
|
69
|
+
const changed = changedDocuments(current, proposal);
|
|
70
|
+
const staged = stageProposal(this.path, current, proposal);
|
|
71
|
+
let preflight;
|
|
72
|
+
try {
|
|
73
|
+
loadConfig(staged.manifest, { yamlMode: 'strict' });
|
|
74
|
+
preflight = await this.preflight(staged.manifest);
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
throw safeError(error, [...sensitiveValues(next), ...sourceSensitiveValues(current)]);
|
|
78
|
+
}
|
|
79
|
+
finally {
|
|
80
|
+
staged.remove();
|
|
81
|
+
}
|
|
82
|
+
assertRevision(baseRevision, readSources(this.path));
|
|
83
|
+
if (!changed.length) {
|
|
84
|
+
const redacted = redactModel(next);
|
|
85
|
+
return { ...result(current, currentModel, next, proposal, redacted, preflight),
|
|
86
|
+
saved: true, newRevision: baseRevision };
|
|
87
|
+
}
|
|
88
|
+
recheckCollisions(current, proposal, changed);
|
|
89
|
+
const backupRoot = mkdtempSync(join(dirname(this.path), '.fleet-web-backup-'));
|
|
90
|
+
chmodSync(backupRoot, 0o700);
|
|
91
|
+
const backupFiles = join(backupRoot, 'files');
|
|
92
|
+
mkdirSync(backupFiles, { recursive: true, mode: 0o700 });
|
|
93
|
+
for (const rel of changed)
|
|
94
|
+
if (current.documents.has(rel)) {
|
|
95
|
+
const target = join(backupFiles, rel);
|
|
96
|
+
mkdirSync(dirname(target), { recursive: true, mode: 0o700 });
|
|
97
|
+
replaceFileAtomically(target, current.documents.get(rel), 0o600);
|
|
98
|
+
}
|
|
99
|
+
try {
|
|
100
|
+
changed.forEach((rel, index) => {
|
|
101
|
+
this.beforeMutation?.(rel, index);
|
|
102
|
+
const target = absoluteFor(this.path, rel);
|
|
103
|
+
const contents = proposal.get(rel);
|
|
104
|
+
if (contents === undefined)
|
|
105
|
+
rmSync(target, { force: true });
|
|
106
|
+
else {
|
|
107
|
+
assertWritableTarget(target, current.documents.has(rel));
|
|
108
|
+
replaceFileAtomically(target, contents, 0o600);
|
|
109
|
+
chmodSync(target, 0o600);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
try {
|
|
115
|
+
for (const rel of changed) {
|
|
116
|
+
const target = absoluteFor(this.path, rel);
|
|
117
|
+
const old = current.documents.get(rel);
|
|
118
|
+
if (old === undefined)
|
|
119
|
+
rmSync(target, { force: true });
|
|
120
|
+
else
|
|
121
|
+
replaceFileAtomically(target, old, 0o600);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
catch (rollback) {
|
|
125
|
+
throw new FleetError('internal', `configuration recovery failed; restore from ${backupRoot}`, { details: { rollback: rollback instanceof Error ? rollback.message : String(rollback) } });
|
|
126
|
+
}
|
|
127
|
+
throw safeError(error, sensitiveValues(next));
|
|
64
128
|
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
return {
|
|
68
|
-
saved: true,
|
|
69
|
-
normalizedModel: redactedNext.model,
|
|
70
|
-
diff: sourceDiff(currentSource, nextSource),
|
|
71
|
-
redactions: redactedNext.paths, impact: restartImpact(current, restored), preflight,
|
|
72
|
-
backup,
|
|
73
|
-
};
|
|
129
|
+
const final = readSources(this.path);
|
|
130
|
+
const redacted = redactModel(next);
|
|
131
|
+
return { ...result(current, currentModel, next, proposal, redacted, preflight),
|
|
132
|
+
saved: true, newRevision: revision(final), backup: backupRoot };
|
|
74
133
|
});
|
|
75
134
|
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
135
|
+
}
|
|
136
|
+
function trustedFile(path) {
|
|
137
|
+
let stat;
|
|
138
|
+
try {
|
|
139
|
+
stat = lstatSync(path);
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
throw new FleetError('invalid_request', `missing configuration source ${path}`);
|
|
143
|
+
}
|
|
144
|
+
const uid = process.getuid?.();
|
|
145
|
+
if (!stat.isFile() || stat.isSymbolicLink())
|
|
146
|
+
throw new FleetError('invalid_request', `${path} must be a regular non-symlink file`);
|
|
147
|
+
if (uid !== undefined && stat.uid !== uid)
|
|
148
|
+
throw new FleetError('forbidden', `${path} is not owned by the current user`);
|
|
149
|
+
if ((stat.mode & 0o022) !== 0)
|
|
150
|
+
throw new FleetError('forbidden', `${path} is group/world writable`);
|
|
151
|
+
if (stat.size > MAX_CONFIG_BYTES)
|
|
152
|
+
throw new FleetError('invalid_request', `${path} exceeds ${MAX_CONFIG_BYTES} bytes`);
|
|
153
|
+
return readFileSync(path, 'utf8');
|
|
154
|
+
}
|
|
155
|
+
function trustedDirectory(path) {
|
|
156
|
+
const stat = lstatSync(path);
|
|
157
|
+
const uid = process.getuid?.();
|
|
158
|
+
if (!stat.isDirectory() || stat.isSymbolicLink())
|
|
159
|
+
throw new FleetError('invalid_request', `${path} must be a non-symlink directory`);
|
|
160
|
+
if (uid !== undefined && stat.uid !== uid)
|
|
161
|
+
throw new FleetError('forbidden', `${path} is not owned by the current user`);
|
|
162
|
+
if ((stat.mode & 0o022) !== 0)
|
|
163
|
+
throw new FleetError('forbidden', `${path} is group/world writable`);
|
|
164
|
+
}
|
|
165
|
+
function readSources(manifest) {
|
|
166
|
+
const documents = new Map();
|
|
167
|
+
documents.set(basename(manifest), trustedFile(manifest));
|
|
168
|
+
const root = splitRootFor(manifest);
|
|
169
|
+
trustedDirectory(root);
|
|
170
|
+
for (const kind of KINDS) {
|
|
171
|
+
const dir = join(root, kind);
|
|
172
|
+
if (!existsSync(dir)) {
|
|
173
|
+
if (kind === 'agents')
|
|
174
|
+
throw new FleetError('invalid_request', `required Agent root missing: ${dir}`);
|
|
175
|
+
continue;
|
|
100
176
|
}
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
177
|
+
trustedDirectory(dir);
|
|
178
|
+
for (const name of readdirSync(dir).sort()) {
|
|
179
|
+
if ((kind === 'agents' || kind === 'room_templates')
|
|
180
|
+
&& !['.yaml', '.yml'].includes(extname(name).toLowerCase()))
|
|
181
|
+
continue;
|
|
182
|
+
documents.set(join(kind, name), trustedFile(join(dir, name)));
|
|
104
183
|
}
|
|
105
|
-
return { path: candidate, remove: () => rmSync(candidate, { force: true }) };
|
|
106
184
|
}
|
|
185
|
+
return { manifest, root, documents };
|
|
186
|
+
}
|
|
187
|
+
function modelFrom(sources) {
|
|
188
|
+
const manifestKey = basename(sources.manifest);
|
|
189
|
+
const manifest = parseFleetDocument(sources.manifest, sources.documents.get(manifestKey), 'strict').value;
|
|
190
|
+
const agents = {};
|
|
191
|
+
for (const [rel, source] of sources.documents)
|
|
192
|
+
if (rel.startsWith(`agents/`)) {
|
|
193
|
+
const id = basename(rel, extname(rel));
|
|
194
|
+
agents[id] = parseFleetDocument(absoluteFor(sources.manifest, rel), source, 'strict').value;
|
|
195
|
+
}
|
|
196
|
+
return { manifest, agents };
|
|
107
197
|
}
|
|
108
198
|
function assertModel(value) {
|
|
109
199
|
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
110
|
-
throw new FleetError('invalid_request', 'model must be
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
200
|
+
throw new FleetError('invalid_request', 'model must be an object');
|
|
201
|
+
const input = value;
|
|
202
|
+
if (Object.keys(input).sort().join(',') !== 'agents,manifest')
|
|
203
|
+
throw new FleetError('invalid_request', 'model allows exactly manifest and agents');
|
|
204
|
+
for (const key of ['manifest', 'agents'])
|
|
205
|
+
if (!input[key] || typeof input[key] !== 'object' || Array.isArray(input[key]))
|
|
206
|
+
throw new FleetError('invalid_request', `${key} must be an object`);
|
|
207
|
+
for (const [id, doc] of Object.entries(input.agents)) {
|
|
208
|
+
if (!ROLE_NAME_RE.test(id))
|
|
209
|
+
throw new FleetError('invalid_request', `invalid Agent ID '${id}'`);
|
|
210
|
+
if (!doc || typeof doc !== 'object' || Array.isArray(doc))
|
|
211
|
+
throw new FleetError('invalid_request', `Agent '${id}' must be an object`);
|
|
212
|
+
}
|
|
213
|
+
return structuredClone(input);
|
|
214
|
+
}
|
|
215
|
+
function renderProposal(current, model) {
|
|
216
|
+
const next = new Map();
|
|
217
|
+
const manifestRel = basename(current.manifest);
|
|
218
|
+
next.set(manifestRel, render(current.documents.get(manifestRel), model.manifest));
|
|
219
|
+
for (const [id, doc] of Object.entries(model.agents).sort(([a], [b]) => a.localeCompare(b))) {
|
|
220
|
+
const oldRel = [...current.documents.keys()].find(rel => rel.startsWith('agents/')
|
|
221
|
+
&& basename(rel, extname(rel)) === id);
|
|
222
|
+
next.set(oldRel ?? join('agents', `${id}.yaml`), oldRel
|
|
223
|
+
? render(current.documents.get(oldRel), doc) : stringify(doc));
|
|
224
|
+
}
|
|
225
|
+
for (const [rel, source] of current.documents)
|
|
226
|
+
if (rel.startsWith('roles/') || rel.startsWith('brains/') || rel.startsWith('room_templates/'))
|
|
227
|
+
next.set(rel, source);
|
|
228
|
+
return next;
|
|
229
|
+
}
|
|
230
|
+
function render(source, model) {
|
|
118
231
|
try {
|
|
119
|
-
return renderModelOntoSource(
|
|
232
|
+
return renderModelOntoSource(source, model);
|
|
120
233
|
}
|
|
121
234
|
catch (error) {
|
|
122
|
-
throw
|
|
235
|
+
throw safeError(error);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
function stageProposal(manifest, current, proposal) {
|
|
239
|
+
const dir = mkdtempSync(join(dirname(manifest), '.fleet-web-preview-'));
|
|
240
|
+
chmodSync(dir, 0o700);
|
|
241
|
+
const stagedManifest = join(dir, basename(manifest));
|
|
242
|
+
const stagedRoot = splitRootFor(stagedManifest);
|
|
243
|
+
mkdirSync(stagedRoot, { recursive: true, mode: 0o700 });
|
|
244
|
+
mkdirSync(join(stagedRoot, 'agents'), { mode: 0o700 });
|
|
245
|
+
for (const [rel, source] of proposal) {
|
|
246
|
+
const target = rel === basename(manifest) ? stagedManifest : join(stagedRoot, rel);
|
|
247
|
+
mkdirSync(dirname(target), { recursive: true, mode: 0o700 });
|
|
248
|
+
replaceFileAtomically(target, source, 0o600);
|
|
123
249
|
}
|
|
250
|
+
return { manifest: stagedManifest, remove: () => rmSync(dir, { recursive: true, force: true }) };
|
|
124
251
|
}
|
|
125
|
-
function
|
|
126
|
-
return
|
|
252
|
+
function changedDocuments(current, proposal) {
|
|
253
|
+
return [...new Set([...current.documents.keys(), ...proposal.keys()])]
|
|
254
|
+
.filter(rel => current.documents.get(rel) !== proposal.get(rel)).sort();
|
|
127
255
|
}
|
|
128
|
-
function
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
256
|
+
function recheckCollisions(current, proposal, changed) {
|
|
257
|
+
for (const rel of changed) {
|
|
258
|
+
const target = absoluteFor(current.manifest, rel);
|
|
259
|
+
const existed = current.documents.has(rel);
|
|
260
|
+
if (!existed && proposal.has(rel) && existsSync(target))
|
|
261
|
+
throw new FleetError('stale_state', `configuration collision at ${rel}`);
|
|
262
|
+
if (existed)
|
|
263
|
+
trustedFile(target);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
function assertWritableTarget(path, existed) {
|
|
267
|
+
if (!existsSync(path)) {
|
|
268
|
+
if (existed)
|
|
269
|
+
throw new FleetError('stale_state', `${path} disappeared`);
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
trustedFile(path);
|
|
273
|
+
}
|
|
274
|
+
function absoluteFor(manifest, rel) {
|
|
275
|
+
return rel === basename(manifest) ? manifest : join(splitRootFor(manifest), rel);
|
|
276
|
+
}
|
|
277
|
+
function revision(sources) {
|
|
278
|
+
const hash = createHash('sha256');
|
|
279
|
+
for (const [rel, bytes] of [...sources.documents].sort(([a], [b]) => a.localeCompare(b)))
|
|
280
|
+
hash.update(`${Buffer.byteLength(rel)}:${rel}:${Buffer.byteLength(bytes)}:`).update(bytes);
|
|
281
|
+
return hash.digest('hex');
|
|
282
|
+
}
|
|
283
|
+
function assertRevision(expected, sources) {
|
|
284
|
+
if (!expected || expected !== revision(sources))
|
|
285
|
+
throw new FleetError('stale_state', 'configuration sources changed since opened; reload before saving');
|
|
286
|
+
}
|
|
287
|
+
function sensitive(key, inEnv) {
|
|
288
|
+
return inEnv || isSensitiveConfigKey(key);
|
|
140
289
|
}
|
|
141
290
|
function redactModel(input) {
|
|
142
291
|
const model = structuredClone(input);
|
|
143
292
|
const paths = [];
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
293
|
+
const walk = (value, path, inEnv = false) => {
|
|
294
|
+
if (Array.isArray(value)) {
|
|
295
|
+
value.forEach((item, i) => {
|
|
296
|
+
if (inEnv && item !== null && typeof item !== 'object') {
|
|
297
|
+
value[i] = REDACTED_ENV_VALUE;
|
|
298
|
+
paths.push(`${path}/${i}`);
|
|
299
|
+
}
|
|
300
|
+
else
|
|
301
|
+
walk(item, `${path}/${i}`, inEnv);
|
|
302
|
+
});
|
|
303
|
+
return;
|
|
152
304
|
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
for (const key of
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
305
|
+
if (!value || typeof value !== 'object')
|
|
306
|
+
return;
|
|
307
|
+
for (const [key, child] of Object.entries(value)) {
|
|
308
|
+
const next = `${path}/${key}`;
|
|
309
|
+
if (sensitive(key, inEnv) && child !== null && typeof child !== 'object') {
|
|
310
|
+
value[key] = REDACTED_ENV_VALUE;
|
|
311
|
+
paths.push(next);
|
|
159
312
|
}
|
|
313
|
+
else
|
|
314
|
+
walk(child, next, inEnv || key === 'env' || sensitive(key, false));
|
|
315
|
+
}
|
|
316
|
+
};
|
|
317
|
+
walk(model, '');
|
|
160
318
|
return { model, paths: paths.sort() };
|
|
161
319
|
}
|
|
162
|
-
function restoreRedactions(next,
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
320
|
+
function restoreRedactions(next, old, allowed) {
|
|
321
|
+
const walk = (value, previous, path) => {
|
|
322
|
+
if (Array.isArray(value)) {
|
|
323
|
+
value.forEach((item, i) => {
|
|
324
|
+
const prior = Array.isArray(previous) ? previous[i] : undefined;
|
|
325
|
+
if (item === REDACTED_ENV_VALUE) {
|
|
326
|
+
if (!allowed.has(`${path}/${i}`) || prior === undefined)
|
|
327
|
+
throw new FleetError('invalid_request', `${path}/${i} has no prior redacted value`);
|
|
328
|
+
value[i] = structuredClone(prior);
|
|
329
|
+
}
|
|
330
|
+
else
|
|
331
|
+
walk(item, prior, `${path}/${i}`);
|
|
332
|
+
});
|
|
333
|
+
return;
|
|
172
334
|
}
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
335
|
+
if (!value || typeof value !== 'object')
|
|
336
|
+
return;
|
|
337
|
+
for (const [key, child] of Object.entries(value)) {
|
|
338
|
+
const prior = previous && typeof previous === 'object' && !Array.isArray(previous)
|
|
339
|
+
? previous[key] : undefined;
|
|
340
|
+
if (child === REDACTED_ENV_VALUE) {
|
|
341
|
+
if (!allowed.has(`${path}/${key}`) || prior === undefined)
|
|
342
|
+
throw new FleetError('invalid_request', `${path}/${key} has no prior redacted value`);
|
|
343
|
+
value[key] = structuredClone(prior);
|
|
344
|
+
}
|
|
345
|
+
else
|
|
346
|
+
walk(child, prior, `${path}/${key}`);
|
|
182
347
|
}
|
|
348
|
+
};
|
|
349
|
+
walk(next, old, '');
|
|
183
350
|
return next;
|
|
184
351
|
}
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
352
|
+
function maskSource(source, secrets) {
|
|
353
|
+
let masked = redactSourceSecrets(source, REDACTED_ENV_VALUE);
|
|
354
|
+
for (const secret of secrets.sort((a, b) => b.length - a.length))
|
|
355
|
+
if (secret)
|
|
356
|
+
masked = masked.split(secret).join(REDACTED_ENV_VALUE);
|
|
357
|
+
return masked;
|
|
358
|
+
}
|
|
359
|
+
function sourceDiff(before, after, label, secrets) {
|
|
360
|
+
const left = splitLines(maskSource(before, secrets));
|
|
361
|
+
const right = splitLines(maskSource(after, secrets));
|
|
193
362
|
let head = 0;
|
|
194
|
-
while (head <
|
|
195
|
-
head
|
|
363
|
+
while (head < left.length && head < right.length && left[head] === right[head])
|
|
364
|
+
head++;
|
|
196
365
|
let tail = 0;
|
|
197
|
-
while (tail <
|
|
198
|
-
&&
|
|
199
|
-
tail
|
|
200
|
-
if (head ===
|
|
366
|
+
while (tail < left.length - head && tail < right.length - head
|
|
367
|
+
&& left[left.length - 1 - tail] === right[right.length - 1 - tail])
|
|
368
|
+
tail++;
|
|
369
|
+
if (head === left.length && head === right.length)
|
|
201
370
|
return '';
|
|
202
371
|
const start = Math.max(0, head - DIFF_CONTEXT_LINES);
|
|
203
372
|
const trailing = Math.min(DIFF_CONTEXT_LINES, tail);
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
return source.replace(/\n$/, '').split('\n');
|
|
218
|
-
}
|
|
219
|
-
function objectKeys(value) {
|
|
220
|
-
return value && typeof value === 'object' && !Array.isArray(value) ? Object.keys(value) : [];
|
|
373
|
+
return [`--- ${label} (current)`, `+++ ${label} (proposed)`,
|
|
374
|
+
`@@ -${start + 1},${left.length - tail + trailing - start} +${start + 1},${right.length - tail + trailing - start} @@`,
|
|
375
|
+
...left.slice(start, head).map(x => ` ${x}`), ...left.slice(head, left.length - tail).map(x => `-${x}`),
|
|
376
|
+
...right.slice(head, right.length - tail).map(x => `+${x}`),
|
|
377
|
+
...left.slice(left.length - tail, left.length - tail + trailing).map(x => ` ${x}`), ''].join('\n');
|
|
378
|
+
}
|
|
379
|
+
function proposalDiff(current, proposal) {
|
|
380
|
+
const secrets = sensitiveValues(modelFrom(current));
|
|
381
|
+
try {
|
|
382
|
+
secrets.push(...sensitiveValues(modelFrom({ ...current, documents: proposal })));
|
|
383
|
+
}
|
|
384
|
+
catch { /* validation reports later */ }
|
|
385
|
+
return [...new Set([...current.documents.keys(), ...proposal.keys()])].sort().map(rel => sourceDiff(current.documents.get(rel) ?? '', proposal.get(rel) ?? '', rel, secrets)).filter(Boolean).join('\n');
|
|
221
386
|
}
|
|
387
|
+
function splitLines(source) { return source.replace(/\n$/, '').split('\n'); }
|
|
222
388
|
function restartImpact(before, after) {
|
|
223
|
-
const
|
|
224
|
-
|
|
225
|
-
const
|
|
226
|
-
const
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
roles.
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
389
|
+
const roles = [...new Set([...Object.keys(before.agents), ...Object.keys(after.agents)])]
|
|
390
|
+
.filter(id => JSON.stringify(before.agents[id]) !== JSON.stringify(after.agents[id])).sort();
|
|
391
|
+
const watchdogScheduler = JSON.stringify(before.manifest.watchdogs) !== JSON.stringify(after.manifest.watchdogs);
|
|
392
|
+
const scheduledLoops = JSON.stringify(before.manifest.loops) !== JSON.stringify(after.manifest.loops);
|
|
393
|
+
const required = roles.length > 0 || watchdogScheduler || scheduledLoops;
|
|
394
|
+
return { required, roles, watchdogScheduler, scheduledLoops,
|
|
395
|
+
summary: required ? 'Save only writes configuration; apply/restart affected roles separately.' : 'No running process needs a restart.' };
|
|
396
|
+
}
|
|
397
|
+
function result(current, before, after, proposal, redacted, preflight) {
|
|
398
|
+
return { valid: true, revision: revision(current), normalizedModel: redacted.model,
|
|
399
|
+
diff: proposalDiff(current, proposal), redactions: redacted.paths,
|
|
400
|
+
impact: restartImpact(before, after), preflight };
|
|
401
|
+
}
|
|
402
|
+
function sensitiveValues(model) {
|
|
403
|
+
const values = [];
|
|
404
|
+
const walk = (value, inSensitive = false) => {
|
|
405
|
+
if (Array.isArray(value)) {
|
|
406
|
+
for (const item of value)
|
|
407
|
+
walk(item, inSensitive);
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
if (!value || typeof value !== 'object') {
|
|
411
|
+
if (inSensitive && typeof value === 'string' && value !== REDACTED_ENV_VALUE)
|
|
412
|
+
values.push(value);
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
for (const [key, child] of Object.entries(value))
|
|
416
|
+
walk(child, inSensitive || key === 'env' || sensitive(key, false));
|
|
239
417
|
};
|
|
418
|
+
walk(model);
|
|
419
|
+
return [...new Set(values)];
|
|
420
|
+
}
|
|
421
|
+
function sourceSensitiveValues(sources) {
|
|
422
|
+
const values = [];
|
|
423
|
+
for (const [rel, source] of sources.documents) {
|
|
424
|
+
if (!['.yaml', '.yml'].includes(extname(rel).toLowerCase()))
|
|
425
|
+
continue;
|
|
426
|
+
try {
|
|
427
|
+
const parsed = parse(source);
|
|
428
|
+
values.push(...sensitiveValues({ manifest: {}, agents: { source: parsed } }));
|
|
429
|
+
}
|
|
430
|
+
catch { /* the authoritative parser reports malformed YAML */ }
|
|
431
|
+
}
|
|
432
|
+
return [...new Set(values)];
|
|
433
|
+
}
|
|
434
|
+
function safeError(error, secrets = []) {
|
|
435
|
+
let message = error instanceof Error ? error.message : String(error);
|
|
436
|
+
for (const secret of secrets.sort((a, b) => b.length - a.length))
|
|
437
|
+
if (secret)
|
|
438
|
+
message = message.split(secret).join('[redacted]');
|
|
439
|
+
message = message.replace(/[^\s:]+(?:secret|token|password)[^\s:]*/gi, '[redacted]');
|
|
440
|
+
return new FleetError(error instanceof FleetError ? error.code : 'invalid_request', message);
|
|
240
441
|
}
|