@vellumai/assistant 0.12.3 → 0.12.4-dev.202609231620.a15f321
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/Dockerfile +7 -0
- package/README.md +12 -12
- package/docker/mousepad/90-vellum.gschema.override +4 -0
- package/node_modules/@vellumai/avatar-manifest/src/__tests__/notification-avatar.test.ts +48 -4
- package/node_modules/@vellumai/avatar-manifest/src/notification-avatar.ts +12 -8
- package/openapi.yaml +97 -4
- package/package.json +1 -1
- package/src/__tests__/approval-routes-http.test.ts +4 -2
- package/src/__tests__/call-routes-http.test.ts +4 -2
- package/src/__tests__/channel-readiness-discord.test.ts +8 -0
- package/src/__tests__/channel-readiness-slack-remote.test.ts +8 -0
- package/src/__tests__/config-loader-backfill.test.ts +1 -0
- package/src/__tests__/conversation-pairing.test.ts +10 -0
- package/src/__tests__/conversation-resurface-done.test.ts +289 -0
- package/src/__tests__/default-profile-catalog-fallback.test.ts +1 -0
- package/src/__tests__/gemini-provider.test.ts +31 -0
- package/src/__tests__/inference-profile-validation-jev.test.ts +17 -0
- package/src/__tests__/job-handler-registration-guard.test.ts +1 -0
- package/src/__tests__/llm-catalog-parity.test.ts +4 -0
- package/src/__tests__/llm-resolver-override-or-default.test.ts +43 -0
- package/src/__tests__/openai-responses-prompt-cache.test.ts +3 -1
- package/src/__tests__/platform-callback-registration.test.ts +46 -1
- package/src/__tests__/plugin-api-model-profiles.test.ts +7 -0
- package/src/__tests__/plugin-import-boundary-guard.test.ts +3 -0
- package/src/__tests__/pricing.test.ts +33 -0
- package/src/__tests__/provider-error-scenarios.test.ts +46 -0
- package/src/__tests__/provider-platform-proxy-integration.test.ts +45 -15
- package/src/__tests__/retry-fallback-escalation.test.ts +22 -0
- package/src/__tests__/runtime-attachment-metadata.test.ts +4 -2
- package/src/__tests__/scaffold-managed-skill-tool.test.ts +116 -66
- package/src/__tests__/secret-routes-platform-proxy.test.ts +1 -0
- package/src/__tests__/send-endpoint-busy.test.ts +4 -2
- package/src/__tests__/skill-load-inline-command.test.ts +35 -0
- package/src/__tests__/skill-load-tool.test.ts +42 -11
- package/src/__tests__/skill-tool-factory.test.ts +50 -2
- package/src/__tests__/steer-on-enqueue-question.test.ts +265 -34
- package/src/__tests__/validate-input.test.ts +73 -0
- package/src/__tests__/workspace-migration-159-rename-colliding-jev-profile-name.test.ts +197 -0
- package/src/api/ask-question-tool.ts +50 -0
- package/src/api/events/tool-result.ts +11 -0
- package/src/api/index.ts +7 -0
- package/src/api/requests/dictation.ts +2 -2
- package/src/api/responses/home.ts +27 -0
- package/src/avatar/notification-avatar.test.ts +42 -34
- package/src/avatar/notification-avatar.ts +1 -0
- package/src/calls/__tests__/voice-session-bridge.test.ts +63 -1
- package/src/calls/voice-escalation-judge.ts +6 -5
- package/src/calls/voice-screen-annotation.ts +44 -0
- package/src/calls/voice-session-bridge.ts +22 -0
- package/src/cli/commands/__tests__/plugins.test.ts +29 -1
- package/src/cli/commands/channel-verification-sessions.help.ts +12 -2
- package/src/cli/commands/platform/index.help.ts +2 -1
- package/src/cli/commands/platform/index.ts +8 -2
- package/src/cli/commands/plugins.ts +16 -0
- package/src/cli/lib/__tests__/inspect-plugin.test.ts +29 -0
- package/src/cli/lib/__tests__/plugin-catalog-cache.test.ts +92 -2
- package/src/cli/lib/__tests__/plugin-catalog-local.test.ts +15 -0
- package/src/cli/lib/__tests__/plugin-catalog-platform.test.ts +66 -2
- package/src/cli/lib/__tests__/plugin-catalog-resolve.test.ts +21 -0
- package/src/cli/lib/__tests__/plugin-catalog-visibility.test.ts +65 -0
- package/src/cli/lib/__tests__/plugin-details.test.ts +43 -0
- package/src/cli/lib/__tests__/plugins-install-offline.test.ts +26 -0
- package/src/cli/lib/bundled-marketplace.json +4 -4
- package/src/cli/lib/inspect-plugin.ts +5 -0
- package/src/cli/lib/plugin-catalog-cache.ts +41 -4
- package/src/cli/lib/plugin-catalog-local.ts +9 -0
- package/src/cli/lib/plugin-catalog-platform.ts +48 -9
- package/src/cli/lib/plugin-catalog-visibility.ts +41 -0
- package/src/cli/lib/plugin-details.ts +10 -7
- package/src/cli/lib/search-plugins.ts +2 -0
- package/src/config/__tests__/default-profile-catalog.test.ts +61 -1
- package/src/config/__tests__/jev-managed-profile.test.ts +82 -0
- package/src/config/__tests__/sync-gated-profiles.test.ts +71 -0
- package/src/config/bundled-skills/screen-annotation/SKILL.md +6 -3
- package/src/config/call-site-defaults.ts +17 -11
- package/src/config/default-profile-catalog.ts +118 -19
- package/src/config/default-profile-names.ts +69 -0
- package/src/config/feature-flag-registry.json +25 -9
- package/src/config/inference-profile-validation.ts +7 -6
- package/src/config/llm-resolver.ts +16 -0
- package/src/config/profile-text-generation.test.ts +9 -0
- package/src/config/schemas/call-site-catalog.ts +8 -1
- package/src/config/schemas/llm.ts +70 -9
- package/src/config/sidebar-done-gate.ts +27 -0
- package/src/config/sync-gated-profiles.ts +96 -44
- package/src/daemon/__tests__/auto-profile-router.test.ts +228 -0
- package/src/daemon/auto-profile-router.ts +220 -0
- package/src/daemon/conversation-agent-loop.ts +77 -4
- package/src/daemon/conversation-slash.ts +2 -2
- package/src/daemon/conversation-turn-finalize.ts +12 -0
- package/src/daemon/dictation-text-processing.ts +4 -4
- package/src/daemon/handlers/__tests__/conversation-history-search.test.ts +61 -0
- package/src/daemon/handlers/config-channels.ts +3 -1
- package/src/daemon/handlers/conversation-history.ts +11 -1
- package/src/daemon/handlers/conversations.ts +118 -10
- package/src/daemon/pkb-reminder-builder.test.ts +10 -3
- package/src/daemon/pkb-reminder-builder.ts +1 -1
- package/src/desktop/desktop-browser-endpoint.ts +1 -0
- package/src/home/feed-types.ts +2 -0
- package/src/inbound/platform-callback-registration.ts +19 -6
- package/src/ipc/routes/__tests__/conversation-sync-ipc-routes.test.ts +49 -1
- package/src/ipc/routes/conversation-sync-ipc-routes.ts +38 -2
- package/src/live-voice/__tests__/live-voice-agent-turn.test.ts +27 -0
- package/src/live-voice/__tests__/protocol.test.ts +22 -0
- package/src/live-voice/continuation-judge.ts +1 -1
- package/src/live-voice/live-voice-session.ts +7 -6
- package/src/live-voice/protocol.ts +16 -5
- package/src/messaging/providers/gmail/client.ts +4 -0
- package/src/notifications/README.md +11 -3
- package/src/notifications/__tests__/home-feed-side-effect.test.ts +87 -3
- package/src/notifications/conversation-pairing.ts +23 -8
- package/src/notifications/home-feed-mirror.ts +39 -0
- package/src/notifications/home-feed-side-effect.ts +46 -19
- package/src/oauth/AGENTS.md +7 -1
- package/src/permissions/question-prompter.ts +14 -4
- package/src/persistence/conversation-crud.ts +114 -3
- package/src/persistence/conversation-queries.ts +48 -3
- package/src/persistence/embeddings/__tests__/embedding-local-lifecycle.test.ts +228 -32
- package/src/persistence/embeddings/embedding-backend-shutdown.test.ts +47 -0
- package/src/persistence/embeddings/embedding-backend.ts +23 -41
- package/src/persistence/embeddings/embedding-gemini.test.ts +17 -0
- package/src/persistence/embeddings/embedding-gemini.ts +3 -1
- package/src/persistence/embeddings/embedding-local.ts +72 -17
- package/src/persistence/embeddings/embedding-types.ts +0 -19
- package/src/persistence/jobs-store.ts +229 -0
- package/src/persistence/migrations/383-normalize-opencode-host-connections.ts +44 -0
- package/src/persistence/migrations/__tests__/383-normalize-opencode-host-connections.test.ts +102 -0
- package/src/persistence/steps.ts +12 -0
- package/src/platform/sync-avatar.test.ts +29 -0
- package/src/plugin-api/model-profiles.ts +5 -3
- package/src/plugins/defaults/memory/AGENTS.md +5 -3
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-skill-card.test.ts +2 -0
- package/src/plugins/defaults/memory/__tests__/skill-update-receipt-boundary-guard.test.ts +45 -0
- package/src/plugins/defaults/memory/__tests__/skill-update-receipt-job.test.ts +580 -0
- package/src/plugins/defaults/memory/graph/tools.ts +9 -5
- package/src/plugins/defaults/memory/hooks/conversation-deleted.ts +4 -0
- package/src/plugins/defaults/memory/job-handlers.ts +8 -2
- package/src/plugins/defaults/memory/memory-retrospective-skill-card.ts +5 -0
- package/src/plugins/defaults/memory/skill-update-receipt-job.ts +334 -0
- package/src/plugins/defaults/memory/substrate/skill-content.ts +1 -1
- package/src/plugins/defaults/memory/tools.test.ts +17 -0
- package/src/plugins/defaults/memory/v3/__tests__/render-injection.test.ts +3 -0
- package/src/providers/__tests__/connection-model-compat.test.ts +2 -0
- package/src/providers/__tests__/search-provider-catalog.test.ts +8 -0
- package/src/providers/anthropic/client.ts +2 -2
- package/src/providers/gemini/client.ts +17 -1
- package/src/providers/inference/auth.ts +25 -0
- package/src/providers/jev/ask.test.ts +66 -0
- package/src/{calls/typesafe-noul.ts → providers/jev/ask.ts} +75 -36
- package/src/providers/malformed-tool-call.ts +55 -0
- package/src/providers/model-catalog.ts +257 -4
- package/src/providers/openai/__tests__/chat-completions-malformed-tool-call.test.ts +105 -0
- package/src/providers/openai/chat-completions-provider.ts +30 -0
- package/src/providers/openai/codex-models.ts +2 -0
- package/src/providers/platform-proxy/constants.ts +5 -0
- package/src/providers/retry.ts +68 -36
- package/src/providers/search-provider-catalog.ts +10 -0
- package/src/providers/vellum-model-routing.test.ts +1 -0
- package/src/runtime/__tests__/agent-wake-archived-gate.test.ts +90 -0
- package/src/runtime/__tests__/question-resolution.test.ts +32 -0
- package/src/runtime/agent-wake.ts +9 -2
- package/src/runtime/auth/__tests__/middleware.test.ts +138 -0
- package/src/runtime/auth/middleware.ts +15 -11
- package/src/runtime/middleware/auth.ts +7 -1
- package/src/runtime/pending-interactions.ts +8 -8
- package/src/runtime/question-resolution.ts +54 -8
- package/src/runtime/routes/__tests__/conversation-list-routes.test.ts +146 -0
- package/src/runtime/routes/__tests__/dictation-processing.test.ts +174 -0
- package/src/runtime/routes/__tests__/global-search-routes.test.ts +74 -1
- package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +60 -0
- package/src/runtime/routes/__tests__/llm-call-sites-routes.test.ts +16 -1
- package/src/runtime/routes/__tests__/plugins-routes.test.ts +8 -0
- package/src/runtime/routes/conversation-list-routes.ts +16 -14
- package/src/runtime/routes/conversation-query-routes.ts +4 -0
- package/src/runtime/routes/conversation-routes.ts +4 -2
- package/src/runtime/routes/diagnostics-routes.ts +66 -229
- package/src/runtime/routes/global-search-routes.ts +22 -7
- package/src/runtime/routes/inference-provider-connection-routes.ts +25 -8
- package/src/runtime/routes/llm-call-sites-routes.ts +19 -10
- package/src/runtime/routes/platform-routes.ts +6 -1
- package/src/runtime/routes/plugins-routes.ts +5 -2
- package/src/runtime/sync/documents-sidecar-publish.test.ts +50 -4
- package/src/runtime/sync/resource-sync-events.ts +11 -0
- package/src/runtime/sync/worker-daemon-notify.test.ts +30 -0
- package/src/runtime/sync/worker-daemon-notify.ts +44 -0
- package/src/runtime/verification-outbound-actions.ts +2 -2
- package/src/skills/validate-input.ts +75 -11
- package/src/telegram/__tests__/webhook-health.test.ts +8 -0
- package/src/tools/ask-question/ask-question-tool.test.ts +38 -0
- package/src/tools/ask-question/ask-question-tool.ts +12 -1
- package/src/tools/network/__tests__/web-fetch-firecrawl.test.ts +28 -1
- package/src/tools/network/__tests__/web-fetch-metadata.test.ts +34 -0
- package/src/tools/network/__tests__/web-fetch-tinyfish.test.ts +35 -1
- package/src/tools/network/__tests__/web-search.test.ts +96 -0
- package/src/tools/network/web-fetch.ts +30 -9
- package/src/tools/network/web-search.ts +235 -4
- package/src/tools/skills/execute.ts +1 -1
- package/src/tools/skills/format-input-schema.ts +15 -0
- package/src/tools/skills/load.ts +15 -43
- package/src/tools/skills/scaffold-managed.ts +86 -71
- package/src/tools/skills/skill-tool-factory.ts +2 -1
- package/src/tools/workflows/manage-workflows.ts +4 -2
- package/src/util/worker-pipe.test.ts +146 -0
- package/src/util/worker-pipe.ts +49 -0
- package/src/workspace/migrations/158-repoint-opencode-host-provider-fragments.ts +163 -0
- package/src/workspace/migrations/159-rename-colliding-jev-profile-name.ts +344 -0
- package/src/workspace/migrations/__tests__/158-repoint-opencode-host-provider-fragments.test.ts +188 -0
- package/src/workspace/migrations/registry.ts +4 -0
- package/src/runtime/routes/__tests__/dictation-cleanup-guard.test.ts +0 -107
|
@@ -12,6 +12,10 @@ import { partialTagSuffix as sharedPartialTagSuffix } from "../../util/think-tag
|
|
|
12
12
|
import { clampProviderString } from "../content-block-size.js";
|
|
13
13
|
import { fileBlockToProviderText } from "../file-block-text.js";
|
|
14
14
|
import { requestSupportsInlineAudio } from "../inline-audio-support.js";
|
|
15
|
+
import {
|
|
16
|
+
isMalformedToolCallFinishReason,
|
|
17
|
+
malformedToolCallError,
|
|
18
|
+
} from "../malformed-tool-call.js";
|
|
15
19
|
import {
|
|
16
20
|
base64Source,
|
|
17
21
|
mediaSourceByteLength,
|
|
@@ -1150,6 +1154,7 @@ export class OpenAIChatCompletionsProvider implements Provider {
|
|
|
1150
1154
|
>();
|
|
1151
1155
|
const toolProgress = createToolProgressEmitter(onEvent);
|
|
1152
1156
|
let finishReason = "unknown";
|
|
1157
|
+
let upstreamFinishReason: string | undefined;
|
|
1153
1158
|
let responseModel = modelOverride ?? this.model;
|
|
1154
1159
|
let promptTokens = 0;
|
|
1155
1160
|
let completionTokens = 0;
|
|
@@ -1318,6 +1323,14 @@ export class OpenAIChatCompletionsProvider implements Provider {
|
|
|
1318
1323
|
if (choice.finish_reason) {
|
|
1319
1324
|
finishReason = choice.finish_reason;
|
|
1320
1325
|
}
|
|
1326
|
+
// OpenRouter normalizes upstream finish reasons and carries the
|
|
1327
|
+
// raw value in `native_finish_reason`.
|
|
1328
|
+
const nativeFinishReason = (
|
|
1329
|
+
choice as { native_finish_reason?: string | null }
|
|
1330
|
+
).native_finish_reason;
|
|
1331
|
+
if (nativeFinishReason) {
|
|
1332
|
+
upstreamFinishReason = nativeFinishReason;
|
|
1333
|
+
}
|
|
1321
1334
|
}
|
|
1322
1335
|
|
|
1323
1336
|
if (chunk.usage) {
|
|
@@ -1353,6 +1366,23 @@ export class OpenAIChatCompletionsProvider implements Provider {
|
|
|
1353
1366
|
cleanupTimeout();
|
|
1354
1367
|
}
|
|
1355
1368
|
|
|
1369
|
+
const malformedFinishReason = [upstreamFinishReason, finishReason].find(
|
|
1370
|
+
isMalformedToolCallFinishReason,
|
|
1371
|
+
);
|
|
1372
|
+
if (malformedFinishReason) {
|
|
1373
|
+
log.warn(
|
|
1374
|
+
{
|
|
1375
|
+
provider: this.name,
|
|
1376
|
+
model: responseModel,
|
|
1377
|
+
finishReason,
|
|
1378
|
+
upstreamFinishReason,
|
|
1379
|
+
streamedTextLength: contentText.length,
|
|
1380
|
+
},
|
|
1381
|
+
"Response ended on a malformed tool call",
|
|
1382
|
+
);
|
|
1383
|
+
throw malformedToolCallError(this.name, malformedFinishReason);
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1356
1386
|
if (this.parseThinkTags && pendingContent) {
|
|
1357
1387
|
flushPendingContent(true);
|
|
1358
1388
|
}
|
|
@@ -56,6 +56,11 @@ export const PLATFORM_PROVIDER_META: Record<string, ManagedProviderMeta> = {
|
|
|
56
56
|
managed: true,
|
|
57
57
|
proxyPath: "/v1/runtime-proxy/vellum",
|
|
58
58
|
},
|
|
59
|
+
typesafe: {
|
|
60
|
+
name: "typesafe",
|
|
61
|
+
managed: true,
|
|
62
|
+
proxyPath: "/v1/runtime-proxy/typesafe",
|
|
63
|
+
},
|
|
59
64
|
poolside: {
|
|
60
65
|
name: "poolside",
|
|
61
66
|
managed: false,
|
package/src/providers/retry.ts
CHANGED
|
@@ -36,6 +36,7 @@ import {
|
|
|
36
36
|
tryAcquireRecoveryProbe,
|
|
37
37
|
} from "./fallback-breaker.js";
|
|
38
38
|
import { resolveLogitBiasPreset } from "./inference/logit-bias.js";
|
|
39
|
+
import { MALFORMED_TOOL_CALL_MESSAGE } from "./malformed-tool-call.js";
|
|
39
40
|
import {
|
|
40
41
|
isAdaptiveThinkingOnlyModel,
|
|
41
42
|
isAdaptiveThinkingUnsupportedModel,
|
|
@@ -165,18 +166,22 @@ const RETRYABLE_STREAM_PATTERNS = [
|
|
|
165
166
|
// Anthropic client salvages most of these into a `_raw`-wrapped tool call
|
|
166
167
|
// before they surface (see anthropic/stream-content-shadow.ts); ones that
|
|
167
168
|
// still reach here retry with a corrective note
|
|
168
|
-
// (`
|
|
169
|
-
//
|
|
170
|
-
//
|
|
169
|
+
// (`correctiveRetryHint`) because the malformation can be conditioned on
|
|
170
|
+
// the request context: a byte-identical resend can reproduce it
|
|
171
|
+
// indefinitely.
|
|
171
172
|
UNPARSEABLE_TOOL_ARGS_SDK_MESSAGE,
|
|
173
|
+
// The response ended on a tool call the model emitted but that did not
|
|
174
|
+
// parse (Gemini `MALFORMED_FUNCTION_CALL` / `UNEXPECTED_TOOL_CALL`). Its
|
|
175
|
+
// text is the broken call's tail, so it retries with a corrective note too.
|
|
176
|
+
MALFORMED_TOOL_CALL_MESSAGE,
|
|
172
177
|
];
|
|
173
178
|
|
|
174
179
|
/**
|
|
175
|
-
* One-shot
|
|
176
|
-
*
|
|
177
|
-
* message: the request tail sits
|
|
178
|
-
* hint costs no cache reuse (a
|
|
179
|
-
* cached prefix).
|
|
180
|
+
* One-shot notes appended to a retried request after the model's output was
|
|
181
|
+
* malformed in a way a byte-identical resend can reproduce. Appended as a
|
|
182
|
+
* trailing text block on the latest user message: the request tail sits
|
|
183
|
+
* after every prompt-cache anchor, so the hint costs no cache reuse (a
|
|
184
|
+
* system-prompt edit would invalidate the whole cached prefix).
|
|
180
185
|
*/
|
|
181
186
|
const UNPARSEABLE_TOOL_ARGS_RETRY_HINT =
|
|
182
187
|
"[assistant runtime] The previous attempt at this response was discarded: " +
|
|
@@ -185,23 +190,51 @@ const UNPARSEABLE_TOOL_ARGS_RETRY_HINT =
|
|
|
185
190
|
"every string value double-quoted, including values that begin with '[' " +
|
|
186
191
|
"or '{'.";
|
|
187
192
|
|
|
188
|
-
|
|
193
|
+
const MALFORMED_TOOL_CALL_RETRY_HINT =
|
|
194
|
+
"[assistant runtime] The previous attempt at this response was discarded: " +
|
|
195
|
+
"it ended on a tool call that did not parse. Respond again. To call a " +
|
|
196
|
+
"tool, emit one complete, well-formed tool call through the tool-calling " +
|
|
197
|
+
"interface, never as text.";
|
|
198
|
+
|
|
199
|
+
const CORRECTIVE_RETRY_HINTS: ReadonlyArray<{
|
|
200
|
+
pattern: string;
|
|
201
|
+
hint: string;
|
|
202
|
+
}> = [
|
|
203
|
+
{
|
|
204
|
+
pattern: UNPARSEABLE_TOOL_ARGS_SDK_MESSAGE,
|
|
205
|
+
hint: UNPARSEABLE_TOOL_ARGS_RETRY_HINT,
|
|
206
|
+
},
|
|
207
|
+
{
|
|
208
|
+
pattern: MALFORMED_TOOL_CALL_MESSAGE,
|
|
209
|
+
hint: MALFORMED_TOOL_CALL_RETRY_HINT,
|
|
210
|
+
},
|
|
211
|
+
];
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* The corrective note a retry of `error` carries, or `undefined` when the
|
|
215
|
+
* error is not a request-conditioned output malformation. Only status-less
|
|
216
|
+
* provider errors qualify: those come from content the upstream streamed
|
|
217
|
+
* after accepting the request.
|
|
218
|
+
*/
|
|
219
|
+
function correctiveRetryHint(error: unknown): string | undefined {
|
|
189
220
|
if (!(error instanceof ProviderError)) {
|
|
190
|
-
return
|
|
221
|
+
return undefined;
|
|
191
222
|
}
|
|
192
223
|
if (error.statusCode !== undefined) {
|
|
193
|
-
return
|
|
224
|
+
return undefined;
|
|
194
225
|
}
|
|
195
|
-
return
|
|
226
|
+
return CORRECTIVE_RETRY_HINTS.find(({ pattern }) =>
|
|
227
|
+
error.message.includes(pattern),
|
|
228
|
+
)?.hint;
|
|
196
229
|
}
|
|
197
230
|
|
|
198
231
|
/**
|
|
199
|
-
* Copy of `messages` with
|
|
200
|
-
*
|
|
201
|
-
*
|
|
202
|
-
*
|
|
232
|
+
* Copy of `messages` with `hint` appended to the latest user message. When
|
|
233
|
+
* the request doesn't end on a user message (assistant prefill), returns
|
|
234
|
+
* `messages` unchanged, since appending anything there would change prefill
|
|
235
|
+
* semantics.
|
|
203
236
|
*/
|
|
204
|
-
function
|
|
237
|
+
function withRetryHint(messages: Message[], hint: string): Message[] {
|
|
205
238
|
const last = messages[messages.length - 1];
|
|
206
239
|
if (last === undefined || last.role !== "user") {
|
|
207
240
|
return messages;
|
|
@@ -210,10 +243,7 @@ function withUnparseableToolArgsHint(messages: Message[]): Message[] {
|
|
|
210
243
|
...messages.slice(0, -1),
|
|
211
244
|
{
|
|
212
245
|
...last,
|
|
213
|
-
content: [
|
|
214
|
-
...last.content,
|
|
215
|
-
{ type: "text", text: UNPARSEABLE_TOOL_ARGS_RETRY_HINT },
|
|
216
|
-
],
|
|
246
|
+
content: [...last.content, { type: "text", text: hint }],
|
|
217
247
|
},
|
|
218
248
|
];
|
|
219
249
|
}
|
|
@@ -1393,12 +1423,10 @@ export class RetryProvider implements Provider {
|
|
|
1393
1423
|
// outage the route had nothing to do with. Skipped when the hint
|
|
1394
1424
|
// has nowhere to go (an assistant prefill tail), since an
|
|
1395
1425
|
// unchanged resend would only cost another round trip.
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
isUnparseableToolArgsError(error)
|
|
1399
|
-
) {
|
|
1426
|
+
const hint = correctiveRetryHint(error);
|
|
1427
|
+
if (!correctiveResendAttempted && hint !== undefined) {
|
|
1400
1428
|
correctiveResendAttempted = true;
|
|
1401
|
-
const repaired =
|
|
1429
|
+
const repaired = withRetryHint(messages, hint);
|
|
1402
1430
|
if (repaired !== messages) {
|
|
1403
1431
|
messagesForAttempt = repaired;
|
|
1404
1432
|
continue;
|
|
@@ -1522,12 +1550,13 @@ export class RetryProvider implements Provider {
|
|
|
1522
1550
|
}
|
|
1523
1551
|
|
|
1524
1552
|
if (retryAttempt < DEFAULT_MAX_RETRIES && isRetryableError(error)) {
|
|
1525
|
-
//
|
|
1526
|
-
//
|
|
1527
|
-
//
|
|
1528
|
-
//
|
|
1529
|
-
|
|
1530
|
-
|
|
1553
|
+
// A malformed tool call is conditioned on the request, so resend
|
|
1554
|
+
// with the corrective note. Built from the original `messages`
|
|
1555
|
+
// each time, so the note appears exactly once no matter how many
|
|
1556
|
+
// attempts fail this way.
|
|
1557
|
+
const hint = correctiveRetryHint(error);
|
|
1558
|
+
if (hint !== undefined) {
|
|
1559
|
+
messagesForAttempt = withRetryHint(messages, hint);
|
|
1531
1560
|
}
|
|
1532
1561
|
// Prefer server-provided Retry-After; fall back to exponential backoff.
|
|
1533
1562
|
const { delay, retryAfterHeader } = retryPlan(error, retryAttempt);
|
|
@@ -1587,9 +1616,11 @@ export class RetryProvider implements Provider {
|
|
|
1587
1616
|
) {
|
|
1588
1617
|
fallbackAttempted = true;
|
|
1589
1618
|
// What the failure indicts: the whole upstream for an outage, only
|
|
1590
|
-
// this model for a retirement or rename.
|
|
1619
|
+
// this model for a retirement or rename. A malformed output that
|
|
1620
|
+
// takes a corrective hint indicts neither: it is conditioned on this
|
|
1621
|
+
// request, so the backup may serve it without diverting the route.
|
|
1591
1622
|
const failedRoute =
|
|
1592
|
-
breakerRoute === null
|
|
1623
|
+
breakerRoute === null || correctiveRetryHint(error) !== undefined
|
|
1593
1624
|
? null
|
|
1594
1625
|
: failureBreakerRoute(breakerRoute, error);
|
|
1595
1626
|
let failureObservation: BreakerObservation | undefined;
|
|
@@ -1730,8 +1761,9 @@ export class RetryProvider implements Provider {
|
|
|
1730
1761
|
}
|
|
1731
1762
|
// Built from the original `messages` each time, so the corrective note
|
|
1732
1763
|
// appears exactly once however many attempts fail this way.
|
|
1733
|
-
|
|
1734
|
-
|
|
1764
|
+
const hint = correctiveRetryHint(error);
|
|
1765
|
+
if (hint !== undefined) {
|
|
1766
|
+
messagesForAttempt = withRetryHint(messages, hint);
|
|
1735
1767
|
}
|
|
1736
1768
|
const { delay, retryAfterHeader } = retryPlan(error, attempt);
|
|
1737
1769
|
log.warn(
|
|
@@ -187,6 +187,16 @@ export const SEARCH_PROVIDER_CATALOG: readonly SearchProviderCatalogEntry[] = [
|
|
|
187
187
|
supportsApiBase: true,
|
|
188
188
|
defaultApiBase: "https://api.search.tinyfish.ai",
|
|
189
189
|
},
|
|
190
|
+
{
|
|
191
|
+
id: "exa",
|
|
192
|
+
displayName: "Exa",
|
|
193
|
+
kind: "byok",
|
|
194
|
+
apiKeyPrefix: "Exa API key...",
|
|
195
|
+
envVar: "EXA_API_KEY",
|
|
196
|
+
secretKey: "exa",
|
|
197
|
+
fallbackOrder: 9,
|
|
198
|
+
privacyPolicyUrl: "https://exa.ai/privacy-policy",
|
|
199
|
+
},
|
|
190
200
|
];
|
|
191
201
|
|
|
192
202
|
/** Provider ids accepted by the web-search config schema. */
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `sidebar-done` gate on the wake's archived-conversation rejection.
|
|
3
|
+
*
|
|
4
|
+
* `defaultResolveTarget` is module-private, so it is exercised through
|
|
5
|
+
* `wakeAgentForOpportunity()` called without explicit deps. The hydrate step
|
|
6
|
+
* after the archived check is stubbed to throw, which the resolver catches
|
|
7
|
+
* and reports as `not_found` — a flag-on result of `not_found` therefore
|
|
8
|
+
* proves the wake got PAST the archived check and tried to hydrate, while a
|
|
9
|
+
* result of `archived` proves it did not.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { beforeEach, describe, expect, mock, test } from "bun:test";
|
|
13
|
+
|
|
14
|
+
let sidebarDoneEnabled = false;
|
|
15
|
+
mock.module("../../config/sidebar-done-gate.js", () => ({
|
|
16
|
+
SIDEBAR_DONE_FLAG_KEY: "sidebar-done",
|
|
17
|
+
isSidebarDoneEnabled: () => sidebarDoneEnabled,
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
const ARCHIVED_CONVERSATION_ID = "conv-archived-wake";
|
|
21
|
+
|
|
22
|
+
const getConversationMock = mock((id: string) =>
|
|
23
|
+
id === ARCHIVED_CONVERSATION_ID
|
|
24
|
+
? { id, archivedAt: 1_700_000_000_000 }
|
|
25
|
+
: undefined,
|
|
26
|
+
);
|
|
27
|
+
const actualCrud = await import("../../persistence/conversation-crud.js");
|
|
28
|
+
mock.module("../../persistence/conversation-crud.js", () => ({
|
|
29
|
+
...actualCrud,
|
|
30
|
+
getConversation: getConversationMock,
|
|
31
|
+
}));
|
|
32
|
+
|
|
33
|
+
const getOrCreateConversationMock = mock(async () => {
|
|
34
|
+
throw new Error("hydrate stub");
|
|
35
|
+
});
|
|
36
|
+
const actualStore = await import("../../daemon/conversation-store.js");
|
|
37
|
+
mock.module("../../daemon/conversation-store.js", () => ({
|
|
38
|
+
...actualStore,
|
|
39
|
+
getOrCreateConversation: getOrCreateConversationMock,
|
|
40
|
+
}));
|
|
41
|
+
|
|
42
|
+
import { wakeAgentForOpportunity } from "../agent-wake.js";
|
|
43
|
+
|
|
44
|
+
describe("agent-wake archived conversations under sidebar-done", () => {
|
|
45
|
+
beforeEach(() => {
|
|
46
|
+
getConversationMock.mockClear();
|
|
47
|
+
getOrCreateConversationMock.mockClear();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("rejects the wake with reason archived when the flag is off", async () => {
|
|
51
|
+
sidebarDoneEnabled = false;
|
|
52
|
+
|
|
53
|
+
const result = await wakeAgentForOpportunity({
|
|
54
|
+
conversationId: ARCHIVED_CONVERSATION_ID,
|
|
55
|
+
hint: "scheduled run",
|
|
56
|
+
source: "schedule",
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
expect(result.invoked).toBe(false);
|
|
60
|
+
expect(result.reason).toBe("archived");
|
|
61
|
+
expect(getOrCreateConversationMock).not.toHaveBeenCalled();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("proceeds past the archived check when the flag is on", async () => {
|
|
65
|
+
sidebarDoneEnabled = true;
|
|
66
|
+
|
|
67
|
+
const result = await wakeAgentForOpportunity({
|
|
68
|
+
conversationId: ARCHIVED_CONVERSATION_ID,
|
|
69
|
+
hint: "scheduled run",
|
|
70
|
+
source: "schedule",
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
expect(result.reason).not.toBe("archived");
|
|
74
|
+
expect(getOrCreateConversationMock).toHaveBeenCalledTimes(1);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("still reports not_found for a conversation that does not exist", async () => {
|
|
78
|
+
sidebarDoneEnabled = true;
|
|
79
|
+
|
|
80
|
+
const result = await wakeAgentForOpportunity({
|
|
81
|
+
conversationId: "conv-missing",
|
|
82
|
+
hint: "scheduled run",
|
|
83
|
+
source: "schedule",
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
expect(result.invoked).toBe(false);
|
|
87
|
+
expect(result.reason).toBe("not_found");
|
|
88
|
+
expect(getOrCreateConversationMock).not.toHaveBeenCalled();
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -73,6 +73,38 @@ describe("resolvePendingQuestion", () => {
|
|
|
73
73
|
});
|
|
74
74
|
});
|
|
75
75
|
|
|
76
|
+
test("chat_reply: the typed text answers the first question, the rest stay unanswered", () => {
|
|
77
|
+
const resolved: QuestionPromptResult[] = [];
|
|
78
|
+
registerQuestion(
|
|
79
|
+
"req-1",
|
|
80
|
+
[
|
|
81
|
+
{ id: "q1", options: ["a"] },
|
|
82
|
+
{ id: "q2", options: ["b"] },
|
|
83
|
+
],
|
|
84
|
+
(v) => resolved.push(v as QuestionPromptResult),
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
const outcome = resolvePendingQuestion("req-1", {
|
|
88
|
+
kind: "chat_reply",
|
|
89
|
+
text: "tuesday, and keep it short",
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
expect(outcome.status).toBe("resolved");
|
|
93
|
+
expect(resolved[0]).toEqual({
|
|
94
|
+
overall: "completed",
|
|
95
|
+
entries: [
|
|
96
|
+
{
|
|
97
|
+
questionId: "q1",
|
|
98
|
+
decision: "free_text",
|
|
99
|
+
text: "tuesday, and keep it short",
|
|
100
|
+
},
|
|
101
|
+
{ questionId: "q2", decision: "skipped" },
|
|
102
|
+
],
|
|
103
|
+
answeredInChat: true,
|
|
104
|
+
});
|
|
105
|
+
expect(pendingInteractions.get("req-1")).toBeUndefined();
|
|
106
|
+
});
|
|
107
|
+
|
|
76
108
|
test("not_found: unknown or wrong-kind requestId leaves nothing resolved", () => {
|
|
77
109
|
expect(resolvePendingQuestion("missing", { kind: "close" })).toEqual({
|
|
78
110
|
status: "not_found",
|
|
@@ -84,6 +84,7 @@ import {
|
|
|
84
84
|
} from "../config/llm-resolver.js";
|
|
85
85
|
import { getConfig } from "../config/loader.js";
|
|
86
86
|
import type { LLMCallSite } from "../config/schemas/llm.js";
|
|
87
|
+
import { isSidebarDoneEnabled } from "../config/sidebar-done-gate.js";
|
|
87
88
|
import { conversationSupportsDynamicUi } from "../daemon/channel-ui-capability.js";
|
|
88
89
|
import type { Conversation } from "../daemon/conversation.js";
|
|
89
90
|
import type { QueueDrainReason } from "../daemon/conversation-queue-manager.js";
|
|
@@ -506,7 +507,8 @@ export interface WakeDeps {
|
|
|
506
507
|
/**
|
|
507
508
|
* Resolve the live {@link Conversation} for a wake invocation.
|
|
508
509
|
* Returns `null` if the conversation doesn't exist, `"archived"` if it
|
|
509
|
-
* exists but is archived
|
|
510
|
+
* exists but is archived and the `sidebar-done` gate is off, or the
|
|
511
|
+
* `Conversation` to proceed with the wake.
|
|
510
512
|
*
|
|
511
513
|
* Receives the full {@link WakeOptions} so the default resolver can
|
|
512
514
|
* thread `trustContext` into `getOrCreateConversation`. Without that
|
|
@@ -546,7 +548,12 @@ async function defaultResolveTarget(
|
|
|
546
548
|
if (!existing) {
|
|
547
549
|
return null;
|
|
548
550
|
}
|
|
549
|
-
|
|
551
|
+
// Under the `sidebar-done` gate an archived conversation is one the user
|
|
552
|
+
// marked Done, not one they half-deleted: its schedules, channel threads
|
|
553
|
+
// and background tools keep working, and the first message the wake
|
|
554
|
+
// produces for the user brings the conversation back to the list
|
|
555
|
+
// (`resurfaceArchivedConversation`).
|
|
556
|
+
if (existing.archivedAt != null && !isSidebarDoneEnabled()) {
|
|
550
557
|
log.info(
|
|
551
558
|
{ conversationId },
|
|
552
559
|
"agent-wake: conversation is archived; rejecting wake",
|
|
@@ -325,6 +325,144 @@ describe("authenticateRequest for /v1/host-browser-result", () => {
|
|
|
325
325
|
});
|
|
326
326
|
});
|
|
327
327
|
|
|
328
|
+
// ---------------------------------------------------------------------------
|
|
329
|
+
// Dev bypass covers only a request that supplied no Authorization header. A
|
|
330
|
+
// bearer sent to a platform pod (the gateway's exchange token, or a trusted
|
|
331
|
+
// contact's) is verified on its own terms.
|
|
332
|
+
// ---------------------------------------------------------------------------
|
|
333
|
+
|
|
334
|
+
describe("authenticateRequest with auth disabled and a bearer present", () => {
|
|
335
|
+
beforeEach(() => {
|
|
336
|
+
authDisabled = true;
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
test("uses the token's claims rather than the dev-bypass context", () => {
|
|
340
|
+
const token = mintValidToken({ sub: "actor:self:contact-principal" });
|
|
341
|
+
|
|
342
|
+
const req = new Request("http://localhost/v1/messages", {
|
|
343
|
+
method: "POST",
|
|
344
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
const result = authenticateRequest(req);
|
|
348
|
+
expect(result.ok).toBe(true);
|
|
349
|
+
if (result.ok) {
|
|
350
|
+
expect(result.context.actorPrincipalId).toBe("contact-principal");
|
|
351
|
+
expect(result.context.policyEpoch).toBe(CURRENT_POLICY_EPOCH);
|
|
352
|
+
}
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
test("accepts the gateway's exchange token", () => {
|
|
356
|
+
const token = mintValidToken({
|
|
357
|
+
sub: "svc:gateway:self",
|
|
358
|
+
scope_profile: "gateway_service_v1",
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
const req = new Request("http://localhost/v1/messages", {
|
|
362
|
+
method: "POST",
|
|
363
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
const result = authenticateRequest(req);
|
|
367
|
+
expect(result.ok).toBe(true);
|
|
368
|
+
if (result.ok) {
|
|
369
|
+
expect(result.context.principalType).toBe("svc_gateway");
|
|
370
|
+
}
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
test("accepts a gateway-audience token through the fallback", () => {
|
|
374
|
+
const token = mintValidToken({
|
|
375
|
+
aud: "vellum-gateway",
|
|
376
|
+
sub: "actor:self:local-client",
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
const req = new Request("http://localhost/v1/messages", {
|
|
380
|
+
method: "POST",
|
|
381
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
const result = authenticateRequest(req);
|
|
385
|
+
expect(result.ok).toBe(true);
|
|
386
|
+
if (result.ok) {
|
|
387
|
+
expect(result.context.actorPrincipalId).toBe("local-client");
|
|
388
|
+
expect(result.context.assistantId).toBe(DAEMON_INTERNAL_ASSISTANT_ID);
|
|
389
|
+
}
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
test.each([
|
|
393
|
+
["malformed", () => "not-a-token.xxxxxxxxxxxxx"],
|
|
394
|
+
[
|
|
395
|
+
"expired",
|
|
396
|
+
() => mintValidToken({ exp: Math.floor(Date.now() / 1000) - 100 }),
|
|
397
|
+
],
|
|
398
|
+
[
|
|
399
|
+
"wrong-audience",
|
|
400
|
+
() => mintValidToken({ aud: "vellum-other" as TokenAudience }),
|
|
401
|
+
],
|
|
402
|
+
["unparseable-sub", () => mintValidToken({ sub: "garbage" })],
|
|
403
|
+
])("refuses a %s token rather than falling back", (_label, build) => {
|
|
404
|
+
const req = new Request("http://localhost/v1/messages", {
|
|
405
|
+
method: "POST",
|
|
406
|
+
headers: { Authorization: `Bearer ${build()}` },
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
const result = authenticateRequest(req);
|
|
410
|
+
expect(result.ok).toBe(false);
|
|
411
|
+
if (!result.ok) {
|
|
412
|
+
expect(result.response.status).toBe(401);
|
|
413
|
+
}
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
test("still returns the dev-bypass context when no bearer is sent", () => {
|
|
417
|
+
const req = new Request("http://localhost/v1/messages", {
|
|
418
|
+
method: "POST",
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
const result = authenticateRequest(req);
|
|
422
|
+
expect(result.ok).toBe(true);
|
|
423
|
+
if (result.ok) {
|
|
424
|
+
expect(result.context.actorPrincipalId).toBe("dev-bypass");
|
|
425
|
+
}
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
test("verifies a lowercase bearer scheme rather than bypassing it", () => {
|
|
429
|
+
const token = mintValidToken({ sub: "actor:self:contact-principal" });
|
|
430
|
+
|
|
431
|
+
const req = new Request("http://localhost/v1/messages", {
|
|
432
|
+
method: "POST",
|
|
433
|
+
headers: { Authorization: `bearer ${token}` },
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
const result = authenticateRequest(req);
|
|
437
|
+
expect(result.ok).toBe(true);
|
|
438
|
+
if (result.ok) {
|
|
439
|
+
expect(result.context.actorPrincipalId).toBe("contact-principal");
|
|
440
|
+
}
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
test.each([
|
|
444
|
+
["lowercase scheme with a bad token", "bearer not-a-jwt.xxxxxxxx"],
|
|
445
|
+
["empty credential", "Bearer "],
|
|
446
|
+
["non-bearer scheme", "Basic dXNlcjpwYXNz"],
|
|
447
|
+
["an empty header value", ""],
|
|
448
|
+
["a whitespace-only header value", " "],
|
|
449
|
+
])(
|
|
450
|
+
"refuses %s instead of granting the dev-bypass context",
|
|
451
|
+
(_label, header) => {
|
|
452
|
+
const req = new Request("http://localhost/v1/messages", {
|
|
453
|
+
method: "POST",
|
|
454
|
+
headers: { Authorization: header },
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
const result = authenticateRequest(req);
|
|
458
|
+
expect(result.ok).toBe(false);
|
|
459
|
+
if (!result.ok) {
|
|
460
|
+
expect(result.response.status).toBe(401);
|
|
461
|
+
}
|
|
462
|
+
},
|
|
463
|
+
);
|
|
464
|
+
});
|
|
465
|
+
|
|
328
466
|
// ---------------------------------------------------------------------------
|
|
329
467
|
// oauth_proxy_v1 grants: the narrow bearer a third-party CLI presents to the
|
|
330
468
|
// OAuth passthrough route. Accepted on either audience, and carrying nothing
|
|
@@ -17,9 +17,10 @@
|
|
|
17
17
|
* Replaces both the legacy bearer shared-secret check and the
|
|
18
18
|
* actor-token HMAC middleware with a single JWT verification path.
|
|
19
19
|
*
|
|
20
|
-
* When DISABLE_HTTP_AUTH is set (platform-managed deployments),
|
|
21
|
-
*
|
|
22
|
-
*
|
|
20
|
+
* When DISABLE_HTTP_AUTH is set (platform-managed deployments), a request
|
|
21
|
+
* carrying no bearer gets a synthetic AuthContext so downstream code always
|
|
22
|
+
* has a typed context to consume. A request that carries one is verified
|
|
23
|
+
* normally, and a bad token is refused rather than falling back.
|
|
23
24
|
*/
|
|
24
25
|
|
|
25
26
|
import { isHttpAuthDisabled } from "../../config/env.js";
|
|
@@ -73,17 +74,18 @@ function buildDevBypassContext(): AuthContext {
|
|
|
73
74
|
* The caller should return the error Response directly to the client.
|
|
74
75
|
*/
|
|
75
76
|
export function authenticateRequest(req: Request): AuthenticateResult {
|
|
76
|
-
// Dev bypass: skip JWT verification entirely
|
|
77
|
-
if (isHttpAuthDisabled()) {
|
|
78
|
-
return { ok: true, context: buildDevBypassContext() };
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
const path = new URL(req.url).pathname;
|
|
82
|
-
|
|
83
77
|
const rawToken = extractBearerToken(req);
|
|
84
78
|
if (!rawToken) {
|
|
79
|
+
// Dev bypass covers only a request that supplied no Authorization header
|
|
80
|
+
// at all. A header that is present but unusable (empty or whitespace-only
|
|
81
|
+
// value, a non-bearer scheme, a malformed credential) is refused rather
|
|
82
|
+
// than handed the guardian context. `has` rather than `get`: an empty
|
|
83
|
+
// value reads back as "" and would otherwise pass as absent.
|
|
84
|
+
if (isHttpAuthDisabled() && !req.headers.has("authorization")) {
|
|
85
|
+
return { ok: true, context: buildDevBypassContext() };
|
|
86
|
+
}
|
|
85
87
|
log.warn(
|
|
86
|
-
{ reason: "missing_token", path },
|
|
88
|
+
{ reason: "missing_token", path: new URL(req.url).pathname },
|
|
87
89
|
"Auth denied: missing Authorization header",
|
|
88
90
|
);
|
|
89
91
|
return {
|
|
@@ -100,6 +102,8 @@ export function authenticateRequest(req: Request): AuthenticateResult {
|
|
|
100
102
|
};
|
|
101
103
|
}
|
|
102
104
|
|
|
105
|
+
const path = new URL(req.url).pathname;
|
|
106
|
+
|
|
103
107
|
// Verify the JWT — prefer vellum-daemon audience (gateway-proxied requests
|
|
104
108
|
// and daemon-minted tokens), but also accept vellum-gateway audience for
|
|
105
109
|
// direct local clients (macOS SettingsStore) that hold a guardian-issued JWT
|
|
@@ -156,8 +156,14 @@ export function isPrivateNetworkOrigin(req: Request): boolean {
|
|
|
156
156
|
/**
|
|
157
157
|
* Extract and validate a bearer token from the Authorization header.
|
|
158
158
|
* Returns the token string if present, or null.
|
|
159
|
+
*
|
|
160
|
+
* The scheme is matched case-insensitively, per RFC 7235 and to match the
|
|
161
|
+
* gateway's own parsing. An empty credential returns null.
|
|
159
162
|
*/
|
|
160
163
|
export function extractBearerToken(req: Request): string | null {
|
|
161
164
|
const authHeader = req.headers.get("authorization");
|
|
162
|
-
|
|
165
|
+
if (!authHeader?.toLowerCase().startsWith("bearer ")) {
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
return authHeader.slice(7) || null;
|
|
163
169
|
}
|