@gotgenes/pi-permission-system 3.6.0 → 3.8.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 +36 -0
- package/package.json +1 -1
- package/src/forwarded-permissions/io.ts +47 -12
- package/src/forwarded-permissions/polling.ts +33 -11
- package/src/handlers/before-agent-start.ts +112 -0
- package/src/handlers/index.ts +16 -0
- package/src/handlers/input.ts +99 -0
- package/src/handlers/lifecycle.ts +81 -0
- package/src/handlers/tool-call.ts +410 -0
- package/src/handlers/types.ts +72 -0
- package/src/index.ts +73 -1040
- package/src/runtime.ts +484 -0
- package/tests/forwarded-permissions/io.test.ts +135 -0
- package/tests/handlers/before-agent-start.test.ts +290 -0
- package/tests/handlers/input.test.ts +301 -0
- package/tests/handlers/lifecycle.test.ts +352 -0
- package/tests/handlers/tool-call.test.ts +441 -0
- package/tests/runtime.test.ts +618 -0
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
import {
|
|
2
|
+
existsSync,
|
|
3
|
+
mkdirSync,
|
|
4
|
+
renameSync,
|
|
5
|
+
unlinkSync,
|
|
6
|
+
writeFileSync,
|
|
7
|
+
} from "node:fs";
|
|
8
|
+
import { dirname, join, normalize } from "node:path";
|
|
9
|
+
import {
|
|
10
|
+
type ExtensionCommandContext,
|
|
11
|
+
type ExtensionContext,
|
|
12
|
+
getAgentDir,
|
|
13
|
+
} from "@mariozechner/pi-coding-agent";
|
|
14
|
+
import {
|
|
15
|
+
getActiveAgentName,
|
|
16
|
+
getActiveAgentNameFromSystemPrompt,
|
|
17
|
+
} from "./active-agent";
|
|
18
|
+
import { loadAndMergeConfigs, loadUnifiedConfig } from "./config-loader";
|
|
19
|
+
import {
|
|
20
|
+
DEBUG_LOG_FILENAME,
|
|
21
|
+
getGlobalConfigPath,
|
|
22
|
+
getGlobalLogsDir,
|
|
23
|
+
getLegacyExtensionConfigPath,
|
|
24
|
+
getLegacyGlobalPolicyPath,
|
|
25
|
+
getLegacyProjectPolicyPath,
|
|
26
|
+
getProjectConfigPath,
|
|
27
|
+
REVIEW_LOG_FILENAME,
|
|
28
|
+
} from "./config-paths";
|
|
29
|
+
import { buildResolvedConfigLogEntry } from "./config-reporter";
|
|
30
|
+
import {
|
|
31
|
+
DEFAULT_EXTENSION_CONFIG,
|
|
32
|
+
EXTENSION_ROOT,
|
|
33
|
+
ensurePermissionSystemLogsDirectory,
|
|
34
|
+
normalizePermissionSystemConfig,
|
|
35
|
+
type PermissionSystemExtensionConfig,
|
|
36
|
+
} from "./extension-config";
|
|
37
|
+
import {
|
|
38
|
+
confirmPermission,
|
|
39
|
+
type PermissionForwardingDeps,
|
|
40
|
+
processForwardedPermissionRequests,
|
|
41
|
+
} from "./forwarded-permissions/polling";
|
|
42
|
+
import type { PromptPermissionDetails } from "./handlers/types";
|
|
43
|
+
import { createPermissionSystemLogger } from "./logging";
|
|
44
|
+
import type { PermissionPromptDecision } from "./permission-dialog";
|
|
45
|
+
import { PERMISSION_FORWARDING_POLL_INTERVAL_MS } from "./permission-forwarding";
|
|
46
|
+
import { PermissionManager } from "./permission-manager";
|
|
47
|
+
import { SessionApprovalCache } from "./session-approval-cache";
|
|
48
|
+
import type { SkillPromptEntry } from "./skill-prompt-sanitizer";
|
|
49
|
+
import { syncPermissionSystemStatus } from "./status";
|
|
50
|
+
import { isSubagentExecutionContext } from "./subagent-context";
|
|
51
|
+
import { shouldAutoApprovePermissionState } from "./yolo-mode";
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Runtime context object created once inside `piPermissionSystemExtension()`.
|
|
55
|
+
*
|
|
56
|
+
* Holds all path constants (derived from `getAgentDir()` at construction time),
|
|
57
|
+
* mutable extension state, and the log-writing methods — eliminating the
|
|
58
|
+
* module-scope cached constants and setter-injection pattern that previously
|
|
59
|
+
* lived in `src/index.ts`.
|
|
60
|
+
*
|
|
61
|
+
* Tests construct this via `createExtensionRuntime({ agentDir: tmpDir })`
|
|
62
|
+
* without timing issues around `PI_CODING_AGENT_DIR`.
|
|
63
|
+
*/
|
|
64
|
+
export interface ExtensionRuntime {
|
|
65
|
+
// ── Immutable paths (derived from agentDir at construction) ───────────
|
|
66
|
+
readonly agentDir: string;
|
|
67
|
+
readonly sessionsDir: string;
|
|
68
|
+
readonly subagentSessionsDir: string;
|
|
69
|
+
readonly forwardingDir: string;
|
|
70
|
+
readonly globalLogsDir: string;
|
|
71
|
+
|
|
72
|
+
// ── Mutable state ──────────────────────────────────────────────────────
|
|
73
|
+
config: PermissionSystemExtensionConfig;
|
|
74
|
+
runtimeContext: ExtensionContext | null;
|
|
75
|
+
permissionManager: PermissionManager;
|
|
76
|
+
activeSkillEntries: SkillPromptEntry[];
|
|
77
|
+
lastKnownActiveAgentName: string | null;
|
|
78
|
+
lastActiveToolsCacheKey: string | null;
|
|
79
|
+
lastPromptStateCacheKey: string | null;
|
|
80
|
+
lastConfigWarning: string | null;
|
|
81
|
+
readonly sessionApprovalCache: SessionApprovalCache;
|
|
82
|
+
|
|
83
|
+
// ── Forwarding polling state ───────────────────────────────────────────
|
|
84
|
+
permissionForwardingContext: ExtensionContext | null;
|
|
85
|
+
permissionForwardingTimer: NodeJS.Timeout | null;
|
|
86
|
+
isProcessingForwardedRequests: boolean;
|
|
87
|
+
|
|
88
|
+
// ── Logging (backed by logger created at construction) ─────────────────
|
|
89
|
+
writeDebugLog(event: string, details?: Record<string, unknown>): void;
|
|
90
|
+
writeReviewLog(event: string, details?: Record<string, unknown>): void;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ── Pure helpers ───────────────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Derive Pi project-level config and agents paths from a working directory.
|
|
97
|
+
* Returns null when cwd is absent (headless / global-only config).
|
|
98
|
+
*/
|
|
99
|
+
export function derivePiProjectPaths(cwd: string | undefined | null): {
|
|
100
|
+
projectGlobalConfigPath: string;
|
|
101
|
+
projectAgentsDir: string;
|
|
102
|
+
} | null {
|
|
103
|
+
if (!cwd) {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
projectGlobalConfigPath: getProjectConfigPath(cwd),
|
|
108
|
+
projectAgentsDir: join(cwd, ".pi", "agent", "agents"),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Create a new PermissionManager scoped to a working directory's config hierarchy.
|
|
114
|
+
* Pass `cwd` as null/undefined to use global config only.
|
|
115
|
+
*/
|
|
116
|
+
export function createPermissionManagerForCwd(
|
|
117
|
+
agentDir: string,
|
|
118
|
+
cwd: string | undefined | null,
|
|
119
|
+
): PermissionManager {
|
|
120
|
+
const projectPaths = derivePiProjectPaths(cwd);
|
|
121
|
+
return new PermissionManager({
|
|
122
|
+
globalConfigPath: getGlobalConfigPath(agentDir),
|
|
123
|
+
projectGlobalConfigPath: projectPaths?.projectGlobalConfigPath,
|
|
124
|
+
projectAgentsDir: projectPaths?.projectAgentsDir,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Reload merged config from disk into the runtime.
|
|
130
|
+
* If `ctx` is provided, updates `runtime.runtimeContext` first.
|
|
131
|
+
*/
|
|
132
|
+
export function refreshExtensionConfig(
|
|
133
|
+
runtime: ExtensionRuntime,
|
|
134
|
+
ctx?: ExtensionContext,
|
|
135
|
+
): void {
|
|
136
|
+
if (ctx) {
|
|
137
|
+
runtime.runtimeContext = ctx;
|
|
138
|
+
}
|
|
139
|
+
const cwd = runtime.runtimeContext?.cwd ?? null;
|
|
140
|
+
const mergeResult = loadAndMergeConfigs(
|
|
141
|
+
runtime.agentDir,
|
|
142
|
+
cwd ?? "",
|
|
143
|
+
EXTENSION_ROOT,
|
|
144
|
+
);
|
|
145
|
+
const runtimeConfig = normalizePermissionSystemConfig(mergeResult.merged);
|
|
146
|
+
runtime.config = runtimeConfig;
|
|
147
|
+
|
|
148
|
+
if (runtime.runtimeContext?.hasUI) {
|
|
149
|
+
syncPermissionSystemStatus(runtime.runtimeContext, runtimeConfig);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const warning =
|
|
153
|
+
mergeResult.issues.length > 0 ? mergeResult.issues.join("\n") : undefined;
|
|
154
|
+
|
|
155
|
+
if (warning && warning !== runtime.lastConfigWarning) {
|
|
156
|
+
runtime.lastConfigWarning = warning;
|
|
157
|
+
runtime.runtimeContext?.ui.notify(warning, "warning");
|
|
158
|
+
} else if (!warning) {
|
|
159
|
+
runtime.lastConfigWarning = null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
runtime.writeDebugLog("config.loaded", {
|
|
163
|
+
warning: warning ?? null,
|
|
164
|
+
debugLog: runtimeConfig.debugLog,
|
|
165
|
+
permissionReviewLog: runtimeConfig.permissionReviewLog,
|
|
166
|
+
yoloMode: runtimeConfig.yoloMode,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Save updated runtime knobs (debugLog, permissionReviewLog, yoloMode) to the
|
|
172
|
+
* global config file, then update runtime.config and sync UI status.
|
|
173
|
+
*/
|
|
174
|
+
export function saveExtensionConfig(
|
|
175
|
+
runtime: ExtensionRuntime,
|
|
176
|
+
next: PermissionSystemExtensionConfig,
|
|
177
|
+
ctx: ExtensionCommandContext,
|
|
178
|
+
): void {
|
|
179
|
+
const normalized = normalizePermissionSystemConfig(next);
|
|
180
|
+
const globalPath = getGlobalConfigPath(runtime.agentDir);
|
|
181
|
+
|
|
182
|
+
const existing = loadUnifiedConfig(globalPath);
|
|
183
|
+
const merged = {
|
|
184
|
+
...existing.config,
|
|
185
|
+
debugLog: normalized.debugLog,
|
|
186
|
+
permissionReviewLog: normalized.permissionReviewLog,
|
|
187
|
+
yoloMode: normalized.yoloMode,
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const tmpPath = `${globalPath}.tmp`;
|
|
191
|
+
try {
|
|
192
|
+
mkdirSync(dirname(globalPath), { recursive: true });
|
|
193
|
+
writeFileSync(tmpPath, `${JSON.stringify(merged, null, 2)}\n`, "utf-8");
|
|
194
|
+
renameSync(tmpPath, globalPath);
|
|
195
|
+
} catch (error) {
|
|
196
|
+
try {
|
|
197
|
+
if (existsSync(tmpPath)) {
|
|
198
|
+
unlinkSync(tmpPath);
|
|
199
|
+
}
|
|
200
|
+
} catch {
|
|
201
|
+
// Ignore cleanup failures.
|
|
202
|
+
}
|
|
203
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
204
|
+
ctx.ui.notify(
|
|
205
|
+
`Failed to save permission-system config at '${globalPath}': ${message}`,
|
|
206
|
+
"error",
|
|
207
|
+
);
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
runtime.config = normalized;
|
|
212
|
+
syncPermissionSystemStatus(ctx, normalized);
|
|
213
|
+
runtime.lastConfigWarning = null;
|
|
214
|
+
|
|
215
|
+
runtime.writeDebugLog("config.saved", {
|
|
216
|
+
debugLog: normalized.debugLog,
|
|
217
|
+
permissionReviewLog: normalized.permissionReviewLog,
|
|
218
|
+
yoloMode: normalized.yoloMode,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Resolve the active agent name from the Pi session, system prompt, or last
|
|
224
|
+
* known name. Updates `runtime.lastKnownActiveAgentName` as a side effect.
|
|
225
|
+
*/
|
|
226
|
+
export function resolveAgentName(
|
|
227
|
+
runtime: ExtensionRuntime,
|
|
228
|
+
ctx: ExtensionContext,
|
|
229
|
+
systemPrompt?: string,
|
|
230
|
+
): string | null {
|
|
231
|
+
const fromSession = getActiveAgentName(ctx);
|
|
232
|
+
if (fromSession) {
|
|
233
|
+
runtime.lastKnownActiveAgentName = fromSession;
|
|
234
|
+
return fromSession;
|
|
235
|
+
}
|
|
236
|
+
const fromSystemPrompt = getActiveAgentNameFromSystemPrompt(systemPrompt);
|
|
237
|
+
if (fromSystemPrompt) {
|
|
238
|
+
runtime.lastKnownActiveAgentName = fromSystemPrompt;
|
|
239
|
+
return fromSystemPrompt;
|
|
240
|
+
}
|
|
241
|
+
return runtime.lastKnownActiveAgentName;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Write the resolved config path set (global, project, legacy) to the review
|
|
246
|
+
* and debug logs.
|
|
247
|
+
*/
|
|
248
|
+
export function logResolvedConfigPaths(runtime: ExtensionRuntime): void {
|
|
249
|
+
const policyPaths = runtime.permissionManager.getResolvedPolicyPaths();
|
|
250
|
+
const cwd = runtime.runtimeContext?.cwd ?? null;
|
|
251
|
+
const { agentDir } = runtime;
|
|
252
|
+
const legacyGlobalPolicyDetected = existsSync(
|
|
253
|
+
getLegacyGlobalPolicyPath(agentDir),
|
|
254
|
+
);
|
|
255
|
+
const legacyProjectPolicyDetected = cwd
|
|
256
|
+
? existsSync(getLegacyProjectPolicyPath(cwd))
|
|
257
|
+
: false;
|
|
258
|
+
const legacyExtConfigPath = getLegacyExtensionConfigPath(EXTENSION_ROOT);
|
|
259
|
+
const newGlobalPath = getGlobalConfigPath(agentDir);
|
|
260
|
+
const legacyExtensionConfigDetected =
|
|
261
|
+
normalize(legacyExtConfigPath) !== normalize(newGlobalPath) &&
|
|
262
|
+
existsSync(legacyExtConfigPath);
|
|
263
|
+
const entry = buildResolvedConfigLogEntry({
|
|
264
|
+
policyPaths,
|
|
265
|
+
legacyGlobalPolicyDetected,
|
|
266
|
+
legacyProjectPolicyDetected,
|
|
267
|
+
legacyExtensionConfigDetected,
|
|
268
|
+
});
|
|
269
|
+
runtime.writeReviewLog(
|
|
270
|
+
"config.resolved",
|
|
271
|
+
entry as unknown as Record<string, unknown>,
|
|
272
|
+
);
|
|
273
|
+
runtime.writeDebugLog(
|
|
274
|
+
"config.resolved",
|
|
275
|
+
entry as unknown as Record<string, unknown>,
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ── Permission helpers ─────────────────────────────────────────────────────
|
|
280
|
+
|
|
281
|
+
/** Internal: write a structured permission decision entry to the review log. */
|
|
282
|
+
function reviewPermissionDecision(
|
|
283
|
+
writeReviewLog: (event: string, details: Record<string, unknown>) => void,
|
|
284
|
+
event: string,
|
|
285
|
+
details: PromptPermissionDetails & {
|
|
286
|
+
resolution?: string;
|
|
287
|
+
denialReason?: string;
|
|
288
|
+
},
|
|
289
|
+
): void {
|
|
290
|
+
writeReviewLog(event, {
|
|
291
|
+
requestId: details.requestId,
|
|
292
|
+
source: details.source,
|
|
293
|
+
agentName: details.agentName,
|
|
294
|
+
message: details.message,
|
|
295
|
+
toolCallId: details.toolCallId ?? null,
|
|
296
|
+
toolName: details.toolName ?? null,
|
|
297
|
+
skillName: details.skillName ?? null,
|
|
298
|
+
path: details.path ?? null,
|
|
299
|
+
command: details.command ?? null,
|
|
300
|
+
target: details.target ?? null,
|
|
301
|
+
toolInputPreview: details.toolInputPreview ?? null,
|
|
302
|
+
resolution: details.resolution ?? null,
|
|
303
|
+
denialReason: details.denialReason ?? null,
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Prompt the user for a permission decision using the forwarding flow,
|
|
309
|
+
* log the waiting / approved / denied outcome, and return the decision.
|
|
310
|
+
* In yolo mode, auto-approves without prompting.
|
|
311
|
+
*/
|
|
312
|
+
export async function promptPermission(
|
|
313
|
+
runtime: ExtensionRuntime,
|
|
314
|
+
forwardingDeps: PermissionForwardingDeps,
|
|
315
|
+
ctx: ExtensionContext,
|
|
316
|
+
details: PromptPermissionDetails,
|
|
317
|
+
): Promise<PermissionPromptDecision> {
|
|
318
|
+
if (shouldAutoApprovePermissionState("ask", runtime.config)) {
|
|
319
|
+
reviewPermissionDecision(
|
|
320
|
+
runtime.writeReviewLog,
|
|
321
|
+
"permission_request.auto_approved",
|
|
322
|
+
details,
|
|
323
|
+
);
|
|
324
|
+
return { approved: true, state: "approved" };
|
|
325
|
+
}
|
|
326
|
+
reviewPermissionDecision(
|
|
327
|
+
runtime.writeReviewLog,
|
|
328
|
+
"permission_request.waiting",
|
|
329
|
+
details,
|
|
330
|
+
);
|
|
331
|
+
const decision = await confirmPermission(
|
|
332
|
+
ctx,
|
|
333
|
+
details.message,
|
|
334
|
+
forwardingDeps,
|
|
335
|
+
);
|
|
336
|
+
reviewPermissionDecision(
|
|
337
|
+
runtime.writeReviewLog,
|
|
338
|
+
decision.approved
|
|
339
|
+
? "permission_request.approved"
|
|
340
|
+
: "permission_request.denied",
|
|
341
|
+
{
|
|
342
|
+
...details,
|
|
343
|
+
resolution: decision.state,
|
|
344
|
+
denialReason: decision.denialReason,
|
|
345
|
+
},
|
|
346
|
+
);
|
|
347
|
+
return decision;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// ── Forwarding polling lifecycle ───────────────────────────────────────────
|
|
351
|
+
|
|
352
|
+
/** Stop the forwarded-permission polling interval and clear related state. */
|
|
353
|
+
export function stopForwardedPermissionPolling(
|
|
354
|
+
runtime: ExtensionRuntime,
|
|
355
|
+
): void {
|
|
356
|
+
if (runtime.permissionForwardingTimer) {
|
|
357
|
+
clearInterval(runtime.permissionForwardingTimer);
|
|
358
|
+
runtime.permissionForwardingTimer = null;
|
|
359
|
+
}
|
|
360
|
+
runtime.permissionForwardingContext = null;
|
|
361
|
+
runtime.isProcessingForwardedRequests = false;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Start the forwarded-permission polling interval.
|
|
366
|
+
* No-ops (and stops any existing poll) when the context has no UI or is a
|
|
367
|
+
* subagent execution context.
|
|
368
|
+
*/
|
|
369
|
+
export function startForwardedPermissionPolling(
|
|
370
|
+
runtime: ExtensionRuntime,
|
|
371
|
+
forwardingDeps: PermissionForwardingDeps,
|
|
372
|
+
ctx: ExtensionContext,
|
|
373
|
+
): void {
|
|
374
|
+
if (
|
|
375
|
+
!ctx.hasUI ||
|
|
376
|
+
isSubagentExecutionContext(ctx, runtime.subagentSessionsDir)
|
|
377
|
+
) {
|
|
378
|
+
stopForwardedPermissionPolling(runtime);
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
runtime.permissionForwardingContext = ctx;
|
|
382
|
+
if (runtime.permissionForwardingTimer) {
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
runtime.permissionForwardingTimer = setInterval(() => {
|
|
386
|
+
if (
|
|
387
|
+
!runtime.permissionForwardingContext ||
|
|
388
|
+
runtime.isProcessingForwardedRequests
|
|
389
|
+
) {
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
runtime.isProcessingForwardedRequests = true;
|
|
393
|
+
void processForwardedPermissionRequests(
|
|
394
|
+
runtime.permissionForwardingContext,
|
|
395
|
+
forwardingDeps,
|
|
396
|
+
).finally(() => {
|
|
397
|
+
runtime.isProcessingForwardedRequests = false;
|
|
398
|
+
});
|
|
399
|
+
}, PERMISSION_FORWARDING_POLL_INTERVAL_MS);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// ── Factory ────────────────────────────────────────────────────────────────
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Create a fully-initialized `ExtensionRuntime`.
|
|
406
|
+
*
|
|
407
|
+
* Calls `getAgentDir()` at invocation time (never at module scope), so tests
|
|
408
|
+
* may set `PI_CODING_AGENT_DIR` before calling the factory.
|
|
409
|
+
*/
|
|
410
|
+
export function createExtensionRuntime(options?: {
|
|
411
|
+
agentDir?: string;
|
|
412
|
+
}): ExtensionRuntime {
|
|
413
|
+
const agentDir = options?.agentDir ?? getAgentDir();
|
|
414
|
+
const sessionsDir = join(agentDir, "sessions");
|
|
415
|
+
const subagentSessionsDir = join(agentDir, "subagent-sessions");
|
|
416
|
+
const forwardingDir = join(sessionsDir, "permission-forwarding");
|
|
417
|
+
const globalLogsDir = getGlobalLogsDir(agentDir);
|
|
418
|
+
|
|
419
|
+
// Build a plain-object runtime first so the logger's `getConfig` closure
|
|
420
|
+
// can reference `runtime.config` directly (always reads current value).
|
|
421
|
+
const runtime: ExtensionRuntime = {
|
|
422
|
+
agentDir,
|
|
423
|
+
sessionsDir,
|
|
424
|
+
subagentSessionsDir,
|
|
425
|
+
forwardingDir,
|
|
426
|
+
globalLogsDir,
|
|
427
|
+
config: { ...DEFAULT_EXTENSION_CONFIG },
|
|
428
|
+
runtimeContext: null,
|
|
429
|
+
permissionManager: createPermissionManagerForCwd(agentDir, undefined),
|
|
430
|
+
activeSkillEntries: [],
|
|
431
|
+
lastKnownActiveAgentName: null,
|
|
432
|
+
lastActiveToolsCacheKey: null,
|
|
433
|
+
lastPromptStateCacheKey: null,
|
|
434
|
+
lastConfigWarning: null,
|
|
435
|
+
sessionApprovalCache: new SessionApprovalCache(),
|
|
436
|
+
permissionForwardingContext: null,
|
|
437
|
+
permissionForwardingTimer: null,
|
|
438
|
+
isProcessingForwardedRequests: false,
|
|
439
|
+
// Logging methods are replaced below after the logger is constructed.
|
|
440
|
+
writeDebugLog: () => {},
|
|
441
|
+
writeReviewLog: () => {},
|
|
442
|
+
};
|
|
443
|
+
|
|
444
|
+
const reportedLoggingWarnings = new Set<string>();
|
|
445
|
+
const logger = createPermissionSystemLogger({
|
|
446
|
+
// Reads runtime.config at call time — always current.
|
|
447
|
+
getConfig: () => runtime.config,
|
|
448
|
+
debugLogPath: join(globalLogsDir, DEBUG_LOG_FILENAME),
|
|
449
|
+
reviewLogPath: join(globalLogsDir, REVIEW_LOG_FILENAME),
|
|
450
|
+
ensureLogsDirectory: () =>
|
|
451
|
+
ensurePermissionSystemLogsDirectory(globalLogsDir),
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
const reportLoggingWarning = (message: string): void => {
|
|
455
|
+
if (reportedLoggingWarnings.has(message)) {
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
reportedLoggingWarnings.add(message);
|
|
459
|
+
// Reads runtime.runtimeContext at call time — always current.
|
|
460
|
+
runtime.runtimeContext?.ui.notify(message, "warning");
|
|
461
|
+
};
|
|
462
|
+
|
|
463
|
+
runtime.writeDebugLog = (
|
|
464
|
+
event: string,
|
|
465
|
+
details: Record<string, unknown> = {},
|
|
466
|
+
): void => {
|
|
467
|
+
const warning = logger.debug(event, details);
|
|
468
|
+
if (warning) {
|
|
469
|
+
reportLoggingWarning(warning);
|
|
470
|
+
}
|
|
471
|
+
};
|
|
472
|
+
|
|
473
|
+
runtime.writeReviewLog = (
|
|
474
|
+
event: string,
|
|
475
|
+
details: Record<string, unknown> = {},
|
|
476
|
+
): void => {
|
|
477
|
+
const warning = logger.review(event, details);
|
|
478
|
+
if (warning) {
|
|
479
|
+
reportLoggingWarning(warning);
|
|
480
|
+
}
|
|
481
|
+
};
|
|
482
|
+
|
|
483
|
+
return runtime;
|
|
484
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import type { ForwardedPermissionLogger } from "../../src/forwarded-permissions/io";
|
|
4
|
+
import {
|
|
5
|
+
formatUnknownErrorMessage,
|
|
6
|
+
isErrnoCode,
|
|
7
|
+
logPermissionForwardingError,
|
|
8
|
+
logPermissionForwardingWarning,
|
|
9
|
+
} from "../../src/forwarded-permissions/io";
|
|
10
|
+
|
|
11
|
+
// ── helpers ────────────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
function makeLogger(): ForwardedPermissionLogger {
|
|
14
|
+
return {
|
|
15
|
+
writeReviewLog: vi.fn(),
|
|
16
|
+
writeDebugLog: vi.fn(),
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// ── formatUnknownErrorMessage ──────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
describe("formatUnknownErrorMessage", () => {
|
|
23
|
+
it("returns the error message for Error instances", () => {
|
|
24
|
+
expect(formatUnknownErrorMessage(new Error("oops"))).toBe("oops");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("converts non-Error values to string", () => {
|
|
28
|
+
expect(formatUnknownErrorMessage("raw string")).toBe("raw string");
|
|
29
|
+
expect(formatUnknownErrorMessage(42)).toBe("42");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("falls back to String(error) for Error with empty message", () => {
|
|
33
|
+
// error.message is falsy (""), so the function falls through to String(error)
|
|
34
|
+
const e = new Error("");
|
|
35
|
+
expect(formatUnknownErrorMessage(e)).toBe("Error");
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// ── isErrnoCode ────────────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
describe("isErrnoCode", () => {
|
|
42
|
+
it("returns true when code matches", () => {
|
|
43
|
+
expect(isErrnoCode({ code: "ENOENT" }, "ENOENT")).toBe(true);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("returns false when code does not match", () => {
|
|
47
|
+
expect(isErrnoCode({ code: "EACCES" }, "ENOENT")).toBe(false);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("returns false for null", () => {
|
|
51
|
+
expect(isErrnoCode(null, "ENOENT")).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("returns false when no code property", () => {
|
|
55
|
+
expect(isErrnoCode({}, "ENOENT")).toBe(false);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// ── logPermissionForwardingWarning ─────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
describe("logPermissionForwardingWarning", () => {
|
|
62
|
+
it("calls logger.writeReviewLog with the warning event", () => {
|
|
63
|
+
const logger = makeLogger();
|
|
64
|
+
logPermissionForwardingWarning(logger, "something went wrong");
|
|
65
|
+
expect(logger.writeReviewLog).toHaveBeenCalledWith(
|
|
66
|
+
"permission_forwarding.warning",
|
|
67
|
+
{ message: "something went wrong" },
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("calls logger.writeDebugLog with the warning event", () => {
|
|
72
|
+
const logger = makeLogger();
|
|
73
|
+
logPermissionForwardingWarning(logger, "something went wrong");
|
|
74
|
+
expect(logger.writeDebugLog).toHaveBeenCalledWith(
|
|
75
|
+
"permission_forwarding.warning",
|
|
76
|
+
{ message: "something went wrong" },
|
|
77
|
+
);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("includes formatted error when an error is provided", () => {
|
|
81
|
+
const logger = makeLogger();
|
|
82
|
+
logPermissionForwardingWarning(logger, "bad thing", new Error("fs fail"));
|
|
83
|
+
expect(logger.writeReviewLog).toHaveBeenCalledWith(
|
|
84
|
+
"permission_forwarding.warning",
|
|
85
|
+
{ message: "bad thing", error: "fs fail" },
|
|
86
|
+
);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("does not throw when logger is null", () => {
|
|
90
|
+
expect(() => logPermissionForwardingWarning(null, "ignored")).not.toThrow();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("does not call anything when logger is null", () => {
|
|
94
|
+
// Verify the null-logger path is a true no-op — cannot easily spy on null,
|
|
95
|
+
// but we can verify the call succeeds silently.
|
|
96
|
+
expect(() =>
|
|
97
|
+
logPermissionForwardingWarning(null, "msg", new Error("err")),
|
|
98
|
+
).not.toThrow();
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// ── logPermissionForwardingError ───────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
describe("logPermissionForwardingError", () => {
|
|
105
|
+
it("calls logger.writeReviewLog with the error event", () => {
|
|
106
|
+
const logger = makeLogger();
|
|
107
|
+
logPermissionForwardingError(logger, "critical failure");
|
|
108
|
+
expect(logger.writeReviewLog).toHaveBeenCalledWith(
|
|
109
|
+
"permission_forwarding.error",
|
|
110
|
+
{ message: "critical failure" },
|
|
111
|
+
);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("calls logger.writeDebugLog with the error event", () => {
|
|
115
|
+
const logger = makeLogger();
|
|
116
|
+
logPermissionForwardingError(logger, "critical failure");
|
|
117
|
+
expect(logger.writeDebugLog).toHaveBeenCalledWith(
|
|
118
|
+
"permission_forwarding.error",
|
|
119
|
+
{ message: "critical failure" },
|
|
120
|
+
);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("includes formatted error when an error is provided", () => {
|
|
124
|
+
const logger = makeLogger();
|
|
125
|
+
logPermissionForwardingError(logger, "io error", new Error("ENOENT"));
|
|
126
|
+
expect(logger.writeReviewLog).toHaveBeenCalledWith(
|
|
127
|
+
"permission_forwarding.error",
|
|
128
|
+
{ message: "io error", error: "ENOENT" },
|
|
129
|
+
);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("does not throw when logger is null", () => {
|
|
133
|
+
expect(() => logPermissionForwardingError(null, "ignored")).not.toThrow();
|
|
134
|
+
});
|
|
135
|
+
});
|