@lelouchhe/webagent 0.9.0 → 0.10.0
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 +39 -14
- package/config.toml +7 -27
- package/dist/index.html +4 -4
- package/dist/js/app.INIQQEGD.js +5 -0
- package/dist/js/{chunk.S5LRNRJI.js → chunk.7WADDFJZ.js} +27 -27
- package/dist/js/viewer.RHZMFYWJ.js +1 -0
- package/dist/login.html +1 -1
- package/dist/share-viewer.html +5 -5
- package/dist/{styles.00nlhhf3.css → styles.01aj0l37.css} +19 -2
- package/dist/sw.js +6 -6
- package/lib/attachment-dispatch.js +60 -31
- package/lib/attachment-interceptor.js +7 -7
- package/lib/attachment-labels.js +1 -1
- package/lib/attachments.js +25 -0
- package/lib/auth-middleware.js +2 -2
- package/lib/auth.js +2 -2
- package/lib/bridge.js +109 -83
- package/lib/client-registry.js +12 -12
- package/lib/config.js +2 -31
- package/lib/event-handler.js +143 -90
- package/lib/files/routes.js +1 -1
- package/lib/mcp/capability.js +74 -0
- package/lib/mcp/server.js +148 -0
- package/lib/mcp/task-history.js +245 -0
- package/lib/mcp/task-host.js +253 -0
- package/lib/mcp/tools.js +168 -0
- package/lib/mode-bucket.js +1 -1
- package/lib/push-service.js +33 -35
- package/lib/routes.js +947 -489
- package/lib/server.js +64 -16
- package/lib/share/routes.js +88 -88
- package/lib/shared/task-reference.js +20 -0
- package/lib/sse-manager.js +8 -8
- package/lib/store.js +941 -314
- package/lib/task-collaboration.js +15 -0
- package/lib/task-manager.js +1409 -0
- package/lib/task-path.js +131 -0
- package/lib/{session-state.js → task-state.js} +64 -41
- package/lib/task-tree-lock.js +74 -0
- package/lib/{sessions-anchor.js → tasks-anchor.js} +8 -7
- package/lib/tokens.js +1 -1
- package/lib/types.js +2 -2
- package/package.json +7 -1
- package/dist/js/app.QC7IRDTP.js +0 -5
- package/dist/js/viewer.GP5VXAUY.js +0 -1
- package/lib/session-manager.js +0 -638
- package/lib/title-service.js +0 -95
package/lib/server.js
CHANGED
|
@@ -7,8 +7,10 @@ import { setLogLevel, log } from "./log.js";
|
|
|
7
7
|
import { AgentBridge } from "./bridge.js";
|
|
8
8
|
import { agentKeyFromCommand } from "./agent-key.js";
|
|
9
9
|
import { Store } from "./store.js";
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
10
|
+
import { TaskManager } from "./task-manager.js";
|
|
11
|
+
import { CapabilityStore } from "./mcp/capability.js";
|
|
12
|
+
import { createMcpEndpoint } from "./mcp/server.js";
|
|
13
|
+
import { createMcpTaskToolHost } from "./mcp/task-host.js";
|
|
12
14
|
import { createRequestHandler } from "./routes.js";
|
|
13
15
|
import { handleAgentEvent } from "./event-handler.js";
|
|
14
16
|
import { PushService } from "./push-service.js";
|
|
@@ -20,7 +22,7 @@ import { startMessageCleanup } from "./message-cleanup.js";
|
|
|
20
22
|
import { startSharePreviewCleanup, } from "./share/cleanup.js";
|
|
21
23
|
import { AuthStore } from "./auth-store.js";
|
|
22
24
|
import { join as pathJoin } from "node:path";
|
|
23
|
-
import {
|
|
25
|
+
import { resolveTasksAnchor } from "./tasks-anchor.js";
|
|
24
26
|
import { runStartupChecks } from "./startup-checks.js";
|
|
25
27
|
import { AttachmentDispatcher } from "./attachment-dispatch.js";
|
|
26
28
|
import { buildBridgeEventHandlerConfig as _buildBridgeEventHandlerConfig } from "./bridge-event-config.js";
|
|
@@ -55,12 +57,13 @@ const PKG_VERSION = (() => {
|
|
|
55
57
|
})();
|
|
56
58
|
// --- Core dependencies ---
|
|
57
59
|
const store = new Store(config.data_dir, agentKeyFromCommand(preflight.agentCmd));
|
|
60
|
+
store.ensureRootTask(config.default_cwd);
|
|
58
61
|
console.log(`[store] using ${config.data_dir}/`);
|
|
59
|
-
// Pin <data_dir>/
|
|
62
|
+
// Pin <data_dir>/tasks realpath at boot so all later anchor checks
|
|
60
63
|
// (file:// URI construction, permission interceptor) compare against the
|
|
61
64
|
// same canonical path. Defends against macOS /var → /private/var.
|
|
62
|
-
const
|
|
63
|
-
const attachmentDispatcher = new AttachmentDispatcher(store,
|
|
65
|
+
const tasksAnchor = resolveTasksAnchor(config.data_dir);
|
|
66
|
+
const attachmentDispatcher = new AttachmentDispatcher(store, tasksAnchor, {
|
|
64
67
|
warn: (msg) => {
|
|
65
68
|
console.warn(msg);
|
|
66
69
|
},
|
|
@@ -76,8 +79,14 @@ setInterval(() => {
|
|
|
76
79
|
.scope("attachment-interceptor")
|
|
77
80
|
.info("counters", { ...attachmentInterceptorCounters });
|
|
78
81
|
}, ATTACHMENT_INTERCEPTOR_DUMP_MS).unref();
|
|
79
|
-
|
|
80
|
-
|
|
82
|
+
// Per-task MCP capability store; minted/revoked by TaskManager. The
|
|
83
|
+
// MCP endpoint authenticates against it. Tokens are in-memory
|
|
84
|
+
// only, so a restart invalidates every outstanding capability.
|
|
85
|
+
const capabilities = new CapabilityStore();
|
|
86
|
+
// Agent subprocesses run on the same host as WebAgent, so the MCP server
|
|
87
|
+
// URL is always loopback even when the HTTP listener binds elsewhere.
|
|
88
|
+
const mcpBaseUrl = `http://127.0.0.1:${config.port}`;
|
|
89
|
+
const tasks = new TaskManager(store, config.default_cwd, config.data_dir, capabilities, mcpBaseUrl);
|
|
81
90
|
const sseManager = new SseManager();
|
|
82
91
|
const clientRegistry = new ClientRegistry();
|
|
83
92
|
const pushService = new PushService(store, config.data_dir, config.push.vapid_subject, {
|
|
@@ -99,12 +108,46 @@ const attachmentSecret = randomBytes(32);
|
|
|
99
108
|
// within one heartbeat interval (≤15s).
|
|
100
109
|
sseManager.setRevocationCheck((tokenName) => !authStore.hasTokenName(tokenName));
|
|
101
110
|
sseManager.setAttachmentSecret(attachmentSecret);
|
|
102
|
-
sseManager.setLabelMapProvider((
|
|
103
|
-
// Broadcast runtime state patches to all SSE clients interested in the
|
|
104
|
-
|
|
111
|
+
sseManager.setLabelMapProvider((taskId) => tasks.getLabelMap(taskId));
|
|
112
|
+
// Broadcast runtime state patches to all SSE clients interested in the task.
|
|
113
|
+
tasks.state.onPatch((event) => {
|
|
105
114
|
sseManager.broadcast(event);
|
|
106
115
|
});
|
|
107
116
|
let bridge = null;
|
|
117
|
+
const mcpTaskTools = createMcpTaskToolHost({
|
|
118
|
+
store,
|
|
119
|
+
tasks,
|
|
120
|
+
getBridge: () => bridge,
|
|
121
|
+
cancelTimeoutMs: config.limits.cancel_timeout,
|
|
122
|
+
broadcastCollaboration: ({ messageId, sourceTaskId, targetTaskId, title, body, }) => {
|
|
123
|
+
for (const projection of store.listCollaborationProjections(messageId)) {
|
|
124
|
+
sseManager.broadcast({
|
|
125
|
+
type: "system_message",
|
|
126
|
+
taskId: projection.task_id,
|
|
127
|
+
kind: "collaboration",
|
|
128
|
+
messageId,
|
|
129
|
+
sourceTaskId,
|
|
130
|
+
targetTaskId,
|
|
131
|
+
role: projection.role,
|
|
132
|
+
title,
|
|
133
|
+
body,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
},
|
|
137
|
+
broadcastTaskCreated: ({ messageId, sourceTaskId, targetTaskId, title, body, }) => {
|
|
138
|
+
sseManager.broadcast({
|
|
139
|
+
type: "system_message",
|
|
140
|
+
taskId: sourceTaskId,
|
|
141
|
+
kind: "task_created",
|
|
142
|
+
messageId,
|
|
143
|
+
sourceTaskId,
|
|
144
|
+
targetTaskId,
|
|
145
|
+
role: "source",
|
|
146
|
+
title,
|
|
147
|
+
body,
|
|
148
|
+
});
|
|
149
|
+
},
|
|
150
|
+
});
|
|
108
151
|
const messageCleanup = startMessageCleanup(store, config.messages.unprocessed_ttl_days, (pendingCount) => {
|
|
109
152
|
sseManager.broadcastGlobal({ type: "inbox_count_changed", pendingCount });
|
|
110
153
|
});
|
|
@@ -112,10 +155,9 @@ let sharePreviewCleanup = null;
|
|
|
112
155
|
// --- HTTP server ---
|
|
113
156
|
const requestHandler = createRequestHandler({
|
|
114
157
|
store,
|
|
115
|
-
|
|
158
|
+
tasks,
|
|
116
159
|
sseManager,
|
|
117
160
|
clientRegistry,
|
|
118
|
-
titleService,
|
|
119
161
|
getBridge: () => bridge,
|
|
120
162
|
publicDir: PUBLIC_DIR,
|
|
121
163
|
dataDir: config.data_dir,
|
|
@@ -127,6 +169,11 @@ const requestHandler = createRequestHandler({
|
|
|
127
169
|
ticketStore,
|
|
128
170
|
attachmentSecret,
|
|
129
171
|
shareConfig: config.share,
|
|
172
|
+
mcpEndpoint: createMcpEndpoint({
|
|
173
|
+
capabilities,
|
|
174
|
+
isTaskActive: (taskId) => tasks.isMcpSessionActive(taskId),
|
|
175
|
+
taskTools: mcpTaskTools,
|
|
176
|
+
}),
|
|
130
177
|
});
|
|
131
178
|
const server = createServer((req, res) => {
|
|
132
179
|
void requestHandler(req, res);
|
|
@@ -147,7 +194,7 @@ async function initBridge(agentCmd) {
|
|
|
147
194
|
},
|
|
148
195
|
});
|
|
149
196
|
b.on("event", (event) => {
|
|
150
|
-
handleAgentEvent(event,
|
|
197
|
+
handleAgentEvent(event, tasks, store, b, eventHandlerConfig, sseManager, pushService, clientRegistry);
|
|
151
198
|
});
|
|
152
199
|
await b.start();
|
|
153
200
|
bridge = b;
|
|
@@ -159,7 +206,7 @@ async function shutdown() {
|
|
|
159
206
|
sseManager.stopHeartbeat();
|
|
160
207
|
messageCleanup.stop();
|
|
161
208
|
sharePreviewCleanup?.stop();
|
|
162
|
-
|
|
209
|
+
tasks.killAllBashProcs();
|
|
163
210
|
await bridge?.shutdown();
|
|
164
211
|
await authStore.close();
|
|
165
212
|
store.close();
|
|
@@ -199,7 +246,8 @@ server.listen(config.port, config.host, () => {
|
|
|
199
246
|
try {
|
|
200
247
|
await initBridge(agentCmd);
|
|
201
248
|
console.log(`[bridge] ready`);
|
|
202
|
-
|
|
249
|
+
await tasks.ensureRootTask(bridge);
|
|
250
|
+
tasks.hydrate();
|
|
203
251
|
}
|
|
204
252
|
catch (err) {
|
|
205
253
|
console.error(`[bridge] failed to start:`, err);
|
package/lib/share/routes.js
CHANGED
|
@@ -9,7 +9,7 @@ import { enrichStoredEventsForDisplay } from "../attachment-labels.js";
|
|
|
9
9
|
import { log } from "../log.js";
|
|
10
10
|
import { HTTP_STATUS } from "../http-status.js";
|
|
11
11
|
const slog = log.scope("share");
|
|
12
|
-
// In-flight dedup for concurrent POST /share on the same
|
|
12
|
+
// In-flight dedup for concurrent POST /share on the same task.
|
|
13
13
|
// First caller does the work; concurrent callers await the same promise.
|
|
14
14
|
// Idempotent because the body re-checks for an existing preview before
|
|
15
15
|
// inserting.
|
|
@@ -79,9 +79,9 @@ function json(res, status, body) {
|
|
|
79
79
|
*
|
|
80
80
|
* URL space claimed:
|
|
81
81
|
* /s, /s/:token (viewer — C3)
|
|
82
|
-
* /api/v1/
|
|
83
|
-
* /api/v1/
|
|
84
|
-
* /api/v1/
|
|
82
|
+
* /api/v1/tasks/:id/share (preview create — C2 here)
|
|
83
|
+
* /api/v1/tasks/:id/share/preview (preview read — C2 here)
|
|
84
|
+
* /api/v1/tasks/:id/share/publish (activate — C3)
|
|
85
85
|
* /api/v1/shares, /api/v1/shares/:t (owner list/patch — C4)
|
|
86
86
|
* /api/v1/shared/:token (public viewer JSON — C3)
|
|
87
87
|
*/
|
|
@@ -119,20 +119,20 @@ export async function handleShareRoutes(req, res, deps) {
|
|
|
119
119
|
res.end("share token required");
|
|
120
120
|
return true;
|
|
121
121
|
}
|
|
122
|
-
// POST /api/v1/
|
|
123
|
-
const createMatch = url.match(/^\/api\/v1\/
|
|
122
|
+
// POST /api/v1/tasks/:id/share — create preview
|
|
123
|
+
const createMatch = url.match(/^\/api\/v1\/tasks\/([^/?]+)\/share\/?(?:\?.*)?$/);
|
|
124
124
|
if (createMatch && method === "POST") {
|
|
125
125
|
await handlePreviewCreate(req, res, deps, decodeURIComponent(createMatch[1]));
|
|
126
126
|
return true;
|
|
127
127
|
}
|
|
128
|
-
// GET /api/v1/
|
|
129
|
-
const previewMatch = url.match(/^\/api\/v1\/
|
|
128
|
+
// GET /api/v1/tasks/:id/share/preview — read preview + staleness
|
|
129
|
+
const previewMatch = url.match(/^\/api\/v1\/tasks\/([^/?]+)\/share\/preview\/?(?:\?.*)?$/);
|
|
130
130
|
if (previewMatch && method === "GET") {
|
|
131
131
|
await handlePreviewRead(req, res, deps, decodeURIComponent(previewMatch[1]));
|
|
132
132
|
return true;
|
|
133
133
|
}
|
|
134
|
-
// POST /api/v1/
|
|
135
|
-
const publishMatch = url.match(/^\/api\/v1\/
|
|
134
|
+
// POST /api/v1/tasks/:id/share/publish — promote preview to public
|
|
135
|
+
const publishMatch = url.match(/^\/api\/v1\/tasks\/([^/?]+)\/share\/publish\/?(?:\?.*)?$/);
|
|
136
136
|
if (publishMatch && method === "POST") {
|
|
137
137
|
await handlePublish(req, res, deps, decodeURIComponent(publishMatch[1]));
|
|
138
138
|
return true;
|
|
@@ -143,13 +143,13 @@ export async function handleShareRoutes(req, res, deps) {
|
|
|
143
143
|
await handleSharedEvents(res, deps, sharedEventsMatch[1]);
|
|
144
144
|
return true;
|
|
145
145
|
}
|
|
146
|
-
const revokeMatch = url.match(/^\/api\/v1\/
|
|
147
|
-
// DELETE /api/v1/
|
|
146
|
+
const revokeMatch = url.match(/^\/api\/v1\/tasks\/([^/?]+)\/share\/?(?:\?.*)?$/);
|
|
147
|
+
// DELETE /api/v1/tasks/:id/share — hard-delete share row
|
|
148
148
|
if (revokeMatch && method === "DELETE") {
|
|
149
149
|
await handleRevoke(req, res, deps, decodeURIComponent(revokeMatch[1]));
|
|
150
150
|
return true;
|
|
151
151
|
}
|
|
152
|
-
// PATCH /api/v1/
|
|
152
|
+
// PATCH /api/v1/tasks/:id/share — update display_name / owner_label
|
|
153
153
|
if (revokeMatch && method === "PATCH") {
|
|
154
154
|
await handlePatchLabel(req, res, deps, decodeURIComponent(revokeMatch[1]));
|
|
155
155
|
return true;
|
|
@@ -172,7 +172,7 @@ export async function handleShareRoutes(req, res, deps) {
|
|
|
172
172
|
return true;
|
|
173
173
|
}
|
|
174
174
|
// Any other /api/v1/shares[/...] or /api/v1/shared/... miss → 404.
|
|
175
|
-
if (/^\/api\/v1\/
|
|
175
|
+
if (/^\/api\/v1\/tasks\/[^/]+\/share(?:\/|$|\?)/.test(url) ||
|
|
176
176
|
url === "/api/v1/shares" ||
|
|
177
177
|
url.startsWith("/api/v1/shares/") ||
|
|
178
178
|
url.startsWith("/api/v1/shared/") ||
|
|
@@ -193,9 +193,9 @@ function resolveDisplayName(deps, validated) {
|
|
|
193
193
|
return deps.store.getOwnerPref(DEFAULT_DISPLAY_NAME_KEY) ?? null;
|
|
194
194
|
}
|
|
195
195
|
/**
|
|
196
|
-
* POST /api/v1/
|
|
196
|
+
* POST /api/v1/tasks/:id/share — create (or return existing) preview.
|
|
197
197
|
*
|
|
198
|
-
* share-plan §4.2 R1-c1: same-
|
|
198
|
+
* share-plan §4.2 R1-c1: same-task dedup — if an un-activated preview
|
|
199
199
|
* already exists, return it verbatim. Only create new on miss.
|
|
200
200
|
*
|
|
201
201
|
* Body (all optional):
|
|
@@ -204,17 +204,17 @@ function resolveDisplayName(deps, validated) {
|
|
|
204
204
|
* display_name: str — shown as "by @<name>" in viewer footer
|
|
205
205
|
* owner_label: str — private owner-side label (full validation in C4)
|
|
206
206
|
*/
|
|
207
|
-
async function handlePreviewCreate(req, res, deps,
|
|
208
|
-
const
|
|
209
|
-
if (!
|
|
210
|
-
json(res, HTTP_STATUS.NOT_FOUND, { error: "
|
|
207
|
+
async function handlePreviewCreate(req, res, deps, taskId) {
|
|
208
|
+
const task = deps.store.getTask(taskId);
|
|
209
|
+
if (!task) {
|
|
210
|
+
json(res, HTTP_STATUS.NOT_FOUND, { error: "task not found" });
|
|
211
211
|
return;
|
|
212
212
|
}
|
|
213
|
-
// 409 guard: block while the agent is actively streaming into this
|
|
214
|
-
if (deps.
|
|
213
|
+
// 409 guard: block while the agent is actively streaming into this task.
|
|
214
|
+
if (deps.tasks?.getBusyKind(taskId) === "agent") {
|
|
215
215
|
json(res, HTTP_STATUS.CONFLICT, {
|
|
216
|
-
error: "
|
|
217
|
-
detail: "此
|
|
216
|
+
error: "task busy",
|
|
217
|
+
detail: "此 task 正在接收 agent 输出,请等 agent 输出结束后再分享",
|
|
218
218
|
});
|
|
219
219
|
return;
|
|
220
220
|
}
|
|
@@ -257,24 +257,24 @@ async function handlePreviewCreate(req, res, deps, sessionId) {
|
|
|
257
257
|
const displayName = resolveDisplayName(deps, dnResult.value);
|
|
258
258
|
const ownerLabel = olResult.value === "" ? null : olResult.value;
|
|
259
259
|
try {
|
|
260
|
-
const existingInflight = pendingShareCreates.get(
|
|
260
|
+
const existingInflight = pendingShareCreates.get(taskId);
|
|
261
261
|
const inflight = existingInflight ??
|
|
262
262
|
(async () => {
|
|
263
263
|
// Dedup first — existing preview short-circuits the gate.
|
|
264
|
-
const existing = deps.store.
|
|
264
|
+
const existing = deps.store.findActivePreviewByTask(taskId);
|
|
265
265
|
if (existing)
|
|
266
266
|
return { row: existing, reused: true };
|
|
267
267
|
// Flush buffered chunks so snapshot_seq includes the streaming tail.
|
|
268
|
-
deps.
|
|
269
|
-
const allEvents = deps.store.getEvents(
|
|
268
|
+
deps.tasks?.flushBuffers(taskId);
|
|
269
|
+
const allEvents = deps.store.getEvents(taskId);
|
|
270
270
|
const snapshotSeq = allEvents.length > 0 ? Math.max(...allEvents.map((e) => e.seq)) : 0;
|
|
271
271
|
// Gate: run the sanitizer on-write. Hard-rejects throw here so we
|
|
272
|
-
// never create a preview row for a
|
|
273
|
-
runSanitizeGate(allEvents,
|
|
272
|
+
// never create a preview row for a task with leaked secrets.
|
|
273
|
+
runSanitizeGate(allEvents, task.cwd, deps.config.internal_hosts);
|
|
274
274
|
const token = generateShareToken();
|
|
275
275
|
const row = deps.store.insertSharePreview({
|
|
276
276
|
token,
|
|
277
|
-
|
|
277
|
+
taskId,
|
|
278
278
|
snapshotSeq,
|
|
279
279
|
ttlHours,
|
|
280
280
|
displayName,
|
|
@@ -282,14 +282,14 @@ async function handlePreviewCreate(req, res, deps, sessionId) {
|
|
|
282
282
|
});
|
|
283
283
|
return { row, reused: false };
|
|
284
284
|
})().finally(() => {
|
|
285
|
-
pendingShareCreates.delete(
|
|
285
|
+
pendingShareCreates.delete(taskId);
|
|
286
286
|
});
|
|
287
287
|
if (!existingInflight)
|
|
288
|
-
pendingShareCreates.set(
|
|
288
|
+
pendingShareCreates.set(taskId, inflight);
|
|
289
289
|
const result = await inflight;
|
|
290
290
|
json(res, result.reused ? HTTP_STATUS.OK : HTTP_STATUS.CREATED, {
|
|
291
291
|
token: result.row.token,
|
|
292
|
-
|
|
292
|
+
task_id: taskId,
|
|
293
293
|
snapshot_seq: result.row.share_snapshot_seq,
|
|
294
294
|
ttl_hours: result.row.ttl_hours,
|
|
295
295
|
display_name: result.row.display_name,
|
|
@@ -326,14 +326,14 @@ function runSanitizeGate(events, cwd, internalHosts) {
|
|
|
326
326
|
});
|
|
327
327
|
}
|
|
328
328
|
/**
|
|
329
|
-
* GET /api/v1/
|
|
329
|
+
* GET /api/v1/tasks/:id/share/preview — read sanitized preview.
|
|
330
330
|
*
|
|
331
331
|
* Auth: owner + X-Share-Token header (token never in URL, never in logs).
|
|
332
332
|
* Returns a `{schema_version, events, share}` bundle matching the public
|
|
333
333
|
* viewer contract (minus public-only fields) so the overlay can share
|
|
334
334
|
* the render path.
|
|
335
335
|
*/
|
|
336
|
-
async function handlePreviewRead(req, res, deps,
|
|
336
|
+
async function handlePreviewRead(req, res, deps, taskId) {
|
|
337
337
|
const tokenHeader = req.headers["x-share-token"];
|
|
338
338
|
const token = Array.isArray(tokenHeader) ? tokenHeader[0] : tokenHeader;
|
|
339
339
|
if (!token) {
|
|
@@ -347,7 +347,7 @@ async function handlePreviewRead(req, res, deps, sessionId) {
|
|
|
347
347
|
json(res, HTTP_STATUS.NOT_FOUND, { error: "share not found" });
|
|
348
348
|
return;
|
|
349
349
|
}
|
|
350
|
-
if (row.
|
|
350
|
+
if (row.task_id !== taskId) {
|
|
351
351
|
json(res, HTTP_STATUS.NOT_FOUND, { error: "share not found" });
|
|
352
352
|
return;
|
|
353
353
|
}
|
|
@@ -357,24 +357,24 @@ async function handlePreviewRead(req, res, deps, sessionId) {
|
|
|
357
357
|
});
|
|
358
358
|
return;
|
|
359
359
|
}
|
|
360
|
-
const
|
|
361
|
-
if (!
|
|
362
|
-
json(res, HTTP_STATUS.NOT_FOUND, { error: "
|
|
360
|
+
const task = deps.store.getTask(taskId);
|
|
361
|
+
if (!task) {
|
|
362
|
+
json(res, HTTP_STATUS.NOT_FOUND, { error: "task not found" });
|
|
363
363
|
return;
|
|
364
364
|
}
|
|
365
365
|
const allEvents = deps.store
|
|
366
|
-
.getEvents(
|
|
366
|
+
.getEvents(taskId)
|
|
367
367
|
.filter((e) => e.seq <= row.share_snapshot_seq);
|
|
368
|
-
if (deps.
|
|
369
|
-
enrichStoredEventsForDisplay(allEvents, deps.
|
|
368
|
+
if (deps.tasks) {
|
|
369
|
+
enrichStoredEventsForDisplay(allEvents, deps.tasks.getLabelMap(taskId));
|
|
370
370
|
}
|
|
371
371
|
const currentLastSeq = deps.store
|
|
372
|
-
.getEvents(
|
|
372
|
+
.getEvents(taskId)
|
|
373
373
|
.reduce((m, e) => Math.max(m, e.seq), 0);
|
|
374
374
|
try {
|
|
375
375
|
const { events } = sanitizeEventsForShare({
|
|
376
376
|
events: allEvents,
|
|
377
|
-
cwd:
|
|
377
|
+
cwd: task.cwd,
|
|
378
378
|
homeDir: homedir(),
|
|
379
379
|
internalHosts: deps.config.internal_hosts,
|
|
380
380
|
});
|
|
@@ -384,8 +384,8 @@ async function handlePreviewRead(req, res, deps, sessionId) {
|
|
|
384
384
|
schema_version: "1.0",
|
|
385
385
|
share: {
|
|
386
386
|
token: row.token,
|
|
387
|
-
|
|
388
|
-
|
|
387
|
+
task_id: taskId,
|
|
388
|
+
task_title: task.title,
|
|
389
389
|
shared_at: null,
|
|
390
390
|
snapshot_seq: row.share_snapshot_seq,
|
|
391
391
|
current_last_seq: currentLastSeq,
|
|
@@ -416,19 +416,19 @@ async function handlePreviewRead(req, res, deps, sessionId) {
|
|
|
416
416
|
}
|
|
417
417
|
}
|
|
418
418
|
/**
|
|
419
|
-
* POST /api/v1/
|
|
419
|
+
* POST /api/v1/tasks/:id/share/publish — activate an existing preview.
|
|
420
420
|
*
|
|
421
421
|
* Body: { token, display_name?, owner_label? }
|
|
422
|
-
* - token MUST match a preview row for this
|
|
422
|
+
* - token MUST match a preview row for this task that has not been
|
|
423
423
|
* activated or revoked.
|
|
424
424
|
* - display_name / owner_label, if present, overwrite the preview row and
|
|
425
425
|
* are persisted into owner_prefs so the next /share defaults to them.
|
|
426
426
|
*
|
|
427
|
-
* Response: { token,
|
|
427
|
+
* Response: { token, task_id, shared_at, display_name, owner_label,
|
|
428
428
|
* public_url } on 200; 404/409/410 on state errors.
|
|
429
429
|
*/
|
|
430
430
|
// eslint-disable-next-line complexity -- TODO: split validation / state-update / response phases
|
|
431
|
-
async function handlePublish(req, res, deps,
|
|
431
|
+
async function handlePublish(req, res, deps, taskId) {
|
|
432
432
|
let body;
|
|
433
433
|
try {
|
|
434
434
|
body = (await readJson(req));
|
|
@@ -444,7 +444,7 @@ async function handlePublish(req, res, deps, sessionId) {
|
|
|
444
444
|
json(res, HTTP_STATUS.BAD_REQUEST, { error: "token required" });
|
|
445
445
|
return;
|
|
446
446
|
}
|
|
447
|
-
if (!deps.store.
|
|
447
|
+
if (!deps.store.ownsTask(taskId)) {
|
|
448
448
|
json(res, HTTP_STATUS.NOT_FOUND, { error: "share not found" });
|
|
449
449
|
return;
|
|
450
450
|
}
|
|
@@ -453,7 +453,7 @@ async function handlePublish(req, res, deps, sessionId) {
|
|
|
453
453
|
json(res, HTTP_STATUS.NOT_FOUND, { error: "share not found" });
|
|
454
454
|
return;
|
|
455
455
|
}
|
|
456
|
-
if (row.
|
|
456
|
+
if (row.task_id !== taskId) {
|
|
457
457
|
json(res, HTTP_STATUS.NOT_FOUND, { error: "share not found" });
|
|
458
458
|
return;
|
|
459
459
|
}
|
|
@@ -525,7 +525,7 @@ async function handlePublish(req, res, deps, sessionId) {
|
|
|
525
525
|
: "";
|
|
526
526
|
json(res, HTTP_STATUS.OK, {
|
|
527
527
|
token: after.token,
|
|
528
|
-
|
|
528
|
+
task_id: taskId,
|
|
529
529
|
shared_at: after.shared_at,
|
|
530
530
|
display_name: after.display_name,
|
|
531
531
|
owner_label: after.owner_label,
|
|
@@ -606,27 +606,27 @@ async function handleSharedEvents(res, deps, token) {
|
|
|
606
606
|
return;
|
|
607
607
|
}
|
|
608
608
|
// Public viewer must keep working after the owner deletes the source
|
|
609
|
-
//
|
|
610
|
-
// them (Store.
|
|
611
|
-
const
|
|
612
|
-
if (!
|
|
613
|
-
json(res, HTTP_STATUS.INTERNAL_SERVER_ERROR, { error: "
|
|
609
|
+
// task — events stay alive as long as any active share references
|
|
610
|
+
// them (Store.deleteTask soft-deletes when shares exist).
|
|
611
|
+
const task = deps.store.getTaskIncludingDeleted(row.task_id);
|
|
612
|
+
if (!task) {
|
|
613
|
+
json(res, HTTP_STATUS.INTERNAL_SERVER_ERROR, { error: "task vanished" });
|
|
614
614
|
return;
|
|
615
615
|
}
|
|
616
616
|
const allEvents = deps.store
|
|
617
|
-
.getEvents(row.
|
|
617
|
+
.getEvents(row.task_id)
|
|
618
618
|
.filter((e) => e.seq <= row.share_snapshot_seq);
|
|
619
|
-
if (deps.
|
|
620
|
-
enrichStoredEventsForDisplay(allEvents, deps.
|
|
619
|
+
if (deps.tasks) {
|
|
620
|
+
enrichStoredEventsForDisplay(allEvents, deps.tasks.getLabelMap(row.task_id));
|
|
621
621
|
}
|
|
622
622
|
try {
|
|
623
623
|
const { events } = sanitizeEventsForShare({
|
|
624
624
|
events: allEvents,
|
|
625
|
-
cwd:
|
|
625
|
+
cwd: task.cwd,
|
|
626
626
|
homeDir: homedir(),
|
|
627
627
|
internalHosts: deps.config.internal_hosts,
|
|
628
628
|
});
|
|
629
|
-
// Public response: DOES NOT expose
|
|
629
|
+
// Public response: DOES NOT expose task_id. Only title + display_name + meta.
|
|
630
630
|
res.writeHead(HTTP_STATUS.OK, {
|
|
631
631
|
"Content-Type": "application/json",
|
|
632
632
|
"Cache-Control": "no-store",
|
|
@@ -636,7 +636,7 @@ async function handleSharedEvents(res, deps, token) {
|
|
|
636
636
|
schema_version: "1.0",
|
|
637
637
|
share: {
|
|
638
638
|
token: row.token,
|
|
639
|
-
|
|
639
|
+
task_title: task.title,
|
|
640
640
|
shared_at: row.shared_at,
|
|
641
641
|
snapshot_seq: row.share_snapshot_seq,
|
|
642
642
|
display_name: row.display_name,
|
|
@@ -648,7 +648,7 @@ async function handleSharedEvents(res, deps, token) {
|
|
|
648
648
|
}
|
|
649
649
|
catch (err) {
|
|
650
650
|
if (err instanceof SanitizeError) {
|
|
651
|
-
// Hard-reject on a LIVE active share — owner's
|
|
651
|
+
// Hard-reject on a LIVE active share — owner's task gained a
|
|
652
652
|
// post-publish leak. Return 410 publicly; owner sees root cause via
|
|
653
653
|
// preview re-gate.
|
|
654
654
|
slog.error("shared_events hard-reject", {
|
|
@@ -721,8 +721,8 @@ async function handleViewerAsset(res, deps, file) {
|
|
|
721
721
|
}
|
|
722
722
|
/**
|
|
723
723
|
* GET /s/:token/attachments/:file — token-scoped image proxy. Resolves the token
|
|
724
|
-
* to a
|
|
725
|
-
* would leak
|
|
724
|
+
* to a task_id on-demand; directly serving /api/v1/tasks/:id/images
|
|
725
|
+
* would leak task_id.
|
|
726
726
|
*/
|
|
727
727
|
async function handleViewerImage(res, deps, token, file) {
|
|
728
728
|
if (!deps.dataDir) {
|
|
@@ -745,10 +745,10 @@ async function handleViewerImage(res, deps, token, file) {
|
|
|
745
745
|
json(res, HTTP_STATUS.NOT_FOUND, { error: "invalid file" });
|
|
746
746
|
return;
|
|
747
747
|
}
|
|
748
|
-
const
|
|
749
|
-
const filePath = join(
|
|
750
|
-
// Final realpath-style guard: must stay under <dataDir>/
|
|
751
|
-
if (!filePath.startsWith(
|
|
748
|
+
const taskRoot = join(deps.dataDir, "tasks", row.task_id, "attachments");
|
|
749
|
+
const filePath = join(taskRoot, file);
|
|
750
|
+
// Final realpath-style guard: must stay under <dataDir>/tasks/<sid>/attachments.
|
|
751
|
+
if (!filePath.startsWith(taskRoot + "/") && filePath !== taskRoot) {
|
|
752
752
|
res.writeHead(HTTP_STATUS.FORBIDDEN);
|
|
753
753
|
res.end("Forbidden");
|
|
754
754
|
return;
|
|
@@ -760,7 +760,7 @@ async function handleViewerImage(res, deps, token, file) {
|
|
|
760
760
|
// appends ".bin" to the <a download> name (e.g. zhihu.user.js →
|
|
761
761
|
// zhihu.user.js.bin). The owner-side route at routes.ts does the
|
|
762
762
|
// same lookup; share viewer needs parity for non-image attachments.
|
|
763
|
-
const att = deps.store.getAttachmentByFile(row.
|
|
763
|
+
const att = deps.store.getAttachmentByFile(row.task_id, file);
|
|
764
764
|
const ext = extname(filePath).toLowerCase();
|
|
765
765
|
let mime = att?.mime;
|
|
766
766
|
mime ??= IMAGE_MIME[ext];
|
|
@@ -831,13 +831,13 @@ export function validateLabel(input, field, maxBytes = 1024) {
|
|
|
831
831
|
return { ok: true, value: input };
|
|
832
832
|
}
|
|
833
833
|
/**
|
|
834
|
-
* DELETE /api/v1/
|
|
834
|
+
* DELETE /api/v1/tasks/:id/share — revoke an active or preview share.
|
|
835
835
|
* Body: { token }.
|
|
836
836
|
* Idempotent: already-revoked tokens return 200 with revoked=false.
|
|
837
837
|
* Returns { ok, token, revoked, purge_status }. purge_status is always
|
|
838
838
|
* 'skipped' in v1 — image/event purge is a future hardening pass.
|
|
839
839
|
*/
|
|
840
|
-
async function handleRevoke(req, res, deps,
|
|
840
|
+
async function handleRevoke(req, res, deps, taskId) {
|
|
841
841
|
let body;
|
|
842
842
|
try {
|
|
843
843
|
body = (await readJson(req));
|
|
@@ -853,14 +853,14 @@ async function handleRevoke(req, res, deps, sessionId) {
|
|
|
853
853
|
json(res, HTTP_STATUS.BAD_REQUEST, { error: "token required" });
|
|
854
854
|
return;
|
|
855
855
|
}
|
|
856
|
-
if (!deps.store.
|
|
856
|
+
if (!deps.store.ownsTask(taskId)) {
|
|
857
857
|
json(res, HTTP_STATUS.NOT_FOUND, { error: "share not found" });
|
|
858
858
|
return;
|
|
859
859
|
}
|
|
860
860
|
const row = deps.store.getShareByToken(body.token);
|
|
861
861
|
if (!row) {
|
|
862
862
|
// Idempotent DELETE: row already gone (revoked or never existed).
|
|
863
|
-
// We can't verify
|
|
863
|
+
// We can't verify task ownership without a row, but the token
|
|
864
864
|
// is opaque/random so leaking "revoked or never existed" is fine.
|
|
865
865
|
json(res, HTTP_STATUS.OK, {
|
|
866
866
|
ok: true,
|
|
@@ -870,18 +870,18 @@ async function handleRevoke(req, res, deps, sessionId) {
|
|
|
870
870
|
});
|
|
871
871
|
return;
|
|
872
872
|
}
|
|
873
|
-
if (row.
|
|
873
|
+
if (row.task_id !== taskId) {
|
|
874
874
|
json(res, HTTP_STATUS.NOT_FOUND, { error: "share not found" });
|
|
875
875
|
return;
|
|
876
876
|
}
|
|
877
877
|
const revoked = deps.store.revokeShare(body.token);
|
|
878
|
-
// If this was the last share on a soft-deleted
|
|
879
|
-
// hard-delete (events +
|
|
878
|
+
// If this was the last share on a soft-deleted task, finish the
|
|
879
|
+
// hard-delete (events + tasks row) so we don't leak orphans.
|
|
880
880
|
if (revoked) {
|
|
881
|
-
const reaped = deps.store.reapTombstoneIfOrphaned(row.
|
|
881
|
+
const reaped = deps.store.reapTombstoneIfOrphaned(row.task_id);
|
|
882
882
|
if (reaped && deps.dataDir) {
|
|
883
|
-
// Tombstoned
|
|
884
|
-
rm(join(deps.dataDir, "
|
|
883
|
+
// Tombstoned task is fully gone; sweep its attachments directory too.
|
|
884
|
+
rm(join(deps.dataDir, "tasks", row.task_id), {
|
|
885
885
|
recursive: true,
|
|
886
886
|
force: true,
|
|
887
887
|
}).catch(() => { });
|
|
@@ -895,14 +895,14 @@ async function handleRevoke(req, res, deps, sessionId) {
|
|
|
895
895
|
});
|
|
896
896
|
}
|
|
897
897
|
/**
|
|
898
|
-
* PATCH /api/v1/
|
|
898
|
+
* PATCH /api/v1/tasks/:id/share — update owner_label / display_name on
|
|
899
899
|
* a live (non-revoked) share. Body: { token, owner_label?, display_name? }.
|
|
900
900
|
*
|
|
901
901
|
* Full validation: UTF-8 ≤1024B, no C0 controls (except TAB), no DEL, no
|
|
902
902
|
* bidi overrides. Fields omitted from body are left unchanged; fields set
|
|
903
903
|
* to empty string clear the value.
|
|
904
904
|
*/
|
|
905
|
-
async function handlePatchLabel(req, res, deps,
|
|
905
|
+
async function handlePatchLabel(req, res, deps, taskId) {
|
|
906
906
|
let body;
|
|
907
907
|
try {
|
|
908
908
|
body = (await readJson(req));
|
|
@@ -918,7 +918,7 @@ async function handlePatchLabel(req, res, deps, sessionId) {
|
|
|
918
918
|
json(res, HTTP_STATUS.BAD_REQUEST, { error: "token required" });
|
|
919
919
|
return;
|
|
920
920
|
}
|
|
921
|
-
if (!deps.store.
|
|
921
|
+
if (!deps.store.ownsTask(taskId)) {
|
|
922
922
|
json(res, HTTP_STATUS.NOT_FOUND, { error: "share not found" });
|
|
923
923
|
return;
|
|
924
924
|
}
|
|
@@ -927,7 +927,7 @@ async function handlePatchLabel(req, res, deps, sessionId) {
|
|
|
927
927
|
json(res, HTTP_STATUS.NOT_FOUND, { error: "share not found" });
|
|
928
928
|
return;
|
|
929
929
|
}
|
|
930
|
-
if (row.
|
|
930
|
+
if (row.task_id !== taskId) {
|
|
931
931
|
json(res, HTTP_STATUS.NOT_FOUND, { error: "share not found" });
|
|
932
932
|
return;
|
|
933
933
|
}
|
|
@@ -962,7 +962,7 @@ async function handlePatchLabel(req, res, deps, sessionId) {
|
|
|
962
962
|
}
|
|
963
963
|
json(res, HTTP_STATUS.OK, {
|
|
964
964
|
token: after.token,
|
|
965
|
-
|
|
965
|
+
task_id: taskId,
|
|
966
966
|
owner_label: after.owner_label,
|
|
967
967
|
display_name: after.display_name,
|
|
968
968
|
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** Quote one task/path word for the shell-style task command grammar. */
|
|
2
|
+
export function quoteShellWord(word) {
|
|
3
|
+
if (/[\s"'\\]/.test(word) || word === "") {
|
|
4
|
+
return `"${word.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
5
|
+
}
|
|
6
|
+
return word;
|
|
7
|
+
}
|
|
8
|
+
/** Format a task title as a stable, shell-parseable @ reference. */
|
|
9
|
+
export function formatTaskReference(title) {
|
|
10
|
+
return `@${quoteShellWord(title)}`;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Format tree path segments as the absolute `@`-prefixed path the input grammar
|
|
14
|
+
* and the UI completion use. The caller decides which segments are root-relative
|
|
15
|
+
* (see `Store.getTaskPath`); this only renders them, shell-quoting as needed so
|
|
16
|
+
* the result can be pasted straight into the input.
|
|
17
|
+
*/
|
|
18
|
+
export function formatTaskPath(segments) {
|
|
19
|
+
return `@/${segments.map(quoteShellWord).join("/")}`;
|
|
20
|
+
}
|