@khanglvm/outline-cli 0.1.2 → 0.1.4
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/CHANGELOG.md +10 -0
- package/README.md +44 -22
- package/bin/outline-cli.js +14 -0
- package/docs/TOOL_CONTRACTS.md +8 -0
- package/package.json +1 -1
- package/src/agent-skills.js +160 -23
- package/src/cli.js +207 -63
- package/src/config-store.js +86 -6
- package/src/entry-integrity-manifest.generated.js +15 -11
- package/src/entry-integrity.js +3 -0
- package/src/summary-redaction.js +37 -0
- package/src/tool-arg-schemas.js +266 -10
- package/src/tools.extended.js +123 -16
- package/src/tools.js +277 -21
- package/src/tools.mutation.js +2 -1
- package/src/tools.navigation.js +3 -2
- package/test/agent-skills.unit.test.js +64 -1
- package/test/config-store.unit.test.js +32 -0
- package/test/hardening.unit.test.js +26 -1
- package/test/live.integration.test.js +20 -24
- package/test/profile-selection.unit.test.js +14 -4
- package/test/tool-resolution.unit.test.js +333 -0
- package/test/version.unit.test.js +21 -0
|
@@ -2,6 +2,7 @@ import test from "node:test";
|
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
3
|
import {
|
|
4
4
|
buildProfile,
|
|
5
|
+
getProfile,
|
|
5
6
|
normalizeBaseUrlWithHints,
|
|
6
7
|
suggestProfileMetadata,
|
|
7
8
|
suggestProfiles,
|
|
@@ -70,6 +71,37 @@ test("suggestProfiles ranks profiles by keywords/description/host signals", () =
|
|
|
70
71
|
assert.ok(Array.isArray(result.matches[0].matchedOn));
|
|
71
72
|
});
|
|
72
73
|
|
|
74
|
+
test("getProfile can auto-select a strong read-only match when query context is provided", () => {
|
|
75
|
+
const config = {
|
|
76
|
+
version: 1,
|
|
77
|
+
profiles: {
|
|
78
|
+
engineering: {
|
|
79
|
+
name: "Engineering",
|
|
80
|
+
baseUrl: "https://wiki.example.com",
|
|
81
|
+
description: "Runbooks and incident policy",
|
|
82
|
+
keywords: ["incident", "runbook", "sre"],
|
|
83
|
+
auth: { type: "apiKey", apiKey: "ol_api_eng" },
|
|
84
|
+
},
|
|
85
|
+
marketing: {
|
|
86
|
+
name: "Acme Handbook",
|
|
87
|
+
baseUrl: "https://handbook.acme.example",
|
|
88
|
+
description: "Marketing campaign and event tracking handbook",
|
|
89
|
+
keywords: ["tracking", "campaign", "analytics"],
|
|
90
|
+
auth: { type: "apiKey", apiKey: "ol_api_marketing" },
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const selected = getProfile(config, undefined, {
|
|
96
|
+
query: "documents search incident runbook sre",
|
|
97
|
+
allowAutoSelect: true,
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
assert.equal(selected.id, "engineering");
|
|
101
|
+
assert.equal(selected.selection?.autoSelected, true);
|
|
102
|
+
assert.equal(selected.selection?.query, "documents search incident runbook sre");
|
|
103
|
+
});
|
|
104
|
+
|
|
73
105
|
test("suggestProfileMetadata can generate and enrich metadata from hints", () => {
|
|
74
106
|
const next = suggestProfileMetadata({
|
|
75
107
|
id: "acme-handbook",
|
|
@@ -24,6 +24,26 @@ test("validateToolArgs rejects unknown args by default", () => {
|
|
|
24
24
|
);
|
|
25
25
|
});
|
|
26
26
|
|
|
27
|
+
test("validateToolArgs exposes accepted args and closest suggestions", () => {
|
|
28
|
+
assert.throws(
|
|
29
|
+
() => validateToolArgs("auth.info", { vew: "summary", unexpected: true }),
|
|
30
|
+
(err) => {
|
|
31
|
+
assert.ok(err instanceof CliError);
|
|
32
|
+
assert.equal(err.details?.code, "ARG_VALIDATION_FAILED");
|
|
33
|
+
assert.deepEqual(err.details?.requiredArgs, []);
|
|
34
|
+
assert.ok(Array.isArray(err.details?.acceptedArgs));
|
|
35
|
+
assert.ok(err.details.acceptedArgs.includes("view"));
|
|
36
|
+
assert.ok(err.details.acceptedArgs.includes("compact"));
|
|
37
|
+
assert.deepEqual(err.details?.unknownArgs, ["vew", "unexpected"]);
|
|
38
|
+
const typoIssue = err.details?.issues?.find((issue) => issue.path === "args.vew");
|
|
39
|
+
assert.deepEqual(typoIssue?.suggestions, ["view"]);
|
|
40
|
+
assert.equal(err.details?.suggestedArgs, undefined);
|
|
41
|
+
assert.match(err.details?.validationHint || "", /Accepted args:/);
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
);
|
|
45
|
+
});
|
|
46
|
+
|
|
27
47
|
test("validateToolArgs supports allowUnknown opt-out", () => {
|
|
28
48
|
const toolName = "__test.allow_unknown";
|
|
29
49
|
TOOL_ARG_SCHEMAS[toolName] = {
|
|
@@ -1651,8 +1671,13 @@ test("shares lifecycle schemas enforce deterministic selectors and update requir
|
|
|
1651
1671
|
}
|
|
1652
1672
|
);
|
|
1653
1673
|
|
|
1674
|
+
assert.equal(
|
|
1675
|
+
validateToolArgs("shares.update", { id: "share-1", published: "true" }).published,
|
|
1676
|
+
true
|
|
1677
|
+
);
|
|
1678
|
+
|
|
1654
1679
|
assert.throws(
|
|
1655
|
-
() => validateToolArgs("shares.update", { id: "share-1", published: "
|
|
1680
|
+
() => validateToolArgs("shares.update", { id: "share-1", published: "yes" }),
|
|
1656
1681
|
(err) => {
|
|
1657
1682
|
assert.ok(err instanceof CliError);
|
|
1658
1683
|
assert.ok(err.details?.issues?.some((issue) => issue.path === "args.published"));
|
|
@@ -1789,11 +1789,6 @@ test("live integration suite (real Outline API, no mocks)", { timeout: 300_000 }
|
|
|
1789
1789
|
});
|
|
1790
1790
|
|
|
1791
1791
|
if (run.status !== 0) {
|
|
1792
|
-
if (isApiNotFoundErrorEnvelope(run.stderrJson)) {
|
|
1793
|
-
t.diagnostic(`documents.answer unsupported payload: ${run.stderr || "<empty stderr>"}`);
|
|
1794
|
-
t.skip("documents.answerQuestion endpoint unsupported by this Outline deployment");
|
|
1795
|
-
return;
|
|
1796
|
-
}
|
|
1797
1792
|
assert.fail(`documents.answer expected success, got status=${run.status}, stderr=${run.stderr || "<empty>"}`);
|
|
1798
1793
|
}
|
|
1799
1794
|
|
|
@@ -1805,11 +1800,17 @@ test("live integration suite (real Outline API, no mocks)", { timeout: 300_000 }
|
|
|
1805
1800
|
assert.equal(payload.result?.question, happyQuestion);
|
|
1806
1801
|
|
|
1807
1802
|
const signals = extractAnswerSignals(payload.result);
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1803
|
+
if (payload.result?.fallbackUsed) {
|
|
1804
|
+
assert.equal(payload.result?.unsupported, true);
|
|
1805
|
+
assert.ok(Array.isArray(signals.citations), "documents.answer fallback should expose citations/documents array");
|
|
1806
|
+
assert.ok(signals.citations.length > 0, `documents.answer fallback should include retrieved docs: ${JSON.stringify(payload.result)}`);
|
|
1807
|
+
} else {
|
|
1808
|
+
assert.ok(
|
|
1809
|
+
signals.answerText.length > 0,
|
|
1810
|
+
`documents.answer happy path should include answer text: ${JSON.stringify(payload.result)}`
|
|
1811
|
+
);
|
|
1812
|
+
assert.ok(Array.isArray(signals.citations), "documents.answer envelope should expose citations array");
|
|
1813
|
+
}
|
|
1813
1814
|
});
|
|
1814
1815
|
|
|
1815
1816
|
await t.test("documents.answer no-hit path assertions", async (t) => {
|
|
@@ -1826,11 +1827,6 @@ test("live integration suite (real Outline API, no mocks)", { timeout: 300_000 }
|
|
|
1826
1827
|
});
|
|
1827
1828
|
|
|
1828
1829
|
if (run.status !== 0) {
|
|
1829
|
-
if (isApiNotFoundErrorEnvelope(run.stderrJson)) {
|
|
1830
|
-
t.diagnostic(`documents.answer unsupported payload: ${run.stderr || "<empty stderr>"}`);
|
|
1831
|
-
t.skip("documents.answerQuestion endpoint unsupported by this Outline deployment");
|
|
1832
|
-
return;
|
|
1833
|
-
}
|
|
1834
1830
|
assert.fail(`documents.answer no-hit expected success, got status=${run.status}, stderr=${run.stderr || "<empty>"}`);
|
|
1835
1831
|
}
|
|
1836
1832
|
|
|
@@ -1840,10 +1836,15 @@ test("live integration suite (real Outline API, no mocks)", { timeout: 300_000 }
|
|
|
1840
1836
|
assert.equal(payload.result?.question, noHitQuestion);
|
|
1841
1837
|
|
|
1842
1838
|
const signals = extractAnswerSignals(payload.result);
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
`documents.answer no-hit should include
|
|
1846
|
-
|
|
1839
|
+
if (payload.result?.fallbackUsed) {
|
|
1840
|
+
assert.equal(payload.result?.unsupported, true);
|
|
1841
|
+
assert.ok(signals.noAnswerReason.length > 0, `documents.answer fallback no-hit should include reason: ${JSON.stringify(payload.result)}`);
|
|
1842
|
+
} else {
|
|
1843
|
+
assert.ok(
|
|
1844
|
+
signals.noAnswerReason.length > 0 || signals.citations.length === 0 || noHitPattern.test(signals.answerText),
|
|
1845
|
+
`documents.answer no-hit should include explicit no-hit signal: ${JSON.stringify(payload.result)}`
|
|
1846
|
+
);
|
|
1847
|
+
}
|
|
1847
1848
|
});
|
|
1848
1849
|
|
|
1849
1850
|
await t.test("documents.answer_batch mixed questions keep per-item isolation", async (t) => {
|
|
@@ -1865,11 +1866,6 @@ test("live integration suite (real Outline API, no mocks)", { timeout: 300_000 }
|
|
|
1865
1866
|
});
|
|
1866
1867
|
|
|
1867
1868
|
if (run.status !== 0) {
|
|
1868
|
-
if (isApiNotFoundErrorEnvelope(run.stderrJson)) {
|
|
1869
|
-
t.diagnostic(`documents.answer_batch unsupported payload: ${run.stderr || "<empty stderr>"}`);
|
|
1870
|
-
t.skip("documents.answerQuestion endpoint unsupported by this Outline deployment");
|
|
1871
|
-
return;
|
|
1872
|
-
}
|
|
1873
1869
|
assert.fail(`documents.answer_batch expected success, got status=${run.status}, stderr=${run.stderr || "<empty>"}`);
|
|
1874
1870
|
}
|
|
1875
1871
|
|
|
@@ -74,6 +74,17 @@ test("profile selection supports explicit, default, and single-profile fallback
|
|
|
74
74
|
]);
|
|
75
75
|
assert.equal(addAlpha.stdoutJson?.defaultProfile, null, "first add should not auto-set default");
|
|
76
76
|
|
|
77
|
+
const profileListJson = runCli([
|
|
78
|
+
"profile",
|
|
79
|
+
"list",
|
|
80
|
+
"--config",
|
|
81
|
+
configPath,
|
|
82
|
+
"--output",
|
|
83
|
+
"json",
|
|
84
|
+
]);
|
|
85
|
+
assert.equal(profileListJson.stdoutJson?.ok, true);
|
|
86
|
+
assert.equal(profileListJson.stdoutJson?.profiles?.length, 1);
|
|
87
|
+
|
|
77
88
|
const singleFallback = runCli([
|
|
78
89
|
"invoke",
|
|
79
90
|
"no.such.tool",
|
|
@@ -105,10 +116,9 @@ test("profile selection supports explicit, default, and single-profile fallback
|
|
|
105
116
|
"--args",
|
|
106
117
|
"{}",
|
|
107
118
|
], { expectCode: 1 });
|
|
108
|
-
assert.match(
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
);
|
|
119
|
+
assert.match(ambiguous.stderrJson?.error?.message || "", /Unknown tool: no\.such\.tool/);
|
|
120
|
+
assert.equal(ambiguous.stderrJson?.error?.code, "UNKNOWN_TOOL");
|
|
121
|
+
assert.ok(Array.isArray(ambiguous.stderrJson?.error?.suggestions));
|
|
112
122
|
|
|
113
123
|
const explicitProfile = runCli([
|
|
114
124
|
"invoke",
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
|
|
4
|
+
import { ApiError, CliError } from "../src/errors.js";
|
|
5
|
+
import { getToolContract, invokeTool, resolveToolInvocation } from "../src/tools.js";
|
|
6
|
+
|
|
7
|
+
test("getToolContract auto-corrects common raw endpoint aliases", () => {
|
|
8
|
+
const contract = getToolContract("documents.search_titles");
|
|
9
|
+
assert.equal(contract.name, "documents.search");
|
|
10
|
+
assert.equal(contract.autoCorrected, true);
|
|
11
|
+
assert.equal(contract.requestedName, "documents.search_titles");
|
|
12
|
+
assert.match(contract.reason || "", /mode=titles/);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("resolveToolInvocation surfaces structured suggestions for unknown tools", () => {
|
|
16
|
+
assert.throws(
|
|
17
|
+
() => resolveToolInvocation("document.search"),
|
|
18
|
+
(err) => {
|
|
19
|
+
assert.equal(err.name, "CliError");
|
|
20
|
+
assert.equal(err.details?.code, "UNKNOWN_TOOL");
|
|
21
|
+
assert.ok(Array.isArray(err.details?.suggestions));
|
|
22
|
+
assert.ok(err.details.suggestions.some((row) => row.name === "documents.search"));
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("invokeTool auto-corrects title-search alias and coerces common JSON string mistakes", async () => {
|
|
29
|
+
const calls = [];
|
|
30
|
+
const ctx = {
|
|
31
|
+
profile: { id: "prod" },
|
|
32
|
+
client: {
|
|
33
|
+
async call(method, body) {
|
|
34
|
+
calls.push({ method, body });
|
|
35
|
+
return {
|
|
36
|
+
body: {
|
|
37
|
+
ok: true,
|
|
38
|
+
status: 200,
|
|
39
|
+
data: [
|
|
40
|
+
{
|
|
41
|
+
id: "doc-1",
|
|
42
|
+
title: "Engineering Handbook",
|
|
43
|
+
collectionId: "col-1",
|
|
44
|
+
updatedAt: "2026-03-07T00:00:00.000Z",
|
|
45
|
+
publishedAt: "2026-03-07T00:00:00.000Z",
|
|
46
|
+
urlId: "abc123",
|
|
47
|
+
ranking: 0.99,
|
|
48
|
+
context: "sample context",
|
|
49
|
+
},
|
|
50
|
+
],
|
|
51
|
+
pagination: { limit: body.limit, offset: body.offset },
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const result = await invokeTool(ctx, "documents.searchTitles", {
|
|
59
|
+
query: ["engineering handbook"],
|
|
60
|
+
limit: "5",
|
|
61
|
+
offset: "0",
|
|
62
|
+
compact: false,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
assert.equal(calls.length, 1);
|
|
66
|
+
assert.equal(calls[0].method, "documents.search_titles");
|
|
67
|
+
assert.equal(calls[0].body.query, "engineering handbook");
|
|
68
|
+
assert.equal(calls[0].body.limit, 5);
|
|
69
|
+
assert.equal(calls[0].body.offset, 0);
|
|
70
|
+
assert.equal(result.tool, "documents.search");
|
|
71
|
+
assert.equal(result.requestedTool, "documents.searchTitles");
|
|
72
|
+
assert.equal(result.toolResolution?.autoCorrected, true);
|
|
73
|
+
assert.equal(result.result?.ok, true);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("documents.list preserves parentDocumentId null for collection root filtering", async () => {
|
|
77
|
+
const calls = [];
|
|
78
|
+
const ctx = {
|
|
79
|
+
profile: { id: "prod" },
|
|
80
|
+
client: {
|
|
81
|
+
async call(method, body) {
|
|
82
|
+
calls.push({ method, body });
|
|
83
|
+
return {
|
|
84
|
+
body: {
|
|
85
|
+
ok: true,
|
|
86
|
+
status: 200,
|
|
87
|
+
data: [],
|
|
88
|
+
pagination: { limit: body.limit, offset: body.offset },
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
await invokeTool(ctx, "documents.list", {
|
|
96
|
+
collectionId: "col-1",
|
|
97
|
+
parentDocumentId: null,
|
|
98
|
+
limit: 10,
|
|
99
|
+
compact: false,
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
assert.equal(calls.length, 1);
|
|
103
|
+
assert.equal(calls[0].method, "documents.list");
|
|
104
|
+
assert.ok(Object.prototype.hasOwnProperty.call(calls[0].body, "parentDocumentId"));
|
|
105
|
+
assert.equal(calls[0].body.parentDocumentId, null);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("documents.list rootOnly maps to parentDocumentId null", async () => {
|
|
109
|
+
const calls = [];
|
|
110
|
+
const ctx = {
|
|
111
|
+
profile: { id: "prod" },
|
|
112
|
+
client: {
|
|
113
|
+
async call(method, body) {
|
|
114
|
+
calls.push({ method, body });
|
|
115
|
+
return {
|
|
116
|
+
body: {
|
|
117
|
+
ok: true,
|
|
118
|
+
status: 200,
|
|
119
|
+
data: [],
|
|
120
|
+
pagination: { limit: body.limit, offset: body.offset },
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
await invokeTool(ctx, "documents.list", {
|
|
128
|
+
collectionId: "col-1",
|
|
129
|
+
rootOnly: "true",
|
|
130
|
+
compact: false,
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
assert.equal(calls.length, 1);
|
|
134
|
+
assert.equal(calls[0].method, "documents.list");
|
|
135
|
+
assert.ok(Object.prototype.hasOwnProperty.call(calls[0].body, "parentDocumentId"));
|
|
136
|
+
assert.equal(calls[0].body.parentDocumentId, null);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("invokeTool enriches arg validation failures with contract hints", async () => {
|
|
140
|
+
const ctx = {
|
|
141
|
+
profile: { id: "prod" },
|
|
142
|
+
client: {
|
|
143
|
+
async call() {
|
|
144
|
+
throw new Error("client.call should not run for validation failures");
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
await assert.rejects(
|
|
150
|
+
() => invokeTool(ctx, "docs.answer", { question: "Where?", limt: "3", compact: false }),
|
|
151
|
+
(err) => {
|
|
152
|
+
assert.ok(err instanceof CliError);
|
|
153
|
+
assert.equal(err.details?.code, "ARG_VALIDATION_FAILED");
|
|
154
|
+
assert.equal(err.details?.tool, "documents.answer");
|
|
155
|
+
assert.equal(err.details?.requestedTool, "docs.answer");
|
|
156
|
+
assert.equal(err.details?.toolResolution?.autoCorrected, true);
|
|
157
|
+
assert.match(err.details?.toolSignature || "", /^documents\.answer\(/);
|
|
158
|
+
assert.equal(err.details?.usageExample?.tool, "documents.answer");
|
|
159
|
+
assert.match(err.details?.contractHint || "", /tools contract documents\.answer/);
|
|
160
|
+
const typoIssue = err.details?.issues?.find((issue) => issue.path === "args.limt");
|
|
161
|
+
assert.ok(Array.isArray(typoIssue?.suggestions));
|
|
162
|
+
assert.ok(typoIssue.suggestions.includes("limit"));
|
|
163
|
+
assert.deepEqual(err.details?.suggestedArgs, {
|
|
164
|
+
question: "Where?",
|
|
165
|
+
limit: 3,
|
|
166
|
+
compact: false,
|
|
167
|
+
});
|
|
168
|
+
return true;
|
|
169
|
+
}
|
|
170
|
+
);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("docs.answer alias accepts limit and falls back to deterministic retrieval", async () => {
|
|
174
|
+
const calls = [];
|
|
175
|
+
const ctx = {
|
|
176
|
+
profile: { id: "prod" },
|
|
177
|
+
client: {
|
|
178
|
+
async call(method, body) {
|
|
179
|
+
calls.push({ method, body });
|
|
180
|
+
if (method === "documents.answerQuestion") {
|
|
181
|
+
throw new ApiError("Not Found", {
|
|
182
|
+
status: 404,
|
|
183
|
+
url: "https://example.com/api/documents.answerQuestion",
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
if (method === "documents.search") {
|
|
187
|
+
return {
|
|
188
|
+
body: {
|
|
189
|
+
ok: true,
|
|
190
|
+
status: 200,
|
|
191
|
+
data: [
|
|
192
|
+
{
|
|
193
|
+
id: "doc-1",
|
|
194
|
+
title: "Engineering / Welcome",
|
|
195
|
+
collectionId: "col-1",
|
|
196
|
+
updatedAt: "2026-03-07T00:00:00.000Z",
|
|
197
|
+
publishedAt: "2026-03-07T00:00:00.000Z",
|
|
198
|
+
urlId: "welcome",
|
|
199
|
+
ranking: 0.88,
|
|
200
|
+
context: "This page helps onboard new engineers.",
|
|
201
|
+
},
|
|
202
|
+
],
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
throw new Error(`Unexpected method: ${method}`);
|
|
207
|
+
},
|
|
208
|
+
},
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
const payload = await invokeTool(ctx, "docs.answer", {
|
|
212
|
+
question: "How do I onboard engineers?",
|
|
213
|
+
collectionId: "col-1",
|
|
214
|
+
limit: "3",
|
|
215
|
+
compact: false,
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
assert.equal(calls[0].method, "documents.answerQuestion");
|
|
219
|
+
assert.equal(calls[0].body.limit, undefined);
|
|
220
|
+
assert.equal(calls[1].method, "documents.search");
|
|
221
|
+
assert.equal(calls[1].body.limit, 3);
|
|
222
|
+
assert.equal(payload.tool, "documents.answer");
|
|
223
|
+
assert.equal(payload.requestedTool, "docs.answer");
|
|
224
|
+
assert.equal(payload.toolResolution?.autoCorrected, true);
|
|
225
|
+
assert.equal(payload.result?.fallbackUsed, true);
|
|
226
|
+
assert.equal(payload.result?.unsupported, true);
|
|
227
|
+
assert.equal(payload.result?.fallbackTool, "documents.search");
|
|
228
|
+
assert.equal(payload.result?.question, "How do I onboard engineers?");
|
|
229
|
+
assert.deepEqual(payload.result?.fallbackSuggestion, {
|
|
230
|
+
tool: "documents.search",
|
|
231
|
+
args: {
|
|
232
|
+
query: "How do I onboard engineers?",
|
|
233
|
+
collectionId: "col-1",
|
|
234
|
+
limit: 3,
|
|
235
|
+
view: "summary",
|
|
236
|
+
},
|
|
237
|
+
});
|
|
238
|
+
assert.ok(Array.isArray(payload.result?.documents));
|
|
239
|
+
assert.equal(payload.result.documents[0]?.title, "Engineering / Welcome");
|
|
240
|
+
assert.match(payload.result?.noAnswerReason || "", /unsupported/i);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test("documents.info summary redacts obvious credentials in excerpts", async () => {
|
|
244
|
+
const ctx = {
|
|
245
|
+
profile: { id: "prod" },
|
|
246
|
+
client: {
|
|
247
|
+
async call() {
|
|
248
|
+
return {
|
|
249
|
+
body: {
|
|
250
|
+
ok: true,
|
|
251
|
+
status: 200,
|
|
252
|
+
data: {
|
|
253
|
+
id: "doc-1",
|
|
254
|
+
title: "Keycloak",
|
|
255
|
+
collectionId: "col-1",
|
|
256
|
+
revision: 3,
|
|
257
|
+
updatedAt: "2026-03-07T00:00:00.000Z",
|
|
258
|
+
publishedAt: "2026-03-07T00:00:00.000Z",
|
|
259
|
+
urlId: "keycloak",
|
|
260
|
+
text: [
|
|
261
|
+
"Site: https://idp.example.com",
|
|
262
|
+
"User / Pass: dev / qUHxy1auV5E7",
|
|
263
|
+
"* **Credentials**: `test@yopmail.com` / `xxxxyyyy`",
|
|
264
|
+
"API key: ol_api_abcdef123456",
|
|
265
|
+
"Git Repo: https://dev:supersecret@example.com/repo",
|
|
266
|
+
].join("\n"),
|
|
267
|
+
},
|
|
268
|
+
},
|
|
269
|
+
};
|
|
270
|
+
},
|
|
271
|
+
},
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
const payload = await invokeTool(ctx, "documents.info", {
|
|
275
|
+
id: "doc-1",
|
|
276
|
+
view: "summary",
|
|
277
|
+
compact: false,
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
const excerpt = payload.result?.data?.excerpt || "";
|
|
281
|
+
assert.match(excerpt, /User \/ Pass: \[REDACTED\]/);
|
|
282
|
+
assert.match(excerpt, /\* \*\*Credentials\*\*: \[REDACTED\]/);
|
|
283
|
+
assert.match(excerpt, /API key: \[REDACTED\]/i);
|
|
284
|
+
assert.match(excerpt, /https:\/\/\[REDACTED\]@example\.com\/repo/);
|
|
285
|
+
assert.ok(!excerpt.includes("qUHxy1auV5E7"));
|
|
286
|
+
assert.ok(!excerpt.includes("xxxxyyyy"));
|
|
287
|
+
assert.ok(!excerpt.includes("ol_api_abcdef123456"));
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test("documents.search summary redacts obvious credentials in contexts", async () => {
|
|
291
|
+
const ctx = {
|
|
292
|
+
profile: { id: "prod" },
|
|
293
|
+
client: {
|
|
294
|
+
async call(method) {
|
|
295
|
+
assert.equal(method, "documents.search");
|
|
296
|
+
return {
|
|
297
|
+
body: {
|
|
298
|
+
ok: true,
|
|
299
|
+
status: 200,
|
|
300
|
+
data: [
|
|
301
|
+
{
|
|
302
|
+
document: {
|
|
303
|
+
id: "doc-1",
|
|
304
|
+
title: "Keycloak",
|
|
305
|
+
collectionId: "col-1",
|
|
306
|
+
updatedAt: "2026-03-07T00:00:00.000Z",
|
|
307
|
+
publishedAt: "2026-03-07T00:00:00.000Z",
|
|
308
|
+
urlId: "keycloak",
|
|
309
|
+
},
|
|
310
|
+
ranking: 0.9,
|
|
311
|
+
context:
|
|
312
|
+
"* **Credentials**: dev / secretpass\nAPI key: ol_api_searchsecret\nURL: https://dev:pass@example.com/repo",
|
|
313
|
+
},
|
|
314
|
+
],
|
|
315
|
+
},
|
|
316
|
+
};
|
|
317
|
+
},
|
|
318
|
+
},
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
const payload = await invokeTool(ctx, "documents.search", {
|
|
322
|
+
query: "keycloak",
|
|
323
|
+
view: "summary",
|
|
324
|
+
compact: false,
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
const context = payload.result?.data?.[0]?.context || "";
|
|
328
|
+
assert.match(context, /\* \*\*Credentials\*\*: \[REDACTED\]/);
|
|
329
|
+
assert.match(context, /API key: \[REDACTED\]/i);
|
|
330
|
+
assert.match(context, /https:\/\/\[REDACTED\]@example\.com\/repo/);
|
|
331
|
+
assert.ok(!context.includes("secretpass"));
|
|
332
|
+
assert.ok(!context.includes("ol_api_searchsecret"));
|
|
333
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
|
|
8
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
9
|
+
const __dirname = path.dirname(__filename);
|
|
10
|
+
const repoRoot = path.resolve(__dirname, "..");
|
|
11
|
+
const packageJson = JSON.parse(fs.readFileSync(path.join(repoRoot, "package.json"), "utf8"));
|
|
12
|
+
|
|
13
|
+
test("outline-cli --version matches package.json", () => {
|
|
14
|
+
const result = spawnSync("node", [path.join(repoRoot, "bin/outline-cli.js"), "--version"], {
|
|
15
|
+
cwd: repoRoot,
|
|
16
|
+
encoding: "utf8",
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
assert.equal(result.status, 0, result.stderr);
|
|
20
|
+
assert.equal(result.stdout.trim(), packageJson.version);
|
|
21
|
+
});
|