@usearete/sdk 0.33.0 → 0.35.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
@@ -45,9 +45,14 @@ const browser = await createSession(
45
45
  );
46
46
  ```
47
47
 
48
- Outside a browser, when no `auth` option is set, the SDK reads `ARETE_API_KEY`
49
- itself, so `createSession({ stacks })` is enough on a server with that variable
50
- set. `secretKey` throws in a browser, as does a secret or agent key passed as
48
+ Outside a browser, when no `auth` option is set, the SDK uses `ARETE_API_KEY`
49
+ if it is set, and otherwise the agent or secret key from your `a4` login
50
+ (`a4 auth login`, `a4 auth signup` or `a4 init`). So `createSession({ stacks })`
51
+ is enough in a script on a machine where you are logged in with `a4`. The login
52
+ is read in Node (20.16+ / 22.3+), Bun and Deno (only with read permission
53
+ already granted), never in a browser, and browser bundles contain no Node
54
+ imports for it. If several `a4` profiles hold a key, set `ARETE_PROFILE` to pick
55
+ one. `secretKey` throws in a browser, as does a secret or agent key passed as
51
56
  `publishableKey`; a publishable key passed as `secretKey` is refused everywhere.
52
57
  Keys are never included in error messages or logs.
53
58
 
package/dist/index.cjs CHANGED
@@ -1043,6 +1043,479 @@ const AUTH_ERROR_CODES_BY_WIRE = {
1043
1043
  'stack-version-unknown': 'STACK_VERSION_UNKNOWN',
1044
1044
  };
1045
1045
 
1046
+ /**
1047
+ * The key from the active `a4` CLI login, for servers and local scripts.
1048
+ *
1049
+ * The `a4` CLI stores the keys it logs in with in a TOML credentials file
1050
+ * (`~/.arete/credentials.toml`, or `ARETE_CREDENTIALS_PATH`), keyed by profile
1051
+ * and API URL. This module mirrors the CLI's lookup (the Rust SDK's
1052
+ * `credentials` module is the reference):
1053
+ *
1054
+ * - Profile: `.arete/auth.toml` in the working directory (may pin only the
1055
+ * `agent` profile), then `ARETE_PROFILE`, then the single profile holding a
1056
+ * key for the API URL. More than one candidate means no key.
1057
+ * - Only the key stored for the default Arete API is used, because that is
1058
+ * where the SDK's default token endpoint sends it. Keys the CLI stored for
1059
+ * another API URL are never sent anywhere else.
1060
+ * - Any problem (no file, unreadable, malformed, ambiguous) means no key.
1061
+ *
1062
+ * Browser safety: nothing here is imported statically from Node. Node builtins
1063
+ * are reached at call time through `process.getBuiltinModule` (Node 20.16+,
1064
+ * 22.3+, Bun), Deno through its own permission-checked APIs, so bundlers see no
1065
+ * `fs` import. Callers must not call this in a browser; `resolveAuthConfig`
1066
+ * returns before reaching it there.
1067
+ */
1068
+ /** API URL whose key the SDK may use: its default token endpoint's origin. */
1069
+ const DEFAULT_A4_API_URL = 'https://api.arete.run';
1070
+ /**
1071
+ * True when `url` is on the API the `a4` login key was stored for. A key
1072
+ * found in the login is only ever sent there, never to an endpoint a stack
1073
+ * names on another host.
1074
+ */
1075
+ function isA4LoginKeyDestination(url) {
1076
+ try {
1077
+ const parsed = new URL(url);
1078
+ const host = parsed.hostname.toLowerCase().replace(/\.+$/, '');
1079
+ return parsed.protocol === 'https:'
1080
+ && host === new URL(DEFAULT_A4_API_URL).hostname
1081
+ && (parsed.port === '' || parsed.port === '443')
1082
+ && parsed.username === ''
1083
+ && parsed.password === '';
1084
+ }
1085
+ catch {
1086
+ return false;
1087
+ }
1088
+ }
1089
+ const PROFILE_ENV = 'ARETE_PROFILE';
1090
+ const CREDENTIALS_PATH_ENV = 'ARETE_CREDENTIALS_PATH';
1091
+ const AGENT_PROFILE = 'agent';
1092
+ const HUMAN_PROFILE = 'human';
1093
+ // ── Minimal TOML reader ─────────────────────────────────────────────────────
1094
+ //
1095
+ // Covers what the CLI writes (`[a.b."c"]` tables and `key = "string"`
1096
+ // pairs) plus one-line scalars. Anything else (arrays, inline tables,
1097
+ // multi-line strings) makes the whole document unreadable, which means
1098
+ // "no key" rather than a guess.
1099
+ /** A one-line non-string value (number, boolean, date); its text is unused. */
1100
+ class TomlScalar {
1101
+ constructor(text) {
1102
+ this.text = text;
1103
+ }
1104
+ }
1105
+ class TomlUnsupported extends Error {
1106
+ }
1107
+ function isTable(value) {
1108
+ return typeof value === 'object' && !(value instanceof TomlScalar);
1109
+ }
1110
+ function readBasicString(line, start) {
1111
+ let out = '';
1112
+ let index = start + 1;
1113
+ while (index < line.length) {
1114
+ const char = line[index];
1115
+ if (char === '"')
1116
+ return [out, index + 1];
1117
+ if (char === '\\') {
1118
+ const next = line[index + 1];
1119
+ const simple = {
1120
+ b: '\b', t: '\t', n: '\n', f: '\f', r: '\r', '"': '"', '\\': '\\',
1121
+ };
1122
+ if (next !== undefined && simple[next] !== undefined) {
1123
+ out += simple[next];
1124
+ index += 2;
1125
+ continue;
1126
+ }
1127
+ if (next === 'u' || next === 'U') {
1128
+ const length = next === 'u' ? 4 : 8;
1129
+ const hex = line.slice(index + 2, index + 2 + length);
1130
+ if (!/^[0-9a-fA-F]+$/.test(hex) || hex.length !== length)
1131
+ throw new TomlUnsupported();
1132
+ out += String.fromCodePoint(parseInt(hex, 16));
1133
+ index += 2 + length;
1134
+ continue;
1135
+ }
1136
+ throw new TomlUnsupported();
1137
+ }
1138
+ out += char;
1139
+ index += 1;
1140
+ }
1141
+ throw new TomlUnsupported();
1142
+ }
1143
+ function readLiteralString(line, start) {
1144
+ const end = line.indexOf("'", start + 1);
1145
+ if (end < 0)
1146
+ throw new TomlUnsupported();
1147
+ return [line.slice(start + 1, end), end + 1];
1148
+ }
1149
+ function skipSpaces(line, index) {
1150
+ while (index < line.length && (line[index] === ' ' || line[index] === '\t'))
1151
+ index += 1;
1152
+ return index;
1153
+ }
1154
+ /** Read a dotted key (`a."b".c`) ending at `terminator`. */
1155
+ function readKey(line, start, terminator) {
1156
+ const parts = [];
1157
+ let index = skipSpaces(line, start);
1158
+ for (;;) {
1159
+ let part;
1160
+ if (line[index] === '"') {
1161
+ [part, index] = readBasicString(line, index);
1162
+ }
1163
+ else if (line[index] === "'") {
1164
+ [part, index] = readLiteralString(line, index);
1165
+ }
1166
+ else {
1167
+ const match = /^[A-Za-z0-9_-]+/.exec(line.slice(index));
1168
+ if (!match)
1169
+ throw new TomlUnsupported();
1170
+ part = match[0];
1171
+ index += part.length;
1172
+ }
1173
+ parts.push(part);
1174
+ index = skipSpaces(line, index);
1175
+ if (line[index] === '.') {
1176
+ index = skipSpaces(line, index + 1);
1177
+ continue;
1178
+ }
1179
+ if (line.startsWith(terminator, index))
1180
+ return [parts, index + terminator.length];
1181
+ throw new TomlUnsupported();
1182
+ }
1183
+ }
1184
+ function assertRestIsComment(line, index) {
1185
+ const rest = line.slice(index).trim();
1186
+ if (rest !== '' && !rest.startsWith('#'))
1187
+ throw new TomlUnsupported();
1188
+ }
1189
+ function tableAt(root, path) {
1190
+ let table = root;
1191
+ for (const part of path) {
1192
+ const existing = table[part];
1193
+ if (existing === undefined) {
1194
+ const created = Object.create(null);
1195
+ table[part] = created;
1196
+ table = created;
1197
+ }
1198
+ else if (isTable(existing)) {
1199
+ table = existing;
1200
+ }
1201
+ else {
1202
+ throw new TomlUnsupported();
1203
+ }
1204
+ }
1205
+ return table;
1206
+ }
1207
+ /** @internal Parse the subset of TOML described above; throws when unsupported. */
1208
+ function parseSimpleToml(content) {
1209
+ const root = Object.create(null);
1210
+ let current = root;
1211
+ for (const rawLine of content.replace(/^/, '').split(/\r?\n/)) {
1212
+ const line = rawLine.trim();
1213
+ if (line === '' || line.startsWith('#'))
1214
+ continue;
1215
+ if (line.startsWith('[['))
1216
+ throw new TomlUnsupported();
1217
+ if (line.startsWith('[')) {
1218
+ const [path, end] = readKey(line, 1, ']');
1219
+ assertRestIsComment(line, end);
1220
+ current = tableAt(root, path);
1221
+ continue;
1222
+ }
1223
+ const [path, afterEquals] = readKey(line, 0, '=');
1224
+ const valueStart = skipSpaces(line, afterEquals);
1225
+ let value;
1226
+ let end;
1227
+ if (line.startsWith('"""', valueStart) || line.startsWith("'''", valueStart)) {
1228
+ throw new TomlUnsupported();
1229
+ }
1230
+ else if (line[valueStart] === '"') {
1231
+ [value, end] = readBasicString(line, valueStart);
1232
+ }
1233
+ else if (line[valueStart] === "'") {
1234
+ [value, end] = readLiteralString(line, valueStart);
1235
+ }
1236
+ else {
1237
+ const match = /^[A-Za-z0-9_:.+-]+(?:[ T][0-9:.+Z-]+)?/.exec(line.slice(valueStart));
1238
+ if (!match)
1239
+ throw new TomlUnsupported();
1240
+ value = new TomlScalar(match[0]);
1241
+ end = valueStart + match[0].length;
1242
+ }
1243
+ assertRestIsComment(line, end);
1244
+ const key = path[path.length - 1];
1245
+ const table = tableAt(current, path.slice(0, -1));
1246
+ if (Object.prototype.hasOwnProperty.call(table, key))
1247
+ throw new TomlUnsupported();
1248
+ table[key] = value;
1249
+ }
1250
+ return root;
1251
+ }
1252
+ // ── Lookup (mirror of the Rust `lookup_credentials`) ─────────────────────────
1253
+ /** Mirror of the CLI's API URL normalisation for credential lookup. */
1254
+ function normalizeA4ApiUrl(url) {
1255
+ const trimmed = url.trim().replace(/\/+$/, '');
1256
+ const schemeAt = trimmed.indexOf('://');
1257
+ const scheme = schemeAt >= 0 ? trimmed.slice(0, schemeAt).toLowerCase() : '';
1258
+ const rest = schemeAt >= 0 ? trimmed.slice(schemeAt + 3) : trimmed;
1259
+ const authorityEnd = rest.search(/[/?#]/);
1260
+ const authority = authorityEnd >= 0 ? rest.slice(0, authorityEnd) : rest;
1261
+ const tail = authorityEnd >= 0 ? rest.slice(authorityEnd) : '';
1262
+ const hostStart = authority.lastIndexOf('@') + 1;
1263
+ const hostPort = authority.slice(hostStart);
1264
+ let host;
1265
+ let port;
1266
+ if (hostPort.startsWith('[')) {
1267
+ const close = hostPort.indexOf(']');
1268
+ host = close >= 0 ? hostPort.slice(0, close + 1) : hostPort;
1269
+ port = close >= 0 ? hostPort.slice(close + 1) : '';
1270
+ }
1271
+ else {
1272
+ const colon = hostPort.lastIndexOf(':');
1273
+ host = colon >= 0 ? hostPort.slice(0, colon) : hostPort;
1274
+ port = colon >= 0 ? hostPort.slice(colon) : '';
1275
+ }
1276
+ host = host.replace(/\.+$/, '').toLowerCase();
1277
+ return `${scheme ? `${scheme}://` : ''}${authority.slice(0, hostStart)}${host}${port}${tail}`;
1278
+ }
1279
+ function stringMap(value) {
1280
+ if (value === undefined)
1281
+ return undefined;
1282
+ if (!isTable(value))
1283
+ throw new TomlUnsupported();
1284
+ const out = new Map();
1285
+ for (const [key, entry] of Object.entries(value)) {
1286
+ if (typeof entry !== 'string')
1287
+ throw new TomlUnsupported();
1288
+ out.set(key, entry);
1289
+ }
1290
+ return out;
1291
+ }
1292
+ function findUrlKey(keys, apiUrl) {
1293
+ if (!keys)
1294
+ return undefined;
1295
+ const wanted = normalizeA4ApiUrl(apiUrl);
1296
+ const exact = (keys.get(apiUrl) ?? keys.get(wanted))?.trim();
1297
+ if (exact)
1298
+ return exact;
1299
+ return [...keys.entries()]
1300
+ .filter(([url]) => normalizeA4ApiUrl(url) === wanted)
1301
+ .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
1302
+ .map(([, key]) => key.trim())
1303
+ .find((key) => key !== '');
1304
+ }
1305
+ function isValidProfileName(profile) {
1306
+ return profile.length > 0 && profile.length <= 64 && /^[A-Za-z0-9_-]+$/.test(profile);
1307
+ }
1308
+ function keyFitsProfile(profile, key) {
1309
+ const agentKey = key.trim().startsWith('a4_ak_');
1310
+ if (profile === AGENT_PROFILE)
1311
+ return agentKey;
1312
+ if (profile === HUMAN_PROFILE)
1313
+ return !agentKey;
1314
+ return true;
1315
+ }
1316
+ /** @internal Mirror of the Rust `lookup_credentials`. */
1317
+ function lookupA4Credentials(content, apiUrl, requestedProfile) {
1318
+ let profiles;
1319
+ let legacyKeys;
1320
+ let legacyApiKey;
1321
+ try {
1322
+ const parsed = parseSimpleToml(content);
1323
+ const profilesValue = parsed['profiles'];
1324
+ if (profilesValue !== undefined && !isTable(profilesValue))
1325
+ return { kind: 'invalid' };
1326
+ profiles = Object.entries(profilesValue ?? {}).map(([name, profile]) => {
1327
+ if (!isTable(profile))
1328
+ throw new TomlUnsupported();
1329
+ return [name, stringMap(profile['keys'])];
1330
+ });
1331
+ legacyKeys = stringMap(parsed['keys']);
1332
+ const apiKey = parsed['api_key'];
1333
+ if (apiKey !== undefined && typeof apiKey !== 'string')
1334
+ return { kind: 'invalid' };
1335
+ legacyApiKey = apiKey?.trim() || undefined;
1336
+ }
1337
+ catch {
1338
+ return { kind: 'invalid' };
1339
+ }
1340
+ const legacy = findUrlKey(legacyKeys, apiUrl) ?? legacyApiKey;
1341
+ if (requestedProfile !== undefined) {
1342
+ if (!isValidProfileName(requestedProfile))
1343
+ return { kind: 'invalid' };
1344
+ const entry = profiles.find(([name]) => name === requestedProfile);
1345
+ const key = findUrlKey(entry?.[1], apiUrl);
1346
+ if (key !== undefined) {
1347
+ return keyFitsProfile(requestedProfile, key) ? { kind: 'key', key } : { kind: 'invalid' };
1348
+ }
1349
+ if (legacy !== undefined) {
1350
+ const compatible = requestedProfile === AGENT_PROFILE
1351
+ ? legacy.startsWith('a4_ak_')
1352
+ : requestedProfile === HUMAN_PROFILE && !legacy.startsWith('a4_ak_');
1353
+ if (compatible)
1354
+ return { kind: 'key', key: legacy };
1355
+ }
1356
+ return { kind: 'none' };
1357
+ }
1358
+ const matches = profiles
1359
+ .map(([name, keys]) => [name, findUrlKey(keys, apiUrl)])
1360
+ .filter((entry) => entry[1] !== undefined);
1361
+ if (matches.length > 1)
1362
+ return { kind: 'ambiguous' };
1363
+ if (matches.length === 1) {
1364
+ const [name, key] = matches[0];
1365
+ return keyFitsProfile(name, key) ? { kind: 'key', key } : { kind: 'invalid' };
1366
+ }
1367
+ return legacy !== undefined ? { kind: 'key', key: legacy } : { kind: 'none' };
1368
+ }
1369
+ /** Selected profile: `undefined` for none, `null` when selection is invalid. */
1370
+ function selectProfile(host) {
1371
+ // The project file can pin the agent profile; if it cannot be checked
1372
+ // (unknown working directory, denied or failed read), choose nothing rather
1373
+ // than risk a profile the project excludes.
1374
+ const cwd = host.cwd();
1375
+ if (cwd === undefined)
1376
+ return null;
1377
+ let project;
1378
+ try {
1379
+ project = host.readTextFile(host.joinPath(cwd, '.arete', 'auth.toml'));
1380
+ }
1381
+ catch {
1382
+ return null;
1383
+ }
1384
+ if (project !== null) {
1385
+ try {
1386
+ const profile = parseSimpleToml(project)['default_profile'];
1387
+ return profile === AGENT_PROFILE ? AGENT_PROFILE : null;
1388
+ }
1389
+ catch {
1390
+ return null;
1391
+ }
1392
+ }
1393
+ const profile = host.readEnv(PROFILE_ENV)?.trim();
1394
+ if (profile === undefined)
1395
+ return undefined;
1396
+ return isValidProfileName(profile) ? profile : null;
1397
+ }
1398
+ /**
1399
+ * @internal Key of the active `a4` login for the default Arete API, or
1400
+ * `undefined`. Never throws. The caller checks the key class.
1401
+ */
1402
+ function readA4ProfileKey(host = systemProfileHost()) {
1403
+ if (!host)
1404
+ return {};
1405
+ try {
1406
+ const profile = selectProfile(host);
1407
+ if (profile === null)
1408
+ return {};
1409
+ const override = host.readEnv(CREDENTIALS_PATH_ENV);
1410
+ let path;
1411
+ if (override) {
1412
+ path = override;
1413
+ }
1414
+ else {
1415
+ const home = host.homeDir();
1416
+ path = home ? host.joinPath(home, '.arete', 'credentials.toml') : undefined;
1417
+ }
1418
+ if (!path)
1419
+ return {};
1420
+ const content = host.readTextFile(path);
1421
+ if (content === null)
1422
+ return {};
1423
+ const lookup = lookupA4Credentials(content, DEFAULT_A4_API_URL, profile);
1424
+ if (lookup.kind === 'key')
1425
+ return { key: lookup.key };
1426
+ if (lookup.kind === 'ambiguous')
1427
+ return { ambiguous: true };
1428
+ return {};
1429
+ }
1430
+ catch {
1431
+ return {};
1432
+ }
1433
+ }
1434
+ function denoHost(deno) {
1435
+ const granted = (descriptor) => {
1436
+ try {
1437
+ return deno.permissions?.querySync?.(descriptor)?.state === 'granted';
1438
+ }
1439
+ catch {
1440
+ return false;
1441
+ }
1442
+ };
1443
+ const readEnv = (name) => granted({ name: 'env', variable: name }) ? deno.env?.get?.(name) : undefined;
1444
+ const separator = deno.build?.os === 'windows' ? '\\' : '/';
1445
+ return {
1446
+ readEnv,
1447
+ readTextFile(path) {
1448
+ // Without --allow-read Deno would prompt or throw; ask first. Denied
1449
+ // access is not a missing file: callers must not treat it as absent.
1450
+ if (!deno.readTextFileSync)
1451
+ throw new Error('file reads unavailable');
1452
+ if (!granted({ name: 'read', path }))
1453
+ throw new Error('read permission not granted');
1454
+ try {
1455
+ return deno.readTextFileSync(path);
1456
+ }
1457
+ catch (error) {
1458
+ const notFound = deno.errors?.NotFound;
1459
+ if (notFound && error instanceof notFound)
1460
+ return null;
1461
+ throw error;
1462
+ }
1463
+ },
1464
+ cwd() {
1465
+ if (!granted({ name: 'read' }))
1466
+ return undefined;
1467
+ try {
1468
+ return deno.cwd?.();
1469
+ }
1470
+ catch {
1471
+ return undefined;
1472
+ }
1473
+ },
1474
+ homeDir: () => readEnv('HOME') ?? readEnv('USERPROFILE'),
1475
+ joinPath: (...parts) => parts.join(separator),
1476
+ };
1477
+ }
1478
+ function nodeHost(process) {
1479
+ const load = process.getBuiltinModule;
1480
+ if (typeof load !== 'function')
1481
+ return undefined;
1482
+ const fs = load.call(process, 'fs');
1483
+ const os = load.call(process, 'os');
1484
+ const path = load.call(process, 'path');
1485
+ if (!fs || !os || !path)
1486
+ return undefined;
1487
+ return {
1488
+ readEnv: (name) => process.env?.[name],
1489
+ readTextFile(file) {
1490
+ try {
1491
+ return fs.readFileSync(file, 'utf8');
1492
+ }
1493
+ catch (error) {
1494
+ if (error.code === 'ENOENT')
1495
+ return null;
1496
+ throw error;
1497
+ }
1498
+ },
1499
+ cwd: () => process.cwd?.(),
1500
+ homeDir: () => os.homedir(),
1501
+ joinPath: (...parts) => path.join(...parts),
1502
+ };
1503
+ }
1504
+ /** Host for the current server runtime, or `undefined` where files are unreachable. */
1505
+ function systemProfileHost() {
1506
+ try {
1507
+ const scope = globalThis;
1508
+ if (scope.Deno)
1509
+ return denoHost(scope.Deno);
1510
+ if (scope.process)
1511
+ return nodeHost(scope.process);
1512
+ }
1513
+ catch {
1514
+ // Fall through: no profile support in this runtime.
1515
+ }
1516
+ return undefined;
1517
+ }
1518
+
1046
1519
  /** Environment variable read for a secret-class key outside browsers. */
1047
1520
  const ARETE_API_KEY_ENV = 'ARETE_API_KEY';
1048
1521
  const CREATE_PUBLISHABLE_HINT = 'create one with `a4 auth keys create-publishable --origin <scheme://host[:port]>`';
@@ -1092,6 +1565,19 @@ function readEnvironmentVariable(name) {
1092
1565
  }
1093
1566
  }
1094
1567
  const warned$1 = new Set();
1568
+ /**
1569
+ * Marks a resolved config whose `secretKey` came from the `a4` login. It holds
1570
+ * that key, so the mark is carried through object spreads (binding paths copy
1571
+ * the resolved config) and only applies while `secretKey` is still that key:
1572
+ * a copy given another `secretKey` is no longer restricted. Per config, never
1573
+ * process-wide, so the same key passed explicitly elsewhere is unaffected.
1574
+ */
1575
+ const A4_LOGIN_SECRET_KEY = Symbol('arete.a4LoginSecretKey');
1576
+ /** True when `auth.secretKey` was supplied by the `a4` login fallback. */
1577
+ function secretKeyFromA4Login(auth) {
1578
+ const marked = auth;
1579
+ return marked?.secretKey !== undefined && marked[A4_LOGIN_SECRET_KEY] === marked.secretKey;
1580
+ }
1095
1581
  function warnOnce$1(id, message) {
1096
1582
  if (warned$1.has(id))
1097
1583
  return;
@@ -1106,17 +1592,18 @@ function hasExplicitAuth(auth) {
1106
1592
  || auth.secretKey !== undefined);
1107
1593
  }
1108
1594
  /**
1109
- * Validate the configured API keys and apply the `ARETE_API_KEY` fallback.
1595
+ * Validate the configured API keys and apply the server-side credential chain.
1110
1596
  *
1111
1597
  * - `secretKey` is refused in browsers and refuses publishable keys.
1112
1598
  * - A secret-class key in `publishableKey` is refused in browsers and warned
1113
1599
  * about elsewhere (it still works server-side, as it always has).
1114
1600
  * - Outside browsers, when no auth option is set at all, `ARETE_API_KEY`
1115
- * supplies `secretKey`.
1601
+ * supplies `secretKey`; without it, the agent or secret key from the active
1602
+ * `a4` CLI login does. Browsers never read either.
1116
1603
  *
1117
1604
  * Error and warning text never includes key material.
1118
1605
  */
1119
- function resolveAuthConfig(auth) {
1606
+ function resolveAuthConfig(auth, readProfileKey = readA4ProfileKey) {
1120
1607
  const browser = isBrowserEnvironment();
1121
1608
  if (auth?.secretKey !== undefined) {
1122
1609
  if (browser) {
@@ -1147,19 +1634,47 @@ function resolveAuthConfig(auth) {
1147
1634
  if (browser || hasExplicitAuth(auth))
1148
1635
  return auth;
1149
1636
  const environmentKey = readEnvironmentVariable(ARETE_API_KEY_ENV)?.trim();
1150
- if (!environmentKey)
1151
- return auth;
1152
- if (classifyApiKey(environmentKey) === 'publishable') {
1637
+ if (environmentKey) {
1638
+ if (classifyApiKey(environmentKey) !== 'publishable') {
1639
+ return { ...auth, secretKey: environmentKey };
1640
+ }
1153
1641
  warnOnce$1('publishable-in-env', `${ARETE_API_KEY_ENV} holds a publishable key (a4_pk_...) and was ignored. Set it to an agent `
1154
1642
  + 'key (a4_ak_...) or secret key (a4_sk_...), or pass the publishable key as auth.publishableKey.');
1155
- return auth;
1156
1643
  }
1157
- return { ...auth, secretKey: environmentKey };
1644
+ const profile = readProfileKey();
1645
+ if (profile.ambiguous) {
1646
+ warnOnce$1('ambiguous-a4-profile', 'More than one a4 login profile holds a key; not choosing one. Set ARETE_PROFILE '
1647
+ + `(for example \`agent\`) or ${ARETE_API_KEY_ENV}.`);
1648
+ }
1649
+ if (profile.key && classifyApiKey(profile.key) === 'secret') {
1650
+ const resolved = {
1651
+ ...auth,
1652
+ secretKey: profile.key,
1653
+ [A4_LOGIN_SECRET_KEY]: profile.key,
1654
+ };
1655
+ return resolved;
1656
+ }
1657
+ return auth;
1158
1658
  }
1159
- /** The key sent as the token endpoint bearer credential, if any. */
1160
- function tokenEndpointApiKey(auth) {
1659
+ /**
1660
+ * Appended to a 401 for a request that carried no API key. It names the
1661
+ * commands and options that supply one, never where credentials are stored.
1662
+ */
1663
+ const NO_API_KEY_HINT = 'No Arete API key found. Run `a4 auth login` (or `a4 auth signup` for an agent), '
1664
+ + 'or set ARETE_API_KEY, or pass auth.secretKey.';
1665
+ /**
1666
+ * The key sent as the bearer credential to `endpoint`, if any. A key taken
1667
+ * from the `a4` login is only sent to the Arete API it was stored for.
1668
+ */
1669
+ function tokenEndpointApiKey(auth, endpoint) {
1670
+ if (secretKeyFromA4Login(auth) && !isA4LoginKeyDestination(endpoint))
1671
+ return undefined;
1161
1672
  return auth?.secretKey ?? auth?.publishableKey;
1162
1673
  }
1674
+ /** True when `headers` carries its own `Authorization` header. */
1675
+ function hasAuthorizationHeader(headers) {
1676
+ return Object.keys(headers ?? {}).some((name) => name.toLowerCase() === 'authorization');
1677
+ }
1163
1678
 
1164
1679
  const GZIP_MAGIC_0 = 0x1f;
1165
1680
  const GZIP_MAGIC_1 = 0x8b;
@@ -2220,7 +2735,7 @@ class ConnectionManager {
2220
2735
  };
2221
2736
  }
2222
2737
  async fetchTokenFromEndpoint(tokenEndpoint, request) {
2223
- const apiKey = tokenEndpointApiKey(this.authConfig);
2738
+ const apiKey = tokenEndpointApiKey(this.authConfig, tokenEndpoint);
2224
2739
  const response = await this.authFetch(tokenEndpoint, {
2225
2740
  method: 'POST',
2226
2741
  headers: {
@@ -2251,9 +2766,17 @@ class ConnectionManager {
2251
2766
  : response.status === 429
2252
2767
  ? 'QUOTA_EXCEEDED'
2253
2768
  : 'AUTH_REQUIRED';
2254
- const errorMessage = typeof parsedError?.error === 'string' && parsedError.error.length > 0
2769
+ const responseMessage = typeof parsedError?.error === 'string' && parsedError.error.length > 0
2255
2770
  ? parsedError.error
2256
2771
  : rawError || response.statusText || 'Authentication request failed';
2772
+ // A server-side 401 for a request that carried no credential at all:
2773
+ // say how to supply one. Custom Authorization headers have their own
2774
+ // advice to give.
2775
+ const sentCredential = Boolean(apiKey)
2776
+ || hasAuthorizationHeader(this.authConfig?.tokenEndpointHeaders);
2777
+ const errorMessage = response.status === 401 && !sentCredential && !isBrowserEnvironment()
2778
+ ? `${responseMessage}. ${NO_API_KEY_HINT}`
2779
+ : responseMessage;
2257
2780
  const retryAfterHeader = response.headers.get('Retry-After');
2258
2781
  const retryAfterSeconds = retryAfterHeader && /^\d+$/.test(retryAfterHeader)
2259
2782
  ? Number(retryAfterHeader)