@controlflow-ai/daemon 0.1.2 → 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 +54 -6
- 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 +936 -98
- 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/src/local-api.ts
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
|
-
import { LockClient } from './client.js';
|
|
2
|
-
import {
|
|
1
|
+
import { LockClient, type CreateHandoffRoomRequest } from './client.js';
|
|
2
|
+
import { authenticateLocalRequest, assertLoopbackBindHost, type LocalApiPrincipal, type RuntimeTokenLookup } from './local-auth.js';
|
|
3
3
|
import { failure, HttpError, json, numberParam, readJson, stringParam } from './http.js';
|
|
4
|
+
import { ALL_AGENTS_MENTION } from './db.js';
|
|
4
5
|
import { readdir, stat } from 'node:fs/promises';
|
|
5
6
|
import { homedir } from 'node:os';
|
|
6
7
|
import { dirname, isAbsolute, join, normalize } from 'node:path';
|
|
7
|
-
import type { RemoteFileEntry, RemoteFileList, RunAction } from './types.js';
|
|
8
|
+
import type { HeldDraftStatus, MessageFreshnessInput, RemoteFileEntry, RemoteFileList, RoomParticipant, RoomReminderStatus, RoomTaskStatus, RunAction } from './types.js';
|
|
8
9
|
|
|
9
10
|
interface LocalApiOptions {
|
|
10
11
|
serverUrl: string;
|
|
11
12
|
token?: string;
|
|
12
|
-
controlToken?: string;
|
|
13
|
-
daemonAuth?: ConstructorParameters<typeof LockClient>[1];
|
|
13
|
+
controlToken?: string | (() => string | undefined);
|
|
14
|
+
daemonAuth?: ConstructorParameters<typeof LockClient>[1] | (() => ConstructorParameters<typeof LockClient>[1]);
|
|
15
|
+
runtimeTokenLookup?: RuntimeTokenLookup;
|
|
14
16
|
}
|
|
15
17
|
|
|
16
18
|
interface ArtifactBody {
|
|
@@ -36,12 +38,324 @@ interface SendBody {
|
|
|
36
38
|
type?: 'message' | 'system';
|
|
37
39
|
idempotency_key?: string | null;
|
|
38
40
|
mentions?: string[];
|
|
41
|
+
mention_lint?: 'strict' | 'literal' | 'infer';
|
|
42
|
+
attachments?: Array<{
|
|
43
|
+
kind?: 'image' | 'file';
|
|
44
|
+
mime_type?: string;
|
|
45
|
+
filename?: string | null;
|
|
46
|
+
content_base64?: string;
|
|
47
|
+
}>;
|
|
48
|
+
messages?: Array<{
|
|
49
|
+
parent_id?: number;
|
|
50
|
+
channel_id?: string | null;
|
|
51
|
+
recipient?: string | null;
|
|
52
|
+
content?: string;
|
|
53
|
+
type?: 'message' | 'system';
|
|
54
|
+
idempotency_key?: string | null;
|
|
55
|
+
mentions?: string[];
|
|
56
|
+
attachments?: Array<{
|
|
57
|
+
kind?: 'image' | 'file';
|
|
58
|
+
mime_type?: string;
|
|
59
|
+
filename?: string | null;
|
|
60
|
+
content_base64?: string;
|
|
61
|
+
}>;
|
|
62
|
+
}>;
|
|
63
|
+
freshness?: MessageFreshnessInput;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
interface MentionLintResult {
|
|
67
|
+
missing_mentions: string[];
|
|
68
|
+
available_mentions: string[];
|
|
69
|
+
message: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function normalizeSendAttachments(attachments: SendBody['attachments']) {
|
|
73
|
+
return attachments?.map((attachment) => ({
|
|
74
|
+
kind: attachment.kind === 'image' ? 'image' as const : 'file' as const,
|
|
75
|
+
mime_type: attachment.mime_type ?? '',
|
|
76
|
+
filename: attachment.filename,
|
|
77
|
+
content_base64: attachment.content_base64 ?? '',
|
|
78
|
+
}));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function normalizeMentionValue(value: unknown): string {
|
|
82
|
+
if (typeof value !== 'string') return '';
|
|
83
|
+
const mention = value.trim().replace(/^@/, '');
|
|
84
|
+
const lower = mention.toLowerCase();
|
|
85
|
+
if (lower === 'all' || lower === '_all' || mention === '所有人' || mention === ALL_AGENTS_MENTION) return ALL_AGENTS_MENTION;
|
|
86
|
+
return mention;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function mentionBoundaryChar(value: string | undefined): boolean {
|
|
90
|
+
if (!value) return true;
|
|
91
|
+
return !/[\p{Letter}\p{Number}._-]/u.test(value);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function textMentionsKey(content: string, key: string): boolean {
|
|
95
|
+
if (!key) return false;
|
|
96
|
+
let start = 0;
|
|
97
|
+
const needle = `@${key}`;
|
|
98
|
+
while (start < content.length) {
|
|
99
|
+
const index = content.indexOf(needle, start);
|
|
100
|
+
if (index < 0) return false;
|
|
101
|
+
const previous = index > 0 ? content[index - 1] : undefined;
|
|
102
|
+
const next = content[index + needle.length];
|
|
103
|
+
if (mentionBoundaryChar(previous) && mentionBoundaryChar(next)) return true;
|
|
104
|
+
start = index + 1;
|
|
105
|
+
}
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function textMentionsAllAgents(content: string): boolean {
|
|
110
|
+
return /(^|[^\p{Letter}\p{Number}._-])@(?:all|_all|所有人)(?=$|[^\p{Letter}\p{Number}._-])/iu.test(content);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function lintAgentTextMentions(input: {
|
|
114
|
+
sender: string;
|
|
115
|
+
items: Array<{ content?: string; mentions?: string[] }>;
|
|
116
|
+
participants: RoomParticipant[];
|
|
117
|
+
}): MentionLintResult | null {
|
|
118
|
+
const agentKeys = input.participants
|
|
119
|
+
.filter((participant) => participant.kind === 'agent' && participant.status === 'active')
|
|
120
|
+
.map((participant) => participant.participant_id)
|
|
121
|
+
.filter(Boolean);
|
|
122
|
+
if (!agentKeys.includes(input.sender)) return null;
|
|
123
|
+
const available = agentKeys;
|
|
124
|
+
const missing = new Set<string>();
|
|
125
|
+
|
|
126
|
+
for (const item of input.items) {
|
|
127
|
+
const content = item.content ?? '';
|
|
128
|
+
if (!content) continue;
|
|
129
|
+
const explicitMentions = new Set((item.mentions ?? []).map(normalizeMentionValue).filter(Boolean));
|
|
130
|
+
if (textMentionsAllAgents(content) && !explicitMentions.has(ALL_AGENTS_MENTION)) {
|
|
131
|
+
missing.add(ALL_AGENTS_MENTION);
|
|
132
|
+
}
|
|
133
|
+
for (const key of agentKeys) {
|
|
134
|
+
if (key === input.sender) continue;
|
|
135
|
+
if (explicitMentions.has(key)) continue;
|
|
136
|
+
if (textMentionsKey(content, key)) missing.add(key);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (missing.size === 0) return null;
|
|
141
|
+
const display = [...missing].map((mention) => mention === ALL_AGENTS_MENTION ? 'all' : mention);
|
|
142
|
+
return {
|
|
143
|
+
missing_mentions: [...missing],
|
|
144
|
+
available_mentions: available,
|
|
145
|
+
message: `Message text contains ${display.map((mention) => `@${mention}`).join(', ')} but no matching --mention flag was provided.`,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function lintLocalSendMentions(client: LockClient, principal: LocalApiPrincipal, body: SendBody): Promise<MentionLintResult | null> {
|
|
150
|
+
if (body.mention_lint === 'literal') return null;
|
|
151
|
+
const sender = body.sender?.trim() ?? '';
|
|
152
|
+
if (!sender) return null;
|
|
153
|
+
const roomRef = principal.kind === 'agent'
|
|
154
|
+
? principal.chatId
|
|
155
|
+
: body.room_id ?? body.chat_id ?? body.room ?? body.chat;
|
|
156
|
+
if (!roomRef) return null;
|
|
157
|
+
let members: Awaited<ReturnType<LockClient['listRoomMembers']>>;
|
|
158
|
+
try {
|
|
159
|
+
members = await client.listRoomMembers(roomRef);
|
|
160
|
+
} catch (error) {
|
|
161
|
+
if (error instanceof HttpError && error.code === 'ROOM_NOT_FOUND') return null;
|
|
162
|
+
throw error;
|
|
163
|
+
}
|
|
164
|
+
const items = body.messages?.length
|
|
165
|
+
? body.messages.map((message) => ({
|
|
166
|
+
content: message.content ?? '',
|
|
167
|
+
mentions: [...(body.mentions ?? []), ...(message.mentions ?? [])],
|
|
168
|
+
}))
|
|
169
|
+
: [{ content: body.content ?? '', mentions: body.mentions ?? [] }];
|
|
170
|
+
return lintAgentTextMentions({ sender, items, participants: members.participants });
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function appendInferredMentions(base: string[] | undefined, missing: string[]): string[] {
|
|
174
|
+
const out: string[] = [];
|
|
175
|
+
const seen = new Set<string>();
|
|
176
|
+
for (const value of [...(base ?? []), ...missing]) {
|
|
177
|
+
const normalized = normalizeMentionValue(value);
|
|
178
|
+
if (!normalized || seen.has(normalized)) continue;
|
|
179
|
+
seen.add(normalized);
|
|
180
|
+
out.push(normalized);
|
|
181
|
+
}
|
|
182
|
+
return out;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
interface CreateRoomTasksBody {
|
|
186
|
+
title?: string;
|
|
187
|
+
tasks?: Array<{ title?: string }>;
|
|
188
|
+
created_by?: string | null;
|
|
189
|
+
source_message_id?: number | null;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
interface ClaimRoomTaskBody {
|
|
193
|
+
assignee?: string;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
interface UpdateRoomTaskBody {
|
|
197
|
+
status?: RoomTaskStatus;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
interface CreateRoomReminderBody {
|
|
201
|
+
msg_id?: number;
|
|
202
|
+
title?: string;
|
|
203
|
+
created_by?: string | null;
|
|
204
|
+
fire_at?: string | null;
|
|
205
|
+
delay_seconds?: number | null;
|
|
206
|
+
repeat?: string | null;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
interface CreateRoomSavedMessageBody {
|
|
210
|
+
msg_id?: number;
|
|
211
|
+
source_message_id?: number;
|
|
212
|
+
saved_by?: string | null;
|
|
213
|
+
note?: string | null;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
interface LocalHandoffRoomBody extends Partial<CreateHandoffRoomRequest> {}
|
|
217
|
+
|
|
218
|
+
interface ReviseHeldDraftBody {
|
|
219
|
+
content?: string;
|
|
39
220
|
}
|
|
40
221
|
|
|
41
222
|
function routeNotFound(): Response {
|
|
42
223
|
return Response.json({ ok: false, code: 'NOT_FOUND', message: 'not found' }, { status: 404 });
|
|
43
224
|
}
|
|
44
225
|
|
|
226
|
+
function requireDaemonPrincipal(principal: LocalApiPrincipal): void {
|
|
227
|
+
if (principal.kind !== 'daemon') {
|
|
228
|
+
throw new HttpError(403, 'LOCAL_AGENT_FORBIDDEN', 'agent-scoped token is not allowed for this local API route');
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function requireMessageWritePrincipal(principal: LocalApiPrincipal, body: SendBody): void {
|
|
233
|
+
if (principal.kind === 'daemon') return;
|
|
234
|
+
const sender = body.sender?.trim();
|
|
235
|
+
const chatId = body.room_id ?? body.chat_id;
|
|
236
|
+
if (sender !== principal.agent) {
|
|
237
|
+
throw new HttpError(403, 'LOCAL_AGENT_FORBIDDEN', 'agent-scoped token can only send as its own agent');
|
|
238
|
+
}
|
|
239
|
+
if (!chatId || chatId !== principal.chatId) {
|
|
240
|
+
throw new HttpError(403, 'LOCAL_AGENT_FORBIDDEN', 'agent-scoped token can only send to its active room by chat_id');
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function requireAgentRoomScope(principal: LocalApiPrincipal, roomRef: string): void {
|
|
245
|
+
if (principal.kind === 'daemon') return;
|
|
246
|
+
if (roomRef !== principal.chatId) {
|
|
247
|
+
throw new HttpError(403, 'LOCAL_AGENT_FORBIDDEN', 'agent-scoped token can only manage tasks in its active room by chat_id');
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function requireAgentHandoffCreate(principal: LocalApiPrincipal, body: LocalHandoffRoomBody): void {
|
|
252
|
+
if (principal.kind === 'daemon') return;
|
|
253
|
+
if ((body.actor?.trim() || '') !== principal.agent) {
|
|
254
|
+
throw new HttpError(403, 'LOCAL_AGENT_FORBIDDEN', 'agent-scoped token can only create handoff rooms as its own agent');
|
|
255
|
+
}
|
|
256
|
+
if ((body.source_room_id?.trim() || '') !== principal.chatId) {
|
|
257
|
+
throw new HttpError(403, 'LOCAL_AGENT_FORBIDDEN', 'agent-scoped token can only create handoff rooms from its active room');
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function requireAgentTaskCreator(principal: LocalApiPrincipal, roomRef: string, body: CreateRoomTasksBody): void {
|
|
262
|
+
requireAgentRoomScope(principal, roomRef);
|
|
263
|
+
if (principal.kind === 'daemon') return;
|
|
264
|
+
if ((body.created_by?.trim() || '') !== principal.agent) {
|
|
265
|
+
throw new HttpError(403, 'LOCAL_AGENT_FORBIDDEN', 'agent-scoped token can only create tasks as its own agent');
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function requireAgentTaskAssignee(principal: LocalApiPrincipal, roomRef: string, body: ClaimRoomTaskBody): void {
|
|
270
|
+
requireAgentRoomScope(principal, roomRef);
|
|
271
|
+
if (principal.kind === 'daemon') return;
|
|
272
|
+
if ((body.assignee?.trim() || '') !== principal.agent) {
|
|
273
|
+
throw new HttpError(403, 'LOCAL_AGENT_FORBIDDEN', 'agent-scoped token can only claim tasks as its own agent');
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function requireAgentReminderCreator(principal: LocalApiPrincipal, body: CreateRoomReminderBody): void {
|
|
278
|
+
if (principal.kind === 'daemon') return;
|
|
279
|
+
if ((body.created_by?.trim() || '') !== principal.agent) {
|
|
280
|
+
throw new HttpError(403, 'LOCAL_AGENT_FORBIDDEN', 'agent-scoped token can only schedule reminders as its own agent');
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async function requireAgentReminderSource(client: LockClient, principal: LocalApiPrincipal, sourceMessageId: number): Promise<void> {
|
|
285
|
+
if (principal.kind === 'daemon') return;
|
|
286
|
+
const message = await client.getMessage(sourceMessageId, principal.agent);
|
|
287
|
+
if (message.chat_id !== principal.chatId) {
|
|
288
|
+
throw new HttpError(403, 'LOCAL_AGENT_FORBIDDEN', 'agent-scoped token can only schedule reminders from its active room');
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
async function requireAgentReminderAccess(client: LockClient, principal: LocalApiPrincipal, reminderId: string): Promise<void> {
|
|
293
|
+
if (principal.kind === 'daemon') return;
|
|
294
|
+
const reminders = await client.listReminders({
|
|
295
|
+
status: 'all',
|
|
296
|
+
created_by: principal.agent,
|
|
297
|
+
room_id: principal.chatId,
|
|
298
|
+
limit: 500,
|
|
299
|
+
});
|
|
300
|
+
if (!reminders.some((reminder) => reminder.id === reminderId)) {
|
|
301
|
+
throw new HttpError(403, 'LOCAL_AGENT_FORBIDDEN', 'agent-scoped token can only cancel reminders it scheduled in its active room');
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function requireAgentSavedMessageCreator(principal: LocalApiPrincipal, body: CreateRoomSavedMessageBody): void {
|
|
306
|
+
if (principal.kind === 'daemon') return;
|
|
307
|
+
if ((body.saved_by?.trim() || '') !== principal.agent) {
|
|
308
|
+
throw new HttpError(403, 'LOCAL_AGENT_FORBIDDEN', 'agent-scoped token can only save messages as its own agent');
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
async function requireAgentSavedMessageSource(client: LockClient, principal: LocalApiPrincipal, sourceMessageId: number): Promise<void> {
|
|
313
|
+
if (principal.kind === 'daemon') return;
|
|
314
|
+
const message = await client.getMessage(sourceMessageId, principal.agent);
|
|
315
|
+
if (message.chat_id !== principal.chatId) {
|
|
316
|
+
throw new HttpError(403, 'LOCAL_AGENT_FORBIDDEN', 'agent-scoped token can only save messages from its active room');
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async function requireAgentSavedMessageAccess(client: LockClient, principal: LocalApiPrincipal, savedMessageId: string): Promise<void> {
|
|
321
|
+
if (principal.kind === 'daemon') return;
|
|
322
|
+
const savedMessages = await client.listSavedMessages({
|
|
323
|
+
saved_by: principal.agent,
|
|
324
|
+
room_id: principal.chatId,
|
|
325
|
+
limit: 500,
|
|
326
|
+
});
|
|
327
|
+
if (!savedMessages.some((savedMessage) => savedMessage.id === savedMessageId)) {
|
|
328
|
+
throw new HttpError(403, 'LOCAL_AGENT_FORBIDDEN', 'agent-scoped token can only remove messages it saved in its active room');
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
async function requireAgentHeldDraftAccess(client: LockClient, principal: LocalApiPrincipal, draftId: string): Promise<void> {
|
|
333
|
+
if (principal.kind === 'daemon') return;
|
|
334
|
+
const drafts = await client.listHeldDrafts({
|
|
335
|
+
agent: principal.agent,
|
|
336
|
+
room_id: principal.chatId,
|
|
337
|
+
status: 'held',
|
|
338
|
+
limit: 500,
|
|
339
|
+
});
|
|
340
|
+
if (!drafts.some((draft) => draft.id === draftId)) {
|
|
341
|
+
throw new HttpError(403, 'LOCAL_AGENT_FORBIDDEN', 'agent-scoped token can only manage held drafts it owns in its active room');
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
async function listRoomsForPrincipal(client: LockClient, principal: LocalApiPrincipal) {
|
|
346
|
+
return principal.kind === 'agent' ? client.listRooms(principal.agent) : client.listRooms();
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
async function resolveRoomForPrincipal(client: LockClient, principal: LocalApiPrincipal, roomRef: string): Promise<string> {
|
|
350
|
+
if (principal.kind === 'daemon') return roomRef;
|
|
351
|
+
const rooms = await listRoomsForPrincipal(client, principal);
|
|
352
|
+
const room = rooms.find((candidate) => candidate.id === roomRef || candidate.name === roomRef);
|
|
353
|
+
if (!room) {
|
|
354
|
+
throw new HttpError(403, 'LOCAL_AGENT_FORBIDDEN', 'agent-scoped token can only read rooms it can participate in');
|
|
355
|
+
}
|
|
356
|
+
return room.id;
|
|
357
|
+
}
|
|
358
|
+
|
|
45
359
|
async function canReadDirectory(path: string): Promise<boolean> {
|
|
46
360
|
try {
|
|
47
361
|
await readdir(path);
|
|
@@ -99,8 +413,10 @@ async function listLocalFiles(input: { path?: string; showHidden?: boolean }): P
|
|
|
99
413
|
|
|
100
414
|
export async function localRoute(request: Request, options: LocalApiOptions): Promise<Response> {
|
|
101
415
|
try {
|
|
102
|
-
|
|
103
|
-
const
|
|
416
|
+
const controlToken = typeof options.controlToken === 'function' ? options.controlToken() : options.controlToken;
|
|
417
|
+
const principal = authenticateLocalRequest(request, [options.token, controlToken].filter((item): item is string => Boolean(item)), options.runtimeTokenLookup);
|
|
418
|
+
const daemonAuth = typeof options.daemonAuth === 'function' ? options.daemonAuth() : options.daemonAuth;
|
|
419
|
+
const client = new LockClient(options.serverUrl, daemonAuth ?? null);
|
|
104
420
|
const url = new URL(request.url);
|
|
105
421
|
const { pathname } = url;
|
|
106
422
|
|
|
@@ -109,6 +425,7 @@ export async function localRoute(request: Request, options: LocalApiOptions): Pr
|
|
|
109
425
|
}
|
|
110
426
|
|
|
111
427
|
if (request.method === 'GET' && pathname === '/local/files') {
|
|
428
|
+
requireDaemonPrincipal(principal);
|
|
112
429
|
const files = await listLocalFiles({
|
|
113
430
|
path: stringParam(url, 'path'),
|
|
114
431
|
showHidden: url.searchParams.get('show_hidden') === 'true',
|
|
@@ -117,52 +434,254 @@ export async function localRoute(request: Request, options: LocalApiOptions): Pr
|
|
|
117
434
|
}
|
|
118
435
|
|
|
119
436
|
if (request.method === 'GET' && pathname === '/local/chats') {
|
|
437
|
+
requireDaemonPrincipal(principal);
|
|
120
438
|
return json({ chats: await client.listChats() });
|
|
121
439
|
}
|
|
122
440
|
|
|
123
441
|
if (request.method === 'GET' && pathname === '/local/rooms') {
|
|
124
|
-
return json({ rooms: await client
|
|
442
|
+
return json({ rooms: await listRoomsForPrincipal(client, principal) });
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
if (request.method === 'POST' && pathname === '/local/rooms/handoff') {
|
|
446
|
+
const body = await readJson<LocalHandoffRoomBody>(request);
|
|
447
|
+
requireAgentHandoffCreate(principal, body);
|
|
448
|
+
return json(await client.createHandoffRoom({
|
|
449
|
+
source_room_id: body.source_room_id ?? '',
|
|
450
|
+
source_message_id: body.source_message_id,
|
|
451
|
+
actor: body.actor ?? '',
|
|
452
|
+
purpose: body.purpose ?? '',
|
|
453
|
+
name: body.name ?? '',
|
|
454
|
+
team: body.team,
|
|
455
|
+
handoff_content: body.handoff_content,
|
|
456
|
+
}), 201);
|
|
125
457
|
}
|
|
126
458
|
|
|
127
459
|
const roomMembersMatch = pathname.match(/^\/local\/rooms\/([^/]+)\/members$/);
|
|
128
460
|
if (request.method === 'GET' && roomMembersMatch) {
|
|
129
|
-
|
|
461
|
+
const roomRef = await resolveRoomForPrincipal(client, principal, decodeURIComponent(roomMembersMatch[1]!));
|
|
462
|
+
return json(await client.listRoomMembers(roomRef));
|
|
130
463
|
}
|
|
131
464
|
|
|
132
465
|
|
|
133
466
|
const roomInviteMatch = pathname.match(/^\/local\/rooms\/([^/]+)\/agents$/);
|
|
134
467
|
if (request.method === 'POST' && roomInviteMatch) {
|
|
468
|
+
requireDaemonPrincipal(principal);
|
|
135
469
|
const body = await readJson<{ agent?: string; mode?: string }>(request);
|
|
136
470
|
return json(await client.inviteAgentToRoom(decodeURIComponent(roomInviteMatch[1]!), { agent: body.agent ?? '', mode: body.mode as never }), 201);
|
|
137
471
|
}
|
|
138
472
|
|
|
139
473
|
const roomTopicMatch = pathname.match(/^\/local\/rooms\/([^/]+)\/topics$/);
|
|
140
474
|
if (request.method === 'POST' && roomTopicMatch) {
|
|
475
|
+
requireDaemonPrincipal(principal);
|
|
141
476
|
const body = await readJson<{ name?: string; created_by?: string | null }>(request);
|
|
142
477
|
if (!body.name?.trim()) throw new HttpError(400, 'MISSING_TOPIC_NAME', 'name is required');
|
|
143
478
|
const channel = await client.createTopic(decodeURIComponent(roomTopicMatch[1]!), { name: body.name, created_by: body.created_by });
|
|
144
479
|
return json({ channel }, 201);
|
|
145
480
|
}
|
|
146
481
|
|
|
482
|
+
const roomTasksMatch = pathname.match(/^\/local\/rooms\/([^/]+)\/tasks$/);
|
|
483
|
+
if (request.method === 'GET' && roomTasksMatch) {
|
|
484
|
+
const roomRef = decodeURIComponent(roomTasksMatch[1]!);
|
|
485
|
+
requireAgentRoomScope(principal, roomRef);
|
|
486
|
+
return json({
|
|
487
|
+
tasks: await client.listRoomTasks(
|
|
488
|
+
roomRef,
|
|
489
|
+
(stringParam(url, 'status') ?? 'all') as RoomTaskStatus | 'all',
|
|
490
|
+
numberParam(url, 'limit', 50),
|
|
491
|
+
),
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
if (request.method === 'POST' && roomTasksMatch) {
|
|
496
|
+
const roomRef = decodeURIComponent(roomTasksMatch[1]!);
|
|
497
|
+
const body = await readJson<CreateRoomTasksBody>(request);
|
|
498
|
+
requireAgentTaskCreator(principal, roomRef, body);
|
|
499
|
+
return json({
|
|
500
|
+
tasks: await client.createRoomTasks(roomRef, {
|
|
501
|
+
title: body.title,
|
|
502
|
+
tasks: body.tasks?.map((task) => ({ title: task.title ?? '' })),
|
|
503
|
+
created_by: body.created_by,
|
|
504
|
+
source_message_id: body.source_message_id,
|
|
505
|
+
}),
|
|
506
|
+
}, 201);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
const roomTaskClaimMatch = pathname.match(/^\/local\/rooms\/([^/]+)\/tasks\/([^/]+)\/claim$/);
|
|
510
|
+
if (request.method === 'POST' && roomTaskClaimMatch) {
|
|
511
|
+
const roomRef = decodeURIComponent(roomTaskClaimMatch[1]!);
|
|
512
|
+
const body = await readJson<ClaimRoomTaskBody>(request);
|
|
513
|
+
requireAgentTaskAssignee(principal, roomRef, body);
|
|
514
|
+
return json({ task: await client.claimRoomTask(roomRef, Number(roomTaskClaimMatch[2]), { assignee: body.assignee ?? '' }) });
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
const roomTaskUnclaimMatch = pathname.match(/^\/local\/rooms\/([^/]+)\/tasks\/([^/]+)\/unclaim$/);
|
|
518
|
+
if (request.method === 'POST' && roomTaskUnclaimMatch) {
|
|
519
|
+
const roomRef = decodeURIComponent(roomTaskUnclaimMatch[1]!);
|
|
520
|
+
requireAgentRoomScope(principal, roomRef);
|
|
521
|
+
return json({ task: await client.unclaimRoomTask(roomRef, Number(roomTaskUnclaimMatch[2])) });
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
const roomTaskStatusMatch = pathname.match(/^\/local\/rooms\/([^/]+)\/tasks\/([^/]+)$/);
|
|
525
|
+
if (request.method === 'PATCH' && roomTaskStatusMatch) {
|
|
526
|
+
const roomRef = decodeURIComponent(roomTaskStatusMatch[1]!);
|
|
527
|
+
requireAgentRoomScope(principal, roomRef);
|
|
528
|
+
const body = await readJson<UpdateRoomTaskBody>(request);
|
|
529
|
+
return json({ task: await client.updateRoomTaskStatus(roomRef, Number(roomTaskStatusMatch[2]), body.status as RoomTaskStatus) });
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
const roomAgentRestartMatch = pathname.match(/^\/local\/rooms\/([^/]+)\/agents\/([^/]+)\/restart$/);
|
|
533
|
+
if (request.method === 'POST' && roomAgentRestartMatch) {
|
|
534
|
+
const roomRef = decodeURIComponent(roomAgentRestartMatch[1]!);
|
|
535
|
+
requireAgentRoomScope(principal, roomRef);
|
|
536
|
+
const restartAgent = decodeURIComponent(roomAgentRestartMatch[2]!);
|
|
537
|
+
if (principal.kind === 'agent' && restartAgent !== principal.agent) {
|
|
538
|
+
throw new HttpError(403, 'LOCAL_AGENT_FORBIDDEN', 'agent-scoped token can only restart its own runtime');
|
|
539
|
+
}
|
|
540
|
+
return json({ run: await client.restartRoomAgent(roomRef, restartAgent) });
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
if (request.method === 'GET' && pathname === '/local/reminders') {
|
|
544
|
+
return json({
|
|
545
|
+
reminders: await client.listReminders({
|
|
546
|
+
status: (stringParam(url, 'status') ?? 'scheduled') as RoomReminderStatus | 'all',
|
|
547
|
+
created_by: principal.kind === 'agent' ? principal.agent : stringParam(url, 'created_by'),
|
|
548
|
+
room_id: principal.kind === 'agent'
|
|
549
|
+
? principal.chatId
|
|
550
|
+
: stringParam(url, 'room_id') ?? stringParam(url, 'chat_id'),
|
|
551
|
+
limit: numberParam(url, 'limit', 50),
|
|
552
|
+
}),
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
if (request.method === 'POST' && pathname === '/local/reminders') {
|
|
557
|
+
const body = await readJson<CreateRoomReminderBody>(request);
|
|
558
|
+
requireAgentReminderCreator(principal, body);
|
|
559
|
+
const sourceMessageId = Number(body.msg_id);
|
|
560
|
+
if (!Number.isInteger(sourceMessageId) || sourceMessageId < 1) {
|
|
561
|
+
throw new HttpError(400, 'INVALID_SOURCE_MESSAGE_ID', 'msg_id must be a positive integer');
|
|
562
|
+
}
|
|
563
|
+
await requireAgentReminderSource(client, principal, sourceMessageId);
|
|
564
|
+
return json({
|
|
565
|
+
reminder: await client.scheduleReminder({
|
|
566
|
+
msg_id: sourceMessageId,
|
|
567
|
+
title: body.title ?? '',
|
|
568
|
+
created_by: body.created_by,
|
|
569
|
+
fire_at: body.fire_at,
|
|
570
|
+
delay_seconds: body.delay_seconds,
|
|
571
|
+
repeat: body.repeat,
|
|
572
|
+
}),
|
|
573
|
+
}, 201);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
const reminderCancelMatch = pathname.match(/^\/local\/reminders\/([^/]+)\/cancel$/);
|
|
577
|
+
if (request.method === 'POST' && reminderCancelMatch) {
|
|
578
|
+
const reminderId = decodeURIComponent(reminderCancelMatch[1]!);
|
|
579
|
+
await requireAgentReminderAccess(client, principal, reminderId);
|
|
580
|
+
return json({ reminder: await client.cancelReminder(reminderId) });
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
if (request.method === 'GET' && pathname === '/local/saved-messages') {
|
|
584
|
+
return json({
|
|
585
|
+
saved_messages: await client.listSavedMessages({
|
|
586
|
+
saved_by: principal.kind === 'agent' ? principal.agent : stringParam(url, 'saved_by'),
|
|
587
|
+
room_id: principal.kind === 'agent'
|
|
588
|
+
? principal.chatId
|
|
589
|
+
: stringParam(url, 'room_id') ?? stringParam(url, 'chat_id'),
|
|
590
|
+
limit: numberParam(url, 'limit', 50),
|
|
591
|
+
}),
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
if (request.method === 'POST' && pathname === '/local/saved-messages') {
|
|
596
|
+
const body = await readJson<CreateRoomSavedMessageBody>(request);
|
|
597
|
+
requireAgentSavedMessageCreator(principal, body);
|
|
598
|
+
const sourceMessageId = Number(body.msg_id ?? body.source_message_id);
|
|
599
|
+
if (!Number.isInteger(sourceMessageId) || sourceMessageId < 1) {
|
|
600
|
+
throw new HttpError(400, 'INVALID_SOURCE_MESSAGE_ID', 'msg_id must be a positive integer');
|
|
601
|
+
}
|
|
602
|
+
await requireAgentSavedMessageSource(client, principal, sourceMessageId);
|
|
603
|
+
return json({
|
|
604
|
+
saved_message: await client.saveMessage({
|
|
605
|
+
msg_id: sourceMessageId,
|
|
606
|
+
saved_by: body.saved_by ?? '',
|
|
607
|
+
note: body.note,
|
|
608
|
+
}),
|
|
609
|
+
}, 201);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
const savedMessageDeleteMatch = pathname.match(/^\/local\/saved-messages\/([^/]+)\/delete$/);
|
|
613
|
+
if (request.method === 'POST' && savedMessageDeleteMatch) {
|
|
614
|
+
const savedMessageId = decodeURIComponent(savedMessageDeleteMatch[1]!);
|
|
615
|
+
await requireAgentSavedMessageAccess(client, principal, savedMessageId);
|
|
616
|
+
return json({ saved_message: await client.removeSavedMessage(savedMessageId) });
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
if (request.method === 'GET' && pathname === '/local/held-drafts') {
|
|
620
|
+
const agent = principal.kind === 'agent'
|
|
621
|
+
? principal.agent
|
|
622
|
+
: stringParam(url, 'agent') ?? stringParam(url, 'from') ?? stringParam(url, 'sender');
|
|
623
|
+
if (!agent) throw new HttpError(400, 'MISSING_AGENT', 'held draft list requires agent');
|
|
624
|
+
return json({
|
|
625
|
+
drafts: await client.listHeldDrafts({
|
|
626
|
+
agent,
|
|
627
|
+
room_id: principal.kind === 'agent'
|
|
628
|
+
? principal.chatId
|
|
629
|
+
: stringParam(url, 'room_id') ?? stringParam(url, 'chat_id'),
|
|
630
|
+
status: (stringParam(url, 'status') ?? 'held') as HeldDraftStatus | 'all',
|
|
631
|
+
limit: numberParam(url, 'limit', 50),
|
|
632
|
+
}),
|
|
633
|
+
});
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
const heldDraftActionMatch = pathname.match(/^\/local\/held-drafts\/([^/]+)\/(abandon|stay-silent|send-anyway|revise)$/);
|
|
637
|
+
if (request.method === 'POST' && heldDraftActionMatch) {
|
|
638
|
+
const draftId = decodeURIComponent(heldDraftActionMatch[1]!);
|
|
639
|
+
const action = heldDraftActionMatch[2]!;
|
|
640
|
+
await requireAgentHeldDraftAccess(client, principal, draftId);
|
|
641
|
+
if (action === 'revise') {
|
|
642
|
+
const body = await readJson<ReviseHeldDraftBody>(request);
|
|
643
|
+
return json(await client.reviseHeldDraft(draftId, body.content ?? ''));
|
|
644
|
+
}
|
|
645
|
+
if (action === 'send-anyway') {
|
|
646
|
+
return json(await client.sendHeldDraftAnyway(draftId));
|
|
647
|
+
}
|
|
648
|
+
return json(await client.abandonHeldDraft(draftId));
|
|
649
|
+
}
|
|
650
|
+
|
|
147
651
|
if (request.method === 'GET' && pathname === '/local/messages') {
|
|
148
652
|
const params = new URLSearchParams();
|
|
149
653
|
for (const key of ['chat', 'room', 'chat_id', 'room_id', 'parent_id', 'channel_id', 'after', 'limit', 'q']) {
|
|
150
654
|
const value = url.searchParams.get(key);
|
|
151
655
|
if (value !== null) params.set(key, value);
|
|
152
656
|
}
|
|
657
|
+
if (principal.kind === 'agent') {
|
|
658
|
+
const chatId = params.get('chat_id') ?? params.get('room_id');
|
|
659
|
+
const chat = params.get('chat') ?? params.get('room');
|
|
660
|
+
if (!chatId && !chat) throw new HttpError(400, 'MISSING_ROOM_SCOPE', 'agent-scoped message reads require chat_id or chat');
|
|
661
|
+
params.set('agent_scope', principal.agent);
|
|
662
|
+
}
|
|
153
663
|
return json({ messages: await client.getMessages(params) });
|
|
154
664
|
}
|
|
155
665
|
|
|
156
666
|
const messageMatch = pathname.match(/^\/local\/messages\/(\d+)$/);
|
|
157
667
|
if (request.method === 'GET' && messageMatch) {
|
|
158
|
-
return json({ message: await client.getMessage(Number(messageMatch[1])) });
|
|
668
|
+
return json({ message: await client.getMessage(Number(messageMatch[1]), principal.kind === 'agent' ? principal.agent : undefined) });
|
|
159
669
|
}
|
|
160
670
|
|
|
161
671
|
if (request.method === 'POST' && pathname === '/local/messages') {
|
|
162
672
|
const body = await readJson<SendBody>(request);
|
|
673
|
+
requireMessageWritePrincipal(principal, body);
|
|
163
674
|
if (!body.sender?.trim()) throw new HttpError(400, 'MISSING_SENDER', 'sender is required');
|
|
164
|
-
|
|
165
|
-
|
|
675
|
+
const hasContent = body.content?.trim()
|
|
676
|
+
|| body.attachments?.length
|
|
677
|
+
|| body.messages?.some((message) => message.content?.trim() || message.attachments?.length);
|
|
678
|
+
if (!hasContent) throw new HttpError(400, 'MISSING_CONTENT', 'content or attachments are required');
|
|
679
|
+
const mentionLint = await lintLocalSendMentions(client, principal, body);
|
|
680
|
+
if (mentionLint && body.mention_lint !== 'infer') {
|
|
681
|
+
return json({ mention_lint: mentionLint });
|
|
682
|
+
}
|
|
683
|
+
const inferredMentions = mentionLint?.missing_mentions ?? [];
|
|
684
|
+
const result = await client.sendMessageResult({
|
|
166
685
|
chat: body.room ?? body.chat,
|
|
167
686
|
room: body.room,
|
|
168
687
|
chat_id: body.room_id ?? body.chat_id,
|
|
@@ -171,19 +690,29 @@ export async function localRoute(request: Request, options: LocalApiOptions): Pr
|
|
|
171
690
|
channel_id: body.channel_id,
|
|
172
691
|
sender: body.sender,
|
|
173
692
|
recipient: body.recipient,
|
|
174
|
-
content: body.content,
|
|
693
|
+
content: body.content ?? '',
|
|
175
694
|
type: body.type,
|
|
176
695
|
idempotency_key: body.idempotency_key,
|
|
177
|
-
mentions: body.mentions,
|
|
696
|
+
mentions: appendInferredMentions(body.mentions, inferredMentions),
|
|
697
|
+
attachments: normalizeSendAttachments(body.attachments),
|
|
698
|
+
messages: body.messages?.map((message) => ({
|
|
699
|
+
...message,
|
|
700
|
+
content: message.content ?? '',
|
|
701
|
+
mentions: appendInferredMentions(message.mentions, inferredMentions),
|
|
702
|
+
attachments: normalizeSendAttachments(message.attachments),
|
|
703
|
+
})),
|
|
704
|
+
freshness: body.freshness,
|
|
178
705
|
});
|
|
179
|
-
return json(
|
|
706
|
+
return json(result, result.message ? 201 : 202);
|
|
180
707
|
}
|
|
181
708
|
|
|
182
709
|
if (request.method === 'GET' && pathname === '/local/runs') {
|
|
710
|
+
requireDaemonPrincipal(principal);
|
|
183
711
|
return json({ runs: await client.listRuns() });
|
|
184
712
|
}
|
|
185
713
|
|
|
186
714
|
if (request.method === 'POST' && pathname === '/local/artifacts') {
|
|
715
|
+
requireDaemonPrincipal(principal);
|
|
187
716
|
const body = await readJson<ArtifactBody>(request);
|
|
188
717
|
if (body.source_path || body.path) throw new HttpError(400, 'PATH_NOT_ALLOWED', 'artifact upload must include content, not a path');
|
|
189
718
|
if (!body.content_base64) throw new HttpError(400, 'MISSING_CONTENT', 'content_base64 is required');
|
|
@@ -200,6 +729,7 @@ export async function localRoute(request: Request, options: LocalApiOptions): Pr
|
|
|
200
729
|
|
|
201
730
|
const actionMatch = pathname.match(/^\/local\/runs\/([^/]+)\/(kill|restart)$/);
|
|
202
731
|
if (request.method === 'POST' && actionMatch) {
|
|
732
|
+
requireDaemonPrincipal(principal);
|
|
203
733
|
const run = await client.requestRunAction(actionMatch[1]!, actionMatch[2] as RunAction);
|
|
204
734
|
return json({ run });
|
|
205
735
|
}
|