@controlflow-ai/daemon 0.1.2 → 0.1.4
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 +54 -6
- package/bin/daemon.js +6 -1
- package/package.json +3 -1
- package/src/agent-avatar.ts +30 -0
- package/src/agent-key.ts +28 -0
- package/src/agent-permissions.ts +359 -0
- package/src/agent-runtime.ts +795 -28
- package/src/agent-workspace.ts +183 -0
- package/src/app.ts +1970 -79
- package/src/args.ts +54 -7
- package/src/cli.ts +873 -14
- package/src/client.ts +472 -10
- package/src/coco.ts +9 -40
- package/src/codex.ts +33 -5
- package/src/config.ts +28 -4
- package/src/console.ts +230 -20
- package/src/daemon-client.ts +116 -3
- package/src/daemon.ts +937 -99
- package/src/db.ts +3128 -122
- package/src/delivery-ws.ts +269 -0
- package/src/format.ts +4 -1
- package/src/lark/cli.ts +3 -3
- package/src/lark/event-router.ts +60 -4
- package/src/lark/inbound-events.ts +156 -3
- package/src/lark/server-integration.ts +659 -111
- package/src/lark/ws-daemon.ts +136 -10
- package/src/local-api.ts +545 -15
- package/src/local-auth.ts +33 -1
- package/src/message-attachments.ts +71 -0
- package/src/messaging-cli.ts +741 -0
- package/src/messaging-status.ts +669 -0
- package/src/migrations/024_agents_model.ts +10 -0
- package/src/migrations/025_room_archive.ts +44 -0
- package/src/migrations/026_project_archive.ts +44 -0
- package/src/migrations/027_agent_permission_profiles.ts +16 -0
- package/src/migrations/028_lark_websocket_restart_state.ts +16 -0
- package/src/migrations/029_held_message_drafts.ts +32 -0
- package/src/migrations/030_agent_room_read_state.ts +25 -0
- package/src/migrations/031_room_tasks.ts +29 -0
- package/src/migrations/032_room_reminders.ts +29 -0
- package/src/migrations/033_room_saved_messages.ts +25 -0
- package/src/migrations/034_agent_activity_events.ts +27 -0
- package/src/migrations/035_agent_avatars.ts +17 -0
- package/src/migrations/036_project_agent_defaults.ts +21 -0
- package/src/migrations/037_message_attachments.ts +36 -0
- package/src/migrations/038_agent_activity_room_scope.ts +64 -0
- package/src/migrations/039_message_attachments_path.ts +34 -0
- package/src/migrations/040_message_attachments_file_schema.ts +80 -0
- package/src/migrations/041_room_system_events.ts +30 -0
- package/src/migrations/042_message_attachment_file_kind.ts +52 -0
- package/src/migrations/043_room_mode_skill_registry.ts +92 -0
- package/src/migrations/044_workflow_runtime.ts +69 -0
- package/src/migrations/045_skill_repository_ownership.ts +64 -0
- package/src/migrations.ts +69 -1
- package/src/neeko.ts +40 -4
- package/src/runtime-env.ts +179 -0
- package/src/runtime-registry.ts +83 -13
- package/src/server.ts +244 -4
- package/src/token-file.ts +13 -6
- package/src/types.ts +362 -0
- package/src/workflow-runtime.ts +275 -0
- package/src/web.ts +0 -904
package/README.md
CHANGED
|
@@ -15,6 +15,10 @@ The current preferred model is computer-first: provision a computer, start one d
|
|
|
15
15
|
|
|
16
16
|
## Quick Start
|
|
17
17
|
|
|
18
|
+
The commands below work on Linux/macOS shells. For native Windows daemon setup,
|
|
19
|
+
PowerShell examples, and Windows path notes, see
|
|
20
|
+
[`docs/windows-daemon.md`](docs/windows-daemon.md).
|
|
21
|
+
|
|
18
22
|
### 1. Install Dependencies
|
|
19
23
|
|
|
20
24
|
```bash
|
|
@@ -27,6 +31,12 @@ For disposable local smoke tests, keep state isolated:
|
|
|
27
31
|
export PAL_HOME=/tmp/pal-demo
|
|
28
32
|
```
|
|
29
33
|
|
|
34
|
+
PowerShell equivalent:
|
|
35
|
+
|
|
36
|
+
```powershell
|
|
37
|
+
$env:PAL_HOME = "$env:TEMP\pal-demo"
|
|
38
|
+
```
|
|
39
|
+
|
|
30
40
|
### 2. Start Server
|
|
31
41
|
|
|
32
42
|
```bash
|
|
@@ -39,6 +49,14 @@ Server listens on `http://127.0.0.1:4127/` by default. Change the bind address w
|
|
|
39
49
|
PAL_HOST=0.0.0.0 PAL_PORT=4127 bun run server
|
|
40
50
|
```
|
|
41
51
|
|
|
52
|
+
PowerShell equivalent:
|
|
53
|
+
|
|
54
|
+
```powershell
|
|
55
|
+
$env:PAL_HOST = "0.0.0.0"
|
|
56
|
+
$env:PAL_PORT = "4127"
|
|
57
|
+
bun run server
|
|
58
|
+
```
|
|
59
|
+
|
|
42
60
|
When clients or daemons connect through a non-default address, pass `--server <url>` or set `PAL_SERVER` / `LOCK_SERVER_URL` to the reachable URL.
|
|
43
61
|
|
|
44
62
|
### 3. Provision a Computer
|
|
@@ -49,6 +67,13 @@ curl -s -X POST http://127.0.0.1:4127/api/computers/provision \
|
|
|
49
67
|
-d '{"name":"Local Demo","server_url":"http://127.0.0.1:4127"}'
|
|
50
68
|
```
|
|
51
69
|
|
|
70
|
+
PowerShell equivalent:
|
|
71
|
+
|
|
72
|
+
```powershell
|
|
73
|
+
$body = @{ name = "Local Demo"; server_url = "http://127.0.0.1:4127" } | ConvertTo-Json
|
|
74
|
+
Invoke-RestMethod -Method Post -Uri "http://127.0.0.1:4127/api/computers/provision" -ContentType "application/json" -Body $body
|
|
75
|
+
```
|
|
76
|
+
|
|
52
77
|
The response includes a `computer.id`, an `api_key`, and a packaged daemon command. For a source checkout, start the local Bun daemon with the returned key:
|
|
53
78
|
|
|
54
79
|
```bash
|
|
@@ -84,10 +109,10 @@ curl -s -X POST http://127.0.0.1:4127/api/agents/codex/assignment \
|
|
|
84
109
|
|
|
85
110
|
```bash
|
|
86
111
|
# Through the local daemon
|
|
87
|
-
bun run cli -- send --room general --from alice --
|
|
112
|
+
bun run cli -- send --room general --from alice --mention codex "hello"
|
|
88
113
|
|
|
89
114
|
# Directly to the server
|
|
90
|
-
bun run console -- send --chat general --from alice --
|
|
115
|
+
bun run console -- send --chat general --from alice --mention codex "hello"
|
|
91
116
|
```
|
|
92
117
|
|
|
93
118
|
## Console Commands
|
|
@@ -112,6 +137,9 @@ bun run console -- agents update --key <agent-key> [--runtime neeko|coco|codex]
|
|
|
112
137
|
bun run console -- agents update --key <agent-key> --lark-app-id <app-id> --lark-app-secret <app-secret> [--lark-label <name>] [--rebind-lark]
|
|
113
138
|
bun run console -- agents update --key <agent-key> --unbind-lark
|
|
114
139
|
bun run console -- agents update --interactive
|
|
140
|
+
|
|
141
|
+
# Delete an agent and clean up its assignment, room memberships, subscriptions, Lark binding, and active deliveries
|
|
142
|
+
bun run console -- agents delete --key <agent-key> --yes
|
|
115
143
|
```
|
|
116
144
|
|
|
117
145
|
`codex` is the validated runtime for the current demo environment. Do not use `neeko` or `coco` until their binaries and adapters are available on the runtime host.
|
|
@@ -174,7 +202,7 @@ CLI connects through the local daemon. It is the surface used by coding agents a
|
|
|
174
202
|
|
|
175
203
|
```bash
|
|
176
204
|
# Send a message
|
|
177
|
-
bun run cli -- send --room general --from alice [--
|
|
205
|
+
bun run cli -- send --room general --from alice [--mention codex ...] "Task completed"
|
|
178
206
|
|
|
179
207
|
# Reply to an existing message
|
|
180
208
|
bun run cli -- send --room general --from codex --parent 1 "Reply content"
|
|
@@ -189,6 +217,9 @@ bun run cli -- rooms members --room general [--json]
|
|
|
189
217
|
# Invite an agent to a room with a delivery mode
|
|
190
218
|
bun run cli -- rooms invite --room general --agent codex [--mode mentions|all|periodic|muted|off]
|
|
191
219
|
|
|
220
|
+
# Restart the active runtime for an agent in a room
|
|
221
|
+
bun run cli -- rooms restart-agent --room general --agent codex [--json]
|
|
222
|
+
|
|
192
223
|
# Create a topic inside a room
|
|
193
224
|
bun run cli -- topics create --room general "Investigation"
|
|
194
225
|
|
|
@@ -218,6 +249,7 @@ bun run cli -- chats [--json]
|
|
|
218
249
|
bun run cli -- runs [--json]
|
|
219
250
|
bun run cli -- run-action <run-id> kill
|
|
220
251
|
bun run cli -- run-action <run-id> restart
|
|
252
|
+
bun run cli -- rooms restart-agent --room general --agent codex
|
|
221
253
|
```
|
|
222
254
|
|
|
223
255
|
## Daemon Commands
|
|
@@ -242,7 +274,21 @@ bun run daemon -- --agent codex --computer-id hm-media-demo --computer-secret pa
|
|
|
242
274
|
bun run daemon -- --server http://127.0.0.1:4127 --api-key sk_machine_... --once
|
|
243
275
|
```
|
|
244
276
|
|
|
245
|
-
|
|
277
|
+
PowerShell equivalents:
|
|
278
|
+
|
|
279
|
+
```powershell
|
|
280
|
+
# Preferred computer-first startup
|
|
281
|
+
bun run daemon -- --server http://127.0.0.1:4127 --api-key sk_machine_...
|
|
282
|
+
|
|
283
|
+
# Equivalent env-var startup
|
|
284
|
+
$env:PAL_API_KEY = "sk_machine_..."
|
|
285
|
+
bun run daemon -- --server http://127.0.0.1:4127
|
|
286
|
+
|
|
287
|
+
# Windows project checkout as runtime cwd
|
|
288
|
+
bun run daemon -- --server http://127.0.0.1:4127 --api-key sk_machine_... --cwd C:\Projects\pal
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
The daemon creates one local API for CLI calls, connects the computer to the server, opens a delivery WebSocket at `/api/daemon/ws`, heartbeats the connection, reconciles assigned agents, claims pending deliveries for currently assigned agents, and starts one runtime process per claimed delivery. The WebSocket is a wakeup path, not the reliability boundary: deliveries are still durably recorded as `pending`, so messages can be sent while the daemon is offline or the network is unhealthy, and the daemon drains pending work when the socket reconnects. If the WebSocket is unavailable, the daemon falls back to processing pending deliveries during its heartbeat loop. It can stay online with no assigned agents. Runs have no default timeout; use run actions to kill or restart them.
|
|
246
292
|
|
|
247
293
|
## Environment Variables
|
|
248
294
|
|
|
@@ -275,7 +321,7 @@ bun run console -- lark-users delete --user-id <union-id>
|
|
|
275
321
|
## Runtime Semantics
|
|
276
322
|
|
|
277
323
|
- Rooms are the primary conversation container; `chat` remains as a compatibility alias in several APIs.
|
|
278
|
-
- Agents receive deliveries through room subscriptions, direct
|
|
324
|
+
- Agents receive deliveries through room subscriptions, direct mentions, or Lark bot bindings.
|
|
279
325
|
- A daemon starts one agent run for each claimed delivery.
|
|
280
326
|
- Runs do not have a default timeout. Agents can work as long as needed.
|
|
281
327
|
- A visible chat reply is just a message, not run completion.
|
|
@@ -294,6 +340,7 @@ Common read and room APIs:
|
|
|
294
340
|
- `GET /api/rooms/<room-id-or-name>/members`
|
|
295
341
|
- `GET /api/rooms/<room-id-or-name>/mentionables`
|
|
296
342
|
- `POST /api/rooms/<room-id-or-name>/agents`
|
|
343
|
+
- `POST /api/rooms/<room-id-or-name>/agents/<agent-key>/restart`
|
|
297
344
|
- `POST /api/rooms/<room-id-or-name>/topics`
|
|
298
345
|
- `GET /api/messages?chat=general&after=0&limit=50`
|
|
299
346
|
- `GET /api/messages/<id>`
|
|
@@ -305,6 +352,7 @@ Computer, daemon, and delivery APIs:
|
|
|
305
352
|
- `POST /api/computers/provision`
|
|
306
353
|
- `POST /api/computers/connect`
|
|
307
354
|
- `POST /api/computers/<computer-id>/heartbeat`
|
|
355
|
+
- `GET /api/daemon/ws`
|
|
308
356
|
- `POST /api/daemons`
|
|
309
357
|
- `GET /api/deliveries?agent=codex&status=pending&limit=50`
|
|
310
358
|
- `POST /api/deliveries`
|
|
@@ -343,7 +391,7 @@ Example message body:
|
|
|
343
391
|
{
|
|
344
392
|
"room": "general",
|
|
345
393
|
"sender": "alice",
|
|
346
|
-
"
|
|
394
|
+
"mentions": ["codex"],
|
|
347
395
|
"content": "hello"
|
|
348
396
|
}
|
|
349
397
|
```
|
package/bin/daemon.js
CHANGED
package/package.json
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@controlflow-ai/daemon",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"server": "bun run src/server.ts",
|
|
7
|
+
"server:dev": "bun --watch src/server.ts",
|
|
7
8
|
"daemon": "bun run src/daemon.ts",
|
|
9
|
+
"daemon:dev": "bun --watch src/daemon.ts",
|
|
8
10
|
"cli": "bun run src/cli.ts",
|
|
9
11
|
"console": "bun run src/console.ts",
|
|
10
12
|
"web:dev": "vite --config web/app/vite.config.ts --host 127.0.0.1",
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
const avatarKeys = [
|
|
2
|
+
'sage',
|
|
3
|
+
'teal',
|
|
4
|
+
'indigo',
|
|
5
|
+
'amber',
|
|
6
|
+
'rose',
|
|
7
|
+
'violet',
|
|
8
|
+
'slate',
|
|
9
|
+
'moss',
|
|
10
|
+
] as const;
|
|
11
|
+
|
|
12
|
+
export type AgentAvatar = typeof avatarKeys[number];
|
|
13
|
+
|
|
14
|
+
function hashSeed(seed: string): number {
|
|
15
|
+
let hash = 2166136261;
|
|
16
|
+
for (const char of seed) {
|
|
17
|
+
hash ^= char.charCodeAt(0);
|
|
18
|
+
hash = Math.imul(hash, 16777619);
|
|
19
|
+
}
|
|
20
|
+
return hash >>> 0;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function generateAgentAvatar(agentKey: string, displayName = ''): AgentAvatar {
|
|
24
|
+
const seed = `${agentKey.trim().toLowerCase()}:${displayName.trim().toLowerCase()}`;
|
|
25
|
+
return avatarKeys[hashSeed(seed) % avatarKeys.length]!;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function normalizeAgentAvatar(value: unknown, agentKey: string, displayName = ''): AgentAvatar {
|
|
29
|
+
return avatarKeys.includes(value as AgentAvatar) ? value as AgentAvatar : generateAgentAvatar(agentKey, displayName);
|
|
30
|
+
}
|
package/src/agent-key.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { MessageStore } from './db.js';
|
|
2
|
+
|
|
3
|
+
export function agentKeyFromDisplayName(displayName: string): string {
|
|
4
|
+
const slug = displayName
|
|
5
|
+
.normalize('NFKD')
|
|
6
|
+
.replace(/[\u0300-\u036f]/g, '')
|
|
7
|
+
.toLowerCase()
|
|
8
|
+
.replace(/['’]/g, '')
|
|
9
|
+
.replace(/[^\p{Letter}\p{Number}]+/gu, '-')
|
|
10
|
+
.replace(/^-+|-+$/g, '')
|
|
11
|
+
.replace(/-{2,}/g, '-')
|
|
12
|
+
.split('')
|
|
13
|
+
.slice(0, 64)
|
|
14
|
+
.join('')
|
|
15
|
+
.replace(/-+$/g, '');
|
|
16
|
+
return slug || 'agent';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function uniqueAgentKeyFromDisplayName(displayName: string, store: Pick<MessageStore, 'getAgent'>): string {
|
|
20
|
+
const base = agentKeyFromDisplayName(displayName);
|
|
21
|
+
let candidate = base;
|
|
22
|
+
let suffix = 2;
|
|
23
|
+
while (store.getAgent(candidate)) {
|
|
24
|
+
candidate = `${base}-${suffix}`;
|
|
25
|
+
suffix += 1;
|
|
26
|
+
}
|
|
27
|
+
return candidate;
|
|
28
|
+
}
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { isAbsolute, join, normalize, relative, win32 } from 'node:path';
|
|
3
|
+
import type { AgentRuntimeRunInput } from './agent-runtime.js';
|
|
4
|
+
|
|
5
|
+
export type FilesystemPermissionMode = 'project-write' | 'scoped-write' | 'full-access';
|
|
6
|
+
|
|
7
|
+
export interface RuntimeLaunchContext {
|
|
8
|
+
runtimeRoot: string;
|
|
9
|
+
agentHome: string;
|
|
10
|
+
projectRoot: string | null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface AgentPermissionProfile {
|
|
14
|
+
filesystemMode: FilesystemPermissionMode;
|
|
15
|
+
extraWritableRoots: string[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface PermissionValidationDiagnostic {
|
|
19
|
+
level: 'error' | 'warning';
|
|
20
|
+
code: string;
|
|
21
|
+
message: string;
|
|
22
|
+
path?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface PermissionValidationResult {
|
|
26
|
+
ok: boolean;
|
|
27
|
+
profile: AgentPermissionProfile;
|
|
28
|
+
diagnostics: PermissionValidationDiagnostic[];
|
|
29
|
+
warnings: PermissionValidationDiagnostic[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const defaultAgentPermissionProfile: AgentPermissionProfile = {
|
|
33
|
+
filesystemMode: 'project-write',
|
|
34
|
+
extraWritableRoots: [],
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
function isWindowsAbsolutePath(path: string): boolean {
|
|
38
|
+
return /^[a-zA-Z]:[\\/]/.test(path) || /^\\\\[^\\]+\\[^\\]+/.test(path);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function isPortableAbsolutePath(path: string): boolean {
|
|
42
|
+
return isAbsolute(path) || isWindowsAbsolutePath(path);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function normalizeWindowsWritableRoot(path: string): string {
|
|
46
|
+
const normalized = win32.normalize(path.trim());
|
|
47
|
+
const root = win32.parse(normalized).root;
|
|
48
|
+
return normalized === root ? normalized : normalized.replace(/[\\/]+$/, '');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function isFilesystemPermissionMode(value: unknown): value is FilesystemPermissionMode {
|
|
52
|
+
return value === 'project-write' || value === 'scoped-write' || value === 'full-access';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function normalizeWritableRoot(path: string): string {
|
|
56
|
+
if (isWindowsAbsolutePath(path.trim())) return normalizeWindowsWritableRoot(path);
|
|
57
|
+
const normalized = normalize(path.trim());
|
|
58
|
+
return normalized === '/' ? normalized : normalized.replace(/\/+$/, '');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function normalizeExtraWritableRoots(paths: unknown): string[] {
|
|
62
|
+
if (!Array.isArray(paths)) return [];
|
|
63
|
+
const seen = new Set<string>();
|
|
64
|
+
const roots: string[] = [];
|
|
65
|
+
for (const item of paths) {
|
|
66
|
+
if (typeof item !== 'string') continue;
|
|
67
|
+
const normalized = normalizeWritableRoot(item);
|
|
68
|
+
if (!normalized || seen.has(normalized)) continue;
|
|
69
|
+
seen.add(normalized);
|
|
70
|
+
roots.push(normalized);
|
|
71
|
+
}
|
|
72
|
+
return roots;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function coerceAgentPermissionProfile(input: unknown): AgentPermissionProfile {
|
|
76
|
+
if (!input || typeof input !== 'object') return { ...defaultAgentPermissionProfile };
|
|
77
|
+
const record = input as Record<string, unknown>;
|
|
78
|
+
return {
|
|
79
|
+
filesystemMode: isFilesystemPermissionMode(record.filesystemMode) ? record.filesystemMode : defaultAgentPermissionProfile.filesystemMode,
|
|
80
|
+
extraWritableRoots: normalizeExtraWritableRoots(record.extraWritableRoots),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function validateAgentPermissionProfile(input: unknown, options: { checkFilesystem?: boolean } = {}): PermissionValidationResult {
|
|
85
|
+
const raw = input && typeof input === 'object' ? input as Record<string, unknown> : {};
|
|
86
|
+
const diagnostics: PermissionValidationDiagnostic[] = [];
|
|
87
|
+
const filesystemMode = raw.filesystemMode;
|
|
88
|
+
if (filesystemMode !== undefined && !isFilesystemPermissionMode(filesystemMode)) {
|
|
89
|
+
diagnostics.push({ level: 'error', code: 'BAD_FILESYSTEM_MODE', message: 'filesystemMode must be project-write, scoped-write, or full-access' });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if ('extraWritableRoots' in raw && !Array.isArray(raw.extraWritableRoots)) {
|
|
93
|
+
diagnostics.push({ level: 'error', code: 'BAD_EXTRA_WRITABLE_ROOTS', message: 'extraWritableRoots must be an array of absolute paths' });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const seen = new Set<string>();
|
|
97
|
+
const roots: string[] = [];
|
|
98
|
+
if (Array.isArray(raw.extraWritableRoots)) {
|
|
99
|
+
for (const value of raw.extraWritableRoots) {
|
|
100
|
+
if (typeof value !== 'string') {
|
|
101
|
+
diagnostics.push({ level: 'error', code: 'BAD_EXTRA_WRITABLE_ROOT', message: 'extra writable root must be a string' });
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
const trimmed = value.trim();
|
|
105
|
+
if (!trimmed) {
|
|
106
|
+
diagnostics.push({ level: 'error', code: 'EMPTY_EXTRA_WRITABLE_ROOT', message: 'extra writable root cannot be empty' });
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
const normalized = normalizeWritableRoot(trimmed);
|
|
110
|
+
if (!isPortableAbsolutePath(normalized)) {
|
|
111
|
+
diagnostics.push({ level: 'error', code: 'EXTRA_WRITABLE_ROOT_NOT_ABSOLUTE', message: 'extra writable root must be an absolute path', path: value });
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (seen.has(normalized)) {
|
|
115
|
+
diagnostics.push({ level: 'error', code: 'DUPLICATE_EXTRA_WRITABLE_ROOT', message: 'extra writable roots cannot contain duplicates', path: normalized });
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
seen.add(normalized);
|
|
119
|
+
if (options.checkFilesystem) {
|
|
120
|
+
try {
|
|
121
|
+
const stat = statSync(normalized);
|
|
122
|
+
if (!stat.isDirectory()) diagnostics.push({ level: 'error', code: 'EXTRA_WRITABLE_ROOT_NOT_DIRECTORY', message: 'extra writable root must be a directory', path: normalized });
|
|
123
|
+
} catch {
|
|
124
|
+
diagnostics.push({ level: 'error', code: 'EXTRA_WRITABLE_ROOT_NOT_FOUND', message: 'extra writable root does not exist', path: normalized });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
roots.push(normalized);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const profile: AgentPermissionProfile = {
|
|
132
|
+
filesystemMode: isFilesystemPermissionMode(filesystemMode) ? filesystemMode : defaultAgentPermissionProfile.filesystemMode,
|
|
133
|
+
extraWritableRoots: roots,
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
if (profile.filesystemMode === 'full-access') {
|
|
137
|
+
diagnostics.push({ level: 'warning', code: 'FULL_ACCESS_DANGEROUS', message: 'full-access disables filesystem sandboxing and should only be used for trusted agents' });
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const warnings = diagnostics.filter((diagnostic) => diagnostic.level === 'warning');
|
|
141
|
+
return { ok: diagnostics.every((diagnostic) => diagnostic.level !== 'error'), profile, diagnostics, warnings };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function runtimeStateHome(input: AgentRuntimeRunInput): string {
|
|
145
|
+
return input.launchContext?.agentHome ?? input.agentHome ?? input.cwd;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function runtimeLaunchRoot(input: AgentRuntimeRunInput): string {
|
|
149
|
+
return input.launchContext?.runtimeRoot ?? input.agentHome ?? input.cwd;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function buildRuntimeLaunchContext(input: AgentRuntimeRunInput): RuntimeLaunchContext {
|
|
153
|
+
const agentHome = input.agentHome ?? input.cwd;
|
|
154
|
+
const projectRoot = input.projectCwd ?? null;
|
|
155
|
+
return {
|
|
156
|
+
runtimeRoot: agentHome,
|
|
157
|
+
agentHome,
|
|
158
|
+
projectRoot,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function effectivePermissionProfile(input: AgentRuntimeRunInput): AgentPermissionProfile {
|
|
163
|
+
return input.permissionProfile ?? defaultAgentPermissionProfile;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function buildCodexPermissionArgs(profile: AgentPermissionProfile, context: RuntimeLaunchContext): string[] {
|
|
167
|
+
if (profile.filesystemMode === 'full-access') return ['--sandbox', 'danger-full-access'];
|
|
168
|
+
const args = ['--sandbox', 'workspace-write', '-c', 'sandbox_workspace_write.network_access=true'];
|
|
169
|
+
for (const root of writableRootsForProfile(profile, context)) {
|
|
170
|
+
if (normalizeWritableRoot(root) === normalizeWritableRoot(context.runtimeRoot)) continue;
|
|
171
|
+
args.push('--add-dir', root);
|
|
172
|
+
}
|
|
173
|
+
return args;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function writableRootsForProfile(profile: AgentPermissionProfile, context: RuntimeLaunchContext): string[] {
|
|
177
|
+
if (profile.filesystemMode === 'full-access') return ['/'];
|
|
178
|
+
const roots = [context.runtimeRoot];
|
|
179
|
+
if (context.projectRoot) roots.push(context.projectRoot);
|
|
180
|
+
if (profile.filesystemMode === 'scoped-write') roots.push(...profile.extraWritableRoots);
|
|
181
|
+
return Array.from(new Set(roots.map(normalizeWritableRoot)));
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function buildCocoPermissionArgs(profile: AgentPermissionProfile, context: RuntimeLaunchContext): string[] {
|
|
185
|
+
if (profile.filesystemMode === 'full-access') return ['-y'];
|
|
186
|
+
return writableRootsForProfile(profile, context).flatMap((root) => ['--add-dir', root]);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function pathWithinRoot(path: string, root: string): boolean {
|
|
190
|
+
const normalizedPath = normalizeWritableRoot(path);
|
|
191
|
+
const normalizedRoot = normalizeWritableRoot(root);
|
|
192
|
+
if (normalizedRoot === '/') return true;
|
|
193
|
+
if (normalizedPath === normalizedRoot) return true;
|
|
194
|
+
if (isWindowsAbsolutePath(normalizedPath) || isWindowsAbsolutePath(normalizedRoot)) {
|
|
195
|
+
if (!isWindowsAbsolutePath(normalizedPath) || !isWindowsAbsolutePath(normalizedRoot)) return false;
|
|
196
|
+
const rel = win32.relative(normalizedRoot, normalizedPath);
|
|
197
|
+
return Boolean(rel) && !rel.startsWith('..') && !win32.isAbsolute(rel);
|
|
198
|
+
}
|
|
199
|
+
const rel = relative(normalizedRoot, normalizedPath);
|
|
200
|
+
return Boolean(rel) && !rel.startsWith('..') && !isAbsolute(rel);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function collectAbsolutePaths(value: unknown, paths: string[] = []): string[] {
|
|
204
|
+
if (typeof value === 'string') {
|
|
205
|
+
const trimmed = value.trim();
|
|
206
|
+
if (isPortableAbsolutePath(trimmed)) paths.push(normalizeWritableRoot(trimmed));
|
|
207
|
+
return paths;
|
|
208
|
+
}
|
|
209
|
+
if (Array.isArray(value)) {
|
|
210
|
+
for (const item of value) collectAbsolutePaths(item, paths);
|
|
211
|
+
return paths;
|
|
212
|
+
}
|
|
213
|
+
if (value && typeof value === 'object') {
|
|
214
|
+
for (const item of Object.values(value)) collectAbsolutePaths(item, paths);
|
|
215
|
+
}
|
|
216
|
+
return paths;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function permissionOptionMatches(option: Record<string, unknown>, pattern: RegExp): boolean {
|
|
220
|
+
return pattern.test(String(option.name ?? option.optionId ?? option.description ?? option.kind ?? ''));
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function selectAcpPermissionOption(input: {
|
|
224
|
+
params?: Record<string, unknown>;
|
|
225
|
+
profile: AgentPermissionProfile;
|
|
226
|
+
context: RuntimeLaunchContext;
|
|
227
|
+
}): string {
|
|
228
|
+
const options = Array.isArray(input.params?.options) ? input.params.options as Array<Record<string, unknown>> : [];
|
|
229
|
+
const fallback = String(options[0]?.optionId ?? 'allow');
|
|
230
|
+
const allowOption = options.find((option) => permissionOptionMatches(option, /allow|approve|yes/i));
|
|
231
|
+
const denyOption = options.find((option) => permissionOptionMatches(option, /deny|reject|no/i));
|
|
232
|
+
|
|
233
|
+
if (input.profile.filesystemMode === 'full-access') {
|
|
234
|
+
return String((allowOption ?? options[0])?.optionId ?? 'allow');
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const paths = Array.from(new Set(collectAbsolutePaths(input.params)));
|
|
238
|
+
const roots = writableRootsForProfile(input.profile, input.context);
|
|
239
|
+
const hasOutsidePath = paths.some((path) => !roots.some((root) => pathWithinRoot(path, root)));
|
|
240
|
+
if (hasOutsidePath && denyOption) return String(denyOption.optionId ?? denyOption.name ?? fallback);
|
|
241
|
+
return String((allowOption ?? options[0])?.optionId ?? 'allow');
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function directoryPattern(path: string): string {
|
|
245
|
+
const withoutTrailingSlash = path.replace(/\/+$/, '');
|
|
246
|
+
return `${withoutTrailingSlash}/**`;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const openCodeBuiltinAgentNames = ['build', 'plan', 'general', 'explore', 'scout'];
|
|
250
|
+
|
|
251
|
+
function nonInteractiveOpenCodePermissions(): Record<string, unknown> {
|
|
252
|
+
return {
|
|
253
|
+
read: { '*': 'allow' },
|
|
254
|
+
list: 'allow',
|
|
255
|
+
glob: 'allow',
|
|
256
|
+
grep: 'allow',
|
|
257
|
+
edit: 'allow',
|
|
258
|
+
bash: 'allow',
|
|
259
|
+
task: 'allow',
|
|
260
|
+
skill: 'allow',
|
|
261
|
+
lsp: 'allow',
|
|
262
|
+
todoread: 'allow',
|
|
263
|
+
todowrite: 'allow',
|
|
264
|
+
webfetch: 'allow',
|
|
265
|
+
websearch: 'allow',
|
|
266
|
+
codesearch: 'allow',
|
|
267
|
+
ask: 'deny',
|
|
268
|
+
question: 'deny',
|
|
269
|
+
doom_loop: 'deny',
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function nonInteractiveOpenCodeTools(): Record<string, boolean> {
|
|
274
|
+
return {
|
|
275
|
+
ask: false,
|
|
276
|
+
question: false,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function withPalOwnedOpenCodePermissions(baseConfig: Record<string, unknown>, permission: Record<string, unknown>): Record<string, unknown> {
|
|
281
|
+
const { agent: baseAgent, permission: _basePermission, tools: _baseTools, ...rest } = baseConfig;
|
|
282
|
+
const tools = nonInteractiveOpenCodeTools();
|
|
283
|
+
const config: Record<string, unknown> = {
|
|
284
|
+
...rest,
|
|
285
|
+
'$schema': typeof baseConfig.$schema === 'string' ? baseConfig.$schema : 'https://opencode.ai/config.json',
|
|
286
|
+
permission,
|
|
287
|
+
tools,
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
const agentNames = new Set(openCodeBuiltinAgentNames);
|
|
291
|
+
if (baseAgent && typeof baseAgent === 'object' && !Array.isArray(baseAgent)) {
|
|
292
|
+
for (const name of Object.keys(baseAgent as Record<string, unknown>)) agentNames.add(name);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const agent: Record<string, unknown> = {};
|
|
296
|
+
for (const name of agentNames) {
|
|
297
|
+
const rawValue = baseAgent && typeof baseAgent === 'object' && !Array.isArray(baseAgent)
|
|
298
|
+
? (baseAgent as Record<string, unknown>)[name]
|
|
299
|
+
: undefined;
|
|
300
|
+
if (rawValue !== undefined) {
|
|
301
|
+
if (!rawValue || typeof rawValue !== 'object' || Array.isArray(rawValue)) {
|
|
302
|
+
agent[name] = rawValue;
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
const { permission: _agentPermission, tools: _agentTools, ...agentRest } = rawValue as Record<string, unknown>;
|
|
306
|
+
agent[name] = { ...agentRest, permission, tools };
|
|
307
|
+
} else {
|
|
308
|
+
agent[name] = { permission, tools };
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
config.agent = agent;
|
|
312
|
+
|
|
313
|
+
return config;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export function buildOpenCodePermissionConfig(profile: AgentPermissionProfile, context: RuntimeLaunchContext, baseConfig: Record<string, unknown> = {}): Record<string, unknown> {
|
|
317
|
+
if (profile.filesystemMode === 'full-access') {
|
|
318
|
+
return withPalOwnedOpenCodePermissions(baseConfig, {
|
|
319
|
+
...nonInteractiveOpenCodePermissions(),
|
|
320
|
+
external_directory: 'allow',
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const writableRoots = writableRootsForProfile(profile, context);
|
|
325
|
+
const externalDirectory: Record<string, 'allow' | 'deny'> = { '*': 'deny' };
|
|
326
|
+
for (const root of writableRoots) {
|
|
327
|
+
if (normalizeWritableRoot(root) !== normalizeWritableRoot(context.runtimeRoot)) {
|
|
328
|
+
externalDirectory[directoryPattern(root)] = 'allow';
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
return withPalOwnedOpenCodePermissions(baseConfig, {
|
|
333
|
+
...nonInteractiveOpenCodePermissions(),
|
|
334
|
+
external_directory: externalDirectory,
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function readProjectOpenCodeConfig(runtimeRoot: string): Record<string, unknown> {
|
|
339
|
+
const path = join(runtimeRoot, 'opencode.json');
|
|
340
|
+
if (!existsSync(path)) return {};
|
|
341
|
+
try {
|
|
342
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8')) as unknown;
|
|
343
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as Record<string, unknown> : {};
|
|
344
|
+
} catch {
|
|
345
|
+
return {};
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
export function writeGeneratedOpenCodeConfig(input: AgentRuntimeRunInput): string {
|
|
350
|
+
const context = input.launchContext ?? buildRuntimeLaunchContext(input);
|
|
351
|
+
const profile = effectivePermissionProfile(input);
|
|
352
|
+
const baseConfig = readProjectOpenCodeConfig(context.projectRoot ?? context.runtimeRoot);
|
|
353
|
+
const config = buildOpenCodePermissionConfig(profile, context, baseConfig);
|
|
354
|
+
const dir = join(context.agentHome, 'generated');
|
|
355
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
356
|
+
const path = join(dir, 'opencode-pal-permissions.json');
|
|
357
|
+
writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
|
|
358
|
+
return path;
|
|
359
|
+
}
|