@datarails-ai/dr-cli 1.6.97 → 1.6.98

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
@@ -230,6 +230,20 @@ under one identity and run as another.
230
230
  { "apiKey": "<key>", "server": "DEV", "email": "svc@datarails.com" }
231
231
  ```
232
232
 
233
+ The key may instead live in an environment variable, with the file carrying
234
+ only the variable's **name** — the shape for CI (the key stays in the runner's
235
+ secret env, never on disk) and for agent sandboxes (where the variable's
236
+ in-sandbox value is an opaque vault placeholder substituted at egress, passed
237
+ verbatim and never validated):
238
+
239
+ ```json
240
+ { "apiKeyEnvVar": "DR_API_KEY", "server": "DEV", "email": "svc@datarails.com" }
241
+ ```
242
+
243
+ `apiKey` and `apiKeyEnvVar` are mutually exclusive — a file carrying both is
244
+ refused rather than guessed about. An unset variable fails the command with an
245
+ error naming it; the stored file is never modified by that failure.
246
+
233
247
  `email` names the stored identity — what `--account` selects and what the
234
248
  keychain entry is keyed by. It need not match the principal the key is bound to;
235
249
  the token the exchange mints is what every request authenticates as, and
package/dist/cli.js CHANGED
@@ -8,7 +8,7 @@ import { fileURLToPath as fileURLToPath3 } from "url";
8
8
  // package.json
9
9
  var package_default = {
10
10
  name: "@datarails-ai/dr-cli",
11
- version: "1.6.97",
11
+ version: "1.6.98",
12
12
  description: "Datarails CLI",
13
13
  type: "module",
14
14
  engines: {
@@ -38,7 +38,7 @@ var package_default = {
38
38
  postinstall: "node scripts/postinstall.mjs"
39
39
  },
40
40
  dependencies: {
41
- "@datarails-ai/dr-appspace-cli": "0.10.20",
41
+ "@datarails-ai/dr-appspace-cli": "0.10.21",
42
42
  "@modelcontextprotocol/sdk": "^1.29.0",
43
43
  "@napi-rs/keyring": "^1.3.0",
44
44
  commander: "^12.1.0",
@@ -67,7 +67,7 @@ var package_default = {
67
67
  };
68
68
 
69
69
  // src/buildInfo.ts
70
- var BUILD_TIME = "2026-10-05T07:44:13.912Z";
70
+ var BUILD_TIME = "2026-10-05T10:25:04.884Z";
71
71
  function formatBuildTime(iso) {
72
72
  if (iso === "") return "";
73
73
  const d = new Date(iso);
@@ -151,14 +151,17 @@ function parseApiKeyExpiry(value) {
151
151
  function hasApiKey(s) {
152
152
  return s.apiKey !== void 0 && s.apiKey !== "";
153
153
  }
154
+ function hasApiKeyEnvVar(s) {
155
+ return s.apiKeyEnvVar !== void 0 && s.apiKeyEnvVar !== "";
156
+ }
154
157
  function isImpersonatedSession(s) {
155
158
  return s.impersonatorEmail !== void 0 && s.impersonatorEmail !== "";
156
159
  }
157
160
  function isCookielessSession(s) {
158
- return isImpersonatedSession(s) || s.cookieless === true || hasApiKey(s);
161
+ return isImpersonatedSession(s) || s.cookieless === true || hasApiKey(s) || hasApiKeyEnvVar(s);
159
162
  }
160
163
  function usesApiKey(s) {
161
- return hasApiKey(s) && !isImpersonatedSession(s);
164
+ return (hasApiKey(s) || hasApiKeyEnvVar(s)) && !isImpersonatedSession(s);
162
165
  }
163
166
  var KeychainError = class extends Error {
164
167
  constructor(message, options) {
@@ -294,6 +297,16 @@ var credentialsFileSchema = z.object({
294
297
  // A user-bound API key, exchanged for `access` on demand. A file may hold
295
298
  // only this; `access` then appears after the first exchange.
296
299
  apiKey: z.string().min(1).optional(),
300
+ // DR-52657: the NAME of an environment variable holding the API key, for
301
+ // provisioners that must not put the key on disk — an agent sandbox, where
302
+ // the variable's in-sandbox value is an opaque vault placeholder
303
+ // substituted at egress, or a CI runner keeping the key in its secret env.
304
+ // The value is resolved at exchange time and passed verbatim; dr-cli never
305
+ // inspects, validates, or logs it. Mutually exclusive with `apiKey` (a
306
+ // refinement below): a file carrying both is ambiguous about which
307
+ // credential authenticates it, and silently preferring either one turns a
308
+ // provisioning mistake into commands running as the wrong key.
309
+ apiKeyEnvVar: z.string().min(1).optional(),
297
310
  // Carried verbatim, judged by `parseApiKeyExpiry` where it is read. Not
298
311
  // validated here, and not `.catch(undefined)` either: a rotation rewrites
299
312
  // the whole file from the parsed object, so a value this schema dropped
@@ -332,13 +345,19 @@ var credentialsFileSchema = z.object({
332
345
  }).passthrough().refine((c) => c.sessionId === void 0 === (c.csrfToken === void 0), {
333
346
  message: "sessionId and csrfToken must be provided together",
334
347
  path: ["sessionId"]
335
- }).refine((c) => c.access !== void 0 || c.apiKey !== void 0, {
336
- message: "one of access or apiKey is required",
348
+ }).refine((c) => !(c.apiKey !== void 0 && c.apiKeyEnvVar !== void 0), {
349
+ message: "apiKey and apiKeyEnvVar are mutually exclusive",
350
+ path: ["apiKeyEnvVar"]
351
+ }).refine((c) => c.access !== void 0 || c.apiKey !== void 0 || c.apiKeyEnvVar !== void 0, {
352
+ message: "one of access, apiKey or apiKeyEnvVar is required",
337
353
  path: ["access"]
338
- }).refine((c) => c.apiKey !== void 0 || c.refresh !== void 0 || c.sessionId !== void 0, {
339
- message: "refresh is required unless an apiKey or a session cookie is present",
340
- path: ["refresh"]
341
- });
354
+ }).refine(
355
+ (c) => c.apiKey !== void 0 || c.apiKeyEnvVar !== void 0 || c.refresh !== void 0 || c.sessionId !== void 0,
356
+ {
357
+ message: "refresh is required unless an apiKey, apiKeyEnvVar or a session cookie is present",
358
+ path: ["refresh"]
359
+ }
360
+ );
342
361
  function credentialsFilePath() {
343
362
  const override = process.env[CREDENTIALS_FILE_ENV];
344
363
  return override !== void 0 && override !== "" ? override : DEFAULT_CREDENTIALS_FILE;
@@ -801,7 +820,13 @@ async function exchangeApiKey(host, apiKey, options) {
801
820
  if (key === "") {
802
821
  throw new Error("api-key exchange: the stored API key is empty");
803
822
  }
804
- if (!/^[\x21-\x7e]+$/.test(key)) {
823
+ if (options?.opaque === true) {
824
+ if (/[\r\n\0]/.test(key) || !/^[\x00-\xff]+$/.test(key)) {
825
+ throw new Error(
826
+ "api-key exchange: the environment variable named by apiKeyEnvVar holds a value that cannot travel in an HTTP header (a line break, NUL, or a character above U+00FF)"
827
+ );
828
+ }
829
+ } else if (!/^[\x21-\x7e]+$/.test(key)) {
805
830
  throw new Error(
806
831
  "api-key exchange: dr only accepts an API key of printable ASCII with no spaces; the stored one contains whitespace, control characters, or non-ASCII characters"
807
832
  );
@@ -965,10 +990,31 @@ function mintedByApiKey(token) {
965
990
  function apiKeyFingerprint(apiKey) {
966
991
  return createHash("sha256").update(apiKey.trim()).digest("hex").slice(0, 32);
967
992
  }
993
+ function resolveApiKeyMaterial(session) {
994
+ if (session.apiKey !== void 0 && session.apiKey !== "") {
995
+ return { key: session.apiKey, fromEnv: false };
996
+ }
997
+ const name = session.apiKeyEnvVar;
998
+ if (name === void 0 || name === "") {
999
+ throw new Error("api-key exchange: the session carries no API key");
1000
+ }
1001
+ const value = process.env[name];
1002
+ if (value === void 0 || value === "") {
1003
+ throw new Error(
1004
+ `api-key exchange: the credential file names environment variable ${name} as the API-key carrier, but it is unset or empty in this environment`
1005
+ );
1006
+ }
1007
+ return { key: value, fromEnv: true };
1008
+ }
968
1009
  function mintedByStoredApiKey(session) {
969
1010
  if (!mintedByApiKey(session.jwtAccess)) return false;
970
- if (session.apiKey === void 0 || session.apiKey === "") return false;
971
- return session.jwtApiKeyFingerprint === apiKeyFingerprint(session.apiKey);
1011
+ let material;
1012
+ try {
1013
+ material = resolveApiKeyMaterial(session);
1014
+ } catch {
1015
+ return false;
1016
+ }
1017
+ return session.jwtApiKeyFingerprint === apiKeyFingerprint(material.key);
972
1018
  }
973
1019
  function apiKeyStaleBuffer(session, identity2, fallbackMs) {
974
1020
  if (session.jwtAccess === void 0 || session.jwtAccess === "") return fallbackMs;
@@ -995,12 +1041,13 @@ function ttlOf(token) {
995
1041
  function exchangeForSession(store, serverKey, email, session, host, opts) {
996
1042
  const identity2 = `${serverKey}:${email}`;
997
1043
  if (opts.signal?.aborted === true) return Promise.reject(opts.signal.reason);
998
- const key = JSON.stringify([
999
- identity2,
1000
- host,
1001
- opts.force,
1002
- apiKeyFingerprint(session.apiKey)
1003
- ]);
1044
+ let material;
1045
+ try {
1046
+ material = resolveApiKeyMaterial(session);
1047
+ } catch (err) {
1048
+ throw new SessionExpiredError(`${identity2} (${msgOf3(err)})`);
1049
+ }
1050
+ const key = JSON.stringify([identity2, host, opts.force, apiKeyFingerprint(material.key)]);
1004
1051
  const pending = inFlightExchanges.get(key);
1005
1052
  if (pending !== void 0) return joinExchange(pending, opts.signal);
1006
1053
  const controller = new AbortController();
@@ -1015,7 +1062,7 @@ function exchangeForSession(store, serverKey, email, session, host, opts) {
1015
1062
  for (; ; ) {
1016
1063
  let tok;
1017
1064
  try {
1018
- tok = await exchangeApiKey(host, session.apiKey, { signal });
1065
+ tok = await exchangeApiKey(host, material.key, { signal, opaque: material.fromEnv });
1019
1066
  } catch (err) {
1020
1067
  const cachedIsUsable = !opts.force && mintedByStoredApiKey(session) && !jwtExpiringSoon(session, 0);
1021
1068
  if (!(err instanceof ApiKeyExchangeError)) {
@@ -1054,7 +1101,7 @@ function exchangeForSession(store, serverKey, email, session, host, opts) {
1054
1101
  // the token did not come from. Read back by `mintedByStoredApiKey` on
1055
1102
  // every later command to tell this token apart from one the previous
1056
1103
  // key minted.
1057
- jwtApiKeyFingerprint: apiKeyFingerprint(session.apiKey),
1104
+ jwtApiKeyFingerprint: apiKeyFingerprint(material.key),
1058
1105
  // Dropped, not carried: the exchange mints no refresh token, and the
1059
1106
  // refresh rung is closed to a keyed identity by contract. Clearing it
1060
1107
  // is what lets "keyed, and still holding a refresh token" mean "these
@@ -1065,7 +1112,7 @@ function exchangeForSession(store, serverKey, email, session, host, opts) {
1065
1112
  };
1066
1113
  try {
1067
1114
  const current = await store.getSession(serverKey, email);
1068
- if (current === void 0 || current.apiKey !== session.apiKey) {
1115
+ if (current === void 0 || current.apiKey !== session.apiKey || current.apiKeyEnvVar !== session.apiKeyEnvVar) {
1069
1116
  return next;
1070
1117
  }
1071
1118
  await store.saveSession(serverKey, next);
@@ -1383,7 +1430,8 @@ var KeyringStore = class _KeyringStore {
1383
1430
  const impersonated = impersonatorEmail !== "";
1384
1431
  const cookielessFlag = parsed2.cookieless === true;
1385
1432
  const apiKey = await this.backend.get(SERVICE, `${sessionAccount}:apikey`);
1386
- const keyPresent = apiKey !== void 0 && apiKey !== "";
1433
+ const apiKeyEnvVar = typeof parsed2.apiKeyEnvVar === "string" ? parsed2.apiKeyEnvVar : "";
1434
+ const keyPresent = apiKey !== void 0 && apiKey !== "" || apiKeyEnvVar !== "";
1387
1435
  const cookieless = impersonated || cookielessFlag || keyPresent;
1388
1436
  const halfCookie = sessionId === "" !== (csrfToken === "");
1389
1437
  if (savedAtStr === "" || halfCookie || !cookieless && sessionId === "") {
@@ -1429,9 +1477,12 @@ var KeyringStore = class _KeyringStore {
1429
1477
  if (jwtRefresh !== void 0 && jwtRefresh !== "") {
1430
1478
  session.jwtRefresh = jwtRefresh;
1431
1479
  }
1432
- if (keyPresent) {
1480
+ if (apiKey !== void 0 && apiKey !== "") {
1433
1481
  session.apiKey = apiKey;
1434
1482
  }
1483
+ if (apiKeyEnvVar !== "") {
1484
+ session.apiKeyEnvVar = apiKeyEnvVar;
1485
+ }
1435
1486
  return session;
1436
1487
  }
1437
1488
  /**
@@ -1463,9 +1514,9 @@ var KeyringStore = class _KeyringStore {
1463
1514
  const impersonated = isImpersonatedSession(session);
1464
1515
  if (isCookielessSession(session)) {
1465
1516
  const hasJwt = session.jwtAccess !== void 0 && session.jwtAccess !== "";
1466
- if (!hasJwt && !hasApiKey(session)) {
1517
+ if (!hasJwt && !hasApiKey(session) && !hasApiKeyEnvVar(session)) {
1467
1518
  throw new Error(
1468
- "saveSession: cookie-less session must have a jwtAccess token or an apiKey"
1519
+ "saveSession: cookie-less session must have a jwtAccess token, an apiKey, or an apiKeyEnvVar"
1469
1520
  );
1470
1521
  }
1471
1522
  if (session.sessionId === "" !== (session.csrfToken === "")) {
@@ -1496,6 +1547,9 @@ var KeyringStore = class _KeyringStore {
1496
1547
  if (session.jwtApiKeyFingerprint !== void 0 && session.jwtApiKeyFingerprint !== "") {
1497
1548
  metaBlob.jwtApiKeyFingerprint = session.jwtApiKeyFingerprint;
1498
1549
  }
1550
+ if (hasApiKeyEnvVar(session)) {
1551
+ metaBlob.apiKeyEnvVar = session.apiKeyEnvVar;
1552
+ }
1499
1553
  if (impersonated) {
1500
1554
  metaBlob.impersonatorEmail = session.impersonatorEmail;
1501
1555
  if (session.impersonatedUserId !== void 0) {
@@ -2599,7 +2653,9 @@ var FileKeyringBackend = class _FileKeyringBackend {
2599
2653
  // Named as the Session field, since the blob is what `getSession` reads.
2600
2654
  ...creds.accessApiKeyFingerprint !== void 0 && {
2601
2655
  jwtApiKeyFingerprint: creds.accessApiKeyFingerprint
2602
- }
2656
+ },
2657
+ // The env var's NAME only — the resolved key never enters any store.
2658
+ ...creds.apiKeyEnvVar !== void 0 && { apiKeyEnvVar: creds.apiKeyEnvVar }
2603
2659
  });
2604
2660
  }
2605
2661
  static slotOf(creds, account) {
@@ -2627,11 +2683,16 @@ var FileKeyringBackend = class _FileKeyringBackend {
2627
2683
  const csrfToken = typeof parsed2.csrfToken === "string" ? parsed2.csrfToken : "";
2628
2684
  const apiKeyExpiresAt = typeof parsed2.apiKeyExpiresAt === "string" ? parsed2.apiKeyExpiresAt : "";
2629
2685
  const accessApiKeyFingerprint = typeof parsed2.jwtApiKeyFingerprint === "string" ? parsed2.jwtApiKeyFingerprint : "";
2686
+ const apiKeyEnvVar = typeof parsed2.apiKeyEnvVar === "string" ? parsed2.apiKeyEnvVar : "";
2630
2687
  return {
2631
2688
  ...sessionId !== "" && { sessionId },
2632
2689
  ...csrfToken !== "" && { csrfToken },
2633
2690
  ...apiKeyExpiresAt !== "" && { apiKeyExpiresAt },
2634
- ...accessApiKeyFingerprint !== "" && { accessApiKeyFingerprint }
2691
+ ...accessApiKeyFingerprint !== "" && { accessApiKeyFingerprint },
2692
+ // Set-if-present, never deleted on a blob that lost it (the
2693
+ // `apiKeyExpiresAt` rule): it is the identity's only credential pointer,
2694
+ // and a meta rewrite must not be able to brick the file.
2695
+ ...apiKeyEnvVar !== "" && { apiKeyEnvVar }
2635
2696
  };
2636
2697
  }
2637
2698
  async get(_service, account) {
@@ -2724,6 +2785,7 @@ var FileKeyringBackend = class _FileKeyringBackend {
2724
2785
  if (meta.apiKeyExpiresAt !== void 0) next.apiKeyExpiresAt = meta.apiKeyExpiresAt;
2725
2786
  if (meta.accessApiKeyFingerprint === void 0) delete next.accessApiKeyFingerprint;
2726
2787
  else next.accessApiKeyFingerprint = meta.accessApiKeyFingerprint;
2788
+ if (meta.apiKeyEnvVar !== void 0) next.apiKeyEnvVar = meta.apiKeyEnvVar;
2727
2789
  break;
2728
2790
  }
2729
2791
  }
@@ -44947,7 +45009,7 @@ import * as nodePath2 from "path";
44947
45009
  var API_PREFIX = "/api/v1";
44948
45010
  var OPERATOR_EMAIL_HEADER = "X-Operator-Email";
44949
45011
  function withOperatorAttribution(client, options) {
44950
- const operatorEmail = isImpersonatedSession(client.session) ? client.session.impersonatorEmail : client.session.cookieless === true && !hasApiKey(client.session) && client.session.interactiveLogin !== true ? void 0 : client.email;
45012
+ const operatorEmail = isImpersonatedSession(client.session) ? client.session.impersonatorEmail : client.session.cookieless === true && !hasApiKey(client.session) && !hasApiKeyEnvVar(client.session) && client.session.interactiveLogin !== true ? void 0 : client.email;
44951
45013
  if (operatorEmail === void 0) return options ?? {};
44952
45014
  return {
44953
45015
  ...options,
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "version": 1,
3
- "cliVersion": "1.6.97",
3
+ "cliVersion": "1.6.98",
4
4
  "_contract": "Resolve a command path through `aliases` (canonical prefix + alias segment -> canonical path) before any lookup. A path listed in `pendingCommands`, or whose first segment is a key of `pending`, is UNDECIDED, not unrestricted: fail closed or skip it, and never read a path's absence from `commands` as 'no requirement'. An entry carrying `denied` is UNAVAILABLE to any AGENT-SCOPED credential — one whose `scopes` claim carries at least one `resource:action` entry; a claim that is absent, empty, capability-only or wholly unparseable is UNRESTRICTED, and this mark does not apply to it — whatever else that entry says: it is not a ceiling and it is not always-available, its `denied.reason` says why and `denied.until` names the ticket that lifts it, and the absence of `requires` on it must NEVER be read as 'no requirement'. Only an entry in `commands` with an empty `requires` means always-available.",
5
5
  "commands": {
6
6
  "login": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@datarails-ai/dr-cli",
3
- "version": "1.6.97",
3
+ "version": "1.6.98",
4
4
  "description": "Datarails CLI",
5
5
  "type": "module",
6
6
  "engines": {
@@ -17,7 +17,7 @@
17
17
  "access": "public"
18
18
  },
19
19
  "dependencies": {
20
- "@datarails-ai/dr-appspace-cli": "0.10.20",
20
+ "@datarails-ai/dr-appspace-cli": "0.10.21",
21
21
  "@modelcontextprotocol/sdk": "^1.29.0",
22
22
  "@napi-rs/keyring": "^1.3.0",
23
23
  "commander": "^12.1.0",