@ours.network/fleet 1.2.0-nightly.3 → 1.2.0-nightly.30
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 +158 -28
- package/dist/account-origin.d.ts +2 -0
- package/dist/account-origin.js +6 -0
- package/dist/agent-ours/bridge.d.ts +3 -0
- package/dist/agent-ours/bridge.js +178 -0
- package/dist/agent-ours/controller.d.ts +14 -0
- package/dist/agent-ours/controller.js +60 -0
- package/dist/agent-ours/correspondence.d.ts +10 -0
- package/dist/agent-ours/correspondence.js +1 -0
- package/dist/agent-ours/file-rpc.d.ts +23 -0
- package/dist/agent-ours/file-rpc.js +123 -0
- package/dist/agent-ours/harness.d.ts +14 -0
- package/dist/agent-ours/harness.js +72 -0
- package/dist/agent-ours/mcp-endpoint.d.ts +15 -0
- package/dist/agent-ours/mcp-endpoint.js +111 -0
- package/dist/agent-ours/runtime.d.ts +59 -0
- package/dist/agent-ours/runtime.js +283 -0
- package/dist/agent-ours/service.d.ts +23 -0
- package/dist/agent-ours/service.js +372 -0
- package/dist/agent-ours/state.d.ts +45 -0
- package/dist/agent-ours/state.js +95 -0
- package/dist/agent-ours/wire.d.ts +14 -0
- package/dist/agent-ours/wire.js +55 -0
- package/dist/application/conversation-history.d.ts +25 -0
- package/dist/application/conversation-history.js +189 -0
- package/dist/application/fleet-query-service.d.ts +1 -1
- package/dist/application/fleet-query-service.js +2 -2
- package/dist/application/legacy-supervisor-identity.d.ts +7 -0
- package/dist/application/legacy-supervisor-identity.js +43 -0
- package/dist/application/model-catalog.d.ts +1 -1
- package/dist/application/model-catalog.js +5 -2
- package/dist/application/preset-provenance.d.ts +19 -0
- package/dist/application/preset-provenance.js +59 -0
- package/dist/application/room-layout-definitions.d.ts +45 -0
- package/dist/application/room-layout-definitions.js +130 -0
- package/dist/application/session-control.d.ts +18 -0
- package/dist/application/session-control.js +54 -3
- package/dist/application/supervisor-ours-tools.d.ts +159 -0
- package/dist/application/supervisor-ours-tools.js +113 -0
- package/dist/application/task-layouts.d.ts +91 -0
- package/dist/application/task-layouts.js +249 -0
- package/dist/application/task-room-service.d.ts +99 -3
- package/dist/application/task-room-service.js +296 -31
- package/dist/atomic-file.d.ts +5 -0
- package/dist/atomic-file.js +62 -1
- package/dist/briefing.js +10 -59
- package/dist/build-info.json +6 -4
- package/dist/capabilities.d.ts +3 -1
- package/dist/capabilities.js +4 -0
- package/dist/cli.js +109 -3
- package/dist/client-profile.d.ts +5 -16
- package/dist/client-profile.js +4 -112
- package/dist/config.d.ts +4 -0
- package/dist/config.js +7 -0
- package/dist/creation.d.ts +1 -1
- package/dist/creation.js +5 -16
- package/dist/daemon-recovery.d.ts +1 -8
- package/dist/daemon-recovery.js +8 -113
- package/dist/docs.d.ts +3 -3
- package/dist/docs.js +43 -23
- package/dist/doctor.d.ts +1 -1
- package/dist/doctor.js +46 -59
- package/dist/erased-resources.d.ts +9 -0
- package/dist/erased-resources.js +87 -0
- package/dist/fleet-command-audit.d.ts +1 -0
- package/dist/fleet-command-audit.js +12 -7
- package/dist/harness/agent-session.d.ts +2 -0
- package/dist/harness/claude-code-session.js +4 -2
- package/dist/harness/codex-app-server-proxy.d.ts +4 -1
- package/dist/harness/codex-app-server-proxy.js +41 -5
- package/dist/harness/codex-session.js +9 -2
- package/dist/harness/codex.d.ts +3 -2
- package/dist/harness/codex.js +4 -3
- package/dist/harness/hermes-session.js +7 -1
- package/dist/init-wizard.js +20 -9
- package/dist/isolation/policy.js +2 -0
- package/dist/isolation/types.d.ts +5 -0
- package/dist/monitor.d.ts +1 -34
- package/dist/monitor.js +24 -151
- package/dist/notifications/fleet-producer.d.ts +18 -0
- package/dist/notifications/fleet-producer.js +129 -0
- package/dist/notifications/outbox.d.ts +31 -0
- package/dist/notifications/outbox.js +104 -0
- package/dist/owner-channel/channel.d.ts +3 -0
- package/dist/owner-channel/channel.js +34 -6
- package/dist/owner-channel/commands.d.ts +4 -0
- package/dist/owner-channel/commands.js +8 -0
- package/dist/owner-channel/lifecycle-outbox.js +4 -3
- package/dist/owner-channel/ours-client.js +2 -17
- package/dist/preset-migration.js +15 -0
- package/dist/rooms-tasks/add-member.d.ts +48 -0
- package/dist/rooms-tasks/add-member.js +83 -0
- package/dist/rooms-tasks/archived-absence.d.ts +8 -0
- package/dist/rooms-tasks/archived-absence.js +48 -0
- package/dist/rooms-tasks/cli.js +34 -5
- package/dist/rooms-tasks/close.d.ts +5 -2
- package/dist/rooms-tasks/close.js +208 -28
- package/dist/rooms-tasks/config.js +3 -1
- package/dist/rooms-tasks/cowork-adapter.d.ts +6 -4
- package/dist/rooms-tasks/cowork-adapter.js +11 -117
- package/dist/rooms-tasks/cowork-http.d.ts +3 -0
- package/dist/rooms-tasks/cowork-http.js +70 -0
- package/dist/rooms-tasks/deletion.d.ts +1 -1
- package/dist/rooms-tasks/deletion.js +74 -5
- package/dist/rooms-tasks/erasure.d.ts +6 -0
- package/dist/rooms-tasks/erasure.js +169 -0
- package/dist/rooms-tasks/external-worker.d.ts +1 -1
- package/dist/rooms-tasks/external-worker.js +3 -4
- package/dist/rooms-tasks/layout-binding-client.d.ts +11 -0
- package/dist/rooms-tasks/layout-binding-client.js +56 -0
- package/dist/rooms-tasks/layout-binding-grants.d.ts +21 -0
- package/dist/rooms-tasks/layout-binding-grants.js +128 -0
- package/dist/rooms-tasks/layout-cli.d.ts +4 -0
- package/dist/rooms-tasks/layout-cli.js +107 -0
- package/dist/rooms-tasks/layout-config.d.ts +11 -0
- package/dist/rooms-tasks/layout-config.js +89 -0
- package/dist/rooms-tasks/layout-control.d.ts +21 -0
- package/dist/rooms-tasks/layout-control.js +52 -0
- package/dist/rooms-tasks/layout-service.d.ts +42 -0
- package/dist/rooms-tasks/layout-service.js +185 -0
- package/dist/rooms-tasks/layout.d.ts +114 -0
- package/dist/rooms-tasks/layout.js +250 -0
- package/dist/rooms-tasks/live-readiness.d.ts +18 -0
- package/dist/rooms-tasks/live-readiness.js +85 -0
- package/dist/rooms-tasks/member-startup.d.ts +1 -0
- package/dist/rooms-tasks/member-startup.js +2 -0
- package/dist/rooms-tasks/provision.d.ts +14 -0
- package/dist/rooms-tasks/provision.js +182 -48
- package/dist/rooms-tasks/room-state.d.ts +4 -2
- package/dist/rooms-tasks/room-state.js +53 -8
- package/dist/rooms-tasks/task-state.d.ts +42 -7
- package/dist/rooms-tasks/task-state.js +153 -23
- package/dist/rooms-tasks/types.d.ts +28 -0
- package/dist/rooms-tasks/workspace-artifacts.d.ts +6 -0
- package/dist/rooms-tasks/workspace-artifacts.js +93 -0
- package/dist/rooms-tasks/workspace.d.ts +26 -0
- package/dist/rooms-tasks/workspace.js +195 -0
- package/dist/runner.d.ts +23 -3
- package/dist/runner.js +581 -494
- package/dist/session/acp.d.ts +3 -0
- package/dist/session/acp.js +14 -0
- package/dist/session/arbiter.d.ts +3 -0
- package/dist/session/arbiter.js +20 -2
- package/dist/session/codex-app-server-transport.js +1 -1
- package/dist/session/codex-app-server.d.ts +3 -0
- package/dist/session/codex-app-server.js +25 -1
- package/dist/session/control.d.ts +10 -1
- package/dist/session/control.js +37 -2
- package/dist/session/conversation-normalizer.d.ts +2 -1
- package/dist/session/conversation-normalizer.js +1 -1
- package/dist/session/conversation-types.d.ts +5 -3
- package/dist/session/task-notice.d.ts +14 -0
- package/dist/session/task-notice.js +13 -0
- package/dist/session/types.d.ts +3 -0
- package/dist/socket-path.d.ts +2 -0
- package/dist/socket-path.js +22 -0
- package/dist/spawn.js +19 -16
- package/dist/startup-readiness.d.ts +13 -0
- package/dist/startup-readiness.js +38 -0
- package/dist/subscriptions/cli.d.ts +78 -0
- package/dist/subscriptions/cli.js +186 -0
- package/dist/subscriptions/launch.d.ts +37 -0
- package/dist/subscriptions/launch.js +93 -0
- package/dist/subscriptions/login.d.ts +75 -0
- package/dist/subscriptions/login.js +182 -0
- package/dist/subscriptions/observed.d.ts +7 -0
- package/dist/subscriptions/observed.js +44 -0
- package/dist/subscriptions/probe.d.ts +10 -0
- package/dist/subscriptions/probe.js +73 -0
- package/dist/subscriptions/service.d.ts +102 -0
- package/dist/subscriptions/service.js +364 -0
- package/dist/subscriptions/store.d.ts +71 -0
- package/dist/subscriptions/store.js +140 -0
- package/dist/supervisor/launchd.js +2 -1
- package/dist/supervisor/systemd.js +9 -23
- package/dist/temp-idle.d.ts +17 -0
- package/dist/temp-idle.js +42 -0
- package/dist/temp-lifecycle.d.ts +6 -1
- package/dist/temp-lifecycle.js +29 -13
- package/dist/temp-supervisor-recovery.d.ts +3 -0
- package/dist/temp-supervisor-recovery.js +46 -0
- package/dist/watchdog/briefing.js +3 -16
- package/dist/watchdog/run.js +19 -5
- package/dist/web/agent-attachments.d.ts +29 -0
- package/dist/web/agent-attachments.js +162 -0
- package/dist/web/auth.d.ts +28 -2
- package/dist/web/auth.js +81 -8
- package/dist/web/control.d.ts +3 -2
- package/dist/web/control.js +11 -7
- package/dist/web/fleet-config-service.d.ts +4 -1
- package/dist/web/fleet-config-service.js +46 -15
- package/dist/web/prefix-gateway.d.ts +23 -0
- package/dist/web/prefix-gateway.js +156 -0
- package/dist/web/runtime.d.ts +4 -0
- package/dist/web/runtime.js +90 -19
- package/dist/web/server.d.ts +11 -0
- package/dist/web/server.js +445 -31
- package/dist/web/service.d.ts +6 -2
- package/dist/web/service.js +22 -4
- package/dist/web/workspace-devices.d.ts +29 -0
- package/dist/web/workspace-devices.js +84 -0
- package/dist/web/workspace-tunnel.d.ts +1 -0
- package/dist/web/workspace-tunnel.js +32 -0
- package/dist/workspace-enrollment.d.ts +25 -0
- package/dist/workspace-enrollment.js +112 -0
- package/docs/room-layouts.md +227 -0
- package/docs/task-workspaces.md +130 -0
- package/examples/room-layouts/fresh.yaml +27 -0
- package/examples/room-layouts/scoped.yaml +21 -0
- package/examples/room-layouts/shared.yaml +19 -0
- package/package.json +14 -17
- package/presets/README.md +78 -0
- package/presets/brain-catalog.json +8 -2
- package/presets/fleet/agent_templates/Engineer.yaml +5 -0
- package/presets/fleet/brains/claude-fable-5-1-high.yaml +5 -0
- package/presets/fleet/brains/claude-fable-5-1-low.yaml +5 -0
- package/presets/fleet/brains/claude-fable-5-1-max.yaml +5 -0
- package/presets/fleet/brains/claude-fable-5-1-medium.yaml +5 -0
- package/presets/fleet/brains/claude-fable-5-1-xhigh.yaml +5 -0
- package/presets/fleet/brains/claude-fable-5-high.yaml +1 -1
- package/presets/fleet/brains/claude-fable-5-low.yaml +1 -1
- package/presets/fleet/brains/claude-fable-5-max.yaml +1 -1
- package/presets/fleet/brains/claude-fable-5-medium.yaml +1 -1
- package/presets/fleet/brains/claude-fable-5-xhigh.yaml +1 -1
- package/presets/fleet/brains/claude-haiku-4-5-20251001-default.yaml +4 -0
- package/presets/fleet/brains/claude-opus-5-5-high.yaml +5 -0
- package/presets/fleet/brains/claude-opus-5-5-low.yaml +5 -0
- package/presets/fleet/brains/claude-opus-5-5-max.yaml +5 -0
- package/presets/fleet/brains/claude-opus-5-5-medium.yaml +5 -0
- package/presets/fleet/brains/claude-opus-5-5-xhigh.yaml +5 -0
- package/presets/fleet/brains/claude-opus-5-high.yaml +1 -1
- package/presets/fleet/brains/claude-opus-5-low.yaml +1 -1
- package/presets/fleet/brains/claude-opus-5-max.yaml +1 -1
- package/presets/fleet/brains/claude-opus-5-medium.yaml +1 -1
- package/presets/fleet/brains/claude-opus-5-xhigh.yaml +1 -1
- package/presets/fleet/brains/claude-sonnet-5-high.yaml +1 -1
- package/presets/fleet/brains/claude-sonnet-5-low.yaml +1 -1
- package/presets/fleet/brains/claude-sonnet-5-max.yaml +1 -1
- package/presets/fleet/brains/claude-sonnet-5-medium.yaml +1 -1
- package/presets/fleet/brains/claude-sonnet-5-xhigh.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-3-codex-spark-high.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-3-codex-spark-low.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-3-codex-spark-medium.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-3-codex-spark-xhigh.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-4-high.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-4-low.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-4-medium.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-4-mini-high.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-4-mini-low.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-4-mini-medium.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-4-mini-xhigh.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-4-xhigh.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-5-high.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-5-low.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-5-medium.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-5-xhigh.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-6-luna-high.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-6-luna-low.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-6-luna-max.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-6-luna-medium.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-6-luna-xhigh.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-6-sol-high.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-6-sol-low.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-6-sol-max.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-6-sol-medium.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-6-sol-ultra.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-6-sol-xhigh.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-6-terra-high.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-6-terra-low.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-6-terra-max.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-6-terra-medium.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-6-terra-ultra.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-6-terra-xhigh.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-6-1-sol-high.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-6-1-sol-low.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-6-1-sol-max.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-6-1-sol-medium.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-6-1-sol-ultra.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-6-1-sol-xhigh.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-6-astra-high.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-6-astra-low.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-6-astra-max.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-6-astra-medium.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-6-astra-ultra.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-6-astra-xhigh.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-6-luna-high.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-6-luna-low.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-6-luna-max.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-6-luna-medium.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-6-luna-xhigh.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-6-sol-high.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-6-sol-low.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-6-sol-max.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-6-sol-medium.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-6-sol-ultra.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-6-sol-xhigh.yaml +5 -0
- package/presets/fleet/roles/Coordinator.yaml +22 -0
- package/presets/fleet/roles/Engineer.yaml +40 -0
- package/presets/fleet/room_layouts/engineering.yaml +25 -0
- package/presets/fleet/room_layouts/pair.yaml +24 -0
- package/presets/fleet/room_layouts/single.yaml +17 -0
- package/presets/fleet/room_layouts/team.yaml +30 -0
- package/presets/fleet/room_templates/engineering.yaml +19 -0
- package/presets/manifest.json +2 -2
- package/dist/web-app/assets/index-59GF-gsZ.css +0 -1
- package/dist/web-app/assets/index-C0h3ALvs.js +0 -10
- package/dist/web-app/icons/ours-fleet-maskable.svg +0 -4
- package/dist/web-app/icons/ours-fleet.svg +0 -4
- package/dist/web-app/index.html +0 -17
- package/dist/web-app/manifest.webmanifest +0 -15
- package/dist/web-app/offline.html +0 -18
- package/dist/web-app/sw.js +0 -51
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { constants, closeSync, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { FleetError } from '../application/errors.js';
|
|
5
|
+
export const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024;
|
|
6
|
+
export const MAX_ATTACHMENT_BODY = Math.ceil(MAX_ATTACHMENT_BYTES / 3) * 4 + 4096;
|
|
7
|
+
function fail(message) { throw new FleetError('invalid_request', message); }
|
|
8
|
+
const digest = (data) => createHash('sha256').update(data).digest('hex');
|
|
9
|
+
function directory(path, create = false, privateStorage = true) {
|
|
10
|
+
if (create && !existsSync(path))
|
|
11
|
+
mkdirSync(path, { mode: 0o700 });
|
|
12
|
+
const stat = lstatSync(path);
|
|
13
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() || stat.uid !== process.getuid?.() || (stat.mode & (privateStorage ? 0o022 : 0o002)))
|
|
14
|
+
fail('Attachment directory is not a private owned directory');
|
|
15
|
+
}
|
|
16
|
+
function read(path, max) {
|
|
17
|
+
const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
18
|
+
try {
|
|
19
|
+
const stat = fstatSync(fd);
|
|
20
|
+
if (!stat.isFile() || stat.nlink !== 1 || stat.uid !== process.getuid?.() || stat.size > max)
|
|
21
|
+
fail('Attachment is not a bounded owned regular file');
|
|
22
|
+
return readFileSync(fd);
|
|
23
|
+
}
|
|
24
|
+
finally {
|
|
25
|
+
closeSync(fd);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function immutable(path, bytes) {
|
|
29
|
+
try {
|
|
30
|
+
writeFileSync(path, bytes, { flag: 'wx', mode: 0o400 });
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
if (error.code !== 'EEXIST')
|
|
34
|
+
throw error;
|
|
35
|
+
if (!read(path, bytes.length).equals(bytes))
|
|
36
|
+
fail('Attachment content changed');
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function filename(name) {
|
|
40
|
+
const chars = Array.from(name.replace(/[^\p{L}\p{N}._-]/gu, '_').replace(/^\.+/, '_'));
|
|
41
|
+
while (Buffer.byteLength(chars.join('')) > 120)
|
|
42
|
+
chars.pop();
|
|
43
|
+
return chars.join('') || 'file';
|
|
44
|
+
}
|
|
45
|
+
export function storeAgentAttachment(stateDir, generation, input) {
|
|
46
|
+
if (!input || typeof input !== 'object')
|
|
47
|
+
fail('File data is required');
|
|
48
|
+
const body = input;
|
|
49
|
+
if (typeof body.name !== 'string' || !body.name.trim() || body.name.length > 240 || /[\x00-\x1f]/u.test(body.name))
|
|
50
|
+
fail('A file name of 1–240 characters is required');
|
|
51
|
+
if (typeof body.mimeType !== 'string' || !/^[a-zA-Z0-9.+-]+\/[a-zA-Z0-9.+-]+$/.test(body.mimeType))
|
|
52
|
+
fail('A valid media type is required');
|
|
53
|
+
if (typeof body.data !== 'string' || body.data.length > Math.ceil(MAX_ATTACHMENT_BYTES / 3) * 4 || /[^A-Za-z0-9+/=]/.test(body.data))
|
|
54
|
+
fail('Invalid base64 file data or file exceeds 20 MiB');
|
|
55
|
+
const bytes = Buffer.from(body.data, 'base64');
|
|
56
|
+
if (bytes.length > MAX_ATTACHMENT_BYTES || bytes.toString('base64') !== body.data)
|
|
57
|
+
fail('Invalid file data or file exceeds 20 MiB');
|
|
58
|
+
const sha256 = digest(bytes), name = body.name, mimeType = body.mimeType;
|
|
59
|
+
const id = digest(JSON.stringify({ generation, name, mimeType, sha256 }));
|
|
60
|
+
const record = { id, name, mimeType, size: bytes.length, sha256, generation };
|
|
61
|
+
// Fleet runtime state directories may use the account group (0775). Upload storage remains private.
|
|
62
|
+
directory(stateDir, false, false);
|
|
63
|
+
const root = join(stateDir, 'web-attachments');
|
|
64
|
+
directory(root, true);
|
|
65
|
+
const dir = join(root, id);
|
|
66
|
+
directory(dir, true);
|
|
67
|
+
immutable(join(dir, 'content-' + filename(name)), bytes);
|
|
68
|
+
immutable(join(dir, 'metadata.json'), Buffer.from(JSON.stringify(record)));
|
|
69
|
+
return record;
|
|
70
|
+
}
|
|
71
|
+
export function attachmentPrompt(stateDir, generation, ids, text) {
|
|
72
|
+
if (!Array.isArray(ids) || ids.length < 1 || ids.length > 5 || new Set(ids).size !== ids.length)
|
|
73
|
+
fail('Select between 1 and 5 distinct attachments');
|
|
74
|
+
// Fleet runtime state directories may use the account group (0775). Upload storage remains private.
|
|
75
|
+
directory(stateDir, false, false);
|
|
76
|
+
const root = join(stateDir, 'web-attachments');
|
|
77
|
+
directory(root);
|
|
78
|
+
const files = ids.map(id => {
|
|
79
|
+
if (typeof id !== 'string' || !/^[a-f0-9]{64}$/.test(id))
|
|
80
|
+
fail('Invalid attachment ID');
|
|
81
|
+
const dir = join(root, id);
|
|
82
|
+
directory(dir);
|
|
83
|
+
const record = JSON.parse(read(join(dir, 'metadata.json'), 4096).toString());
|
|
84
|
+
if (record.id !== id || record.generation !== generation)
|
|
85
|
+
throw new FleetError('stale_state', 'Attachment belongs to a different agent session');
|
|
86
|
+
const file = join(dir, 'content-' + filename(record.name)), bytes = read(file, MAX_ATTACHMENT_BYTES);
|
|
87
|
+
if (bytes.length !== record.size || digest(bytes) !== record.sha256 || digest(JSON.stringify({ generation, name: record.name, mimeType: record.mimeType, sha256: record.sha256 })) !== id)
|
|
88
|
+
fail('Attachment content changed');
|
|
89
|
+
return { name: record.name, mimeType: record.mimeType, size: record.size, sha256: record.sha256, path: file };
|
|
90
|
+
});
|
|
91
|
+
return `${text}${text ? '\n\n' : ''}Attached files (available on this host; open the files to inspect their contents, and use the image viewer for images):\n${JSON.stringify(files, null, 2)}`;
|
|
92
|
+
}
|
|
93
|
+
/** Authenticated content access reuses the same ownership and integrity checks as admission. */
|
|
94
|
+
function attachmentMetadata(stateDir, id) {
|
|
95
|
+
if (!/^[a-f0-9]{64}$/.test(id))
|
|
96
|
+
fail('Invalid attachment ID');
|
|
97
|
+
directory(stateDir, false, false);
|
|
98
|
+
const root = join(stateDir, 'web-attachments');
|
|
99
|
+
directory(root);
|
|
100
|
+
const dir = join(root, id);
|
|
101
|
+
directory(dir);
|
|
102
|
+
const record = JSON.parse(read(join(dir, 'metadata.json'), 4096).toString());
|
|
103
|
+
if (record.id !== id || typeof record.name !== 'string' || typeof record.mimeType !== 'string')
|
|
104
|
+
fail('Invalid attachment metadata');
|
|
105
|
+
if (digest(JSON.stringify({ generation: record.generation, name: record.name, mimeType: record.mimeType, sha256: record.sha256 })) !== id)
|
|
106
|
+
fail('Attachment metadata changed');
|
|
107
|
+
return record;
|
|
108
|
+
}
|
|
109
|
+
export function readAgentAttachment(stateDir, id) {
|
|
110
|
+
let record;
|
|
111
|
+
try {
|
|
112
|
+
record = attachmentMetadata(stateDir, id);
|
|
113
|
+
}
|
|
114
|
+
catch (error) {
|
|
115
|
+
if (error.code === 'ENOENT')
|
|
116
|
+
throw new FleetError('resource_not_found', 'Attachment not found');
|
|
117
|
+
throw error;
|
|
118
|
+
}
|
|
119
|
+
const bytes = read(join(stateDir, 'web-attachments', id, 'content-' + filename(record.name)), MAX_ATTACHMENT_BYTES);
|
|
120
|
+
if (bytes.length !== record.size || digest(bytes) !== record.sha256)
|
|
121
|
+
fail('Attachment content changed');
|
|
122
|
+
return { record, bytes };
|
|
123
|
+
}
|
|
124
|
+
/** Written before admission so a lost HTTP response cannot lose the visible message. */
|
|
125
|
+
export function storeAttachmentPresentation(stateDir, value) {
|
|
126
|
+
directory(stateDir, false, false);
|
|
127
|
+
const root = join(stateDir, 'web-attachment-messages');
|
|
128
|
+
directory(root, true);
|
|
129
|
+
immutable(join(root, digest(JSON.stringify([value.generation, value.commandId])) + '.json'), Buffer.from(JSON.stringify(value)));
|
|
130
|
+
}
|
|
131
|
+
export function prepareAttachmentPresentation(stateDir, generation, commandId, ids, text, transport) {
|
|
132
|
+
storeAttachmentPresentation(stateDir, { generation, commandId, ids, text, transportDigest: digest(transport) });
|
|
133
|
+
}
|
|
134
|
+
/** Never infer attachments by stripping user text or interpreting a marker. */
|
|
135
|
+
export function presentAttachmentEvent(stateDir, roleId, event) {
|
|
136
|
+
const e = event;
|
|
137
|
+
if (!stateDir || e.kind !== 'prompt.admitted' || e.source !== 'owner_admin_console' || !e.commandId || e.payload?.text?.redacted)
|
|
138
|
+
return event;
|
|
139
|
+
const root = join(stateDir, 'web-attachment-messages');
|
|
140
|
+
if (!existsSync(root))
|
|
141
|
+
return event;
|
|
142
|
+
const path = join(root, digest(JSON.stringify([e.sessionGeneration, e.commandId])) + '.json');
|
|
143
|
+
if (!existsSync(path))
|
|
144
|
+
return event;
|
|
145
|
+
try {
|
|
146
|
+
directory(stateDir, false, false);
|
|
147
|
+
directory(root);
|
|
148
|
+
const value = JSON.parse(read(path, 1024 * 1024).toString());
|
|
149
|
+
if (value.generation !== e.sessionGeneration || value.commandId !== e.commandId || value.transportDigest !== digest(e.payload.text.text))
|
|
150
|
+
return event;
|
|
151
|
+
const attachments = value.ids.map(id => {
|
|
152
|
+
const record = attachmentMetadata(stateDir, id);
|
|
153
|
+
if (record.generation !== value.generation)
|
|
154
|
+
fail('Attachment session changed');
|
|
155
|
+
return { id, name: record.name, mimeType: record.mimeType, size: record.size, url: `/api/v1/roles/${encodeURIComponent(roleId)}/attachments/${id}` };
|
|
156
|
+
});
|
|
157
|
+
return { ...e, payload: { ...e.payload, text: { type: 'text', text: value.text, bytes: Buffer.byteLength(value.text) }, displayText: { type: 'text', text: value.text, bytes: Buffer.byteLength(value.text) }, attachments } };
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
return event;
|
|
161
|
+
}
|
|
162
|
+
}
|
package/dist/web/auth.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { WorkspaceDeviceStore } from './workspace-devices.js';
|
|
1
2
|
import type { FastifyRequest } from 'fastify';
|
|
2
3
|
import type { WebSocket } from 'ws';
|
|
3
4
|
import { TrustedDeviceStore, type TrustedDeviceIssue } from './device-store.js';
|
|
@@ -26,6 +27,8 @@ export declare class WebAuth {
|
|
|
26
27
|
private readonly now;
|
|
27
28
|
private readonly devices;
|
|
28
29
|
private readonly access;
|
|
30
|
+
private readonly workspaceDevices?;
|
|
31
|
+
readonly appOrigin: string;
|
|
29
32
|
private _bootstrapSecret;
|
|
30
33
|
private bootstrapExpiresAt;
|
|
31
34
|
private bootstrapUsed;
|
|
@@ -33,8 +36,9 @@ export declare class WebAuth {
|
|
|
33
36
|
private readonly sessionDevices;
|
|
34
37
|
private readonly tickets;
|
|
35
38
|
private readonly rates;
|
|
39
|
+
private readonly transports;
|
|
36
40
|
private readonly sockets;
|
|
37
|
-
constructor(_origin: string, _host: string, now?: () => number, devices?: TrustedDeviceStore, access?: WebAccessConfig);
|
|
41
|
+
constructor(_origin: string, _host: string, now?: () => number, devices?: TrustedDeviceStore, access?: WebAccessConfig, workspaceDevices?: WorkspaceDeviceStore | undefined, appOrigin?: string);
|
|
38
42
|
get bootstrapSecret(): string;
|
|
39
43
|
get origin(): string;
|
|
40
44
|
get host(): string;
|
|
@@ -48,7 +52,7 @@ export declare class WebAuth {
|
|
|
48
52
|
}): void;
|
|
49
53
|
/** Mint a replacement for an operator-triggered reauthentication ceremony. */
|
|
50
54
|
mintBootstrap(): string;
|
|
51
|
-
validateBoundary(request: FastifyRequest, requireOrigin: boolean): void;
|
|
55
|
+
validateBoundary(request: FastifyRequest, requireOrigin: boolean, allowPageNavigation?: boolean): void;
|
|
52
56
|
exchange(request: FastifyRequest): AuthResult;
|
|
53
57
|
login(request: FastifyRequest, password: string): AuthResult;
|
|
54
58
|
anonymous(request: FastifyRequest): BrowserSession;
|
|
@@ -61,8 +65,30 @@ export declare class WebAuth {
|
|
|
61
65
|
};
|
|
62
66
|
consumeTicket(request: FastifyRequest, value: string, purpose: Ticket['purpose'], roleId?: string): BrowserSession;
|
|
63
67
|
bindSocket(sessionId: string, socket: WebSocket): void;
|
|
68
|
+
/** Raw service streams share the same logout/revocation boundary as native sockets. */
|
|
69
|
+
bindTransport(sessionId: string, close: () => void): () => void;
|
|
64
70
|
clearSessions(): void;
|
|
65
71
|
revokeAllTrustedDevices(): number;
|
|
72
|
+
workspaceIdentity(): {
|
|
73
|
+
workspaceId: string;
|
|
74
|
+
};
|
|
75
|
+
workspaceEnrollment(issuer?: string | null): {
|
|
76
|
+
enrollment: string;
|
|
77
|
+
workspaceId: string;
|
|
78
|
+
expiresAt: number;
|
|
79
|
+
};
|
|
80
|
+
enrollWorkspace(request: FastifyRequest, input: {
|
|
81
|
+
enrollment: string;
|
|
82
|
+
workspaceId: string;
|
|
83
|
+
label: string;
|
|
84
|
+
}): {
|
|
85
|
+
token: string;
|
|
86
|
+
device: import("./workspace-devices.js").WorkspaceDevice;
|
|
87
|
+
workspaceId: string;
|
|
88
|
+
};
|
|
89
|
+
listWorkspaceDevices(): import("./workspace-devices.js").WorkspaceDevice[];
|
|
90
|
+
revokeWorkspaceDevice(id: string): void;
|
|
91
|
+
private requireWorkspaceDevices;
|
|
66
92
|
shutdown(): void;
|
|
67
93
|
private createSession;
|
|
68
94
|
private removeSession;
|
package/dist/web/auth.js
CHANGED
|
@@ -14,6 +14,8 @@ export class WebAuth {
|
|
|
14
14
|
now;
|
|
15
15
|
devices;
|
|
16
16
|
access;
|
|
17
|
+
workspaceDevices;
|
|
18
|
+
appOrigin;
|
|
17
19
|
_bootstrapSecret = token(32);
|
|
18
20
|
bootstrapExpiresAt = Date.now() + 5 * 60_000;
|
|
19
21
|
bootstrapUsed = false;
|
|
@@ -21,13 +23,16 @@ export class WebAuth {
|
|
|
21
23
|
sessionDevices = new Map();
|
|
22
24
|
tickets = new Map();
|
|
23
25
|
rates = new Map();
|
|
26
|
+
transports = new Map();
|
|
24
27
|
sockets = new Map();
|
|
25
|
-
constructor(_origin, _host, now = Date.now, devices = new TrustedDeviceStore(), access = { version: 1, mode: 'pairing' }) {
|
|
28
|
+
constructor(_origin, _host, now = Date.now, devices = new TrustedDeviceStore(), access = { version: 1, mode: 'pairing' }, workspaceDevices, appOrigin = 'https://app.ours.network') {
|
|
26
29
|
this._origin = _origin;
|
|
27
30
|
this._host = _host;
|
|
28
31
|
this.now = now;
|
|
29
32
|
this.devices = devices;
|
|
30
33
|
this.access = access;
|
|
34
|
+
this.workspaceDevices = workspaceDevices;
|
|
35
|
+
this.appOrigin = appOrigin;
|
|
31
36
|
}
|
|
32
37
|
get bootstrapSecret() { return this._bootstrapSecret; }
|
|
33
38
|
get origin() { return this._origin; }
|
|
@@ -49,16 +54,23 @@ export class WebAuth {
|
|
|
49
54
|
this.bootstrapUsed = false;
|
|
50
55
|
return this._bootstrapSecret;
|
|
51
56
|
}
|
|
52
|
-
validateBoundary(request, requireOrigin) {
|
|
57
|
+
validateBoundary(request, requireOrigin, allowPageNavigation = false) {
|
|
53
58
|
const host = request.headers.host;
|
|
54
59
|
const hosts = this.allowedHosts.size ? this.allowedHosts : new Set([this.host]);
|
|
55
60
|
const origins = this.allowedOrigins.size ? this.allowedOrigins : new Set([this.origin]);
|
|
56
61
|
if (!host || !hosts.has(host))
|
|
57
62
|
throw new FleetError('forbidden', `This address is not configured for the fleet console. Open ${this.origin} or set --public-origin.`);
|
|
58
|
-
if (
|
|
63
|
+
if (this.workspaceDevices && String(request.headers.authorization ?? '').startsWith('Bearer ') && request.headers.origin && request.headers.origin !== this.appOrigin && !origins.has(request.headers.origin))
|
|
64
|
+
throw new FleetError('forbidden', 'workspace request Origin does not match configured account origin');
|
|
65
|
+
const workspaceRequest = Boolean(this.workspaceDevices) && request.headers.origin === this.appOrigin
|
|
66
|
+
&& (String(request.headers.authorization ?? '').startsWith('Bearer ') || (request.url ?? '').split('?')[0] === '/api/v1/devices/enroll' || request.method === 'OPTIONS');
|
|
67
|
+
if (requireOrigin && !workspaceRequest && (!request.headers.origin || !origins.has(request.headers.origin)))
|
|
59
68
|
throw new FleetError('forbidden', 'request Origin does not match the configured control-panel origin');
|
|
60
69
|
const fetchSite = request.headers['sec-fetch-site'];
|
|
61
|
-
|
|
70
|
+
const pageNavigation = allowPageNavigation && !requireOrigin && request.method === 'GET'
|
|
71
|
+
&& request.headers['sec-fetch-mode'] === 'navigate'
|
|
72
|
+
&& (request.headers['sec-fetch-dest'] === 'document' || (Boolean(this.workspaceDevices) && request.headers['sec-fetch-dest'] === 'iframe' && new URL(request.url || '/', this.origin).searchParams.get('workspace-frame') === '1'));
|
|
73
|
+
if (!workspaceRequest && !pageNavigation && fetchSite && !['same-origin', 'none'].includes(String(fetchSite)))
|
|
62
74
|
throw new FleetError('forbidden', 'cross-site request rejected');
|
|
63
75
|
}
|
|
64
76
|
exchange(request) {
|
|
@@ -100,7 +112,23 @@ export class WebAuth {
|
|
|
100
112
|
return { session: this.createSession(device.id), device };
|
|
101
113
|
}
|
|
102
114
|
authenticate(request, mutation = false) {
|
|
115
|
+
const authorization = String(request.headers.authorization ?? '');
|
|
116
|
+
if (authorization.startsWith('Bearer ')) {
|
|
117
|
+
this.validateBoundary(request, mutation);
|
|
118
|
+
if (!this.workspaceDevices)
|
|
119
|
+
throw new FleetError('unauthorized', 'workspace bearer credential is invalid');
|
|
120
|
+
const device = this.requireWorkspaceDevices().authenticate(authorization.slice(7));
|
|
121
|
+
const id = `workspace:${device.id}`;
|
|
122
|
+
const session = { id, csrf: '', createdAt: device.createdAt, lastSeenAt: this.now(), absoluteExpiresAt: device.expiresAt };
|
|
123
|
+
this.sessions.set(id, session);
|
|
124
|
+
this.sessionDevices.set(id, id);
|
|
125
|
+
if (mutation)
|
|
126
|
+
this.consumeRate(`mutation:${id}`, 120, 60000);
|
|
127
|
+
return session;
|
|
128
|
+
}
|
|
103
129
|
this.validateBoundary(request, mutation);
|
|
130
|
+
if (request.headers.origin === this.appOrigin && !this.allowedOrigins.has(this.appOrigin))
|
|
131
|
+
throw new FleetError('unauthorized', 'workspace bearer credential required');
|
|
104
132
|
const id = parseCookies(request.headers.cookie ?? '').ofs_session;
|
|
105
133
|
const session = id ? this.sessions.get(id) : undefined;
|
|
106
134
|
const now = this.now();
|
|
@@ -121,7 +149,9 @@ export class WebAuth {
|
|
|
121
149
|
logout(request) {
|
|
122
150
|
const session = this.authenticate(request, true);
|
|
123
151
|
const deviceId = this.sessionDevices.get(session.id);
|
|
124
|
-
if (
|
|
152
|
+
if (session.id.startsWith('workspace:'))
|
|
153
|
+
this.workspaceDevices?.revoke(session.id.slice(10));
|
|
154
|
+
else if (deviceId && deviceId !== 'unprotected')
|
|
125
155
|
this.devices.revokeId(deviceId);
|
|
126
156
|
this.removeSession(session.id);
|
|
127
157
|
}
|
|
@@ -143,7 +173,7 @@ export class WebAuth {
|
|
|
143
173
|
|| ticket.roleId !== roleId)
|
|
144
174
|
throw new FleetError('unauthorized', 'WebSocket ticket is invalid, expired, or already used');
|
|
145
175
|
const session = this.sessions.get(ticket.sessionId);
|
|
146
|
-
if (!session)
|
|
176
|
+
if (!session || (session.id.startsWith('workspace:') && !this.workspaceDevices?.valid(session.id.slice(10))))
|
|
147
177
|
throw new FleetError('unauthorized', 'browser session expired');
|
|
148
178
|
return session;
|
|
149
179
|
}
|
|
@@ -154,9 +184,30 @@ export class WebAuth {
|
|
|
154
184
|
this.sockets.set(sessionId, sockets);
|
|
155
185
|
}
|
|
156
186
|
sockets.add(socket);
|
|
157
|
-
|
|
187
|
+
const expiry = setInterval(() => { const session = this.sessions.get(sessionId); if (!session || session.absoluteExpiresAt <= this.now() || (sessionId.startsWith('workspace:') && !this.workspaceDevices?.valid(sessionId.slice(10))))
|
|
188
|
+
this.removeSession(sessionId); }, 1000);
|
|
189
|
+
expiry.unref();
|
|
190
|
+
socket.once('close', () => { clearInterval(expiry); sockets?.delete(socket); });
|
|
191
|
+
}
|
|
192
|
+
/** Raw service streams share the same logout/revocation boundary as native sockets. */
|
|
193
|
+
bindTransport(sessionId, close) {
|
|
194
|
+
let transports = this.transports.get(sessionId);
|
|
195
|
+
if (!transports) {
|
|
196
|
+
transports = new Set();
|
|
197
|
+
this.transports.set(sessionId, transports);
|
|
198
|
+
}
|
|
199
|
+
transports.add(close);
|
|
200
|
+
const expiry = setInterval(() => { const session = this.sessions.get(sessionId); if (!session || session.absoluteExpiresAt <= this.now() || (sessionId.startsWith('workspace:') && !this.workspaceDevices?.valid(sessionId.slice(10))))
|
|
201
|
+
this.removeSession(sessionId); }, 1000);
|
|
202
|
+
expiry.unref();
|
|
203
|
+
return () => { clearInterval(expiry); transports.delete(close); if (!transports.size)
|
|
204
|
+
this.transports.delete(sessionId); };
|
|
158
205
|
}
|
|
159
206
|
clearSessions() {
|
|
207
|
+
for (const transports of this.transports.values())
|
|
208
|
+
for (const close of transports)
|
|
209
|
+
close();
|
|
210
|
+
this.transports.clear();
|
|
160
211
|
for (const sockets of this.sockets.values())
|
|
161
212
|
for (const socket of sockets)
|
|
162
213
|
socket.close(4401, 'authentication revoked');
|
|
@@ -167,10 +218,29 @@ export class WebAuth {
|
|
|
167
218
|
}
|
|
168
219
|
revokeAllTrustedDevices() {
|
|
169
220
|
const count = this.devices.revokeAll();
|
|
221
|
+
for (const device of this.workspaceDevices?.list() ?? [])
|
|
222
|
+
this.workspaceDevices?.revoke(device.id);
|
|
170
223
|
this.clearSessions();
|
|
171
224
|
return count;
|
|
172
225
|
}
|
|
173
|
-
|
|
226
|
+
workspaceIdentity() { return { workspaceId: this.requireWorkspaceDevices().workspaceId }; }
|
|
227
|
+
workspaceEnrollment(issuer = null) { return this.requireWorkspaceDevices().mint(issuer?.startsWith('workspace:') ? issuer.slice(10) : null); }
|
|
228
|
+
enrollWorkspace(request, input) {
|
|
229
|
+
this.validateBoundary(request, true);
|
|
230
|
+
this.consumeRate('workspace-enrollment', 30, 60000);
|
|
231
|
+
return this.requireWorkspaceDevices().enroll(input.enrollment, input.workspaceId, input.label);
|
|
232
|
+
}
|
|
233
|
+
listWorkspaceDevices() { return this.requireWorkspaceDevices().list(); }
|
|
234
|
+
revokeWorkspaceDevice(id) {
|
|
235
|
+
this.requireWorkspaceDevices().revoke(id);
|
|
236
|
+
this.removeSession(`workspace:${id}`);
|
|
237
|
+
}
|
|
238
|
+
requireWorkspaceDevices() {
|
|
239
|
+
if (!this.workspaceDevices)
|
|
240
|
+
throw new FleetError('capability_unavailable', 'workspace device access is not configured');
|
|
241
|
+
return this.workspaceDevices;
|
|
242
|
+
}
|
|
243
|
+
shutdown() { this.clearSessions(); this.workspaceDevices?.close(); }
|
|
174
244
|
createSession(deviceId) {
|
|
175
245
|
const now = this.now();
|
|
176
246
|
const session = {
|
|
@@ -182,6 +252,9 @@ export class WebAuth {
|
|
|
182
252
|
return session;
|
|
183
253
|
}
|
|
184
254
|
removeSession(id) {
|
|
255
|
+
for (const close of this.transports.get(id) ?? [])
|
|
256
|
+
close();
|
|
257
|
+
this.transports.delete(id);
|
|
185
258
|
this.sessions.delete(id);
|
|
186
259
|
this.sessionDevices.delete(id);
|
|
187
260
|
for (const [ticket, value] of this.tickets)
|
package/dist/web/control.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type WebControlCommand = 'open' | 'revoke-all';
|
|
1
|
+
export type WebControlCommand = 'open' | 'revoke-all' | 'link-device';
|
|
2
2
|
export declare const webControlPath: (dir?: string) => string;
|
|
3
3
|
export interface WebControlServer {
|
|
4
4
|
path: string;
|
|
@@ -7,8 +7,9 @@ export interface WebControlServer {
|
|
|
7
7
|
export declare function startWebControlServer(options: {
|
|
8
8
|
dir?: string;
|
|
9
9
|
onOpen(): void | Promise<void>;
|
|
10
|
+
onLinkDevice?(): Record<string, unknown> | Promise<Record<string, unknown>>;
|
|
10
11
|
onRevokeAll(): void | Promise<void>;
|
|
11
12
|
now?: () => number;
|
|
12
13
|
rateLimit?: number;
|
|
13
14
|
}): Promise<WebControlServer>;
|
|
14
|
-
export declare function requestWebControl(command: WebControlCommand, path?: string, timeoutMs?: number): Promise<void>;
|
|
15
|
+
export declare function requestWebControl(command: WebControlCommand, path?: string, timeoutMs?: number): Promise<Record<string, unknown> | void>;
|
package/dist/web/control.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
+
import { socketPath as privateSocketPath } from '../socket-path.js';
|
|
1
2
|
import { chmodSync, mkdirSync, rmSync } from 'node:fs';
|
|
2
3
|
import { createConnection, createServer } from 'node:net';
|
|
3
4
|
import { join } from 'node:path';
|
|
4
5
|
import { FleetError, safeLine } from '../application/errors.js';
|
|
5
6
|
import { stateRoot } from '../paths.js';
|
|
6
|
-
export const webControlPath = (dir = join(stateRoot(), 'web')) => join(dir, 'control.sock');
|
|
7
|
+
export const webControlPath = (dir = join(stateRoot(), 'web')) => privateSocketPath(join(dir, 'control.sock'));
|
|
7
8
|
export async function startWebControlServer(options) {
|
|
8
9
|
const dir = options.dir ?? join(stateRoot(), 'web');
|
|
9
10
|
const path = webControlPath(dir);
|
|
@@ -34,13 +35,16 @@ export async function startWebControlServer(options) {
|
|
|
34
35
|
if (used > rateLimit)
|
|
35
36
|
throw new FleetError('rate_limited', 'local web control rate limit exceeded');
|
|
36
37
|
const parsed = JSON.parse(body.slice(0, body.indexOf('\n')));
|
|
37
|
-
|
|
38
|
+
let result;
|
|
39
|
+
if (parsed.command === 'link-device' && options.onLinkDevice)
|
|
40
|
+
result = await options.onLinkDevice();
|
|
41
|
+
else if (parsed.command === 'open')
|
|
38
42
|
await options.onOpen();
|
|
39
43
|
else if (parsed.command === 'revoke-all')
|
|
40
44
|
await options.onRevokeAll();
|
|
41
45
|
else
|
|
42
46
|
throw new FleetError('invalid_request', 'unknown local web control command');
|
|
43
|
-
socket.end(JSON.stringify({ ok: true }) + '\n');
|
|
47
|
+
socket.end(JSON.stringify({ ok: true, result }) + '\n');
|
|
44
48
|
}
|
|
45
49
|
catch (error) {
|
|
46
50
|
const code = error instanceof FleetError ? error.code : 'internal';
|
|
@@ -65,7 +69,7 @@ export async function startWebControlServer(options) {
|
|
|
65
69
|
};
|
|
66
70
|
}
|
|
67
71
|
export async function requestWebControl(command, path = webControlPath(), timeoutMs = 5_000) {
|
|
68
|
-
await new Promise((resolve, reject) => {
|
|
72
|
+
return await new Promise((resolve, reject) => {
|
|
69
73
|
const socket = createConnection(path);
|
|
70
74
|
let response = '';
|
|
71
75
|
let settled = false;
|
|
@@ -73,7 +77,7 @@ export async function requestWebControl(command, path = webControlPath(), timeou
|
|
|
73
77
|
socket.destroy();
|
|
74
78
|
reject(new FleetError('timeout', 'local web control request timed out'));
|
|
75
79
|
}, timeoutMs);
|
|
76
|
-
const finish = (error) => {
|
|
80
|
+
const finish = (error, result) => {
|
|
77
81
|
if (settled)
|
|
78
82
|
return;
|
|
79
83
|
settled = true;
|
|
@@ -81,7 +85,7 @@ export async function requestWebControl(command, path = webControlPath(), timeou
|
|
|
81
85
|
if (error)
|
|
82
86
|
reject(error);
|
|
83
87
|
else
|
|
84
|
-
resolve();
|
|
88
|
+
resolve(result);
|
|
85
89
|
};
|
|
86
90
|
socket.once('connect', () => socket.write(JSON.stringify({ command }) + '\n'));
|
|
87
91
|
socket.on('data', chunk => { response += chunk.toString('utf8'); });
|
|
@@ -91,7 +95,7 @@ export async function requestWebControl(command, path = webControlPath(), timeou
|
|
|
91
95
|
const parsed = JSON.parse(response);
|
|
92
96
|
if (!parsed.ok)
|
|
93
97
|
throw new FleetError(parsed.error?.code === 'rate_limited' ? 'rate_limited' : 'rejected', parsed.error?.message ?? 'local web control request was rejected');
|
|
94
|
-
finish();
|
|
98
|
+
finish(undefined, parsed.result);
|
|
95
99
|
}
|
|
96
100
|
catch (error) {
|
|
97
101
|
finish(error);
|
|
@@ -4,6 +4,9 @@ export interface EditableFleetModel {
|
|
|
4
4
|
manifest: Record<string, unknown>;
|
|
5
5
|
agents: Record<string, Record<string, unknown>>;
|
|
6
6
|
agent_templates: Record<string, Record<string, unknown>>;
|
|
7
|
+
roles?: Record<string, Record<string, unknown>>;
|
|
8
|
+
brains?: Record<string, Record<string, unknown>>;
|
|
9
|
+
room_templates?: Record<string, Record<string, unknown>>;
|
|
7
10
|
}
|
|
8
11
|
export interface ConfigReadResult {
|
|
9
12
|
path: string;
|
|
@@ -45,7 +48,7 @@ export declare class FleetConfigService {
|
|
|
45
48
|
private readonly preflight;
|
|
46
49
|
private readonly beforeMutation?;
|
|
47
50
|
constructor(options?: FleetConfigServiceOptions);
|
|
48
|
-
read(): ConfigReadResult;
|
|
51
|
+
read(includeDefinitions?: boolean): ConfigReadResult;
|
|
49
52
|
preview(baseRevision: string, input: unknown): Promise<ConfigPreviewResult>;
|
|
50
53
|
write(baseRevision: string, input: unknown): Promise<ConfigWriteResult>;
|
|
51
54
|
}
|
|
@@ -23,7 +23,7 @@ export class FleetConfigService {
|
|
|
23
23
|
this.preflight = options.preflight ?? (async () => emptyReport());
|
|
24
24
|
this.beforeMutation = options.beforeMutation;
|
|
25
25
|
}
|
|
26
|
-
read() {
|
|
26
|
+
read(includeDefinitions = false) {
|
|
27
27
|
const sources = readSources(this.path);
|
|
28
28
|
try {
|
|
29
29
|
loadConfig(this.path, { yamlMode: 'strict' });
|
|
@@ -31,7 +31,7 @@ export class FleetConfigService {
|
|
|
31
31
|
catch (error) {
|
|
32
32
|
throw safeError(error, sourceSensitiveValues(sources));
|
|
33
33
|
}
|
|
34
|
-
const raw = modelFrom(sources);
|
|
34
|
+
const raw = modelFrom(sources, includeDefinitions);
|
|
35
35
|
const redacted = redactModel(raw);
|
|
36
36
|
return {
|
|
37
37
|
path: basename(this.path), exists: true, firstRun: false,
|
|
@@ -41,7 +41,7 @@ export class FleetConfigService {
|
|
|
41
41
|
async preview(baseRevision, input) {
|
|
42
42
|
const current = readSources(this.path);
|
|
43
43
|
assertRevision(baseRevision, current);
|
|
44
|
-
const currentModel = modelFrom(current);
|
|
44
|
+
const currentModel = modelFrom(current, !!input && typeof input === 'object' && 'roles' in input);
|
|
45
45
|
const next = restoreRedactions(assertModel(input), currentModel, new Set(redactModel(currentModel).paths));
|
|
46
46
|
const proposal = renderProposal(current, next);
|
|
47
47
|
const staged = stageProposal(this.path, current, proposal);
|
|
@@ -63,7 +63,7 @@ export class FleetConfigService {
|
|
|
63
63
|
return withFileLock(`${splitRootFor(this.path)}.web-edit.lock`, async () => {
|
|
64
64
|
const current = readSources(this.path);
|
|
65
65
|
assertRevision(baseRevision, current);
|
|
66
|
-
const currentModel = modelFrom(current);
|
|
66
|
+
const currentModel = modelFrom(current, !!input && typeof input === 'object' && 'roles' in input);
|
|
67
67
|
const next = restoreRedactions(assertModel(input), currentModel, new Set(redactModel(currentModel).paths));
|
|
68
68
|
const proposal = renderProposal(current, next);
|
|
69
69
|
const changed = changedDocuments(current, proposal);
|
|
@@ -105,6 +105,8 @@ export class FleetConfigService {
|
|
|
105
105
|
rmSync(target, { force: true });
|
|
106
106
|
else {
|
|
107
107
|
assertWritableTarget(target, current.documents.has(rel));
|
|
108
|
+
mkdirSync(dirname(target), { recursive: true, mode: 0o700 });
|
|
109
|
+
trustedDirectory(dirname(target));
|
|
108
110
|
replaceFileAtomically(target, contents, 0o600);
|
|
109
111
|
chmodSync(target, 0o600);
|
|
110
112
|
}
|
|
@@ -184,7 +186,7 @@ function readSources(manifest) {
|
|
|
184
186
|
}
|
|
185
187
|
return { manifest, root, documents };
|
|
186
188
|
}
|
|
187
|
-
function modelFrom(sources) {
|
|
189
|
+
function modelFrom(sources, includeDefinitions = false) {
|
|
188
190
|
const manifestKey = basename(sources.manifest);
|
|
189
191
|
const manifest = parseFleetDocument(sources.manifest, sources.documents.get(manifestKey), 'strict').value;
|
|
190
192
|
const agents = {};
|
|
@@ -199,15 +201,24 @@ function modelFrom(sources) {
|
|
|
199
201
|
const id = basename(rel, extname(rel));
|
|
200
202
|
agent_templates[id] = parseFleetDocument(absoluteFor(sources.manifest, rel), source, 'strict').value;
|
|
201
203
|
}
|
|
202
|
-
|
|
204
|
+
const model = { manifest, agents, agent_templates };
|
|
205
|
+
if (includeDefinitions)
|
|
206
|
+
for (const kind of ['roles', 'brains', 'room_templates']) {
|
|
207
|
+
model[kind] = {};
|
|
208
|
+
for (const [rel, source] of sources.documents)
|
|
209
|
+
if (rel.startsWith(kind + '/') && ['.yaml', '.yml'].includes(extname(rel))) {
|
|
210
|
+
model[kind][basename(rel, extname(rel))] = parseFleetDocument(absoluteFor(sources.manifest, rel), source, 'strict').value;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return model;
|
|
203
214
|
}
|
|
204
215
|
function assertModel(value) {
|
|
205
216
|
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
206
217
|
throw new FleetError('invalid_request', 'model must be an object');
|
|
207
218
|
const input = value;
|
|
208
|
-
if (Object.keys(input).sort().join(',')
|
|
209
|
-
throw new FleetError('invalid_request', 'model
|
|
210
|
-
for (const key of
|
|
219
|
+
if (!['agent_templates,agents,manifest', 'agent_templates,agents,brains,manifest,roles,room_templates'].includes(Object.keys(input).sort().join(',')))
|
|
220
|
+
throw new FleetError('invalid_request', 'model requires manifest, agents, agent_templates and optionally all definition collections');
|
|
221
|
+
for (const key of Object.keys(input))
|
|
211
222
|
if (!input[key] || typeof input[key] !== 'object' || Array.isArray(input[key]))
|
|
212
223
|
throw new FleetError('invalid_request', `${key} must be an object`);
|
|
213
224
|
for (const [id, doc] of Object.entries(input.agents)) {
|
|
@@ -222,6 +233,14 @@ function assertModel(value) {
|
|
|
222
233
|
if (!doc || typeof doc !== 'object' || Array.isArray(doc))
|
|
223
234
|
throw new FleetError('invalid_request', `Agent Template '${id}' must be an object`);
|
|
224
235
|
}
|
|
236
|
+
for (const kind of ['roles', 'brains', 'room_templates']) {
|
|
237
|
+
for (const [id, doc] of Object.entries((input[kind] ?? {}))) {
|
|
238
|
+
if (!ROLE_NAME_RE.test(id))
|
|
239
|
+
throw new FleetError('invalid_request', `invalid definition ID '${id}'`);
|
|
240
|
+
if (!doc || typeof doc !== 'object' || Array.isArray(doc))
|
|
241
|
+
throw new FleetError('invalid_request', `definition '${id}' must be an object`);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
225
244
|
return structuredClone(input);
|
|
226
245
|
}
|
|
227
246
|
function renderProposal(current, model) {
|
|
@@ -240,9 +259,21 @@ function renderProposal(current, model) {
|
|
|
240
259
|
next.set(oldRel ?? join('agent_templates', `${id}.yaml`), oldRel
|
|
241
260
|
? render(current.documents.get(oldRel), doc) : stringify(doc));
|
|
242
261
|
}
|
|
243
|
-
for (const [
|
|
244
|
-
if (
|
|
245
|
-
|
|
262
|
+
for (const kind of ['roles', 'brains', 'room_templates']) {
|
|
263
|
+
if (model[kind] === undefined) {
|
|
264
|
+
for (const [rel, source] of current.documents)
|
|
265
|
+
if (rel.startsWith(kind + '/'))
|
|
266
|
+
next.set(rel, source);
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
for (const [rel, source] of current.documents)
|
|
270
|
+
if (rel.startsWith(kind + '/') && !['.yaml', '.yml'].includes(extname(rel)))
|
|
271
|
+
next.set(rel, source);
|
|
272
|
+
for (const [id, doc] of Object.entries(model[kind])) {
|
|
273
|
+
const oldRel = [...current.documents.keys()].find(rel => rel.startsWith(kind + '/') && ['.yaml', '.yml'].includes(extname(rel)) && basename(rel, extname(rel)) === id);
|
|
274
|
+
next.set(oldRel ?? join(kind, `${id}.yaml`), oldRel ? render(current.documents.get(oldRel), doc) : stringify(doc));
|
|
275
|
+
}
|
|
276
|
+
}
|
|
246
277
|
return next;
|
|
247
278
|
}
|
|
248
279
|
function render(source, model) {
|
|
@@ -395,9 +426,9 @@ function sourceDiff(before, after, label, secrets) {
|
|
|
395
426
|
...left.slice(left.length - tail, left.length - tail + trailing).map(x => ` ${x}`), ''].join('\n');
|
|
396
427
|
}
|
|
397
428
|
function proposalDiff(current, proposal) {
|
|
398
|
-
const secrets = sensitiveValues(modelFrom(current));
|
|
429
|
+
const secrets = sensitiveValues(modelFrom(current, true));
|
|
399
430
|
try {
|
|
400
|
-
secrets.push(...sensitiveValues(modelFrom({ ...current, documents: proposal })));
|
|
431
|
+
secrets.push(...sensitiveValues(modelFrom({ ...current, documents: proposal }, true)));
|
|
401
432
|
}
|
|
402
433
|
catch { /* validation reports later */ }
|
|
403
434
|
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');
|
|
@@ -405,7 +436,7 @@ function proposalDiff(current, proposal) {
|
|
|
405
436
|
function splitLines(source) { return source.replace(/\n$/, '').split('\n'); }
|
|
406
437
|
function restartImpact(before, after) {
|
|
407
438
|
const roles = [...new Set([...Object.keys(before.agents), ...Object.keys(after.agents)])]
|
|
408
|
-
.filter(id => JSON.stringify(before.agents[id]) !== JSON.stringify(after.agents[id])).sort();
|
|
439
|
+
.filter(id => JSON.stringify(before.agents[id]) !== JSON.stringify(after.agents[id]) || JSON.stringify(before.roles) !== JSON.stringify(after.roles) || JSON.stringify(before.brains) !== JSON.stringify(after.brains)).sort();
|
|
409
440
|
const watchdogScheduler = JSON.stringify(before.manifest.watchdogs) !== JSON.stringify(after.manifest.watchdogs);
|
|
410
441
|
const scheduledLoops = JSON.stringify(before.manifest.loops) !== JSON.stringify(after.manifest.loops);
|
|
411
442
|
const required = roles.length > 0 || watchdogScheduler || scheduledLoops;
|