pi-creel 0.2.1 → 0.2.3
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 +17 -0
- package/package.json +1 -1
- package/src/capture.test.ts +13 -1
- package/src/capture.ts +31 -3
- package/src/index.test.ts +52 -2
- package/src/index.ts +20 -0
package/README.md
CHANGED
|
@@ -17,6 +17,23 @@ user pastes once, and the value goes straight from the popup into the target
|
|
|
17
17
|
- `name` — the environment-variable name, e.g. `OPENAI_API_KEY`.
|
|
18
18
|
- `dest` — path to the `.env` (relative to cwd), default `.env`.
|
|
19
19
|
|
|
20
|
+
## Consuming the secret
|
|
21
|
+
|
|
22
|
+
The value is written to `.env` mid-session, but the pi process env is frozen at
|
|
23
|
+
launch, so the model can't just read `process.env.NAME`. Two supported paths,
|
|
24
|
+
both of which keep the value out of the chat/context:
|
|
25
|
+
|
|
26
|
+
- **`creel exec NAME[,NAME2,...] -- <command>`** runs `<command>` with each
|
|
27
|
+
value in its environment **only** — never printed, never in argv, never in an
|
|
28
|
+
error (a missing key is a hard error naming just the key). This is the
|
|
29
|
+
language-agnostic way to hand a mid-session key to a child process
|
|
30
|
+
(`creel exec OPENAI_API_KEY -- node run.mjs`).
|
|
31
|
+
- **`process.env.NAME` after a relaunch**, once the new process inherits the
|
|
32
|
+
updated `.env`.
|
|
33
|
+
|
|
34
|
+
The model should **not** read the `.env` itself. The `added` / `updated` reply
|
|
35
|
+
from `request_secret` states this consumption path directly.
|
|
36
|
+
|
|
20
37
|
## Requirements
|
|
21
38
|
|
|
22
39
|
- A tmux session (`$TMUX`). Without one the tool fails fast and asks the user to
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-creel",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.3",
|
|
4
4
|
"description": "request_secret for the pi coding agent: capture an API key into a local .env via a tmux popup (the creel tool) so it never enters the chat/context.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/capture.test.ts
CHANGED
|
@@ -28,8 +28,20 @@ test("tokenToText maps every outcome and never echoes a value", () => {
|
|
|
28
28
|
assert.match(tokenToText("added", "OPENAI_API_KEY", ".env"), /Added OPENAI_API_KEY to \.env/);
|
|
29
29
|
assert.match(tokenToText("updated", "OPENAI_API_KEY", ".env"), /Updated OPENAI_API_KEY in \.env/);
|
|
30
30
|
assert.match(tokenToText("cancelled", "K", ".env"), /cancelled/i);
|
|
31
|
-
assert.match(tokenToText("error:dest-outside-cwd", "K", ".env"), /Could not store K: dest
|
|
31
|
+
assert.match(tokenToText("error:dest-outside-cwd", "K", ".env", "/p"), /Could not store K: dest must be inside the working directory \(\/p\)/);
|
|
32
32
|
assert.match(tokenToText(undefined, "K", ".env"), /Timed out/);
|
|
33
33
|
assert.match(tokenToText("", "K", ".env"), /Timed out/);
|
|
34
34
|
assert.match(tokenToText("weird", "K", ".env"), /unexpected status \(weird\)/);
|
|
35
35
|
});
|
|
36
|
+
|
|
37
|
+
test("a successful capture tells the model how to consume the secret", () => {
|
|
38
|
+
for (const token of ["added", "updated"]) {
|
|
39
|
+
const text = tokenToText(token, "TYPESAFE_API_KEY", ".env");
|
|
40
|
+
// Points at the containment path (creel exec) with the real var name...
|
|
41
|
+
assert.match(text, /creel exec TYPESAFE_API_KEY -- /);
|
|
42
|
+
// ...and the relaunch fallback...
|
|
43
|
+
assert.match(text, /process\.env\.TYPESAFE_API_KEY/);
|
|
44
|
+
// ...and steers the model away from reading the .env itself.
|
|
45
|
+
assert.match(text, /do not read|don't read/i);
|
|
46
|
+
}
|
|
47
|
+
});
|
package/src/capture.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
1
2
|
// Pure pieces of the request_secret tool: parameter schema, env-var name
|
|
2
3
|
// validation, the popup command string, and the status-token -> reply mapping.
|
|
3
4
|
// None of these ever see the captured value — creel writes it straight to the
|
|
@@ -14,7 +15,7 @@ export const REQUEST_SECRET_PARAMS = {
|
|
|
14
15
|
dest: {
|
|
15
16
|
type: "string",
|
|
16
17
|
description:
|
|
17
|
-
"Path to the .env file to write
|
|
18
|
+
"Path to the .env file to write. Must be inside the working directory (relative, e.g. .env or .worktrees/<name>/.env); creel refuses anything outside it. Defaults to .env.",
|
|
18
19
|
},
|
|
19
20
|
},
|
|
20
21
|
required: ["name"],
|
|
@@ -38,22 +39,49 @@ export function creelCommand(name: string, dest: string, statusFile: string): st
|
|
|
38
39
|
|
|
39
40
|
// tokenToText maps creel's status token to the reply the model sees. It never
|
|
40
41
|
// includes the secret — only the outcome.
|
|
42
|
+
// destInsideCwd mirrors creel's own rule (ResolveDest): an agent-chosen dest
|
|
43
|
+
// resolves against the working directory and may not leave it. Checked here
|
|
44
|
+
// too, so a refused path never opens a popup the user then sees fail.
|
|
45
|
+
export function destInsideCwd(cwd: string, dest: string): boolean {
|
|
46
|
+
const rel = relative(cwd, resolve(cwd, dest));
|
|
47
|
+
return rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// outsideCwdText is the reply for a dest outside the working directory: the
|
|
51
|
+
// rule, and a path that works.
|
|
52
|
+
export function outsideCwdText(name: string, cwd: string): string {
|
|
53
|
+
return (
|
|
54
|
+
`Could not store ${name}: dest must be inside the working directory (${cwd}), ` +
|
|
55
|
+
"so a secret only lands where this project keeps its own files. Use a path under it, " +
|
|
56
|
+
"e.g. .env, or .worktrees/<name>/.env for scratch work; nothing was written."
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
41
60
|
export function tokenToText(
|
|
42
61
|
token: string | undefined,
|
|
43
62
|
name: string,
|
|
44
63
|
dest: string,
|
|
64
|
+
cwd: string = process.cwd(),
|
|
45
65
|
): string {
|
|
46
66
|
if (token === undefined || token === "") {
|
|
47
67
|
return `Timed out waiting for the creel popup; nothing was recorded for ${name}.`;
|
|
48
68
|
}
|
|
69
|
+
// howToUse tells the model the two supported ways to consume a mid-session
|
|
70
|
+
// secret without ever handling the value itself. It must never suggest
|
|
71
|
+
// reading the .env directly (the value must not enter the harness/context).
|
|
72
|
+
const howToUse =
|
|
73
|
+
` The value was not shown here. To use it, run \`creel exec ${name} -- <command>\`` +
|
|
74
|
+
` (that puts ${name} in the command's environment only), or read \`process.env.${name}\`` +
|
|
75
|
+
` after a relaunch. Do not read ${dest} yourself.`;
|
|
49
76
|
switch (token) {
|
|
50
77
|
case "added":
|
|
51
|
-
return `Added ${name} to ${dest} (chmod 600)
|
|
78
|
+
return `Added ${name} to ${dest} (chmod 600).` + howToUse;
|
|
52
79
|
case "updated":
|
|
53
|
-
return `Updated ${name} in ${dest}
|
|
80
|
+
return `Updated ${name} in ${dest}.` + howToUse;
|
|
54
81
|
case "cancelled":
|
|
55
82
|
return `Capture cancelled; nothing was written for ${name}.`;
|
|
56
83
|
default:
|
|
84
|
+
if (token === "error:dest-outside-cwd") return outsideCwdText(name, cwd);
|
|
57
85
|
if (token.startsWith("error:")) {
|
|
58
86
|
return `Could not store ${name}: ${token.slice("error:".length)}.`;
|
|
59
87
|
}
|
package/src/index.test.ts
CHANGED
|
@@ -89,6 +89,56 @@ test("reports a timeout when no token arrives", async () => {
|
|
|
89
89
|
|
|
90
90
|
test("surfaces a creel error token", async () => {
|
|
91
91
|
const reg = harness(okDeps({ waitForToken: async () => "error:dest-outside-cwd" }));
|
|
92
|
-
const text = await run(reg, { name: "K", dest: "
|
|
93
|
-
assert.match(text, /Could not store K: dest
|
|
92
|
+
const text = await run(reg, { name: "K", dest: "sub/.env" });
|
|
93
|
+
assert.match(text, /Could not store K: dest must be inside the working directory/);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// The popup exits non-zero when creel refuses (tmux then throws), but creel has
|
|
97
|
+
// already written its reason to the status file: the agent must get that
|
|
98
|
+
// reason, not a bare "Command failed".
|
|
99
|
+
test("a popup that exits non-zero still reports creel's reason", async () => {
|
|
100
|
+
const reg = harness(okDeps({
|
|
101
|
+
spawnPopup: () => { throw new Error("Command failed: tmux display-popup ..."); },
|
|
102
|
+
readStatus: () => "error:dest-outside-cwd",
|
|
103
|
+
}));
|
|
104
|
+
const text = await run(reg, { name: "K", dest: "sub/.env" });
|
|
105
|
+
assert.match(text, /Could not store K/);
|
|
106
|
+
assert.match(text, /inside the working directory/);
|
|
107
|
+
assert.doesNotMatch(text, /Command failed/);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("a popup that fails with no status keeps the tmux error", async () => {
|
|
111
|
+
const reg = harness(okDeps({
|
|
112
|
+
spawnPopup: () => { throw new Error("no server running"); },
|
|
113
|
+
readStatus: () => undefined,
|
|
114
|
+
}));
|
|
115
|
+
assert.match(await run(reg, { name: "K" }), /Failed to run the creel popup: .*no server running/);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// A dest outside the working directory is refused before any popup opens, with
|
|
119
|
+
// the rule and a path that works.
|
|
120
|
+
test("a dest outside the working directory is refused without opening the popup", async () => {
|
|
121
|
+
for (const dest of ["../../tmp/x/.env", "/tmp/x/.env"]) {
|
|
122
|
+
let spawned = false;
|
|
123
|
+
const reg = harness(okDeps({ spawnPopup: () => { spawned = true; } }));
|
|
124
|
+
const text = await run(reg, { name: "K", dest });
|
|
125
|
+
assert.equal(spawned, false, `popup opened for ${dest}`);
|
|
126
|
+
assert.match(text, /inside the working directory/);
|
|
127
|
+
assert.match(text, /\.worktrees\//);
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("a dest inside the working directory, absolute or relative, opens the popup", async () => {
|
|
132
|
+
for (const dest of [".worktrees/check/.env", `${process.cwd()}/sub/.env`]) {
|
|
133
|
+
let spawned = false;
|
|
134
|
+
const reg = harness(okDeps({ spawnPopup: () => { spawned = true; } }));
|
|
135
|
+
await run(reg, { name: "K", dest });
|
|
136
|
+
assert.equal(spawned, true, `popup not opened for ${dest}`);
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("the tool description states the dest rule", () => {
|
|
141
|
+
let def: any;
|
|
142
|
+
createCreel({ registerTool(d: any) { def = d; } }, okDeps());
|
|
143
|
+
assert.match(def.parameters.properties.dest.description, /inside the working directory/);
|
|
94
144
|
});
|
package/src/index.ts
CHANGED
|
@@ -24,6 +24,8 @@ import {
|
|
|
24
24
|
REQUEST_SECRET_PARAMS,
|
|
25
25
|
creelCommand,
|
|
26
26
|
tokenToText,
|
|
27
|
+
destInsideCwd,
|
|
28
|
+
outsideCwdText,
|
|
27
29
|
validName,
|
|
28
30
|
} from "./capture.ts";
|
|
29
31
|
import { parseEvent, savedMessage } from "./notify.ts";
|
|
@@ -41,6 +43,7 @@ export type Deps = {
|
|
|
41
43
|
spawnPopup?: (tmux: TmuxContext, command: string) => void;
|
|
42
44
|
waitForToken?: (statusPath: string, timeoutMs: number) => Promise<string | undefined>;
|
|
43
45
|
tmpStatusPath?: () => string;
|
|
46
|
+
readStatus?: (statusPath: string) => string | undefined;
|
|
44
47
|
resolveSessionId?: (tmux: TmuxContext) => string | undefined;
|
|
45
48
|
homedir?: () => string;
|
|
46
49
|
watch?: (dir: string, cb: (event: string, filename: string | null) => void) => WatchHandle;
|
|
@@ -49,6 +52,14 @@ export type Deps = {
|
|
|
49
52
|
mkdir?: (dir: string) => void;
|
|
50
53
|
};
|
|
51
54
|
|
|
55
|
+
function defaultReadStatus(statusPath: string): string | undefined {
|
|
56
|
+
try {
|
|
57
|
+
return existsSync(statusPath) ? readFileSync(statusPath, "utf-8").trim() || undefined : undefined;
|
|
58
|
+
} catch {
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
52
63
|
function defaultCreelOnPath(): boolean {
|
|
53
64
|
try {
|
|
54
65
|
execFileSync("creel", ["version"], { stdio: "ignore" });
|
|
@@ -114,6 +125,7 @@ export function createCreel(pi: any, deps: Deps = {}): void {
|
|
|
114
125
|
const spawn = deps.spawnPopup ?? defaultSpawnPopup;
|
|
115
126
|
const wait = deps.waitForToken ?? defaultWaitForToken;
|
|
116
127
|
const tmpPath = deps.tmpStatusPath ?? defaultTmpStatusPath;
|
|
128
|
+
const readStatus = deps.readStatus ?? defaultReadStatus;
|
|
117
129
|
|
|
118
130
|
pi.registerTool({
|
|
119
131
|
name: "request_secret",
|
|
@@ -143,12 +155,20 @@ export function createCreel(pi: any, deps: Deps = {}): void {
|
|
|
143
155
|
);
|
|
144
156
|
}
|
|
145
157
|
|
|
158
|
+
if (!destInsideCwd(process.cwd(), dest)) {
|
|
159
|
+
return reply(outsideCwdText(name, process.cwd()));
|
|
160
|
+
}
|
|
161
|
+
|
|
146
162
|
const statusPath = tmpPath();
|
|
147
163
|
try {
|
|
148
164
|
spawn(tmux, creelCommand(name, dest, statusPath));
|
|
149
165
|
const token = await wait(statusPath, TOKEN_TIMEOUT_MS);
|
|
150
166
|
return reply(tokenToText(token, name, dest));
|
|
151
167
|
} catch (error) {
|
|
168
|
+
// creel exits non-zero when it refuses, and tmux then reports a bare
|
|
169
|
+
// "Command failed"; creel's reason is already in the status file.
|
|
170
|
+
const token = readStatus(statusPath);
|
|
171
|
+
if (token) return reply(tokenToText(token, name, dest));
|
|
152
172
|
return reply(`Failed to run the creel popup: ${String(error)}`);
|
|
153
173
|
} finally {
|
|
154
174
|
try {
|