@lelouchhe/webagent 0.8.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 +43 -15
- package/config.toml +7 -27
- package/dist/index.html +21 -5
- package/dist/js/app.INIQQEGD.js +5 -0
- package/dist/js/chunk.3CLGCUHW.js +1 -0
- package/dist/js/{chunk.CT5WBNGZ.js → chunk.7WADDFJZ.js} +50 -49
- package/dist/js/chunk.AOTG3PL7.js +20 -0
- package/dist/js/{login.2WA6DTGM.js → login.WMURU4NI.js} +1 -1
- package/dist/js/viewer.RHZMFYWJ.js +1 -0
- package/dist/login.html +2 -2
- package/dist/share-viewer.html +6 -6
- package/dist/{styles.00etlpgs.css → styles.01aj0l37.css} +186 -4
- package/dist/sw.js +6 -6
- package/lib/agent-key.js +6 -0
- 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 +69 -7
- package/lib/auth-middleware.js +11 -4
- package/lib/auth.js +2 -2
- package/lib/bridge.js +209 -90
- package/lib/client-registry.js +12 -12
- package/lib/config.js +2 -31
- package/lib/event-handler.js +166 -85
- package/lib/files/limits.js +15 -0
- package/lib/files/paths.js +155 -0
- package/lib/files/routes.js +232 -0
- package/lib/home-path.js +35 -0
- package/lib/http-status.js +1 -0
- 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 +1022 -475
- package/lib/server.js +84 -34
- package/lib/share/routes.js +97 -85
- package/lib/shared/task-reference.js +20 -0
- package/lib/sse-manager.js +8 -8
- package/lib/store.js +992 -284
- 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} +90 -38
- 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 +8 -1
- package/dist/js/app.XBFXH37R.js +0 -2
- package/dist/js/chunk.UMQMOGWO.js +0 -1
- package/dist/js/viewer.CVWXSKJM.js +0 -1
- package/lib/session-manager.js +0 -613
- package/lib/title-service.js +0 -95
package/lib/server.js
CHANGED
|
@@ -5,9 +5,12 @@ import { fileURLToPath } from "node:url";
|
|
|
5
5
|
import { loadConfig } from "./config.js";
|
|
6
6
|
import { setLogLevel, log } from "./log.js";
|
|
7
7
|
import { AgentBridge } from "./bridge.js";
|
|
8
|
+
import { agentKeyFromCommand } from "./agent-key.js";
|
|
8
9
|
import { Store } from "./store.js";
|
|
9
|
-
import {
|
|
10
|
-
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";
|
|
11
14
|
import { createRequestHandler } from "./routes.js";
|
|
12
15
|
import { handleAgentEvent } from "./event-handler.js";
|
|
13
16
|
import { PushService } from "./push-service.js";
|
|
@@ -19,7 +22,7 @@ import { startMessageCleanup } from "./message-cleanup.js";
|
|
|
19
22
|
import { startSharePreviewCleanup, } from "./share/cleanup.js";
|
|
20
23
|
import { AuthStore } from "./auth-store.js";
|
|
21
24
|
import { join as pathJoin } from "node:path";
|
|
22
|
-
import {
|
|
25
|
+
import { resolveTasksAnchor } from "./tasks-anchor.js";
|
|
23
26
|
import { runStartupChecks } from "./startup-checks.js";
|
|
24
27
|
import { AttachmentDispatcher } from "./attachment-dispatch.js";
|
|
25
28
|
import { buildBridgeEventHandlerConfig as _buildBridgeEventHandlerConfig } from "./bridge-event-config.js";
|
|
@@ -53,13 +56,14 @@ const PKG_VERSION = (() => {
|
|
|
53
56
|
}
|
|
54
57
|
})();
|
|
55
58
|
// --- Core dependencies ---
|
|
56
|
-
const store = new Store(config.data_dir);
|
|
59
|
+
const store = new Store(config.data_dir, agentKeyFromCommand(preflight.agentCmd));
|
|
60
|
+
store.ensureRootTask(config.default_cwd);
|
|
57
61
|
console.log(`[store] using ${config.data_dir}/`);
|
|
58
|
-
// Pin <data_dir>/
|
|
62
|
+
// Pin <data_dir>/tasks realpath at boot so all later anchor checks
|
|
59
63
|
// (file:// URI construction, permission interceptor) compare against the
|
|
60
64
|
// same canonical path. Defends against macOS /var → /private/var.
|
|
61
|
-
const
|
|
62
|
-
const attachmentDispatcher = new AttachmentDispatcher(store,
|
|
65
|
+
const tasksAnchor = resolveTasksAnchor(config.data_dir);
|
|
66
|
+
const attachmentDispatcher = new AttachmentDispatcher(store, tasksAnchor, {
|
|
63
67
|
warn: (msg) => {
|
|
64
68
|
console.warn(msg);
|
|
65
69
|
},
|
|
@@ -75,8 +79,14 @@ setInterval(() => {
|
|
|
75
79
|
.scope("attachment-interceptor")
|
|
76
80
|
.info("counters", { ...attachmentInterceptorCounters });
|
|
77
81
|
}, ATTACHMENT_INTERCEPTOR_DUMP_MS).unref();
|
|
78
|
-
|
|
79
|
-
|
|
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);
|
|
80
90
|
const sseManager = new SseManager();
|
|
81
91
|
const clientRegistry = new ClientRegistry();
|
|
82
92
|
const pushService = new PushService(store, config.data_dir, config.push.vapid_subject, {
|
|
@@ -98,39 +108,78 @@ const attachmentSecret = randomBytes(32);
|
|
|
98
108
|
// within one heartbeat interval (≤15s).
|
|
99
109
|
sseManager.setRevocationCheck((tokenName) => !authStore.hasTokenName(tokenName));
|
|
100
110
|
sseManager.setAttachmentSecret(attachmentSecret);
|
|
101
|
-
sseManager.setLabelMapProvider((
|
|
102
|
-
// Broadcast runtime state patches to all SSE clients interested in the
|
|
103
|
-
|
|
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) => {
|
|
104
114
|
sseManager.broadcast(event);
|
|
105
115
|
});
|
|
106
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
|
+
});
|
|
107
151
|
const messageCleanup = startMessageCleanup(store, config.messages.unprocessed_ttl_days, (pendingCount) => {
|
|
108
152
|
sseManager.broadcastGlobal({ type: "inbox_count_changed", pendingCount });
|
|
109
153
|
});
|
|
110
154
|
let sharePreviewCleanup = null;
|
|
111
155
|
// --- HTTP server ---
|
|
156
|
+
const requestHandler = createRequestHandler({
|
|
157
|
+
store,
|
|
158
|
+
tasks,
|
|
159
|
+
sseManager,
|
|
160
|
+
clientRegistry,
|
|
161
|
+
getBridge: () => bridge,
|
|
162
|
+
publicDir: PUBLIC_DIR,
|
|
163
|
+
dataDir: config.data_dir,
|
|
164
|
+
limits: config.limits,
|
|
165
|
+
pushService,
|
|
166
|
+
serverVersion: PKG_VERSION,
|
|
167
|
+
debugLevel: config.debug.level,
|
|
168
|
+
authStore,
|
|
169
|
+
ticketStore,
|
|
170
|
+
attachmentSecret,
|
|
171
|
+
shareConfig: config.share,
|
|
172
|
+
mcpEndpoint: createMcpEndpoint({
|
|
173
|
+
capabilities,
|
|
174
|
+
isTaskActive: (taskId) => tasks.isMcpSessionActive(taskId),
|
|
175
|
+
taskTools: mcpTaskTools,
|
|
176
|
+
}),
|
|
177
|
+
});
|
|
112
178
|
const server = createServer((req, res) => {
|
|
113
|
-
void
|
|
114
|
-
store,
|
|
115
|
-
sessions,
|
|
116
|
-
sseManager,
|
|
117
|
-
clientRegistry,
|
|
118
|
-
titleService,
|
|
119
|
-
getBridge: () => bridge,
|
|
120
|
-
publicDir: PUBLIC_DIR,
|
|
121
|
-
dataDir: config.data_dir,
|
|
122
|
-
limits: config.limits,
|
|
123
|
-
pushService,
|
|
124
|
-
serverVersion: PKG_VERSION,
|
|
125
|
-
debugLevel: config.debug.level,
|
|
126
|
-
authStore,
|
|
127
|
-
ticketStore,
|
|
128
|
-
attachmentSecret,
|
|
129
|
-
shareConfig: config.share,
|
|
130
|
-
})(req, res);
|
|
179
|
+
void requestHandler(req, res);
|
|
131
180
|
});
|
|
132
181
|
async function initBridge(agentCmd) {
|
|
133
|
-
const b = new AgentBridge(agentCmd);
|
|
182
|
+
const b = new AgentBridge(agentCmd, store);
|
|
134
183
|
b.setAttachmentDispatcher(attachmentDispatcher);
|
|
135
184
|
const eventHandlerConfig = _buildBridgeEventHandlerConfig({
|
|
136
185
|
cancelTimeout: config.limits.cancel_timeout,
|
|
@@ -145,7 +194,7 @@ async function initBridge(agentCmd) {
|
|
|
145
194
|
},
|
|
146
195
|
});
|
|
147
196
|
b.on("event", (event) => {
|
|
148
|
-
handleAgentEvent(event,
|
|
197
|
+
handleAgentEvent(event, tasks, store, b, eventHandlerConfig, sseManager, pushService, clientRegistry);
|
|
149
198
|
});
|
|
150
199
|
await b.start();
|
|
151
200
|
bridge = b;
|
|
@@ -157,7 +206,7 @@ async function shutdown() {
|
|
|
157
206
|
sseManager.stopHeartbeat();
|
|
158
207
|
messageCleanup.stop();
|
|
159
208
|
sharePreviewCleanup?.stop();
|
|
160
|
-
|
|
209
|
+
tasks.killAllBashProcs();
|
|
161
210
|
await bridge?.shutdown();
|
|
162
211
|
await authStore.close();
|
|
163
212
|
store.close();
|
|
@@ -197,7 +246,8 @@ server.listen(config.port, config.host, () => {
|
|
|
197
246
|
try {
|
|
198
247
|
await initBridge(agentCmd);
|
|
199
248
|
console.log(`[bridge] ready`);
|
|
200
|
-
|
|
249
|
+
await tasks.ensureRootTask(bridge);
|
|
250
|
+
tasks.hydrate();
|
|
201
251
|
}
|
|
202
252
|
catch (err) {
|
|
203
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,12 +444,16 @@ 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.ownsTask(taskId)) {
|
|
448
|
+
json(res, HTTP_STATUS.NOT_FOUND, { error: "share not found" });
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
447
451
|
const row = deps.store.getShareByToken(body.token);
|
|
448
452
|
if (!row) {
|
|
449
453
|
json(res, HTTP_STATUS.NOT_FOUND, { error: "share not found" });
|
|
450
454
|
return;
|
|
451
455
|
}
|
|
452
|
-
if (row.
|
|
456
|
+
if (row.task_id !== taskId) {
|
|
453
457
|
json(res, HTTP_STATUS.NOT_FOUND, { error: "share not found" });
|
|
454
458
|
return;
|
|
455
459
|
}
|
|
@@ -521,7 +525,7 @@ async function handlePublish(req, res, deps, sessionId) {
|
|
|
521
525
|
: "";
|
|
522
526
|
json(res, HTTP_STATUS.OK, {
|
|
523
527
|
token: after.token,
|
|
524
|
-
|
|
528
|
+
task_id: taskId,
|
|
525
529
|
shared_at: after.shared_at,
|
|
526
530
|
display_name: after.display_name,
|
|
527
531
|
owner_label: after.owner_label,
|
|
@@ -602,27 +606,27 @@ async function handleSharedEvents(res, deps, token) {
|
|
|
602
606
|
return;
|
|
603
607
|
}
|
|
604
608
|
// Public viewer must keep working after the owner deletes the source
|
|
605
|
-
//
|
|
606
|
-
// them (Store.
|
|
607
|
-
const
|
|
608
|
-
if (!
|
|
609
|
-
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" });
|
|
610
614
|
return;
|
|
611
615
|
}
|
|
612
616
|
const allEvents = deps.store
|
|
613
|
-
.getEvents(row.
|
|
617
|
+
.getEvents(row.task_id)
|
|
614
618
|
.filter((e) => e.seq <= row.share_snapshot_seq);
|
|
615
|
-
if (deps.
|
|
616
|
-
enrichStoredEventsForDisplay(allEvents, deps.
|
|
619
|
+
if (deps.tasks) {
|
|
620
|
+
enrichStoredEventsForDisplay(allEvents, deps.tasks.getLabelMap(row.task_id));
|
|
617
621
|
}
|
|
618
622
|
try {
|
|
619
623
|
const { events } = sanitizeEventsForShare({
|
|
620
624
|
events: allEvents,
|
|
621
|
-
cwd:
|
|
625
|
+
cwd: task.cwd,
|
|
622
626
|
homeDir: homedir(),
|
|
623
627
|
internalHosts: deps.config.internal_hosts,
|
|
624
628
|
});
|
|
625
|
-
// Public response: DOES NOT expose
|
|
629
|
+
// Public response: DOES NOT expose task_id. Only title + display_name + meta.
|
|
626
630
|
res.writeHead(HTTP_STATUS.OK, {
|
|
627
631
|
"Content-Type": "application/json",
|
|
628
632
|
"Cache-Control": "no-store",
|
|
@@ -632,7 +636,7 @@ async function handleSharedEvents(res, deps, token) {
|
|
|
632
636
|
schema_version: "1.0",
|
|
633
637
|
share: {
|
|
634
638
|
token: row.token,
|
|
635
|
-
|
|
639
|
+
task_title: task.title,
|
|
636
640
|
shared_at: row.shared_at,
|
|
637
641
|
snapshot_seq: row.share_snapshot_seq,
|
|
638
642
|
display_name: row.display_name,
|
|
@@ -644,7 +648,7 @@ async function handleSharedEvents(res, deps, token) {
|
|
|
644
648
|
}
|
|
645
649
|
catch (err) {
|
|
646
650
|
if (err instanceof SanitizeError) {
|
|
647
|
-
// Hard-reject on a LIVE active share — owner's
|
|
651
|
+
// Hard-reject on a LIVE active share — owner's task gained a
|
|
648
652
|
// post-publish leak. Return 410 publicly; owner sees root cause via
|
|
649
653
|
// preview re-gate.
|
|
650
654
|
slog.error("shared_events hard-reject", {
|
|
@@ -717,8 +721,8 @@ async function handleViewerAsset(res, deps, file) {
|
|
|
717
721
|
}
|
|
718
722
|
/**
|
|
719
723
|
* GET /s/:token/attachments/:file — token-scoped image proxy. Resolves the token
|
|
720
|
-
* to a
|
|
721
|
-
* would leak
|
|
724
|
+
* to a task_id on-demand; directly serving /api/v1/tasks/:id/images
|
|
725
|
+
* would leak task_id.
|
|
722
726
|
*/
|
|
723
727
|
async function handleViewerImage(res, deps, token, file) {
|
|
724
728
|
if (!deps.dataDir) {
|
|
@@ -741,10 +745,10 @@ async function handleViewerImage(res, deps, token, file) {
|
|
|
741
745
|
json(res, HTTP_STATUS.NOT_FOUND, { error: "invalid file" });
|
|
742
746
|
return;
|
|
743
747
|
}
|
|
744
|
-
const
|
|
745
|
-
const filePath = join(
|
|
746
|
-
// Final realpath-style guard: must stay under <dataDir>/
|
|
747
|
-
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) {
|
|
748
752
|
res.writeHead(HTTP_STATUS.FORBIDDEN);
|
|
749
753
|
res.end("Forbidden");
|
|
750
754
|
return;
|
|
@@ -756,7 +760,7 @@ async function handleViewerImage(res, deps, token, file) {
|
|
|
756
760
|
// appends ".bin" to the <a download> name (e.g. zhihu.user.js →
|
|
757
761
|
// zhihu.user.js.bin). The owner-side route at routes.ts does the
|
|
758
762
|
// same lookup; share viewer needs parity for non-image attachments.
|
|
759
|
-
const att = deps.store.getAttachmentByFile(row.
|
|
763
|
+
const att = deps.store.getAttachmentByFile(row.task_id, file);
|
|
760
764
|
const ext = extname(filePath).toLowerCase();
|
|
761
765
|
let mime = att?.mime;
|
|
762
766
|
mime ??= IMAGE_MIME[ext];
|
|
@@ -827,13 +831,13 @@ export function validateLabel(input, field, maxBytes = 1024) {
|
|
|
827
831
|
return { ok: true, value: input };
|
|
828
832
|
}
|
|
829
833
|
/**
|
|
830
|
-
* DELETE /api/v1/
|
|
834
|
+
* DELETE /api/v1/tasks/:id/share — revoke an active or preview share.
|
|
831
835
|
* Body: { token }.
|
|
832
836
|
* Idempotent: already-revoked tokens return 200 with revoked=false.
|
|
833
837
|
* Returns { ok, token, revoked, purge_status }. purge_status is always
|
|
834
838
|
* 'skipped' in v1 — image/event purge is a future hardening pass.
|
|
835
839
|
*/
|
|
836
|
-
async function handleRevoke(req, res, deps,
|
|
840
|
+
async function handleRevoke(req, res, deps, taskId) {
|
|
837
841
|
let body;
|
|
838
842
|
try {
|
|
839
843
|
body = (await readJson(req));
|
|
@@ -849,10 +853,14 @@ async function handleRevoke(req, res, deps, sessionId) {
|
|
|
849
853
|
json(res, HTTP_STATUS.BAD_REQUEST, { error: "token required" });
|
|
850
854
|
return;
|
|
851
855
|
}
|
|
856
|
+
if (!deps.store.ownsTask(taskId)) {
|
|
857
|
+
json(res, HTTP_STATUS.NOT_FOUND, { error: "share not found" });
|
|
858
|
+
return;
|
|
859
|
+
}
|
|
852
860
|
const row = deps.store.getShareByToken(body.token);
|
|
853
861
|
if (!row) {
|
|
854
862
|
// Idempotent DELETE: row already gone (revoked or never existed).
|
|
855
|
-
// We can't verify
|
|
863
|
+
// We can't verify task ownership without a row, but the token
|
|
856
864
|
// is opaque/random so leaking "revoked or never existed" is fine.
|
|
857
865
|
json(res, HTTP_STATUS.OK, {
|
|
858
866
|
ok: true,
|
|
@@ -862,18 +870,18 @@ async function handleRevoke(req, res, deps, sessionId) {
|
|
|
862
870
|
});
|
|
863
871
|
return;
|
|
864
872
|
}
|
|
865
|
-
if (row.
|
|
873
|
+
if (row.task_id !== taskId) {
|
|
866
874
|
json(res, HTTP_STATUS.NOT_FOUND, { error: "share not found" });
|
|
867
875
|
return;
|
|
868
876
|
}
|
|
869
877
|
const revoked = deps.store.revokeShare(body.token);
|
|
870
|
-
// If this was the last share on a soft-deleted
|
|
871
|
-
// 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.
|
|
872
880
|
if (revoked) {
|
|
873
|
-
const reaped = deps.store.reapTombstoneIfOrphaned(row.
|
|
881
|
+
const reaped = deps.store.reapTombstoneIfOrphaned(row.task_id);
|
|
874
882
|
if (reaped && deps.dataDir) {
|
|
875
|
-
// Tombstoned
|
|
876
|
-
rm(join(deps.dataDir, "
|
|
883
|
+
// Tombstoned task is fully gone; sweep its attachments directory too.
|
|
884
|
+
rm(join(deps.dataDir, "tasks", row.task_id), {
|
|
877
885
|
recursive: true,
|
|
878
886
|
force: true,
|
|
879
887
|
}).catch(() => { });
|
|
@@ -887,14 +895,14 @@ async function handleRevoke(req, res, deps, sessionId) {
|
|
|
887
895
|
});
|
|
888
896
|
}
|
|
889
897
|
/**
|
|
890
|
-
* PATCH /api/v1/
|
|
898
|
+
* PATCH /api/v1/tasks/:id/share — update owner_label / display_name on
|
|
891
899
|
* a live (non-revoked) share. Body: { token, owner_label?, display_name? }.
|
|
892
900
|
*
|
|
893
901
|
* Full validation: UTF-8 ≤1024B, no C0 controls (except TAB), no DEL, no
|
|
894
902
|
* bidi overrides. Fields omitted from body are left unchanged; fields set
|
|
895
903
|
* to empty string clear the value.
|
|
896
904
|
*/
|
|
897
|
-
async function handlePatchLabel(req, res, deps,
|
|
905
|
+
async function handlePatchLabel(req, res, deps, taskId) {
|
|
898
906
|
let body;
|
|
899
907
|
try {
|
|
900
908
|
body = (await readJson(req));
|
|
@@ -910,12 +918,16 @@ async function handlePatchLabel(req, res, deps, sessionId) {
|
|
|
910
918
|
json(res, HTTP_STATUS.BAD_REQUEST, { error: "token required" });
|
|
911
919
|
return;
|
|
912
920
|
}
|
|
921
|
+
if (!deps.store.ownsTask(taskId)) {
|
|
922
|
+
json(res, HTTP_STATUS.NOT_FOUND, { error: "share not found" });
|
|
923
|
+
return;
|
|
924
|
+
}
|
|
913
925
|
const row = deps.store.getShareByToken(body.token);
|
|
914
926
|
if (!row) {
|
|
915
927
|
json(res, HTTP_STATUS.NOT_FOUND, { error: "share not found" });
|
|
916
928
|
return;
|
|
917
929
|
}
|
|
918
|
-
if (row.
|
|
930
|
+
if (row.task_id !== taskId) {
|
|
919
931
|
json(res, HTTP_STATUS.NOT_FOUND, { error: "share not found" });
|
|
920
932
|
return;
|
|
921
933
|
}
|
|
@@ -950,7 +962,7 @@ async function handlePatchLabel(req, res, deps, sessionId) {
|
|
|
950
962
|
}
|
|
951
963
|
json(res, HTTP_STATUS.OK, {
|
|
952
964
|
token: after.token,
|
|
953
|
-
|
|
965
|
+
task_id: taskId,
|
|
954
966
|
owner_label: after.owner_label,
|
|
955
967
|
display_name: after.display_name,
|
|
956
968
|
});
|