@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.
Files changed (159) hide show
  1. package/README.md +122 -133
  2. package/dist/agent-recovery-gate.d.ts +17 -0
  3. package/dist/agent-recovery-gate.js +135 -0
  4. package/dist/application/capabilities.d.ts +0 -1
  5. package/dist/application/capabilities.js +3 -13
  6. package/dist/application/fleet-query-service.d.ts +0 -3
  7. package/dist/application/fleet-query-service.js +14 -25
  8. package/dist/application/role-creation-service.d.ts +5 -24
  9. package/dist/application/role-creation-service.js +27 -67
  10. package/dist/application/role-removal-service.js +6 -3
  11. package/dist/application/role-repository.d.ts +1 -2
  12. package/dist/application/role-repository.js +7 -7
  13. package/dist/application/session-control.d.ts +0 -12
  14. package/dist/application/session-control.js +0 -42
  15. package/dist/application/session-mutations.d.ts +5 -5
  16. package/dist/application/task-room-service.d.ts +1 -1
  17. package/dist/application/task-room-service.js +154 -16
  18. package/dist/application/types.d.ts +1 -8
  19. package/dist/briefing.js +12 -11
  20. package/dist/build-info.json +5 -5
  21. package/dist/cli.js +143 -63
  22. package/dist/config-yaml.d.ts +1 -1
  23. package/dist/config.d.ts +66 -6
  24. package/dist/config.js +398 -43
  25. package/dist/creation.d.ts +1 -1
  26. package/dist/creation.js +2 -2
  27. package/dist/daemon-recovery.d.ts +93 -0
  28. package/dist/daemon-recovery.js +328 -0
  29. package/dist/docs.d.ts +1 -1
  30. package/dist/docs.js +103 -127
  31. package/dist/doctor.js +18 -11
  32. package/dist/fleet-command-audit.d.ts +160 -0
  33. package/dist/fleet-command-audit.js +433 -0
  34. package/dist/fleet-proxy.d.ts +3 -1
  35. package/dist/fleet-proxy.js +30 -8
  36. package/dist/generated-agent-source.d.ts +9 -0
  37. package/dist/generated-agent-source.js +53 -0
  38. package/dist/harness/acp-session-transport.d.ts +8 -0
  39. package/dist/harness/acp-session-transport.js +3 -0
  40. package/dist/harness/agent-session.d.ts +51 -0
  41. package/dist/harness/agent-session.js +1 -0
  42. package/dist/harness/claude-code-session.d.ts +24 -0
  43. package/dist/harness/claude-code-session.js +36 -0
  44. package/dist/harness/claude-code.d.ts +2 -1
  45. package/dist/harness/claude-code.js +41 -81
  46. package/dist/harness/codex-session.d.ts +24 -0
  47. package/dist/harness/codex-session.js +36 -0
  48. package/dist/harness/codex.d.ts +2 -1
  49. package/dist/harness/codex.js +55 -45
  50. package/dist/harness/registry.js +4 -0
  51. package/dist/harness/types.d.ts +6 -38
  52. package/dist/index.d.ts +4 -6
  53. package/dist/index.js +2 -4
  54. package/dist/init-guidance.d.ts +1 -0
  55. package/dist/init-guidance.js +6 -0
  56. package/dist/isolation/resources.d.ts +3 -7
  57. package/dist/isolation/resources.js +3 -7
  58. package/dist/model-env.d.ts +4 -6
  59. package/dist/model-env.js +5 -8
  60. package/dist/monitor.d.ts +1 -24
  61. package/dist/monitor.js +5 -140
  62. package/dist/ops.js +4 -2
  63. package/dist/owner-channel/channel.d.ts +58 -2
  64. package/dist/owner-channel/channel.js +269 -29
  65. package/dist/owner-channel/commands.d.ts +2 -0
  66. package/dist/owner-channel/commands.js +31 -13
  67. package/dist/owner-channel/state.d.ts +2 -0
  68. package/dist/owner-channel/state.js +8 -2
  69. package/dist/preset-bootstrap.d.ts +10 -0
  70. package/dist/preset-bootstrap.js +85 -0
  71. package/dist/reports/artifact.d.ts +10 -0
  72. package/dist/reports/artifact.js +32 -0
  73. package/dist/reports/index.d.ts +5 -0
  74. package/dist/reports/index.js +5 -0
  75. package/dist/reports/render.d.ts +6 -0
  76. package/dist/reports/render.js +240 -0
  77. package/dist/reports/service.d.ts +42 -0
  78. package/dist/reports/service.js +57 -0
  79. package/dist/reports/tasks.d.ts +52 -0
  80. package/dist/reports/tasks.js +177 -0
  81. package/dist/reports/types.d.ts +315 -0
  82. package/dist/reports/types.js +6 -0
  83. package/dist/resolved-plan.d.ts +3 -2
  84. package/dist/resolved-plan.js +21 -5
  85. package/dist/rooms-tasks/cli.js +225 -40
  86. package/dist/rooms-tasks/config.js +24 -13
  87. package/dist/rooms-tasks/external-worker.d.ts +1 -0
  88. package/dist/rooms-tasks/external-worker.js +14 -9
  89. package/dist/rooms-tasks/provision.js +98 -36
  90. package/dist/rooms-tasks/templates.d.ts +0 -1
  91. package/dist/rooms-tasks/templates.js +9 -76
  92. package/dist/rooms-tasks/types.d.ts +10 -16
  93. package/dist/rooms-tasks/types.js +1 -4
  94. package/dist/runner.d.ts +13 -16
  95. package/dist/runner.js +110 -122
  96. package/dist/sensitive-config.d.ts +2 -0
  97. package/dist/sensitive-config.js +4 -0
  98. package/dist/session/acp.d.ts +7 -2
  99. package/dist/session/acp.js +29 -6
  100. package/dist/session/activity.d.ts +2 -3
  101. package/dist/session/activity.js +2 -3
  102. package/dist/session/arbiter.d.ts +4 -4
  103. package/dist/session/control.d.ts +17 -4
  104. package/dist/session/control.js +35 -0
  105. package/dist/session/types.d.ts +11 -5
  106. package/dist/spawn.d.ts +8 -33
  107. package/dist/spawn.js +53 -213
  108. package/dist/supervisor/none.d.ts +1 -1
  109. package/dist/supervisor/none.js +2 -2
  110. package/dist/temp-lifecycle.d.ts +1 -1
  111. package/dist/tmux.d.ts +1 -14
  112. package/dist/tmux.js +1 -51
  113. package/dist/watchdog/config.d.ts +10 -7
  114. package/dist/watchdog/config.js +30 -18
  115. package/dist/watchdog/run.js +12 -36
  116. package/dist/web/auth.d.ts +1 -1
  117. package/dist/web/fleet-config-service.d.ts +11 -8
  118. package/dist/web/fleet-config-service.js +384 -183
  119. package/dist/web/runtime.js +4 -15
  120. package/dist/web/server.d.ts +1 -3
  121. package/dist/web/server.js +3 -14
  122. package/dist/web/topology-promote.js +26 -19
  123. package/dist/web/topology.js +2 -2
  124. package/dist/web/yaml-document-edit.js +2 -0
  125. package/dist/web-app/assets/index-59GF-gsZ.css +1 -0
  126. package/dist/web-app/assets/index-BbTc5KtZ.js +10 -0
  127. package/dist/web-app/index.html +2 -2
  128. package/examples/fleet/agents/Alice.yaml +11 -0
  129. package/examples/fleet/agents/FleetCoordinator.yaml +7 -0
  130. package/examples/fleet/brains/claude-default.yaml +5 -0
  131. package/examples/fleet/roles/coordinator.yaml +5 -0
  132. package/examples/fleet/roles/developer.yaml +3 -0
  133. package/examples/fleet.yaml +74 -0
  134. package/package.json +5 -8
  135. package/presets/fleet/agents/Agent.yaml +3 -0
  136. package/presets/fleet/agents/Architect.yaml +3 -0
  137. package/presets/fleet/agents/Critic.yaml +3 -0
  138. package/presets/fleet/agents/Developer.yaml +3 -0
  139. package/presets/fleet/agents/Secretary.yaml +3 -0
  140. package/presets/fleet/agents/Tester.yaml +3 -0
  141. package/presets/fleet/brains/claude-default.yaml +5 -0
  142. package/presets/fleet/roles/Agent.yaml +6 -0
  143. package/presets/fleet/roles/Architect.yaml +6 -0
  144. package/presets/fleet/roles/Critic.yaml +6 -0
  145. package/presets/fleet/roles/Developer.yaml +6 -0
  146. package/presets/fleet/roles/Secretary.yaml +6 -0
  147. package/presets/fleet/roles/Tester.yaml +6 -0
  148. package/presets/fleet/room_templates/pair.yaml +11 -0
  149. package/presets/fleet/room_templates/single.yaml +8 -0
  150. package/presets/fleet/room_templates/team.yaml +12 -0
  151. package/presets/fleet.yaml +7 -0
  152. package/presets/manifest.json +5 -0
  153. package/dist/session/tmux.d.ts +0 -28
  154. package/dist/session/tmux.js +0 -80
  155. package/dist/web/terminal/bridge.d.ts +0 -27
  156. package/dist/web/terminal/bridge.js +0 -317
  157. package/dist/web-app/assets/TerminalView-C_G1ID2P.js +0 -9
  158. package/dist/web-app/assets/index-BCBK78hw.js +0 -10
  159. package/dist/web-app/assets/index-DuC-xnX4.css +0 -1
@@ -1,240 +1,441 @@
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', '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 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' || 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 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) {
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(currentSource, model);
232
+ return renderModelOntoSource(source, model);
120
233
  }
121
234
  catch (error) {
122
- throw new FleetError('invalid_request', error.message);
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 digest(source) {
126
- return createHash('sha256').update(source).digest('hex');
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 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;
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 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}`);
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
- 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}`);
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, 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;
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
- 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];
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
- * 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));
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 < before.length && head < after.length && before[head] === after[head])
195
- head += 1;
363
+ while (head < left.length && head < right.length && left[head] === right[head])
364
+ head++;
196
365
  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)
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
- 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) : [];
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 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.',
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
  }