goosetools-worker 0.2.6 → 0.2.7

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": "goosetools-worker",
3
- "version": "0.2.6",
3
+ "version": "0.2.7",
4
4
  "description": "The Goose Tools carousel worker — your computer drafts and renders carousel slides for goosetools.com using your own Claude account.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -146,7 +146,11 @@ export function detect(agent) {
146
146
  }
147
147
 
148
148
  // Tools Claude must NOT touch in oneshot mode — everything is inlined.
149
- const CLAUDE_TOOLS_OFF = "Read,Edit,Write,Bash,Glob,Grep,WebFetch,WebSearch";
149
+ const CLAUDE_TOOLS_OFF = "Read,Edit,Write,Bash,Glob,Grep,WebFetch,WebSearch,NotebookEdit,Task";
150
+ // "read" may open the files a prompt names (photos, references) and nothing
151
+ // else: no writing, no shell, no web. Denied explicitly, because an allow-list
152
+ // alone doesn't override a user whose own Claude settings pre-approve more.
153
+ const CLAUDE_READ_ONLY_OFF = "Edit,Write,Bash,WebFetch,WebSearch,NotebookEdit,Task";
150
154
 
151
155
  /**
152
156
  * Run a prompt through the chosen agent CLI.
@@ -181,6 +185,9 @@ export async function runAgent({
181
185
  "acceptEdits",
182
186
  "--allowedTools",
183
187
  tools,
188
+ // The user's own MCP servers (a Gmail "send", say) must never be in
189
+ // reach of a prompt built from untrusted email.
190
+ "--strict-mcp-config",
184
191
  ];
185
192
  if (resume) args.push("--resume", resume);
186
193
  const raw = await run(normalized, args, { timeoutMs, cwd });
@@ -199,9 +206,9 @@ export async function runAgent({
199
206
  return { text: raw, sessionId: namespaced(normalized, resume) };
200
207
  }
201
208
  }
202
- const args = ["-p", prompt, "--output-format", "text"];
203
- if (mode === "oneshot") args.push("--disallowedTools", CLAUDE_TOOLS_OFF);
204
- if (mode === "read") args.push("--allowedTools", "Read");
209
+ const args = ["-p", prompt, "--output-format", "text", "--strict-mcp-config"];
210
+ if (mode === "oneshot" || mode === "text") args.push("--disallowedTools", CLAUDE_TOOLS_OFF);
211
+ if (mode === "read") args.push("--allowedTools", "Read", "--disallowedTools", CLAUDE_READ_ONLY_OFF);
205
212
  const text = await run(normalized, args, { timeoutMs, cwd });
206
213
  return { text, sessionId: null };
207
214
  }
package/worker/index.js CHANGED
@@ -566,6 +566,10 @@ async function runJob(job) {
566
566
 
567
567
  // Render.
568
568
  const outDir = join(dir, "output");
569
+ // A requeued job reuses this folder. Slides left from the earlier attempt
570
+ // (an 8-slide draft re-drafted as 6, or an old slide-03.mp4 that outranks the
571
+ // new slide-03.png) would be uploaded with the new deck.
572
+ rmSync(outDir, { recursive: true, force: true });
569
573
  await execFileAsync(
570
574
  "node",
571
575
  [join(ROOT, "src", "render.js"), dir, "--aspect", job.aspect, "--out", outDir],
@@ -0,0 +1,113 @@
1
+ // SHARED FILE: the source of truth is workers/shared/install-helpers.js in the
2
+ // goosetools repo; copied into each worker (as install-helpers.mjs for the
3
+ // overlay worker, a CommonJS package). Edit the shared one, then
4
+ // `npm run workers:sync`.
5
+ //
6
+ // The parts of installing a background worker that every worker gets wrong in
7
+ // the same way if each writes its own.
8
+
9
+ import { execSync } from "node:child_process";
10
+ import { chmodSync, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from "node:fs";
11
+ import { dirname } from "node:path";
12
+
13
+ const sleepSync = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
14
+
15
+ /**
16
+ * The node binary the background service should run.
17
+ *
18
+ * `process.execPath` under Homebrew is a versioned folder
19
+ * (/opt/homebrew/Cellar/node/25.8.2/bin/node) that `brew upgrade` + cleanup
20
+ * deletes, after which every worker fails to start, silently, forever. The
21
+ * stable link (/opt/homebrew/bin/node) follows upgrades, so prefer it whenever
22
+ * it points at the same install.
23
+ */
24
+ export function stableNodePath(execPath = process.execPath) {
25
+ if (!/\/Cellar\/node(@\d+)?\//.test(execPath)) return execPath;
26
+ for (const candidate of ["/opt/homebrew/bin/node", "/usr/local/bin/node"]) {
27
+ try {
28
+ if (existsSync(candidate) && realpathSync(candidate).includes("/Cellar/node")) return candidate;
29
+ } catch {}
30
+ }
31
+ return execPath;
32
+ }
33
+
34
+ /** The newest published version, asked of the registry itself, not a cache. */
35
+ export function latestPublished(pkg) {
36
+ try {
37
+ return execSync(`npm view ${pkg} version --prefer-online`, {
38
+ stdio: ["ignore", "pipe", "pipe"],
39
+ timeout: 20000,
40
+ shell: true,
41
+ })
42
+ .toString()
43
+ .trim();
44
+ } catch {
45
+ return null; // offline, or npm unreachable
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Install exactly the newest version of `pkg` into `prefix`, and prove it.
51
+ *
52
+ * `npm install pkg@latest` reads a cached, abbreviated copy of the package
53
+ * listing that can lag a publish by minutes, so right after a release it
54
+ * quietly reinstalled the previous version and reported success. This asks
55
+ * the registry for the version first, installs that exact version with
56
+ * --prefer-online, and retries (for a few minutes) until what's on disk is
57
+ * what was asked for.
58
+ */
59
+ export function installExact({ pkg, prefix, installedVersion, log = console.log }) {
60
+ const target = latestPublished(pkg);
61
+ if (!target) {
62
+ // Can't reach the registry to ask; fall back to whatever it will give.
63
+ execSync(`npm install --no-fund --no-audit --loglevel=error --prefix "${prefix}" ${pkg}@latest`, {
64
+ stdio: ["ignore", "inherit", "inherit"],
65
+ shell: true,
66
+ });
67
+ return installedVersion();
68
+ }
69
+ const attempts = 8;
70
+ for (let i = 1; i <= attempts; i++) {
71
+ try {
72
+ execSync(
73
+ `npm install --no-fund --no-audit --loglevel=error --prefer-online --prefix "${prefix}" ${pkg}@${target}`,
74
+ { stdio: ["ignore", "inherit", "pipe"], shell: true },
75
+ );
76
+ if (installedVersion() === target) return target;
77
+ } catch (e) {
78
+ if (i === attempts) throw e;
79
+ }
80
+ if (i < attempts) {
81
+ log(` npm hasn't caught up with ${pkg}@${target} yet — trying again in 20s (${i}/${attempts})`);
82
+ sleepSync(20000);
83
+ }
84
+ }
85
+ throw new Error(`installed ${installedVersion() ?? "nothing"} but ${target} is the latest — try again in a few minutes`);
86
+ }
87
+
88
+ /**
89
+ * Write the shared ~/.goosetools/env, keeping any keys this installer doesn't
90
+ * own (VOICE_DIR and friends) and never leaving it world-readable, even for a
91
+ * moment: written 0600 to a temp file and renamed into place.
92
+ */
93
+ export function writeEnv(file, updates) {
94
+ const existing = {};
95
+ if (existsSync(file)) {
96
+ for (const line of readFileSync(file, "utf8").split("\n")) {
97
+ const i = line.indexOf("=");
98
+ if (i > 0 && !line.trim().startsWith("#")) existing[line.slice(0, i).trim()] = line.slice(i + 1).trim();
99
+ }
100
+ }
101
+ const merged = { ...existing, ...updates };
102
+ mkdirSync(dirname(file), { recursive: true });
103
+ const tmp = `${file}.tmp-${process.pid}`;
104
+ writeFileSync(
105
+ tmp,
106
+ Object.entries(merged)
107
+ .map(([k, v]) => `${k}=${v}`)
108
+ .join("\n") + "\n",
109
+ { mode: 0o600 },
110
+ );
111
+ chmodSync(tmp, 0o600);
112
+ renameSync(tmp, file);
113
+ }
package/worker/lock.js CHANGED
@@ -35,7 +35,7 @@ export function acquireWorkerLock(name, { label, stopHint }) {
35
35
  const file = join(LOCK_DIR, `${name}.pid`);
36
36
 
37
37
  const holder = readHolder(file);
38
- if (holder && isAlive(holder.pid)) {
38
+ if (holder && isAlive(holder.pid, holder.script)) {
39
39
  console.log(
40
40
  `\n${label} is already running on this computer (pid ${holder.pid}${
41
41
  holder.since ? `, since ${holder.since}` : ""
@@ -58,7 +58,9 @@ export function acquireWorkerLock(name, { label, stopHint }) {
58
58
  console.log(`\n${label} started somewhere else a moment ago. Stopping here.\n`);
59
59
  process.exit(0);
60
60
  }
61
- writeSync(fd, `${process.pid}\n${new Date().toISOString()}\n`);
61
+ // The script path lets a later start tell THIS worker apart from another
62
+ // process that has since been given the same pid.
63
+ writeSync(fd, `${process.pid}\n${new Date().toISOString()}\n${process.argv[1] ?? ""}\n`);
62
64
  closeSync(fd);
63
65
 
64
66
  const release = () => rmSync(file, { force: true });
@@ -74,29 +76,35 @@ export function acquireWorkerLock(name, { label, stopHint }) {
74
76
  function readHolder(file) {
75
77
  if (!existsSync(file)) return null;
76
78
  try {
77
- const [pid, since] = readFileSync(file, "utf8").split("\n");
79
+ const [pid, since, script] = readFileSync(file, "utf8").split("\n");
78
80
  const n = Number.parseInt(pid, 10);
79
- return Number.isFinite(n) ? { pid: n, since: since?.trim() || null } : null;
81
+ return Number.isFinite(n)
82
+ ? { pid: n, since: since?.trim() || null, script: script?.trim() || null }
83
+ : null;
80
84
  } catch {
81
85
  return null;
82
86
  }
83
87
  }
84
88
 
85
89
  // A pid file outlives a SIGKILLed worker, and pids get reused — so "is that
86
- // pid alive" isn't enough on its own. Checking that it's a node process is
87
- // cheap and rules out the reuse case that would otherwise lock out the daemon
88
- // until someone deleted the file by hand.
89
- function isAlive(pid) {
90
+ // pid alive" isn't enough on its own. And "is it a node process" isn't either:
91
+ // all four workers are node processes started together at login, so after a
92
+ // crash and reboot a sibling worker can inherit the dead one's pid and lock it
93
+ // out indefinitely. So the lock records the worker's script, and only a
94
+ // process running that script counts. (Older lock files without it fall back
95
+ // to the node check.)
96
+ function isAlive(pid, script) {
90
97
  try {
91
98
  process.kill(pid, 0);
92
99
  } catch {
93
100
  return false;
94
101
  }
95
102
  try {
96
- return /node/.test(execFileSync("ps", ["-p", String(pid), "-o", "command="], {
103
+ const command = execFileSync("ps", ["-p", String(pid), "-o", "command="], {
97
104
  encoding: "utf8",
98
105
  stdio: ["ignore", "pipe", "ignore"],
99
- }));
106
+ });
107
+ return script ? command.includes(script) : /node/.test(command);
100
108
  } catch {
101
109
  return false;
102
110
  }
@@ -22,7 +22,6 @@
22
22
 
23
23
  import { execSync } from "node:child_process";
24
24
  import {
25
- chmodSync,
26
25
  existsSync,
27
26
  mkdirSync,
28
27
  readFileSync,
@@ -32,6 +31,7 @@ import {
32
31
  } from "node:fs";
33
32
  import { homedir } from "node:os";
34
33
  import { dirname, join } from "node:path";
34
+ import { installExact, latestPublished, stableNodePath, writeEnv } from "./install-helpers.js";
35
35
 
36
36
  export const GOOSE_DIR = join(homedir(), ".goosetools");
37
37
  const ENV_FILE = join(GOOSE_DIR, "env");
@@ -65,7 +65,7 @@ function npmGlobalBin() {
65
65
  // `claude` lives), plus the usual suspects.
66
66
  function servicePath() {
67
67
  const parts = [
68
- dirname(process.execPath),
68
+ dirname(stableNodePath()),
69
69
  npmGlobalBin(),
70
70
  ...(process.platform === "win32"
71
71
  ? [process.env.PATH ?? ""]
@@ -93,10 +93,9 @@ function readEnvFile() {
93
93
  );
94
94
  }
95
95
 
96
+ // Keeps keys other tools put there, and is never world-readable (install-helpers).
96
97
  function writeEnvFile({ url, token }) {
97
- mkdirSync(GOOSE_DIR, { recursive: true });
98
- writeFileSync(ENV_FILE, `GOOSETOOLS_URL=${url}\nWORKER_TOKEN=${token}\n`);
99
- chmodSync(ENV_FILE, 0o600);
98
+ writeEnv(ENV_FILE, { GOOSETOOLS_URL: url, WORKER_TOKEN: token });
100
99
  }
101
100
 
102
101
  function compareVersions(a, b) {
@@ -163,12 +162,15 @@ export function createService(cfg) {
163
162
  console.log("Setting up the background copy (this can take a minute)…");
164
163
  mkdirSync(APP_PREFIX, { recursive: true });
165
164
  // From a git checkout (development), install the checkout itself; normal
166
- // npx users get the published package.
167
- const source = isCheckout(root) ? `"${root}"` : `${cfg.pkg}@latest`;
168
- execSync(`npm install --no-fund --no-audit --loglevel=error --prefix "${APP_PREFIX}" ${source}`, {
169
- stdio: ["ignore", "inherit", "inherit"],
170
- shell: true,
171
- });
165
+ // npx users get exactly the newest published version (install-helpers).
166
+ if (isCheckout(root)) {
167
+ execSync(`npm install --no-fund --no-audit --loglevel=error --prefix "${APP_PREFIX}" "${root}"`, {
168
+ stdio: ["ignore", "inherit", "inherit"],
169
+ shell: true,
170
+ });
171
+ } else {
172
+ installExact({ pkg: cfg.pkg, prefix: APP_PREFIX, installedVersion });
173
+ }
172
174
  if (!existsSync(INSTALLED_CLI)) {
173
175
  throw new Error("background copy did not install where expected");
174
176
  }
@@ -186,7 +188,7 @@ export function createService(cfg) {
186
188
  <string>${cfg.macLabel}</string>
187
189
  <key>ProgramArguments</key>
188
190
  <array>
189
- <string>${esc(process.execPath)}</string>
191
+ <string>${esc(stableNodePath())}</string>
190
192
  <string>${esc(INSTALLED_CLI)}</string>
191
193
  <string>run</string>
192
194
  </array>
@@ -272,11 +274,7 @@ export function createService(cfg) {
272
274
  }
273
275
 
274
276
  function latestVersion() {
275
- try {
276
- return sh(`npm view ${cfg.pkg} version`, { shell: true, timeout: 15000 });
277
- } catch {
278
- return null; // offline, or npm unreachable — not an error worth failing on
279
- }
277
+ return latestPublished(cfg.pkg); // null when offline — not worth failing on
280
278
  }
281
279
 
282
280
  // launchctl prints "<pid>\t<last exit>\t<label>"; a "-" pid means registered