@controlflow-ai/daemon 0.1.1 → 0.1.3
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 +66 -24
- package/package.json +16 -3
- 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 +810 -28
- package/src/agent-workspace.ts +183 -0
- package/src/app.ts +2183 -79
- package/src/args.ts +54 -7
- package/src/cli.ts +873 -14
- package/src/client.ts +482 -12
- package/src/coco.ts +9 -40
- package/src/codex.ts +33 -5
- package/src/config.ts +28 -4
- package/src/console.ts +460 -26
- package/src/daemon-client.ts +116 -3
- package/src/daemon.ts +958 -101
- package/src/db.ts +3216 -113
- package/src/delivery-ws.ts +269 -0
- package/src/format.ts +4 -1
- package/src/lark/app-registration.ts +141 -0
- package/src/lark/cli.ts +7 -137
- package/src/lark/credentials.ts +36 -3
- package/src/lark/event-router.ts +61 -5
- package/src/lark/inbound-events.ts +156 -3
- package/src/lark/server-integration.ts +659 -111
- package/src/lark/setup.ts +74 -5
- package/src/lark/ws-daemon.ts +136 -10
- package/src/local-api.ts +611 -14
- package/src/local-auth.ts +36 -3
- package/src/message-attachments.ts +71 -0
- package/src/messaging-cli.ts +741 -0
- package/src/messaging-status.ts +669 -0
- package/src/migrations/023_projects.ts +65 -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 +70 -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 +394 -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
|
|
@@ -103,32 +128,27 @@ bun run console -- agents list [--json]
|
|
|
103
128
|
# Create or update an agent record
|
|
104
129
|
bun run console -- agents create --key <agent-key> --name <display-name> [--runtime neeko|coco|codex] [--desc <description>]
|
|
105
130
|
|
|
106
|
-
# Create/update an agent
|
|
131
|
+
# Create/update an agent, optionally assign it to a computer, and optionally set up Lark at the end
|
|
107
132
|
bun run console -- agents onboard --key <agent-key> --name <display-name> [--runtime codex] [--desc <description>] [--computer-id <machine-id>]
|
|
133
|
+
bun run console -- agents onboard --interactive
|
|
134
|
+
|
|
135
|
+
# Update runtime and/or manage the Lark bot bound to the agent
|
|
136
|
+
bun run console -- agents update --key <agent-key> [--runtime neeko|coco|codex]
|
|
137
|
+
bun run console -- agents update --key <agent-key> --lark-app-id <app-id> --lark-app-secret <app-secret> [--lark-label <name>] [--rebind-lark]
|
|
138
|
+
bun run console -- agents update --key <agent-key> --unbind-lark
|
|
139
|
+
bun run console -- agents update --interactive
|
|
108
140
|
|
|
109
|
-
#
|
|
110
|
-
bun run console -- agents
|
|
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
|
|
111
143
|
```
|
|
112
144
|
|
|
113
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.
|
|
114
146
|
|
|
147
|
+
`agents onboard --interactive` asks at the end whether to set up a Lark bot. Lark setup can create a Feishu app by QR scan/open-link, or accept a pasted App ID/App Secret. `agents update --interactive` manages Lark binding, rebind, and unbind for existing agents.
|
|
148
|
+
|
|
115
149
|
### Lark Bots
|
|
116
150
|
|
|
117
151
|
```bash
|
|
118
|
-
# Configure a bot, resolve its bot open_id, bind it to an agent, and ask the running server to reload Lark
|
|
119
|
-
bun run console -- lark setup --app-id <app-id> --app-secret <app-secret> --label <name> --agent <agent-key>
|
|
120
|
-
|
|
121
|
-
# Configure a bot and create/update the bound agent in one command
|
|
122
|
-
bun run console -- lark setup \
|
|
123
|
-
--app-id <app-id> \
|
|
124
|
-
--app-secret <app-secret> \
|
|
125
|
-
--label <name> \
|
|
126
|
-
--agent codex \
|
|
127
|
-
--create-agent \
|
|
128
|
-
--agent-name "Codex" \
|
|
129
|
-
--runtime codex \
|
|
130
|
-
--computer-id <machine-id>
|
|
131
|
-
|
|
132
152
|
# List configured bots; secrets are redacted
|
|
133
153
|
bun run console -- lark list
|
|
134
154
|
|
|
@@ -139,7 +159,9 @@ bun run console -- lark events --limit 20
|
|
|
139
159
|
bun run console -- lark send --app-id <app-id> --to <receive_id> [--to-type chat_id|open_id|union_id|email|user_id] "Hello"
|
|
140
160
|
```
|
|
141
161
|
|
|
142
|
-
`lark
|
|
162
|
+
Lark bot binding is managed as part of agent settings. `agents update --lark-app-id ... --lark-app-secret ...` validates the credential, resolves `bot open_id`, writes `~/.pal/lark.json` or `PAL_LARK_CONFIG`, and asks the running server to reload Lark integration. An agent may only be bound to one Lark bot at a time; use `--rebind-lark` to move that agent from its previous bot to the new bot, or `--unbind-lark` to clear the binding while keeping the credential.
|
|
163
|
+
|
|
164
|
+
The Lark config file is written with mode `0600`. Use `--no-reload` when you want to edit or validate the file first, then trigger reload manually:
|
|
143
165
|
|
|
144
166
|
```bash
|
|
145
167
|
curl -s -X POST http://127.0.0.1:4127/api/lark/reload
|
|
@@ -180,7 +202,7 @@ CLI connects through the local daemon. It is the surface used by coding agents a
|
|
|
180
202
|
|
|
181
203
|
```bash
|
|
182
204
|
# Send a message
|
|
183
|
-
bun run cli -- send --room general --from alice [--
|
|
205
|
+
bun run cli -- send --room general --from alice [--mention codex ...] "Task completed"
|
|
184
206
|
|
|
185
207
|
# Reply to an existing message
|
|
186
208
|
bun run cli -- send --room general --from codex --parent 1 "Reply content"
|
|
@@ -195,6 +217,9 @@ bun run cli -- rooms members --room general [--json]
|
|
|
195
217
|
# Invite an agent to a room with a delivery mode
|
|
196
218
|
bun run cli -- rooms invite --room general --agent codex [--mode mentions|all|periodic|muted|off]
|
|
197
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
|
+
|
|
198
223
|
# Create a topic inside a room
|
|
199
224
|
bun run cli -- topics create --room general "Investigation"
|
|
200
225
|
|
|
@@ -224,6 +249,7 @@ bun run cli -- chats [--json]
|
|
|
224
249
|
bun run cli -- runs [--json]
|
|
225
250
|
bun run cli -- run-action <run-id> kill
|
|
226
251
|
bun run cli -- run-action <run-id> restart
|
|
252
|
+
bun run cli -- rooms restart-agent --room general --agent codex
|
|
227
253
|
```
|
|
228
254
|
|
|
229
255
|
## Daemon Commands
|
|
@@ -248,7 +274,21 @@ bun run daemon -- --agent codex --computer-id hm-media-demo --computer-secret pa
|
|
|
248
274
|
bun run daemon -- --server http://127.0.0.1:4127 --api-key sk_machine_... --once
|
|
249
275
|
```
|
|
250
276
|
|
|
251
|
-
|
|
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.
|
|
252
292
|
|
|
253
293
|
## Environment Variables
|
|
254
294
|
|
|
@@ -281,7 +321,7 @@ bun run console -- lark-users delete --user-id <union-id>
|
|
|
281
321
|
## Runtime Semantics
|
|
282
322
|
|
|
283
323
|
- Rooms are the primary conversation container; `chat` remains as a compatibility alias in several APIs.
|
|
284
|
-
- Agents receive deliveries through room subscriptions, direct
|
|
324
|
+
- Agents receive deliveries through room subscriptions, direct mentions, or Lark bot bindings.
|
|
285
325
|
- A daemon starts one agent run for each claimed delivery.
|
|
286
326
|
- Runs do not have a default timeout. Agents can work as long as needed.
|
|
287
327
|
- A visible chat reply is just a message, not run completion.
|
|
@@ -300,6 +340,7 @@ Common read and room APIs:
|
|
|
300
340
|
- `GET /api/rooms/<room-id-or-name>/members`
|
|
301
341
|
- `GET /api/rooms/<room-id-or-name>/mentionables`
|
|
302
342
|
- `POST /api/rooms/<room-id-or-name>/agents`
|
|
343
|
+
- `POST /api/rooms/<room-id-or-name>/agents/<agent-key>/restart`
|
|
303
344
|
- `POST /api/rooms/<room-id-or-name>/topics`
|
|
304
345
|
- `GET /api/messages?chat=general&after=0&limit=50`
|
|
305
346
|
- `GET /api/messages/<id>`
|
|
@@ -311,6 +352,7 @@ Computer, daemon, and delivery APIs:
|
|
|
311
352
|
- `POST /api/computers/provision`
|
|
312
353
|
- `POST /api/computers/connect`
|
|
313
354
|
- `POST /api/computers/<computer-id>/heartbeat`
|
|
355
|
+
- `GET /api/daemon/ws`
|
|
314
356
|
- `POST /api/daemons`
|
|
315
357
|
- `GET /api/deliveries?agent=codex&status=pending&limit=50`
|
|
316
358
|
- `POST /api/deliveries`
|
|
@@ -349,7 +391,7 @@ Example message body:
|
|
|
349
391
|
{
|
|
350
392
|
"room": "general",
|
|
351
393
|
"sender": "alice",
|
|
352
|
-
"
|
|
394
|
+
"mentions": ["codex"],
|
|
353
395
|
"content": "hello"
|
|
354
396
|
}
|
|
355
397
|
```
|
package/package.json
CHANGED
|
@@ -1,22 +1,35 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@controlflow-ai/daemon",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
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",
|
|
12
|
+
"web:dev": "vite --config web/app/vite.config.ts --host 127.0.0.1",
|
|
13
|
+
"web:build": "vite build --config web/app/vite.config.ts",
|
|
14
|
+
"web:preview": "vite preview --config web/app/vite.config.ts --host 127.0.0.1",
|
|
10
15
|
"typecheck": "tsc --noEmit",
|
|
11
16
|
"test": "bun test",
|
|
12
17
|
"check": "bun run typecheck && bun test"
|
|
13
18
|
},
|
|
14
19
|
"devDependencies": {
|
|
15
20
|
"@types/bun": "latest",
|
|
16
|
-
"
|
|
21
|
+
"@types/qrcode": "^1.5.6",
|
|
22
|
+
"@types/react": "^19.2.15",
|
|
23
|
+
"@types/react-dom": "^19.2.3",
|
|
24
|
+
"@vitejs/plugin-react": "^6.0.2",
|
|
25
|
+
"typescript": "^5.9.3",
|
|
26
|
+
"vite": "^8.0.14"
|
|
17
27
|
},
|
|
18
28
|
"dependencies": {
|
|
19
|
-
"@larksuiteoapi/node-sdk": "^1.59.0"
|
|
29
|
+
"@larksuiteoapi/node-sdk": "^1.59.0",
|
|
30
|
+
"qrcode": "^1.5.4",
|
|
31
|
+
"react": "^19.2.6",
|
|
32
|
+
"react-dom": "^19.2.6"
|
|
20
33
|
},
|
|
21
34
|
"bin": {
|
|
22
35
|
"daemon": "bin/daemon.js",
|
|
@@ -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
|
+
}
|