pi-creel 0.2.2 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-creel",
3
- "version": "0.2.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",
@@ -28,7 +28,7 @@ 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-outside-cwd/);
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\)/);
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, relative to the working directory. Defaults to .env.",
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,10 +39,29 @@ 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}.`;
@@ -61,6 +81,7 @@ export function tokenToText(
61
81
  case "cancelled":
62
82
  return `Capture cancelled; nothing was written for ${name}.`;
63
83
  default:
84
+ if (token === "error:dest-outside-cwd") return outsideCwdText(name, cwd);
64
85
  if (token.startsWith("error:")) {
65
86
  return `Could not store ${name}: ${token.slice("error:".length)}.`;
66
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: "../x.env" });
93
- assert.match(text, /Could not store K: dest-outside-cwd/);
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 {