@sleep2agi/commhub-server 0.9.0-preview.7 → 0.9.0-preview.70
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 +125 -7
- package/bin/commhub.ts +10 -2
- package/package.json +5 -5
- package/src/ack-create-request-transport.test.ts +159 -0
- package/src/ack-create-request.test.ts +331 -0
- package/src/admin-networks-http.test.ts +88 -0
- package/src/agent-access.ts +267 -0
- package/src/agent-acl-http.test.ts +510 -0
- package/src/agent-acl-slice3-http.test.ts +267 -0
- package/src/alias-filter.test.ts +72 -0
- package/src/alias-filter.ts +32 -0
- package/src/api-host-supervisors-can-create-nodes.test.ts +350 -0
- package/src/api-host-supervisors-fallback.test.ts +271 -0
- package/src/api-nodes-shape.test.ts +26 -10
- package/src/auth-sessions-http.test.ts +250 -0
- package/src/auth-validate.test.ts +5 -1
- package/src/auth.ts +348 -29
- package/src/avatar-validate.test.ts +163 -0
- package/src/avatar-validate.ts +103 -0
- package/src/blocked-exit-on-reply.test.ts +122 -0
- package/src/boot-side-effect.test.ts +124 -0
- package/src/config-apply-sec1.test.ts +107 -0
- package/src/config-apply-validate.test.ts +92 -0
- package/src/config-apply-validate.ts +81 -3
- package/src/create-node-blocked-gate.test.ts +356 -0
- package/src/create-node-tool-schema.test.ts +39 -0
- package/src/create-node-validate.test.ts +89 -1
- package/src/create-node-validate.ts +89 -7
- package/src/create-node-workdir.test.ts +255 -0
- package/src/create-node.ts +51 -1
- package/src/dashboard-slash-routing-http.test.ts +107 -0
- package/src/db-adapter-guard.test.ts +85 -0
- package/src/db-adapter.ts +64 -23
- package/src/db-timestamp.test.ts +38 -0
- package/src/db-timestamp.ts +15 -0
- package/src/db.ts +762 -33
- package/src/delivered-without-push.test.ts +140 -0
- package/src/desktop-message-seam.test.ts +150 -0
- package/src/desktop-message.test.ts +368 -0
- package/src/ext-token-shared.test.ts +60 -0
- package/src/ext-validation-regression.test.ts +131 -0
- package/src/external-schedule-edits-http.test.ts +321 -0
- package/src/external-schedule-edits-timestamps.test.ts +64 -0
- package/src/external-schedule-edits.ts +327 -0
- package/src/file-download-authz-dev-open.test.ts +135 -0
- package/src/file-download-authz.test.ts +404 -0
- package/src/file-download-date-bucket.test.ts +436 -0
- package/src/file-network-scope-dev-open.test.ts +97 -0
- package/src/file-network-scope.test.ts +732 -0
- package/src/health-redaction.test.ts +642 -0
- package/src/http-gzip.test.ts +73 -0
- package/src/http-gzip.ts +60 -0
- package/src/hub-timestamp.test.ts +38 -0
- package/src/hub-timestamp.ts +27 -0
- package/src/human-dm.ts +149 -0
- package/src/inbox-count-sse.test.ts +310 -0
- package/src/inbox-count.ts +29 -0
- package/src/index.ts +15 -2331
- package/src/lifecycle-guard.ts +50 -0
- package/src/mcp-write-network-resolution.test.ts +429 -0
- package/src/messages-ack-by-agent-http.test.ts +142 -0
- package/src/network-name-http.test.ts +75 -0
- package/src/network-scope.ts +224 -0
- package/src/node-attrs-validate.ts +88 -0
- package/src/node-attrs.test.ts +217 -0
- package/src/node-create-request-status-http.test.ts +92 -0
- package/src/node-files-transport.test.ts +344 -0
- package/src/node-id-alias.test.ts +188 -0
- package/src/node-lifecycle-controllable.test.ts +64 -0
- package/src/node-lifecycle-controllable.ts +51 -0
- package/src/node-logs-transport.test.ts +263 -0
- package/src/node-request-retention.test.ts +281 -0
- package/src/node-request-retention.ts +120 -0
- package/src/node-skills-transport.test.ts +209 -0
- package/src/node-token-privilege-http.test.ts +149 -0
- package/src/observer-avatar-http.test.ts +429 -0
- package/src/observer-push.test.ts +252 -0
- package/src/package-version-consistency.test.ts +25 -0
- package/src/peer-reply-atomic.test.ts +455 -0
- package/src/peer-reply-capability.test.ts +70 -0
- package/src/push.ts +204 -7
- package/src/redact-tokens.test.ts +65 -0
- package/src/redact-tokens.ts +40 -0
- package/src/rename.ts +7 -2
- package/src/reply-target-derivation.test.ts +96 -0
- package/src/report-status-host-schema.test.ts +211 -0
- package/src/report-status-resume-id-handover.test.ts +150 -0
- package/src/require-explicit-test-db.ts +9 -0
- package/src/requirement-tags.ts +16 -0
- package/src/requirements-http.test.ts +708 -0
- package/src/requirements-mcp.test.ts +142 -0
- package/src/requirements-migrate.ts +83 -0
- package/src/requirements-priority-lowest.test.ts +114 -0
- package/src/requirements-upgrade-e2e.test.ts +126 -0
- package/src/requirements-upgrade-migration.test.ts +85 -0
- package/src/requirements.ts +865 -0
- package/src/resolve-port.test.ts +57 -0
- package/src/resolve-port.ts +44 -0
- package/src/response-charset.test.ts +130 -0
- package/src/rest-explicit-columns-http.test.ts +216 -0
- package/src/rest-identity.test.ts +129 -0
- package/src/rest-identity.ts +100 -0
- package/src/rest-projections.ts +68 -0
- package/src/rest-write-network-resolution.test.ts +133 -0
- package/src/restricted-files.ts +73 -0
- package/src/retention.ts +10 -1
- package/src/rules-file-duplicate-alias.test.ts +234 -0
- package/src/rules-file-session-target.test.ts +207 -0
- package/src/rules-file-transport.test.ts +237 -0
- package/src/runtime-label.test.ts +22 -0
- package/src/runtime-label.ts +21 -0
- package/src/scheduled-doorbell-nodeless-session.test.ts +130 -0
- package/src/scheduled-run-terminal.test.ts +268 -0
- package/src/scheduled-skip-blocking-task.test.ts +120 -0
- package/src/scheduled-tasks-http.test.ts +574 -0
- package/src/scheduled-tasks.ts +596 -0
- package/src/send-dedup-request-id.test.ts +229 -0
- package/src/send-reply-agent-warning.test.ts +202 -0
- package/src/send-reply-alias-optional.test.ts +131 -0
- package/src/send-reply-attachments.test.ts +458 -0
- package/src/send_dedup.test.ts +54 -0
- package/src/send_dedup.ts +52 -6
- package/src/serve-error.test.ts +38 -0
- package/src/serve-error.ts +27 -0
- package/src/server.ts +4279 -0
- package/src/shared/external-schedule-contract.ts +60 -0
- package/src/side-thread-command-transport.test.ts +302 -0
- package/src/side-thread-command-transport.ts +318 -0
- package/src/side-thread-http-integration.test.ts +233 -0
- package/src/side-thread-http.ts +504 -0
- package/src/side-thread-production.test.ts +72 -0
- package/src/side-thread-production.ts +118 -0
- package/src/side-thread.test.ts +1067 -0
- package/src/side-thread.ts +2495 -0
- package/src/skillhub-http.test.ts +92 -0
- package/src/start-node.test.ts +109 -0
- package/src/stop-delete-node.test.ts +1164 -0
- package/src/task-auth-origin-http.test.ts +91 -0
- package/src/task-auth-origin.test.ts +24 -0
- package/src/task-auth-origin.ts +7 -0
- package/src/task-consumption.test.ts +482 -0
- package/src/task-diagnostic-http.test.ts +149 -0
- package/src/task-diagnostic.test.ts +115 -0
- package/src/task-diagnostic.ts +118 -0
- package/src/task-idempotency-mcp.test.ts +142 -0
- package/src/task-idempotency.test.ts +60 -0
- package/src/task-idempotency.ts +75 -0
- package/src/task-lifecycle-watcher.test.ts +204 -0
- package/src/task-lifecycle-watcher.ts +49 -0
- package/src/task-network-resolution-http.test.ts +141 -0
- package/src/tasks-pagination-http.test.ts +141 -0
- package/src/tools-list-json-schema.test.ts +66 -0
- package/src/tools.ts +3445 -257
- package/src/update-provider.test.ts +65 -0
- package/src/upload-early-reject-guard.test.ts +155 -0
- package/src/uploads-http.test.ts +97 -15
- package/src/uploads.test.ts +66 -0
- package/src/uploads.ts +130 -5
- package/src/user-inbox-by-agent-http.test.ts +161 -0
- package/src/user-inbox-netid-notnull.test.ts +122 -0
- package/src/user-inbox-read-http.test.ts +223 -0
- package/src/user-inbox-schema.test.ts +54 -0
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
import {
|
|
2
|
+
SIDE_THREAD_API_ACTIONS,
|
|
3
|
+
SIDE_THREAD_API_ROOT,
|
|
4
|
+
SideThreadCoordinator,
|
|
5
|
+
SideThreadError,
|
|
6
|
+
type SideThreadActor,
|
|
7
|
+
type SideThreadRecord,
|
|
8
|
+
} from "./side-thread.js";
|
|
9
|
+
|
|
10
|
+
export interface SideThreadHttpContext {
|
|
11
|
+
req: Request;
|
|
12
|
+
url: URL;
|
|
13
|
+
actor: SideThreadActor | null;
|
|
14
|
+
coordinator: SideThreadCoordinator;
|
|
15
|
+
resolveCapabilityTarget?: (input: {
|
|
16
|
+
actor: SideThreadActor;
|
|
17
|
+
alias?: string;
|
|
18
|
+
nodeId?: string;
|
|
19
|
+
networkId?: string;
|
|
20
|
+
}) => { nodeId: string; networkId: string } | undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function handleSideThreadHttpRequest(
|
|
24
|
+
ctx: SideThreadHttpContext,
|
|
25
|
+
): Promise<Response | null> {
|
|
26
|
+
const root = ctx.url.pathname === SIDE_THREAD_API_ROOT;
|
|
27
|
+
const capabilityPath =
|
|
28
|
+
ctx.url.pathname === `${SIDE_THREAD_API_ROOT}/capability`;
|
|
29
|
+
const match = ctx.url.pathname.match(
|
|
30
|
+
new RegExp(
|
|
31
|
+
`^${SIDE_THREAD_API_ROOT}/([^/]+)(?:/(${SIDE_THREAD_API_ACTIONS.join("|")}))?$`,
|
|
32
|
+
),
|
|
33
|
+
);
|
|
34
|
+
if (!root && !capabilityPath && !match) return null;
|
|
35
|
+
if (!ctx.coordinator.isEnabled())
|
|
36
|
+
return json({ ok: false, error: "SIDE_THREAD_DISABLED" }, 404);
|
|
37
|
+
if (!ctx.actor)
|
|
38
|
+
return json({ ok: false, error: "SIDE_THREAD_AUTH_REQUIRED" }, 401);
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
if (capabilityPath) {
|
|
42
|
+
if (ctx.req.method !== "GET") return methodNotAllowed("GET");
|
|
43
|
+
const target = ctx.resolveCapabilityTarget?.({
|
|
44
|
+
actor: ctx.actor,
|
|
45
|
+
alias: query(ctx.url, "alias"),
|
|
46
|
+
nodeId: query(ctx.url, "nodeId", "node_id"),
|
|
47
|
+
networkId: query(ctx.url, "networkId", "network_id"),
|
|
48
|
+
});
|
|
49
|
+
if (!target)
|
|
50
|
+
throw new SideThreadError(
|
|
51
|
+
"SIDE_THREAD_NOT_FOUND",
|
|
52
|
+
"node not found",
|
|
53
|
+
404,
|
|
54
|
+
);
|
|
55
|
+
const sourceThreadId = requiredQueryIdentity(ctx.url, "sourceThreadId");
|
|
56
|
+
const boundaryKind = query(ctx.url, "boundaryKind");
|
|
57
|
+
const boundaryTurnId = requiredQueryIdentity(ctx.url, "boundaryTurnId");
|
|
58
|
+
if (boundaryKind !== "through" && boundaryKind !== "before")
|
|
59
|
+
throw new SideThreadError(
|
|
60
|
+
"SIDE_THREAD_INVALID_CONTEXT",
|
|
61
|
+
"boundaryKind must be through or before",
|
|
62
|
+
400,
|
|
63
|
+
);
|
|
64
|
+
const boundary = { kind: boundaryKind, turnId: boundaryTurnId } as const;
|
|
65
|
+
const capability = await ctx.coordinator.capability(
|
|
66
|
+
ctx.actor,
|
|
67
|
+
target.networkId,
|
|
68
|
+
target.nodeId,
|
|
69
|
+
boundary,
|
|
70
|
+
);
|
|
71
|
+
return json({
|
|
72
|
+
ok: true,
|
|
73
|
+
capability: serializeCapability(capability, {
|
|
74
|
+
networkId: target.networkId,
|
|
75
|
+
nodeId: target.nodeId,
|
|
76
|
+
sourceThreadId,
|
|
77
|
+
boundary,
|
|
78
|
+
}),
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
if (root && ctx.req.method === "GET") {
|
|
82
|
+
const rawLimit = ctx.url.searchParams.get("limit");
|
|
83
|
+
const limit = rawLimit === null ? undefined : parseLimit(rawLimit);
|
|
84
|
+
if (rawLimit !== null && limit === null)
|
|
85
|
+
throw new SideThreadError(
|
|
86
|
+
"SIDE_THREAD_INVALID_LIMIT",
|
|
87
|
+
"invalid list limit",
|
|
88
|
+
400,
|
|
89
|
+
);
|
|
90
|
+
const records = ctx.coordinator.list(ctx.actor, {
|
|
91
|
+
networkId: query(ctx.url, "networkId", "network_id"),
|
|
92
|
+
nodeId: query(ctx.url, "nodeId", "node_id"),
|
|
93
|
+
limit: limit ?? undefined,
|
|
94
|
+
});
|
|
95
|
+
return json({
|
|
96
|
+
ok: true,
|
|
97
|
+
sideThreads: records.map(serialize),
|
|
98
|
+
count: records.length,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
if (root && ctx.req.method === "POST") {
|
|
102
|
+
const body = await requestJson(ctx.req);
|
|
103
|
+
const record = await ctx.coordinator.create(ctx.actor, {
|
|
104
|
+
requestKey: field(body, "requestKey", "request_key"),
|
|
105
|
+
networkId: field(body, "networkId", "network_id"),
|
|
106
|
+
nodeId: field(body, "nodeId", "node_id"),
|
|
107
|
+
sourceThreadId: field(body, "sourceThreadId", "source_thread_id"),
|
|
108
|
+
boundary: {
|
|
109
|
+
kind: body.boundary?.kind,
|
|
110
|
+
turnId: field(body.boundary, "turnId", "turn_id"),
|
|
111
|
+
},
|
|
112
|
+
prompt: field(body, "question", "prompt"),
|
|
113
|
+
attachments: mapAttachments(body.attachments),
|
|
114
|
+
});
|
|
115
|
+
return json({ ok: true, sideThread: serialize(record) }, 201);
|
|
116
|
+
}
|
|
117
|
+
if (root) return methodNotAllowed("GET, POST");
|
|
118
|
+
|
|
119
|
+
const sideChatId = decodeSegment(match![1]);
|
|
120
|
+
const action = match![2];
|
|
121
|
+
if (!action && ctx.req.method === "GET") {
|
|
122
|
+
return json({
|
|
123
|
+
ok: true,
|
|
124
|
+
sideThread: serialize(ctx.coordinator.get(ctx.actor, sideChatId)),
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
if (!action) return methodNotAllowed("GET");
|
|
128
|
+
|
|
129
|
+
if (action === "events" && ctx.req.method === "GET") {
|
|
130
|
+
// Authorize before opening a long-lived stream. A non-owner gets the
|
|
131
|
+
// same 404 as an unknown id and cannot infer another user's side chat.
|
|
132
|
+
ctx.coordinator.get(ctx.actor, sideChatId);
|
|
133
|
+
const headerCursor = ctx.req.headers.get("Last-Event-ID");
|
|
134
|
+
const queryCursor = ctx.url.searchParams.get("after");
|
|
135
|
+
const after = parseCursor(headerCursor ?? queryCursor);
|
|
136
|
+
if (after === null)
|
|
137
|
+
throw new SideThreadError(
|
|
138
|
+
"SIDE_THREAD_INVALID_CURSOR",
|
|
139
|
+
"invalid event cursor",
|
|
140
|
+
400,
|
|
141
|
+
);
|
|
142
|
+
return sideThreadEventStream(
|
|
143
|
+
ctx.coordinator,
|
|
144
|
+
ctx.actor,
|
|
145
|
+
sideChatId,
|
|
146
|
+
after,
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (ctx.req.method !== "POST") return methodNotAllowed("POST");
|
|
151
|
+
const body = await requestJson(ctx.req);
|
|
152
|
+
const requestKey = field(body, "requestKey", "request_key");
|
|
153
|
+
if (action === "cancel") {
|
|
154
|
+
return json({
|
|
155
|
+
ok: true,
|
|
156
|
+
sideThread: serialize(
|
|
157
|
+
await ctx.coordinator.cancel(ctx.actor, sideChatId, { requestKey }),
|
|
158
|
+
),
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
if (action === "archive") {
|
|
162
|
+
return json({
|
|
163
|
+
ok: true,
|
|
164
|
+
sideThread: serialize(
|
|
165
|
+
await ctx.coordinator.archive(ctx.actor, sideChatId, { requestKey }),
|
|
166
|
+
),
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
if (action === "purge") {
|
|
170
|
+
return json({
|
|
171
|
+
ok: true,
|
|
172
|
+
sideThread: serialize(
|
|
173
|
+
await ctx.coordinator.purge(ctx.actor, sideChatId, { requestKey }),
|
|
174
|
+
),
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
if (action === "retry") {
|
|
178
|
+
return json({
|
|
179
|
+
ok: true,
|
|
180
|
+
sideThread: serialize(
|
|
181
|
+
await ctx.coordinator.retry(ctx.actor, sideChatId, {
|
|
182
|
+
requestKey,
|
|
183
|
+
prompt: field(body, "question", "prompt"),
|
|
184
|
+
attachments:
|
|
185
|
+
body.attachments === undefined
|
|
186
|
+
? undefined
|
|
187
|
+
: mapAttachments(body.attachments),
|
|
188
|
+
}),
|
|
189
|
+
),
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
const broughtBack = await ctx.coordinator.bringBack(ctx.actor, sideChatId, {
|
|
193
|
+
requestKey,
|
|
194
|
+
destinationThreadId: field(
|
|
195
|
+
body,
|
|
196
|
+
"destinationThreadId",
|
|
197
|
+
"destination_thread_id",
|
|
198
|
+
),
|
|
199
|
+
attemptId: field(body, "attemptId", "attempt_id"),
|
|
200
|
+
});
|
|
201
|
+
return json({ ok: true, bringBack: broughtBack });
|
|
202
|
+
} catch (error) {
|
|
203
|
+
if (error instanceof SideThreadError) {
|
|
204
|
+
return json(
|
|
205
|
+
{
|
|
206
|
+
ok: false,
|
|
207
|
+
error: error.code,
|
|
208
|
+
message: error.message,
|
|
209
|
+
...(error.operationId ? { operationId: error.operationId } : {}),
|
|
210
|
+
...(error.sideChatId ? { sideThreadId: error.sideChatId } : {}),
|
|
211
|
+
...(error.attemptId ? { attemptId: error.attemptId } : {}),
|
|
212
|
+
},
|
|
213
|
+
error.status,
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
return json({ ok: false, error: "SIDE_THREAD_INTERNAL_ERROR" }, 500);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function serialize(record: SideThreadRecord) {
|
|
221
|
+
const completedBringBackAttempts = new Set(
|
|
222
|
+
record.bringBacks
|
|
223
|
+
.filter((receipt) => receipt.state === "completed")
|
|
224
|
+
.map((receipt) => receipt.attemptId),
|
|
225
|
+
);
|
|
226
|
+
return {
|
|
227
|
+
sideThreadId: record.sideChatId,
|
|
228
|
+
requestKey: record.requestKey,
|
|
229
|
+
networkId: record.networkId,
|
|
230
|
+
nodeId: record.nodeId,
|
|
231
|
+
sourceThreadId: record.sourceThreadId,
|
|
232
|
+
question: record.question,
|
|
233
|
+
title: record.question.split(/\r?\n/, 1)[0].slice(0, 120),
|
|
234
|
+
boundary: { kind: record.boundary.kind, turnId: record.boundary.turnId },
|
|
235
|
+
threadId: record.threadId ?? null,
|
|
236
|
+
state: record.state,
|
|
237
|
+
activeAttemptId: record.activeAttemptId ?? null,
|
|
238
|
+
capability: {
|
|
239
|
+
runtime: record.runtime ?? null,
|
|
240
|
+
runtimeVersion: record.runtimeVersion ?? null,
|
|
241
|
+
topology: record.topology ?? null,
|
|
242
|
+
evidenceRevision: record.evidenceRevision ?? null,
|
|
243
|
+
},
|
|
244
|
+
attachments: record.attachments.map((a) => ({ fileId: a.fileId })),
|
|
245
|
+
attempts: record.attempts.map((a) => ({
|
|
246
|
+
attemptId: a.attemptId,
|
|
247
|
+
requestKey: a.requestKey,
|
|
248
|
+
parentAttemptId: a.parentAttemptId ?? null,
|
|
249
|
+
threadId: a.threadId ?? null,
|
|
250
|
+
turnId: a.turnId ?? null,
|
|
251
|
+
state: a.state,
|
|
252
|
+
result: a.result ?? null,
|
|
253
|
+
error: a.error ?? null,
|
|
254
|
+
attachments: a.attachments.map((attachment) => ({
|
|
255
|
+
fileId: attachment.fileId,
|
|
256
|
+
})),
|
|
257
|
+
broughtBack: completedBringBackAttempts.has(a.attemptId),
|
|
258
|
+
createdAt: a.createdAt,
|
|
259
|
+
updatedAt: a.updatedAt,
|
|
260
|
+
})),
|
|
261
|
+
bringBacks: record.bringBacks.map((receipt) => ({
|
|
262
|
+
bringBackId: receipt.bringBackId,
|
|
263
|
+
attemptId: receipt.attemptId,
|
|
264
|
+
requestKey: receipt.requestKey,
|
|
265
|
+
destinationThreadId: receipt.destinationThreadId,
|
|
266
|
+
destinationTurnId: receipt.destinationTurnId ?? null,
|
|
267
|
+
state: receipt.state,
|
|
268
|
+
broughtBack: receipt.state === "completed",
|
|
269
|
+
createdAt: receipt.createdAt,
|
|
270
|
+
updatedAt: receipt.updatedAt,
|
|
271
|
+
completedAt: receipt.completedAt ?? null,
|
|
272
|
+
})),
|
|
273
|
+
operations: record.operations.map((operation) => ({
|
|
274
|
+
operationId: operation.operationId,
|
|
275
|
+
attemptId: operation.attemptId ?? null,
|
|
276
|
+
kind: operation.kind,
|
|
277
|
+
requestKey: operation.requestKey,
|
|
278
|
+
state: operation.state,
|
|
279
|
+
threadId: operation.threadId ?? null,
|
|
280
|
+
turnId: operation.turnId ?? null,
|
|
281
|
+
errorCode: operation.errorCode ?? null,
|
|
282
|
+
createdAt: operation.createdAt,
|
|
283
|
+
updatedAt: operation.updatedAt,
|
|
284
|
+
})),
|
|
285
|
+
createdAt: record.createdAt,
|
|
286
|
+
updatedAt: record.updatedAt,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function serializeCapability(
|
|
291
|
+
capability: Awaited<ReturnType<SideThreadCoordinator["capability"]>>,
|
|
292
|
+
context: {
|
|
293
|
+
networkId: string;
|
|
294
|
+
nodeId: string;
|
|
295
|
+
sourceThreadId: string;
|
|
296
|
+
boundary: { kind: "through" | "before"; turnId: string };
|
|
297
|
+
},
|
|
298
|
+
) {
|
|
299
|
+
return {
|
|
300
|
+
enabled: true,
|
|
301
|
+
supported: capability.supported,
|
|
302
|
+
mode: capability.mode ?? null,
|
|
303
|
+
runtime: capability.runtime ?? null,
|
|
304
|
+
runtimeVersion: capability.runtimeVersion ?? null,
|
|
305
|
+
topology: capability.topology ?? null,
|
|
306
|
+
evidenceRevision: capability.evidenceRevision ?? null,
|
|
307
|
+
exactBoundary: capability.exactBoundary ?? null,
|
|
308
|
+
reason: capability.reason ?? null,
|
|
309
|
+
context: capability.supported ? context : null,
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function sideThreadEventStream(
|
|
314
|
+
coordinator: SideThreadCoordinator,
|
|
315
|
+
actor: SideThreadActor,
|
|
316
|
+
sideChatId: string,
|
|
317
|
+
after: number,
|
|
318
|
+
): Response {
|
|
319
|
+
const encoder = new TextEncoder();
|
|
320
|
+
let unsubscribe = () => {};
|
|
321
|
+
let heartbeat: ReturnType<typeof setInterval> | undefined;
|
|
322
|
+
let closed = false;
|
|
323
|
+
const cleanup = () => {
|
|
324
|
+
if (closed) return;
|
|
325
|
+
closed = true;
|
|
326
|
+
unsubscribe();
|
|
327
|
+
if (heartbeat) clearInterval(heartbeat);
|
|
328
|
+
};
|
|
329
|
+
const stream = new ReadableStream<Uint8Array>(
|
|
330
|
+
{
|
|
331
|
+
start(controller) {
|
|
332
|
+
const send = (
|
|
333
|
+
event: ReturnType<SideThreadCoordinator["listEvents"]>[number],
|
|
334
|
+
) => {
|
|
335
|
+
if (closed) return;
|
|
336
|
+
if ((controller.desiredSize ?? -262_145) < -262_144) {
|
|
337
|
+
cleanup();
|
|
338
|
+
try {
|
|
339
|
+
controller.close();
|
|
340
|
+
} catch {}
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
try {
|
|
344
|
+
controller.enqueue(
|
|
345
|
+
encoder.encode(
|
|
346
|
+
`id: ${event.eventId}\nevent: side_thread\ndata: ${JSON.stringify(serializeEvent(event))}\n\n`,
|
|
347
|
+
),
|
|
348
|
+
);
|
|
349
|
+
} catch {
|
|
350
|
+
cleanup();
|
|
351
|
+
}
|
|
352
|
+
};
|
|
353
|
+
for (const event of coordinator.listEvents(actor, sideChatId, after))
|
|
354
|
+
send(event);
|
|
355
|
+
if (closed) return;
|
|
356
|
+
unsubscribe = coordinator.subscribe(sideChatId, send);
|
|
357
|
+
heartbeat = setInterval(() => {
|
|
358
|
+
if ((controller.desiredSize ?? -262_145) < -262_144) {
|
|
359
|
+
cleanup();
|
|
360
|
+
try {
|
|
361
|
+
controller.close();
|
|
362
|
+
} catch {}
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
try {
|
|
366
|
+
controller.enqueue(encoder.encode(": keepalive\n\n"));
|
|
367
|
+
} catch {
|
|
368
|
+
cleanup();
|
|
369
|
+
}
|
|
370
|
+
}, 30_000);
|
|
371
|
+
},
|
|
372
|
+
cancel() {
|
|
373
|
+
cleanup();
|
|
374
|
+
},
|
|
375
|
+
},
|
|
376
|
+
{ highWaterMark: 64 * 1024, size: (chunk) => chunk.byteLength },
|
|
377
|
+
);
|
|
378
|
+
return new Response(stream, {
|
|
379
|
+
headers: {
|
|
380
|
+
"Content-Type": "text/event-stream; charset=utf-8",
|
|
381
|
+
"Cache-Control": "no-cache, no-transform",
|
|
382
|
+
"X-Accel-Buffering": "no",
|
|
383
|
+
},
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function serializeEvent(
|
|
388
|
+
event: ReturnType<SideThreadCoordinator["listEvents"]>[number],
|
|
389
|
+
) {
|
|
390
|
+
return {
|
|
391
|
+
eventId: event.eventId,
|
|
392
|
+
sideThreadId: event.sideChatId,
|
|
393
|
+
attemptId: event.attemptId ?? null,
|
|
394
|
+
threadId: event.threadId ?? null,
|
|
395
|
+
turnId: event.turnId ?? null,
|
|
396
|
+
type: event.type,
|
|
397
|
+
state: event.state ?? null,
|
|
398
|
+
reason: event.reason ?? null,
|
|
399
|
+
createdAt: event.createdAt,
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function mapAttachments(value: unknown): Array<{ fileId: string }> | undefined {
|
|
404
|
+
if (value === undefined) return undefined;
|
|
405
|
+
if (!Array.isArray(value))
|
|
406
|
+
throw new SideThreadError(
|
|
407
|
+
"SIDE_THREAD_CONFLICT",
|
|
408
|
+
"invalid attachments",
|
|
409
|
+
409,
|
|
410
|
+
);
|
|
411
|
+
return value.map((entry: any) => {
|
|
412
|
+
if (!entry || typeof entry !== "object")
|
|
413
|
+
return { fileId: undefined as any };
|
|
414
|
+
// Do not forward path/url/name fields even temporarily. The coordinator
|
|
415
|
+
// rejects the original shape when extra keys are present.
|
|
416
|
+
const allowed = Object.keys(entry).every(
|
|
417
|
+
(key) => key === "fileId" || key === "file_id",
|
|
418
|
+
);
|
|
419
|
+
return allowed
|
|
420
|
+
? { fileId: field(entry, "fileId", "file_id") }
|
|
421
|
+
: { ...(entry as any), fileId: field(entry, "fileId", "file_id") };
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function field(value: any, camel: string, legacy: string): any {
|
|
426
|
+
return value?.[camel] ?? value?.[legacy];
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function query(url: URL, camel: string, legacy?: string): string | undefined {
|
|
430
|
+
return (
|
|
431
|
+
url.searchParams.get(camel) ??
|
|
432
|
+
(legacy ? url.searchParams.get(legacy) : null) ??
|
|
433
|
+
undefined
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function requiredQueryIdentity(url: URL, name: string): string {
|
|
438
|
+
const value = query(url, name);
|
|
439
|
+
if (!value || value.length > 512 || /[\r\n\0]/.test(value))
|
|
440
|
+
throw new SideThreadError(
|
|
441
|
+
"SIDE_THREAD_INVALID_CONTEXT",
|
|
442
|
+
`invalid ${name}`,
|
|
443
|
+
400,
|
|
444
|
+
);
|
|
445
|
+
return value;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
async function requestJson(req: Request): Promise<any> {
|
|
449
|
+
const contentType = req.headers.get("Content-Type") ?? "";
|
|
450
|
+
if (!/^application\/json(?:;|$)/i.test(contentType)) {
|
|
451
|
+
throw new SideThreadError(
|
|
452
|
+
"SIDE_THREAD_INVALID_BODY",
|
|
453
|
+
"application/json required",
|
|
454
|
+
415,
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
try {
|
|
458
|
+
const value = await req.json();
|
|
459
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
460
|
+
throw new SideThreadError(
|
|
461
|
+
"SIDE_THREAD_INVALID_BODY",
|
|
462
|
+
"JSON object required",
|
|
463
|
+
400,
|
|
464
|
+
);
|
|
465
|
+
}
|
|
466
|
+
return value;
|
|
467
|
+
} catch {
|
|
468
|
+
throw new SideThreadError("SIDE_THREAD_INVALID_BODY", "invalid JSON", 400);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
function decodeSegment(value: string): string {
|
|
472
|
+
try {
|
|
473
|
+
return decodeURIComponent(value);
|
|
474
|
+
} catch {
|
|
475
|
+
throw new SideThreadError(
|
|
476
|
+
"SIDE_THREAD_NOT_FOUND",
|
|
477
|
+
"side chat not found",
|
|
478
|
+
404,
|
|
479
|
+
);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
function parseCursor(value: string | null): number | null {
|
|
483
|
+
if (value === null || value === "") return 0;
|
|
484
|
+
if (!/^\d+$/.test(value)) return null;
|
|
485
|
+
const n = Number(value);
|
|
486
|
+
return Number.isSafeInteger(n) ? n : null;
|
|
487
|
+
}
|
|
488
|
+
function parseLimit(value: string): number | null {
|
|
489
|
+
if (!/^\d+$/.test(value)) return null;
|
|
490
|
+
const n = Number(value);
|
|
491
|
+
return Number.isSafeInteger(n) && n >= 1 && n <= 100 ? n : null;
|
|
492
|
+
}
|
|
493
|
+
function json(body: unknown, status = 200): Response {
|
|
494
|
+
return Response.json(body, {
|
|
495
|
+
status,
|
|
496
|
+
headers: { "Cache-Control": "no-store" },
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
function methodNotAllowed(allow: string): Response {
|
|
500
|
+
return Response.json(
|
|
501
|
+
{ ok: false, error: "SIDE_THREAD_METHOD_NOT_ALLOWED" },
|
|
502
|
+
{ status: 405, headers: { Allow: allow } },
|
|
503
|
+
);
|
|
504
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { Database } from "bun:sqlite";
|
|
3
|
+
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { SQLiteAdapter } from "./db-adapter";
|
|
7
|
+
import { createProductionSideThreadTransport } from "./side-thread-production";
|
|
8
|
+
|
|
9
|
+
const roots: string[] = [];
|
|
10
|
+
const priorUploads = process.env.COMMHUB_UPLOADS_DIR;
|
|
11
|
+
afterEach(() => {
|
|
12
|
+
if (priorUploads === undefined) delete process.env.COMMHUB_UPLOADS_DIR;
|
|
13
|
+
else process.env.COMMHUB_UPLOADS_DIR = priorUploads;
|
|
14
|
+
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe("production Hub SideThread wiring", () => {
|
|
18
|
+
test("advertises only an exact node snapshot and serves a short-lived node-bound grant", async () => {
|
|
19
|
+
const root = mkdtempSync(join(tmpdir(), "side-hub-prod-")); roots.push(root);
|
|
20
|
+
process.env.COMMHUB_UPLOADS_DIR = root;
|
|
21
|
+
const db = new SQLiteAdapter(new Database(":memory:"));
|
|
22
|
+
db.exec("CREATE TABLE nodes(node_id TEXT PRIMARY KEY, network_id TEXT, config_snapshot TEXT)");
|
|
23
|
+
db.run("INSERT INTO nodes VALUES (?1,?2,?3)", ["node-1", "net-1", JSON.stringify({ side_thread_capability: {
|
|
24
|
+
supported: true, runtime: "codex-app-server", runtimeVersion: "0.148.0", topology: "owned-stdio",
|
|
25
|
+
evidenceRevision: "test1190-wire-v2", mode: "native-exact-fork", exactBoundary: { through: true, before: false },
|
|
26
|
+
} })]);
|
|
27
|
+
let now = 1_000;
|
|
28
|
+
const transport = createProductionSideThreadTransport(db, () => now, 0);
|
|
29
|
+
expect(transport.port.capability("node-1")).toMatchObject({ supported: true, exactBoundary: { before: false } });
|
|
30
|
+
expect(transport.port.capability("missing")).toMatchObject({ supported: false, reason: "runtime" });
|
|
31
|
+
|
|
32
|
+
const fileId = "file12345678"; const bytes = Buffer.from("image bytes"); const bucket = "2026-08-26";
|
|
33
|
+
mkdirSync(join(root, ".index"), { recursive: true }); mkdirSync(join(root, bucket), { recursive: true });
|
|
34
|
+
writeFileSync(join(root, bucket, `${fileId}.png`), bytes);
|
|
35
|
+
writeFileSync(join(root, ".index", `${fileId}.json`), JSON.stringify({
|
|
36
|
+
file_id: fileId, date_bucket: bucket, ext: ".png", name: "x.png", mime: "image/png",
|
|
37
|
+
size: bytes.length, owner_id: "user-1", network_id: "net-1", uploaded_at: new Date().toISOString(),
|
|
38
|
+
}));
|
|
39
|
+
await transport.port.start({
|
|
40
|
+
operationId: "op-start", requestKey: "rk-start", sideChatId: "side-1", attemptId: "attempt-1",
|
|
41
|
+
nodeId: "node-1", threadId: "derived-1", prompt: "read it",
|
|
42
|
+
attachments: [{ fileId, mediaType: "image/png", size: bytes.length }],
|
|
43
|
+
}).catch(() => {});
|
|
44
|
+
const command = transport.store.claim({ tokenId: "token-1", networkId: "net-1", nodeId: "node-1" }) as any;
|
|
45
|
+
const grant = command.payload.attachments[0];
|
|
46
|
+
expect(grant).toMatchObject({ fileId, mediaType: "image/png", size: bytes.length });
|
|
47
|
+
expect(grant.sha256).toMatch(/^[a-f0-9]{64}$/);
|
|
48
|
+
const url = new URL(`http://hub/api/side-thread/attachment-grants/${grant.grantId}`);
|
|
49
|
+
const foreign = await transport.attachment(new Request(url), url, { tokenId: "foreign", networkId: "net-1", nodeId: "node-2" });
|
|
50
|
+
expect(foreign?.status).toBe(404);
|
|
51
|
+
const response = await transport.attachment(new Request(url), url, { tokenId: "token-1", networkId: "net-1", nodeId: "node-1" });
|
|
52
|
+
expect(response?.status).toBe(200); expect(Buffer.from(await response!.arrayBuffer())).toEqual(bytes);
|
|
53
|
+
now += 5 * 60_000 + 1;
|
|
54
|
+
expect((await transport.attachment(new Request(url), url, { tokenId: "token-1", networkId: "net-1", nodeId: "node-1" }))?.status).toBe(404);
|
|
55
|
+
await transport.port.start({
|
|
56
|
+
operationId: "op-start-2", requestKey: "rk-start-2", sideChatId: "side-2", attemptId: "attempt-2",
|
|
57
|
+
nodeId: "node-1", threadId: "derived-2", prompt: "read it again",
|
|
58
|
+
attachments: [{ fileId, mediaType: "image/png", size: bytes.length }],
|
|
59
|
+
}).catch(() => {});
|
|
60
|
+
expect(db.get<{ count: number }>("SELECT COUNT(*) AS count FROM side_thread_attachment_grants")?.count).toBe(1);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("rejects forged capability shapes instead of widening the allowlist", () => {
|
|
64
|
+
const db = new SQLiteAdapter(new Database(":memory:"));
|
|
65
|
+
db.exec("CREATE TABLE nodes(node_id TEXT PRIMARY KEY, network_id TEXT, config_snapshot TEXT)");
|
|
66
|
+
db.run("INSERT INTO nodes VALUES (?1,?2,?3)", ["node-1", "net-1", JSON.stringify({ side_thread_capability: {
|
|
67
|
+
supported: true, runtime: "codex-app-server", runtimeVersion: "latest", topology: "owned-stdio",
|
|
68
|
+
evidenceRevision: "test1190-wire-v2", mode: "native-exact-fork", exactBoundary: { through: true, before: true },
|
|
69
|
+
} })]);
|
|
70
|
+
expect(createProductionSideThreadTransport(db).port.capability("node-1")).toMatchObject({ supported: false });
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import type { DbAdapter } from "./db-adapter.js";
|
|
4
|
+
import { indexEntryPath, pathForExistingBlob, validateIndexEntry } from "./uploads.js";
|
|
5
|
+
import type { SideThreadAttachmentRef, SideThreadCapability } from "./side-thread.js";
|
|
6
|
+
import {
|
|
7
|
+
DurableSideThreadCommandPort,
|
|
8
|
+
SideThreadCommandStore,
|
|
9
|
+
handleSideThreadCommandRequest,
|
|
10
|
+
type NodeCommandActor,
|
|
11
|
+
} from "./side-thread-command-transport.js";
|
|
12
|
+
|
|
13
|
+
const GRANT_TTL_MS = 5 * 60_000;
|
|
14
|
+
|
|
15
|
+
type GrantRow = {
|
|
16
|
+
grant_id: string; file_id: string; network_id: string; node_id: string;
|
|
17
|
+
sha256: string; size: number; media_type: string; expires_at: number;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/** Production-only SideThread transport. It remains absent unless the public
|
|
21
|
+
* feature flag is enabled and the configured DB can provide atomic claims. */
|
|
22
|
+
export function createProductionSideThreadTransport(db: DbAdapter, now = Date.now, ackWaitMs = 2_500) {
|
|
23
|
+
const store = new SideThreadCommandStore(db, now);
|
|
24
|
+
db.exec(`CREATE TABLE IF NOT EXISTS side_thread_attachment_grants (
|
|
25
|
+
grant_id TEXT PRIMARY KEY, file_id TEXT NOT NULL, network_id TEXT NOT NULL,
|
|
26
|
+
node_id TEXT NOT NULL, sha256 TEXT NOT NULL, size INTEGER NOT NULL,
|
|
27
|
+
media_type TEXT NOT NULL, expires_at INTEGER NOT NULL, created_at INTEGER NOT NULL
|
|
28
|
+
)`);
|
|
29
|
+
const port = new DurableSideThreadCommandPort({
|
|
30
|
+
store,
|
|
31
|
+
networkForNode(nodeId) {
|
|
32
|
+
return db.get<{ network_id: string | null }>("SELECT network_id FROM nodes WHERE node_id=?1", nodeId)?.network_id ?? null;
|
|
33
|
+
},
|
|
34
|
+
capabilityForNode(nodeId) {
|
|
35
|
+
const row = db.get<{ config_snapshot: string | null }>("SELECT config_snapshot FROM nodes WHERE node_id=?1", nodeId);
|
|
36
|
+
if (!row?.config_snapshot) return unsupported("runtime");
|
|
37
|
+
try {
|
|
38
|
+
const cap = JSON.parse(row.config_snapshot)?.side_thread_capability;
|
|
39
|
+
return validateCapability(cap) ? cap : unsupported("runtime");
|
|
40
|
+
} catch { return unsupported("runtime"); }
|
|
41
|
+
},
|
|
42
|
+
grantAttachment(nodeId, ref) { return issueGrant(db, nodeId, ref, now); },
|
|
43
|
+
// The dedicated node consumer polls asynchronously. Give its durable ACK
|
|
44
|
+
// a bounded window so the coordinator can advance fork→start atomically;
|
|
45
|
+
// timeout remains an honest ambiguous result, never a task fallback.
|
|
46
|
+
ackWaitMs,
|
|
47
|
+
});
|
|
48
|
+
return {
|
|
49
|
+
store,
|
|
50
|
+
port,
|
|
51
|
+
handle: (input: { req: Request; url: URL; actor: NodeCommandActor | null }) =>
|
|
52
|
+
handleSideThreadCommandRequest({ ...input, store, port }),
|
|
53
|
+
attachment: (req: Request, url: URL, actor: NodeCommandActor | null) =>
|
|
54
|
+
serveGrant(db, req, url, actor, now),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function issueGrant(db: DbAdapter, nodeId: string, ref: SideThreadAttachmentRef, now: () => number): Record<string, unknown> {
|
|
59
|
+
// Grants are deliberately short-lived and must not become an unbounded
|
|
60
|
+
// metadata cache. Lazy sweeping keeps the hot download path read-only while
|
|
61
|
+
// ensuring every new issuance bounds accumulated expired rows.
|
|
62
|
+
db.run("DELETE FROM side_thread_attachment_grants WHERE expires_at < ?1", [now()]);
|
|
63
|
+
const node = db.get<{ network_id: string | null }>("SELECT network_id FROM nodes WHERE node_id=?1", nodeId);
|
|
64
|
+
if (!node?.network_id) throw new Error("SideThread attachment node scope unavailable");
|
|
65
|
+
const indexPath = indexEntryPath(ref.fileId);
|
|
66
|
+
if (!indexPath) throw new Error("SideThread attachment index unavailable");
|
|
67
|
+
const entry = JSON.parse(readFileSync(indexPath, "utf8"));
|
|
68
|
+
if (!validateIndexEntry(entry) || entry.file_id !== ref.fileId || entry.network_id !== node.network_id)
|
|
69
|
+
throw new Error("SideThread attachment scope mismatch");
|
|
70
|
+
const path = pathForExistingBlob(entry.date_bucket, entry.file_id, entry.ext).absolutePath;
|
|
71
|
+
const bytes = readFileSync(path);
|
|
72
|
+
if (bytes.byteLength !== entry.size) throw new Error("SideThread attachment size changed");
|
|
73
|
+
const sha256 = createHash("sha256").update(bytes).digest("hex");
|
|
74
|
+
const grantId = `stg_${randomUUID().replace(/-/g, "")}`;
|
|
75
|
+
const mediaType = typeof entry.mime === "string" && entry.mime.length <= 200 ? entry.mime : "application/octet-stream";
|
|
76
|
+
db.run(
|
|
77
|
+
"INSERT INTO side_thread_attachment_grants (grant_id,file_id,network_id,node_id,sha256,size,media_type,expires_at,created_at) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9)",
|
|
78
|
+
[grantId, ref.fileId, node.network_id, nodeId, sha256, bytes.byteLength, mediaType, now() + GRANT_TTL_MS, now()],
|
|
79
|
+
);
|
|
80
|
+
return { fileId: ref.fileId, grantId, sha256, size: bytes.byteLength, mediaType };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function serveGrant(db: DbAdapter, req: Request, url: URL, actor: NodeCommandActor | null, now: () => number): Promise<Response | null> {
|
|
84
|
+
const match = url.pathname.match(/^\/api\/side-thread\/attachment-grants\/([^/]+)$/);
|
|
85
|
+
if (!match) return null;
|
|
86
|
+
if (req.method !== "GET") return Response.json({ ok: false, error: "method_not_allowed" }, { status: 405 });
|
|
87
|
+
if (!actor) return Response.json({ ok: false, error: "node_token_binding_required" }, { status: 403 });
|
|
88
|
+
let grantId: string;
|
|
89
|
+
try { grantId = decodeURIComponent(match[1]); } catch { return Response.json({ ok: false, error: "invalid_grant" }, { status: 400 }); }
|
|
90
|
+
const row = db.get<GrantRow>("SELECT * FROM side_thread_attachment_grants WHERE grant_id=?1", grantId);
|
|
91
|
+
// 404 deliberately hides existence, ownership and expiry.
|
|
92
|
+
if (!row || row.node_id !== actor.nodeId || row.network_id !== actor.networkId || row.expires_at < now())
|
|
93
|
+
return Response.json({ ok: false, error: "not_found" }, { status: 404 });
|
|
94
|
+
const indexPath = indexEntryPath(row.file_id);
|
|
95
|
+
if (!indexPath) return Response.json({ ok: false, error: "not_found" }, { status: 404 });
|
|
96
|
+
try {
|
|
97
|
+
const entry = JSON.parse(readFileSync(indexPath, "utf8"));
|
|
98
|
+
if (!validateIndexEntry(entry) || entry.network_id !== row.network_id || entry.size !== row.size)
|
|
99
|
+
return Response.json({ ok: false, error: "not_found" }, { status: 404 });
|
|
100
|
+
const bytes = readFileSync(pathForExistingBlob(entry.date_bucket, entry.file_id, entry.ext).absolutePath);
|
|
101
|
+
if (bytes.byteLength !== row.size || createHash("sha256").update(bytes).digest("hex") !== row.sha256)
|
|
102
|
+
return Response.json({ ok: false, error: "not_found" }, { status: 404 });
|
|
103
|
+
return new Response(bytes, { headers: { "Content-Type": row.media_type, "Content-Length": String(row.size), "Cache-Control": "no-store" } });
|
|
104
|
+
} catch { return Response.json({ ok: false, error: "not_found" }, { status: 404 }); }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function validateCapability(value: unknown): value is SideThreadCapability {
|
|
108
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
109
|
+
const v = value as Record<string, unknown>;
|
|
110
|
+
return v.supported === true && v.runtime === "codex-app-server" && v.runtimeVersion === "0.148.0"
|
|
111
|
+
&& v.topology === "owned-stdio" && v.evidenceRevision === "test1190-wire-v2"
|
|
112
|
+
&& v.mode === "native-exact-fork" && !!v.exactBoundary && typeof v.exactBoundary === "object"
|
|
113
|
+
&& (v.exactBoundary as any).through === true && typeof (v.exactBoundary as any).before === "boolean";
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function unsupported(reason: SideThreadCapability["reason"]): SideThreadCapability {
|
|
117
|
+
return { supported: false, runtime: "unknown", runtimeVersion: "unknown", topology: "unknown", evidenceRevision: "none", reason };
|
|
118
|
+
}
|