@ours.network/fleet 1.0.5 → 1.1.0-nightly.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 +90 -112
- 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 +1 -24
- package/dist/application/role-creation-service.d.ts +5 -5
- package/dist/application/role-creation-service.js +14 -9
- 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/types.d.ts +1 -8
- package/dist/briefing.js +3 -6
- package/dist/build-info.json +5 -5
- package/dist/cli.js +17 -28
- package/dist/config.d.ts +29 -6
- package/dist/config.js +320 -40
- package/dist/creation.d.ts +1 -1
- package/dist/creation.js +2 -2
- package/dist/docs.d.ts +1 -1
- package/dist/docs.js +51 -95
- package/dist/doctor.js +1 -8
- package/dist/fleet-proxy.d.ts +1 -1
- 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 +46 -0
- package/dist/harness/agent-session.js +1 -0
- package/dist/harness/claude-code-session.d.ts +22 -0
- package/dist/harness/claude-code-session.js +32 -0
- package/dist/harness/claude-code.d.ts +2 -1
- package/dist/harness/claude-code.js +39 -81
- package/dist/harness/codex-session.d.ts +22 -0
- package/dist/harness/codex-session.js +32 -0
- package/dist/harness/codex.d.ts +2 -1
- package/dist/harness/codex.js +49 -45
- package/dist/harness/registry.js +4 -0
- package/dist/harness/types.d.ts +6 -38
- package/dist/index.d.ts +3 -5
- package/dist/index.js +1 -3
- package/dist/init-guidance.d.ts +1 -0
- package/dist/init-guidance.js +5 -0
- package/dist/isolation/resources.d.ts +3 -7
- package/dist/isolation/resources.js +3 -7
- package/dist/model-env.d.ts +2 -4
- package/dist/model-env.js +4 -7
- 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 +2 -2
- package/dist/resolved-plan.d.ts +3 -2
- package/dist/resolved-plan.js +21 -5
- package/dist/runner.d.ts +5 -15
- package/dist/runner.js +26 -102
- package/dist/sensitive-config.d.ts +2 -0
- package/dist/sensitive-config.js +4 -0
- package/dist/session/acp.d.ts +2 -2
- package/dist/session/acp.js +2 -3
- 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 +3 -3
- package/dist/session/types.d.ts +11 -5
- package/dist/spawn.d.ts +4 -4
- package/dist/spawn.js +60 -32
- package/dist/supervisor/none.d.ts +1 -1
- package/dist/supervisor/none.js +2 -2
- package/dist/tmux.d.ts +1 -14
- package/dist/tmux.js +1 -51
- package/dist/watchdog/config.js +6 -4
- package/dist/watchdog/run.js +8 -23
- 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 +383 -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 +18 -18
- 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-BbE9EX6n.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 +4 -8
- 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,440 @@
|
|
|
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'];
|
|
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' && !['.yaml', '.yml'].includes(extname(name).toLowerCase()))
|
|
180
|
+
continue;
|
|
181
|
+
documents.set(join(kind, name), trustedFile(join(dir, name)));
|
|
104
182
|
}
|
|
105
|
-
return { path: candidate, remove: () => rmSync(candidate, { force: true }) };
|
|
106
183
|
}
|
|
184
|
+
return { manifest, root, documents };
|
|
185
|
+
}
|
|
186
|
+
function modelFrom(sources) {
|
|
187
|
+
const manifestKey = basename(sources.manifest);
|
|
188
|
+
const manifest = parseFleetDocument(sources.manifest, sources.documents.get(manifestKey), 'strict').value;
|
|
189
|
+
const agents = {};
|
|
190
|
+
for (const [rel, source] of sources.documents)
|
|
191
|
+
if (rel.startsWith(`agents/`)) {
|
|
192
|
+
const id = basename(rel, extname(rel));
|
|
193
|
+
agents[id] = parseFleetDocument(absoluteFor(sources.manifest, rel), source, 'strict').value;
|
|
194
|
+
}
|
|
195
|
+
return { manifest, agents };
|
|
107
196
|
}
|
|
108
197
|
function assertModel(value) {
|
|
109
198
|
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
110
|
-
throw new FleetError('invalid_request', 'model must be
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
199
|
+
throw new FleetError('invalid_request', 'model must be an object');
|
|
200
|
+
const input = value;
|
|
201
|
+
if (Object.keys(input).sort().join(',') !== 'agents,manifest')
|
|
202
|
+
throw new FleetError('invalid_request', 'model allows exactly manifest and agents');
|
|
203
|
+
for (const key of ['manifest', 'agents'])
|
|
204
|
+
if (!input[key] || typeof input[key] !== 'object' || Array.isArray(input[key]))
|
|
205
|
+
throw new FleetError('invalid_request', `${key} must be an object`);
|
|
206
|
+
for (const [id, doc] of Object.entries(input.agents)) {
|
|
207
|
+
if (!ROLE_NAME_RE.test(id))
|
|
208
|
+
throw new FleetError('invalid_request', `invalid Agent ID '${id}'`);
|
|
209
|
+
if (!doc || typeof doc !== 'object' || Array.isArray(doc))
|
|
210
|
+
throw new FleetError('invalid_request', `Agent '${id}' must be an object`);
|
|
211
|
+
}
|
|
212
|
+
return structuredClone(input);
|
|
213
|
+
}
|
|
214
|
+
function renderProposal(current, model) {
|
|
215
|
+
const next = new Map();
|
|
216
|
+
const manifestRel = basename(current.manifest);
|
|
217
|
+
next.set(manifestRel, render(current.documents.get(manifestRel), model.manifest));
|
|
218
|
+
for (const [id, doc] of Object.entries(model.agents).sort(([a], [b]) => a.localeCompare(b))) {
|
|
219
|
+
const oldRel = [...current.documents.keys()].find(rel => rel.startsWith('agents/')
|
|
220
|
+
&& basename(rel, extname(rel)) === id);
|
|
221
|
+
next.set(oldRel ?? join('agents', `${id}.yaml`), oldRel
|
|
222
|
+
? render(current.documents.get(oldRel), doc) : stringify(doc));
|
|
223
|
+
}
|
|
224
|
+
for (const [rel, source] of current.documents)
|
|
225
|
+
if (rel.startsWith('roles/') || rel.startsWith('brains/'))
|
|
226
|
+
next.set(rel, source);
|
|
227
|
+
return next;
|
|
228
|
+
}
|
|
229
|
+
function render(source, model) {
|
|
118
230
|
try {
|
|
119
|
-
return renderModelOntoSource(
|
|
231
|
+
return renderModelOntoSource(source, model);
|
|
120
232
|
}
|
|
121
233
|
catch (error) {
|
|
122
|
-
throw
|
|
234
|
+
throw safeError(error);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
function stageProposal(manifest, current, proposal) {
|
|
238
|
+
const dir = mkdtempSync(join(dirname(manifest), '.fleet-web-preview-'));
|
|
239
|
+
chmodSync(dir, 0o700);
|
|
240
|
+
const stagedManifest = join(dir, basename(manifest));
|
|
241
|
+
const stagedRoot = splitRootFor(stagedManifest);
|
|
242
|
+
mkdirSync(stagedRoot, { recursive: true, mode: 0o700 });
|
|
243
|
+
mkdirSync(join(stagedRoot, 'agents'), { mode: 0o700 });
|
|
244
|
+
for (const [rel, source] of proposal) {
|
|
245
|
+
const target = rel === basename(manifest) ? stagedManifest : join(stagedRoot, rel);
|
|
246
|
+
mkdirSync(dirname(target), { recursive: true, mode: 0o700 });
|
|
247
|
+
replaceFileAtomically(target, source, 0o600);
|
|
123
248
|
}
|
|
249
|
+
return { manifest: stagedManifest, remove: () => rmSync(dir, { recursive: true, force: true }) };
|
|
124
250
|
}
|
|
125
|
-
function
|
|
126
|
-
return
|
|
251
|
+
function changedDocuments(current, proposal) {
|
|
252
|
+
return [...new Set([...current.documents.keys(), ...proposal.keys()])]
|
|
253
|
+
.filter(rel => current.documents.get(rel) !== proposal.get(rel)).sort();
|
|
127
254
|
}
|
|
128
|
-
function
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
255
|
+
function recheckCollisions(current, proposal, changed) {
|
|
256
|
+
for (const rel of changed) {
|
|
257
|
+
const target = absoluteFor(current.manifest, rel);
|
|
258
|
+
const existed = current.documents.has(rel);
|
|
259
|
+
if (!existed && proposal.has(rel) && existsSync(target))
|
|
260
|
+
throw new FleetError('stale_state', `configuration collision at ${rel}`);
|
|
261
|
+
if (existed)
|
|
262
|
+
trustedFile(target);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
function assertWritableTarget(path, existed) {
|
|
266
|
+
if (!existsSync(path)) {
|
|
267
|
+
if (existed)
|
|
268
|
+
throw new FleetError('stale_state', `${path} disappeared`);
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
trustedFile(path);
|
|
272
|
+
}
|
|
273
|
+
function absoluteFor(manifest, rel) {
|
|
274
|
+
return rel === basename(manifest) ? manifest : join(splitRootFor(manifest), rel);
|
|
275
|
+
}
|
|
276
|
+
function revision(sources) {
|
|
277
|
+
const hash = createHash('sha256');
|
|
278
|
+
for (const [rel, bytes] of [...sources.documents].sort(([a], [b]) => a.localeCompare(b)))
|
|
279
|
+
hash.update(`${Buffer.byteLength(rel)}:${rel}:${Buffer.byteLength(bytes)}:`).update(bytes);
|
|
280
|
+
return hash.digest('hex');
|
|
281
|
+
}
|
|
282
|
+
function assertRevision(expected, sources) {
|
|
283
|
+
if (!expected || expected !== revision(sources))
|
|
284
|
+
throw new FleetError('stale_state', 'configuration sources changed since opened; reload before saving');
|
|
285
|
+
}
|
|
286
|
+
function sensitive(key, inEnv) {
|
|
287
|
+
return inEnv || isSensitiveConfigKey(key);
|
|
140
288
|
}
|
|
141
289
|
function redactModel(input) {
|
|
142
290
|
const model = structuredClone(input);
|
|
143
291
|
const paths = [];
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
292
|
+
const walk = (value, path, inEnv = false) => {
|
|
293
|
+
if (Array.isArray(value)) {
|
|
294
|
+
value.forEach((item, i) => {
|
|
295
|
+
if (inEnv && item !== null && typeof item !== 'object') {
|
|
296
|
+
value[i] = REDACTED_ENV_VALUE;
|
|
297
|
+
paths.push(`${path}/${i}`);
|
|
298
|
+
}
|
|
299
|
+
else
|
|
300
|
+
walk(item, `${path}/${i}`, inEnv);
|
|
301
|
+
});
|
|
302
|
+
return;
|
|
152
303
|
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
for (const key of
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
304
|
+
if (!value || typeof value !== 'object')
|
|
305
|
+
return;
|
|
306
|
+
for (const [key, child] of Object.entries(value)) {
|
|
307
|
+
const next = `${path}/${key}`;
|
|
308
|
+
if (sensitive(key, inEnv) && child !== null && typeof child !== 'object') {
|
|
309
|
+
value[key] = REDACTED_ENV_VALUE;
|
|
310
|
+
paths.push(next);
|
|
159
311
|
}
|
|
312
|
+
else
|
|
313
|
+
walk(child, next, inEnv || key === 'env' || sensitive(key, false));
|
|
314
|
+
}
|
|
315
|
+
};
|
|
316
|
+
walk(model, '');
|
|
160
317
|
return { model, paths: paths.sort() };
|
|
161
318
|
}
|
|
162
|
-
function restoreRedactions(next,
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
319
|
+
function restoreRedactions(next, old, allowed) {
|
|
320
|
+
const walk = (value, previous, path) => {
|
|
321
|
+
if (Array.isArray(value)) {
|
|
322
|
+
value.forEach((item, i) => {
|
|
323
|
+
const prior = Array.isArray(previous) ? previous[i] : undefined;
|
|
324
|
+
if (item === REDACTED_ENV_VALUE) {
|
|
325
|
+
if (!allowed.has(`${path}/${i}`) || prior === undefined)
|
|
326
|
+
throw new FleetError('invalid_request', `${path}/${i} has no prior redacted value`);
|
|
327
|
+
value[i] = structuredClone(prior);
|
|
328
|
+
}
|
|
329
|
+
else
|
|
330
|
+
walk(item, prior, `${path}/${i}`);
|
|
331
|
+
});
|
|
332
|
+
return;
|
|
172
333
|
}
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
334
|
+
if (!value || typeof value !== 'object')
|
|
335
|
+
return;
|
|
336
|
+
for (const [key, child] of Object.entries(value)) {
|
|
337
|
+
const prior = previous && typeof previous === 'object' && !Array.isArray(previous)
|
|
338
|
+
? previous[key] : undefined;
|
|
339
|
+
if (child === REDACTED_ENV_VALUE) {
|
|
340
|
+
if (!allowed.has(`${path}/${key}`) || prior === undefined)
|
|
341
|
+
throw new FleetError('invalid_request', `${path}/${key} has no prior redacted value`);
|
|
342
|
+
value[key] = structuredClone(prior);
|
|
343
|
+
}
|
|
344
|
+
else
|
|
345
|
+
walk(child, prior, `${path}/${key}`);
|
|
182
346
|
}
|
|
347
|
+
};
|
|
348
|
+
walk(next, old, '');
|
|
183
349
|
return next;
|
|
184
350
|
}
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
351
|
+
function maskSource(source, secrets) {
|
|
352
|
+
let masked = redactSourceSecrets(source, REDACTED_ENV_VALUE);
|
|
353
|
+
for (const secret of secrets.sort((a, b) => b.length - a.length))
|
|
354
|
+
if (secret)
|
|
355
|
+
masked = masked.split(secret).join(REDACTED_ENV_VALUE);
|
|
356
|
+
return masked;
|
|
357
|
+
}
|
|
358
|
+
function sourceDiff(before, after, label, secrets) {
|
|
359
|
+
const left = splitLines(maskSource(before, secrets));
|
|
360
|
+
const right = splitLines(maskSource(after, secrets));
|
|
193
361
|
let head = 0;
|
|
194
|
-
while (head <
|
|
195
|
-
head
|
|
362
|
+
while (head < left.length && head < right.length && left[head] === right[head])
|
|
363
|
+
head++;
|
|
196
364
|
let tail = 0;
|
|
197
|
-
while (tail <
|
|
198
|
-
&&
|
|
199
|
-
tail
|
|
200
|
-
if (head ===
|
|
365
|
+
while (tail < left.length - head && tail < right.length - head
|
|
366
|
+
&& left[left.length - 1 - tail] === right[right.length - 1 - tail])
|
|
367
|
+
tail++;
|
|
368
|
+
if (head === left.length && head === right.length)
|
|
201
369
|
return '';
|
|
202
370
|
const start = Math.max(0, head - DIFF_CONTEXT_LINES);
|
|
203
371
|
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) : [];
|
|
372
|
+
return [`--- ${label} (current)`, `+++ ${label} (proposed)`,
|
|
373
|
+
`@@ -${start + 1},${left.length - tail + trailing - start} +${start + 1},${right.length - tail + trailing - start} @@`,
|
|
374
|
+
...left.slice(start, head).map(x => ` ${x}`), ...left.slice(head, left.length - tail).map(x => `-${x}`),
|
|
375
|
+
...right.slice(head, right.length - tail).map(x => `+${x}`),
|
|
376
|
+
...left.slice(left.length - tail, left.length - tail + trailing).map(x => ` ${x}`), ''].join('\n');
|
|
377
|
+
}
|
|
378
|
+
function proposalDiff(current, proposal) {
|
|
379
|
+
const secrets = sensitiveValues(modelFrom(current));
|
|
380
|
+
try {
|
|
381
|
+
secrets.push(...sensitiveValues(modelFrom({ ...current, documents: proposal })));
|
|
382
|
+
}
|
|
383
|
+
catch { /* validation reports later */ }
|
|
384
|
+
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
385
|
}
|
|
386
|
+
function splitLines(source) { return source.replace(/\n$/, '').split('\n'); }
|
|
222
387
|
function restartImpact(before, after) {
|
|
223
|
-
const
|
|
224
|
-
|
|
225
|
-
const
|
|
226
|
-
const
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
roles.
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
388
|
+
const roles = [...new Set([...Object.keys(before.agents), ...Object.keys(after.agents)])]
|
|
389
|
+
.filter(id => JSON.stringify(before.agents[id]) !== JSON.stringify(after.agents[id])).sort();
|
|
390
|
+
const watchdogScheduler = JSON.stringify(before.manifest.watchdogs) !== JSON.stringify(after.manifest.watchdogs);
|
|
391
|
+
const scheduledLoops = JSON.stringify(before.manifest.loops) !== JSON.stringify(after.manifest.loops);
|
|
392
|
+
const required = roles.length > 0 || watchdogScheduler || scheduledLoops;
|
|
393
|
+
return { required, roles, watchdogScheduler, scheduledLoops,
|
|
394
|
+
summary: required ? 'Save only writes configuration; apply/restart affected roles separately.' : 'No running process needs a restart.' };
|
|
395
|
+
}
|
|
396
|
+
function result(current, before, after, proposal, redacted, preflight) {
|
|
397
|
+
return { valid: true, revision: revision(current), normalizedModel: redacted.model,
|
|
398
|
+
diff: proposalDiff(current, proposal), redactions: redacted.paths,
|
|
399
|
+
impact: restartImpact(before, after), preflight };
|
|
400
|
+
}
|
|
401
|
+
function sensitiveValues(model) {
|
|
402
|
+
const values = [];
|
|
403
|
+
const walk = (value, inSensitive = false) => {
|
|
404
|
+
if (Array.isArray(value)) {
|
|
405
|
+
for (const item of value)
|
|
406
|
+
walk(item, inSensitive);
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
if (!value || typeof value !== 'object') {
|
|
410
|
+
if (inSensitive && typeof value === 'string' && value !== REDACTED_ENV_VALUE)
|
|
411
|
+
values.push(value);
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
for (const [key, child] of Object.entries(value))
|
|
415
|
+
walk(child, inSensitive || key === 'env' || sensitive(key, false));
|
|
239
416
|
};
|
|
417
|
+
walk(model);
|
|
418
|
+
return [...new Set(values)];
|
|
419
|
+
}
|
|
420
|
+
function sourceSensitiveValues(sources) {
|
|
421
|
+
const values = [];
|
|
422
|
+
for (const [rel, source] of sources.documents) {
|
|
423
|
+
if (!['.yaml', '.yml'].includes(extname(rel).toLowerCase()))
|
|
424
|
+
continue;
|
|
425
|
+
try {
|
|
426
|
+
const parsed = parse(source);
|
|
427
|
+
values.push(...sensitiveValues({ manifest: {}, agents: { source: parsed } }));
|
|
428
|
+
}
|
|
429
|
+
catch { /* the authoritative parser reports malformed YAML */ }
|
|
430
|
+
}
|
|
431
|
+
return [...new Set(values)];
|
|
432
|
+
}
|
|
433
|
+
function safeError(error, secrets = []) {
|
|
434
|
+
let message = error instanceof Error ? error.message : String(error);
|
|
435
|
+
for (const secret of secrets.sort((a, b) => b.length - a.length))
|
|
436
|
+
if (secret)
|
|
437
|
+
message = message.split(secret).join('[redacted]');
|
|
438
|
+
message = message.replace(/[^\s:]+(?:secret|token|password)[^\s:]*/gi, '[redacted]');
|
|
439
|
+
return new FleetError(error instanceof FleetError ? error.code : 'invalid_request', message);
|
|
240
440
|
}
|