@coderook/cli 0.41.0 → 0.41.2

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.
@@ -2,7 +2,7 @@
2
2
  "name": "coderook",
3
3
  "displayName": "CodeRook",
4
4
  "description": "Save, browse and restore whole-snapshot versions of a project on CodeRook, with cbx or plain git, from Claude Code.",
5
- "version": "0.41.0",
5
+ "version": "0.41.2",
6
6
  "author": {
7
7
  "name": "ACCA Gaming Productions",
8
8
  "url": "https://coderook.com"
package/README.md CHANGED
@@ -526,9 +526,22 @@ cbx secret set DEPLOY_TOKEN asked for at a hidden prompt; never shown aga
526
526
  cbx secret list
527
527
  ```
528
528
 
529
- Every run gets `CODEROOK_TOKEN`, `CODEROOK_API` and `CODEROOK_PROJECT`, so
530
- `cbx issue comment`, `cbx pr label` or `cbx release` inside a workflow need no
531
- setup. Secrets reach only runs started by people who can change the project.
529
+ Runs get `CODEROOK_API` and `CODEROOK_PROJECT`. A job allowed to act on the
530
+ project also receives a temporary `CODEROOK_TOKEN`; credential-free workspace
531
+ checks and artifact-producing builds receive no project write token.
532
+
533
+ Project secrets require approval tied to the saved source and execution
534
+ settings. PR checks receive none. Workspace release recipes request only their
535
+ configured secret names. Credential-capable jobs cannot retain artifacts: build
536
+ packages in a separate credential-free job, then publish the approved artifact.
537
+
538
+ CLI 0.41.1 buffers complete output lines before masking, including tokens split
539
+ across process chunks. It also isolates the child process's HOME/npm settings
540
+ and stops inheriting unrelated host environment credentials. Supply required
541
+ service credentials through the approved Action, not the runner's own login.
542
+ Use a dedicated native runner: these protections do not sandbox project code
543
+ against the host filesystem or stop a malicious approved command sending a key
544
+ over the network. Review and approve the exact release code before adding keys.
532
545
 
533
546
  ## Issues
534
547
 
@@ -0,0 +1,85 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.RunOutputStream = void 0;
4
+ exports.secretVariants = secretVariants;
5
+ exports.redactRunOutput = redactRunOutput;
6
+ exports.credentialArtifactPath = credentialArtifactPath;
7
+ exports.artifactContainsSecret = artifactContainsSecret;
8
+ /** Output protection, shared by the API and both runners. Not an exfiltration sandbox. */
9
+ function secretVariants(values) {
10
+ const variants = new Set();
11
+ for (const value of values) {
12
+ if (!value)
13
+ continue;
14
+ for (const part of [value, ...value.split(/\r?\n/)]) {
15
+ if (!part)
16
+ continue;
17
+ variants.add(part);
18
+ variants.add(encodeURIComponent(part));
19
+ variants.add(JSON.stringify(part).slice(1, -1));
20
+ }
21
+ }
22
+ return [...variants].sort((a, b) => b.length - a.length);
23
+ }
24
+ function redactRunOutput(text, values = []) {
25
+ // Strip terminal escapes before matching, so ANSI colour cannot split a value.
26
+ let safe = text.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "");
27
+ for (const value of secretVariants(values))
28
+ safe = safe.split(value).join("***");
29
+ // Run tokens must be covered even after revocation, when only their hash remains.
30
+ safe = safe.replace(/\b(?:cr(?:r|w|k|p|pj|pt)_[A-Za-z0-9_-]{16,256}|npm_[A-Za-z0-9]{20,256})\b/g, "***");
31
+ safe = safe.replace(/(\b(?:authorization|proxy-authorization)\s*[:=]\s*)(?:Bearer|Basic)\s+[^\s"']+/gi, "$1***");
32
+ safe = safe.replace(/(\b[a-z][a-z0-9+.-]*:\/\/)[^\s/@]+:[^\s/@]+@/gi, "$1***@");
33
+ safe = safe.replace(/([?&](?:access_token|api_key|apikey|token|password|secret|signature|x-amz-signature)=)[^&#\s"']+/gi, "$1***");
34
+ return safe;
35
+ }
36
+ /** Buffer complete lines: chunk boundaries must never turn a token into two public fragments. */
37
+ class RunOutputStream {
38
+ values;
39
+ limit;
40
+ pending = "";
41
+ omitting = false;
42
+ constructor(values, limit = 64 * 1024) {
43
+ this.values = values;
44
+ this.limit = limit;
45
+ }
46
+ push(chunk) {
47
+ let safe = "";
48
+ for (const part of chunk.split(/([\r\n])/)) {
49
+ if (part === "\r" || part === "\n") {
50
+ if (!this.omitting)
51
+ safe += redactRunOutput(this.pending, this.values) + part;
52
+ this.pending = "";
53
+ this.omitting = false;
54
+ }
55
+ else if (!this.omitting) {
56
+ if (this.pending.length + part.length > this.limit) {
57
+ safe += "[Oversized output line omitted]\n";
58
+ this.pending = "";
59
+ this.omitting = true;
60
+ }
61
+ else
62
+ this.pending += part;
63
+ }
64
+ }
65
+ return safe;
66
+ }
67
+ end() {
68
+ const safe = this.omitting ? "" : redactRunOutput(this.pending, this.values);
69
+ this.pending = "";
70
+ this.omitting = false;
71
+ return safe;
72
+ }
73
+ }
74
+ exports.RunOutputStream = RunOutputStream;
75
+ /** Never export local authentication/configuration files, even under a broad glob. */
76
+ function credentialArtifactPath(path) {
77
+ return path.replace(/\\/g, "/").split("/").some(part => /^(?:\.env(?:\..*)?|\.dev\.vars(?:\..*)?|\.npmrc|\.pypirc|\.netrc|\.git-credentials|id_rsa|id_ed25519|credentials(?:\.json)?|token(?:s)?\.json)$/i.test(part) ||
78
+ /\.(?:pem|p12|pfx|key)$/i.test(part));
79
+ }
80
+ /** A best-effort raw-byte check; credential-bearing jobs must not export archives at all. */
81
+ function artifactContainsSecret(bytes, values) {
82
+ const text = new TextDecoder().decode(bytes);
83
+ return secretVariants(values).some(value => text.includes(value)) ||
84
+ /\b(?:cr(?:r|w|k|p|pj|pt)_[A-Za-z0-9_-]{16,256}|npm_[A-Za-z0-9]{20,256})\b/.test(text);
85
+ }
@@ -2755,9 +2755,9 @@ const SPECS = [
2755
2755
  name: "workspace",
2756
2756
  group: "Workspaces",
2757
2757
  summary: "edit, save and run a private project remotely without a checkout",
2758
- usage: "workspace <open|list|show|read|files|search|diff|patch|save|run|logs|cancel|deploy|preview|handoff|close|recipe|assign|assignments|unassign|checkout> <project> [workspace] [job] | workspace push [folder]",
2758
+ usage: "workspace <usage|open|list|show|read|files|search|diff|patch|save|run|logs|cancel|deploy|preview|handoff|close|recipe|assign|assignments|unassign|checkout> <project> [workspace] [job] | workspace push [folder]",
2759
2759
  brief: "workspace <action> <project>",
2760
- detail: "Works on CodeRook's durable remote draft, never the current local folder. Use --data-file for structured edits or recipe configuration. Mutations need the reviewed --revision and a unique --key; reuse the same key and request when retrying. Recipes reference existing project workflows. Saving and deploying are separate operations.",
2760
+ detail: "Works on CodeRook's durable remote draft, never the current local folder. Use --data-file for structured edits or recipe configuration. Mutations need the reviewed --revision and a unique --key; reuse the same key and request when retrying. Recipes reference existing project workflows. Usage reads the shared workspace credits and separate PR allowance without a draft ID. Saving and deploying are separate operations.",
2761
2761
  options: [
2762
2762
  { flags: "--data-file <path>", description: "read an operation's JSON from this local file" },
2763
2763
  { flags: "--name <name>", description: "name a new workspace" },
@@ -105,6 +105,20 @@ async function withVersion(projectId, wanted) {
105
105
  return { id: found.id, sequence: found.sequence };
106
106
  }
107
107
  const TOOLS = [
108
+ {
109
+ name: "coderook_usage",
110
+ description: "Read the project's shared billing-account workspace credits (included, spent, reserved and purchased), separate PR assistant allowance and UTC monthly reset. Supply a project or use the linked folder. Hosted jobs reserve included then purchased credits; insufficient funds leave jobs queued. Native runners use no hosted credits. Workspace top-ups do not refill PR allowance. This tool cannot buy credits or enable recurring payments; purchases stay in CodeRook Subscription or repository Settings.",
111
+ inputSchema: {
112
+ type: "object",
113
+ properties: { project: { type: "string" } },
114
+ additionalProperties: false,
115
+ },
116
+ run: async (input) => {
117
+ const project = await resolve(input.project);
118
+ await assertMayRead(project.id, project.slug);
119
+ return JSON.stringify(await (0, api_js_1.callApi)(`/v1/repositories/${encodeURIComponent(project.id)}/workspace-usage`), null, 2);
120
+ },
121
+ },
108
122
  {
109
123
  name: "coderook_projects",
110
124
  description: "Every project on the signed-in CodeRook account, with how much each " +
@@ -30,6 +30,8 @@ exports.report = report;
30
30
  * decision somebody made rather than one they discovered.
31
31
  */
32
32
  const node_child_process_1 = require("node:child_process");
33
+ const node_string_decoder_1 = require("node:string_decoder");
34
+ const run_output_js_1 = require("../../cbx/src/shared/run_output.js");
33
35
  const node_crypto_1 = require("node:crypto");
34
36
  const promises_1 = require("node:fs/promises");
35
37
  const node_fs_1 = require("node:fs");
@@ -44,12 +46,7 @@ const config_js_1 = require("./config.js");
44
46
  const progress_js_1 = require("./progress.js");
45
47
  /** Replace each secret's value with ***, longest first. */
46
48
  function maskSecrets(text, secrets) {
47
- let out = text;
48
- for (const value of secrets) {
49
- if (value.length >= 4 && out.includes(value))
50
- out = out.split(value).join("***");
51
- }
52
- return out;
49
+ return (0, run_output_js_1.redactRunOutput)(text, secrets);
53
50
  }
54
51
  /** How often to tell the service the machine is still working. */
55
52
  const HEARTBEAT_MS = 30_000;
@@ -154,14 +151,15 @@ class LogShipper {
154
151
  * the run log, which read access is enough to see — so a collaborator could
155
152
  * walk off with the account of whoever was hosting the runner.
156
153
  *
157
- * The rest of the environment is left alone. PATH, HOME and the compiler
158
- * settings are what makes a build possible, and stripping them would break
159
- * every real workflow to guard against a risk the runner already announces.
154
+ * Only platform and toolchain variables are inherited. The command gets a
155
+ * temporary HOME, npm configuration and cache inside the job folder. This
156
+ * prevents accidental inheritance; a native runner is still trusted code on
157
+ * its host, not a filesystem or network sandbox.
160
158
  */
161
159
  function withoutCredentials(source) {
162
160
  const safe = { ...source };
163
161
  for (const name of Object.keys(safe)) {
164
- if (/^CODEROOK_(TOKEN|SECRET|PASSWORD|API_KEY)$/i.test(name)) {
162
+ if (!/^(?:PATH|PATHEXT|SYSTEMROOT|WINDIR|COMSPEC|TEMP|TMP|HOME|USERPROFILE|LANG|LC_[A-Z_]+|TERM|COLORTERM|NUMBER_OF_PROCESSORS|PROCESSOR_ARCHITECTURE|JAVA_HOME|DOTNET_ROOT|CARGO_HOME|RUSTUP_HOME|GOROOT|GOPATH)$/i.test(name)) {
165
163
  delete safe[name];
166
164
  }
167
165
  }
@@ -231,12 +229,20 @@ function defaultLabels() {
231
229
  * waiting for, and losing a build that passed because the network went odd
232
230
  * afterwards would be a worse answer than a pass with a missing download.
233
231
  */
234
- async function uploadArtifacts(repositoryId, runId, workspace, patterns, say) {
232
+ async function uploadArtifacts(repositoryId, runId, workspace, patterns, say, secrets = []) {
235
233
  const wanted = await collectArtifacts(workspace, patterns);
236
234
  let kept = 0;
237
235
  for (const file of wanted) {
238
236
  try {
237
+ if ((0, run_output_js_1.credentialArtifactPath)(file.relative)) {
238
+ say("Skipped an authentication/configuration artifact");
239
+ continue;
240
+ }
239
241
  const bytes = await (0, promises_1.readFile)(file.absolute);
242
+ if ((0, run_output_js_1.artifactContainsSecret)(bytes, secrets)) {
243
+ say("Skipped an artifact containing credential material");
244
+ continue;
245
+ }
240
246
  const digest = (0, node_crypto_1.createHash)("sha256").update(bytes).digest("hex");
241
247
  const token = await (0, config_js_1.loadToken)();
242
248
  const stored = await fetch(`${(0, config_js_1.apiOrigin)()}/v1/repositories/${repositoryId}` +
@@ -374,7 +380,7 @@ async function performRun(repositoryId, run, runner) {
374
380
  }
375
381
  }
376
382
  };
377
- const logs = new LogShipper(repositoryId, run.runId, (line) => progress.say(line));
383
+ const logs = new LogShipper(repositoryId, run.runId, (line) => progress.say(maskSecrets(line, secrets)));
378
384
  const started = Date.now();
379
385
  const workspace = await (0, promises_1.mkdtemp)(node_path_1.default.join((0, node_os_1.tmpdir)(), "coderook-run-"));
380
386
  let beating;
@@ -452,7 +458,14 @@ async function performRun(repositoryId, run, runner) {
452
458
  const directory = run.workingDirectory
453
459
  ? insideWorkspace(workspace, run.workingDirectory)
454
460
  : workspace;
455
- progress.step("Running", command);
461
+ progress.step("Running", maskSecrets(command, secrets));
462
+ // npm and other tools must not automatically reuse the runner owner's login files.
463
+ const home = node_path_1.default.join(workspace, ".run-home");
464
+ await (0, promises_1.mkdir)(home, { recursive: true });
465
+ const temporary = node_path_1.default.join(home, "tmp");
466
+ await (0, promises_1.mkdir)(temporary, { recursive: true });
467
+ const emptyConfig = node_path_1.default.join(home, "empty.npmrc");
468
+ await (0, promises_1.writeFile)(emptyConfig, "", { mode: 0o600 });
456
469
  flushing = setInterval(() => void logs.flush(), FLUSH_MS);
457
470
  const code = await new Promise((settle) => {
458
471
  /*
@@ -473,6 +486,16 @@ async function performRun(repositoryId, run, runner) {
473
486
  env: {
474
487
  ...withoutCredentials(process.env),
475
488
  ...(run.environment ?? {}),
489
+ HOME: home,
490
+ USERPROFILE: home,
491
+ TEMP: temporary,
492
+ TMP: temporary,
493
+ TMPDIR: temporary,
494
+ XDG_CONFIG_HOME: node_path_1.default.join(home, ".config"),
495
+ XDG_CACHE_HOME: node_path_1.default.join(home, ".cache"),
496
+ NPM_CONFIG_USERCONFIG: emptyConfig,
497
+ NPM_CONFIG_GLOBALCONFIG: emptyConfig,
498
+ NPM_CONFIG_CACHE: node_path_1.default.join(home, ".npm-cache"),
476
499
  CI: "true",
477
500
  CODEROOK_RUN: String(run.number),
478
501
  CODEROOK_VERSION: run.versionSequence
@@ -485,18 +508,25 @@ async function performRun(repositoryId, run, runner) {
485
508
  a failed run can be read later; the person watching needs it so they
486
509
  can tell a slow build from a stuck one.
487
510
  */
488
- const tee = (chunk, stream) => {
489
- const text = maskSecrets(chunk.toString("utf8"), secrets);
511
+ const decoders = { stdout: new node_string_decoder_1.StringDecoder("utf8"), stderr: new node_string_decoder_1.StringDecoder("utf8") };
512
+ const protectedStreams = { stdout: new run_output_js_1.RunOutputStream(secrets), stderr: new run_output_js_1.RunOutputStream(secrets) };
513
+ const show = (text, stream) => { if (text) {
490
514
  logs.add(text, stream);
491
515
  progress.say(text);
516
+ } };
517
+ const tee = (chunk, stream) => show(protectedStreams[stream].push(decoders[stream].write(chunk)), stream);
518
+ const finishOutput = () => {
519
+ for (const stream of ["stdout", "stderr"]) {
520
+ show(protectedStreams[stream].push(decoders[stream].end()) + protectedStreams[stream].end(), stream);
521
+ }
492
522
  };
493
523
  child.stdout?.on("data", (chunk) => tee(chunk, "stdout"));
494
524
  child.stderr?.on("data", (chunk) => tee(chunk, "stderr"));
495
525
  child.on("error", (error) => {
496
- logs.add(errorText(error), "stderr");
526
+ logs.add(maskSecrets(errorText(error), secrets), "stderr");
497
527
  settle(1);
498
528
  });
499
- child.on("close", (status) => settle(status ?? 1));
529
+ child.on("close", (status) => { finishOutput(); settle(status ?? 1); });
500
530
  if (run.timeoutMinutes) {
501
531
  limitTimer = setTimeout(() => {
502
532
  timedOut = true;
@@ -514,16 +544,18 @@ async function performRun(repositoryId, run, runner) {
514
544
  very log or partial output somebody needs to work out why, and throwing
515
545
  that away is exactly when they would have wanted it.
516
546
  */
517
- if (run.artifactPaths?.length) {
547
+ if (run.artifactPaths?.length && (run.artifactsAllowed ?? !run.secretNames?.length)) {
518
548
  progress.step("Keeping what it built");
519
549
  const kept = await uploadArtifacts(repositoryId, run.runId, workspace, run.artifactPaths, (line) => {
520
- logs.add(line, "stdout");
521
- progress.say(line);
522
- });
550
+ logs.add(maskSecrets(line, secrets), "stdout");
551
+ progress.say(maskSecrets(line, secrets));
552
+ }, secrets);
523
553
  if (!kept) {
524
554
  logs.add("Nothing matched the paths this workflow keeps.", "stdout");
525
555
  }
526
556
  }
557
+ if (run.artifactPaths?.length && !(run.artifactsAllowed ?? !run.secretNames?.length))
558
+ logs.add("Artifacts disabled for this credential-capable run; use a separate credential-free build job.", "stdout");
527
559
  await logs.flush();
528
560
  const seconds = Math.round((Date.now() - started) / 1000);
529
561
  if (timedOut)
@@ -535,9 +567,10 @@ async function performRun(repositoryId, run, runner) {
535
567
  : { status: "failed", summary: `Exited ${code} after ${seconds}s` };
536
568
  }
537
569
  catch (error) {
538
- logs.add(errorText(error), "stderr");
570
+ const safeError = maskSecrets(errorText(error), secrets);
571
+ logs.add(safeError, "stderr");
539
572
  await logs.flush();
540
- return { status: "failed", summary: errorText(error).slice(0, 200) };
573
+ return { status: "failed", summary: safeError.slice(0, 200) };
541
574
  }
542
575
  finally {
543
576
  /*
@@ -552,7 +585,13 @@ async function performRun(repositoryId, run, runner) {
552
585
  if (flushing)
553
586
  clearInterval(flushing);
554
587
  await logs.flush();
555
- await (0, promises_1.rm)(workspace, { recursive: true, force: true }).catch(() => { });
588
+ await (0, promises_1.rm)(workspace, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }).catch(async () => {
589
+ const summary = "Runner cleanup failed. Stop this runner and remove its temporary job folder before processing more credential-bearing work.";
590
+ // Report a final failure before stopping the runner, rather than leaving
591
+ // a completed publish queued for a lease-expiry retry.
592
+ await report(repositoryId, run.runId, { status: "failed", summary }, Date.now() - started).catch(() => undefined);
593
+ throw new Error(summary);
594
+ });
556
595
  }
557
596
  }
558
597
  /** Say how it went. */
@@ -41,7 +41,10 @@ async function commandWorkspace(parsed) {
41
41
  const key = flag("key") ?? data.operationKey;
42
42
  const revision = integer("revision") ?? data.expectedRevision;
43
43
  let path = `${root}/workspaces`, method = "GET", body;
44
- if (operation === "open") {
44
+ if (operation === "usage") {
45
+ path = `${root}/workspace-usage`;
46
+ }
47
+ else if (operation === "open") {
45
48
  method = "POST";
46
49
  body = {
47
50
  ...data,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coderook/cli",
3
- "version": "0.41.0",
3
+ "version": "0.41.2",
4
4
  "description": "The command line for CodeRook (coderook.com): version control and project hosting for projects of any size. Every save is a compressed snapshot, private or public, never used to train AI.",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://coderook.com",
@@ -53,7 +53,8 @@
53
53
  "test:runner": "node test/runner-live.mjs",
54
54
  "sync:plugin": "node scripts/sync-plugin-version.mjs",
55
55
  "test:track": "node test/get-track.mjs",
56
- "package:remote": "node ../scripts/package-cli-release.mjs"
56
+ "package:remote": "node ../scripts/package-cli-release.mjs",
57
+ "publish:remote": "node ../scripts/publish-cli-release.mjs"
57
58
  },
58
59
  "devDependencies": {
59
60
  "@types/node": "24.10.1",