@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.
Files changed (100) hide show
  1. package/README.md +90 -112
  2. package/dist/application/capabilities.d.ts +0 -1
  3. package/dist/application/capabilities.js +3 -13
  4. package/dist/application/fleet-query-service.d.ts +0 -3
  5. package/dist/application/fleet-query-service.js +1 -24
  6. package/dist/application/role-creation-service.d.ts +5 -5
  7. package/dist/application/role-creation-service.js +14 -9
  8. package/dist/application/role-removal-service.js +6 -3
  9. package/dist/application/role-repository.d.ts +1 -2
  10. package/dist/application/role-repository.js +7 -7
  11. package/dist/application/session-control.d.ts +0 -12
  12. package/dist/application/session-control.js +0 -42
  13. package/dist/application/session-mutations.d.ts +5 -5
  14. package/dist/application/types.d.ts +1 -8
  15. package/dist/briefing.js +3 -6
  16. package/dist/build-info.json +5 -5
  17. package/dist/cli.js +17 -28
  18. package/dist/config.d.ts +29 -6
  19. package/dist/config.js +320 -40
  20. package/dist/creation.d.ts +1 -1
  21. package/dist/creation.js +2 -2
  22. package/dist/docs.d.ts +1 -1
  23. package/dist/docs.js +51 -95
  24. package/dist/doctor.js +1 -8
  25. package/dist/fleet-proxy.d.ts +1 -1
  26. package/dist/generated-agent-source.d.ts +9 -0
  27. package/dist/generated-agent-source.js +53 -0
  28. package/dist/harness/acp-session-transport.d.ts +8 -0
  29. package/dist/harness/acp-session-transport.js +3 -0
  30. package/dist/harness/agent-session.d.ts +46 -0
  31. package/dist/harness/agent-session.js +1 -0
  32. package/dist/harness/claude-code-session.d.ts +22 -0
  33. package/dist/harness/claude-code-session.js +32 -0
  34. package/dist/harness/claude-code.d.ts +2 -1
  35. package/dist/harness/claude-code.js +39 -81
  36. package/dist/harness/codex-session.d.ts +22 -0
  37. package/dist/harness/codex-session.js +32 -0
  38. package/dist/harness/codex.d.ts +2 -1
  39. package/dist/harness/codex.js +49 -45
  40. package/dist/harness/registry.js +4 -0
  41. package/dist/harness/types.d.ts +6 -38
  42. package/dist/index.d.ts +3 -5
  43. package/dist/index.js +1 -3
  44. package/dist/init-guidance.d.ts +1 -0
  45. package/dist/init-guidance.js +5 -0
  46. package/dist/isolation/resources.d.ts +3 -7
  47. package/dist/isolation/resources.js +3 -7
  48. package/dist/model-env.d.ts +2 -4
  49. package/dist/model-env.js +4 -7
  50. package/dist/monitor.d.ts +1 -24
  51. package/dist/monitor.js +5 -140
  52. package/dist/ops.js +4 -2
  53. package/dist/owner-channel/channel.d.ts +2 -2
  54. package/dist/resolved-plan.d.ts +3 -2
  55. package/dist/resolved-plan.js +21 -5
  56. package/dist/runner.d.ts +5 -15
  57. package/dist/runner.js +26 -102
  58. package/dist/sensitive-config.d.ts +2 -0
  59. package/dist/sensitive-config.js +4 -0
  60. package/dist/session/acp.d.ts +2 -2
  61. package/dist/session/acp.js +2 -3
  62. package/dist/session/activity.d.ts +2 -3
  63. package/dist/session/activity.js +2 -3
  64. package/dist/session/arbiter.d.ts +4 -4
  65. package/dist/session/control.d.ts +3 -3
  66. package/dist/session/types.d.ts +11 -5
  67. package/dist/spawn.d.ts +4 -4
  68. package/dist/spawn.js +60 -32
  69. package/dist/supervisor/none.d.ts +1 -1
  70. package/dist/supervisor/none.js +2 -2
  71. package/dist/tmux.d.ts +1 -14
  72. package/dist/tmux.js +1 -51
  73. package/dist/watchdog/config.js +6 -4
  74. package/dist/watchdog/run.js +8 -23
  75. package/dist/web/auth.d.ts +1 -1
  76. package/dist/web/fleet-config-service.d.ts +11 -8
  77. package/dist/web/fleet-config-service.js +383 -183
  78. package/dist/web/runtime.js +4 -15
  79. package/dist/web/server.d.ts +1 -3
  80. package/dist/web/server.js +3 -14
  81. package/dist/web/topology-promote.js +18 -18
  82. package/dist/web/topology.js +2 -2
  83. package/dist/web/yaml-document-edit.js +2 -0
  84. package/dist/web-app/assets/index-59GF-gsZ.css +1 -0
  85. package/dist/web-app/assets/index-BbE9EX6n.js +10 -0
  86. package/dist/web-app/index.html +2 -2
  87. package/examples/fleet/agents/Alice.yaml +11 -0
  88. package/examples/fleet/agents/FleetCoordinator.yaml +7 -0
  89. package/examples/fleet/brains/claude-default.yaml +5 -0
  90. package/examples/fleet/roles/coordinator.yaml +5 -0
  91. package/examples/fleet/roles/developer.yaml +3 -0
  92. package/examples/fleet.yaml +74 -0
  93. package/package.json +4 -8
  94. package/dist/session/tmux.d.ts +0 -28
  95. package/dist/session/tmux.js +0 -80
  96. package/dist/web/terminal/bridge.d.ts +0 -27
  97. package/dist/web/terminal/bridge.js +0 -317
  98. package/dist/web-app/assets/TerminalView-C_G1ID2P.js +0 -9
  99. package/dist/web-app/assets/index-BCBK78hw.js +0 -10
  100. package/dist/web-app/assets/index-DuC-xnX4.css +0 -1
@@ -1,240 +1,440 @@
1
- import { createHash, randomUUID } from 'node:crypto';
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 source = this.readSource();
23
- const parsed = parseFleetDocument(this.path, source, 'strict').value;
24
- const redacted = redactModel(parsed);
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: existsSync(this.path),
27
- firstRun: !existsSync(this.path), revision: digest(source),
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, model) {
32
- const currentSource = this.readSource();
33
- this.assertRevision(baseRevision, currentSource);
34
- const current = parseFleetDocument(this.path, currentSource, 'strict').value;
35
- const restored = restoreRedactions(assertModel(model), current);
36
- const nextSource = render(currentSource, restored);
37
- const candidate = this.validateCandidate(nextSource);
38
- const redactedNext = redactModel(restored);
39
- const preflight = await this.preflight(candidate.path).finally(candidate.remove);
40
- return {
41
- valid: true, revision: digest(currentSource), normalizedModel: redactedNext.model,
42
- diff: sourceDiff(currentSource, nextSource),
43
- redactions: redactedNext.paths,
44
- impact: restartImpact(current, restored), preflight,
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, model) {
48
- return withFileLock(`${this.path}.web-edit.lock`, async () => {
49
- const currentSource = this.readSource();
50
- this.assertRevision(baseRevision, currentSource);
51
- const current = parseFleetDocument(this.path, currentSource, 'strict').value;
52
- const restored = restoreRedactions(assertModel(model), current);
53
- const nextSource = render(currentSource, restored);
54
- const candidate = this.validateCandidate(nextSource);
55
- const preflight = await this.preflight(candidate.path).finally(candidate.remove);
56
- // The lock coordinates trusted web/agent writers. An operator's editor does
57
- // not take it, so re-check immediately before replacement as well.
58
- this.assertRevision(baseRevision, this.readSource());
59
- const redactedNext = redactModel(restored);
60
- let backup;
61
- if (existsSync(this.path)) {
62
- backup = `${basename(this.path)}.backup-${new Date().toISOString().replace(/[:.]/g, '-')}-${randomUUID().slice(0, 8)}`;
63
- replaceFileAtomically(join(dirname(this.path), backup), currentSource, 0o600);
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
- replaceFileAtomically(this.path, nextSource, 0o600);
66
- chmodSync(this.path, 0o600);
67
- return {
68
- saved: true, valid: true, revision: digest(currentSource), newRevision: digest(nextSource),
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
- readSource() {
77
- if (!existsSync(this.path))
78
- return 'roles: {}\n';
79
- const stat = lstatSync(this.path);
80
- const uid = process.getuid?.();
81
- if (!stat.isFile() || stat.isSymbolicLink())
82
- throw new FleetError('invalid_request', 'fleet configuration must be a regular non-symlink file');
83
- if (stat.size > MAX_CONFIG_BYTES)
84
- throw new FleetError('invalid_request', `fleet configuration exceeds ${MAX_CONFIG_BYTES} bytes`);
85
- if (uid !== undefined && stat.uid !== uid)
86
- throw new FleetError('forbidden', 'fleet configuration is not owned by the current user');
87
- return readFileSync(this.path, 'utf8');
88
- }
89
- assertRevision(expected, source) {
90
- if (!expected || expected !== digest(source))
91
- throw new FleetError('stale_state', 'fleet.yaml changed since it was opened; reload before saving');
92
- }
93
- validateCandidate(source) {
94
- if (Buffer.byteLength(source) > MAX_CONFIG_BYTES)
95
- throw new FleetError('invalid_request', `fleet configuration exceeds ${MAX_CONFIG_BYTES} bytes`);
96
- const candidate = join(dirname(this.path), `.${basename(this.path)}.preview-${process.pid}-${randomUUID()}.yaml`);
97
- replaceFileAtomically(candidate, source, 0o600);
98
- try {
99
- loadConfig(candidate, { yamlMode: 'strict' });
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
- catch (error) {
102
- rmSync(candidate, { force: true });
103
- throw new FleetError('invalid_request', error.message);
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 a JSON object');
111
- return structuredClone(value);
112
- }
113
- /**
114
- * Apply the edited model onto the user's own document rather than re-serializing
115
- * it, so comments, key order and formatting outside the edit survive the save.
116
- */
117
- function render(currentSource, model) {
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(currentSource, model);
231
+ return renderModelOntoSource(source, model);
120
232
  }
121
233
  catch (error) {
122
- throw new FleetError('invalid_request', error.message);
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 digest(source) {
126
- return createHash('sha256').update(source).digest('hex');
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 envMaps(model) {
129
- const maps = [];
130
- const add = (value, path) => {
131
- if (value && typeof value === 'object' && !Array.isArray(value))
132
- maps.push({ path, map: value });
133
- };
134
- add(model.defaults?.env, 'defaults.env');
135
- const roles = model.roles;
136
- if (roles && typeof roles === 'object' && !Array.isArray(roles))
137
- for (const [name, raw] of Object.entries(roles))
138
- add(raw?.env, `roles.${name}.env`);
139
- return maps;
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 secretVars = new Set();
145
- for (const item of envMaps(model))
146
- for (const [key, value] of Object.entries(item.map)) {
147
- if (typeof value === 'string')
148
- for (const match of value.matchAll(/\$\{(\w+)\}/g))
149
- secretVars.add(match[1]);
150
- item.map[key] = REDACTED_ENV_VALUE;
151
- paths.push(`${item.path}.${key}`);
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
- const vars = model.vars;
154
- if (vars && typeof vars === 'object' && !Array.isArray(vars))
155
- for (const key of secretVars)
156
- if (key in vars) {
157
- vars[key] = REDACTED_ENV_VALUE;
158
- paths.push(`vars.${key}`);
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, current) {
163
- const oldByPath = new Map(envMaps(current).map(item => [item.path, item.map]));
164
- for (const item of envMaps(next))
165
- for (const [key, value] of Object.entries(item.map)) {
166
- if (value !== REDACTED_ENV_VALUE)
167
- continue;
168
- const previous = oldByPath.get(item.path)?.[key];
169
- if (previous === undefined)
170
- throw new FleetError('invalid_request', `${item.path}.${key} uses a redaction marker with no prior value`);
171
- item.map[key] = previous;
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
- const nextVars = next.vars;
174
- const oldVars = current.vars;
175
- if (nextVars)
176
- for (const [key, value] of Object.entries(nextVars)) {
177
- if (value !== REDACTED_ENV_VALUE)
178
- continue;
179
- if (!oldVars || oldVars[key] === undefined)
180
- throw new FleetError('invalid_request', `vars.${key} uses a redaction marker with no prior value`);
181
- nextVars[key] = oldVars[key];
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
- * Unified-style diff of the real file text with env secrets masked. Common
187
- * leading/trailing lines are trimmed, so a surgical edit reviews as a small
188
- * hunk instead of the whole configuration twice.
189
- */
190
- function sourceDiff(beforeSource, afterSource) {
191
- const before = splitLines(redactSourceSecrets(beforeSource, REDACTED_ENV_VALUE));
192
- const after = splitLines(redactSourceSecrets(afterSource, REDACTED_ENV_VALUE));
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 < before.length && head < after.length && before[head] === after[head])
195
- head += 1;
362
+ while (head < left.length && head < right.length && left[head] === right[head])
363
+ head++;
196
364
  let tail = 0;
197
- while (tail < before.length - head && tail < after.length - head
198
- && before[before.length - 1 - tail] === after[after.length - 1 - tail])
199
- tail += 1;
200
- if (head === before.length && head === after.length)
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
- const beforeSpan = before.length - tail + trailing - start;
205
- const afterSpan = after.length - tail + trailing - start;
206
- return [
207
- '--- fleet.yaml (current)', '+++ fleet.yaml (proposed)',
208
- `@@ -${start + 1},${beforeSpan} +${start + 1},${afterSpan} @@`,
209
- ...before.slice(start, head).map(line => ` ${line}`),
210
- ...before.slice(head, before.length - tail).map(line => `-${line}`),
211
- ...after.slice(head, after.length - tail).map(line => `+${line}`),
212
- ...before.slice(before.length - tail, before.length - tail + trailing).map(line => ` ${line}`),
213
- '',
214
- ].join('\n');
215
- }
216
- function splitLines(source) {
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 changed = (key) => JSON.stringify(before[key]) !== JSON.stringify(after[key]);
224
- const beforeRoles = (before.roles ?? {});
225
- const afterRoles = (after.roles ?? {});
226
- const roles = [...new Set([...objectKeys(beforeRoles), ...objectKeys(afterRoles)])]
227
- .filter(name => JSON.stringify(beforeRoles[name]) !== JSON.stringify(afterRoles[name])).sort();
228
- if (changed('defaults'))
229
- roles.splice(0, roles.length, ...objectKeys(afterRoles).sort());
230
- const watchdogScheduler = changed('watchdogs');
231
- const scheduledLoops = changed('loops');
232
- const required = roles.length > 0 || watchdogScheduler || scheduledLoops || changed('vars');
233
- const parts = [roles.length ? `${roles.length} role${roles.length === 1 ? '' : 's'}` : '',
234
- watchdogScheduler ? 'watchdog scheduler' : '', scheduledLoops ? 'scheduled loops' : ''].filter(Boolean);
235
- return {
236
- required, roles, watchdogScheduler, scheduledLoops,
237
- summary: required ? `Save only writes configuration; apply/restart ${parts.join(', ') || 'affected roles'} separately.`
238
- : 'No running process needs a restart.',
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
  }