@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.
- package/openapi.yaml +19 -2
- package/package.json +1 -1
- package/src/__tests__/credential-routes.test.ts +22 -3
- package/src/__tests__/daemon-credential-client.test.ts +88 -0
- package/src/__tests__/dm-backfill.test.ts +31 -0
- package/src/__tests__/input-repairs.test.ts +207 -0
- package/src/__tests__/secret-routes-platform-proxy.test.ts +7 -0
- package/src/__tests__/skill-tool-factory.test.ts +161 -0
- package/src/__tests__/validate-input.test.ts +243 -7
- package/src/agent/loop.ts +1 -7
- package/src/cli/commands/__tests__/keys.test.ts +89 -8
- package/src/cli/commands/keys.help.ts +13 -1
- package/src/cli/commands/keys.ts +26 -15
- package/src/cli/lib/daemon-credential-client.ts +25 -3
- package/src/plugins/defaults/memory/memory-retrospective-prompt.ts +1 -1
- package/src/providers/connection-resolution.ts +2 -1
- package/src/providers/content-blocks.ts +22 -0
- package/src/providers/inference/__tests__/missing-credential-guard.test.ts +117 -0
- package/src/providers/inference/adapter-factory.ts +13 -1
- package/src/providers/inference/credential-usage.ts +37 -0
- package/src/providers/inference/missing-credential-guard.ts +110 -0
- package/src/runtime/http-errors.ts +1 -0
- package/src/runtime/routes/__tests__/credential-delete-in-use.test.ts +155 -0
- package/src/runtime/routes/credential-in-use.ts +85 -0
- package/src/runtime/routes/credential-routes.ts +29 -3
- package/src/runtime/routes/inbound-message-handler.ts +36 -7
- package/src/runtime/routes/secret-routes.ts +39 -8
- package/src/skills/validate-input.ts +177 -40
- package/src/tools/schema-transforms.ts +40 -5
- package/src/tools/shared/input-repairs.ts +160 -0
- 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:
|
|
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:
|
|
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
|
@@ -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 = {
|
|
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({
|
|
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({
|
|
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"],
|