@gotgenes/pi-permission-system 8.2.0 → 8.3.0
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 +23 -0
- package/package.json +1 -1
- package/src/builtin-tool-input-formatters.ts +82 -0
- package/src/config-loader.ts +53 -46
- package/src/handlers/gates/bash-path-extractor.ts +135 -169
- package/src/handlers/gates/bash-token-classification.ts +105 -0
- package/src/handlers/permission-gate-handler.ts +3 -0
- package/src/index.ts +13 -1
- package/src/permission-prompts.ts +5 -1
- package/src/service.ts +21 -1
- package/src/tool-input-formatter-registry.ts +57 -0
- package/src/tool-preview-formatter.ts +18 -1
- package/test/builtin-tool-input-formatters.test.ts +109 -0
- package/test/config-loader.test.ts +82 -0
- package/test/handlers/before-agent-start.test.ts +2 -20
- package/test/handlers/external-directory-integration.test.ts +43 -81
- package/test/handlers/external-directory-session-dedup.test.ts +2 -29
- package/test/handlers/gates/bash-path.test.ts +5 -26
- package/test/handlers/gates/bash-token-classification.test.ts +241 -0
- package/test/handlers/gates/path.test.ts +3 -12
- package/test/handlers/gates/runner.test.ts +78 -91
- package/test/handlers/input-events.test.ts +42 -95
- package/test/handlers/input.test.ts +3 -71
- package/test/handlers/lifecycle.test.ts +3 -20
- package/test/handlers/tool-call-events.test.ts +30 -127
- package/test/handlers/tool-call.test.ts +21 -110
- package/test/helpers/gate-fixtures.ts +105 -0
- package/test/helpers/handler-fixtures.ts +141 -0
- package/test/helpers/manager-harness.ts +51 -0
- package/test/permission-prompts.test.ts +53 -7
- package/test/permission-session.test.ts +1 -19
- package/test/permission-system.test.ts +4 -40
- package/test/service.test.ts +52 -0
- package/test/tool-input-formatter-registry.test.ts +75 -0
- package/test/tool-preview-formatter.test.ts +73 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure, synchronous token-classification helpers for bash path extraction.
|
|
3
|
+
*
|
|
4
|
+
* Exports two classifiers consumed by `bash-path-extractor.ts`:
|
|
5
|
+
* - `classifyTokenAsPathCandidate` — strict gate for the external-directory guard.
|
|
6
|
+
* - `classifyTokenAsRuleCandidate` — broader gate for cross-cutting `path` rules.
|
|
7
|
+
*
|
|
8
|
+
* Both classifiers share the private `rejectNonPathToken` predicate that captures
|
|
9
|
+
* the seven rejection cases common to both (the production clone this module was
|
|
10
|
+
* extracted to eliminate).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
// ── Public classifiers ─────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Strict path-candidate classifier for the external-directory guard.
|
|
17
|
+
*
|
|
18
|
+
* Accepts tokens that unambiguously look like filesystem paths:
|
|
19
|
+
* - Absolute paths (starting with `/`)
|
|
20
|
+
* - Home-relative paths (starting with `~/`)
|
|
21
|
+
* - Parent-traversal paths (containing `..`)
|
|
22
|
+
*
|
|
23
|
+
* Returns the raw token string if it qualifies, or `null` to skip.
|
|
24
|
+
*/
|
|
25
|
+
export function classifyTokenAsPathCandidate(token: string): string | null {
|
|
26
|
+
if (rejectNonPathToken(token)) return null;
|
|
27
|
+
|
|
28
|
+
if (token.startsWith("/")) return token;
|
|
29
|
+
if (token.startsWith("~/")) return token;
|
|
30
|
+
if (token.includes("..")) return token;
|
|
31
|
+
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Broader token classifier for cross-cutting `path` permission rules.
|
|
37
|
+
*
|
|
38
|
+
* Accepts the same shapes as `classifyTokenAsPathCandidate`, plus:
|
|
39
|
+
* - Dot-files and `./`-relative paths (starting with `.`)
|
|
40
|
+
* - Any relative path containing `/` (e.g. `src/foo.ts`)
|
|
41
|
+
*
|
|
42
|
+
* The `~/foo` case is covered by `includes("/")` — no separate `~/` branch needed.
|
|
43
|
+
*
|
|
44
|
+
* Does NOT require the strict "must start with `/` or `~/` or contain `..`"
|
|
45
|
+
* gate that the external-directory classifier uses.
|
|
46
|
+
*
|
|
47
|
+
* Returns the raw token string if it qualifies, or `null` to skip.
|
|
48
|
+
*/
|
|
49
|
+
export function classifyTokenAsRuleCandidate(token: string): string | null {
|
|
50
|
+
if (rejectNonPathToken(token)) return null;
|
|
51
|
+
|
|
52
|
+
if (token.startsWith(".")) return token;
|
|
53
|
+
if (token.includes("/")) return token; // covers ~/ paths and all relative paths with /
|
|
54
|
+
if (token.includes("..")) return token; // bare ".." (no slash)
|
|
55
|
+
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ── Private rejection predicate ────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* URL pattern to skip tokens that look like URLs rather than paths.
|
|
63
|
+
*/
|
|
64
|
+
const URL_PATTERN = /^[a-z][a-z0-9+.-]*:\/\//i;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Regex metacharacter sequences that are never found in real filesystem paths.
|
|
68
|
+
* If a token contains any of these, it is almost certainly a regex pattern
|
|
69
|
+
* (e.g. a grep argument) rather than a path.
|
|
70
|
+
*/
|
|
71
|
+
const REGEX_METACHAR_PATTERN = /\.\*|\.\+|\\\||\\\(|\\\)|\[.*?\]|\^\//;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Shared rejection prelude: returns `true` when a token can never be a
|
|
75
|
+
* filesystem path, regardless of which classifier is asking.
|
|
76
|
+
*
|
|
77
|
+
* Rejects: empty tokens, flags (leading `-`), env assignments (`FOO=/bar`),
|
|
78
|
+
* URLs, `@scope/package` patterns, bare-slash tokens, and regex metacharacter
|
|
79
|
+
* sequences.
|
|
80
|
+
*/
|
|
81
|
+
function rejectNonPathToken(token: string): boolean {
|
|
82
|
+
if (!token) return true;
|
|
83
|
+
if (token.startsWith("-")) return true;
|
|
84
|
+
|
|
85
|
+
// Env assignment: = appears before any / (FOO=/bar is an assignment,
|
|
86
|
+
// /foo=bar is not because the slash comes first).
|
|
87
|
+
const eqIndex = token.indexOf("=");
|
|
88
|
+
const slashIndex = token.indexOf("/");
|
|
89
|
+
if (eqIndex !== -1 && (slashIndex === -1 || eqIndex < slashIndex))
|
|
90
|
+
return true;
|
|
91
|
+
|
|
92
|
+
if (URL_PATTERN.test(token)) return true;
|
|
93
|
+
|
|
94
|
+
// @scope/package patterns (npm scoped packages) — but @/ is allowed through
|
|
95
|
+
// since it looks like an absolute-rooted path, not an npm scope.
|
|
96
|
+
if (token.startsWith("@") && !token.startsWith("@/")) return true;
|
|
97
|
+
|
|
98
|
+
// Bare-slash tokens (/, //, ///) resolve to filesystem root and are never
|
|
99
|
+
// meaningful path arguments in practice.
|
|
100
|
+
if (/^\/+$/.test(token)) return true;
|
|
101
|
+
|
|
102
|
+
if (REGEX_METACHAR_PATTERN.test(token)) return true;
|
|
103
|
+
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
formatUnknownToolReason,
|
|
17
17
|
} from "#src/permission-prompts";
|
|
18
18
|
import type { PermissionSession } from "#src/permission-session";
|
|
19
|
+
import type { ToolInputFormatterLookup } from "#src/tool-input-formatter-registry";
|
|
19
20
|
import {
|
|
20
21
|
resolveToolPreviewLimits,
|
|
21
22
|
ToolPreviewFormatter,
|
|
@@ -54,6 +55,7 @@ export class PermissionGateHandler {
|
|
|
54
55
|
private readonly session: PermissionSession,
|
|
55
56
|
private readonly events: PermissionEventBus,
|
|
56
57
|
private readonly toolRegistry: ToolRegistry,
|
|
58
|
+
private readonly customFormatters?: ToolInputFormatterLookup,
|
|
57
59
|
) {}
|
|
58
60
|
|
|
59
61
|
async handleToolCall(
|
|
@@ -145,6 +147,7 @@ export class PermissionGateHandler {
|
|
|
145
147
|
|
|
146
148
|
const formatter = new ToolPreviewFormatter(
|
|
147
149
|
resolveToolPreviewLimits(this.session.config),
|
|
150
|
+
this.customFormatters,
|
|
148
151
|
);
|
|
149
152
|
|
|
150
153
|
// ── Ordered gate pipeline ─────────────────────────────────────────────
|
package/src/index.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { registerBuiltinToolInputFormatters } from "./builtin-tool-input-formatters";
|
|
2
3
|
import { registerPermissionSystemCommand } from "./config-modal";
|
|
3
4
|
import { getGlobalConfigPath } from "./config-paths";
|
|
4
5
|
import type { PermissionForwardingDeps } from "./forwarded-permissions/polling";
|
|
@@ -29,6 +30,7 @@ import { createSessionLogger } from "./session-logger";
|
|
|
29
30
|
import { isSubagentExecutionContext } from "./subagent-context";
|
|
30
31
|
import { subscribeSubagentLifecycle } from "./subagent-lifecycle-events";
|
|
31
32
|
import { SubagentSessionRegistry } from "./subagent-registry";
|
|
33
|
+
import { ToolInputFormatterRegistry } from "./tool-input-formatter-registry";
|
|
32
34
|
import {
|
|
33
35
|
canResolveAskPermissionRequest,
|
|
34
36
|
shouldAutoApprovePermissionState,
|
|
@@ -37,6 +39,8 @@ import {
|
|
|
37
39
|
export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
38
40
|
const runtime = createExtensionRuntime();
|
|
39
41
|
const subagentRegistry = new SubagentSessionRegistry();
|
|
42
|
+
const formatterRegistry = new ToolInputFormatterRegistry();
|
|
43
|
+
registerBuiltinToolInputFormatters(formatterRegistry);
|
|
40
44
|
|
|
41
45
|
const prompter = new PermissionPrompter({
|
|
42
46
|
getConfig: () => runtime.config,
|
|
@@ -121,6 +125,9 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
121
125
|
getToolPermission(toolName, agentName) {
|
|
122
126
|
return runtime.permissionManager.getToolPermission(toolName, agentName);
|
|
123
127
|
},
|
|
128
|
+
registerToolInputFormatter(toolName, formatter) {
|
|
129
|
+
return formatterRegistry.register(toolName, formatter);
|
|
130
|
+
},
|
|
124
131
|
};
|
|
125
132
|
publishPermissionsService(permissionsService);
|
|
126
133
|
|
|
@@ -145,7 +152,12 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
145
152
|
unpublishPermissionsService();
|
|
146
153
|
});
|
|
147
154
|
const agentPrep = new AgentPrepHandler(session, toolRegistry);
|
|
148
|
-
const gates = new PermissionGateHandler(
|
|
155
|
+
const gates = new PermissionGateHandler(
|
|
156
|
+
session,
|
|
157
|
+
pi.events,
|
|
158
|
+
toolRegistry,
|
|
159
|
+
formatterRegistry,
|
|
160
|
+
);
|
|
149
161
|
|
|
150
162
|
pi.on("session_start", (event, ctx) =>
|
|
151
163
|
lifecycle.handleSessionStart(event, ctx),
|
|
@@ -46,7 +46,11 @@ export function formatAskPrompt(
|
|
|
46
46
|
const patternInfo = result.matchedPattern
|
|
47
47
|
? ` (matched '${result.matchedPattern}')`
|
|
48
48
|
: "";
|
|
49
|
-
|
|
49
|
+
const mcpPreview = formatter
|
|
50
|
+
? formatter.formatToolInputForPrompt("mcp", input)
|
|
51
|
+
: "";
|
|
52
|
+
const previewSuffix = mcpPreview ? ` ${mcpPreview}` : "";
|
|
53
|
+
return `${subject} requested MCP target '${result.target}'${patternInfo}${previewSuffix}. Allow this call?`;
|
|
50
54
|
}
|
|
51
55
|
|
|
52
56
|
const patternInfo = result.matchedPattern
|
package/src/service.ts
CHANGED
|
@@ -11,9 +11,10 @@
|
|
|
11
11
|
* reference — this ensures resilience across `/reload` and load-order edge cases.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
+
import type { ToolInputFormatter } from "./tool-input-formatter-registry";
|
|
14
15
|
import type { PermissionCheckResult, PermissionState } from "./types";
|
|
15
16
|
|
|
16
|
-
export type { PermissionCheckResult, PermissionState };
|
|
17
|
+
export type { PermissionCheckResult, PermissionState, ToolInputFormatter };
|
|
17
18
|
|
|
18
19
|
/** Process-global key for the service slot. */
|
|
19
20
|
const SERVICE_KEY = Symbol.for("@gotgenes/pi-permission-system:service");
|
|
@@ -43,6 +44,25 @@ export interface PermissionsService {
|
|
|
43
44
|
agentName?: string,
|
|
44
45
|
): PermissionCheckResult;
|
|
45
46
|
|
|
47
|
+
/**
|
|
48
|
+
* Register a custom preview formatter for a specific tool name.
|
|
49
|
+
*
|
|
50
|
+
* The formatter is consulted first inside `ToolPreviewFormatter.formatToolInputForPrompt`;
|
|
51
|
+
* returning `undefined` falls through to the built-in switch (and ultimately
|
|
52
|
+
* the JSON default).
|
|
53
|
+
*
|
|
54
|
+
* Only one formatter may be registered per tool name — a second call for the
|
|
55
|
+
* same name throws. The returned disposer unregisters the formatter.
|
|
56
|
+
*
|
|
57
|
+
* @param toolName - Exact tool name to register for (e.g. `"mcp"`, `"my-server:run"`).
|
|
58
|
+
* @param formatter - Receives the raw `input` record; return a string to use
|
|
59
|
+
* as the prompt preview, or `undefined` to decline.
|
|
60
|
+
*/
|
|
61
|
+
registerToolInputFormatter(
|
|
62
|
+
toolName: string,
|
|
63
|
+
formatter: ToolInputFormatter,
|
|
64
|
+
): () => void;
|
|
65
|
+
|
|
46
66
|
/**
|
|
47
67
|
* Query the tool-level permission state for pre-filtering tools before
|
|
48
68
|
* creating a child session.
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registry for custom tool-input preview formatters.
|
|
3
|
+
*
|
|
4
|
+
* Allows extensions to register a formatter for a specific tool name so
|
|
5
|
+
* permission prompts can show a human-readable summary instead of raw JSON.
|
|
6
|
+
* One formatter per tool name; duplicate registration throws.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** A custom preview formatter for one tool's input. Returns `undefined` to decline. */
|
|
10
|
+
export type ToolInputFormatter = (
|
|
11
|
+
input: Record<string, unknown>,
|
|
12
|
+
) => string | undefined;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Read-only lookup used by `ToolPreviewFormatter` (ISP — exposes only the
|
|
16
|
+
* read side, not the registration surface).
|
|
17
|
+
*/
|
|
18
|
+
export interface ToolInputFormatterLookup {
|
|
19
|
+
get(toolName: string): ToolInputFormatter | undefined;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Persistent registry mapping tool names to custom preview formatters.
|
|
24
|
+
*
|
|
25
|
+
* Owned by the extension factory (`index.ts`) so it survives across the
|
|
26
|
+
* per-tool-call `ToolPreviewFormatter` construction cycle.
|
|
27
|
+
* Exposed to sibling extensions via `PermissionsService.registerToolInputFormatter`.
|
|
28
|
+
*/
|
|
29
|
+
export class ToolInputFormatterRegistry implements ToolInputFormatterLookup {
|
|
30
|
+
private readonly formatters = new Map<string, ToolInputFormatter>();
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Register a formatter for `toolName`.
|
|
34
|
+
*
|
|
35
|
+
* Throws if a formatter is already registered for that name — keeps
|
|
36
|
+
* resolution deterministic (a pi-permission-system package priority).
|
|
37
|
+
* Returns a disposer that removes the formatter; the disposer is
|
|
38
|
+
* identity-guarded so a stale call cannot evict a later registration.
|
|
39
|
+
*/
|
|
40
|
+
register(toolName: string, formatter: ToolInputFormatter): () => void {
|
|
41
|
+
if (this.formatters.has(toolName)) {
|
|
42
|
+
throw new Error(
|
|
43
|
+
`A tool input formatter is already registered for '${toolName}'.`,
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
this.formatters.set(toolName, formatter);
|
|
47
|
+
return () => {
|
|
48
|
+
if (this.formatters.get(toolName) === formatter) {
|
|
49
|
+
this.formatters.delete(toolName);
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
get(toolName: string): ToolInputFormatter | undefined {
|
|
55
|
+
return this.formatters.get(toolName);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { getNonEmptyString, toRecord } from "./common";
|
|
2
2
|
import type { PermissionSystemExtensionConfig } from "./extension-config";
|
|
3
|
+
import type { ToolInputFormatterLookup } from "./tool-input-formatter-registry";
|
|
3
4
|
import {
|
|
4
5
|
formatEditInputForPrompt,
|
|
5
6
|
formatReadInputForPrompt,
|
|
@@ -47,7 +48,10 @@ export function resolveToolPreviewLimits(
|
|
|
47
48
|
* point for preview-length configuration (#266).
|
|
48
49
|
*/
|
|
49
50
|
export class ToolPreviewFormatter {
|
|
50
|
-
constructor(
|
|
51
|
+
constructor(
|
|
52
|
+
private readonly options: ToolPreviewFormatterOptions,
|
|
53
|
+
private readonly customFormatters?: ToolInputFormatterLookup,
|
|
54
|
+
) {}
|
|
51
55
|
|
|
52
56
|
// ── Prompt formatting ───────────────────────────────────────────────────
|
|
53
57
|
|
|
@@ -107,6 +111,14 @@ export class ToolPreviewFormatter {
|
|
|
107
111
|
formatToolInputForPrompt(toolName: string, input: unknown): string {
|
|
108
112
|
const inputRecord = toRecord(input);
|
|
109
113
|
|
|
114
|
+
const custom = this.customFormatters?.get(toolName);
|
|
115
|
+
if (custom) {
|
|
116
|
+
const rendered = custom(inputRecord);
|
|
117
|
+
if (rendered !== undefined) {
|
|
118
|
+
return rendered;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
110
122
|
switch (toolName) {
|
|
111
123
|
case "edit":
|
|
112
124
|
return formatEditInputForPrompt(inputRecord);
|
|
@@ -118,6 +130,11 @@ export class ToolPreviewFormatter {
|
|
|
118
130
|
case "grep":
|
|
119
131
|
case "ls":
|
|
120
132
|
return this.formatSearchInputForPrompt(toolName, inputRecord);
|
|
133
|
+
case "mcp":
|
|
134
|
+
// The MCP target is already surfaced in formatAskPrompt's MCP branch.
|
|
135
|
+
// When no custom formatter is registered (or it declines), produce no
|
|
136
|
+
// additional preview rather than leaking the raw event JSON.
|
|
137
|
+
return "";
|
|
121
138
|
default:
|
|
122
139
|
return this.formatJsonInputForPrompt(input);
|
|
123
140
|
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
formatMcpInputForPrompt,
|
|
5
|
+
registerBuiltinToolInputFormatters,
|
|
6
|
+
} from "#src/builtin-tool-input-formatters";
|
|
7
|
+
import { ToolInputFormatterRegistry } from "#src/tool-input-formatter-registry";
|
|
8
|
+
|
|
9
|
+
// ── formatMcpInputForPrompt ───────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
describe("formatMcpInputForPrompt", () => {
|
|
12
|
+
test("returns undefined when arguments is absent", () => {
|
|
13
|
+
expect(formatMcpInputForPrompt({ tool: "exa:search" })).toBeUndefined();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("returns undefined when arguments is an empty object", () => {
|
|
17
|
+
expect(
|
|
18
|
+
formatMcpInputForPrompt({ tool: "exa:search", arguments: {} }),
|
|
19
|
+
).toBeUndefined();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("returns a summary for a single string argument", () => {
|
|
23
|
+
const result = formatMcpInputForPrompt({
|
|
24
|
+
tool: "exa:search",
|
|
25
|
+
arguments: { query: "typescript generics" },
|
|
26
|
+
});
|
|
27
|
+
expect(result).toBeDefined();
|
|
28
|
+
expect(result).toContain("query");
|
|
29
|
+
expect(result).toContain("typescript generics");
|
|
30
|
+
expect(result).toMatch(/^with /);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("returns a comma-separated summary for multiple arguments", () => {
|
|
34
|
+
const result = formatMcpInputForPrompt({
|
|
35
|
+
tool: "exa:search",
|
|
36
|
+
arguments: { query: "test", numResults: 5 },
|
|
37
|
+
});
|
|
38
|
+
expect(result).toContain("query");
|
|
39
|
+
expect(result).toContain("numResults");
|
|
40
|
+
expect(result).toContain("5");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("renders number arguments without quotes", () => {
|
|
44
|
+
const result = formatMcpInputForPrompt({
|
|
45
|
+
arguments: { count: 42 },
|
|
46
|
+
});
|
|
47
|
+
expect(result).toContain("42");
|
|
48
|
+
expect(result).not.toContain('"42"');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("renders boolean arguments without quotes", () => {
|
|
52
|
+
const result = formatMcpInputForPrompt({
|
|
53
|
+
arguments: { verbose: true },
|
|
54
|
+
});
|
|
55
|
+
expect(result).toContain("true");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("renders array arguments as '[N items]'", () => {
|
|
59
|
+
const result = formatMcpInputForPrompt({
|
|
60
|
+
arguments: { ids: [1, 2, 3] },
|
|
61
|
+
});
|
|
62
|
+
expect(result).toContain("[3 items]");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("renders nested object arguments as '{…}'", () => {
|
|
66
|
+
const result = formatMcpInputForPrompt({
|
|
67
|
+
arguments: { filter: { type: "file" } },
|
|
68
|
+
});
|
|
69
|
+
expect(result).toContain("{…}");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("truncates the full summary when it exceeds the limit", () => {
|
|
73
|
+
// Need multiple long-valued args so the joined summary exceeds 160 chars
|
|
74
|
+
const result = formatMcpInputForPrompt({
|
|
75
|
+
arguments: {
|
|
76
|
+
first: "x".repeat(80),
|
|
77
|
+
second: "y".repeat(80),
|
|
78
|
+
third: "z".repeat(80),
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
expect(result).toBeDefined();
|
|
82
|
+
expect(result!.endsWith("…")).toBe(true);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("truncates long string argument values", () => {
|
|
86
|
+
const result = formatMcpInputForPrompt({
|
|
87
|
+
arguments: { query: "x".repeat(100) },
|
|
88
|
+
});
|
|
89
|
+
expect(result).toBeDefined();
|
|
90
|
+
// Should not include the full 100-char string verbatim
|
|
91
|
+
expect(result).not.toContain("x".repeat(100));
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// ── registerBuiltinToolInputFormatters ────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
describe("registerBuiltinToolInputFormatters", () => {
|
|
98
|
+
test("registers the mcp formatter in the registry", () => {
|
|
99
|
+
const registry = new ToolInputFormatterRegistry();
|
|
100
|
+
registerBuiltinToolInputFormatters(registry);
|
|
101
|
+
expect(registry.get("mcp")).toBe(formatMcpInputForPrompt);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("throws if called twice (duplicate registration guard)", () => {
|
|
105
|
+
const registry = new ToolInputFormatterRegistry();
|
|
106
|
+
registerBuiltinToolInputFormatters(registry);
|
|
107
|
+
expect(() => registerBuiltinToolInputFormatters(registry)).toThrow("mcp");
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -7,8 +7,90 @@ import {
|
|
|
7
7
|
loadAndMergeConfigs,
|
|
8
8
|
loadUnifiedConfig,
|
|
9
9
|
mergeUnifiedConfigs,
|
|
10
|
+
stripJsonComments,
|
|
10
11
|
} from "#src/config-loader";
|
|
11
12
|
|
|
13
|
+
describe("stripJsonComments", () => {
|
|
14
|
+
it("returns empty string for empty input", () => {
|
|
15
|
+
expect(stripJsonComments("")).toBe("");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("passes through plain JSON unchanged", () => {
|
|
19
|
+
const input = '{"key": true}';
|
|
20
|
+
expect(stripJsonComments(input)).toBe(input);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("drops a line comment body and preserves the trailing newline", () => {
|
|
24
|
+
// The space before // is emitted; the comment body is dropped; \n is kept.
|
|
25
|
+
expect(stripJsonComments('{ // comment\n"k": 1}')).toBe('{ \n"k": 1}');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("drops a line comment that runs to EOF with no trailing newline", () => {
|
|
29
|
+
expect(stripJsonComments('{"k": 1} // trailing')).toBe('{"k": 1} ');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("drops a block comment and nothing else", () => {
|
|
33
|
+
expect(stripJsonComments('{ /* block */ "k": 1}')).toBe('{ "k": 1}');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("drops an unterminated block comment to EOF", () => {
|
|
37
|
+
expect(stripJsonComments("{ /* no close")).toBe("{ ");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("preserves // inside a double-quoted string", () => {
|
|
41
|
+
expect(stripJsonComments('{"url": "http://example.com"}')).toBe(
|
|
42
|
+
'{"url": "http://example.com"}',
|
|
43
|
+
);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("preserves block-comment markers inside a double-quoted string", () => {
|
|
47
|
+
expect(stripJsonComments('{"v": "a /* b */ c"}')).toBe(
|
|
48
|
+
'{"v": "a /* b */ c"}',
|
|
49
|
+
);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("preserves // inside a single-quoted string", () => {
|
|
53
|
+
expect(stripJsonComments("{'url': 'http://x.com'}")).toBe(
|
|
54
|
+
"{'url': 'http://x.com'}",
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("preserves block-comment markers inside a single-quoted string", () => {
|
|
59
|
+
expect(stripJsonComments("{'v': 'a /* b */ c'}")).toBe(
|
|
60
|
+
"{'v': 'a /* b */ c'}",
|
|
61
|
+
);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("honors a backslash-escaped quote so it does not close the string", () => {
|
|
65
|
+
// The string value is: a\"b (backslash-escaped double quote)
|
|
66
|
+
expect(stripJsonComments('{"k": "a\\"b"}')).toBe('{"k": "a\\"b"}');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("emits an unterminated string to EOF verbatim", () => {
|
|
70
|
+
expect(stripJsonComments('{"k": "unterminated')).toBe(
|
|
71
|
+
'{"k": "unterminated',
|
|
72
|
+
);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("preserves a lone slash that is not part of // or /*", () => {
|
|
76
|
+
expect(stripJsonComments('{"v": 1/2}')).toBe('{"v": 1/2}');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("handles a combined JSONC document that round-trips to valid JSON", () => {
|
|
80
|
+
const jsonc = [
|
|
81
|
+
"{",
|
|
82
|
+
' "debugLog": true, // runtime knob',
|
|
83
|
+
' "permission": { /* the policy */ "*": "ask" }',
|
|
84
|
+
"}",
|
|
85
|
+
].join("\n");
|
|
86
|
+
const stripped = stripJsonComments(jsonc);
|
|
87
|
+
// Must parse without throwing
|
|
88
|
+
const parsed = JSON.parse(stripped) as Record<string, unknown>;
|
|
89
|
+
expect(parsed.debugLog).toBe(true);
|
|
90
|
+
expect(parsed.permission).toEqual({ "*": "ask" });
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
12
94
|
describe("loadUnifiedConfig", () => {
|
|
13
95
|
let tempDir: string;
|
|
14
96
|
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
1
|
import { describe, expect, it, vi } from "vitest";
|
|
3
2
|
|
|
4
3
|
import {
|
|
@@ -8,6 +7,8 @@ import {
|
|
|
8
7
|
import type { PermissionSession } from "#src/permission-session";
|
|
9
8
|
import type { ToolRegistry } from "#src/tool-registry";
|
|
10
9
|
|
|
10
|
+
import { makeCtx } from "#test/helpers/handler-fixtures";
|
|
11
|
+
|
|
11
12
|
// ── SDK stubs ──────────────────────────────────────────────────────────────
|
|
12
13
|
vi.mock("@earendil-works/pi-coding-agent", async (importOriginal) => {
|
|
13
14
|
const original =
|
|
@@ -20,25 +21,6 @@ vi.mock("@earendil-works/pi-coding-agent", async (importOriginal) => {
|
|
|
20
21
|
|
|
21
22
|
// ── helpers ────────────────────────────────────────────────────────────────
|
|
22
23
|
|
|
23
|
-
function makeCtx(overrides: Partial<ExtensionContext> = {}): ExtensionContext {
|
|
24
|
-
return {
|
|
25
|
-
cwd: "/test/project",
|
|
26
|
-
hasUI: true,
|
|
27
|
-
ui: {
|
|
28
|
-
setStatus: vi.fn(),
|
|
29
|
-
notify: vi.fn(),
|
|
30
|
-
select: vi.fn(),
|
|
31
|
-
input: vi.fn(),
|
|
32
|
-
},
|
|
33
|
-
sessionManager: {
|
|
34
|
-
getEntries: vi.fn().mockReturnValue([]),
|
|
35
|
-
getSessionDir: vi.fn().mockReturnValue("/sessions/test"),
|
|
36
|
-
addEntry: vi.fn(),
|
|
37
|
-
},
|
|
38
|
-
...overrides,
|
|
39
|
-
} as unknown as ExtensionContext;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
24
|
function makeEvent(systemPrompt = "You are an assistant.") {
|
|
43
25
|
return { systemPrompt };
|
|
44
26
|
}
|