impel-cli 0.16.0 → 0.16.1

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": "impel-cli",
3
- "version": "0.16.0",
3
+ "version": "0.16.1",
4
4
  "description": "Configure Claude Code and Codex CLI to talk to Impel's gateway, authenticated by an Impel Personal Access Token",
5
5
  "type": "module",
6
6
  "bin": {
package/src/apps.js CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  secureManagedCodexHome,
11
11
  } from "./codexSecurity.js";
12
12
  import { normalizeTenantId } from "./tenants.js";
13
+ import { impelCliInvocation } from "./selfInvocation.js";
13
14
  import { crossAppModelsEnabled, redactSecretText } from "./config.js";
14
15
  import { ADHOC_IDENTITY, codesignIdentityArgs, desiredSigningMode, resolveSigningIdentity } from "./codesign.js";
15
16
 
@@ -925,9 +926,16 @@ function writeChatGPTConfig(paths, config, models, vendorCodexModels, invocation
925
926
  };
926
927
  writeAtomic(paths.chatgpt.catalog, JSON.stringify(catalog, null, 2) + "\n", 0o600);
927
928
  const auth = invocations?.auth || { command: paths.tokenHelper, args: null };
929
+ // Finder/Dock-launched apps inherit a minimal PATH without npm/nvm bin
930
+ // directories, so a PATH-relative "impel" never spawns there. Bake the
931
+ // absolute node + CLI invocation; the launch-time `app refresh` rewrites
932
+ // this config whenever those paths change.
933
+ const mcpInvocation = impelCliInvocation(
934
+ config.tenantId ? ["mcp", "--tenant", config.tenantId] : ["mcp"]
935
+ );
928
936
  const mcp = invocations?.mcp || {
929
- command: "impel",
930
- args: config.tenantId ? ["mcp", "--tenant", config.tenantId] : ["mcp"],
937
+ command: mcpInvocation.command,
938
+ args: [...mcpInvocation.args],
931
939
  };
932
940
 
933
941
  const managedToml = [
@@ -1153,20 +1161,63 @@ function readTopLevelTomlString(toml, key) {
1153
1161
  }
1154
1162
  }
1155
1163
 
1164
+ /**
1165
+ * Rewrite one tenant's app token helper with the current node/CLI paths.
1166
+ * Cheap and Impel-owned, so install recovery can use it to heal helpers whose
1167
+ * baked interpreter or checkout paths went stale.
1168
+ */
1169
+ export function rewriteTenantTokenHelper(tenantId, homeDir = os.homedir()) {
1170
+ const paths = appPaths(homeDir, tenantId);
1171
+ if (!fs.existsSync(path.dirname(paths.tokenHelper))) return false;
1172
+ writeTokenHelper(paths.tokenHelper, tenantId);
1173
+ return true;
1174
+ }
1175
+
1156
1176
  function writeTokenHelper(target, tenantId) {
1157
1177
  const cliPath = fileURLToPath(new URL("../bin/impel.js", import.meta.url));
1158
1178
  const node = shellQuote(process.execPath);
1159
1179
  const cli = shellQuote(cliPath);
1160
1180
  const tenantArgs = tenantId ? ` --tenant ${shellQuote(tenantId)}` : "";
1161
- const refreshTenantArgs = tenantId ? ` --tenant ${shellQuote(tenantId)}` : "";
1162
1181
  // The apps invoke this helper whenever they need auth, which makes it the
1163
1182
  // one hook that fires on Finder/Dock launches too. Piggyback a detached,
1164
1183
  // TTL-gated config/catalog/skills refresh (`app refresh --stale-only`) so
1165
1184
  // the managed apps keep themselves current without touching stdout — the
1166
1185
  // caller consumes stdout as the token.
1186
+ //
1187
+ // The baked interpreter/CLI paths are only first choices: an nvm upgrade or
1188
+ // a moved checkout deletes them, and a helper that hardcodes them strands
1189
+ // every managed app with no auth (observed as "provider auth command exited
1190
+ // with status 1" outages). Resolve again at call time before giving up, and
1191
+ // prefer the global npm install next to whichever node is found so the
1192
+ // refresh keeps rewriting this helper with current paths.
1167
1193
  const script = `#!/bin/sh
1168
- (${node} ${cli} app refresh --stale-only${refreshTenantArgs} </dev/null >/dev/null 2>&1 &)
1169
- exec ${node} ${cli} token${tenantArgs}
1194
+ # Managed by impel-cli; regenerated by \`impel app refresh\`.
1195
+ NODE=${node}
1196
+ CLI=${cli}
1197
+ if ! [ -x "$NODE" ]; then
1198
+ NODE="$(command -v node 2>/dev/null || true)"
1199
+ fi
1200
+ if ! [ -x "$NODE" ]; then
1201
+ for candidate in /opt/homebrew/bin/node /usr/local/bin/node "$HOME"/.nvm/versions/node/*/bin/node; do
1202
+ if [ -x "$candidate" ]; then NODE="$candidate"; fi
1203
+ done
1204
+ fi
1205
+ if ! [ -x "$NODE" ]; then
1206
+ echo 'impel token helper: no usable node runtime found; re-run \`impel setup\`' >&2
1207
+ exit 1
1208
+ fi
1209
+ if ! [ -f "$CLI" ]; then
1210
+ CLI="$(dirname "$NODE")/../lib/node_modules/impel-cli/bin/impel.js"
1211
+ fi
1212
+ if ! [ -f "$CLI" ]; then
1213
+ CLI="$(command -v impel 2>/dev/null || true)"
1214
+ fi
1215
+ if [ -z "$CLI" ] || ! [ -f "$CLI" ]; then
1216
+ echo 'impel token helper: impel-cli not found; re-run \`impel setup\`' >&2
1217
+ exit 1
1218
+ fi
1219
+ ("$NODE" "$CLI" app refresh --stale-only${tenantArgs} </dev/null >/dev/null 2>&1 &)
1220
+ exec "$NODE" "$CLI" token${tenantArgs}
1170
1221
  `;
1171
1222
  writeAtomic(target, script, 0o700);
1172
1223
  }
@@ -17,6 +17,7 @@ import {
17
17
  import { promptSecret, promptText } from "../prompt.js";
18
18
  import { ensureImpelClaudeProfile, ensureImpelCodexProfile } from "../cliProfiles.js";
19
19
  import { fetchTenants, normalizeTenantId, tenantCredential } from "../tenants.js";
20
+ import { rewriteTenantTokenHelper } from "../apps.js";
20
21
  import { cmdApps } from "./apps.js";
21
22
  import { prepareWindowsClis, windowsCliInstallCommands } from "../windowsSetup.js";
22
23
  import { runInstallRecovery } from "../installRecovery/engine.js";
@@ -490,6 +491,9 @@ export async function cmdSetup(argv, overrides = {}) {
490
491
  crossAppModels: crossAppModelsEnabled(config),
491
492
  });
492
493
  ensureImpelCodexProfile(gatewayUrl, tenant.id);
494
+ // Also refresh the app token helper: a stale baked node/CLI
495
+ // path there breaks every managed app's auth.
496
+ rewriteTenantTokenHelper(tenant.id);
493
497
  return true;
494
498
  },
495
499
  installVendorApp,
@@ -36,8 +36,11 @@ export function saveInstallRecoveryCheckpoint(
36
36
  ) {
37
37
  fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
38
38
  const temporary = `${filePath}.tmp-${process.pid}`;
39
+ fs.rmSync(temporary, { force: true });
40
+ // "wx" refuses to follow a pre-planted symlink at the predictable temp path.
39
41
  fs.writeFileSync(temporary, `${JSON.stringify(checkpoint, null, 2)}\n`, {
40
42
  mode: 0o600,
43
+ flag: "wx",
41
44
  });
42
45
  fs.renameSync(temporary, filePath);
43
46
  try {
@@ -28,6 +28,7 @@ import {
28
28
  import { createRecoveryInference, resolveRecoverySeat } from "./inference.js";
29
29
  import {
30
30
  installRecoveryFingerprint,
31
+ redactInstallRecoveryText,
31
32
  sanitizeInstallFailureEnvelope,
32
33
  } from "./redact.js";
33
34
  import {
@@ -93,8 +94,10 @@ async function runGoals(goals) {
93
94
  }
94
95
 
95
96
  function describeToolCall(name, input) {
97
+ // Model-supplied values render in approval prompts and logs; strip control
98
+ // sequences so a confused model cannot rewrite what the user is approving.
96
99
  const parameters = Object.entries(input || {})
97
- .map(([key, value]) => `${key}=${value}`)
100
+ .map(([key, value]) => `${key}=${redactSecretText(String(value)).slice(0, 160)}`)
98
101
  .join(", ");
99
102
  return parameters ? `${name} (${parameters})` : name;
100
103
  }
@@ -141,7 +144,9 @@ async function executeCall(session, name, input, reason = null) {
141
144
 
142
145
  function buildFirstUserMessage(session, failure, deterministicSummary, goalReport) {
143
146
  const goalLines = goalReport.results.map(
144
- (goal) => `- ${goal.ok ? "pass" : "FAIL"} ${goal.id}: ${goal.description}${goal.detail ? ` — ${goal.detail}` : ""}`
147
+ (goal) => `- ${goal.ok ? "pass" : "FAIL"} ${goal.id}: ${goal.description}${
148
+ goal.detail ? ` — ${redactInstallRecoveryText(goal.detail).slice(0, 300)}` : ""
149
+ }`
145
150
  );
146
151
  const lines = [
147
152
  "An `impel` installation step failed on this machine. Diagnose and repair it.",
@@ -359,7 +364,9 @@ export async function runInstallRecovery(options, overrides = {}) {
359
364
  tool_use_id: call.id,
360
365
  content: `Rejected: goal health checks still fail locally:\n${goalReport.results
361
366
  .filter((goal) => !goal.ok)
362
- .map((goal) => `- ${goal.id}: ${goal.description}${goal.detail ? ` — ${goal.detail}` : ""}`)
367
+ .map((goal) => `- ${goal.id}: ${goal.description}${
368
+ goal.detail ? ` — ${redactInstallRecoveryText(goal.detail).slice(0, 300)}` : ""
369
+ }`)
363
370
  .join("\n")}\nKeep repairing or report blocked.`,
364
371
  is_error: true,
365
372
  });
@@ -5,9 +5,15 @@ import { redactSecretText } from "../config.js";
5
5
 
6
6
  const JWT_RE = /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/gu;
7
7
  const BEARER_RE = /\bBearer\s+[A-Za-z0-9._~+\/-]{8,}/giu;
8
- const SECRET_ASSIGNMENT_RE = /\b(api[_-]?key|authorization|password|secret|token)\s*[:=]\s*([^\s,;]+)/giu;
8
+ // Quote-aware and prefix-tolerant: catches `token=x`, `"token": "x"`,
9
+ // `ANTHROPIC_API_KEY=x`, and `Authorization: x` shapes alike.
10
+ const SECRET_ASSIGNMENT_RE = /([A-Za-z0-9_.-]*(?:api[_-]?key|authorization|passwords?|secrets?|tokens?|credentials?)["']?\s*[:=]\s*["']?)([^\s"',;]+)/giu;
11
+ // Common vendor credential formats that appear bare in logs with no key= prefix.
12
+ const VENDOR_TOKEN_RE = /\b(?:sk-[A-Za-z0-9-]{2,20}-[A-Za-z0-9_-]{8,}|sk-[A-Za-z0-9_-]{16,}|ghp_[A-Za-z0-9]{8,}|gho_[A-Za-z0-9]{8,}|ghu_[A-Za-z0-9]{8,}|ghs_[A-Za-z0-9]{8,}|github_pat_[A-Za-z0-9_]{8,}|glpat-[A-Za-z0-9_-]{8,}|xox[abposr]-[A-Za-z0-9-]{8,}|AKIA[0-9A-Z]{16}|AIza[0-9A-Za-z_-]{20,})\b/gu;
9
13
  const EMAIL_RE = /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/giu;
10
- const WINDOWS_USER_PATH_RE = /\b[A-Za-z]:\\Users\\[^\\\s]+/giu;
14
+ // Any per-user directory is PII, not just the current user's home.
15
+ const WINDOWS_USER_PATH_RE = /\b[A-Za-z]:[\\/](?:Users|Profiles)[\\/][^\\/\s"']+/giu;
16
+ const POSIX_USER_PATH_RE = /(?:^|[\s"'=:(])((?:\/Users|\/home)\/)([^\/\s"']+)/gu;
11
17
 
12
18
  function escapeRegex(value) {
13
19
  return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
@@ -20,9 +26,14 @@ export function redactInstallRecoveryText(value, { homeDir = os.homedir() } = {}
20
26
  }
21
27
  return text
22
28
  .replace(WINDOWS_USER_PATH_RE, "%USERPROFILE%")
29
+ .replace(POSIX_USER_PATH_RE, (_match, prefix, name, offset, whole) => {
30
+ const lead = whole[offset] === "/" ? "" : whole[offset];
31
+ return `${lead}${prefix}[USER]`.replace("//", "/");
32
+ })
23
33
  .replace(JWT_RE, "[REDACTED TOKEN]")
24
34
  .replace(BEARER_RE, "Bearer [REDACTED]")
25
- .replace(SECRET_ASSIGNMENT_RE, (_match, name) => `${name}=[REDACTED]`)
35
+ .replace(SECRET_ASSIGNMENT_RE, (_match, name) => `${name}[REDACTED]`)
36
+ .replace(VENDOR_TOKEN_RE, "[REDACTED TOKEN]")
26
37
  .replace(EMAIL_RE, "[REDACTED EMAIL]")
27
38
  .slice(0, 12_000);
28
39
  }
@@ -70,6 +70,12 @@ export const INSTALL_RECOVERY_TOOLS = Object.freeze({
70
70
  "Run a synthetic authenticated probe against the Impel gateway. Reports reachable/rejected status; never sends user content.",
71
71
  schema: {},
72
72
  },
73
+ check_auth_helper: {
74
+ risk: "read",
75
+ description:
76
+ "Run the tenant's Impel-owned app token helper with a bounded timeout and classify the result (works, missing, stale interpreter/CLI path, or failing). Use when managed apps report auth/credential failures.",
77
+ schema: {},
78
+ },
73
79
  check_health: {
74
80
  risk: "read",
75
81
  description:
@@ -297,6 +303,52 @@ function readProfile(input, context) {
297
303
  });
298
304
  }
299
305
 
306
+ function checkAuthHelper(context, io) {
307
+ if (!context.tenantId) {
308
+ return result("failed", "No tenant is selected; the app token helper has not been created yet.");
309
+ }
310
+ if (context.platform === "win32") {
311
+ return result("failed", "Windows managed apps do not use a shell token helper; check the CLI profiles instead.");
312
+ }
313
+ const helperPath = path.join(
314
+ CONFIG_DIR,
315
+ "apps",
316
+ "tenants",
317
+ context.tenantId,
318
+ "bin",
319
+ "token"
320
+ );
321
+ if (!fs.existsSync(helperPath)) {
322
+ return result("failed", "The tenant app token helper is missing; repair_impel_profile regenerates it.");
323
+ }
324
+ const run = io.spawnSync(helperPath, [], {
325
+ encoding: "utf8",
326
+ env: context.environment,
327
+ stdio: ["ignore", "pipe", "pipe"],
328
+ timeout: 20_000,
329
+ windowsHide: true,
330
+ });
331
+ if (run?.error) {
332
+ return result("failed", `The app token helper could not be executed (${run.error.code || run.error.message}).`);
333
+ }
334
+ if (run.status === 0 && /impel_/u.test(String(run.stdout || ""))) {
335
+ return result("succeeded", "The app token helper runs and emits a credential.");
336
+ }
337
+ const stderr = String(run.stderr || "");
338
+ const classified = /Cannot find module|MODULE_NOT_FOUND/iu.test(stderr)
339
+ ? "its embedded impel-cli path no longer exists"
340
+ : /No such file or directory|not found|no usable node/iu.test(stderr)
341
+ ? "its embedded node runtime path no longer exists"
342
+ : /SyntaxError/iu.test(stderr)
343
+ ? "it points at an incompatible impel-cli checkout"
344
+ : "it exited without emitting a credential";
345
+ return result(
346
+ "failed",
347
+ `The app token helper is broken: ${classified}. repair_impel_profile rewrites it with current paths.`,
348
+ { exitCode: run.status, output: stderr.slice(0, 2_000) }
349
+ );
350
+ }
351
+
300
352
  async function probeGateway(context) {
301
353
  if (typeof context.probeGateway !== "function") {
302
354
  return result("failed", "Gateway verification is unavailable in this recovery context.");
@@ -517,6 +569,7 @@ export async function executeInstallRecoveryTool(name, rawInput, context = {}, d
517
569
  case "inspect_processes": return inspectProcesses(resolvedContext, io);
518
570
  case "check_binary": return checkBinary(input, resolvedContext, io);
519
571
  case "read_profile": return readProfile(input, resolvedContext);
572
+ case "check_auth_helper": return checkAuthHelper(resolvedContext, io);
520
573
  case "probe_gateway": return probeGateway(resolvedContext);
521
574
  case "check_health": return checkHealth(resolvedContext);
522
575
  case "repair_impel_profile": return repairImpelProfile(resolvedContext);
@@ -577,5 +630,19 @@ export function deterministicInstallRecoveryPlan(failure, platform = process.pla
577
630
  reason: "Transient npm/network failures often succeed on a clean retry.",
578
631
  });
579
632
  }
633
+ if (/provider auth command|cannot find module|module_not_found|token helper/u.test(text)) {
634
+ plan.push(
635
+ {
636
+ tool: "check_auth_helper",
637
+ input: {},
638
+ reason: "The failure mentions the app auth helper; classify how it is broken.",
639
+ },
640
+ {
641
+ tool: "repair_impel_profile",
642
+ input: {},
643
+ reason: "Regenerating Impel-owned profiles rewrites the token helper with current paths.",
644
+ }
645
+ );
646
+ }
580
647
  return plan;
581
648
  }