@vellumai/assistant 0.12.6-dev.202609292019.a6f6d85 → 0.12.6-staging.1
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/.env.example +0 -2
- package/openapi.yaml +4 -8
- package/package.json +1 -1
- package/src/cli/lib/bundled-marketplace.json +0 -26
- package/src/config/feature-flag-registry.json +8 -0
- package/src/config/schemas/llm.ts +0 -1
- package/src/providers/inference/adapter-factory.ts +0 -6
- package/src/providers/model-catalog.ts +0 -40
- package/src/providers/openai/chat-completions-provider.ts +1 -23
- package/src/providers/retry.ts +4 -10
- package/src/runtime/migrations/__tests__/vbundle-streaming-validator.test.ts +10 -35
- package/src/runtime/migrations/__tests__/vbundle-validator-v1-schema.test.ts +0 -48
- package/src/runtime/migrations/vbundle-builder.ts +3 -19
- package/src/runtime/migrations/vbundle-streaming-importer.ts +19 -14
- package/src/runtime/migrations/vbundle-streaming-validator.ts +7 -14
- package/src/runtime/migrations/vbundle-validator.ts +1 -54
- package/src/runtime/routes/migration-routes.ts +1 -3
- package/src/tools/host-terminal/host-shell.ts +2 -4
- package/src/tools/shared/shell-output.test.ts +0 -42
- package/src/tools/shared/shell-output.ts +4 -27
- package/src/tools/terminal/shell.ts +0 -2
- package/src/providers/ionet/client.test.ts +0 -143
- package/src/providers/ionet/client.ts +0 -37
package/.env.example
CHANGED
|
@@ -2,8 +2,6 @@
|
|
|
2
2
|
ANTHROPIC_API_KEY=sk-ant-...
|
|
3
3
|
OPENAI_API_KEY=sk-...
|
|
4
4
|
GEMINI_API_KEY=...
|
|
5
|
-
# Optional. IO Intelligence (io.net) open-models endpoint.
|
|
6
|
-
IONET_API_KEY=...
|
|
7
5
|
# Optional. TypeSafe / Jev System One decision model.
|
|
8
6
|
TYPESAFE_API_KEY=...
|
|
9
7
|
# Optional for authenticated/proxied Ollama deployments
|
package/openapi.yaml
CHANGED
|
@@ -6025,7 +6025,6 @@ paths:
|
|
|
6025
6025
|
- minimax
|
|
6026
6026
|
- atlascloud
|
|
6027
6027
|
- baseten
|
|
6028
|
-
- ionet
|
|
6029
6028
|
- poolside
|
|
6030
6029
|
connectionName:
|
|
6031
6030
|
type: string
|
|
@@ -16974,8 +16973,8 @@ paths:
|
|
|
16974
16973
|
type: string
|
|
16975
16974
|
description:
|
|
16976
16975
|
"Filter by provider id. One of: anthropic, openai, gemini, ollama, fireworks, together, openrouter,
|
|
16977
|
-
vercel-ai-gateway, litellm, opencode, openai-compatible, minimax, atlascloud, baseten,
|
|
16978
|
-
|
|
16976
|
+
vercel-ai-gateway, litellm, opencode, openai-compatible, minimax, atlascloud, baseten, poolside, typesafe,
|
|
16977
|
+
vellum"
|
|
16979
16978
|
responses:
|
|
16980
16979
|
"200":
|
|
16981
16980
|
description: Successful response
|
|
@@ -17247,8 +17246,8 @@ paths:
|
|
|
17247
17246
|
type: string
|
|
17248
17247
|
description:
|
|
17249
17248
|
"Filter by provider. One of: anthropic, openai, gemini, ollama, fireworks, together, openrouter,
|
|
17250
|
-
vercel-ai-gateway, litellm, opencode, openai-compatible, minimax, atlascloud, baseten,
|
|
17251
|
-
|
|
17249
|
+
vercel-ai-gateway, litellm, opencode, openai-compatible, minimax, atlascloud, baseten, poolside, typesafe,
|
|
17250
|
+
vellum, chatgpt"
|
|
17252
17251
|
responses:
|
|
17253
17252
|
"200":
|
|
17254
17253
|
description: Successful response
|
|
@@ -39426,7 +39425,6 @@ components:
|
|
|
39426
39425
|
- minimax
|
|
39427
39426
|
- atlascloud
|
|
39428
39427
|
- baseten
|
|
39429
|
-
- ionet
|
|
39430
39428
|
- poolside
|
|
39431
39429
|
- typesafe
|
|
39432
39430
|
- vellum
|
|
@@ -39691,7 +39689,6 @@ components:
|
|
|
39691
39689
|
- minimax
|
|
39692
39690
|
- atlascloud
|
|
39693
39691
|
- baseten
|
|
39694
|
-
- ionet
|
|
39695
39692
|
- poolside
|
|
39696
39693
|
connectionName:
|
|
39697
39694
|
type: string
|
|
@@ -40080,7 +40077,6 @@ components:
|
|
|
40080
40077
|
- minimax
|
|
40081
40078
|
- atlascloud
|
|
40082
40079
|
- baseten
|
|
40083
|
-
- ionet
|
|
40084
40080
|
- poolside
|
|
40085
40081
|
- type: "null"
|
|
40086
40082
|
connectionName:
|
package/package.json
CHANGED
|
@@ -148,19 +148,6 @@
|
|
|
148
148
|
"ref": "2d293cdcb68af03d446b61a9915a0ee3dc842d4e"
|
|
149
149
|
}
|
|
150
150
|
},
|
|
151
|
-
{
|
|
152
|
-
"name": "contractor-photo-to-quote",
|
|
153
|
-
"description": "Turns job-site and equipment photos into standardized trade estimates and quotes in Jobber or QuickBooks with multi-tier Good/Better/Best options in 5 minutes.",
|
|
154
|
-
"category": "commerce",
|
|
155
|
-
"icon": "📸",
|
|
156
|
-
"license": "MIT",
|
|
157
|
-
"homepage": "https://github.com/ZeebBoyBlue/contractor-photo-to-quote",
|
|
158
|
-
"source": {
|
|
159
|
-
"source": "github",
|
|
160
|
-
"repo": "ZeebBoyBlue/contractor-photo-to-quote",
|
|
161
|
-
"ref": "6c136186faaf03bcf125d202f0b1f51f97d94544"
|
|
162
|
-
}
|
|
163
|
-
},
|
|
164
151
|
{
|
|
165
152
|
"name": "contractor-voice-invoicer",
|
|
166
153
|
"description": "Converts 20-second truck voice memos and job photos into itemized client invoices with custom labor rates, material markups, and one-click accounting staging.",
|
|
@@ -487,19 +474,6 @@
|
|
|
487
474
|
"license": "MIT",
|
|
488
475
|
"icon": "\ud83d\udcac"
|
|
489
476
|
},
|
|
490
|
-
{
|
|
491
|
-
"name": "quickbooks-expense-categorizer",
|
|
492
|
-
"description": "Scans uncategorized QuickBooks Online expenses and bank feeds, matches receipts, and stages categorized entries with confidence scoring for 1-click batch review.",
|
|
493
|
-
"category": "commerce",
|
|
494
|
-
"icon": "🧾",
|
|
495
|
-
"license": "MIT",
|
|
496
|
-
"homepage": "https://github.com/ZeebBoyBlue/quickbooks-expense-categorizer",
|
|
497
|
-
"source": {
|
|
498
|
-
"source": "github",
|
|
499
|
-
"repo": "ZeebBoyBlue/quickbooks-expense-categorizer",
|
|
500
|
-
"ref": "120776619698cc3e9490caf5c756465f5caaa6c2"
|
|
501
|
-
}
|
|
502
|
-
},
|
|
503
477
|
{
|
|
504
478
|
"name": "reading-pal",
|
|
505
479
|
"source": {
|
|
@@ -560,6 +560,14 @@
|
|
|
560
560
|
"description": "Streams an interactive virtual desktop from a platform-hosted, containerized assistant over /v1/desktop/stream. Requires platform hosting; self-hosted assistants are excluded. Off: the virtual desktop is hidden. On: the chat header offers its viewer and controls.",
|
|
561
561
|
"defaultEnabled": false
|
|
562
562
|
},
|
|
563
|
+
{
|
|
564
|
+
"id": "assistant-inbox",
|
|
565
|
+
"scope": "client",
|
|
566
|
+
"key": "assistant-inbox",
|
|
567
|
+
"label": "Assistant Inbox",
|
|
568
|
+
"description": "Shows an Assistant Inbox entry above Preferences in the web sidebar and the /assistant/inbox page it opens: a mailbox for the assistant's managed vellum.me address (received and sent, with a reading pane), the inline email setup card for entitled orgs with no address yet, and an upgrade card for orgs without the managed_email entitlement. With it on, email setup happens only there: the billing wizard hands off to the inbox instead of its own domain step, the billing page's finish-setup nudge gives way to the inbox's rail entry, and the Channels page's Email section points there instead of registering an address itself. Off hides the entry, redirects the route to chat, and keeps the older setup surfaces.",
|
|
569
|
+
"defaultEnabled": false
|
|
570
|
+
},
|
|
563
571
|
{
|
|
564
572
|
"id": "sidebar-done",
|
|
565
573
|
"scope": "both",
|
|
@@ -35,7 +35,6 @@ import { AtlasCloudProvider } from "../atlascloud/client.js";
|
|
|
35
35
|
import { BasetenProvider } from "../baseten/client.js";
|
|
36
36
|
import { FireworksProvider } from "../fireworks/client.js";
|
|
37
37
|
import { GeminiProvider } from "../gemini/client.js";
|
|
38
|
-
import { IonetProvider } from "../ionet/client.js";
|
|
39
38
|
import { JevProvider } from "../jev/client.js";
|
|
40
39
|
import { MinimaxProvider } from "../minimax/client.js";
|
|
41
40
|
import { PROVIDER_CATALOG } from "../model-catalog.js";
|
|
@@ -207,11 +206,6 @@ const ADAPTER_FACTORIES: Record<string, AdapterFactory> = {
|
|
|
207
206
|
streamTimeoutMs,
|
|
208
207
|
...(baseURL ? { baseURL } : {}),
|
|
209
208
|
}),
|
|
210
|
-
ionet: ({ apiKey, model, streamTimeoutMs, baseURL }) =>
|
|
211
|
-
new IonetProvider(apiKey, model, {
|
|
212
|
-
streamTimeoutMs,
|
|
213
|
-
...(baseURL ? { baseURL } : {}),
|
|
214
|
-
}),
|
|
215
209
|
typesafe: ({ apiKey, model, streamTimeoutMs, baseURL }) =>
|
|
216
210
|
new JevProvider(apiKey, model, {
|
|
217
211
|
streamTimeoutMs,
|
|
@@ -2746,46 +2746,6 @@ const RAW_PROVIDER_CATALOG: ProviderCatalogEntry[] = [
|
|
|
2746
2746
|
apiKeyUrl: "https://app.baseten.co/settings/api_keys",
|
|
2747
2747
|
apiKeyPlaceholder: "Your Baseten API key",
|
|
2748
2748
|
},
|
|
2749
|
-
{
|
|
2750
|
-
id: "ionet",
|
|
2751
|
-
displayName: "IO Intelligence",
|
|
2752
|
-
subtitle:
|
|
2753
|
-
"Open models served by IO Intelligence, io.net's inference platform (OpenAI-compatible). Requires an IO Intelligence API key.",
|
|
2754
|
-
setupMode: "api-key",
|
|
2755
|
-
setupHint:
|
|
2756
|
-
"Enter your IO Intelligence API key to enable IO Intelligence models.",
|
|
2757
|
-
envVar: "IONET_API_KEY",
|
|
2758
|
-
credentialsGuide: {
|
|
2759
|
-
description: "Sign in to io.net and create an IO Intelligence API key.",
|
|
2760
|
-
url: "https://io.net/docs/guides/intelligence/api-keys-and-secrets",
|
|
2761
|
-
linkLabel: "Open io.net",
|
|
2762
|
-
},
|
|
2763
|
-
// Model ids are Hugging Face-style `org/name`. Context windows and tool
|
|
2764
|
-
// support come from the endpoint's live `GET /models` catalog, and every
|
|
2765
|
-
// entry is accessible at the basic access tier.
|
|
2766
|
-
models: [
|
|
2767
|
-
{
|
|
2768
|
-
id: "meta-llama/Llama-3.3-70B-Instruct",
|
|
2769
|
-
displayName: "Llama 3.3 70B Instruct",
|
|
2770
|
-
contextWindowTokens: 128000,
|
|
2771
|
-
defaultContextWindowTokens: 128000,
|
|
2772
|
-
maxOutputTokens: 4096,
|
|
2773
|
-
supportsToolUse: true,
|
|
2774
|
-
},
|
|
2775
|
-
{
|
|
2776
|
-
id: "openai/gpt-oss-20b",
|
|
2777
|
-
displayName: "GPT-OSS 20B",
|
|
2778
|
-
contextWindowTokens: 64000,
|
|
2779
|
-
defaultContextWindowTokens: 64000,
|
|
2780
|
-
maxOutputTokens: 32768,
|
|
2781
|
-
supportsThinking: true,
|
|
2782
|
-
supportsToolUse: true,
|
|
2783
|
-
},
|
|
2784
|
-
],
|
|
2785
|
-
defaultModel: "meta-llama/Llama-3.3-70B-Instruct",
|
|
2786
|
-
apiKeyUrl: "https://io.net/docs/guides/intelligence/api-keys-and-secrets",
|
|
2787
|
-
apiKeyPlaceholder: "Your IO Intelligence API key",
|
|
2788
|
-
},
|
|
2789
2749
|
{
|
|
2790
2750
|
id: "poolside",
|
|
2791
2751
|
displayName: "Poolside",
|
|
@@ -212,12 +212,6 @@ export interface OpenAIChatCompletionsProviderOptions {
|
|
|
212
212
|
* (Fireworks, Together) keep sending `none` / forced choices. Enabled for
|
|
213
213
|
* the generic `openai-compatible` adapter, whose upstream is unknown. */
|
|
214
214
|
omitToolChoiceWhenReasoning?: boolean;
|
|
215
|
-
/** Send `tool_choice: "auto"` whenever tools are offered and the caller
|
|
216
|
-
* left the choice unspecified. Strict OpenAI-compatible upstreams default
|
|
217
|
-
* an unspecified `tool_choice` to "none" (io.net documents exactly that),
|
|
218
|
-
* which would leave the model unable to call any tool; OpenAI's own
|
|
219
|
-
* default is "auto", so this option only exists for upstreams that differ. */
|
|
220
|
-
defaultToolChoiceAuto?: boolean;
|
|
221
215
|
/** Wire field for the output-token limit. OpenAI and OpenAI-compatible
|
|
222
216
|
* backends use `max_completion_tokens`. OpenRouter defaults to
|
|
223
217
|
* `max_tokens` because its parameter router matches that key on
|
|
@@ -870,7 +864,6 @@ export class OpenAIChatCompletionsProvider implements Provider {
|
|
|
870
864
|
private coerceObjectArgsToJsonString: boolean;
|
|
871
865
|
private salvageXmlToolCalls: boolean;
|
|
872
866
|
private omitToolChoiceWhenReasoning: boolean;
|
|
873
|
-
private defaultToolChoiceAuto: boolean;
|
|
874
867
|
private outputTokenLimitField: "max_completion_tokens" | "max_tokens";
|
|
875
868
|
|
|
876
869
|
constructor(
|
|
@@ -903,7 +896,6 @@ export class OpenAIChatCompletionsProvider implements Provider {
|
|
|
903
896
|
options.salvageXmlToolCalls ?? shouldSalvageXmlToolCalls(model);
|
|
904
897
|
this.omitToolChoiceWhenReasoning =
|
|
905
898
|
options.omitToolChoiceWhenReasoning ?? false;
|
|
906
|
-
this.defaultToolChoiceAuto = options.defaultToolChoiceAuto ?? false;
|
|
907
899
|
this.outputTokenLimitField =
|
|
908
900
|
options.outputTokenLimitField ?? "max_completion_tokens";
|
|
909
901
|
}
|
|
@@ -966,15 +958,6 @@ export class OpenAIChatCompletionsProvider implements Provider {
|
|
|
966
958
|
maxTokens;
|
|
967
959
|
}
|
|
968
960
|
|
|
969
|
-
// Per-conversation prompt-cache key (OpenAI Chat Completions
|
|
970
|
-
// `prompt_cache_key`). `RetryProvider` stamps it from the durable
|
|
971
|
-
// conversation id for providers in PROMPT_CACHE_KEY_PROVIDERS; omitted
|
|
972
|
-
// from the wire when absent.
|
|
973
|
-
const promptCacheKey = configObj?.promptCacheKey;
|
|
974
|
-
if (typeof promptCacheKey === "string" && promptCacheKey.length > 0) {
|
|
975
|
-
params.prompt_cache_key = promptCacheKey;
|
|
976
|
-
}
|
|
977
|
-
|
|
978
961
|
// Profile-scoped token biasing (e.g. the `suppress-cjk` preset). Resolved
|
|
979
962
|
// and gated to this provider family upstream in `RetryProvider`.
|
|
980
963
|
if (logitBias) {
|
|
@@ -1045,12 +1028,7 @@ export class OpenAIChatCompletionsProvider implements Provider {
|
|
|
1045
1028
|
// wire. Catalog providers that honor `none` / forced choices still
|
|
1046
1029
|
// receive them; the generic openai-compatible adapter drops every
|
|
1047
1030
|
// explicit value in thinking mode via `omitToolChoiceWhenReasoning`.
|
|
1048
|
-
const
|
|
1049
|
-
const toolChoice =
|
|
1050
|
-
callerToolChoice ??
|
|
1051
|
-
(this.defaultToolChoiceAuto
|
|
1052
|
-
? ("auto" as OpenAI.Chat.Completions.ChatCompletionToolChoiceOption)
|
|
1053
|
-
: undefined);
|
|
1031
|
+
const toolChoice = mapNeutralToolChoice(configObj?.tool_choice);
|
|
1054
1032
|
xmlToolCallSalvageEnabled =
|
|
1055
1033
|
this.salvageXmlToolCalls && toolChoice !== "none";
|
|
1056
1034
|
if (toolChoice !== undefined) {
|
package/src/providers/retry.ts
CHANGED
|
@@ -78,16 +78,10 @@ const USAGE_ATTRIBUTION_HEADER_NAMES = {
|
|
|
78
78
|
subagentSpawnMode: "X-Vellum-Subagent-Spawn-Mode",
|
|
79
79
|
} as const;
|
|
80
80
|
|
|
81
|
-
/** Providers whose transports consume `promptCacheKey` (OpenAI Responses
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
|
|
85
|
-
* per-conversation key and can pin server-side sessions / prompt caches to it. */
|
|
86
|
-
const PROMPT_CACHE_KEY_PROVIDERS = new Set([
|
|
87
|
-
"openai",
|
|
88
|
-
"openrouter",
|
|
89
|
-
"openai-compatible",
|
|
90
|
-
]);
|
|
81
|
+
/** Providers whose transports consume `promptCacheKey` (OpenAI Responses
|
|
82
|
+
* `prompt_cache_key`); `RetryProvider` derives it from `selectionSeed` for
|
|
83
|
+
* these only. */
|
|
84
|
+
const PROMPT_CACHE_KEY_PROVIDERS = new Set(["openai", "openrouter"]);
|
|
91
85
|
|
|
92
86
|
/** Providers that support the `effort` config (extended thinking / reasoning). */
|
|
93
87
|
const EFFORT_SUPPORTED_PROVIDERS = new Set([
|
|
@@ -30,7 +30,6 @@ import {
|
|
|
30
30
|
import {
|
|
31
31
|
computeLegacyManifestSha256,
|
|
32
32
|
computeManifestChecksum,
|
|
33
|
-
MANIFEST_MAX_BYTES,
|
|
34
33
|
} from "../vbundle-validator.js";
|
|
35
34
|
import { defaultV1Options } from "./v1-test-helpers.js";
|
|
36
35
|
|
|
@@ -203,16 +202,15 @@ describe("readAndValidateManifest — negative paths", () => {
|
|
|
203
202
|
});
|
|
204
203
|
|
|
205
204
|
test("throws manifest_too_large and fails fast before draining the whole body", async () => {
|
|
206
|
-
// Fake a tar entry whose body would emit
|
|
207
|
-
//
|
|
208
|
-
//
|
|
209
|
-
// pulling chunks.
|
|
205
|
+
// Fake a tar entry whose body would emit 5 MiB if fully drained. The
|
|
206
|
+
// validator must destroy() the stream and throw the moment the running
|
|
207
|
+
// byte count crosses the 1 MiB cap — it must NOT keep pulling chunks.
|
|
210
208
|
//
|
|
211
209
|
// We count bytes emitted via a _read implementation, and after the
|
|
212
|
-
// throw assert both that destroy() fired and that far
|
|
213
|
-
//
|
|
214
|
-
const CHUNK =
|
|
215
|
-
const TOTAL_CHUNKS =
|
|
210
|
+
// throw assert both that destroy() fired and that far fewer than 5 MiB
|
|
211
|
+
// were ever pulled out of the stream.
|
|
212
|
+
const CHUNK = 512 * 1024; // 512 KiB
|
|
213
|
+
const TOTAL_CHUNKS = 10; // 5 MiB worth — way past the 1 MiB cap
|
|
216
214
|
let chunksEmitted = 0;
|
|
217
215
|
let bytesEmitted = 0;
|
|
218
216
|
const body = new Readable({
|
|
@@ -247,34 +245,11 @@ describe("readAndValidateManifest — negative paths", () => {
|
|
|
247
245
|
expect(err).toBeInstanceOf(StreamingValidationError);
|
|
248
246
|
expect(err?.code).toBe("manifest_too_large");
|
|
249
247
|
// Fail-fast assertions: destroy() was called, and we didn't drain past
|
|
250
|
-
//
|
|
251
|
-
//
|
|
248
|
+
// ~1 MiB + one chunk. If the validator had drained to EOF we'd see the
|
|
249
|
+
// full 5 MiB / 10 chunks here.
|
|
252
250
|
expect(body.destroyed).toBe(true);
|
|
253
251
|
expect(chunksEmitted).toBeLessThanOrEqual(3);
|
|
254
|
-
expect(bytesEmitted).toBeLessThan(2 *
|
|
255
|
-
});
|
|
256
|
-
|
|
257
|
-
test("throws bundle_too_many_entries when manifest.contents exceeds maxEntries", async () => {
|
|
258
|
-
const { archive } = buildVBundle({
|
|
259
|
-
files: [
|
|
260
|
-
{ path: "data/db/assistant.db", data: new Uint8Array() },
|
|
261
|
-
{ path: "workspace/a.txt", data: new TextEncoder().encode("a") },
|
|
262
|
-
{ path: "workspace/b.txt", data: new TextEncoder().encode("b") },
|
|
263
|
-
],
|
|
264
|
-
...defaultV1Options(),
|
|
265
|
-
});
|
|
266
|
-
const { entry, drainRest } = await firstEntryOf(archive);
|
|
267
|
-
|
|
268
|
-
let err: StreamingValidationError | null = null;
|
|
269
|
-
try {
|
|
270
|
-
await readAndValidateManifest(entry, { maxEntries: 2 });
|
|
271
|
-
} catch (e) {
|
|
272
|
-
err = e as StreamingValidationError;
|
|
273
|
-
}
|
|
274
|
-
await drainRest();
|
|
275
|
-
|
|
276
|
-
expect(err).toBeInstanceOf(StreamingValidationError);
|
|
277
|
-
expect(err?.code).toBe("bundle_too_many_entries");
|
|
252
|
+
expect(bytesEmitted).toBeLessThan(2 * 1024 * 1024);
|
|
278
253
|
});
|
|
279
254
|
|
|
280
255
|
test("throws manifest_malformed when manifest body is not valid JSON", async () => {
|
|
@@ -19,8 +19,6 @@ import {
|
|
|
19
19
|
canonicalizeJson,
|
|
20
20
|
computeLegacyManifestSha256,
|
|
21
21
|
computeManifestChecksum,
|
|
22
|
-
MANIFEST_MAX_BYTES,
|
|
23
|
-
MANIFEST_MAX_ENTRIES,
|
|
24
22
|
ManifestSchema,
|
|
25
23
|
validateVBundle,
|
|
26
24
|
} from "../vbundle-validator.js";
|
|
@@ -176,52 +174,6 @@ describe("ManifestSchema — v1 acceptance", () => {
|
|
|
176
174
|
});
|
|
177
175
|
});
|
|
178
176
|
|
|
179
|
-
describe("validateVBundle: streaming manifest limits", () => {
|
|
180
|
-
test("rejects a manifest over MANIFEST_MAX_BYTES with MANIFEST_TOO_LARGE", () => {
|
|
181
|
-
const oversized = new Uint8Array(MANIFEST_MAX_BYTES + 1).fill(0x20);
|
|
182
|
-
const archive = gzipSync(
|
|
183
|
-
tarArchive([{ name: "manifest.json", data: oversized }]),
|
|
184
|
-
);
|
|
185
|
-
const result = validateVBundle(archive, { enforceStreamingLimits: true });
|
|
186
|
-
expect(result.is_valid).toBe(false);
|
|
187
|
-
expect(result.errors.map((e) => e.code)).toEqual(["MANIFEST_TOO_LARGE"]);
|
|
188
|
-
});
|
|
189
|
-
|
|
190
|
-
test("rejects a manifest declaring more than MANIFEST_MAX_ENTRIES with MANIFEST_TOO_MANY_ENTRIES", () => {
|
|
191
|
-
const skeleton = v1Skeleton();
|
|
192
|
-
const dbEntry = (skeleton.contents as Array<Record<string, unknown>>)[0]!;
|
|
193
|
-
const extra = Array.from({ length: MANIFEST_MAX_ENTRIES }, (_, i) => ({
|
|
194
|
-
...dbEntry,
|
|
195
|
-
path: `workspace/${i}`,
|
|
196
|
-
}));
|
|
197
|
-
const archive = gzipTarOf(
|
|
198
|
-
withChecksum({ ...skeleton, contents: [dbEntry, ...extra] }),
|
|
199
|
-
);
|
|
200
|
-
const result = validateVBundle(archive, { enforceStreamingLimits: true });
|
|
201
|
-
expect(result.is_valid).toBe(false);
|
|
202
|
-
expect(result.errors.map((e) => e.code)).toEqual([
|
|
203
|
-
"MANIFEST_TOO_MANY_ENTRIES",
|
|
204
|
-
]);
|
|
205
|
-
});
|
|
206
|
-
|
|
207
|
-
test("does not apply the limits unless enforceStreamingLimits is set", () => {
|
|
208
|
-
const skeleton = v1Skeleton();
|
|
209
|
-
const dbEntry = (skeleton.contents as Array<Record<string, unknown>>)[0]!;
|
|
210
|
-
const extra = Array.from({ length: MANIFEST_MAX_ENTRIES }, (_, i) => ({
|
|
211
|
-
...dbEntry,
|
|
212
|
-
path: `workspace/${i}`,
|
|
213
|
-
}));
|
|
214
|
-
const archive = gzipTarOf(
|
|
215
|
-
withChecksum({ ...skeleton, contents: [dbEntry, ...extra] }),
|
|
216
|
-
[{ name: "data/db/assistant.db", data: DB_BYTES }],
|
|
217
|
-
);
|
|
218
|
-
const result = validateVBundle(archive);
|
|
219
|
-
expect(result.errors.map((e) => e.code)).not.toContain(
|
|
220
|
-
"MANIFEST_TOO_MANY_ENTRIES",
|
|
221
|
-
);
|
|
222
|
-
});
|
|
223
|
-
});
|
|
224
|
-
|
|
225
177
|
describe("ManifestSchema — v1 rejection", () => {
|
|
226
178
|
test("rejects schema_version !== 1", () => {
|
|
227
179
|
const manifest = withChecksum({ ...v1Skeleton(), schema_version: 2 });
|
|
@@ -31,11 +31,9 @@ import { sanitizeConfigForTransfer } from "../../config/sanitize-for-transfer.js
|
|
|
31
31
|
import { trackDaemonActivity } from "../../daemon/activity-trail.js";
|
|
32
32
|
import { getLogger } from "../../util/logger.js";
|
|
33
33
|
import type { VBundleOriginMode } from "./origin-mode.js";
|
|
34
|
-
import {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
type ManifestFileEntryType,
|
|
38
|
-
type ManifestType,
|
|
34
|
+
import type {
|
|
35
|
+
ManifestFileEntryType,
|
|
36
|
+
ManifestType,
|
|
39
37
|
} from "./vbundle-validator.js";
|
|
40
38
|
|
|
41
39
|
// ---------------------------------------------------------------------------
|
|
@@ -402,20 +400,6 @@ function buildManifestObject(input: {
|
|
|
402
400
|
const checksum = sha256Hex(canonicalizeJson(manifestWithEmptyChecksum));
|
|
403
401
|
const manifest: ManifestType = { ...manifestWithEmptyChecksum, checksum };
|
|
404
402
|
const manifestData = new TextEncoder().encode(JSON.stringify(manifest));
|
|
405
|
-
if (
|
|
406
|
-
manifest.contents.length > MANIFEST_MAX_ENTRIES ||
|
|
407
|
-
manifestData.length > MANIFEST_MAX_BYTES
|
|
408
|
-
) {
|
|
409
|
-
getLogger("vbundle-builder").warn(
|
|
410
|
-
{
|
|
411
|
-
entries: manifest.contents.length,
|
|
412
|
-
manifestBytes: manifestData.length,
|
|
413
|
-
maxEntries: MANIFEST_MAX_ENTRIES,
|
|
414
|
-
maxBytes: MANIFEST_MAX_BYTES,
|
|
415
|
-
},
|
|
416
|
-
"Bundle manifest exceeds the streaming import limits; teleport and staged-path imports will reject this bundle",
|
|
417
|
-
);
|
|
418
|
-
}
|
|
419
403
|
return { manifest, manifestData };
|
|
420
404
|
}
|
|
421
405
|
|
|
@@ -69,10 +69,7 @@ import {
|
|
|
69
69
|
verifySymlinkEntry,
|
|
70
70
|
} from "./vbundle-streaming-validator.js";
|
|
71
71
|
import { parseVBundleStream } from "./vbundle-tar-stream.js";
|
|
72
|
-
import {
|
|
73
|
-
MANIFEST_MAX_ENTRIES,
|
|
74
|
-
type ManifestType,
|
|
75
|
-
} from "./vbundle-validator.js";
|
|
72
|
+
import type { ManifestType } from "./vbundle-validator.js";
|
|
76
73
|
|
|
77
74
|
const log = getLogger("vbundle-streaming-importer");
|
|
78
75
|
|
|
@@ -94,11 +91,11 @@ const log = getLogger("vbundle-streaming-importer");
|
|
|
94
91
|
const DEFAULT_MAX_BUNDLE_BYTES = 16 * 1024 * 1024 * 1024;
|
|
95
92
|
|
|
96
93
|
/**
|
|
97
|
-
* Entry-count ceiling for the bundle
|
|
98
|
-
*
|
|
99
|
-
*
|
|
94
|
+
* Entry-count ceiling for the bundle. 100k is well above the largest
|
|
95
|
+
* workspace we ship; anything past that is almost certainly an attack or
|
|
96
|
+
* a corrupted archive.
|
|
100
97
|
*/
|
|
101
|
-
const DEFAULT_MAX_BUNDLE_ENTRIES =
|
|
98
|
+
const DEFAULT_MAX_BUNDLE_ENTRIES = 100_000;
|
|
102
99
|
|
|
103
100
|
/**
|
|
104
101
|
* Prefixes used for scratch dirs the streaming importer creates INSIDE the
|
|
@@ -156,7 +153,7 @@ export interface StreamCommitArgs {
|
|
|
156
153
|
maxBundleBytes?: number;
|
|
157
154
|
/**
|
|
158
155
|
* Test-only override for the entry-count ceiling. Production callers
|
|
159
|
-
* should omit this and rely on the
|
|
156
|
+
* should omit this and rely on the 100_000 default.
|
|
160
157
|
*/
|
|
161
158
|
maxBundleEntries?: number;
|
|
162
159
|
}
|
|
@@ -316,9 +313,7 @@ export async function streamCommitImport(
|
|
|
316
313
|
if (entryIndex === 0) {
|
|
317
314
|
// First entry MUST be manifest.json — readAndValidateManifest
|
|
318
315
|
// enforces that and throws StreamingValidationError otherwise.
|
|
319
|
-
const manifestResult = await readAndValidateManifest(entry
|
|
320
|
-
maxEntries: bundleEntryCap,
|
|
321
|
-
});
|
|
316
|
+
const manifestResult = await readAndValidateManifest(entry);
|
|
322
317
|
manifest = manifestResult.manifest;
|
|
323
318
|
expected = manifestResult.expected;
|
|
324
319
|
|
|
@@ -345,8 +340,18 @@ export async function streamCommitImport(
|
|
|
345
340
|
);
|
|
346
341
|
}
|
|
347
342
|
|
|
348
|
-
//
|
|
349
|
-
//
|
|
343
|
+
// Entry-count ceiling check. The manifest declares every file the
|
|
344
|
+
// bundle claims to contain, so one check here bounds the work the
|
|
345
|
+
// importer is willing to do for this bundle.
|
|
346
|
+
if (manifest.contents.length > bundleEntryCap) {
|
|
347
|
+
throw new StreamingValidationError(
|
|
348
|
+
"bundle_too_many_entries",
|
|
349
|
+
`bundle contains more than ${bundleEntryCap} entries (declared: ${manifest.contents.length})`,
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Only NOW — after the manifest is parsed, the version gate passes,
|
|
354
|
+
// and the entry-count ceiling is enforced — do we materialize the
|
|
350
355
|
// temp staging dir on disk. Doing this lazily preserves the plan
|
|
351
356
|
// invariant that importers gate on runtime-version compat BEFORE
|
|
352
357
|
// any state mutation. If this throws, the outer catch runs
|
|
@@ -28,8 +28,6 @@ import {
|
|
|
28
28
|
computeLegacyManifestSha256,
|
|
29
29
|
computeManifestChecksum,
|
|
30
30
|
LegacyManifestSchema,
|
|
31
|
-
MANIFEST_MAX_BYTES,
|
|
32
|
-
MANIFEST_MAX_ENTRIES,
|
|
33
31
|
ManifestSchema,
|
|
34
32
|
type ManifestType,
|
|
35
33
|
translateLegacyManifest,
|
|
@@ -76,17 +74,21 @@ export class StreamingValidationError extends Error {
|
|
|
76
74
|
// Manifest validation
|
|
77
75
|
// ---------------------------------------------------------------------------
|
|
78
76
|
|
|
77
|
+
// Manifests are metadata only — typically tens to hundreds of KB even for
|
|
78
|
+
// huge bundles. A 1 MiB cap is comfortably above realistic sizes and
|
|
79
|
+
// protects against a malicious archive whose "manifest" is actually a
|
|
80
|
+
// multi-GB stream intended to OOM the validator.
|
|
81
|
+
const MANIFEST_MAX_BYTES = 1 * 1024 * 1024;
|
|
82
|
+
|
|
79
83
|
/**
|
|
80
84
|
* Drain the first tar entry — which MUST be `manifest.json` — and run the
|
|
81
85
|
* full manifest-level validation pipeline:
|
|
82
86
|
* 1. Entry name check.
|
|
83
|
-
* 2. Size cap (
|
|
87
|
+
* 2. Size cap (1 MiB).
|
|
84
88
|
* 3. JSON parse.
|
|
85
89
|
* 4. Zod schema validation.
|
|
86
90
|
* 5. Self-referencing `checksum` verification against the
|
|
87
91
|
* canonicalized JSON (minus that field).
|
|
88
|
-
* 6. Entry-count ceiling on `contents` (`maxEntries`, default
|
|
89
|
-
* `MANIFEST_MAX_ENTRIES`).
|
|
90
92
|
*
|
|
91
93
|
* On success, returns the parsed manifest plus a `Map` keyed by archive
|
|
92
94
|
* path that callers consult as each subsequent entry streams past.
|
|
@@ -96,9 +98,7 @@ export class StreamingValidationError extends Error {
|
|
|
96
98
|
*/
|
|
97
99
|
export async function readAndValidateManifest(
|
|
98
100
|
first: StreamedTarEntry,
|
|
99
|
-
options: { maxEntries?: number } = {},
|
|
100
101
|
): Promise<ManifestReadResult> {
|
|
101
|
-
const maxEntries = options.maxEntries ?? MANIFEST_MAX_ENTRIES;
|
|
102
102
|
if (first.header.name !== "manifest.json") {
|
|
103
103
|
// Drain the body so the underlying tar extractor isn't left dangling
|
|
104
104
|
// on backpressure before the caller reports the error.
|
|
@@ -186,13 +186,6 @@ export async function readAndValidateManifest(
|
|
|
186
186
|
manifest = translateLegacyManifest(legacy);
|
|
187
187
|
}
|
|
188
188
|
|
|
189
|
-
if (manifest.contents.length > maxEntries) {
|
|
190
|
-
throw new StreamingValidationError(
|
|
191
|
-
"bundle_too_many_entries",
|
|
192
|
-
`bundle contains more than ${maxEntries} entries (declared: ${manifest.contents.length})`,
|
|
193
|
-
);
|
|
194
|
-
}
|
|
195
|
-
|
|
196
189
|
const expected = new Map<
|
|
197
190
|
string,
|
|
198
191
|
{ sha256: string; size: number; linkTarget: string | null }
|
|
@@ -381,33 +381,6 @@ export function computeManifestChecksum(manifest: unknown): string {
|
|
|
381
381
|
// Core validation
|
|
382
382
|
// ---------------------------------------------------------------------------
|
|
383
383
|
|
|
384
|
-
/**
|
|
385
|
-
* Hard caps on `manifest.json`, shared by the buffered and streaming
|
|
386
|
-
* validators so preflight and import agree on what is acceptable. The two
|
|
387
|
-
* are sized together: the manifest carries one `contents` entry (path,
|
|
388
|
-
* sha256, size) per bundled file at roughly 150 bytes each, so 200,000
|
|
389
|
-
* entries serialize to about 32 MiB.
|
|
390
|
-
*
|
|
391
|
-
* That absorbs a workspace with a stray dependency install or checked-out
|
|
392
|
-
* repo while still bounding the transient memory a hostile "manifest" can
|
|
393
|
-
* force the importer to hold (buffer, decoded string, parsed object,
|
|
394
|
-
* canonical re-serialization for the self-checksum) and the per-entry work
|
|
395
|
-
* it commits to, on a memory-constrained pod.
|
|
396
|
-
*/
|
|
397
|
-
export const MANIFEST_MAX_BYTES = 32 * 1024 * 1024;
|
|
398
|
-
export const MANIFEST_MAX_ENTRIES = 200_000;
|
|
399
|
-
|
|
400
|
-
export interface ValidateVBundleOptions {
|
|
401
|
-
/**
|
|
402
|
-
* Reject manifests over `MANIFEST_MAX_BYTES` or `MANIFEST_MAX_ENTRIES`.
|
|
403
|
-
* Preflights that precede a streaming import set this so they never
|
|
404
|
-
* approve a bundle the import will refuse. The buffered import path
|
|
405
|
-
* (`commitImport`) has no such ceilings, so it stays off there to keep
|
|
406
|
-
* existing large backups restorable.
|
|
407
|
-
*/
|
|
408
|
-
enforceStreamingLimits?: boolean;
|
|
409
|
-
}
|
|
410
|
-
|
|
411
384
|
// Only manifest.json is structurally required. The DB and config live under
|
|
412
385
|
// workspace/ (new format) or data/db/ + config/ (old format) — both are valid.
|
|
413
386
|
const REQUIRED_ENTRIES = ["manifest.json"];
|
|
@@ -424,10 +397,7 @@ const MAX_DECOMPRESSED_SIZE = 2 * 1024 * 1024 * 1024;
|
|
|
424
397
|
* 3. Manifest checksum (SHA-256 of canonicalized JSON with the `checksum` field set to empty string)
|
|
425
398
|
* 4. Per-file content integrity (SHA-256 of each file vs manifest declaration)
|
|
426
399
|
*/
|
|
427
|
-
export function validateVBundle(
|
|
428
|
-
data: Uint8Array,
|
|
429
|
-
{ enforceStreamingLimits = false }: ValidateVBundleOptions = {},
|
|
430
|
-
): VBundleValidationResult {
|
|
400
|
+
export function validateVBundle(data: Uint8Array): VBundleValidationResult {
|
|
431
401
|
const errors: ValidationError[] = [];
|
|
432
402
|
|
|
433
403
|
// Step 1: Decompress gzip with size cap to prevent zip-bomb DoS
|
|
@@ -485,17 +455,6 @@ export function validateVBundle(
|
|
|
485
455
|
}
|
|
486
456
|
|
|
487
457
|
// Step 4: Parse and validate manifest schema
|
|
488
|
-
if (
|
|
489
|
-
enforceStreamingLimits &&
|
|
490
|
-
manifestEntry.data.length > MANIFEST_MAX_BYTES
|
|
491
|
-
) {
|
|
492
|
-
errors.push({
|
|
493
|
-
code: "MANIFEST_TOO_LARGE",
|
|
494
|
-
message: `manifest.json exceeds ${MANIFEST_MAX_BYTES} byte limit (${manifestEntry.data.length} bytes)`,
|
|
495
|
-
path: "manifest.json",
|
|
496
|
-
});
|
|
497
|
-
return { is_valid: false, errors };
|
|
498
|
-
}
|
|
499
458
|
let manifestRaw: unknown;
|
|
500
459
|
try {
|
|
501
460
|
manifestRaw = JSON.parse(new TextDecoder().decode(manifestEntry.data));
|
|
@@ -564,18 +523,6 @@ export function validateVBundle(
|
|
|
564
523
|
manifest = translateLegacyManifest(legacy);
|
|
565
524
|
}
|
|
566
525
|
|
|
567
|
-
if (
|
|
568
|
-
enforceStreamingLimits &&
|
|
569
|
-
manifest.contents.length > MANIFEST_MAX_ENTRIES
|
|
570
|
-
) {
|
|
571
|
-
errors.push({
|
|
572
|
-
code: "MANIFEST_TOO_MANY_ENTRIES",
|
|
573
|
-
message: `manifest.json declares more than ${MANIFEST_MAX_ENTRIES} entries (${manifest.contents.length})`,
|
|
574
|
-
path: "manifest.json",
|
|
575
|
-
});
|
|
576
|
-
return { is_valid: false, errors };
|
|
577
|
-
}
|
|
578
|
-
|
|
579
526
|
// Step 6: Verify per-file content integrity
|
|
580
527
|
const manifestFilePaths = new Set(manifest.contents.map((f) => f.path));
|
|
581
528
|
|
|
@@ -2056,9 +2056,7 @@ export async function handleMigrationPreflightFromGcs({
|
|
|
2056
2056
|
}
|
|
2057
2057
|
|
|
2058
2058
|
const bytes = new Uint8Array(await upstream.arrayBuffer());
|
|
2059
|
-
const validationResult = validateVBundle(bytes
|
|
2060
|
-
enforceStreamingLimits: true,
|
|
2061
|
-
});
|
|
2059
|
+
const validationResult = validateVBundle(bytes);
|
|
2062
2060
|
|
|
2063
2061
|
if (!validationResult.is_valid || !validationResult.manifest) {
|
|
2064
2062
|
return {
|
|
@@ -486,7 +486,7 @@ export const hostShellTool = {
|
|
|
486
486
|
// Only the first handler to fire should wake the agent.
|
|
487
487
|
let completed = false;
|
|
488
488
|
|
|
489
|
-
child.on("close", (code
|
|
489
|
+
child.on("close", (code) => {
|
|
490
490
|
if (completed) {
|
|
491
491
|
return;
|
|
492
492
|
}
|
|
@@ -494,7 +494,6 @@ export const hostShellTool = {
|
|
|
494
494
|
clearTimeout(timer);
|
|
495
495
|
const result = collector.format(code, timedOut, timeoutSec, {
|
|
496
496
|
started: launch.didStart(),
|
|
497
|
-
signal: aborted ? null : signal,
|
|
498
497
|
});
|
|
499
498
|
// Cancel takes precedence over the SIGKILL-induced error result.
|
|
500
499
|
const status = aborted
|
|
@@ -668,13 +667,12 @@ export const hostShellTool = {
|
|
|
668
667
|
}
|
|
669
668
|
}
|
|
670
669
|
|
|
671
|
-
child.on("close", (code
|
|
670
|
+
child.on("close", (code) => {
|
|
672
671
|
clearTimeout(timer);
|
|
673
672
|
context.signal?.removeEventListener("abort", onAbort);
|
|
674
673
|
|
|
675
674
|
const result = collector.format(code, timedOut, timeoutSec, {
|
|
676
675
|
started: launch.didStart(),
|
|
677
|
-
signal: context.signal?.aborted ? null : signal,
|
|
678
676
|
});
|
|
679
677
|
|
|
680
678
|
resolve({
|
|
@@ -112,45 +112,3 @@ describe("formatShellOutput truncation", () => {
|
|
|
112
112
|
expect(result.content).toContain(OUTPUT_TRUNCATED_TAG);
|
|
113
113
|
});
|
|
114
114
|
});
|
|
115
|
-
|
|
116
|
-
describe("formatShellOutput kill reporting", () => {
|
|
117
|
-
test("names the signal and hints at memory for a SIGKILL", () => {
|
|
118
|
-
const result = formatShellOutput("", "", null, false, 120, {
|
|
119
|
-
signal: "SIGKILL",
|
|
120
|
-
});
|
|
121
|
-
expect(result.isError).toBe(true);
|
|
122
|
-
expect(result.content).toContain('<command_killed signal="SIGKILL" />');
|
|
123
|
-
expect(result.content).toContain("out of memory");
|
|
124
|
-
expect(result.content).not.toContain('code="null"');
|
|
125
|
-
expect(result.status).toBe('<command_killed signal="SIGKILL" />');
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
test("keeps the output produced before the kill", () => {
|
|
129
|
-
const result = formatShellOutput("partial", "", null, false, 120, {
|
|
130
|
-
signal: "SIGTERM",
|
|
131
|
-
});
|
|
132
|
-
expect(result.content.startsWith("partial\n")).toBe(true);
|
|
133
|
-
expect(result.content).toContain("killed by SIGTERM");
|
|
134
|
-
expect(result.content).not.toContain("out of memory");
|
|
135
|
-
});
|
|
136
|
-
|
|
137
|
-
test("does not infer a kill from a 128 + n exit code", () => {
|
|
138
|
-
const result = formatShellOutput("", "Killed", 137, false, 120);
|
|
139
|
-
expect(result.content).toBe("Killed");
|
|
140
|
-
expect(result.status).toBe('<command_exit code="137" />');
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
test("leaves a timeout framed as a timeout", () => {
|
|
144
|
-
const result = formatShellOutput("", "", null, true, 120, {
|
|
145
|
-
signal: "SIGKILL",
|
|
146
|
-
});
|
|
147
|
-
expect(result.content).toContain("<command_timeout");
|
|
148
|
-
expect(result.content).not.toContain("command_killed");
|
|
149
|
-
});
|
|
150
|
-
|
|
151
|
-
test("leaves an ordinary failure unchanged", () => {
|
|
152
|
-
const result = formatShellOutput("", "boom", 1, false, 120);
|
|
153
|
-
expect(result.content).toBe("boom");
|
|
154
|
-
expect(result.status).toBe('<command_exit code="1" />');
|
|
155
|
-
});
|
|
156
|
-
});
|
|
@@ -13,15 +13,6 @@ export interface ShellOutputResult {
|
|
|
13
13
|
isError: boolean;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
function describeKill(signal: NodeJS.Signals): string {
|
|
17
|
-
const tag = `<command_killed signal="${signal}" />`;
|
|
18
|
-
const hint =
|
|
19
|
-
signal === "SIGKILL"
|
|
20
|
-
? " A SIGKILL the command did not send itself usually means the system ran out of memory."
|
|
21
|
-
: "";
|
|
22
|
-
return `${tag}\nCommand was killed by ${signal} before it finished.${hint}`;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
16
|
type StdioStream = "stdout" | "stderr";
|
|
26
17
|
|
|
27
18
|
type DataListener = (data: Buffer | string) => void;
|
|
@@ -43,11 +34,7 @@ export function formatShellOutput(
|
|
|
43
34
|
code: number | null,
|
|
44
35
|
timedOut: boolean,
|
|
45
36
|
timeoutSec: number,
|
|
46
|
-
options?: {
|
|
47
|
-
truncated?: boolean;
|
|
48
|
-
started?: boolean;
|
|
49
|
-
signal?: NodeJS.Signals | null;
|
|
50
|
-
},
|
|
37
|
+
options?: { truncated?: boolean; started?: boolean },
|
|
51
38
|
): ShellOutputResult {
|
|
52
39
|
if (options?.started === false) {
|
|
53
40
|
return {
|
|
@@ -79,12 +66,6 @@ export function formatShellOutput(
|
|
|
79
66
|
statusParts.push(OUTPUT_TRUNCATED_TAG);
|
|
80
67
|
}
|
|
81
68
|
|
|
82
|
-
const killSignal = timedOut ? null : (options?.signal ?? null);
|
|
83
|
-
if (killSignal) {
|
|
84
|
-
output += (output ? "\n" : "") + describeKill(killSignal);
|
|
85
|
-
statusParts.push(`<command_killed signal="${killSignal}" />`);
|
|
86
|
-
}
|
|
87
|
-
|
|
88
69
|
if (!output.trim()) {
|
|
89
70
|
if (code === 0) {
|
|
90
71
|
output = "<command_completed />";
|
|
@@ -93,7 +74,7 @@ export function formatShellOutput(
|
|
|
93
74
|
output = `${exitTag}\nCommand failed with exit code ${code}. No stdout or stderr output was produced.`;
|
|
94
75
|
statusParts.push(exitTag);
|
|
95
76
|
}
|
|
96
|
-
} else if (code !== 0 && !timedOut
|
|
77
|
+
} else if (code !== 0 && !timedOut) {
|
|
97
78
|
statusParts.push(`<command_exit code="${code}" />`);
|
|
98
79
|
}
|
|
99
80
|
|
|
@@ -167,7 +148,7 @@ export class BoundedStdioCollector {
|
|
|
167
148
|
code: number | null,
|
|
168
149
|
timedOut: boolean,
|
|
169
150
|
timeoutSec: number,
|
|
170
|
-
options?: { started?: boolean
|
|
151
|
+
options?: { started?: boolean },
|
|
171
152
|
): ShellOutputResult {
|
|
172
153
|
return formatShellOutput(
|
|
173
154
|
Buffer.concat(this.stdoutParts).toString(),
|
|
@@ -175,11 +156,7 @@ export class BoundedStdioCollector {
|
|
|
175
156
|
code,
|
|
176
157
|
timedOut,
|
|
177
158
|
timeoutSec,
|
|
178
|
-
{
|
|
179
|
-
truncated: this.truncated,
|
|
180
|
-
started: options?.started,
|
|
181
|
-
signal: options?.signal,
|
|
182
|
-
},
|
|
159
|
+
{ truncated: this.truncated, started: options?.started },
|
|
183
160
|
);
|
|
184
161
|
}
|
|
185
162
|
}
|
|
@@ -374,7 +374,6 @@ export const shellTool = {
|
|
|
374
374
|
|
|
375
375
|
const fmtResult = collector.format(code, timedOut, timeoutSec, {
|
|
376
376
|
started: launch.didStart(),
|
|
377
|
-
signal: aborted ? null : signal,
|
|
378
377
|
});
|
|
379
378
|
|
|
380
379
|
const status: BackgroundToolCompletedEvent["status"] = aborted
|
|
@@ -586,7 +585,6 @@ export const shellTool = {
|
|
|
586
585
|
|
|
587
586
|
const fmtResult = collector.format(code, timedOut, timeoutSec, {
|
|
588
587
|
started: launch.didStart(),
|
|
589
|
-
signal: context.signal?.aborted ? null : signal,
|
|
590
588
|
});
|
|
591
589
|
|
|
592
590
|
resolve({
|
|
@@ -1,143 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Verifies the IO Intelligence (io.net) provider wiring: the SDK client must
|
|
3
|
-
* resolve to the documented IO Intelligence chat-completions endpoint unless
|
|
4
|
-
* an explicit override is supplied (custom-connection / test usage).
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import { describe, expect, test } from "bun:test";
|
|
8
|
-
|
|
9
|
-
import { IonetProvider } from "./client.js";
|
|
10
|
-
|
|
11
|
-
type MockChunk = {
|
|
12
|
-
choices: Array<{
|
|
13
|
-
delta: { content?: string | null };
|
|
14
|
-
finish_reason?: string | null;
|
|
15
|
-
}>;
|
|
16
|
-
usage?: { prompt_tokens: number; completion_tokens: number };
|
|
17
|
-
};
|
|
18
|
-
|
|
19
|
-
const OK_CHUNKS: MockChunk[] = [
|
|
20
|
-
{
|
|
21
|
-
choices: [{ delta: { content: "ok" }, finish_reason: "stop" }],
|
|
22
|
-
usage: { prompt_tokens: 1, completion_tokens: 1 },
|
|
23
|
-
},
|
|
24
|
-
];
|
|
25
|
-
|
|
26
|
-
function makeStream(chunks: MockChunk[]): AsyncIterable<MockChunk> {
|
|
27
|
-
return {
|
|
28
|
-
async *[Symbol.asyncIterator]() {
|
|
29
|
-
for (const c of chunks) {
|
|
30
|
-
yield c;
|
|
31
|
-
}
|
|
32
|
-
},
|
|
33
|
-
};
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
function clientOf(provider: IonetProvider) {
|
|
37
|
-
return (
|
|
38
|
-
provider as unknown as {
|
|
39
|
-
client: {
|
|
40
|
-
baseURL: string;
|
|
41
|
-
chat: {
|
|
42
|
-
completions: {
|
|
43
|
-
create: (params: unknown) => Promise<AsyncIterable<MockChunk>>;
|
|
44
|
-
};
|
|
45
|
-
};
|
|
46
|
-
};
|
|
47
|
-
}
|
|
48
|
-
).client;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
describe("IonetProvider", () => {
|
|
52
|
-
test("defaults the SDK client to the IO Intelligence endpoint", async () => {
|
|
53
|
-
const provider = new IonetProvider(
|
|
54
|
-
"test-key",
|
|
55
|
-
"meta-llama/Llama-3.3-70B-Instruct",
|
|
56
|
-
);
|
|
57
|
-
|
|
58
|
-
const client = clientOf(provider);
|
|
59
|
-
expect(client.baseURL).toBe("https://api.intelligence.io.solutions/api/v1");
|
|
60
|
-
|
|
61
|
-
// The same base URL must be reported on the response, read off the live
|
|
62
|
-
// client instance rather than a constant.
|
|
63
|
-
client.chat.completions.create = async () => makeStream(OK_CHUNKS);
|
|
64
|
-
const response = await provider.sendMessage([
|
|
65
|
-
{ role: "user", content: [{ type: "text", text: "hi" }] },
|
|
66
|
-
]);
|
|
67
|
-
expect(response.resolvedEndpoint).toBe(
|
|
68
|
-
"https://api.intelligence.io.solutions/api/v1",
|
|
69
|
-
);
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
test("sends tool_choice auto when tools are offered without an explicit choice", async () => {
|
|
73
|
-
// io.net defaults an unspecified tool_choice to "none" (not OpenAI's
|
|
74
|
-
// "auto"), so the provider must send the explicit default or the model
|
|
75
|
-
// could never invoke an offered tool.
|
|
76
|
-
const provider = new IonetProvider(
|
|
77
|
-
"test-key",
|
|
78
|
-
"meta-llama/Llama-3.3-70B-Instruct",
|
|
79
|
-
);
|
|
80
|
-
const client = clientOf(provider);
|
|
81
|
-
|
|
82
|
-
let seenParams:
|
|
83
|
-
| {
|
|
84
|
-
tools?: unknown;
|
|
85
|
-
tool_choice?: unknown;
|
|
86
|
-
}
|
|
87
|
-
| undefined;
|
|
88
|
-
client.chat.completions.create = async (params) => {
|
|
89
|
-
seenParams = params as { tools?: unknown; tool_choice?: unknown };
|
|
90
|
-
return makeStream(OK_CHUNKS);
|
|
91
|
-
};
|
|
92
|
-
await provider.sendMessage(
|
|
93
|
-
[{ role: "user", content: [{ type: "text", text: "hi" }] }],
|
|
94
|
-
{
|
|
95
|
-
tools: [
|
|
96
|
-
{
|
|
97
|
-
name: "get_weather",
|
|
98
|
-
description: "Get the weather",
|
|
99
|
-
input_schema: { type: "object", properties: {} },
|
|
100
|
-
},
|
|
101
|
-
],
|
|
102
|
-
},
|
|
103
|
-
);
|
|
104
|
-
expect(Array.isArray(seenParams?.tools)).toBe(true);
|
|
105
|
-
expect(seenParams?.tool_choice).toBe("auto");
|
|
106
|
-
|
|
107
|
-
// An explicit caller choice still wins.
|
|
108
|
-
let forcedParams: { tool_choice?: unknown } | undefined;
|
|
109
|
-
client.chat.completions.create = async (params) => {
|
|
110
|
-
forcedParams = params as { tool_choice?: unknown };
|
|
111
|
-
return makeStream(OK_CHUNKS);
|
|
112
|
-
};
|
|
113
|
-
await provider.sendMessage(
|
|
114
|
-
[{ role: "user", content: [{ type: "text", text: "hi" }] }],
|
|
115
|
-
{
|
|
116
|
-
tools: [
|
|
117
|
-
{
|
|
118
|
-
name: "get_weather",
|
|
119
|
-
description: "Get the weather",
|
|
120
|
-
input_schema: { type: "object", properties: {} },
|
|
121
|
-
},
|
|
122
|
-
],
|
|
123
|
-
config: { tool_choice: { type: "none" } },
|
|
124
|
-
},
|
|
125
|
-
);
|
|
126
|
-
expect(forcedParams?.tool_choice).toBe("none");
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
test("honours an explicit baseURL override", async () => {
|
|
130
|
-
const provider = new IonetProvider("test-key", "test-model", {
|
|
131
|
-
baseURL: "https://inference.example.test/v1",
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
const client = clientOf(provider);
|
|
135
|
-
expect(client.baseURL).toBe("https://inference.example.test/v1");
|
|
136
|
-
|
|
137
|
-
client.chat.completions.create = async () => makeStream(OK_CHUNKS);
|
|
138
|
-
const response = await provider.sendMessage([
|
|
139
|
-
{ role: "user", content: [{ type: "text", text: "hi" }] },
|
|
140
|
-
]);
|
|
141
|
-
expect(response.resolvedEndpoint).toBe("https://inference.example.test/v1");
|
|
142
|
-
});
|
|
143
|
-
});
|
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
import { OpenAIChatCompletionsProvider } from "../openai/chat-completions-provider.js";
|
|
2
|
-
|
|
3
|
-
export interface IonetProviderOptions {
|
|
4
|
-
apiKey?: string;
|
|
5
|
-
baseURL?: string;
|
|
6
|
-
streamTimeoutMs?: number;
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
const DEFAULT_IONET_BASE_URL = "https://api.intelligence.io.solutions/api/v1";
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* IO Intelligence (io.net) serves an OpenAI chat-completions API with
|
|
13
|
-
* Hugging Face-style `org/name` model ids, so it rides the shared
|
|
14
|
-
* chat-completions client with its own default base URL.
|
|
15
|
-
*/
|
|
16
|
-
export class IonetProvider extends OpenAIChatCompletionsProvider {
|
|
17
|
-
constructor(
|
|
18
|
-
apiKey: string,
|
|
19
|
-
model: string,
|
|
20
|
-
options: IonetProviderOptions = {},
|
|
21
|
-
) {
|
|
22
|
-
super(apiKey, model, {
|
|
23
|
-
baseURL: options.baseURL?.trim() || DEFAULT_IONET_BASE_URL,
|
|
24
|
-
providerName: "ionet",
|
|
25
|
-
providerLabel: "IO Intelligence",
|
|
26
|
-
streamTimeoutMs: options.streamTimeoutMs,
|
|
27
|
-
// IO Intelligence serves DeepSeek- and GLM-style thinking models whose
|
|
28
|
-
// reasoning arrives as `reasoning_content`; replay it there so
|
|
29
|
-
// follow-up requests that include tools stay acceptable upstream.
|
|
30
|
-
assistantReasoningField: "reasoning_content",
|
|
31
|
-
// io.net defaults an unspecified `tool_choice` to "none" (unlike
|
|
32
|
-
// OpenAI's "auto"), so tools offered without an explicit choice must
|
|
33
|
-
// still send one or the model could never invoke them.
|
|
34
|
-
defaultToolChoiceAuto: true,
|
|
35
|
-
});
|
|
36
|
-
}
|
|
37
|
-
}
|