impel-cli 0.14.2 → 0.15.0

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/README.md CHANGED
@@ -110,8 +110,19 @@ impel update # install the latest Impel CLI later
110
110
 
111
111
  `impel setup` keeps your normal Claude and Codex profiles separate. On Mac and
112
112
  Windows it also prepares the supported isolated desktop experiences. On Windows,
113
- it installs missing official vendor command-line tools when needed. On Linux,
114
- it prepares the isolated command-line workflow.
113
+ it installs missing official vendor command-line tools with the vendors'
114
+ checksum-verifying native installers, so Claude Code does not inherit its newer
115
+ Node.js requirement from the deprecated npm package. On Linux, it prepares the
116
+ isolated command-line workflow.
117
+
118
+ If setup or the CLI self-update fails, Impel first runs a deterministic local
119
+ check. In an interactive terminal it then offers assisted recovery; use
120
+ `impel setup --repair` (or `impel update --repair`) to opt in explicitly. Before
121
+ upload, the CLI prints the exact sanitized payload. The hosted agent can only
122
+ return a reviewed action ID; the CLI rejects free-form commands and asks before
123
+ every mutation. Sessions expire after 15 minutes, mutation capabilities are
124
+ single-use, and a private checkpoint lets `--repair` resume an interrupted run.
125
+ Use `--no-recovery` or `IMPEL_DISABLE_INSTALL_RECOVERY=1` to keep recovery off.
115
126
 
116
127
  ### If something does not work
117
128
 
@@ -133,11 +144,13 @@ access, scopes, and organization membership for every protected action.
133
144
 
134
145
  ```
135
146
  impel setup [--pat <pat>] [--tenant <org>] [--skip-apps] [--skip-clis]
147
+ [--repair|--no-recovery]
136
148
  Guided setup: token, tenant, platform clients, verify
137
149
  (closes running Claude/ChatGPT apps, updates the vendor
138
150
  apps, then installs the vendored Impel apps on macOS;
139
- installs missing vendor CLIs on Windows)
140
- impel update [--check] [--skip-apps] Update EVERYTHING: reinstall the CLI from npm, then
151
+ runs official native vendor CLI installers on Windows)
152
+ impel update [--check] [--skip-apps] [--repair|--no-recovery]
153
+ Update EVERYTHING: reinstall the CLI from npm, then
141
154
  cascade to `app update all`, `skills sync all`, and
142
155
  `agents sync all`
143
156
  impel auth [--pat <pat>] [--gateway <url>] [--app <url>]
@@ -472,26 +485,36 @@ and pre-tenancy Impel CLI profiles, so switching organizations cannot strand an
472
485
  older snapshot. These safeguards apply only to Impel-owned `CODEX_HOME`
473
486
  directories; native `~/.codex` remains untouched.
474
487
 
475
- On Windows, `impel setup` ensures the official `@anthropic-ai/claude-code` and
476
- `@openai/codex` packages are available, installing only the missing package(s)
477
- with the same npm installation that supplies Impel. Use `--skip-clis` to prepare
478
- the profiles without installing missing vendor CLIs. The launchers and skill
479
- sync resolve native executables through `PATH`/`PATHEXT`, including global npm
480
- `.cmd` and `.bat` shims. Windows environment references such as `%NVM_SYMLINK%`
481
- are expanded during lookup, and the standard npm-global, Claude native-installer,
482
- and Codex standalone-installer directories are checked even when the parent
483
- PowerShell PATH is stale. Arguments are forwarded through an explicitly escaped
484
- `cmd.exe` invocation without Node shell mode; NULs and line breaks, which cannot
485
- be represented safely through a batch shim, are rejected before anything is
486
- launched. Managed Codex auth and MCP subprocesses bypass batch shims entirely by
487
- calling Node directly.
488
-
489
- If npm itself fails during setup, the wizard prints the native spawn error and
490
- the exact vendor-package command to retry in PowerShell. If npm succeeds but the
491
- new commands remain undiscoverable, close and reopen PowerShell; then use
492
- `npm prefix --global` to identify the directory that must be on the user PATH.
493
- Setup exits non-zero and says it is incomplete until both launchers are ready—it
494
- does not print a success banner after a failed installation.
488
+ On Windows, `impel setup` installs only missing command-line tools through the
489
+ vendors' current native PowerShell installers. Claude Code's npm installation is
490
+ deprecated and its current package requires Node.js 22, while Impel itself still
491
+ supports Node.js 18 or newer. The native path keeps that valid Impel setup
492
+ working. Codex's installer is run non-interactively. Both installers download
493
+ platform-specific native releases and verify their published SHA-256 checksums.
494
+ Use `--skip-clis` to prepare the profiles without installing missing vendor
495
+ CLIs. Each tool is installed independently, so a failure from one vendor does
496
+ not prevent the other tool from being installed and configured.
497
+
498
+ The launchers and skill sync still resolve executables through `PATH`/`PATHEXT`,
499
+ including existing global npm `.cmd` and `.bat` shims. Windows environment
500
+ references such as `%NVM_SYMLINK%` are expanded during lookup, and the standard
501
+ npm-global, Claude native-installer, Codex standalone-installer, and Windows
502
+ PowerShell directories are checked even when the parent PowerShell PATH is
503
+ stale. Candidates must also pass a bounded `--version` probe, so a stale or
504
+ half-removed npm shim cannot hide a healthy native install later in the search
505
+ path. Existing batch-shim arguments are forwarded through an explicitly
506
+ escaped `cmd.exe` invocation without Node shell mode; NULs and line breaks,
507
+ which cannot be represented safely through a batch shim, are rejected before
508
+ anything is launched. Managed Codex auth and MCP subprocesses bypass batch
509
+ shims entirely by calling Node directly.
510
+
511
+ If a native installer fails, the wizard identifies the affected tool, preserves
512
+ the other tool's successful install, and prints that vendor's exact official
513
+ PowerShell command to retry. If an installer exits successfully but its command
514
+ cannot be verified, close and reopen PowerShell, retry the printed command, and
515
+ run `impel setup` again. Setup exits non-zero and says it is incomplete until
516
+ both launchers are ready—it does not print a success banner after a failed
517
+ installation.
495
518
 
496
519
  Windows receives the complete CLI workflow: setup/auth, tenant selection,
497
520
  isolated Claude and Codex launches, native profile switching, tasks, skills,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "impel-cli",
3
- "version": "0.14.2",
3
+ "version": "0.15.0",
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/cli.js CHANGED
@@ -23,7 +23,8 @@ const HELP = `impel — the Impel gateway CLI: isolated Claude Code, Codex, and
23
23
  Get started:
24
24
  impel setup Guided end-to-end onboarding: token, tenant,
25
25
  platform clients, gateway verification
26
- (--pat <pat> --tenant <org> --skip-apps/--skip-clis)
26
+ (--pat <pat> --tenant <org> --skip-apps/--skip-clis
27
+ --repair/--no-recovery)
27
28
  impel update Update everything: the CLI itself, then the
28
29
  Impel apps, skills, and agents (--check, --skip-apps)
29
30
 
@@ -69,6 +70,7 @@ Env:
69
70
  IMPEL_APP_URL Default app/control-plane URL for setup, auth, PATs, and tasks.
70
71
  IMPEL_SKIP_UPDATE_CHECK=1 Silence launch-time update notices.
71
72
  IMPEL_SKIP_AGENT_SYNC=1 Keep the last native-agent catalog without refreshing it.
73
+ IMPEL_DISABLE_INSTALL_RECOVERY=1 Disable local and hosted install recovery.
72
74
 
73
75
  Config file:
74
76
  ~/.config/impel/config.json (mode 0600)
@@ -16,7 +16,8 @@ import {
16
16
  import { promptSecret, promptText } from "../prompt.js";
17
17
  import { fetchTenants, normalizeTenantId, tenantCredential } from "../tenants.js";
18
18
  import { cmdApps } from "./apps.js";
19
- import { prepareWindowsClis, windowsCliInstallCommand } from "../windowsSetup.js";
19
+ import { prepareWindowsClis, windowsCliInstallCommands } from "../windowsSetup.js";
20
+ import { runInstallRecovery } from "../installRecovery/loop.js";
20
21
 
21
22
  const HELP = `impel setup - guided end-to-end gateway setup
22
23
 
@@ -34,6 +35,8 @@ Usage:
34
35
  impel setup --tenant <org> Preselect the organization
35
36
  impel setup --skip-apps Skip the desktop-app installation step
36
37
  impel setup --skip-clis On Windows, don't install missing vendor CLIs
38
+ impel setup --repair Explicitly opt into sanitized assisted recovery on failure
39
+ impel setup --no-recovery Disable local and hosted recovery for this run
37
40
  `;
38
41
 
39
42
  /**
@@ -104,6 +107,7 @@ export async function cmdSetup(argv, overrides = {}) {
104
107
  installApps: cmdApps,
105
108
  prepareWindowsClis,
106
109
  probe: probeGateway,
110
+ recoverInstall: runInstallRecovery,
107
111
  isTTY: process.stdin.isTTY,
108
112
  platform: process.platform,
109
113
  ...overrides,
@@ -115,6 +119,8 @@ export async function cmdSetup(argv, overrides = {}) {
115
119
  app: { type: "string" },
116
120
  "skip-apps": { type: "boolean" },
117
121
  "skip-clis": { type: "boolean" },
122
+ repair: { type: "boolean" },
123
+ "no-recovery": { type: "boolean" },
118
124
  help: { type: "boolean" },
119
125
  });
120
126
  if (flags.help) {
@@ -174,6 +180,15 @@ export async function cmdSetup(argv, overrides = {}) {
174
180
  console.log(` ✓ stored in ~/.config/impel/config.json (mode 0600)`);
175
181
  console.log("");
176
182
 
183
+ const setupFailures = [];
184
+ const recordSetupFailure = (failure) => {
185
+ setupFailures.push({
186
+ platform: io.platform,
187
+ architecture: process.arch,
188
+ ...failure,
189
+ });
190
+ };
191
+
177
192
  // ── Step 2: organization ─────────────────────────────────────────────────
178
193
  // A successful tenant fetch also proves the token: the app verifies it
179
194
  // against impel-identity before answering.
@@ -249,23 +264,42 @@ export async function cmdSetup(argv, overrides = {}) {
249
264
  }
250
265
  if (result.missingAfter.length > 0 && !flags["skip-clis"]) {
251
266
  platformSetupFailed = true;
252
- if (result.installSucceeded === false) {
253
- const failure = result.installFailure;
254
- const detail = failure?.message
255
- ? `: ${redactSecretText(failure.message)}`
256
- : Number.isInteger(failure?.status)
257
- ? ` (exit code ${failure.status})`
258
- : failure?.signal
259
- ? ` (signal ${failure.signal})`
260
- : "";
261
- console.error(` npm could not install the missing vendor CLIs${detail}.`);
262
- console.error(" Retry this command in the same PowerShell window:");
263
- console.error(` ${result.installCommand || windowsCliInstallCommand(result.missingAfter)}`);
264
- } else {
265
- console.error(" npm completed, but the installed commands are not discoverable.");
266
- console.error(" Close and reopen PowerShell, then re-run `impel setup`.");
267
- console.error(" If they are still missing, run `npm prefix --global` and add that directory to your user PATH.");
267
+ const fallbackCommands = windowsCliInstallCommands(result.missingAfter);
268
+ for (const tool of result.missingAfter) {
269
+ const label = tool === "claude" ? "Claude Code" : "Codex";
270
+ const installation = result.installations?.[tool];
271
+ const failure = installation?.failure;
272
+ if (installation?.succeeded === false || (!installation && result.installSucceeded === false)) {
273
+ const detail = failure?.message
274
+ ? `: ${redactSecretText(failure.message)}`
275
+ : Number.isInteger(failure?.status)
276
+ ? ` (exit code ${failure.status})`
277
+ : failure?.signal
278
+ ? ` (signal ${failure.signal})`
279
+ : "";
280
+ console.error(` ${label}'s native installer failed${detail}.`);
281
+ recordSetupFailure({
282
+ step: `install.vendor_cli.${tool}`,
283
+ command: installation?.command || result.installCommands?.[tool] || fallbackCommands[tool],
284
+ exitCode: failure?.status,
285
+ signal: failure?.signal,
286
+ errorCode: failure?.code,
287
+ message: failure?.message
288
+ ? `${label}'s native installer failed: ${failure.message}`
289
+ : `${label}'s native installer did not reach a verified state.`,
290
+ });
291
+ } else {
292
+ console.error(` ${label}'s native installer completed, but Impel could not verify the command.`);
293
+ recordSetupFailure({
294
+ step: `verify.vendor_cli.${tool}`,
295
+ command: installation?.command || result.installCommands?.[tool] || fallbackCommands[tool],
296
+ message: `${label}'s installer completed but the command was not discoverable or did not pass its version check.`,
297
+ });
298
+ }
299
+ console.error(" Retry its official installer in the same PowerShell window:");
300
+ console.error(` ${installation?.command || result.installCommands?.[tool] || fallbackCommands[tool]}`);
268
301
  }
302
+ console.error(" Then close and reopen PowerShell and re-run `impel setup`.");
269
303
  } else if (result.missingAfter.length > 0) {
270
304
  console.log(" Vendor CLI installation was skipped; install it before using the matching launcher.");
271
305
  } else {
@@ -275,6 +309,11 @@ export async function cmdSetup(argv, overrides = {}) {
275
309
  platformSetupFailed = true;
276
310
  console.error(` ✗ Windows CLI setup failed (${redactSecretText(error?.message || error)})`);
277
311
  console.error(" Fix the issue, then re-run `impel setup`.");
312
+ recordSetupFailure({
313
+ step: "setup.windows_clis",
314
+ errorCode: error?.code,
315
+ message: `Windows CLI setup failed: ${error?.message || error}`,
316
+ });
278
317
  }
279
318
  if (flags["skip-apps"]) {
280
319
  console.log(" Windows desktop apps: skipped (--skip-apps).");
@@ -285,6 +324,10 @@ export async function cmdSetup(argv, overrides = {}) {
285
324
  if (installed === false) {
286
325
  platformSetupFailed = true;
287
326
  console.error(" ✗ Windows desktop app setup failed; fix the issue above, then re-run `impel app install all`.");
327
+ recordSetupFailure({
328
+ step: "install.vendor_app.all",
329
+ message: "Windows desktop app setup returned an unsuccessful result.",
330
+ });
288
331
  } else {
289
332
  console.log(" ✓ Impel Claude and Impel ChatGPT profiles are ready");
290
333
  }
@@ -292,6 +335,11 @@ export async function cmdSetup(argv, overrides = {}) {
292
335
  platformSetupFailed = true;
293
336
  console.error(` ✗ Windows desktop app setup failed (${redactSecretText(error?.message || error)})`);
294
337
  console.error(" Fix the issue, then re-run `impel app install all`.");
338
+ recordSetupFailure({
339
+ step: "install.vendor_app.all",
340
+ errorCode: error?.code,
341
+ message: `Windows desktop app setup failed: ${error?.message || error}`,
342
+ });
295
343
  }
296
344
  }
297
345
  } else if (flags["skip-apps"]) {
@@ -313,6 +361,11 @@ export async function cmdSetup(argv, overrides = {}) {
313
361
  ` ✗ app installation failed (${redactSecretText(error?.message || error)})`
314
362
  );
315
363
  console.error(" Fix the issue, then re-run `impel app install`.");
364
+ recordSetupFailure({
365
+ step: "install.vendor_app.all",
366
+ errorCode: error?.code,
367
+ message: `Desktop app installation failed: ${error?.message || error}`,
368
+ });
316
369
  }
317
370
  }
318
371
  console.log("");
@@ -326,15 +379,113 @@ export async function cmdSetup(argv, overrides = {}) {
326
379
  verificationFailed = true;
327
380
  console.log(`4/4 Verify: ✗ gateway reachable but it rejected the token (HTTP ${probe.status}).`);
328
381
  console.log(" Mint a fresh token and re-run `impel setup`.");
329
- process.exitCode = 1;
382
+ recordSetupFailure({
383
+ step: "verify.gateway",
384
+ exitCode: probe.status,
385
+ message: `The gateway rejected the configured credential with HTTP ${probe.status}.`,
386
+ });
330
387
  } else {
331
388
  verificationFailed = true;
332
389
  console.log(`4/4 Verify: ✗ gateway unreachable (${probe.error}).`);
333
390
  console.log(" Check your network, then run `impel status` to retry.");
334
- process.exitCode = 1;
391
+ recordSetupFailure({
392
+ step: "verify.gateway",
393
+ message: `The gateway could not be reached: ${probe.error}.`,
394
+ });
395
+ }
396
+
397
+ if (platformSetupFailed || verificationFailed) {
398
+ const aggregateFailure = {
399
+ platform: io.platform,
400
+ architecture: process.arch,
401
+ step: setupFailures.length === 1 ? setupFailures[0].step : "setup.multiple",
402
+ message:
403
+ setupFailures.length === 1
404
+ ? setupFailures[0].message
405
+ : `${setupFailures.length} setup checks failed.`,
406
+ diagnostics: Object.fromEntries(
407
+ setupFailures.slice(0, 20).map((failure, index) => [
408
+ `failure${index + 1}`,
409
+ `${failure.step}: ${failure.message}`,
410
+ ])
411
+ ),
412
+ };
413
+ try {
414
+ const recovery = await io.recoverInstall(
415
+ {
416
+ failure: aggregateFailure,
417
+ config,
418
+ explicit: Boolean(flags.repair),
419
+ noRecovery: Boolean(flags["no-recovery"]),
420
+ actionContext: {
421
+ probeGateway: () => io.probe(gatewayUrl, pat, tenant.id),
422
+ installVendorClis: (tool) =>
423
+ io.prepareWindowsClis({
424
+ gatewayUrl,
425
+ tenantId: tenant.id,
426
+ skipInstall: false,
427
+ installTools: [tool],
428
+ }),
429
+ repairProfiles: async () => {
430
+ if (io.platform !== "win32") return false;
431
+ const result = await io.prepareWindowsClis({
432
+ gatewayUrl,
433
+ tenantId: tenant.id,
434
+ skipInstall: true,
435
+ });
436
+ return result.missingAfter.length === 0;
437
+ },
438
+ installVendorApp: (target) => io.installApps(["install", target]),
439
+ retryStep: async (step) => {
440
+ if (/vendor_cli|windows_clis/iu.test(step || "")) {
441
+ const result = await io.prepareWindowsClis({
442
+ gatewayUrl,
443
+ tenantId: tenant.id,
444
+ skipInstall: false,
445
+ });
446
+ return result.missingAfter.length === 0;
447
+ }
448
+ if (/vendor_app/iu.test(step || "")) {
449
+ return (await io.installApps(["install", "all"])) !== false;
450
+ }
451
+ if (/gateway/iu.test(step || "")) {
452
+ const result = await io.probe(gatewayUrl, pat, tenant.id);
453
+ return Boolean(result.reachable && !result.rejected);
454
+ }
455
+ return false;
456
+ },
457
+ },
458
+ },
459
+ {
460
+ isTTY: io.isTTY,
461
+ promptText: io.promptText,
462
+ ...(overrides.recoveryOverrides || {}),
463
+ }
464
+ );
465
+ if (recovery.fixed) {
466
+ if (io.platform === "win32" && setupFailures.some((failure) => /vendor_cli|windows_clis/iu.test(failure.step))) {
467
+ const checked = await io.prepareWindowsClis({
468
+ gatewayUrl,
469
+ tenantId: tenant.id,
470
+ skipInstall: true,
471
+ });
472
+ platformSetupFailed = checked.missingAfter.length > 0;
473
+ } else {
474
+ platformSetupFailed = false;
475
+ }
476
+ if (setupFailures.some((failure) => failure.step === "verify.gateway")) {
477
+ const checked = await io.probe(gatewayUrl, pat, tenant.id);
478
+ verificationFailed = !checked.reachable || checked.rejected;
479
+ } else {
480
+ verificationFailed = false;
481
+ }
482
+ }
483
+ } catch (error) {
484
+ console.warn(`Install recovery could not start (${redactSecretText(error?.message || error)}).`);
485
+ }
335
486
  }
336
487
 
337
- if (platformSetupFailed) process.exitCode = 1;
488
+ if (platformSetupFailed || verificationFailed) process.exitCode = 1;
338
489
 
339
490
  console.log("");
340
491
  if (platformSetupFailed || verificationFailed) {
@@ -16,6 +16,7 @@ import { nativeCommandInvocation } from "../nativeProcess.js";
16
16
  import { windowsClaudeUserData } from "../windowsApps.js";
17
17
  import { withProgress } from "../progress.js";
18
18
  import { installedAppTenantIds } from "./apps.js";
19
+ import { runInstallRecovery } from "../installRecovery/loop.js";
19
20
  import {
20
21
  fetchRemoteVersion,
21
22
  installedVersion,
@@ -38,6 +39,8 @@ Usage:
38
39
  impel update Update the CLI, then cascade to apps, skills, and agents
39
40
  impel update --check Report whether an update is available; change nothing
40
41
  impel update --skip-apps Skip the desktop-app step of the cascade
42
+ impel update --repair Explicitly opt into sanitized assisted recovery on failure
43
+ impel update --no-recovery Disable local and hosted recovery for this run
41
44
  `;
42
45
 
43
46
  function reportProcessFailure(stage, result) {
@@ -166,12 +169,16 @@ export async function cmdUpdate(argv, overrides = {}) {
166
169
  appsInstalled: anyAppInstalled,
167
170
  platform: process.platform,
168
171
  progress: withProgress,
172
+ recoverInstall: runInstallRecovery,
173
+ loadConfig,
169
174
  ...overrides,
170
175
  };
171
176
  const { flags } = parseFlags(argv, {
172
177
  check: { type: "boolean" },
173
178
  "skip-apps": { type: "boolean" },
174
179
  "refresh-cache": { type: "boolean" },
180
+ repair: { type: "boolean" },
181
+ "no-recovery": { type: "boolean" },
175
182
  help: { type: "boolean" },
176
183
  });
177
184
  if (flags.help) {
@@ -211,8 +218,39 @@ export async function cmdUpdate(argv, overrides = {}) {
211
218
  console.error(" Verify `npm --version` in PowerShell, then retry `impel update`.");
212
219
  console.error(" Manual recovery: `npm install --global impel-cli@latest`.");
213
220
  }
214
- process.exitCode = 1;
215
- return;
221
+ const config = io.loadConfig();
222
+ let recovered = false;
223
+ if (config?.pat && config?.tenantId && config?.appUrl) {
224
+ try {
225
+ const recovery = await io.recoverInstall(
226
+ {
227
+ failure: {
228
+ platform: io.platform,
229
+ architecture: process.arch,
230
+ step: "install.impel_cli",
231
+ command: "npm install --global impel-cli@latest",
232
+ message: "The npm-verified global impel-cli update failed.",
233
+ },
234
+ config,
235
+ explicit: Boolean(flags.repair),
236
+ noRecovery: Boolean(flags["no-recovery"]),
237
+ actionContext: {
238
+ retryStep: async () =>
239
+ io.selfUpdate(updateInstallSpec()) === true,
240
+ },
241
+ },
242
+ overrides.recoveryOverrides || {}
243
+ );
244
+ recovered = recovery.fixed;
245
+ } catch (error) {
246
+ console.warn(`impel update: assisted recovery could not start (${redactSecretText(error?.message || error)}).`);
247
+ }
248
+ }
249
+ if (!recovered) {
250
+ process.exitCode = 1;
251
+ return;
252
+ }
253
+ console.log("CLI: recovery verified the update path.");
216
254
  }
217
255
  console.log(`CLI: updated${remote ? ` to v${remote}` : ""}.`);
218
256
  }
@@ -11,6 +11,7 @@ const SAFE_NAMESPACE = /^[a-z][a-z0-9_-]{1,31}$/u;
11
11
  const SAFE_PREFIX = /^[a-z][a-z0-9_]{2,31}_$/u;
12
12
  const SAFE_ENVIRONMENT_PREFIX = /^[A-Z][A-Z0-9_]{1,31}$/u;
13
13
  const SAFE_ROUTE = /^\/[A-Za-z0-9._~!$&'()*+,;=:@%/-]*$/u;
14
+ const SAFE_TENANT_ID = /^[A-Za-z0-9_.-]{1,128}$/u;
14
15
  const ANSI_ESCAPE_RE = /\u001B(?:\][^\u0007\u001B]*(?:\u0007|\u001B\\)|\[[0-?]*[ -/]*[@-~]|[@-_])/gu;
15
16
  const CONTROL_TEST_RE = /[\u0000-\u001F\u007F-\u009F]/u;
16
17
  const CONTROL_RE = /[\u0000-\u001F\u007F-\u009F]/gu;
@@ -29,6 +30,15 @@ function validateRoute(name, value) {
29
30
  return value;
30
31
  }
31
32
 
33
+ function validateTenantID(value) {
34
+ if (value == null) return null;
35
+ const tenantID = String(value || "").trim();
36
+ if (!SAFE_TENANT_ID.test(tenantID) || tenantID === "." || tenantID === ".." || CONTROL_TEST_RE.test(tenantID)) {
37
+ throw new Error("gateway CLI tenant.defaultId is invalid");
38
+ }
39
+ return tenantID;
40
+ }
41
+
32
42
  function validateBrand(input) {
33
43
  if (!input || Array.isArray(input) || typeof input !== "object" || input.schemaVersion !== 1) {
34
44
  throw new Error("gateway CLI brand schemaVersion must be 1");
@@ -40,6 +50,8 @@ function validateBrand(input) {
40
50
  const managedMarker = String(input.cli?.managedMarker || "");
41
51
  const environmentPrefix = String(input.cli?.environmentPrefix || command.toUpperCase().replace(/-/gu, "_"));
42
52
  const patPrefix = String(input.auth?.patPrefix || "");
53
+ const tenantPrefix = input.auth?.tenantPrefix == null ? null : String(input.auth.tenantPrefix);
54
+ const defaultTenantID = validateTenantID(input.tenant?.defaultId);
43
55
  if (!SAFE_ID.test(productID)) throw new Error("gateway CLI product.id is invalid");
44
56
  if (!SAFE_ID.test(command)) throw new Error("gateway CLI command is invalid");
45
57
  if (!SAFE_NAMESPACE.test(configNamespace)) throw new Error("gateway CLI configNamespace is invalid");
@@ -47,13 +59,16 @@ function validateBrand(input) {
47
59
  if (!SAFE_NAMESPACE.test(managedMarker)) throw new Error("gateway CLI managedMarker is invalid");
48
60
  if (!SAFE_ENVIRONMENT_PREFIX.test(environmentPrefix)) throw new Error("gateway CLI environmentPrefix is invalid");
49
61
  if (!SAFE_PREFIX.test(patPrefix)) throw new Error("gateway CLI PAT prefix is invalid");
62
+ if (tenantPrefix != null && !SAFE_PREFIX.test(tenantPrefix)) throw new Error("gateway CLI tenant prefix is invalid");
63
+ if (defaultTenantID && !tenantPrefix) throw new Error("gateway CLI tenant prefix is required when tenant.defaultId is set");
50
64
 
51
65
  const defaultOrigin = input.gateway?.defaultOrigin == null ? null : normalizeGatewayUrl(input.gateway.defaultOrigin);
52
66
  return Object.freeze({
53
67
  schemaVersion: 1,
54
68
  product: Object.freeze({ id: productID, displayName: validateText("product.displayName", input.product?.displayName) }),
55
69
  cli: Object.freeze({ command, configNamespace, providerId: providerID, managedMarker, environmentPrefix }),
56
- auth: Object.freeze({ patPrefix }),
70
+ auth: Object.freeze({ patPrefix, tenantPrefix }),
71
+ tenant: Object.freeze({ defaultId: defaultTenantID }),
57
72
  gateway: Object.freeze({
58
73
  defaultOrigin,
59
74
  healthPath: validateRoute("healthPath", input.gateway?.healthPath),
@@ -183,9 +198,21 @@ export function createGatewayCli(options) {
183
198
  return `${secret.slice(0, brand.auth.patPrefix.length + 4)}...${secret.slice(-4)}`;
184
199
  }
185
200
 
201
+ function bearerCredential(pat) {
202
+ if (!brand.tenant.defaultId) return pat;
203
+ const encodedTenant = Buffer.from(brand.tenant.defaultId, "utf8").toString("base64url");
204
+ return `${brand.auth.tenantPrefix}${encodedTenant}.${pat}`;
205
+ }
206
+
186
207
  function redactSecretText(value) {
187
208
  const escapedPrefix = brand.auth.patPrefix.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
188
- const tokenPattern = new RegExp(`${escapedPrefix}[A-Za-z0-9_-]+(?:\\.[A-Za-z0-9_-]+)?`, "gu");
209
+ const escapedTenantPrefix = brand.auth.tenantPrefix?.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
210
+ const tokenPattern = new RegExp(
211
+ escapedTenantPrefix
212
+ ? `(?:${escapedTenantPrefix}[A-Za-z0-9_-]+\\.)?${escapedPrefix}[A-Za-z0-9_-]+(?:\\.[A-Za-z0-9_-]+)?`
213
+ : `${escapedPrefix}[A-Za-z0-9_-]+(?:\\.[A-Za-z0-9_-]+)?`,
214
+ "gu",
215
+ );
189
216
  return String(value ?? "")
190
217
  .replace(tokenPattern, "[REDACTED GATEWAY CREDENTIAL]")
191
218
  .replace(ANSI_ESCAPE_RE, "")
@@ -244,7 +271,7 @@ export function createGatewayCli(options) {
244
271
  if (!health.ok) throw new Error(`gateway health returned HTTP ${health.status}`);
245
272
  const models = await fetch(gatewayRoutes.models(gatewayUrl), {
246
273
  ...requestOptions,
247
- headers: { authorization: `Bearer ${pat}` },
274
+ headers: { authorization: `Bearer ${bearerCredential(pat)}` },
248
275
  });
249
276
  if (!models.ok) throw new Error(`gateway authentication returned HTTP ${models.status}`);
250
277
  const body = await models.json();
@@ -353,6 +380,7 @@ export function createGatewayCli(options) {
353
380
  const config = loadConfig();
354
381
  if (!config?.pat || !config?.gatewayUrl) throw new Error(`not configured; run \`${brand.cli.command} setup\``);
355
382
  print(`Gateway: ${config.gatewayUrl}`);
383
+ if (brand.tenant.defaultId) print(`Tenant: ${brand.tenant.defaultId}`);
356
384
  print(`PAT: ${maskSecret(config.pat)}`);
357
385
  print(`Profiles: ${profileRoot}`);
358
386
  try {
@@ -367,7 +395,7 @@ export function createGatewayCli(options) {
367
395
  async function cmdToken() {
368
396
  const config = loadConfig();
369
397
  if (!config?.pat) throw new Error(`not authenticated; run \`${brand.cli.command} setup\` or \`${brand.cli.command} auth\``);
370
- output.write(`${config.pat}\n`);
398
+ output.write(`${bearerCredential(config.pat)}\n`);
371
399
  return 0;
372
400
  }
373
401
 
@@ -408,7 +436,7 @@ export function createGatewayCli(options) {
408
436
  const profile = ensureClaudeProfile(config.gatewayUrl);
409
437
  childEnvironment.CLAUDE_CONFIG_DIR = profile.configDir;
410
438
  childEnvironment.ANTHROPIC_BASE_URL = gatewayRoutes.anthropic(config.gatewayUrl);
411
- childEnvironment.ANTHROPIC_AUTH_TOKEN = config.pat;
439
+ childEnvironment.ANTHROPIC_AUTH_TOKEN = bearerCredential(config.pat);
412
440
  } else if (tool === "codex") {
413
441
  const profile = ensureCodexProfile(config.gatewayUrl);
414
442
  childEnvironment.CODEX_HOME = profile.codexHome;