@controlflow-ai/daemon 0.1.0 → 0.1.2
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 +21 -19
- package/package.json +14 -3
- package/src/agent-runtime.ts +15 -0
- package/src/app.ts +314 -3
- package/src/client.ts +11 -3
- package/src/console.ts +397 -18
- package/src/daemon.ts +25 -6
- package/src/db.ts +181 -6
- package/src/lark/app-registration.ts +141 -0
- package/src/lark/cli.ts +4 -134
- package/src/lark/credentials.ts +36 -3
- package/src/lark/event-router.ts +22 -2
- package/src/lark/server-integration.ts +9 -16
- package/src/lark/setup.ts +74 -5
- package/src/local-api.ts +69 -2
- package/src/local-auth.ts +4 -3
- package/src/migrations/022_lark_authorized_users.ts +16 -0
- package/src/migrations/023_projects.ts +65 -0
- package/src/migrations.ts +3 -1
- package/src/network.ts +24 -0
- package/src/server.ts +21 -7
- package/src/types.ts +40 -0
- package/src/web.ts +368 -29
package/README.md
CHANGED
|
@@ -103,32 +103,24 @@ bun run console -- agents list [--json]
|
|
|
103
103
|
# Create or update an agent record
|
|
104
104
|
bun run console -- agents create --key <agent-key> --name <display-name> [--runtime neeko|coco|codex] [--desc <description>]
|
|
105
105
|
|
|
106
|
-
# Create/update an agent
|
|
106
|
+
# Create/update an agent, optionally assign it to a computer, and optionally set up Lark at the end
|
|
107
107
|
bun run console -- agents onboard --key <agent-key> --name <display-name> [--runtime codex] [--desc <description>] [--computer-id <machine-id>]
|
|
108
|
+
bun run console -- agents onboard --interactive
|
|
108
109
|
|
|
109
|
-
# Update
|
|
110
|
-
bun run console -- agents update --key <agent-key> --runtime neeko|coco|codex
|
|
110
|
+
# Update runtime and/or manage the Lark bot bound to the agent
|
|
111
|
+
bun run console -- agents update --key <agent-key> [--runtime neeko|coco|codex]
|
|
112
|
+
bun run console -- agents update --key <agent-key> --lark-app-id <app-id> --lark-app-secret <app-secret> [--lark-label <name>] [--rebind-lark]
|
|
113
|
+
bun run console -- agents update --key <agent-key> --unbind-lark
|
|
114
|
+
bun run console -- agents update --interactive
|
|
111
115
|
```
|
|
112
116
|
|
|
113
117
|
`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
118
|
|
|
119
|
+
`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.
|
|
120
|
+
|
|
115
121
|
### Lark Bots
|
|
116
122
|
|
|
117
123
|
```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
124
|
# List configured bots; secrets are redacted
|
|
133
125
|
bun run console -- lark list
|
|
134
126
|
|
|
@@ -139,7 +131,9 @@ bun run console -- lark events --limit 20
|
|
|
139
131
|
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
132
|
```
|
|
141
133
|
|
|
142
|
-
`lark
|
|
134
|
+
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.
|
|
135
|
+
|
|
136
|
+
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
137
|
|
|
144
138
|
```bash
|
|
145
139
|
curl -s -X POST http://127.0.0.1:4127/api/lark/reload
|
|
@@ -267,9 +261,17 @@ The daemon creates one local API for CLI calls, connects the computer to the ser
|
|
|
267
261
|
| `LOCK_DAEMON_URL` | `http://127.0.0.1:4137` | Local daemon API URL used by `bun run cli` |
|
|
268
262
|
| `LOCK_DAEMON_TOKEN` | token file fallback | Local daemon API bearer token |
|
|
269
263
|
| `PAL_LARK_CONFIG` | `$PAL_HOME/lark.json` | Lark bot credential file |
|
|
270
|
-
| `PAL_OWNER_LARK_UNION_ID` | - | Optional Lark sender allowlist for business ingest |
|
|
271
264
|
| `PAL_LARK_ACTION_REACTION_EMOJI` | `Typing` | Reaction added when Lark delivery is created |
|
|
272
265
|
|
|
266
|
+
Lark sender authorization is stored in Pal's database. Manage authorized Lark
|
|
267
|
+
users from the Web Settings Access tab or with:
|
|
268
|
+
|
|
269
|
+
```bash
|
|
270
|
+
bun run console -- lark-users list
|
|
271
|
+
bun run console -- lark-users add --user-id <union-id> --name "Display Name"
|
|
272
|
+
bun run console -- lark-users delete --user-id <union-id>
|
|
273
|
+
```
|
|
274
|
+
|
|
273
275
|
## Runtime Semantics
|
|
274
276
|
|
|
275
277
|
- Rooms are the primary conversation container; `chat` remains as a compatibility alias in several APIs.
|
package/package.json
CHANGED
|
@@ -1,22 +1,33 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@controlflow-ai/daemon",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"server": "bun run src/server.ts",
|
|
7
7
|
"daemon": "bun run src/daemon.ts",
|
|
8
8
|
"cli": "bun run src/cli.ts",
|
|
9
9
|
"console": "bun run src/console.ts",
|
|
10
|
+
"web:dev": "vite --config web/app/vite.config.ts --host 127.0.0.1",
|
|
11
|
+
"web:build": "vite build --config web/app/vite.config.ts",
|
|
12
|
+
"web:preview": "vite preview --config web/app/vite.config.ts --host 127.0.0.1",
|
|
10
13
|
"typecheck": "tsc --noEmit",
|
|
11
14
|
"test": "bun test",
|
|
12
15
|
"check": "bun run typecheck && bun test"
|
|
13
16
|
},
|
|
14
17
|
"devDependencies": {
|
|
15
18
|
"@types/bun": "latest",
|
|
16
|
-
"
|
|
19
|
+
"@types/qrcode": "^1.5.6",
|
|
20
|
+
"@types/react": "^19.2.15",
|
|
21
|
+
"@types/react-dom": "^19.2.3",
|
|
22
|
+
"@vitejs/plugin-react": "^6.0.2",
|
|
23
|
+
"typescript": "^5.9.3",
|
|
24
|
+
"vite": "^8.0.14"
|
|
17
25
|
},
|
|
18
26
|
"dependencies": {
|
|
19
|
-
"@larksuiteoapi/node-sdk": "^1.59.0"
|
|
27
|
+
"@larksuiteoapi/node-sdk": "^1.59.0",
|
|
28
|
+
"qrcode": "^1.5.4",
|
|
29
|
+
"react": "^19.2.6",
|
|
30
|
+
"react-dom": "^19.2.6"
|
|
20
31
|
},
|
|
21
32
|
"bin": {
|
|
22
33
|
"daemon": "bin/daemon.js",
|
package/src/agent-runtime.ts
CHANGED
|
@@ -9,6 +9,15 @@ export interface AgentRuntimeRunInput {
|
|
|
9
9
|
cwd: string;
|
|
10
10
|
agentHome?: string;
|
|
11
11
|
projectCwd?: string;
|
|
12
|
+
projectContext?: {
|
|
13
|
+
id: string;
|
|
14
|
+
name: string;
|
|
15
|
+
rootPath: string;
|
|
16
|
+
computerId: string;
|
|
17
|
+
computerName?: string | null;
|
|
18
|
+
accessible: boolean;
|
|
19
|
+
currentComputerId?: string | null;
|
|
20
|
+
};
|
|
12
21
|
extraArgs: string[];
|
|
13
22
|
localDaemonUrl?: string;
|
|
14
23
|
localDaemonToken?: string;
|
|
@@ -69,6 +78,11 @@ export function buildPalPrompt(input: AgentRuntimeRunInput): string {
|
|
|
69
78
|
const chatName = sanitizeProviderIds(input.message.chat_name);
|
|
70
79
|
const sender = sanitizeProviderIds(input.message.sender);
|
|
71
80
|
const content = sanitizeProviderIds(input.message.content);
|
|
81
|
+
const projectContext = input.projectContext
|
|
82
|
+
? input.projectContext.accessible
|
|
83
|
+
? `\nProject context:\n- You are working in project: ${sanitizeProviderIds(input.projectContext.name)}\n- Project computer: ${sanitizeProviderIds(input.projectContext.computerName || input.projectContext.computerId)}\n- Project path: ${input.projectContext.rootPath}\n- This agent is running on the project computer and can use the project workspace path above.`
|
|
84
|
+
: `\nProject context:\n- This room belongs to project: ${sanitizeProviderIds(input.projectContext.name)}\n- Project computer: ${sanitizeProviderIds(input.projectContext.computerName || input.projectContext.computerId)}\n- Project path: ${input.projectContext.rootPath}\n- This agent is currently running on computer: ${sanitizeProviderIds(input.projectContext.currentComputerId || 'unknown')}\n- The computers differ, so the project path is temporarily not accessible from this agent run. Do not claim to inspect or change that path unless access is restored.`
|
|
85
|
+
: '';
|
|
72
86
|
|
|
73
87
|
return `You are ${input.agent}, a long-running PAL coding agent connected to the pal chat server.
|
|
74
88
|
|
|
@@ -79,6 +93,7 @@ Workspace contract:
|
|
|
79
93
|
- Your cwd is for identity, memory, recovery state, scratch files, and durable local notes.
|
|
80
94
|
- Your cwd is not the project repository.
|
|
81
95
|
- The project workspace for source inspection, code changes, tests, and Git work is: ${projectCwd}
|
|
96
|
+
${projectContext}
|
|
82
97
|
|
|
83
98
|
Startup recovery:
|
|
84
99
|
- Read MEMORY.md in your cwd first when it exists.
|
package/src/app.ts
CHANGED
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
import { artifactHeaders, artifactViewerHtml } from './artifacts.js';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import QRCode from 'qrcode';
|
|
2
6
|
import { MessageStore } from './db.js';
|
|
3
7
|
import { failure, HttpError, json, numberParam, readJson, stringParam } from './http.js';
|
|
4
8
|
import { assertServerAuth } from './server-auth.js';
|
|
5
9
|
import { dashboardHtml } from './web.js';
|
|
6
10
|
import type { RunAction, RunStatus } from './types.js';
|
|
7
11
|
import { createLarkApiClient, sendTextMessage } from './lark/ws-daemon.js';
|
|
8
|
-
import { boundAgents, loadLarkCredentials, type LarkCredentialStore } from './lark/credentials.js';
|
|
12
|
+
import { boundAgents, defaultLarkConfigPath, loadLarkCredentials, type LarkCredentialStore } from './lark/credentials.js';
|
|
13
|
+
import { persistLarkCredential, resolveLarkBotInfo } from './lark/setup.js';
|
|
14
|
+
import { beginLarkAppRegistration, pollLarkAppRegistration, type LarkRegistrationComplete } from './lark/app-registration.js';
|
|
15
|
+
import { tailscaleAddress } from './network.js';
|
|
9
16
|
|
|
10
17
|
interface SendBody {
|
|
11
18
|
chat?: string;
|
|
@@ -27,6 +34,12 @@ interface CreateRoomBody {
|
|
|
27
34
|
kind?: 'group' | 'dm';
|
|
28
35
|
}
|
|
29
36
|
|
|
37
|
+
interface CreateProjectBody {
|
|
38
|
+
name?: string;
|
|
39
|
+
computer_id?: string;
|
|
40
|
+
root_path?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
30
43
|
interface StartRunBody {
|
|
31
44
|
message_id?: number;
|
|
32
45
|
agent?: string;
|
|
@@ -69,6 +82,25 @@ interface ConnectComputerBody {
|
|
|
69
82
|
agents?: Array<{ agent?: string; cwd?: string; capabilities?: Record<string, unknown> }>;
|
|
70
83
|
}
|
|
71
84
|
|
|
85
|
+
async function fetchDaemonJson<T>(input: { localUrl: string; token: string; path: string }): Promise<T> {
|
|
86
|
+
const base = input.localUrl.replace(/\/$/, '');
|
|
87
|
+
const response = await fetch(`${base}${input.path}`, {
|
|
88
|
+
headers: { authorization: `Bearer ${input.token}` },
|
|
89
|
+
});
|
|
90
|
+
const payload = await response.json().catch(() => ({})) as { ok?: boolean; data?: T; message?: string; code?: string };
|
|
91
|
+
if (!response.ok || payload.ok === false) {
|
|
92
|
+
throw new HttpError(response.status || 502, payload.code || 'DAEMON_REQUEST_FAILED', payload.message || 'daemon request failed');
|
|
93
|
+
}
|
|
94
|
+
return (payload.data ?? {}) as T;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function validateRemoteProjectPath(store: MessageStore, computerId: string, rootPath: string): Promise<void> {
|
|
98
|
+
const control = store.getComputerLocalControl(computerId);
|
|
99
|
+
if (!control) throw new HttpError(409, 'COMPUTER_NOT_BROWSABLE', 'computer is not online or does not expose a local filesystem browser');
|
|
100
|
+
const params = new URLSearchParams({ path: rootPath, validate: 'directory', show_hidden: 'true' });
|
|
101
|
+
await fetchDaemonJson({ localUrl: control.daemon.local_url, token: control.token, path: `/local/files?${params}` });
|
|
102
|
+
}
|
|
103
|
+
|
|
72
104
|
interface AssignAgentBody {
|
|
73
105
|
computer_id?: string;
|
|
74
106
|
}
|
|
@@ -81,6 +113,20 @@ interface OnboardAgentBody {
|
|
|
81
113
|
computer_id?: string;
|
|
82
114
|
}
|
|
83
115
|
|
|
116
|
+
interface LarkSetupBody {
|
|
117
|
+
app_id?: string;
|
|
118
|
+
app_secret?: string;
|
|
119
|
+
registration_id?: string;
|
|
120
|
+
label?: string;
|
|
121
|
+
agent?: string;
|
|
122
|
+
config?: string;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
interface LarkAuthorizedUserBody {
|
|
126
|
+
user_id?: string;
|
|
127
|
+
display_name?: string | null;
|
|
128
|
+
}
|
|
129
|
+
|
|
84
130
|
interface CreateDeliveryBody {
|
|
85
131
|
message_id?: number;
|
|
86
132
|
agent?: string;
|
|
@@ -154,6 +200,89 @@ function html(body: string): Response {
|
|
|
154
200
|
return new Response(body, { headers: { 'content-type': 'text/html; charset=utf-8' } });
|
|
155
201
|
}
|
|
156
202
|
|
|
203
|
+
const webDistDir = fileURLToPath(new URL('../web/app/dist/', import.meta.url));
|
|
204
|
+
|
|
205
|
+
interface PendingLarkRegistration {
|
|
206
|
+
id: string;
|
|
207
|
+
deviceCode: string;
|
|
208
|
+
url: string;
|
|
209
|
+
qrDataUrl: string;
|
|
210
|
+
expiresAt: number;
|
|
211
|
+
interval: number;
|
|
212
|
+
completed?: LarkRegistrationComplete;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const larkRegistrations = new Map<string, PendingLarkRegistration>();
|
|
216
|
+
|
|
217
|
+
function pruneLarkRegistrations(): void {
|
|
218
|
+
const now = Date.now();
|
|
219
|
+
for (const [id, registration] of larkRegistrations) {
|
|
220
|
+
if (registration.expiresAt < now - 60_000) larkRegistrations.delete(id);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function larkRegistrationPublic(entry: PendingLarkRegistration, status: 'pending' | 'complete' = entry.completed ? 'complete' : 'pending') {
|
|
225
|
+
return {
|
|
226
|
+
id: entry.id,
|
|
227
|
+
status,
|
|
228
|
+
url: entry.url,
|
|
229
|
+
qrDataUrl: entry.qrDataUrl,
|
|
230
|
+
expiresAt: new Date(entry.expiresAt).toISOString(),
|
|
231
|
+
interval: entry.interval,
|
|
232
|
+
appId: entry.completed?.appId ?? null,
|
|
233
|
+
tenantBrand: entry.completed?.tenantBrand ?? null,
|
|
234
|
+
userOpenId: entry.completed?.userOpenId ?? null,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async function pollStoredLarkRegistration(id: string): Promise<PendingLarkRegistration> {
|
|
239
|
+
pruneLarkRegistrations();
|
|
240
|
+
const entry = larkRegistrations.get(id);
|
|
241
|
+
if (!entry) throw new HttpError(404, 'LARK_REGISTRATION_NOT_FOUND', 'registration was not found or has expired');
|
|
242
|
+
if (entry.completed) return entry;
|
|
243
|
+
if (Date.now() > entry.expiresAt) {
|
|
244
|
+
larkRegistrations.delete(id);
|
|
245
|
+
throw new HttpError(410, 'LARK_REGISTRATION_EXPIRED', 'registration link expired');
|
|
246
|
+
}
|
|
247
|
+
const result = await pollLarkAppRegistration({ deviceCode: entry.deviceCode });
|
|
248
|
+
if (result.status === 'pending') return entry;
|
|
249
|
+
if (result.status === 'slow_down') {
|
|
250
|
+
entry.interval = Math.max(entry.interval + 5, result.interval);
|
|
251
|
+
return entry;
|
|
252
|
+
}
|
|
253
|
+
if (result.status === 'complete') {
|
|
254
|
+
entry.completed = result.registration;
|
|
255
|
+
return entry;
|
|
256
|
+
}
|
|
257
|
+
throw new HttpError(400, 'LARK_REGISTRATION_FAILED', result.message);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function webAssetContentType(pathname: string): string {
|
|
261
|
+
if (pathname.endsWith('.js')) return 'text/javascript; charset=utf-8';
|
|
262
|
+
if (pathname.endsWith('.css')) return 'text/css; charset=utf-8';
|
|
263
|
+
if (pathname.endsWith('.svg')) return 'image/svg+xml';
|
|
264
|
+
if (pathname.endsWith('.png')) return 'image/png';
|
|
265
|
+
if (pathname.endsWith('.jpg') || pathname.endsWith('.jpeg')) return 'image/jpeg';
|
|
266
|
+
if (pathname.endsWith('.webp')) return 'image/webp';
|
|
267
|
+
if (pathname.endsWith('.ico')) return 'image/x-icon';
|
|
268
|
+
return 'application/octet-stream';
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function builtWebIndex(): Response | null {
|
|
272
|
+
const indexPath = join(webDistDir, 'index.html');
|
|
273
|
+
if (!existsSync(indexPath)) return null;
|
|
274
|
+
return new Response(Bun.file(indexPath), { headers: { 'content-type': 'text/html; charset=utf-8' } });
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function builtWebAsset(pathname: string): Response | null {
|
|
278
|
+
if (!pathname.startsWith('/assets/')) return null;
|
|
279
|
+
const relative = decodeURIComponent(pathname.slice('/assets/'.length));
|
|
280
|
+
if (!relative || relative.includes('..') || relative.includes('/') || relative.includes('\\')) return null;
|
|
281
|
+
const assetPath = join(webDistDir, 'assets', relative);
|
|
282
|
+
if (!existsSync(assetPath)) return null;
|
|
283
|
+
return new Response(Bun.file(assetPath), { headers: { 'content-type': webAssetContentType(assetPath) } });
|
|
284
|
+
}
|
|
285
|
+
|
|
157
286
|
function daemonAuthFromRequest(request: Request): { computerId: string; connectionId: string; token: string } | null {
|
|
158
287
|
const computerId = request.headers.get('x-pal-computer-id')?.trim();
|
|
159
288
|
const connectionId = request.headers.get('x-pal-connection-id')?.trim();
|
|
@@ -185,13 +314,29 @@ export function route(store: MessageStore, request: Request): Promise<Response>
|
|
|
185
314
|
const { pathname } = url;
|
|
186
315
|
|
|
187
316
|
if (request.method === 'GET' && pathname === '/') {
|
|
188
|
-
return html(dashboardHtml());
|
|
317
|
+
return builtWebIndex() ?? html(dashboardHtml());
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (request.method === 'GET') {
|
|
321
|
+
const asset = builtWebAsset(pathname);
|
|
322
|
+
if (asset) return asset;
|
|
189
323
|
}
|
|
190
324
|
|
|
191
325
|
if (request.method === 'GET' && pathname === '/health') {
|
|
192
326
|
return json({ status: 'ok' });
|
|
193
327
|
}
|
|
194
328
|
|
|
329
|
+
if (request.method === 'GET' && pathname === '/api/server/access') {
|
|
330
|
+
const requestUrl = new URL(request.url);
|
|
331
|
+
const port = requestUrl.port || (requestUrl.protocol === 'https:' ? '443' : '80');
|
|
332
|
+
const tailscaleHost = tailscaleAddress();
|
|
333
|
+
return json({
|
|
334
|
+
localUrl: `${requestUrl.protocol}//127.0.0.1:${port}`,
|
|
335
|
+
tailscaleUrl: tailscaleHost ? `${requestUrl.protocol}//${tailscaleHost}:${port}` : null,
|
|
336
|
+
tailscaleHost,
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
|
|
195
340
|
if (request.method === 'GET' && pathname === '/api/computers') {
|
|
196
341
|
return json({ computers: store.listComputers(numberParam(url, 'limit', 50)) });
|
|
197
342
|
}
|
|
@@ -225,7 +370,14 @@ export function route(store: MessageStore, request: Request): Promise<Response>
|
|
|
225
370
|
capabilities: agent.capabilities,
|
|
226
371
|
})),
|
|
227
372
|
});
|
|
228
|
-
return json(
|
|
373
|
+
return json({
|
|
374
|
+
computer: result.computer,
|
|
375
|
+
connection: result.connection,
|
|
376
|
+
token: result.token,
|
|
377
|
+
local_control_token: result.localControlToken,
|
|
378
|
+
daemon: result.daemon,
|
|
379
|
+
agents: result.agents,
|
|
380
|
+
}, 201);
|
|
229
381
|
});
|
|
230
382
|
}
|
|
231
383
|
|
|
@@ -303,6 +455,37 @@ export function route(store: MessageStore, request: Request): Promise<Response>
|
|
|
303
455
|
return json({ rooms: store.listChats() });
|
|
304
456
|
}
|
|
305
457
|
|
|
458
|
+
if (request.method === 'GET' && pathname === '/api/projects') {
|
|
459
|
+
return json({ projects: store.listProjects(numberParam(url, 'limit', 50)) });
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
if (request.method === 'POST' && pathname === '/api/projects') {
|
|
463
|
+
return readJson<CreateProjectBody>(request).then(async (body) => {
|
|
464
|
+
const name = body.name?.trim();
|
|
465
|
+
const computerId = body.computer_id?.trim();
|
|
466
|
+
const rootPath = body.root_path?.trim();
|
|
467
|
+
if (!name) throw new HttpError(400, 'MISSING_PROJECT_NAME', 'name is required');
|
|
468
|
+
if (!computerId) throw new HttpError(400, 'MISSING_COMPUTER_ID', 'computer_id is required');
|
|
469
|
+
if (!rootPath) throw new HttpError(400, 'MISSING_ROOT_PATH', 'root_path is required');
|
|
470
|
+
await validateRemoteProjectPath(store, computerId, rootPath);
|
|
471
|
+
const project = store.createProject({ name, computerId, rootPath });
|
|
472
|
+
return json({ project }, 201);
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const computerFilesMatch = pathname.match(/^\/api\/computers\/([^/]+)\/files$/);
|
|
477
|
+
if (request.method === 'GET' && computerFilesMatch) {
|
|
478
|
+
const computerId = decodeURIComponent(computerFilesMatch[1]!);
|
|
479
|
+
const control = store.getComputerLocalControl(computerId);
|
|
480
|
+
if (!control) throw new HttpError(409, 'COMPUTER_NOT_BROWSABLE', 'computer is not online or does not expose a local filesystem browser');
|
|
481
|
+
const params = new URLSearchParams();
|
|
482
|
+
const browsePath = stringParam(url, 'path');
|
|
483
|
+
if (browsePath) params.set('path', browsePath);
|
|
484
|
+
if (url.searchParams.get('show_hidden') === 'true') params.set('show_hidden', 'true');
|
|
485
|
+
return fetchDaemonJson({ localUrl: control.daemon.local_url, token: control.token, path: `/local/files${params.size ? `?${params}` : ''}` })
|
|
486
|
+
.then((data) => json(data));
|
|
487
|
+
}
|
|
488
|
+
|
|
306
489
|
if (request.method === 'POST' && pathname === '/api/rooms') {
|
|
307
490
|
return readJson<CreateRoomBody>(request).then((body) => {
|
|
308
491
|
if (!body.name?.trim()) throw new HttpError(400, 'MISSING_ROOM_NAME', 'name is required');
|
|
@@ -312,6 +495,20 @@ export function route(store: MessageStore, request: Request): Promise<Response>
|
|
|
312
495
|
});
|
|
313
496
|
}
|
|
314
497
|
|
|
498
|
+
const projectRoomsMatch = pathname.match(/^\/api\/projects\/([^/]+)\/rooms$/);
|
|
499
|
+
if (request.method === 'POST' && projectRoomsMatch) {
|
|
500
|
+
return readJson<CreateRoomBody>(request).then((body) => {
|
|
501
|
+
if (!body.name?.trim()) throw new HttpError(400, 'MISSING_ROOM_NAME', 'name is required');
|
|
502
|
+
if (body.kind && body.kind !== 'group' && body.kind !== 'dm') throw new HttpError(400, 'BAD_ROOM_KIND', 'kind must be group or dm');
|
|
503
|
+
const room = store.createProjectRoom({
|
|
504
|
+
projectId: decodeURIComponent(projectRoomsMatch[1]!),
|
|
505
|
+
name: body.name,
|
|
506
|
+
kind: body.kind ?? 'group',
|
|
507
|
+
});
|
|
508
|
+
return json({ room }, 201);
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
|
|
315
512
|
const roomMembersMatch = pathname.match(/^\/api\/rooms\/([^/]+)\/members$/);
|
|
316
513
|
if (request.method === 'GET' && roomMembersMatch) {
|
|
317
514
|
const room = store.resolveRoom(decodeURIComponent(roomMembersMatch[1]!));
|
|
@@ -417,6 +614,120 @@ export function route(store: MessageStore, request: Request): Promise<Response>
|
|
|
417
614
|
return json({ sessions: store.listSessions(numberParam(url, 'limit', 50)) });
|
|
418
615
|
}
|
|
419
616
|
|
|
617
|
+
if (request.method === 'GET' && pathname === '/api/lark/config') {
|
|
618
|
+
const path = stringParam(url, 'config') ?? defaultLarkConfigPath();
|
|
619
|
+
const credentials = loadLarkCredentials(path);
|
|
620
|
+
return json({
|
|
621
|
+
path,
|
|
622
|
+
bots: credentials.bots.map((bot) => ({
|
|
623
|
+
appId: bot.appId,
|
|
624
|
+
label: bot.label ?? null,
|
|
625
|
+
agent: bot.agent ?? null,
|
|
626
|
+
boundAgents: boundAgents(bot),
|
|
627
|
+
botOpenId: bot.botOpenId ?? null,
|
|
628
|
+
hasSecret: Boolean(bot.appSecret),
|
|
629
|
+
})),
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
if (request.method === 'POST' && pathname === '/api/lark/registration') {
|
|
634
|
+
return (async () => {
|
|
635
|
+
pruneLarkRegistrations();
|
|
636
|
+
const begin = await beginLarkAppRegistration({ source: 'pal-web' });
|
|
637
|
+
const id = crypto.randomUUID();
|
|
638
|
+
const qrDataUrl = await QRCode.toDataURL(begin.url, {
|
|
639
|
+
margin: 1,
|
|
640
|
+
width: 220,
|
|
641
|
+
color: { dark: '#181714', light: '#ffffff' },
|
|
642
|
+
});
|
|
643
|
+
const entry: PendingLarkRegistration = {
|
|
644
|
+
id,
|
|
645
|
+
deviceCode: begin.deviceCode,
|
|
646
|
+
url: begin.url,
|
|
647
|
+
qrDataUrl,
|
|
648
|
+
expiresAt: Date.now() + begin.expiresIn * 1000,
|
|
649
|
+
interval: begin.interval,
|
|
650
|
+
};
|
|
651
|
+
larkRegistrations.set(id, entry);
|
|
652
|
+
return json(larkRegistrationPublic(entry), 201);
|
|
653
|
+
})();
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
const larkRegistrationMatch = pathname.match(/^\/api\/lark\/registration\/([^/]+)$/);
|
|
657
|
+
if (request.method === 'GET' && larkRegistrationMatch) {
|
|
658
|
+
return (async () => {
|
|
659
|
+
const entry = await pollStoredLarkRegistration(decodeURIComponent(larkRegistrationMatch[1]!));
|
|
660
|
+
return json(larkRegistrationPublic(entry));
|
|
661
|
+
})();
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
if (request.method === 'GET' && pathname === '/api/lark/authorized-users') {
|
|
665
|
+
return json({ users: store.listLarkAuthorizedUsers() });
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
if (request.method === 'POST' && pathname === '/api/lark/authorized-users') {
|
|
669
|
+
return readJson<LarkAuthorizedUserBody>(request).then((body) => {
|
|
670
|
+
const userId = body.user_id?.trim();
|
|
671
|
+
if (!userId) throw new HttpError(400, 'MISSING_USER_ID', 'user_id is required');
|
|
672
|
+
const user = store.upsertLarkAuthorizedUser({ userId, displayName: body.display_name });
|
|
673
|
+
return json({ user }, 201);
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
const larkAuthorizedUserMatch = pathname.match(/^\/api\/lark\/authorized-users\/([^/]+)$/);
|
|
678
|
+
if (request.method === 'DELETE' && larkAuthorizedUserMatch) {
|
|
679
|
+
const userId = decodeURIComponent(larkAuthorizedUserMatch[1]!);
|
|
680
|
+
const deleted = store.deleteLarkAuthorizedUser(userId);
|
|
681
|
+
if (!deleted) throw new HttpError(404, 'LARK_USER_NOT_FOUND', 'authorized Lark user was not found');
|
|
682
|
+
return json({ deleted: true });
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
if (request.method === 'POST' && pathname === '/api/lark/setup') {
|
|
686
|
+
return readJson<LarkSetupBody>(request).then(async (body) => {
|
|
687
|
+
const registrationId = body.registration_id?.trim();
|
|
688
|
+
let appId = body.app_id?.trim();
|
|
689
|
+
let appSecret = body.app_secret?.trim();
|
|
690
|
+
const agent = body.agent?.trim();
|
|
691
|
+
const label = body.label?.trim();
|
|
692
|
+
const configPath = body.config?.trim() || defaultLarkConfigPath();
|
|
693
|
+
if (registrationId) {
|
|
694
|
+
const registration = await pollStoredLarkRegistration(registrationId);
|
|
695
|
+
if (!registration.completed) throw new HttpError(409, 'LARK_REGISTRATION_PENDING', 'registration has not completed yet');
|
|
696
|
+
if (registration.completed.tenantBrand === 'lark') {
|
|
697
|
+
throw new HttpError(400, 'LARK_TENANT_UNSUPPORTED', 'Lark international tenants are not supported yet; use a Feishu tenant');
|
|
698
|
+
}
|
|
699
|
+
appId = registration.completed.appId;
|
|
700
|
+
appSecret = registration.completed.appSecret;
|
|
701
|
+
}
|
|
702
|
+
if (!appId) throw new HttpError(400, 'MISSING_APP_ID', 'app_id is required');
|
|
703
|
+
if (!appSecret) throw new HttpError(400, 'MISSING_APP_SECRET', 'app_secret is required');
|
|
704
|
+
if (!agent) throw new HttpError(400, 'MISSING_AGENT', 'agent is required');
|
|
705
|
+
if (!store.getAgent(agent)) throw new HttpError(404, 'AGENT_NOT_FOUND', `agent ${agent} not found`);
|
|
706
|
+
|
|
707
|
+
const botInfo = await resolveLarkBotInfo(appId, appSecret);
|
|
708
|
+
if (!botInfo.ok) {
|
|
709
|
+
throw new HttpError(400, 'LARK_CREDENTIALS_REJECTED', `could not resolve bot open_id (${botInfo.error}): ${botInfo.message}`);
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
const result = persistLarkCredential({
|
|
713
|
+
appId,
|
|
714
|
+
appSecret,
|
|
715
|
+
label,
|
|
716
|
+
agent,
|
|
717
|
+
botOpenId: botInfo.openId,
|
|
718
|
+
configPath,
|
|
719
|
+
});
|
|
720
|
+
const account = store.registerChannelAccount({
|
|
721
|
+
name: label || botInfo.appName || appId,
|
|
722
|
+
appId,
|
|
723
|
+
botOpenId: botInfo.openId,
|
|
724
|
+
agent,
|
|
725
|
+
});
|
|
726
|
+
if (registrationId) larkRegistrations.delete(registrationId);
|
|
727
|
+
return json({ ...result, appName: botInfo.appName ?? null, account }, 201);
|
|
728
|
+
});
|
|
729
|
+
}
|
|
730
|
+
|
|
420
731
|
const sessionRunsMatch = pathname.match(/^\/api\/sessions\/([^/]+)\/runs$/);
|
|
421
732
|
if (request.method === 'GET' && sessionRunsMatch) {
|
|
422
733
|
return json({ runs: store.listRunsForSession(sessionRunsMatch[1]!, numberParam(url, 'limit', 50)) });
|
package/src/client.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { defaultServerToken, defaultServerUrl } from './config.js';
|
|
2
2
|
import { serverAuthHeaders } from './server-auth.js';
|
|
3
|
-
import type { AgentRoomSubscriptionMode, AgentRun, AgentSession, ApiResponse, Chat, Computer, ComputerAgentAssignment, ComputerConnection, DaemonInstance, Message, MessageDelivery, ProvisionedComputer, RoomChannel, RoomParticipant, RunAction } from './types.js';
|
|
3
|
+
import type { AgentRoomSubscriptionMode, AgentRun, AgentSession, ApiResponse, Chat, Computer, ComputerAgentAssignment, ComputerConnection, DaemonInstance, Message, MessageDelivery, ProvisionedComputer, RemoteFileList, RoomChannel, RoomParticipant, RunAction } from './types.js';
|
|
4
4
|
|
|
5
5
|
export interface SendRequest {
|
|
6
6
|
chat?: string;
|
|
@@ -182,8 +182,16 @@ export class LockClient {
|
|
|
182
182
|
return data.computers;
|
|
183
183
|
}
|
|
184
184
|
|
|
185
|
-
async connectComputer(input: ConnectComputerRequest): Promise<{ computer: Computer; connection: ComputerConnection; token: string; daemon: DaemonInstance; agents: ComputerAgentAssignment[] }> {
|
|
186
|
-
const data = await this.post<{ computer: Computer; connection: ComputerConnection; token: string; daemon: DaemonInstance; agents: ComputerAgentAssignment[] }>('/api/computers/connect', input);
|
|
185
|
+
async connectComputer(input: ConnectComputerRequest): Promise<{ computer: Computer; connection: ComputerConnection; token: string; local_control_token?: string; daemon: DaemonInstance; agents: ComputerAgentAssignment[] }> {
|
|
186
|
+
const data = await this.post<{ computer: Computer; connection: ComputerConnection; token: string; local_control_token?: string; daemon: DaemonInstance; agents: ComputerAgentAssignment[] }>('/api/computers/connect', input);
|
|
187
|
+
return data;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async listComputerFiles(computerId: string, input: { path?: string; show_hidden?: boolean } = {}): Promise<RemoteFileList> {
|
|
191
|
+
const params = new URLSearchParams();
|
|
192
|
+
if (input.path) params.set('path', input.path);
|
|
193
|
+
if (input.show_hidden) params.set('show_hidden', 'true');
|
|
194
|
+
const data = await this.get<RemoteFileList>(`/api/computers/${encodeURIComponent(computerId)}/files${params.size ? `?${params}` : ''}`);
|
|
187
195
|
return data;
|
|
188
196
|
}
|
|
189
197
|
|