@andares/pi-toolkits 0.8.0 → 0.9.1
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/README.md +72 -5
- package/README.zh-CN.md +71 -5
- package/package.json +7 -3
- package/src/features/ask/index.test.ts +118 -0
- package/src/features/ask/index.ts +31 -13
- package/src/features/autostart/autostart.test.ts +113 -2
- package/src/features/autostart/autostart.ts +28 -2
- package/src/features/autostart/index.ts +138 -49
- package/src/features/permission/constants.test.ts +60 -0
- package/src/features/permission/constants.ts +158 -0
- package/src/features/permission/index.test.ts +340 -0
- package/src/features/permission/index.ts +333 -0
- package/src/features/permission/modes.test.ts +66 -0
- package/src/features/permission/modes.ts +82 -0
- package/src/features/permission/panel.ts +118 -0
- package/src/features/permission/paths.test.ts +111 -0
- package/src/features/permission/paths.ts +126 -0
- package/src/features/permission/policy.test.ts +237 -0
- package/src/features/permission/policy.ts +338 -0
- package/src/features/permission/prompt.ts +146 -0
- package/src/features/permission/session-rules.ts +99 -0
- package/src/features/permission/units.test.ts +205 -0
- package/src/features/permission/units.ts +995 -0
- package/src/index.ts +15 -2
- package/src/lib/gates.test.ts +171 -0
- package/src/lib/gates.ts +153 -0
|
@@ -16,8 +16,10 @@ import {
|
|
|
16
16
|
AUTOSTART_CONFIG_PATH,
|
|
17
17
|
AutostartParseError,
|
|
18
18
|
commandNameOf,
|
|
19
|
+
GLOBAL_AUTOSTART_FILENAME,
|
|
19
20
|
parseAutostartConfig,
|
|
20
21
|
RUNNABLE_REASONS,
|
|
22
|
+
shouldReplay,
|
|
21
23
|
} from "./autostart.js";
|
|
22
24
|
import { registerAutostart } from "./index.js";
|
|
23
25
|
|
|
@@ -111,6 +113,28 @@ describe("RUNNABLE_REASONS", () => {
|
|
|
111
113
|
});
|
|
112
114
|
});
|
|
113
115
|
|
|
116
|
+
describe("shouldReplay", () => {
|
|
117
|
+
it("replays a genuinely new session", () => {
|
|
118
|
+
expect(shouldReplay("startup", false)).toBe(true);
|
|
119
|
+
expect(shouldReplay("new", false)).toBe(true);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("skips a startup that already has history (a resumed process)", () => {
|
|
123
|
+
// `pi -c`, `-r`, `--session` and `--fork` all report `startup` for a
|
|
124
|
+
// session that already carries history.
|
|
125
|
+
expect(shouldReplay("startup", true)).toBe(false);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("replays a fork even though the branch has history", () => {
|
|
129
|
+
expect(shouldReplay("fork", true)).toBe(true);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("skips in-process resume and reload", () => {
|
|
133
|
+
expect(shouldReplay("resume", true)).toBe(false);
|
|
134
|
+
expect(shouldReplay("reload", true)).toBe(false);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
114
138
|
/* ------------------------------------------------------------------ */
|
|
115
139
|
/* registerAutostart integration (mocked pi surface) */
|
|
116
140
|
/* ------------------------------------------------------------------ */
|
|
@@ -121,6 +145,7 @@ type SessionStartHandler = (
|
|
|
121
145
|
cwd: string;
|
|
122
146
|
isProjectTrusted: () => boolean;
|
|
123
147
|
ui: { notify: (message: string, type?: string) => void };
|
|
148
|
+
sessionManager: { buildContextEntries: () => Array<{ type: string }> };
|
|
124
149
|
},
|
|
125
150
|
) => Promise<void>;
|
|
126
151
|
|
|
@@ -132,14 +157,24 @@ interface Harness {
|
|
|
132
157
|
commands: Array<{ name: string; source: string }>;
|
|
133
158
|
failCommands: Set<string>;
|
|
134
159
|
setTrusted(trusted: boolean): void;
|
|
160
|
+
/** Simulate a session that already had a conversation (resumed process). */
|
|
161
|
+
setHistory(entries: Array<{ type: string }>): void;
|
|
135
162
|
start(reason: string): Promise<void>;
|
|
136
163
|
}
|
|
137
164
|
|
|
138
165
|
async function makeHarness(
|
|
139
166
|
config: string | null,
|
|
140
167
|
configAsDir = false,
|
|
168
|
+
globalConfig: string | null = null,
|
|
141
169
|
): Promise<Harness> {
|
|
142
170
|
const cwd = await mkdtemp(join(tmpdir(), "pi-autostart-test-"));
|
|
171
|
+
// The agent dir lives inside the temp project so cleanup covers it; passing
|
|
172
|
+
// it explicitly keeps the real ~/.pi/agent out of the test.
|
|
173
|
+
const agentDir = join(cwd, "agent");
|
|
174
|
+
await mkdir(agentDir, { recursive: true });
|
|
175
|
+
if (globalConfig !== null) {
|
|
176
|
+
await writeFile(join(agentDir, GLOBAL_AUTOSTART_FILENAME), globalConfig, "utf8");
|
|
177
|
+
}
|
|
143
178
|
if (configAsDir) {
|
|
144
179
|
// EISDIR setup: the config path exists but is a directory.
|
|
145
180
|
await mkdir(join(cwd, ".pi"), { recursive: true });
|
|
@@ -149,6 +184,12 @@ async function makeHarness(
|
|
|
149
184
|
await writeFile(join(cwd, AUTOSTART_CONFIG_PATH), config, "utf8");
|
|
150
185
|
}
|
|
151
186
|
let trusted = true;
|
|
187
|
+
// Boot-level entries every session has; a resumed session adds messages.
|
|
188
|
+
let history: Array<{ type: string }> = [
|
|
189
|
+
{ type: "session" },
|
|
190
|
+
{ type: "model_change" },
|
|
191
|
+
{ type: "thinking_level_change" },
|
|
192
|
+
];
|
|
152
193
|
let handler: SessionStartHandler = async () => {};
|
|
153
194
|
const sent: Harness["sent"] = [];
|
|
154
195
|
const notifications: Harness["notifications"] = [];
|
|
@@ -172,7 +213,7 @@ async function makeHarness(
|
|
|
172
213
|
}
|
|
173
214
|
},
|
|
174
215
|
} as unknown as ExtensionAPI;
|
|
175
|
-
registerAutostart(pi);
|
|
216
|
+
registerAutostart(pi, { agentDir });
|
|
176
217
|
return {
|
|
177
218
|
handler,
|
|
178
219
|
sent,
|
|
@@ -182,6 +223,9 @@ async function makeHarness(
|
|
|
182
223
|
setTrusted(value: boolean) {
|
|
183
224
|
trusted = value;
|
|
184
225
|
},
|
|
226
|
+
setHistory(entries) {
|
|
227
|
+
history = entries;
|
|
228
|
+
},
|
|
185
229
|
async start(reason: string) {
|
|
186
230
|
await handler({ reason }, {
|
|
187
231
|
cwd,
|
|
@@ -191,6 +235,7 @@ async function makeHarness(
|
|
|
191
235
|
notifications.push({ message, type });
|
|
192
236
|
},
|
|
193
237
|
},
|
|
238
|
+
sessionManager: { buildContextEntries: () => history },
|
|
194
239
|
});
|
|
195
240
|
},
|
|
196
241
|
cwd,
|
|
@@ -209,8 +254,9 @@ afterEach(async () => {
|
|
|
209
254
|
async function harness(
|
|
210
255
|
config: string | null,
|
|
211
256
|
configAsDir = false,
|
|
257
|
+
globalConfig: string | null = null,
|
|
212
258
|
): Promise<Harness> {
|
|
213
|
-
const h = await makeHarness(config, configAsDir);
|
|
259
|
+
const h = await makeHarness(config, configAsDir, globalConfig);
|
|
214
260
|
current = h;
|
|
215
261
|
return h;
|
|
216
262
|
}
|
|
@@ -256,6 +302,38 @@ describe("registerAutostart", () => {
|
|
|
256
302
|
expect(h.notifications).toEqual([]);
|
|
257
303
|
});
|
|
258
304
|
|
|
305
|
+
it("skips a process started on an existing session (pi -c reports startup)", async () => {
|
|
306
|
+
const h = await harness(fullConfig);
|
|
307
|
+
h.setHistory([
|
|
308
|
+
{ type: "session" },
|
|
309
|
+
{ type: "model_change" },
|
|
310
|
+
{ type: "message" },
|
|
311
|
+
]);
|
|
312
|
+
await h.start("startup");
|
|
313
|
+
expect(h.sent).toEqual([]);
|
|
314
|
+
expect(h.notifications).toEqual([]);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it("still replays when the session holds only boot-level entries", async () => {
|
|
318
|
+
const h = await harness(fullConfig);
|
|
319
|
+
h.setHistory([
|
|
320
|
+
{ type: "session" },
|
|
321
|
+
{ type: "model_change" },
|
|
322
|
+
{ type: "thinking_level_change" },
|
|
323
|
+
{ type: "session_info" },
|
|
324
|
+
{ type: "custom" },
|
|
325
|
+
]);
|
|
326
|
+
await h.start("startup");
|
|
327
|
+
expect(h.sent).toHaveLength(2);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it("replays a fork of a session that has history", async () => {
|
|
331
|
+
const h = await harness(fullConfig);
|
|
332
|
+
h.setHistory([{ type: "message" }]);
|
|
333
|
+
await h.start("fork");
|
|
334
|
+
expect(h.sent).toHaveLength(2);
|
|
335
|
+
});
|
|
336
|
+
|
|
259
337
|
it("silently skips when no config file exists", async () => {
|
|
260
338
|
const h = await harness(null);
|
|
261
339
|
await h.start("startup");
|
|
@@ -312,4 +390,37 @@ describe("registerAutostart", () => {
|
|
|
312
390
|
expect(h.notifications).toHaveLength(1);
|
|
313
391
|
expect(h.notifications[0]?.type).toBe("error");
|
|
314
392
|
});
|
|
393
|
+
|
|
394
|
+
it("runs the global config before the project config", async () => {
|
|
395
|
+
const h = await harness(
|
|
396
|
+
'[command]\nrun = ["/add-dir ../project"]',
|
|
397
|
+
false,
|
|
398
|
+
'[command]\nrun = ["/ask on"]',
|
|
399
|
+
);
|
|
400
|
+
await h.start("startup");
|
|
401
|
+
expect(h.sent.map((entry) => entry.command)).toEqual([
|
|
402
|
+
"/ask on",
|
|
403
|
+
"/add-dir ../project",
|
|
404
|
+
]);
|
|
405
|
+
expect(h.notifications).toEqual([]);
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
it("runs the global config in an untrusted project but skips the project one", async () => {
|
|
409
|
+
const h = await harness(
|
|
410
|
+
'[command]\nrun = ["/add-dir ../project"]',
|
|
411
|
+
false,
|
|
412
|
+
'[command]\nrun = ["/ask on"]',
|
|
413
|
+
);
|
|
414
|
+
h.setTrusted(false);
|
|
415
|
+
await h.start("startup");
|
|
416
|
+
expect(h.sent.map((entry) => entry.command)).toEqual(["/ask on"]);
|
|
417
|
+
expect(h.notifications).toHaveLength(1);
|
|
418
|
+
expect(h.notifications[0]?.type).toBe("warning");
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
it("keeps running the project config when the global one is absent", async () => {
|
|
422
|
+
const h = await harness('[command]\nrun = ["/ask on"]', false, null);
|
|
423
|
+
await h.start("startup");
|
|
424
|
+
expect(h.sent.map((entry) => entry.command)).toEqual(["/ask on"]);
|
|
425
|
+
});
|
|
315
426
|
});
|
|
@@ -18,10 +18,12 @@
|
|
|
18
18
|
*/
|
|
19
19
|
import { parse as parseToml } from "smol-toml";
|
|
20
20
|
|
|
21
|
-
/** Project-relative location of the autostart config
|
|
22
|
-
* project-config convention, precedent: `.pi/mcp.json`). */
|
|
21
|
+
/** Project-relative location of the project autostart config. */
|
|
23
22
|
export const AUTOSTART_CONFIG_PATH = ".pi/autostart.toml";
|
|
24
23
|
|
|
24
|
+
/** File name of the operator's global autostart config, inside the agent dir. */
|
|
25
|
+
export const GLOBAL_AUTOSTART_FILENAME = "autostart.toml";
|
|
26
|
+
|
|
25
27
|
/**
|
|
26
28
|
* session_start reasons that replay autostart commands:
|
|
27
29
|
* - startup / new: fresh session in the project — replay
|
|
@@ -39,6 +41,30 @@ export const RUNNABLE_REASONS: ReadonlySet<string> = new Set([
|
|
|
39
41
|
"fork",
|
|
40
42
|
]);
|
|
41
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Whether this session start should replay the command lists.
|
|
46
|
+
*
|
|
47
|
+
* The reason alone is not enough at process start. `pi -c` (`--continue`),
|
|
48
|
+
* `-r` (`--resume`), `--session <path>` and `--fork <path>` all open an
|
|
49
|
+
* existing session *before* the first runtime exists, and that runtime is
|
|
50
|
+
* created without an explicit session_start event, so extensions see pi's
|
|
51
|
+
* default reason — `startup` — not `resume` (verified on pi 1.0.0–1.3.0:
|
|
52
|
+
* `main.js` builds the initial session manager from those flags and calls
|
|
53
|
+
* `createAgentSessionRuntime` with no `sessionStartEvent`;
|
|
54
|
+
* `agent-session.js` defaults it to `{ reason: "startup" }`). `resume` is only
|
|
55
|
+
* emitted for in-process session switches, never for a resumed process.
|
|
56
|
+
*
|
|
57
|
+
* So the session content decides: a `startup` on a session that already has
|
|
58
|
+
* conversation history is a resumed (or forked) process, and its branch already
|
|
59
|
+
* carries the state a replay would rebuild. `hasHistory` is the same signal pi
|
|
60
|
+
* itself uses to recognise an existing session
|
|
61
|
+
* (`buildSessionContext().messages.length > 0`).
|
|
62
|
+
*/
|
|
63
|
+
export function shouldReplay(reason: string, hasHistory: boolean): boolean {
|
|
64
|
+
if (reason === "startup") return !hasHistory;
|
|
65
|
+
return RUNNABLE_REASONS.has(reason);
|
|
66
|
+
}
|
|
67
|
+
|
|
42
68
|
/** Parse/validation failure with a user-facing message. */
|
|
43
69
|
export class AutostartParseError extends Error {}
|
|
44
70
|
|
|
@@ -1,81 +1,157 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* autostart feature — run
|
|
2
|
+
* autostart feature — run declared slash commands at session start.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* the schema). Execution gates, in order:
|
|
4
|
+
* Two sources, executed in order, never merged:
|
|
6
5
|
*
|
|
7
|
-
* 1.
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
6
|
+
* 1. `<agentDir>/autostart.toml` — the operator's own defaults. Runs in every
|
|
7
|
+
* project; no trust gate, because the file lives in pi's config directory
|
|
8
|
+
* and is not controlled by the project.
|
|
9
|
+
* 2. `<project root>/.pi/autostart.toml` — project-declared commands. Gated on
|
|
10
|
+
* `ctx.isProjectTrusted()`: an untrusted repository must not execute
|
|
11
|
+
* anything.
|
|
12
|
+
*
|
|
13
|
+
* Execution gates, in order:
|
|
14
|
+
*
|
|
15
|
+
* - reason filter — see `shouldReplay`: startup/new/fork replay, but a
|
|
16
|
+
* `startup` that already carries conversation history is a process started
|
|
17
|
+
* on an existing session (`pi -c`/`-r`/`--session`/`--fork`) and does not
|
|
18
|
+
* - config file exists — a missing file is a silent opt-in skip
|
|
19
|
+
* - project trust (project source only)
|
|
20
|
+
* - per-command pre-validation against `pi.getCommands()`: only commands
|
|
21
|
+
* registered by an *extension* are dispatched. This is mandatory:
|
|
22
|
+
* sendUserMessage with an unhandled "/x" falls through prompt() to the
|
|
23
|
+
* model as a plain user message (pi agent-session.js), so an unknown
|
|
24
|
+
* command must be skipped here, never sent.
|
|
15
25
|
*
|
|
16
26
|
* Dispatch uses `sendUserMessage(cmd, { expandPromptTemplates: true })` —
|
|
17
27
|
* that flag is what routes "/cmd args" through _tryExecuteExtensionCommand
|
|
18
|
-
* instead of the model prompt path. The wrapper defaults it to false
|
|
19
|
-
* (each byte-identical across every published version of the supported
|
|
20
|
-
* window, 1.0.0 → 1.3.0; see AGENTS.md host-compat
|
|
21
|
-
*
|
|
28
|
+
* instead of the model prompt path. The wrapper defaults it to false in this
|
|
29
|
+
* package (each byte-identical across every published version of the supported
|
|
30
|
+
* window, 1.0.0 → 1.3.0; see AGENTS.md host-compat discipline for the version
|
|
31
|
+
* matrix).
|
|
22
32
|
*
|
|
23
33
|
* Only extension commands can run this way. Built-in commands (/compact,
|
|
24
34
|
* /reload, /resume, …) are dispatched at the TUI layer and cannot be
|
|
25
35
|
* triggered from the extension layer; skill commands (/skill:name) expand
|
|
26
|
-
* into prompts rather than execute. First-party use
|
|
27
|
-
* (pi-add-dir
|
|
36
|
+
* into prompts rather than execute. First-party use cases: `/add-dir`
|
|
37
|
+
* (pi-add-dir, idempotent) and `/ask-perm` (session-scoped permission).
|
|
28
38
|
*/
|
|
29
39
|
import { readFile } from "node:fs/promises";
|
|
30
40
|
import { join } from "node:path";
|
|
31
|
-
import
|
|
41
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
42
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
32
43
|
import {
|
|
33
44
|
AUTOSTART_CONFIG_PATH,
|
|
34
45
|
AutostartParseError,
|
|
35
46
|
commandNameOf,
|
|
47
|
+
GLOBAL_AUTOSTART_FILENAME,
|
|
36
48
|
parseAutostartConfig,
|
|
37
|
-
|
|
49
|
+
shouldReplay,
|
|
38
50
|
} from "./autostart.js";
|
|
39
51
|
|
|
40
|
-
export
|
|
41
|
-
pi
|
|
42
|
-
|
|
52
|
+
export interface AutostartOptions {
|
|
53
|
+
/** pi's config directory; defaults to `getAgentDir()`. */
|
|
54
|
+
readonly agentDir?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface Source {
|
|
58
|
+
readonly path: string;
|
|
59
|
+
readonly label: string;
|
|
60
|
+
/** Project sources require trust; the operator's global file does not. */
|
|
61
|
+
readonly requiresTrust: boolean;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function messageOf(error: unknown): string {
|
|
65
|
+
return error instanceof Error ? error.message : String(error);
|
|
66
|
+
}
|
|
43
67
|
|
|
68
|
+
/**
|
|
69
|
+
* Entry types that prove the session already held a conversation.
|
|
70
|
+
*
|
|
71
|
+
* `buildContextEntries()` returns the active branch's entries including the
|
|
72
|
+
* boot-level ones (`session`, `model_change`, `thinking_level_change`), so
|
|
73
|
+
* presence alone is not enough — a brand-new session has those too.
|
|
74
|
+
*/
|
|
75
|
+
const HISTORY_ENTRY_TYPES: ReadonlySet<string> = new Set([
|
|
76
|
+
"message",
|
|
77
|
+
"custom_message",
|
|
78
|
+
"compaction",
|
|
79
|
+
"branch_summary",
|
|
80
|
+
]);
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Whether the session already carries conversation history.
|
|
84
|
+
*
|
|
85
|
+
* This is how a `startup` on an existing session (a resumed process) is told
|
|
86
|
+
* apart from a genuinely new one — see {@link shouldReplay}. An unreadable
|
|
87
|
+
* session manager counts as new, which keeps the feature working in hosts where
|
|
88
|
+
* the session surface is unavailable.
|
|
89
|
+
*/
|
|
90
|
+
function hasConversationHistory(ctx: ExtensionContext): boolean {
|
|
91
|
+
try {
|
|
92
|
+
return ctx.sessionManager
|
|
93
|
+
.buildContextEntries()
|
|
94
|
+
.some((entry) => HISTORY_ENTRY_TYPES.has(entry.type));
|
|
95
|
+
} catch {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function registerAutostart(
|
|
101
|
+
pi: ExtensionAPI,
|
|
102
|
+
options: AutostartOptions = {},
|
|
103
|
+
): void {
|
|
104
|
+
const agentDir = options.agentDir ?? getAgentDir();
|
|
105
|
+
|
|
106
|
+
async function readCommands(
|
|
107
|
+
ctx: ExtensionContext,
|
|
108
|
+
source: Source,
|
|
109
|
+
): Promise<string[] | undefined> {
|
|
44
110
|
let text: string;
|
|
45
111
|
try {
|
|
46
|
-
text = await readFile(
|
|
112
|
+
text = await readFile(source.path, "utf8");
|
|
47
113
|
} catch (err) {
|
|
48
114
|
// Missing file = opt-in skip. Any other read failure (EACCES, EISDIR,
|
|
49
115
|
// …) is a misconfiguration the operator should hear about.
|
|
50
116
|
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
51
117
|
ctx.ui.notify(
|
|
52
|
-
`autostart: skipped — cannot read ${
|
|
118
|
+
`autostart: skipped ${source.label} — cannot read ${source.path}: ${messageOf(err)}`,
|
|
53
119
|
"warning",
|
|
54
120
|
);
|
|
55
121
|
}
|
|
56
|
-
return;
|
|
122
|
+
return undefined;
|
|
57
123
|
}
|
|
58
124
|
|
|
59
|
-
let commands: string[];
|
|
60
125
|
try {
|
|
61
|
-
|
|
126
|
+
return parseAutostartConfig(text);
|
|
62
127
|
} catch (err) {
|
|
63
128
|
const message = err instanceof AutostartParseError ? err.message : String(err);
|
|
64
129
|
ctx.ui.notify(
|
|
65
|
-
`autostart: skipped — ${message} (${
|
|
130
|
+
`autostart: skipped ${source.label} — ${message} (${source.path})`,
|
|
66
131
|
"error",
|
|
67
132
|
);
|
|
68
|
-
return;
|
|
133
|
+
return undefined;
|
|
69
134
|
}
|
|
70
|
-
|
|
135
|
+
}
|
|
71
136
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
137
|
+
pi.on("session_start", async (event, ctx) => {
|
|
138
|
+
// `pi -c` / `-r` / `--session` / `--fork` report `startup` for a session
|
|
139
|
+
// that already has history; the content is what tells them apart. See
|
|
140
|
+
// shouldReplay in autostart.ts for the host-side evidence.
|
|
141
|
+
if (!shouldReplay(event.reason, hasConversationHistory(ctx))) return;
|
|
142
|
+
|
|
143
|
+
const sources: Source[] = [
|
|
144
|
+
{
|
|
145
|
+
path: join(agentDir, GLOBAL_AUTOSTART_FILENAME),
|
|
146
|
+
label: "global config",
|
|
147
|
+
requiresTrust: false,
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
path: join(ctx.cwd, AUTOSTART_CONFIG_PATH),
|
|
151
|
+
label: "project config",
|
|
152
|
+
requiresTrust: true,
|
|
153
|
+
},
|
|
154
|
+
];
|
|
79
155
|
|
|
80
156
|
// Only extension-registered commands are dispatchable; skill and
|
|
81
157
|
// prompt-template entries expand rather than execute.
|
|
@@ -87,23 +163,36 @@ export function registerAutostart(pi: ExtensionAPI): void {
|
|
|
87
163
|
),
|
|
88
164
|
);
|
|
89
165
|
|
|
90
|
-
for (const
|
|
91
|
-
const
|
|
92
|
-
if (
|
|
166
|
+
for (const source of sources) {
|
|
167
|
+
const commands = await readCommands(ctx, source);
|
|
168
|
+
if (commands === undefined || commands.length === 0) continue;
|
|
169
|
+
|
|
170
|
+
if (source.requiresTrust && !ctx.isProjectTrusted()) {
|
|
93
171
|
ctx.ui.notify(
|
|
94
|
-
`autostart: skipped
|
|
172
|
+
`autostart: skipped ${source.label} — project not trusted (${source.path})`,
|
|
95
173
|
"warning",
|
|
96
174
|
);
|
|
97
175
|
continue;
|
|
98
176
|
}
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
177
|
+
|
|
178
|
+
for (const command of commands) {
|
|
179
|
+
const name = commandNameOf(command);
|
|
180
|
+
if (!name || !dispatchable.has(name)) {
|
|
181
|
+
ctx.ui.notify(
|
|
182
|
+
`autostart: skipped "${command}" — not a registered extension command`,
|
|
183
|
+
"warning",
|
|
184
|
+
);
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
// One failing command must not cancel the rest of the list.
|
|
188
|
+
try {
|
|
189
|
+
await pi.sendUserMessage(command, { expandPromptTemplates: true });
|
|
190
|
+
} catch (err) {
|
|
191
|
+
ctx.ui.notify(
|
|
192
|
+
`autostart: "${command}" failed — ${messageOf(err)}`,
|
|
193
|
+
"error",
|
|
194
|
+
);
|
|
195
|
+
}
|
|
107
196
|
}
|
|
108
197
|
}
|
|
109
198
|
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fixed safety tables: credential paths are denied in every mode, destructive
|
|
3
|
+
* commands always ask.
|
|
4
|
+
*/
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { describe, expect, it } from "vitest";
|
|
8
|
+
import { dangerReason, secretReason } from "./constants.js";
|
|
9
|
+
|
|
10
|
+
const HOME = homedir();
|
|
11
|
+
|
|
12
|
+
describe("secrets table", () => {
|
|
13
|
+
it("covers the credential directories exactly", () => {
|
|
14
|
+
expect(secretReason(join(HOME, ".ssh", "id_rsa"))).toBe("credentials");
|
|
15
|
+
expect(secretReason(join(HOME, ".ssh"))).toBe("credentials");
|
|
16
|
+
expect(secretReason(join(HOME, ".gnupg", "private-keys-v1.d"))).toBe("credentials");
|
|
17
|
+
expect(secretReason(join(HOME, ".aws", "credentials"))).toBe("credentials");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("does not over-match a sibling prefix", () => {
|
|
21
|
+
expect(secretReason(join(HOME, ".ssh-easy", "x"))).toBeUndefined();
|
|
22
|
+
expect(secretReason(join(HOME, ".ssh_evil", "x"))).toBeUndefined();
|
|
23
|
+
expect(secretReason(join(HOME, ".awsconfig", "x"))).toBeUndefined();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("covers pi auth files but nothing else under the agent dir", () => {
|
|
27
|
+
expect(secretReason(join(HOME, ".pi", "agent", "auth.json"))).toBe("pi credentials");
|
|
28
|
+
expect(secretReason(join(HOME, ".pi", "agent", "auth-foo.json"))).toBe("pi credentials");
|
|
29
|
+
expect(secretReason(join(HOME, ".pi", "agent", "settings.json"))).toBeUndefined();
|
|
30
|
+
expect(secretReason(join(HOME, ".pi", "agent", "xauth", "auth.json"))).toBeUndefined();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("covers .env files but not the templates", () => {
|
|
34
|
+
expect(secretReason("/proj/.env")).toBe("environment file");
|
|
35
|
+
expect(secretReason("/proj/.env.local")).toBe("environment file");
|
|
36
|
+
expect(secretReason("/proj/.env.example")).toBeUndefined();
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe("danger table", () => {
|
|
41
|
+
it("catches the destructive core", () => {
|
|
42
|
+
expect(dangerReason("rm", ["-rf", "/"], ["/"], "/proj")).toContain("recursive delete");
|
|
43
|
+
expect(dangerReason("rm", ["-rf", HOME], [HOME], "/proj")).toContain("recursive delete");
|
|
44
|
+
expect(dangerReason("rm", ["-rf", "/proj"], ["/proj"], "/proj")).toContain("recursive delete");
|
|
45
|
+
expect(dangerReason("git", ["push", "--force"], [], "/proj")).toBe("forced git push");
|
|
46
|
+
expect(dangerReason("git", ["push", "-f"], [], "/proj")).toBe("forced git push");
|
|
47
|
+
expect(dangerReason("git", ["reset", "--hard"], [], "/proj")).toBe("git reset --hard");
|
|
48
|
+
expect(dangerReason("git", ["clean", "-fd"], [], "/proj")).toBe("git clean -f");
|
|
49
|
+
expect(dangerReason("npm", ["publish"], [], "/proj")).toBe("package publish");
|
|
50
|
+
expect(dangerReason("dd", ["of=/dev/sda"], ["/dev/sda"], "/proj")).toBe("raw device write");
|
|
51
|
+
expect(dangerReason("sudo", ["rm", "x"], ["/proj/x"], "/proj")).toBe("privileged command");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("does not flag ordinary commands", () => {
|
|
55
|
+
expect(dangerReason("rm", ["-rf", "/proj/build"], ["/proj/build"], "/proj")).toBeUndefined();
|
|
56
|
+
expect(dangerReason("git", ["push"], [], "/proj")).toBeUndefined();
|
|
57
|
+
expect(dangerReason("git", ["log"], [], "/proj")).toBeUndefined();
|
|
58
|
+
expect(dangerReason("npm", ["view", "react"], [], "/proj")).toBeUndefined();
|
|
59
|
+
});
|
|
60
|
+
});
|