@agent-native/core 0.101.3 → 0.101.5
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/corpus/README.md +1 -1
- package/corpus/core/CHANGELOG.md +14 -0
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/action.ts +15 -0
- package/corpus/core/src/agent/production-agent.ts +215 -19
- package/corpus/core/src/client/RunStuckBanner.tsx +12 -5
- package/corpus/core/src/client/sse-event-processor.ts +36 -1
- package/corpus/core/src/client/use-run-stuck-detection.ts +162 -25
- package/corpus/core/src/coding-tools/run-code.ts +11 -5
- package/corpus/core/src/deploy/build.ts +78 -25
- package/corpus/core/src/extensions/actions.ts +15 -0
- package/corpus/core/src/extensions/url-safety.ts +11 -1
- package/corpus/core/src/notifications/channels.ts +61 -30
- package/corpus/core/src/server/action-discovery.ts +4 -1
- package/corpus/core/src/server/agent-chat/context-tools.ts +4 -0
- package/corpus/templates/chat/changelog/2026-07-14-chat-opens-reliably-on-hosted-deployments-instead-of-failing.md +6 -0
- package/corpus/templates/clips/app/components/player/media-duration.ts +24 -0
- package/corpus/templates/clips/app/components/player/video-player.tsx +9 -18
- package/corpus/templates/clips/changelog/2026-07-14-paused-time-no-longer-counts-toward-chrome-extension-recordi.md +6 -0
- package/corpus/templates/clips/chrome-extension/src/offscreen.ts +30 -4
- package/corpus/templates/clips/chrome-extension/src/recording-duration.ts +32 -0
- package/corpus/templates/slides/actions/_uploaded-files.ts +13 -2
- package/corpus/templates/slides/actions/import-docx.ts +3 -9
- package/corpus/templates/slides/actions/import-file.ts +11 -14
- package/corpus/templates/slides/actions/import-pptx.ts +3 -9
- package/corpus/templates/slides/changelog/2026-07-14-powerpoint-template-uploads-now-work-in-hosted-slides-deploy.md +6 -0
- package/corpus/templates/slides/server/handlers/uploads.ts +100 -50
- package/corpus/templates/slides/server/lib/tenant-files.ts +38 -11
- package/corpus/templates/slides/server/lib/uploaded-reference-storage.ts +108 -0
- package/dist/action.d.ts +12 -0
- package/dist/action.d.ts.map +1 -1
- package/dist/action.js +2 -0
- package/dist/action.js.map +1 -1
- package/dist/agent/production-agent.d.ts +24 -0
- package/dist/agent/production-agent.d.ts.map +1 -1
- package/dist/agent/production-agent.js +175 -18
- package/dist/agent/production-agent.js.map +1 -1
- package/dist/client/RunStuckBanner.d.ts.map +1 -1
- package/dist/client/RunStuckBanner.js +9 -5
- package/dist/client/RunStuckBanner.js.map +1 -1
- package/dist/client/sse-event-processor.d.ts.map +1 -1
- package/dist/client/sse-event-processor.js +33 -1
- package/dist/client/sse-event-processor.js.map +1 -1
- package/dist/client/use-run-stuck-detection.d.ts +4 -4
- package/dist/client/use-run-stuck-detection.d.ts.map +1 -1
- package/dist/client/use-run-stuck-detection.js +104 -19
- package/dist/client/use-run-stuck-detection.js.map +1 -1
- package/dist/coding-tools/run-code.d.ts +5 -2
- package/dist/coding-tools/run-code.d.ts.map +1 -1
- package/dist/coding-tools/run-code.js +11 -5
- package/dist/coding-tools/run-code.js.map +1 -1
- package/dist/collab/awareness.d.ts +2 -2
- package/dist/collab/awareness.d.ts.map +1 -1
- package/dist/collab/struct-routes.d.ts +1 -1
- package/dist/deploy/build.d.ts +15 -14
- package/dist/deploy/build.d.ts.map +1 -1
- package/dist/deploy/build.js +64 -23
- package/dist/deploy/build.js.map +1 -1
- package/dist/extensions/actions.d.ts.map +1 -1
- package/dist/extensions/actions.js +14 -0
- package/dist/extensions/actions.js.map +1 -1
- package/dist/extensions/url-safety.d.ts +6 -0
- package/dist/extensions/url-safety.d.ts.map +1 -1
- package/dist/extensions/url-safety.js +6 -0
- package/dist/extensions/url-safety.js.map +1 -1
- package/dist/notifications/channels.d.ts.map +1 -1
- package/dist/notifications/channels.js +38 -22
- package/dist/notifications/channels.js.map +1 -1
- package/dist/notifications/routes.d.ts +3 -3
- package/dist/observability/routes.d.ts +1 -1
- package/dist/progress/routes.d.ts +1 -1
- package/dist/resources/handlers.d.ts +3 -3
- package/dist/server/action-discovery.d.ts.map +1 -1
- package/dist/server/action-discovery.js +4 -1
- package/dist/server/action-discovery.js.map +1 -1
- package/dist/server/agent-chat/context-tools.d.ts.map +1 -1
- package/dist/server/agent-chat/context-tools.js +4 -0
- package/dist/server/agent-chat/context-tools.js.map +1 -1
- package/package.json +3 -3
|
@@ -63,10 +63,10 @@ export interface UseRunStuckDetectionOptions {
|
|
|
63
63
|
*/
|
|
64
64
|
backgroundStuckThresholdMs?: number;
|
|
65
65
|
/**
|
|
66
|
-
*
|
|
67
|
-
* fresh process heartbeats.
|
|
68
|
-
*
|
|
69
|
-
*
|
|
66
|
+
* Legacy upper bound for a claimed durable background worker that is still
|
|
67
|
+
* sending fresh process heartbeats. Informational quiet-run UI is never
|
|
68
|
+
* delayed past `backgroundStuckThresholdMs`; this option can only request an
|
|
69
|
+
* earlier notice for live workers. Default 13 minutes.
|
|
70
70
|
*/
|
|
71
71
|
liveBackgroundStuckThresholdMs?: number;
|
|
72
72
|
/** Poll interval. Default 5_000ms. */
|
|
@@ -127,6 +127,137 @@ export function useRunStuckDetection({
|
|
|
127
127
|
const base = apiUrl ?? agentNativePath("/_agent-native/agent-chat");
|
|
128
128
|
let cancelled = false;
|
|
129
129
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
130
|
+
let snapshotTransitionTimer: ReturnType<typeof setTimeout> | null = null;
|
|
131
|
+
let snapshotVersion = 0;
|
|
132
|
+
|
|
133
|
+
type RunHealthSnapshot = {
|
|
134
|
+
active: boolean;
|
|
135
|
+
runId: string | null;
|
|
136
|
+
status: string | null;
|
|
137
|
+
lastProgressAt: number | null;
|
|
138
|
+
stuckSinceMs: number | null;
|
|
139
|
+
heartbeatAt: number | null;
|
|
140
|
+
heartbeatSinceMs: number | null;
|
|
141
|
+
dispatchMode: string | null;
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const effectiveThresholdFor = (
|
|
145
|
+
dispatchMode: string | null,
|
|
146
|
+
heartbeatSinceMs: number | null,
|
|
147
|
+
) => {
|
|
148
|
+
const liveBackgroundWorker =
|
|
149
|
+
dispatchMode === "background-processing" &&
|
|
150
|
+
heartbeatSinceMs != null &&
|
|
151
|
+
heartbeatSinceMs >= 0 &&
|
|
152
|
+
heartbeatSinceMs < FRESH_BACKGROUND_HEARTBEAT_MS;
|
|
153
|
+
const serverContinued =
|
|
154
|
+
dispatchMode === "foreground-self-chain" ||
|
|
155
|
+
dispatchMode?.startsWith("background") === true;
|
|
156
|
+
return liveBackgroundWorker
|
|
157
|
+
? Math.min(backgroundStuckThresholdMs, liveBackgroundStuckThresholdMs)
|
|
158
|
+
: serverContinued
|
|
159
|
+
? backgroundStuckThresholdMs
|
|
160
|
+
: stuckThresholdMs;
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
const scheduleSnapshotTransition = (
|
|
164
|
+
snapshot: RunHealthSnapshot,
|
|
165
|
+
observedAtMs: number,
|
|
166
|
+
version: number,
|
|
167
|
+
) => {
|
|
168
|
+
if (snapshotTransitionTimer) clearTimeout(snapshotTransitionTimer);
|
|
169
|
+
snapshotTransitionTimer = null;
|
|
170
|
+
if (
|
|
171
|
+
cancelled ||
|
|
172
|
+
version !== snapshotVersion ||
|
|
173
|
+
!snapshot.active ||
|
|
174
|
+
snapshot.status !== "running" ||
|
|
175
|
+
snapshot.runId == null
|
|
176
|
+
) {
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const currentElapsedMs = Math.max(0, Date.now() - observedAtMs);
|
|
181
|
+
const currentHeartbeatSinceMs =
|
|
182
|
+
snapshot.heartbeatSinceMs == null
|
|
183
|
+
? null
|
|
184
|
+
: snapshot.heartbeatSinceMs + currentElapsedMs;
|
|
185
|
+
const currentStuckSinceMs =
|
|
186
|
+
snapshot.stuckSinceMs == null
|
|
187
|
+
? null
|
|
188
|
+
: snapshot.stuckSinceMs + currentElapsedMs;
|
|
189
|
+
const effectiveThresholdMs = effectiveThresholdFor(
|
|
190
|
+
snapshot.dispatchMode,
|
|
191
|
+
currentHeartbeatSinceMs,
|
|
192
|
+
);
|
|
193
|
+
const currentlyStuck = Boolean(
|
|
194
|
+
currentStuckSinceMs != null &&
|
|
195
|
+
currentStuckSinceMs > effectiveThresholdMs,
|
|
196
|
+
);
|
|
197
|
+
const transitionDelaysMs: number[] = [];
|
|
198
|
+
if (
|
|
199
|
+
currentHeartbeatSinceMs != null &&
|
|
200
|
+
currentHeartbeatSinceMs >= 0 &&
|
|
201
|
+
currentHeartbeatSinceMs < FRESH_BACKGROUND_HEARTBEAT_MS
|
|
202
|
+
) {
|
|
203
|
+
transitionDelaysMs.push(
|
|
204
|
+
FRESH_BACKGROUND_HEARTBEAT_MS - currentHeartbeatSinceMs + 1,
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
if (currentStuckSinceMs != null && !currentlyStuck) {
|
|
208
|
+
transitionDelaysMs.push(
|
|
209
|
+
Math.max(1, effectiveThresholdMs - currentStuckSinceMs + 1),
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
if (transitionDelaysMs.length === 0) return;
|
|
213
|
+
|
|
214
|
+
// Both ages originate from the server clock. Advance those snapshots by
|
|
215
|
+
// only a local elapsed duration, which remains safe when client/server
|
|
216
|
+
// wall clocks differ. Wake at the earlier semantic boundary, then
|
|
217
|
+
// reschedule if the other boundary is still ahead.
|
|
218
|
+
const delayMs = Math.max(1, Math.min(...transitionDelaysMs));
|
|
219
|
+
snapshotTransitionTimer = setTimeout(() => {
|
|
220
|
+
snapshotTransitionTimer = null;
|
|
221
|
+
if (cancelled || version !== snapshotVersion) return;
|
|
222
|
+
const elapsedSinceObservationMs = Math.max(
|
|
223
|
+
0,
|
|
224
|
+
Date.now() - observedAtMs,
|
|
225
|
+
);
|
|
226
|
+
const nextHeartbeatSinceMs =
|
|
227
|
+
snapshot.heartbeatSinceMs == null
|
|
228
|
+
? null
|
|
229
|
+
: snapshot.heartbeatSinceMs + elapsedSinceObservationMs;
|
|
230
|
+
const nextStuckSinceMs =
|
|
231
|
+
snapshot.stuckSinceMs == null
|
|
232
|
+
? null
|
|
233
|
+
: snapshot.stuckSinceMs + elapsedSinceObservationMs;
|
|
234
|
+
const nextEffectiveThresholdMs = effectiveThresholdFor(
|
|
235
|
+
snapshot.dispatchMode,
|
|
236
|
+
nextHeartbeatSinceMs,
|
|
237
|
+
);
|
|
238
|
+
const nextIsStuck = Boolean(
|
|
239
|
+
nextStuckSinceMs != null &&
|
|
240
|
+
nextStuckSinceMs > nextEffectiveThresholdMs,
|
|
241
|
+
);
|
|
242
|
+
setState((current) => {
|
|
243
|
+
if (
|
|
244
|
+
version !== snapshotVersion ||
|
|
245
|
+
current.runId !== snapshot.runId ||
|
|
246
|
+
current.lastProgressAt !== snapshot.lastProgressAt ||
|
|
247
|
+
current.heartbeatAt !== snapshot.heartbeatAt
|
|
248
|
+
) {
|
|
249
|
+
return current;
|
|
250
|
+
}
|
|
251
|
+
return {
|
|
252
|
+
...current,
|
|
253
|
+
isStuck: nextIsStuck,
|
|
254
|
+
stuckSinceMs: nextStuckSinceMs,
|
|
255
|
+
heartbeatSinceMs: nextHeartbeatSinceMs,
|
|
256
|
+
};
|
|
257
|
+
});
|
|
258
|
+
scheduleSnapshotTransition(snapshot, observedAtMs, version);
|
|
259
|
+
}, delayMs);
|
|
260
|
+
};
|
|
130
261
|
|
|
131
262
|
const poll = async () => {
|
|
132
263
|
if (cancelled) return;
|
|
@@ -153,34 +284,38 @@ export function useRunStuckDetection({
|
|
|
153
284
|
heartbeatAt != null ? nowMs - heartbeatAt : null;
|
|
154
285
|
const dispatchMode =
|
|
155
286
|
typeof data.dispatchMode === "string" ? data.dispatchMode : null;
|
|
156
|
-
// Server-continued runs get the wider threshold: the server's own
|
|
157
|
-
// recovery (150s no-progress backstop + chained continuations) must
|
|
158
|
-
// get its chance before the user sees a "stuck" affordance.
|
|
159
|
-
const serverContinued =
|
|
160
|
-
dispatchMode === "foreground-self-chain" ||
|
|
161
|
-
dispatchMode?.startsWith("background") === true;
|
|
162
287
|
// A claimed durable worker with a fresh heartbeat can legitimately
|
|
163
|
-
// be waiting on a bounded long-running tool/sub-agent call.
|
|
164
|
-
//
|
|
165
|
-
//
|
|
166
|
-
//
|
|
167
|
-
//
|
|
168
|
-
const
|
|
169
|
-
dispatchMode
|
|
170
|
-
heartbeatSinceMs
|
|
171
|
-
|
|
172
|
-
heartbeatSinceMs < FRESH_BACKGROUND_HEARTBEAT_MS;
|
|
173
|
-
const effectiveThresholdMs = liveBackgroundWorker
|
|
174
|
-
? liveBackgroundStuckThresholdMs
|
|
175
|
-
: serverContinued
|
|
176
|
-
? backgroundStuckThresholdMs
|
|
177
|
-
: stuckThresholdMs;
|
|
288
|
+
// be waiting on a bounded long-running tool/sub-agent call. Still
|
|
289
|
+
// surface informational status at the normal background threshold;
|
|
290
|
+
// RunStuckBanner uses the fresh heartbeat to withhold Retry while
|
|
291
|
+
// keeping an explicit Cancel available. The legacy live-worker
|
|
292
|
+
// threshold may make that notice earlier, but never later.
|
|
293
|
+
const effectiveThresholdMs = effectiveThresholdFor(
|
|
294
|
+
dispatchMode,
|
|
295
|
+
heartbeatSinceMs,
|
|
296
|
+
);
|
|
178
297
|
const isStuck = Boolean(
|
|
179
298
|
data.active &&
|
|
180
299
|
data.status === "running" &&
|
|
181
300
|
stuckSinceMs != null &&
|
|
182
301
|
stuckSinceMs > effectiveThresholdMs,
|
|
183
302
|
);
|
|
303
|
+
const observedAtMs = Date.now();
|
|
304
|
+
const version = ++snapshotVersion;
|
|
305
|
+
scheduleSnapshotTransition(
|
|
306
|
+
{
|
|
307
|
+
active: data.active,
|
|
308
|
+
runId: data.runId ?? null,
|
|
309
|
+
status: data.status ?? null,
|
|
310
|
+
lastProgressAt,
|
|
311
|
+
stuckSinceMs,
|
|
312
|
+
heartbeatAt,
|
|
313
|
+
heartbeatSinceMs,
|
|
314
|
+
dispatchMode,
|
|
315
|
+
},
|
|
316
|
+
observedAtMs,
|
|
317
|
+
version,
|
|
318
|
+
);
|
|
184
319
|
setState({
|
|
185
320
|
isStuck,
|
|
186
321
|
runId: data.runId ?? null,
|
|
@@ -217,7 +352,9 @@ export function useRunStuckDetection({
|
|
|
217
352
|
|
|
218
353
|
return () => {
|
|
219
354
|
cancelled = true;
|
|
355
|
+
snapshotVersion += 1;
|
|
220
356
|
if (timer) clearTimeout(timer);
|
|
357
|
+
if (snapshotTransitionTimer) clearTimeout(snapshotTransitionTimer);
|
|
221
358
|
};
|
|
222
359
|
}, [
|
|
223
360
|
threadId,
|
|
@@ -139,7 +139,7 @@ export function createRunCodeEntry(
|
|
|
139
139
|
" - `workspaceList(prefix?)` — list workspace files, returns [{ path, sizeBytes, contentType, updatedAt }].",
|
|
140
140
|
"Print results with `console.log()`; only stdout+stderr are returned.",
|
|
141
141
|
"Timeout defaults to 120 s (max 600 s). Output is truncated to 50 000 chars by default (max 200 000).",
|
|
142
|
-
'For LONG compute — big cross-source joins, corpus-wide sweeps, scripts that could exceed ~30 s, or anything at risk of dying with the current chat run — pass `background: true`. That enqueues a durable execution and returns `{ executionId, status: "queued" }` immediately; the code runs out-of-band with a generous budget (default 10 min) and its result survives run timeouts. Continue other work, then poll
|
|
142
|
+
'For LONG compute — big cross-source joins, corpus-wide sweeps, scripts that could exceed ~30 s, or anything at risk of dying with the current chat run — pass `background: true`. That enqueues a durable execution and returns `{ executionId, status: "queued" }` immediately; the code runs out-of-band with a generous budget (default 10 min) and its result survives run timeouts. Continue other work, then poll with `get-code-execution` when that dedicated tool is available; legacy hosts can call run-code again with just `{ executionId }`. Keep quick scripts in the default foreground mode.',
|
|
143
143
|
].join(" "),
|
|
144
144
|
parameters: {
|
|
145
145
|
type: "object",
|
|
@@ -160,12 +160,12 @@ export function createRunCodeEntry(
|
|
|
160
160
|
background: {
|
|
161
161
|
type: "boolean",
|
|
162
162
|
description:
|
|
163
|
-
'Run as a durable background execution: returns { executionId, status: "queued" } immediately and the code executes out-of-band, surviving chat-run timeouts. Use for long compute (large joins, multi-page provider sweeps, heavy analysis). Poll with
|
|
163
|
+
'Run as a durable background execution: returns { executionId, status: "queued" } immediately and the code executes out-of-band, surviving chat-run timeouts. Use for long compute (large joins, multi-page provider sweeps, heavy analysis). Poll with get-code-execution when available.',
|
|
164
164
|
},
|
|
165
165
|
executionId: {
|
|
166
166
|
type: "string",
|
|
167
167
|
description:
|
|
168
|
-
"
|
|
168
|
+
"Legacy polling fallback for hosts without get-code-execution: pass the executionId alone (no code) to get status and output.",
|
|
169
169
|
},
|
|
170
170
|
},
|
|
171
171
|
required: [],
|
|
@@ -582,12 +582,18 @@ function formatTerminalSandboxExecution(row: SandboxExecutionRow): string {
|
|
|
582
582
|
/**
|
|
583
583
|
* Standalone, access-scoped poll tool for background executions. Behaviorally
|
|
584
584
|
* identical to calling `run-code` with only `executionId`; hosts that register
|
|
585
|
-
* it as `get-code-execution` give the model a dedicated read tool (and
|
|
586
|
-
* enqueue guidance automatically points at it when present in the
|
|
585
|
+
* it as `get-code-execution` give the model a dedicated volatile read tool (and
|
|
586
|
+
* the enqueue guidance automatically points at it when present in the
|
|
587
|
+
* registry). Keep the opt-out here rather than on `run-code`: repeated normal
|
|
588
|
+
* run-code calls may execute writes or outbound requests and must retain the
|
|
589
|
+
* agent loop's default duplicate-call protection.
|
|
587
590
|
*/
|
|
588
591
|
export function createGetCodeExecutionEntry(): ActionEntry {
|
|
589
592
|
return {
|
|
590
593
|
readOnly: true,
|
|
594
|
+
// Polling with an identical executionId is the intended usage — the
|
|
595
|
+
// status changes over time, so this must not be deduped.
|
|
596
|
+
dedupe: false,
|
|
591
597
|
tool: {
|
|
592
598
|
description:
|
|
593
599
|
"Check a background run-code execution: returns its status (queued | running | succeeded | failed | timed_out) and, once finished, its stdout/stderr output. Executions are scoped to the user who started them. While one is queued or running, continue other useful work and poll every ~15-30 seconds instead of busy-waiting.",
|
|
@@ -2535,17 +2535,18 @@ function walkServerJavaScriptFiles(
|
|
|
2535
2535
|
}
|
|
2536
2536
|
|
|
2537
2537
|
/**
|
|
2538
|
-
* Nitro
|
|
2539
|
-
*
|
|
2540
|
-
*
|
|
2541
|
-
*
|
|
2538
|
+
* Nitro receives the React Router SSR build as prebuilt chunks, so its normal
|
|
2539
|
+
* dependency resolver cannot reliably fold the preserved bare `yjs` imports
|
|
2540
|
+
* into the same module instance used by core's server collaboration code.
|
|
2541
|
+
* Keep Yjs external through Nitro, bundle its complete public ESM surface once,
|
|
2542
|
+
* then point every emitted server chunk at that one portable runtime module.
|
|
2542
2543
|
*/
|
|
2543
|
-
export function
|
|
2544
|
+
export function bundleYjsRuntimeForServerlessOutput(
|
|
2544
2545
|
serverDir: string,
|
|
2546
|
+
projectCwd: string,
|
|
2545
2547
|
): string[] {
|
|
2546
2548
|
const bareImports: string[] = [];
|
|
2547
2549
|
const unsupportedSubpathImports: string[] = [];
|
|
2548
|
-
const bundledYjsPath = path.join(serverDir, "_libs", "yjs.mjs");
|
|
2549
2550
|
|
|
2550
2551
|
walkServerJavaScriptFiles(serverDir, (filePath) => {
|
|
2551
2552
|
const source = fs.readFileSync(filePath, "utf-8");
|
|
@@ -2563,11 +2564,22 @@ export function rewriteBareYjsImportsForServerlessOutput(
|
|
|
2563
2564
|
);
|
|
2564
2565
|
}
|
|
2565
2566
|
if (bareImports.length === 0) return [];
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
|
|
2569
|
-
|
|
2570
|
-
|
|
2567
|
+
|
|
2568
|
+
const bundledYjsPath = path.join(serverDir, "_libs", "yjs-runtime.mjs");
|
|
2569
|
+
fs.mkdirSync(path.dirname(bundledYjsPath), { recursive: true });
|
|
2570
|
+
execFileSync(
|
|
2571
|
+
findEsbuild(),
|
|
2572
|
+
[
|
|
2573
|
+
resolveNitroBundledYjsEntry(),
|
|
2574
|
+
"--bundle",
|
|
2575
|
+
"--format=esm",
|
|
2576
|
+
"--platform=node",
|
|
2577
|
+
"--target=node22",
|
|
2578
|
+
"--minify",
|
|
2579
|
+
`--outfile=${bundledYjsPath}`,
|
|
2580
|
+
],
|
|
2581
|
+
{ cwd: projectCwd, stdio: "pipe" },
|
|
2582
|
+
);
|
|
2571
2583
|
|
|
2572
2584
|
for (const filePath of bareImports) {
|
|
2573
2585
|
const bundledImport = path
|
|
@@ -2702,6 +2714,26 @@ export function assertSingleTemplateNetlifyBuildOutput(
|
|
|
2702
2714
|
);
|
|
2703
2715
|
}
|
|
2704
2716
|
|
|
2717
|
+
// Nitro's `_libs/yjs.mjs` is a private tree-shaken chunk, not a package
|
|
2718
|
+
// facade. Repointing a prebuilt SSR chunk at it can request public exports
|
|
2719
|
+
// (notably `Text`) that the private chunk did not retain. The controlled
|
|
2720
|
+
// serverless pass must instead target the complete `yjs-runtime.mjs` bundle.
|
|
2721
|
+
const privateYjsImports: string[] = [];
|
|
2722
|
+
walkServerJavaScriptFiles(serverDir, (filePath) => {
|
|
2723
|
+
if (
|
|
2724
|
+
/\b(?:from\s*|import\s*\(\s*|import\s*)(["'])[^"']*_libs\/yjs\.mjs\1/.test(
|
|
2725
|
+
fs.readFileSync(filePath, "utf-8"),
|
|
2726
|
+
)
|
|
2727
|
+
) {
|
|
2728
|
+
privateYjsImports.push(path.relative(projectCwd, filePath));
|
|
2729
|
+
}
|
|
2730
|
+
});
|
|
2731
|
+
if (privateYjsImports.length > 0) {
|
|
2732
|
+
failures.push(
|
|
2733
|
+
`Netlify server bundle imports Nitro's internal tree-shaken _libs/yjs.mjs: ${privateYjsImports.join(", ")}`,
|
|
2734
|
+
);
|
|
2735
|
+
}
|
|
2736
|
+
|
|
2705
2737
|
if (isDurableBackgroundDeployEnabled()) {
|
|
2706
2738
|
const backgroundDir = path.join(
|
|
2707
2739
|
internalDir,
|
|
@@ -3153,18 +3185,27 @@ const BROWSER_ONLY_SERVER_LIBS = [
|
|
|
3153
3185
|
];
|
|
3154
3186
|
|
|
3155
3187
|
/**
|
|
3156
|
-
* Dependencies
|
|
3157
|
-
*
|
|
3158
|
-
*
|
|
3159
|
-
* `yjs` is a direct core dependency, but it is deliberately externalized from
|
|
3160
|
-
* the intermediate Vite SSR graph so that Vite and Nitro do not create two
|
|
3161
|
-
* incompatible Yjs constructors. On file-traced serverless presets, leaving it
|
|
3162
|
-
* external at Nitro's final build can emit `import "yjs"` into a function
|
|
3163
|
-
* chunk without placing the package in that function's `node_modules`. Bundle
|
|
3164
|
-
* it in Nitro's final output so every template receives the one portable copy.
|
|
3188
|
+
* Dependencies Nitro itself must bundle outside the controlled serverless
|
|
3189
|
+
* output pass. Netlify, Vercel, and Lambda keep Yjs external through Nitro;
|
|
3190
|
+
* `bundleYjsRuntimeForServerlessOutput` then creates their one portable copy.
|
|
3165
3191
|
*/
|
|
3166
3192
|
export const NITRO_SERVER_RUNTIME_BUNDLED_DEPS = ["yjs"] as const;
|
|
3167
3193
|
|
|
3194
|
+
/**
|
|
3195
|
+
* Locate the core-owned ESM entry used by the controlled serverless bundling
|
|
3196
|
+
* pass. Resolving from this module keeps the build independent of whether a
|
|
3197
|
+
* template exposes core's transitive Yjs dependency at its own package root.
|
|
3198
|
+
*/
|
|
3199
|
+
export function resolveNitroBundledYjsEntry(): string {
|
|
3200
|
+
const requireFromCore = createRequire(import.meta.url);
|
|
3201
|
+
const packageDir = path.dirname(requireFromCore.resolve("yjs/package.json"));
|
|
3202
|
+
const entry = path.join(packageDir, "dist", "yjs.mjs");
|
|
3203
|
+
if (!fs.existsSync(entry)) {
|
|
3204
|
+
throw new Error(`[build] Could not resolve the Yjs ESM entry at ${entry}`);
|
|
3205
|
+
}
|
|
3206
|
+
return entry;
|
|
3207
|
+
}
|
|
3208
|
+
|
|
3168
3209
|
/**
|
|
3169
3210
|
* Edge runtimes have no node_modules, while Node/serverless outputs only need
|
|
3170
3211
|
* the small set above bundled to keep their package manifests traceable.
|
|
@@ -3175,7 +3216,11 @@ export function nitroNoExternalsForPreset(
|
|
|
3175
3216
|
return targetPreset.startsWith("cloudflare") ||
|
|
3176
3217
|
targetPreset.startsWith("deno")
|
|
3177
3218
|
? true
|
|
3178
|
-
:
|
|
3219
|
+
: targetPreset === "netlify" ||
|
|
3220
|
+
targetPreset === "vercel" ||
|
|
3221
|
+
targetPreset === "aws-lambda"
|
|
3222
|
+
? []
|
|
3223
|
+
: NITRO_SERVER_RUNTIME_BUNDLED_DEPS;
|
|
3179
3224
|
}
|
|
3180
3225
|
|
|
3181
3226
|
/**
|
|
@@ -3327,6 +3372,14 @@ export default bundle;
|
|
|
3327
3372
|
// (ReferenceError: window is not defined → every request 502s). Mirrors the
|
|
3328
3373
|
// Vite `ssrStubPlugin`, which only covers the `build/server` step.
|
|
3329
3374
|
rollupConfig: {
|
|
3375
|
+
// Nitro treats the intermediate React Router SSR files as prebuilt
|
|
3376
|
+
// chunks, while core's server collaboration files participate in the
|
|
3377
|
+
// final Rolldown graph. Externalize Yjs consistently on serverless so
|
|
3378
|
+
// both graphs retain their public import shapes; the controlled
|
|
3379
|
+
// post-build pass below bundles and rewrites them to one module.
|
|
3380
|
+
...(preset === "netlify" || preset === "vercel" || preset === "aws-lambda"
|
|
3381
|
+
? { external: ["yjs"] }
|
|
3382
|
+
: {}),
|
|
3330
3383
|
plugins: [createBrowserOnlyServerStubPlugin()],
|
|
3331
3384
|
},
|
|
3332
3385
|
...(providedPluginsNitroPlugin
|
|
@@ -3334,9 +3387,9 @@ export default bundle;
|
|
|
3334
3387
|
: {}),
|
|
3335
3388
|
routeRules: mcpEmbedStaticAssetRouteRules(appBasePath),
|
|
3336
3389
|
// Edge presets (cloudflare, deno) bundle all deps because node_modules are
|
|
3337
|
-
// unavailable at runtime. Node
|
|
3338
|
-
//
|
|
3339
|
-
//
|
|
3390
|
+
// unavailable at runtime. Ordinary Node presets bundle Yjs through Nitro.
|
|
3391
|
+
// Controlled serverless presets externalize it above, then emit one full
|
|
3392
|
+
// runtime module after Nitro has preserved every consumer's public imports.
|
|
3340
3393
|
noExternals: nitroNoExternalsForPreset(preset),
|
|
3341
3394
|
} as any);
|
|
3342
3395
|
|
|
@@ -3354,7 +3407,7 @@ export default bundle;
|
|
|
3354
3407
|
copyInstalledResvgPackages(nitro.options.output.serverDir);
|
|
3355
3408
|
copyInstalledFfmpegStaticPackage(nitro.options.output.serverDir);
|
|
3356
3409
|
sanitizeServerlessFunctionPackageManifest(nitro.options.output.serverDir);
|
|
3357
|
-
|
|
3410
|
+
bundleYjsRuntimeForServerlessOutput(nitro.options.output.serverDir, cwd);
|
|
3358
3411
|
}
|
|
3359
3412
|
|
|
3360
3413
|
// Durable background agent runs (default-OFF / opt-in; enable with a truthy
|
|
@@ -44,6 +44,15 @@ import {
|
|
|
44
44
|
type ExtensionRow,
|
|
45
45
|
} from "./store.js";
|
|
46
46
|
|
|
47
|
+
// A 200k extension body containing JSON-sensitive HTML/JS characters (quotes,
|
|
48
|
+
// backslashes, and newlines) expands to about 400k characters when pretty-JSON
|
|
49
|
+
// serialized by the agent loop. A history detail can carry the current and
|
|
50
|
+
// previous bodies plus both bodies again in its line diff (about 1.6M chars in
|
|
51
|
+
// the same worst-common-case fixture). These caps add roughly 25% headroom for
|
|
52
|
+
// the surrounding metadata and indentation while still bounding tool context.
|
|
53
|
+
const GET_EXTENSION_MAX_RESULT_CHARS = 500_000;
|
|
54
|
+
const GET_EXTENSION_HISTORY_MAX_RESULT_CHARS = 2_000_000;
|
|
55
|
+
|
|
47
56
|
export function createExtensionActionEntries(): Record<string, ActionEntry> {
|
|
48
57
|
return {
|
|
49
58
|
"list-extensions": {
|
|
@@ -185,6 +194,9 @@ export function createExtensionActionEntries(): Record<string, ActionEntry> {
|
|
|
185
194
|
),
|
|
186
195
|
};
|
|
187
196
|
},
|
|
197
|
+
// Result is JSON including the full Alpine content; account for JSON
|
|
198
|
+
// escaping and envelope metadata instead of matching the source cap.
|
|
199
|
+
maxResultChars: GET_EXTENSION_MAX_RESULT_CHARS,
|
|
188
200
|
readOnly: true,
|
|
189
201
|
},
|
|
190
202
|
|
|
@@ -276,6 +288,9 @@ export function createExtensionActionEntries(): Record<string, ActionEntry> {
|
|
|
276
288
|
),
|
|
277
289
|
};
|
|
278
290
|
},
|
|
291
|
+
// With includeContent, history can contain current + previous source and
|
|
292
|
+
// repeat both in the diff, so it needs more headroom than get-extension.
|
|
293
|
+
maxResultChars: GET_EXTENSION_HISTORY_MAX_RESULT_CHARS,
|
|
279
294
|
readOnly: true,
|
|
280
295
|
},
|
|
281
296
|
|
|
@@ -251,17 +251,27 @@ export async function createSsrfSafeDispatcher(): Promise<unknown | null> {
|
|
|
251
251
|
* `httpsOnly` extends the per-hop validation to the URL scheme: redirects are
|
|
252
252
|
* followed only to `https:` targets, so an HTTPS-only caller cannot be
|
|
253
253
|
* downgraded to plain HTTP by a 30x from the (untrusted) origin.
|
|
254
|
+
*
|
|
255
|
+
* `assertUrlAllowed` lets callers layer a stricter destination policy (for
|
|
256
|
+
* example, a credential's origin allowlist) on top of the SSRF checks. It runs
|
|
257
|
+
* before the initial request and before every redirect hop, so sensitive
|
|
258
|
+
* headers and bodies are never forwarded to a destination the caller rejects.
|
|
254
259
|
*/
|
|
255
260
|
export async function ssrfSafeFetch(
|
|
256
261
|
url: string,
|
|
257
262
|
init: RequestInit = {},
|
|
258
|
-
options: {
|
|
263
|
+
options: {
|
|
264
|
+
maxRedirects?: number;
|
|
265
|
+
httpsOnly?: boolean;
|
|
266
|
+
assertUrlAllowed?: (url: string) => void | Promise<void>;
|
|
267
|
+
} = {},
|
|
259
268
|
): Promise<Response> {
|
|
260
269
|
const maxRedirects = options.maxRedirects ?? 3;
|
|
261
270
|
const dispatcher = (await createSsrfSafeDispatcher()) ?? undefined;
|
|
262
271
|
|
|
263
272
|
let currentUrl = url;
|
|
264
273
|
for (let hop = 0; hop <= maxRedirects; hop++) {
|
|
274
|
+
await options.assertUrlAllowed?.(currentUrl);
|
|
265
275
|
if (options.httpsOnly && new URL(currentUrl).protocol !== "https:") {
|
|
266
276
|
throw new Error(
|
|
267
277
|
`SSRF blocked: refusing to fetch non-HTTPS address (${currentUrl})`,
|
|
@@ -29,9 +29,10 @@
|
|
|
29
29
|
|
|
30
30
|
import { ssrfSafeFetch } from "../extensions/url-safety.js";
|
|
31
31
|
import {
|
|
32
|
+
getKeyAllowlist,
|
|
33
|
+
getResolvedKeyAllowlist,
|
|
32
34
|
resolveKeyReferencesWithRequestScopes,
|
|
33
35
|
validateUrlAllowlist,
|
|
34
|
-
getKeyAllowlist,
|
|
35
36
|
} from "../secrets/substitution.js";
|
|
36
37
|
import { sendEmail } from "../server/email.js";
|
|
37
38
|
import { registerNotificationChannel } from "./registry.js";
|
|
@@ -72,7 +73,7 @@ function createWebhookChannel(
|
|
|
72
73
|
// No-op when neither a per-notification nor workspace URL is set —
|
|
73
74
|
// mirrors email's empty-recipients behavior so notify-all stays quiet.
|
|
74
75
|
if (!urlTemplate) return false;
|
|
75
|
-
const { url, headers } = await resolveWebhookRequest(
|
|
76
|
+
const { url, headers, assertUrlAllowed } = await resolveWebhookRequest(
|
|
76
77
|
urlTemplate,
|
|
77
78
|
overrideUrlTemplate ? undefined : authTemplate,
|
|
78
79
|
meta.owner,
|
|
@@ -92,7 +93,7 @@ function createWebhookChannel(
|
|
|
92
93
|
emittedAt: new Date().toISOString(),
|
|
93
94
|
}),
|
|
94
95
|
},
|
|
95
|
-
{ maxRedirects: 3 },
|
|
96
|
+
{ maxRedirects: 3, assertUrlAllowed },
|
|
96
97
|
);
|
|
97
98
|
if (!res.ok) {
|
|
98
99
|
throw new Error(
|
|
@@ -121,7 +122,7 @@ function createSlackWebhookChannel(
|
|
|
121
122
|
metadataString(input.metadata, "slackWebhookUrl") ??
|
|
122
123
|
envUrlTemplate?.trim();
|
|
123
124
|
if (!urlTemplate) return false;
|
|
124
|
-
const { url, headers } = await resolveWebhookRequest(
|
|
125
|
+
const { url, headers, assertUrlAllowed } = await resolveWebhookRequest(
|
|
125
126
|
urlTemplate,
|
|
126
127
|
overrideUrlTemplate ? undefined : authTemplate,
|
|
127
128
|
meta.owner,
|
|
@@ -165,7 +166,7 @@ function createSlackWebhookChannel(
|
|
|
165
166
|
],
|
|
166
167
|
}),
|
|
167
168
|
},
|
|
168
|
-
{ maxRedirects: 3 },
|
|
169
|
+
{ maxRedirects: 3, assertUrlAllowed },
|
|
169
170
|
);
|
|
170
171
|
if (!res.ok) {
|
|
171
172
|
throw new Error(
|
|
@@ -214,7 +215,11 @@ async function resolveWebhookRequest(
|
|
|
214
215
|
authTemplate: string | undefined,
|
|
215
216
|
owner: string,
|
|
216
217
|
label: string,
|
|
217
|
-
): Promise<{
|
|
218
|
+
): Promise<{
|
|
219
|
+
url: string;
|
|
220
|
+
headers: Record<string, string>;
|
|
221
|
+
assertUrlAllowed: (url: string) => void;
|
|
222
|
+
}> {
|
|
218
223
|
// Resolve `${keys.NAME}` references through the same request-scope
|
|
219
224
|
// cascade already used by extension fetches (extensions/routes.ts) and
|
|
220
225
|
// automation connector headers (automation/index.ts): user scope first
|
|
@@ -229,46 +234,72 @@ async function resolveWebhookRequest(
|
|
|
229
234
|
// strict superset of the previous user-scope-only behavior.
|
|
230
235
|
// Missing keys throw — the error surfaces in logs and the channel is marked
|
|
231
236
|
// un-delivered, but other channels still run.
|
|
232
|
-
const
|
|
237
|
+
const urlResult = await resolveKeyReferencesWithRequestScopes(
|
|
233
238
|
urlTemplate,
|
|
234
239
|
owner,
|
|
235
240
|
);
|
|
241
|
+
const url = urlResult.resolved;
|
|
236
242
|
const headers: Record<string, string> = {
|
|
237
243
|
"Content-Type": "application/json",
|
|
238
244
|
};
|
|
245
|
+
let authResult:
|
|
246
|
+
| Awaited<ReturnType<typeof resolveKeyReferencesWithRequestScopes>>
|
|
247
|
+
| undefined;
|
|
239
248
|
if (authTemplate) {
|
|
240
|
-
|
|
249
|
+
authResult = await resolveKeyReferencesWithRequestScopes(
|
|
241
250
|
authTemplate,
|
|
242
251
|
owner,
|
|
243
252
|
);
|
|
244
|
-
headers.Authorization =
|
|
253
|
+
headers.Authorization = authResult.resolved;
|
|
245
254
|
}
|
|
246
255
|
|
|
247
256
|
// If the user set an allowlist on a referenced key, enforce it here —
|
|
248
257
|
// origin-level check, same rule the automations fetch-tool applies.
|
|
249
|
-
//
|
|
250
|
-
//
|
|
251
|
-
//
|
|
252
|
-
//
|
|
253
|
-
|
|
254
|
-
const
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
)
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
keyNames.map((name) => getKeyAllowlist(name, "user", owner)),
|
|
263
|
-
);
|
|
264
|
-
keyNames.forEach((name, i) => {
|
|
265
|
-
if (!validateUrlAllowlist(url, allowlists[i])) {
|
|
266
|
-
throw new Error(
|
|
267
|
-
`[notifications] ${label} URL ${new URL(url).origin} is not in the allowlist for key "${name}"`,
|
|
258
|
+
// Validate URL and Authorization keys independently so an allowlisted auth
|
|
259
|
+
// token cannot be sent to a metadata-provided destination. Keep scope in the
|
|
260
|
+
// dedupe key because the same key name can resolve at a different scope if a
|
|
261
|
+
// credential changes between the two substitutions.
|
|
262
|
+
type ResolvedKey = NonNullable<typeof urlResult.resolvedKeys>[number];
|
|
263
|
+
const keyUsages = new Map<
|
|
264
|
+
string,
|
|
265
|
+
{ name: string; resolvedKey?: ResolvedKey }
|
|
266
|
+
>();
|
|
267
|
+
for (const result of authResult ? [urlResult, authResult] : [urlResult]) {
|
|
268
|
+
for (const name of result.usedKeys) {
|
|
269
|
+
const resolved = (result.resolvedKeys ?? []).filter(
|
|
270
|
+
(ref) => ref.name === name,
|
|
268
271
|
);
|
|
272
|
+
if (resolved.length === 0) {
|
|
273
|
+
keyUsages.set(`user:${owner}:${name}`, { name });
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
for (const resolvedKey of resolved) {
|
|
277
|
+
keyUsages.set(`${resolvedKey.scope}:${resolvedKey.scopeId}:${name}`, {
|
|
278
|
+
name,
|
|
279
|
+
resolvedKey,
|
|
280
|
+
});
|
|
281
|
+
}
|
|
269
282
|
}
|
|
270
|
-
}
|
|
271
|
-
|
|
283
|
+
}
|
|
284
|
+
const usages = Array.from(keyUsages.values());
|
|
285
|
+
const allowlists = await Promise.all(
|
|
286
|
+
usages.map(({ name, resolvedKey }) =>
|
|
287
|
+
resolvedKey
|
|
288
|
+
? getResolvedKeyAllowlist(resolvedKey)
|
|
289
|
+
: getKeyAllowlist(name, "user", owner),
|
|
290
|
+
),
|
|
291
|
+
);
|
|
292
|
+
const assertUrlAllowed = (candidateUrl: string): void => {
|
|
293
|
+
usages.forEach(({ name }, i) => {
|
|
294
|
+
if (!validateUrlAllowlist(candidateUrl, allowlists[i])) {
|
|
295
|
+
throw new Error(
|
|
296
|
+
`[notifications] ${label} URL ${new URL(candidateUrl).origin} is not in the allowlist for key "${name}"`,
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
};
|
|
301
|
+
assertUrlAllowed(url);
|
|
302
|
+
return { url, headers, assertUrlAllowed };
|
|
272
303
|
}
|
|
273
304
|
|
|
274
305
|
function metadataString(
|