@vellumai/assistant 0.11.4-dev.202608201710.fcf0423 → 0.11.4-dev.202608201811.0c1594d

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.
Files changed (31) hide show
  1. package/openapi.yaml +19 -2
  2. package/package.json +1 -1
  3. package/src/__tests__/credential-routes.test.ts +22 -3
  4. package/src/__tests__/daemon-credential-client.test.ts +88 -0
  5. package/src/__tests__/dm-backfill.test.ts +31 -0
  6. package/src/__tests__/input-repairs.test.ts +207 -0
  7. package/src/__tests__/secret-routes-platform-proxy.test.ts +7 -0
  8. package/src/__tests__/skill-tool-factory.test.ts +161 -0
  9. package/src/__tests__/validate-input.test.ts +243 -7
  10. package/src/agent/loop.ts +1 -7
  11. package/src/cli/commands/__tests__/keys.test.ts +89 -8
  12. package/src/cli/commands/keys.help.ts +13 -1
  13. package/src/cli/commands/keys.ts +26 -15
  14. package/src/cli/lib/daemon-credential-client.ts +25 -3
  15. package/src/plugins/defaults/memory/memory-retrospective-prompt.ts +1 -1
  16. package/src/providers/connection-resolution.ts +2 -1
  17. package/src/providers/content-blocks.ts +22 -0
  18. package/src/providers/inference/__tests__/missing-credential-guard.test.ts +117 -0
  19. package/src/providers/inference/adapter-factory.ts +13 -1
  20. package/src/providers/inference/credential-usage.ts +37 -0
  21. package/src/providers/inference/missing-credential-guard.ts +110 -0
  22. package/src/runtime/http-errors.ts +1 -0
  23. package/src/runtime/routes/__tests__/credential-delete-in-use.test.ts +155 -0
  24. package/src/runtime/routes/credential-in-use.ts +85 -0
  25. package/src/runtime/routes/credential-routes.ts +29 -3
  26. package/src/runtime/routes/inbound-message-handler.ts +36 -7
  27. package/src/runtime/routes/secret-routes.ts +39 -8
  28. package/src/skills/validate-input.ts +177 -40
  29. package/src/tools/schema-transforms.ts +40 -5
  30. package/src/tools/shared/input-repairs.ts +160 -0
  31. package/src/tools/skills/skill-tool-factory.ts +18 -4
package/openapi.yaml CHANGED
@@ -9886,7 +9886,10 @@ paths:
9886
9886
  post:
9887
9887
  operationId: credentials_delete_post
9888
9888
  summary: Delete a credential
9889
- description: Remove a secret, its metadata, and any associated OAuth connection from the vault.
9889
+ description:
9890
+ Remove a secret, its metadata, and any associated OAuth connection from the vault. Refused with
9891
+ CREDENTIAL_IN_USE while an LLM provider connection resolves its auth through the credential, unless `force` is
9892
+ set.
9890
9893
  tags:
9891
9894
  - credentials
9892
9895
  requestBody:
@@ -9902,6 +9905,9 @@ paths:
9902
9905
  field:
9903
9906
  type: string
9904
9907
  description: Field name
9908
+ force:
9909
+ description: Delete even when provider connections depend on the credential
9910
+ type: boolean
9905
9911
  required:
9906
9912
  - service
9907
9913
  - field
@@ -9917,9 +9923,15 @@ paths:
9917
9923
  type: string
9918
9924
  field:
9919
9925
  type: string
9926
+ affectedConnections:
9927
+ type: array
9928
+ items:
9929
+ type: string
9930
+ description: Provider connections that depended on the deleted credential
9920
9931
  required:
9921
9932
  - service
9922
9933
  - field
9934
+ - affectedConnections
9923
9935
  additionalProperties: false
9924
9936
  /v1/credentials/inspect:
9925
9937
  post:
@@ -27991,7 +28003,9 @@ paths:
27991
28003
  delete:
27992
28004
  operationId: secrets_delete
27993
28005
  summary: Delete a secret
27994
- description: Remove a secret from the credential vault by name.
28006
+ description:
28007
+ Remove a secret from the credential vault by name. Refused with CREDENTIAL_IN_USE while an LLM provider
28008
+ connection resolves its auth through the credential, unless `force` is set.
27995
28009
  tags:
27996
28010
  - secrets
27997
28011
  requestBody:
@@ -28007,6 +28021,9 @@ paths:
28007
28021
  name:
28008
28022
  type: string
28009
28023
  description: Name of the secret to delete
28024
+ force:
28025
+ description: Delete even when provider connections depend on the credential
28026
+ type: boolean
28010
28027
  required:
28011
28028
  - type
28012
28029
  - name
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.11.4-dev.202608201710.fcf0423",
3
+ "version": "0.11.4-dev.202608201811.0c1594d",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -146,6 +146,7 @@ mock.module("../tools/credentials/broker.js", () => ({
146
146
  }));
147
147
 
148
148
  import { persistPromptedCredential } from "../credential-execution/prompted-credential.js";
149
+ import { initializeDb } from "../persistence/db-init.js";
149
150
  import {
150
151
  forChatMintsSince,
151
152
  resetForChatMintRegistryForTest,
@@ -158,6 +159,12 @@ import {
158
159
  } from "../runtime/reveal-success-registry.js";
159
160
  import { ROUTES } from "../runtime/routes/credential-routes.js";
160
161
 
162
+ // A delete looks up the provider connections that resolve their auth through
163
+ // the credential, so the inference schema has to exist. No connection is
164
+ // created here: the refusal is covered by
165
+ // runtime/routes/__tests__/credential-delete-in-use.test.ts.
166
+ await initializeDb();
167
+
161
168
  const setRoute = ROUTES.find((r) => r.operationId === "credentials_set");
162
169
  const listRoute = ROUTES.find((r) => r.operationId === "credentials_list");
163
170
  const deleteRoute = ROUTES.find((r) => r.operationId === "credentials_delete");
@@ -173,7 +180,11 @@ type ListResponse = {
173
180
  }>;
174
181
  managedCredentials: unknown[];
175
182
  };
176
- type DeleteResponse = { service: string; field: string };
183
+ type DeleteResponse = {
184
+ service: string;
185
+ field: string;
186
+ affectedConnections: string[];
187
+ };
177
188
 
178
189
  const SECRET_VALUE = "super-secret-token-value";
179
190
 
@@ -786,7 +797,11 @@ describe("credentials routes", () => {
786
797
  })) as DeleteResponse;
787
798
 
788
799
  // THEN the response echoes the identifiers
789
- expect(result).toEqual({ service: "vercel", field: "api_token" });
800
+ expect(result).toEqual({
801
+ service: "vercel",
802
+ field: "api_token",
803
+ affectedConnections: [],
804
+ });
790
805
 
791
806
  // AND the secret and metadata are gone
792
807
  expect(secureStore.has("vercel:api_token")).toBe(false);
@@ -811,7 +826,11 @@ describe("credentials routes", () => {
811
826
  })) as DeleteResponse;
812
827
 
813
828
  // THEN the response echoes the identifiers
814
- expect(result).toEqual({ service: "slack_channel", field: "user_token" });
829
+ expect(result).toEqual({
830
+ service: "slack_channel",
831
+ field: "user_token",
832
+ affectedConnections: [],
833
+ });
815
834
 
816
835
  // AND only the user_token is removed; bot + app tokens remain
817
836
  expect(secureStore.has("slack_channel:user_token")).toBe(false);
@@ -104,6 +104,60 @@ describe("daemon credential client", () => {
104
104
  expect(result.error).toBe("Credential store is unreachable");
105
105
  });
106
106
 
107
+ test("refuses to delete around the in-use guard when the daemon is down", async () => {
108
+ /**
109
+ * The guard runs daemon-side, so a stopped daemon must not turn into an
110
+ * unchecked direct delete.
111
+ */
112
+
113
+ // GIVEN a stored credential and no reachable daemon
114
+ _ipcResponse = {
115
+ ok: false,
116
+ error: "Could not connect to assistant daemon. Is it running?",
117
+ };
118
+ await setSecureKeyViaDaemon("api_key", "guarded-provider", "keep-me");
119
+
120
+ // WHEN the delete is attempted without force
121
+ const result = await deleteSecureKeyViaDaemon(
122
+ "api_key",
123
+ "guarded-provider",
124
+ );
125
+
126
+ // THEN it is refused and the credential survives
127
+ expect(result.result).toBe("error");
128
+ expect(result.code).toBe("IN_USE_CHECK_UNAVAILABLE");
129
+ expect(result.error).toContain("--force");
130
+ expect(
131
+ await getSecureKeyAsync(credentialKey("guarded-provider", "api_key")),
132
+ ).toBe("keep-me");
133
+ });
134
+
135
+ test("deletes directly when the caller forces past the unavailable guard", async () => {
136
+ /**
137
+ * An explicit --force still works with the daemon down.
138
+ */
139
+
140
+ // GIVEN a stored credential and no reachable daemon
141
+ _ipcResponse = {
142
+ ok: false,
143
+ error: "Could not connect to assistant daemon. Is it running?",
144
+ };
145
+ await setSecureKeyViaDaemon("api_key", "forced-provider", "drop-me");
146
+
147
+ // WHEN the delete is forced
148
+ const result = await deleteSecureKeyViaDaemon(
149
+ "api_key",
150
+ "forced-provider",
151
+ true,
152
+ );
153
+
154
+ // THEN the credential is gone from secure storage
155
+ expect(result.result).toBe("deleted");
156
+ expect(
157
+ await getSecureKeyAsync(credentialKey("forced-provider", "api_key")),
158
+ ).toBeUndefined();
159
+ });
160
+
107
161
  test("returns not-found for 404 errors", async () => {
108
162
  _ipcResponse = {
109
163
  ok: false,
@@ -167,5 +221,39 @@ describe("daemon credential client", () => {
167
221
  body: { type: "credential", name: "github-app:pem" },
168
222
  });
169
223
  });
224
+
225
+ test("delete carries the caller's force choice past the in-use guard", async () => {
226
+ // GIVEN a daemon that accepts the delete
227
+ _ipcResponse = { ok: true, result: { success: true } };
228
+ _lastIpcCall = null;
229
+
230
+ // WHEN the caller forces the delete
231
+ await deleteSecureKeyViaDaemon("api_key", "agentrouter", true);
232
+
233
+ // THEN `force` rides along in the request body
234
+ const captured = _lastIpcCall as {
235
+ method: string;
236
+ params: unknown;
237
+ } | null;
238
+ expect(captured?.params).toEqual({
239
+ body: { type: "api_key", name: "agentrouter", force: true },
240
+ });
241
+ });
242
+
243
+ test("delete surfaces the in-use refusal from the daemon", async () => {
244
+ // GIVEN a credential an LLM provider connection resolves its auth through
245
+ _ipcResponse = {
246
+ ok: false,
247
+ error:
248
+ 'Credential credential/agentrouter/api_key is in use by connection "agentrouter". It will stop working without it. Delete it anyway to continue.',
249
+ };
250
+
251
+ // WHEN the delete is attempted without force
252
+ const result = await deleteSecureKeyViaDaemon("api_key", "agentrouter");
253
+
254
+ // THEN the refusal reaches the caller naming the dependent connection
255
+ expect(result.result).toBe("error");
256
+ expect(result.error).toContain('in use by connection "agentrouter"');
257
+ });
170
258
  });
171
259
  });
@@ -587,4 +587,35 @@ describe("PR 23 — Slack DM cold-start backfill", () => {
587
587
  const texts = rows.map((r) => r.content).sort();
588
588
  expect(texts).toEqual(["older A", "older B", "older D"]);
589
589
  });
590
+
591
+ test("backfill refuses history rows carrying a secret, keeping the rest", async () => {
592
+ const leakedKey = "AKIA3H7QWERTY9MNBVC2";
593
+ backfillDmMock.mockImplementation(async () => [
594
+ makeBackfilledMessage({
595
+ id: "1700000000.000001",
596
+ text: "ordinary older chatter",
597
+ }),
598
+ makeBackfilledMessage({
599
+ id: "1700000000.000002",
600
+ text: `here is the access key ${leakedKey} for the deploy`,
601
+ }),
602
+ ]);
603
+
604
+ await handleChannelInbound(
605
+ buildDmRequest("live new DM"),
606
+ noopProcessMessage,
607
+ TEST_BEARER_TOKEN,
608
+ );
609
+
610
+ const rows = readPersistedSlackRows();
611
+ expect(rows.map((r) => r.content)).toEqual(["ordinary older chatter"]);
612
+ // `rawContent`, not `content`: the latter is unwrapped by the test
613
+ // helper, so only the raw column proves what reached storage.
614
+ for (const row of rows) {
615
+ expect(row.rawContent).not.toContain(leakedKey);
616
+ }
617
+ expect(
618
+ rows.some((r) => r.slackMeta?.channelTs === "1700000000.000002"),
619
+ ).toBe(false);
620
+ });
590
621
  });
@@ -0,0 +1,207 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { describe, expect, test } from "bun:test";
4
+
5
+ import type { SkillToolEntry } from "../config/skills.js";
6
+ import {
7
+ bundledToolInputAliases,
8
+ bundledToolInputRepairs,
9
+ } from "../tools/shared/input-repairs.js";
10
+
11
+ const TOOL = "scaffold_managed_skill";
12
+ const FIND_TOOL = "find_similar_skills";
13
+
14
+ /** The real manifest entries the repairs describe. */
15
+ function manifestEntry(name: string): SkillToolEntry {
16
+ const manifest = JSON.parse(
17
+ readFileSync(
18
+ join(
19
+ import.meta.dir,
20
+ "../config/bundled-skills/skill-management/TOOLS.json",
21
+ ),
22
+ "utf-8",
23
+ ),
24
+ ) as { tools: SkillToolEntry[] };
25
+ const entry = manifest.tools.find((t) => t.name === name);
26
+ if (!entry) {
27
+ throw new Error(`${name} is missing from the skill-management TOOLS.json`);
28
+ }
29
+ return entry;
30
+ }
31
+
32
+ function declaredProperties(name: string): Record<string, { type?: string }> {
33
+ const schema = manifestEntry(name).input_schema as {
34
+ properties: Record<string, { type?: string }>;
35
+ };
36
+ return schema.properties;
37
+ }
38
+
39
+ describe("bundledToolInputRepairs: parameter aliases", () => {
40
+ test("reads `body` as the declared `body_markdown`", () => {
41
+ const result = bundledToolInputRepairs(TOOL, {
42
+ skill_id: "deploy",
43
+ body: "# Deploy\n",
44
+ });
45
+ expect(result).toEqual({ skill_id: "deploy", body_markdown: "# Deploy\n" });
46
+ expect("body" in result).toBe(false);
47
+ });
48
+
49
+ test("never overwrites a call that spells the parameter correctly", () => {
50
+ const input = { body: "alias", body_markdown: "declared" };
51
+ const result = bundledToolInputRepairs(TOOL, input);
52
+ expect(result).toBe(input);
53
+ });
54
+
55
+ test("leaves a tool with no alias table alone", () => {
56
+ const input = { body: "# Deploy\n" };
57
+ expect(bundledToolInputRepairs("file_write", input)).toBe(input);
58
+ });
59
+
60
+ test.each([TOOL, FIND_TOOL])(
61
+ "%s: no alias shadows a parameter the tool declares",
62
+ (tool) => {
63
+ const declared = declaredProperties(tool);
64
+ const aliases = bundledToolInputAliases(tool);
65
+ expect(aliases.length).toBeGreaterThan(0);
66
+ for (const alias of aliases) {
67
+ expect(declared[alias]).toBeUndefined();
68
+ }
69
+ },
70
+ );
71
+
72
+ test.each([TOOL, FIND_TOOL])(
73
+ "%s: every alias targets a parameter the tool declares",
74
+ (tool) => {
75
+ const declared = declaredProperties(tool);
76
+ for (const alias of bundledToolInputAliases(tool)) {
77
+ const result = bundledToolInputRepairs(tool, { [alias]: "value" });
78
+ const target = Object.keys(result).find((key) => key !== alias);
79
+ expect(target).toBeDefined();
80
+ expect(declared[target!]).toBeDefined();
81
+ }
82
+ },
83
+ );
84
+
85
+ test.each([
86
+ ["title", "name"],
87
+ ["summary", "description"],
88
+ ["content", "body_markdown"],
89
+ ])("reads `%s` as `%s`", (alias, canonical) => {
90
+ const result = bundledToolInputRepairs(TOOL, { [alias]: "value" });
91
+ expect(result).toEqual({ [canonical]: "value" });
92
+ });
93
+
94
+ test.each(["description", "query"])(
95
+ "find_similar_skills reads `%s` as `goal`",
96
+ (alias) => {
97
+ const result = bundledToolInputRepairs(FIND_TOOL, {
98
+ [alias]: "deploy staging",
99
+ });
100
+ expect(result).toEqual({ goal: "deploy staging" });
101
+ },
102
+ );
103
+ });
104
+
105
+ describe("bundledToolInputRepairs: files written as a path-keyed map", () => {
106
+ test("reads string values as each file's contents", () => {
107
+ const result = bundledToolInputRepairs(TOOL, {
108
+ files: {
109
+ "references/failure-modes.md": "The retry loop stalls.",
110
+ "scripts/run.sh": "echo hi",
111
+ },
112
+ });
113
+ expect(result.files).toEqual([
114
+ {
115
+ path: "references/failure-modes.md",
116
+ content: "The retry loop stalls.",
117
+ },
118
+ { path: "scripts/run.sh", content: "echo hi" },
119
+ ]);
120
+ });
121
+
122
+ test("reads an entry that names content or copy_from", () => {
123
+ const result = bundledToolInputRepairs(TOOL, {
124
+ files: {
125
+ "references/notes.md": { content: "hi" },
126
+ "scripts/run.sh": { copy_from: "/tmp/run.sh" },
127
+ },
128
+ });
129
+ expect(result.files).toEqual([
130
+ { path: "references/notes.md", content: "hi" },
131
+ { path: "scripts/run.sh", copy_from: "/tmp/run.sh" },
132
+ ]);
133
+ });
134
+
135
+ test("leaves one entry spelling out its own fields, which is a file not a map", () => {
136
+ const input = { files: { path: "references/notes.md", content: "hi" } };
137
+ expect(bundledToolInputRepairs(TOOL, input)).toBe(input);
138
+ });
139
+
140
+ test("leaves the whole value when any entry is unreadable", () => {
141
+ const input = {
142
+ files: {
143
+ "references/notes.md": "hi",
144
+ "scripts/run.sh": { mode: 755 },
145
+ },
146
+ };
147
+ expect(bundledToolInputRepairs(TOOL, input)).toBe(input);
148
+ });
149
+
150
+ test("leaves a map with a blank path", () => {
151
+ const input = { files: { " ": "hi" } };
152
+ expect(bundledToolInputRepairs(TOOL, input)).toBe(input);
153
+ });
154
+
155
+ test("leaves an empty object", () => {
156
+ const input = { files: {} };
157
+ expect(bundledToolInputRepairs(TOOL, input)).toBe(input);
158
+ });
159
+
160
+ test("an entry spelling out every declared file property is read as one file", () => {
161
+ const schema = manifestEntry(TOOL).input_schema as {
162
+ properties: {
163
+ files: { items: { properties: Record<string, unknown> } };
164
+ };
165
+ };
166
+ const declared = Object.keys(schema.properties.files.items.properties);
167
+ expect(declared).toContain("path");
168
+ const entry = Object.fromEntries(declared.map((key) => [key, "value"]));
169
+
170
+ const input = { files: entry };
171
+ expect(bundledToolInputRepairs(TOOL, input)).toBe(input);
172
+ });
173
+
174
+ test("leaves files that already arrived as an array", () => {
175
+ const input = { files: [{ path: "references/notes.md", content: "hi" }] };
176
+ expect(bundledToolInputRepairs(TOOL, input)).toBe(input);
177
+ });
178
+ });
179
+
180
+ describe("bundledToolInputRepairs: purity", () => {
181
+ test("returns the same object when there is nothing to repair", () => {
182
+ const input = { skill_id: "deploy", body_markdown: "# Deploy\n" };
183
+ expect(bundledToolInputRepairs(TOOL, input)).toBe(input);
184
+ });
185
+
186
+ test("returns a new object and never mutates the original input", () => {
187
+ const input = {
188
+ body: "# Deploy\n",
189
+ files: { "references/notes.md": "hi" },
190
+ };
191
+ const snapshot = JSON.parse(JSON.stringify(input));
192
+ const result = bundledToolInputRepairs(TOOL, input);
193
+ expect(result).not.toBe(input);
194
+ expect(input).toEqual(snapshot);
195
+ });
196
+
197
+ test("repairs an alias and a files map in the same call", () => {
198
+ const result = bundledToolInputRepairs(TOOL, {
199
+ body: "# Deploy\n",
200
+ files: { "references/notes.md": "hi" },
201
+ });
202
+ expect(result).toEqual({
203
+ body_markdown: "# Deploy\n",
204
+ files: [{ path: "references/notes.md", content: "hi" }],
205
+ });
206
+ });
207
+ });
@@ -103,6 +103,7 @@ mock.module("../daemon/conversation-store.js", () => ({
103
103
  },
104
104
  }));
105
105
 
106
+ import { initializeDb } from "../persistence/db-init.js";
106
107
  import {
107
108
  getProviderRoutingSource,
108
109
  initializeProviders,
@@ -113,6 +114,12 @@ import {
113
114
  ROUTES,
114
115
  } from "../runtime/routes/secret-routes.js";
115
116
 
117
+ // A delete looks up the provider connections that resolve their auth through
118
+ // the credential, so the inference schema has to exist. No connection is
119
+ // created here: the refusal is covered by
120
+ // runtime/routes/__tests__/credential-delete-in-use.test.ts.
121
+ await initializeDb();
122
+
116
123
  const addRoute = ROUTES.find(
117
124
  (r) => r.method === "POST" && r.endpoint === "secrets",
118
125
  )!;
@@ -470,6 +470,75 @@ describe("createSkillTool — required/type/enum validation", () => {
470
470
  );
471
471
  });
472
472
 
473
+ test("repairs array shapes before validation and passes the repaired value to the executor", async () => {
474
+ const hash = computeSkillVersionHash(tempDir);
475
+ const tool = createSkillTool(
476
+ makeEntry({
477
+ executor: "echo.ts",
478
+ input_schema: {
479
+ type: "object",
480
+ properties: {
481
+ activation_hints: { type: "array", items: { type: "string" } },
482
+ avoid_when: { type: "array", items: { type: "string" } },
483
+ files: { type: "array", items: { type: "object" } },
484
+ name: { type: "string" },
485
+ },
486
+ required: ["activation_hints"],
487
+ },
488
+ }),
489
+ tempDir,
490
+ hash,
491
+ BUNDLED,
492
+ );
493
+
494
+ const result = await tool.execute(
495
+ {
496
+ activation_hints: '["user asks to deploy staging","needs a rollback"]',
497
+ avoid_when: "the repo is dirty",
498
+ files: '[{"path":"references/notes.md","content":"hi"}]',
499
+ name: "x",
500
+ },
501
+ makeContext(),
502
+ );
503
+
504
+ expect(result.isError).toBe(false);
505
+ const parsed = JSON.parse(result.content);
506
+ expect(parsed.input).toEqual({
507
+ activation_hints: ["user asks to deploy staging", "needs a rollback"],
508
+ avoid_when: ["the repo is dirty"],
509
+ files: [{ path: "references/notes.md", content: "hi" }],
510
+ name: "x",
511
+ });
512
+ });
513
+
514
+ test("rejects a truncated array with a self-correcting message", async () => {
515
+ const hash = computeSkillVersionHash(tempDir);
516
+ const tool = createSkillTool(
517
+ makeEntry({
518
+ executor: "echo.ts",
519
+ input_schema: {
520
+ type: "object",
521
+ properties: {
522
+ activation_hints: { type: "array", items: { type: "string" } },
523
+ },
524
+ },
525
+ }),
526
+ tempDir,
527
+ hash,
528
+ BUNDLED,
529
+ );
530
+
531
+ const result = await tool.execute(
532
+ { activation_hints: "[user asks to deploy staging" },
533
+ makeContext(),
534
+ );
535
+
536
+ expect(result.isError).toBe(true);
537
+ expect(result.content).toContain(
538
+ "activation_hints must be an array: pass a JSON array, not a string",
539
+ );
540
+ });
541
+
473
542
  test("passes valid input through to the executor unchanged", async () => {
474
543
  const hash = computeSkillVersionHash(tempDir);
475
544
  const tool = createSkillTool(
@@ -680,6 +749,98 @@ function subagentSpawnTool() {
680
749
  * than the resolver silently deletes the resolver's behavior, and every test
681
750
  * that calls `executeSubagentSpawn` directly still passes.
682
751
  */
752
+ describe("createSkillTool: bundled input repairs", () => {
753
+ const SCAFFOLD_SCHEMA = {
754
+ type: "object",
755
+ properties: {
756
+ skill_id: { type: "string" },
757
+ body_markdown: { type: "string" },
758
+ files: {
759
+ type: "array",
760
+ items: {
761
+ type: "object",
762
+ properties: {
763
+ path: { type: "string" },
764
+ content: { type: "string" },
765
+ copy_from: { type: "string" },
766
+ },
767
+ required: ["path"],
768
+ },
769
+ },
770
+ },
771
+ required: ["skill_id", "body_markdown"],
772
+ };
773
+
774
+ function makeScaffoldTool(bundled: boolean) {
775
+ return createSkillTool(
776
+ makeEntry({
777
+ name: "scaffold_managed_skill",
778
+ executor: "echo.ts",
779
+ input_schema: SCAFFOLD_SCHEMA,
780
+ }),
781
+ tempDir,
782
+ computeSkillVersionHash(tempDir),
783
+ bundled,
784
+ );
785
+ }
786
+
787
+ test("reads `body` as `body_markdown` and reaches the executor", async () => {
788
+ const result = await makeScaffoldTool(true).execute(
789
+ { skill_id: "deploy", body: "# Deploy\n" },
790
+ makeContext(),
791
+ );
792
+
793
+ expect(result.isError).toBe(false);
794
+ expect(JSON.parse(result.content).input).toEqual({
795
+ skill_id: "deploy",
796
+ body_markdown: "# Deploy\n",
797
+ });
798
+ });
799
+
800
+ test("reads a path-keyed files map as the declared array", async () => {
801
+ const result = await makeScaffoldTool(true).execute(
802
+ {
803
+ skill_id: "deploy",
804
+ body_markdown: "# Deploy\n",
805
+ files: { "references/notes.md": "hi" },
806
+ },
807
+ makeContext(),
808
+ );
809
+
810
+ expect(result.isError).toBe(false);
811
+ expect(JSON.parse(result.content).input.files).toEqual([
812
+ { path: "references/notes.md", content: "hi" },
813
+ ]);
814
+ });
815
+
816
+ test("accepts the injected activity field the model is shown", async () => {
817
+ const result = await makeScaffoldTool(true).execute(
818
+ {
819
+ skill_id: "deploy",
820
+ body_markdown: "# Deploy\n",
821
+ activity: "Writing the deploy skill",
822
+ },
823
+ makeContext(),
824
+ );
825
+
826
+ expect(result.isError).toBe(false);
827
+ expect(JSON.parse(result.content).input.activity).toBe(
828
+ "Writing the deploy skill",
829
+ );
830
+ });
831
+
832
+ test("a non-bundled skill reusing the name keeps its own contract", async () => {
833
+ const result = await makeScaffoldTool(false).execute(
834
+ { skill_id: "deploy", body: "# Deploy\n" },
835
+ makeContext(),
836
+ );
837
+
838
+ expect(result.isError).toBe(true);
839
+ expect(result.content).toContain("body_markdown is required");
840
+ expect(result.content).toContain('Unknown parameter "body"');
841
+ });
842
+ });
843
+
683
844
  describe("createSkillTool: subagent_spawn role validation", () => {
684
845
  const ROLE_CASES: ReadonlyArray<readonly [string, SubagentRole]> = [
685
846
  ["researcher", "researcher"],