@ours.network/fleet 1.2.0-nightly.19 → 1.2.0-nightly.20
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 +10 -1
- package/dist/agent-ours/correspondence.d.ts +10 -0
- package/dist/agent-ours/correspondence.js +1 -0
- package/dist/agent-ours/runtime.d.ts +6 -0
- package/dist/agent-ours/runtime.js +36 -0
- package/dist/agent-ours/service.js +2 -1
- package/dist/application/conversation-history.d.ts +6 -0
- package/dist/application/conversation-history.js +65 -0
- package/dist/application/fleet-query-service.d.ts +1 -1
- package/dist/application/fleet-query-service.js +2 -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 +8 -0
- package/dist/application/session-control.js +21 -3
- package/dist/application/task-layouts.d.ts +89 -0
- package/dist/application/task-layouts.js +248 -0
- package/dist/application/task-room-service.d.ts +82 -0
- package/dist/application/task-room-service.js +197 -9
- package/dist/build-info.json +4 -4
- package/dist/cli.js +2 -0
- package/dist/config.d.ts +3 -0
- package/dist/config.js +7 -0
- package/dist/init-wizard.js +4 -2
- package/dist/rooms-tasks/add-member.d.ts +48 -0
- package/dist/rooms-tasks/add-member.js +83 -0
- package/dist/rooms-tasks/cli.js +20 -0
- package/dist/rooms-tasks/external-worker.d.ts +1 -1
- package/dist/rooms-tasks/external-worker.js +3 -3
- package/dist/rooms-tasks/layout-cli.d.ts +2 -0
- package/dist/rooms-tasks/layout-cli.js +81 -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 +39 -0
- package/dist/rooms-tasks/layout-service.js +173 -0
- package/dist/rooms-tasks/layout.d.ts +102 -0
- package/dist/rooms-tasks/layout.js +227 -0
- package/dist/rooms-tasks/live-readiness.js +2 -1
- package/dist/rooms-tasks/provision.d.ts +3 -0
- package/dist/rooms-tasks/provision.js +44 -22
- package/dist/rooms-tasks/room-state.d.ts +1 -1
- package/dist/rooms-tasks/room-state.js +3 -1
- package/dist/rooms-tasks/task-state.d.ts +19 -0
- package/dist/rooms-tasks/task-state.js +58 -6
- package/dist/rooms-tasks/types.d.ts +14 -0
- package/dist/runner.d.ts +7 -2
- package/dist/runner.js +52 -4
- package/dist/session/acp.d.ts +3 -0
- package/dist/session/acp.js +7 -0
- package/dist/session/arbiter.d.ts +3 -0
- package/dist/session/arbiter.js +20 -2
- 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 +36 -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/temp-idle.d.ts +17 -0
- package/dist/temp-idle.js +42 -0
- package/dist/temp-lifecycle.d.ts +1 -1
- package/dist/temp-lifecycle.js +1 -1
- package/dist/web/agent-attachments.d.ts +29 -0
- package/dist/web/agent-attachments.js +162 -0
- package/dist/web/auth.d.ts +4 -1
- package/dist/web/auth.js +23 -2
- package/dist/web/control.js +2 -1
- 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 +20 -0
- package/dist/web/prefix-gateway.js +150 -0
- package/dist/web/runtime.d.ts +4 -0
- package/dist/web/runtime.js +19 -14
- package/dist/web/server.d.ts +5 -0
- package/dist/web/server.js +275 -24
- package/docs/room-layouts.md +92 -0
- package/examples/room-layouts/fresh.yaml +27 -0
- package/examples/room-layouts/shared.yaml +19 -0
- package/package.json +14 -13
- 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/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,42 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { replaceFileAtomically } from './atomic-file.js';
|
|
4
|
+
export const TEMP_CHAT_IDLE_MS = 24 * 60 * 60_000;
|
|
5
|
+
const FILE = 'chat-idle.json';
|
|
6
|
+
export function readChatIdle(dir) {
|
|
7
|
+
if (!existsSync(join(dir, FILE)))
|
|
8
|
+
return undefined;
|
|
9
|
+
const value = JSON.parse(readFileSync(join(dir, FILE), 'utf8'));
|
|
10
|
+
if (value.version !== 1 || !Number.isSafeInteger(value.lastActivityAt) || value.timeoutMs !== TEMP_CHAT_IDLE_MS)
|
|
11
|
+
throw new Error('Invalid persisted chat idle policy');
|
|
12
|
+
return value;
|
|
13
|
+
}
|
|
14
|
+
/** Only a standalone temporary supervisor owns this clock; browser reads never touch it. */
|
|
15
|
+
export class TemporaryChatIdle {
|
|
16
|
+
dir;
|
|
17
|
+
value;
|
|
18
|
+
busy = false;
|
|
19
|
+
constructor(dir, now) {
|
|
20
|
+
this.dir = dir;
|
|
21
|
+
this.value = readChatIdle(dir) ?? { version: 1, timeoutMs: TEMP_CHAT_IDLE_MS, lastActivityAt: now };
|
|
22
|
+
this.save();
|
|
23
|
+
}
|
|
24
|
+
save() { replaceFileAtomically(join(this.dir, FILE), JSON.stringify(this.value) + '\n', 0o600); }
|
|
25
|
+
observe(snapshot, queueDepth, now) {
|
|
26
|
+
// Unknown activity evidence never grants permission to retire a session.
|
|
27
|
+
const idle = snapshot.alive && snapshot.readiness === 'idle' && !snapshot.pendingPermissionId && queueDepth === 0 && snapshot.activity?.activeToolCalls === 0;
|
|
28
|
+
const update = Date.parse(snapshot.activity?.lastUpdateAt ?? '');
|
|
29
|
+
let next = this.value.lastActivityAt;
|
|
30
|
+
if (Number.isFinite(update) && update <= now)
|
|
31
|
+
next = Math.max(next, update);
|
|
32
|
+
if (!idle || this.busy)
|
|
33
|
+
next = Math.max(next, now);
|
|
34
|
+
this.busy = !idle;
|
|
35
|
+
// Active work checkpoints at most once a minute, with an immediate idle boundary write.
|
|
36
|
+
if (next > this.value.lastActivityAt && (idle || next - this.value.lastActivityAt >= 60_000)) {
|
|
37
|
+
this.value.lastActivityAt = next;
|
|
38
|
+
this.save();
|
|
39
|
+
}
|
|
40
|
+
return idle && now >= this.value.lastActivityAt && now - this.value.lastActivityAt >= this.value.timeoutMs;
|
|
41
|
+
}
|
|
42
|
+
}
|
package/dist/temp-lifecycle.d.ts
CHANGED
|
@@ -5,7 +5,7 @@ export declare const TEMP_STOP_REQUEST_FILE = ".temp-stop-request.json";
|
|
|
5
5
|
export declare const TEMP_RECLAIM_BATCH = 32;
|
|
6
6
|
export declare const TEMP_LAUNCH_GRACE_MS = 60000;
|
|
7
7
|
export type TempSupervisorKind = 'systemd-transient' | 'launchd-transient' | 'detached';
|
|
8
|
-
export type TempTerminationReason = 'identity-closed' | 'session-ended' | 'operator-stop' | 'supervisor-signal' | 'supervisor-recycle' | 'startup-failure' | 'stale-supervisor';
|
|
8
|
+
export type TempTerminationReason = 'idle-timeout' | 'identity-closed' | 'session-ended' | 'operator-stop' | 'supervisor-signal' | 'supervisor-recycle' | 'startup-failure' | 'stale-supervisor';
|
|
9
9
|
export interface TempSupervisorRecord {
|
|
10
10
|
version: 1;
|
|
11
11
|
role: string;
|
package/dist/temp-lifecycle.js
CHANGED
|
@@ -59,7 +59,7 @@ export function makeTempSupervisorLauncher(options = {}) {
|
|
|
59
59
|
const supervisor = options.supervisor ?? process.env.OURS_FLEET_SUPERVISOR;
|
|
60
60
|
return async (binPath, args, dir) => {
|
|
61
61
|
const inherited = [
|
|
62
|
-
'HOME', 'PATH', 'XDG_RUNTIME_DIR', 'OURS_FLEET_HOME', 'CODEX_HOME',
|
|
62
|
+
'HOME', 'PATH', 'XDG_RUNTIME_DIR', 'OURS_FLEET_HOME', 'OURS_FLEET_SOCKET_ROOT', 'CODEX_HOME',
|
|
63
63
|
// Preserve runtime selection across the service-manager boundary, including
|
|
64
64
|
// an explicit empty value which selects the bundle over manager defaults.
|
|
65
65
|
'CODEX_PATH',
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export declare const MAX_ATTACHMENT_BYTES: number;
|
|
2
|
+
export declare const MAX_ATTACHMENT_BODY: number;
|
|
3
|
+
export interface AgentAttachment {
|
|
4
|
+
id: string;
|
|
5
|
+
name: string;
|
|
6
|
+
mimeType: string;
|
|
7
|
+
size: number;
|
|
8
|
+
sha256: string;
|
|
9
|
+
generation: string;
|
|
10
|
+
}
|
|
11
|
+
export declare function storeAgentAttachment(stateDir: string, generation: string, input: unknown): AgentAttachment;
|
|
12
|
+
export declare function attachmentPrompt(stateDir: string, generation: string, ids: unknown, text: string): string;
|
|
13
|
+
export declare function readAgentAttachment(stateDir: string, id: string): {
|
|
14
|
+
record: AgentAttachment;
|
|
15
|
+
bytes: Buffer;
|
|
16
|
+
};
|
|
17
|
+
interface AttachmentPresentation {
|
|
18
|
+
generation: string;
|
|
19
|
+
commandId: string;
|
|
20
|
+
text: string;
|
|
21
|
+
transportDigest: string;
|
|
22
|
+
ids: string[];
|
|
23
|
+
}
|
|
24
|
+
/** Written before admission so a lost HTTP response cannot lose the visible message. */
|
|
25
|
+
export declare function storeAttachmentPresentation(stateDir: string, value: AttachmentPresentation): void;
|
|
26
|
+
export declare function prepareAttachmentPresentation(stateDir: string, generation: string, commandId: string, ids: string[], text: string, transport: string): void;
|
|
27
|
+
/** Never infer attachments by stripping user text or interpreting a marker. */
|
|
28
|
+
export declare function presentAttachmentEvent<T>(stateDir: string | undefined, roleId: string, event: T): T;
|
|
29
|
+
export {};
|
|
@@ -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
|
@@ -33,6 +33,7 @@ export declare class WebAuth {
|
|
|
33
33
|
private readonly sessionDevices;
|
|
34
34
|
private readonly tickets;
|
|
35
35
|
private readonly rates;
|
|
36
|
+
private readonly transports;
|
|
36
37
|
private readonly sockets;
|
|
37
38
|
constructor(_origin: string, _host: string, now?: () => number, devices?: TrustedDeviceStore, access?: WebAccessConfig);
|
|
38
39
|
get bootstrapSecret(): string;
|
|
@@ -48,7 +49,7 @@ export declare class WebAuth {
|
|
|
48
49
|
}): void;
|
|
49
50
|
/** Mint a replacement for an operator-triggered reauthentication ceremony. */
|
|
50
51
|
mintBootstrap(): string;
|
|
51
|
-
validateBoundary(request: FastifyRequest, requireOrigin: boolean): void;
|
|
52
|
+
validateBoundary(request: FastifyRequest, requireOrigin: boolean, allowPageNavigation?: boolean): void;
|
|
52
53
|
exchange(request: FastifyRequest): AuthResult;
|
|
53
54
|
login(request: FastifyRequest, password: string): AuthResult;
|
|
54
55
|
anonymous(request: FastifyRequest): BrowserSession;
|
|
@@ -61,6 +62,8 @@ export declare class WebAuth {
|
|
|
61
62
|
};
|
|
62
63
|
consumeTicket(request: FastifyRequest, value: string, purpose: Ticket['purpose'], roleId?: string): BrowserSession;
|
|
63
64
|
bindSocket(sessionId: string, socket: WebSocket): void;
|
|
65
|
+
/** Raw service streams share the same logout/revocation boundary as native sockets. */
|
|
66
|
+
bindTransport(sessionId: string, close: () => void): () => void;
|
|
64
67
|
clearSessions(): void;
|
|
65
68
|
revokeAllTrustedDevices(): number;
|
|
66
69
|
shutdown(): void;
|
package/dist/web/auth.js
CHANGED
|
@@ -21,6 +21,7 @@ export class WebAuth {
|
|
|
21
21
|
sessionDevices = new Map();
|
|
22
22
|
tickets = new Map();
|
|
23
23
|
rates = new Map();
|
|
24
|
+
transports = new Map();
|
|
24
25
|
sockets = new Map();
|
|
25
26
|
constructor(_origin, _host, now = Date.now, devices = new TrustedDeviceStore(), access = { version: 1, mode: 'pairing' }) {
|
|
26
27
|
this._origin = _origin;
|
|
@@ -49,7 +50,7 @@ export class WebAuth {
|
|
|
49
50
|
this.bootstrapUsed = false;
|
|
50
51
|
return this._bootstrapSecret;
|
|
51
52
|
}
|
|
52
|
-
validateBoundary(request, requireOrigin) {
|
|
53
|
+
validateBoundary(request, requireOrigin, allowPageNavigation = false) {
|
|
53
54
|
const host = request.headers.host;
|
|
54
55
|
const hosts = this.allowedHosts.size ? this.allowedHosts : new Set([this.host]);
|
|
55
56
|
const origins = this.allowedOrigins.size ? this.allowedOrigins : new Set([this.origin]);
|
|
@@ -58,7 +59,9 @@ export class WebAuth {
|
|
|
58
59
|
if (requireOrigin && (!request.headers.origin || !origins.has(request.headers.origin)))
|
|
59
60
|
throw new FleetError('forbidden', 'request Origin does not match the configured control-panel origin');
|
|
60
61
|
const fetchSite = request.headers['sec-fetch-site'];
|
|
61
|
-
|
|
62
|
+
const pageNavigation = allowPageNavigation && !requireOrigin && request.method === 'GET'
|
|
63
|
+
&& request.headers['sec-fetch-mode'] === 'navigate' && request.headers['sec-fetch-dest'] === 'document';
|
|
64
|
+
if (!pageNavigation && fetchSite && !['same-origin', 'none'].includes(String(fetchSite)))
|
|
62
65
|
throw new FleetError('forbidden', 'cross-site request rejected');
|
|
63
66
|
}
|
|
64
67
|
exchange(request) {
|
|
@@ -156,7 +159,22 @@ export class WebAuth {
|
|
|
156
159
|
sockets.add(socket);
|
|
157
160
|
socket.once('close', () => sockets?.delete(socket));
|
|
158
161
|
}
|
|
162
|
+
/** Raw service streams share the same logout/revocation boundary as native sockets. */
|
|
163
|
+
bindTransport(sessionId, close) {
|
|
164
|
+
let transports = this.transports.get(sessionId);
|
|
165
|
+
if (!transports) {
|
|
166
|
+
transports = new Set();
|
|
167
|
+
this.transports.set(sessionId, transports);
|
|
168
|
+
}
|
|
169
|
+
transports.add(close);
|
|
170
|
+
return () => { transports.delete(close); if (!transports.size)
|
|
171
|
+
this.transports.delete(sessionId); };
|
|
172
|
+
}
|
|
159
173
|
clearSessions() {
|
|
174
|
+
for (const transports of this.transports.values())
|
|
175
|
+
for (const close of transports)
|
|
176
|
+
close();
|
|
177
|
+
this.transports.clear();
|
|
160
178
|
for (const sockets of this.sockets.values())
|
|
161
179
|
for (const socket of sockets)
|
|
162
180
|
socket.close(4401, 'authentication revoked');
|
|
@@ -182,6 +200,9 @@ export class WebAuth {
|
|
|
182
200
|
return session;
|
|
183
201
|
}
|
|
184
202
|
removeSession(id) {
|
|
203
|
+
for (const close of this.transports.get(id) ?? [])
|
|
204
|
+
close();
|
|
205
|
+
this.transports.delete(id);
|
|
185
206
|
this.sessions.delete(id);
|
|
186
207
|
this.sessionDevices.delete(id);
|
|
187
208
|
for (const [ticket, value] of this.tickets)
|
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);
|
|
@@ -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;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { type IncomingMessage } from 'node:http';
|
|
2
|
+
import type { WebAuth } from './auth.js';
|
|
3
|
+
/** Each entry exposes one dedicated backend listener. Endpoint dispatch stays upstream. */
|
|
4
|
+
export interface ServiceTarget {
|
|
5
|
+
prefix: string;
|
|
6
|
+
origin: string;
|
|
7
|
+
/** Server-side service credentials; never taken from browser headers. */
|
|
8
|
+
headers?: Record<string, string>;
|
|
9
|
+
}
|
|
10
|
+
export interface GatewayOptions {
|
|
11
|
+
/** Backend mode is for a separate loopback machine-client listener; browsers are rejected. */
|
|
12
|
+
auth: WebAuth | 'backend';
|
|
13
|
+
fleetOrigin: string;
|
|
14
|
+
services: ServiceTarget[];
|
|
15
|
+
}
|
|
16
|
+
/** Raw transport deliberately runs outside Fastify's JSON parser and body limits. */
|
|
17
|
+
export declare function createPrefixGateway(options: GatewayOptions): {
|
|
18
|
+
server: import("http").Server<typeof IncomingMessage, typeof import("http").ServerResponse>;
|
|
19
|
+
close(): Promise<void>;
|
|
20
|
+
};
|