atris 3.66.0 → 3.68.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.
@@ -0,0 +1,363 @@
1
+ 'use strict';
2
+
3
+ // Agent clients: an agent signs itself in with a client id and secret
4
+ // (OAuth client_credentials) instead of borrowing a person's login.
5
+ //
6
+ // The owner creates the client once with `atris agent-client create`. The
7
+ // agent runs `atris login --client-id atc_... --client-secret-file <path>`
8
+ // (or sets ATRIS_CLIENT_ID + ATRIS_CLIENT_SECRET / ATRIS_CLIENT_SECRET_FILE).
9
+ // There is no refresh token: when the short token runs out, the CLI asks
10
+ // again with the secret. The secret and tokens never reach stdout, stderr,
11
+ // or error text.
12
+
13
+ const crypto = require('crypto');
14
+ const fs = require('fs');
15
+ const path = require('path');
16
+ const { stateHome } = require('../lib/state-home');
17
+ const { getApiBaseUrl, httpRequest, DEFAULT_CLIENT_ID, DEFAULT_USER_AGENT } = require('./api');
18
+
19
+ const AUTH_TYPE = 'agent_client';
20
+ const CLIENT_ID_PREFIX = 'atc_';
21
+ const RENEW_BEFORE_EXPIRY_MS = 60 * 1000;
22
+
23
+ function privateDir(dir) {
24
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
25
+ try { fs.chmodSync(dir, 0o700); } catch {}
26
+ return dir;
27
+ }
28
+
29
+ function clientsDir() {
30
+ return privateDir(path.join(privateDir(path.join(stateHome(), '.atris')), 'agent-clients'));
31
+ }
32
+
33
+ function safeClientId(clientId) {
34
+ return String(clientId).replace(/[^a-zA-Z0-9_-]/g, '-').slice(0, 96);
35
+ }
36
+
37
+ // Saved agent client logins live as a profile named after the client id.
38
+ function profileNameForClient(clientId) {
39
+ return safeClientId(clientId);
40
+ }
41
+
42
+ function tokenCachePath(clientId) {
43
+ return path.join(clientsDir(), `${safeClientId(clientId)}.token.json`);
44
+ }
45
+
46
+ function managedSecretPath(clientId) {
47
+ return path.join(clientsDir(), `${safeClientId(clientId)}.secret`);
48
+ }
49
+
50
+ // Write through a private temp file and rename over the target, so a second
51
+ // CLI process never reads half a file and the content is never world readable.
52
+ function writeFileAtomic(filePath, content) {
53
+ const dir = path.dirname(filePath);
54
+ const tmp = path.join(dir, `.${path.basename(filePath)}.${process.pid}.${crypto.randomBytes(6).toString('hex')}.tmp`);
55
+ try {
56
+ fs.writeFileSync(tmp, content, { mode: 0o600, flag: 'wx' });
57
+ fs.chmodSync(tmp, 0o600);
58
+ fs.renameSync(tmp, filePath);
59
+ } catch (error) {
60
+ try { fs.unlinkSync(tmp); } catch {}
61
+ throw error;
62
+ }
63
+ try { fs.chmodSync(filePath, 0o600); } catch {}
64
+ }
65
+
66
+ function isAgentClientCredential(credentials) {
67
+ return Boolean(credentials
68
+ && credentials.auth_type === AUTH_TYPE
69
+ && typeof credentials.client_id === 'string'
70
+ && credentials.client_id.trim());
71
+ }
72
+
73
+ function scopeList(scope) {
74
+ if (Array.isArray(scope)) return scope.map(String).filter(Boolean);
75
+ if (typeof scope !== 'string') return [];
76
+ return scope.split(/[\s,]+/).map((part) => part.trim()).filter(Boolean);
77
+ }
78
+
79
+ function readTokenCache(clientId) {
80
+ try {
81
+ const parsed = JSON.parse(fs.readFileSync(tokenCachePath(clientId), 'utf8'));
82
+ if (!parsed || parsed.client_id !== clientId) return null;
83
+ if (typeof parsed.access_token !== 'string' || !parsed.access_token) return null;
84
+ const expiresMs = Date.parse(parsed.expires_at);
85
+ if (!Number.isFinite(expiresMs)) return null;
86
+ return { ...parsed, expires_ms: expiresMs };
87
+ } catch {
88
+ return null;
89
+ }
90
+ }
91
+
92
+ function writeTokenCache(clientId, entry) {
93
+ writeFileAtomic(tokenCachePath(clientId), JSON.stringify({ client_id: clientId, ...entry }, null, 2));
94
+ }
95
+
96
+ function deleteAgentClientFiles(clientId) {
97
+ if (!clientId) return;
98
+ for (const filePath of [tokenCachePath(clientId), managedSecretPath(clientId)]) {
99
+ try { fs.unlinkSync(filePath); } catch {}
100
+ }
101
+ }
102
+
103
+ // The agent client named by env, if any. ATRIS_TOKEN still wins over this
104
+ // (readCredentials checks it first), so existing headless boxes are unchanged.
105
+ function envAgentClient(env = process.env) {
106
+ const clientId = typeof env.ATRIS_CLIENT_ID === 'string' ? env.ATRIS_CLIENT_ID.trim() : '';
107
+ if (!clientId) return null;
108
+ const secretFile = typeof env.ATRIS_CLIENT_SECRET_FILE === 'string' ? env.ATRIS_CLIENT_SECRET_FILE.trim() : '';
109
+ return {
110
+ auth_type: AUTH_TYPE,
111
+ client_id: clientId,
112
+ client_secret_file: secretFile ? path.resolve(secretFile) : null,
113
+ client_secret_env: secretFile ? null : 'ATRIS_CLIENT_SECRET',
114
+ scope: null,
115
+ provider: AUTH_TYPE,
116
+ email: null,
117
+ user_id: null,
118
+ from_env: true,
119
+ };
120
+ }
121
+
122
+ // Attach the cached token (if any) so sync readers still see `token`.
123
+ function withCachedToken(stored) {
124
+ const cache = readTokenCache(stored.client_id);
125
+ return {
126
+ ...stored,
127
+ provider: stored.provider || AUTH_TYPE,
128
+ source: AUTH_TYPE,
129
+ token: cache ? cache.access_token : null,
130
+ token_expires_at: cache ? cache.expires_at : null,
131
+ scopes: cache ? scopeList(cache.scope) : scopeList(stored.scope),
132
+ };
133
+ }
134
+
135
+ function needsRenewal(credentials, now = Date.now()) {
136
+ if (!credentials || !credentials.token) return true;
137
+ const expiresMs = Date.parse(credentials.token_expires_at);
138
+ if (!Number.isFinite(expiresMs)) return true;
139
+ return expiresMs - now <= RENEW_BEFORE_EXPIRY_MS;
140
+ }
141
+
142
+ class AgentClientError extends Error {
143
+ constructor(message, code, status) {
144
+ super(message);
145
+ this.code = code;
146
+ this.status = status || 0;
147
+ }
148
+ }
149
+
150
+ function readClientSecret(client, env = process.env) {
151
+ if (client.client_secret_file) {
152
+ let raw;
153
+ try {
154
+ raw = fs.readFileSync(client.client_secret_file, 'utf8');
155
+ } catch (error) {
156
+ throw new AgentClientError(`could not read the client secret file ${client.client_secret_file}${error.code ? ` (${error.code})` : ''}`, 'secret_missing');
157
+ }
158
+ const secret = raw.trim();
159
+ if (!secret) throw new AgentClientError(`the client secret file ${client.client_secret_file} is empty`, 'secret_missing');
160
+ return secret;
161
+ }
162
+ const envName = client.client_secret_env || 'ATRIS_CLIENT_SECRET';
163
+ const secret = typeof env[envName] === 'string' ? env[envName].trim() : '';
164
+ if (!secret) {
165
+ throw new AgentClientError('no client secret found. set ATRIS_CLIENT_SECRET or ATRIS_CLIENT_SECRET_FILE, or run atris login --client-id <id> --client-secret-file <path>', 'secret_missing');
166
+ }
167
+ return secret;
168
+ }
169
+
170
+ function getOAuthTokenUrl() {
171
+ // The token endpoint lives at the server root, not under /api.
172
+ return `${getApiBaseUrl().replace(/\/api$/, '')}/oauth/token`;
173
+ }
174
+
175
+ function scrub(text, secrets) {
176
+ let out = String(text == null ? '' : text);
177
+ for (const secret of secrets) {
178
+ if (secret) out = out.split(secret).join('[redacted]');
179
+ }
180
+ // Belt and braces: anything shaped like a client secret or a JWT goes too.
181
+ return out
182
+ .replace(/atcs_[A-Za-z0-9_\-.~+/=]+/g, '[redacted]')
183
+ .replace(/eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]*/g, '[redacted]')
184
+ .replace(/\s+/g, ' ')
185
+ .trim()
186
+ .slice(0, 300);
187
+ }
188
+
189
+ // POST /oauth/token with grant_type=client_credentials (client_secret_post).
190
+ async function requestClientToken({ clientId, secret, scope }, deps = {}) {
191
+ const request = deps.httpRequest || httpRequest;
192
+ const params = new URLSearchParams({
193
+ grant_type: 'client_credentials',
194
+ client_id: clientId,
195
+ client_secret: secret,
196
+ });
197
+ const wantedScope = scopeList(scope).join(' ');
198
+ if (wantedScope) params.set('scope', wantedScope);
199
+ const body = params.toString();
200
+
201
+ let result;
202
+ try {
203
+ result = await request(getOAuthTokenUrl(), {
204
+ method: 'POST',
205
+ headers: {
206
+ 'Content-Type': 'application/x-www-form-urlencoded',
207
+ Accept: 'application/json',
208
+ 'User-Agent': DEFAULT_USER_AGENT,
209
+ 'X-Atris-Client': DEFAULT_CLIENT_ID,
210
+ },
211
+ body,
212
+ timeoutMs: 30000,
213
+ });
214
+ } catch (error) {
215
+ throw new AgentClientError(`could not reach the token endpoint: ${scrub(error && error.message, [secret])}`, 'network');
216
+ }
217
+
218
+ const text = Buffer.isBuffer(result.body) ? result.body.toString('utf8') : String(result.body || '');
219
+ let data = null;
220
+ try { data = text ? JSON.parse(text) : null; } catch { data = null; }
221
+ const status = result.status || 0;
222
+
223
+ if (status < 200 || status >= 300 || !data || typeof data.access_token !== 'string' || !data.access_token) {
224
+ const code = (data && typeof data.error === 'string' && data.error) || (status >= 200 && status < 300 ? 'missing_token' : 'token_request_failed');
225
+ const leaked = data && typeof data.access_token === 'string' ? data.access_token : null;
226
+ const detail = data && (data.error_description || data.detail || data.message);
227
+ const parts = [`agent client sign-in failed (${status || 'no response'}${code ? `, ${code}` : ''})`];
228
+ if (detail) parts.push(scrub(typeof detail === 'string' ? detail : JSON.stringify(detail), [secret, leaked]));
229
+ throw new AgentClientError(parts.join(': '), code, status);
230
+ }
231
+
232
+ const expiresIn = Number(data.expires_in);
233
+ const ttlMs = Number.isFinite(expiresIn) && expiresIn > 0 ? expiresIn * 1000 : 0;
234
+ const now = deps.now ? deps.now() : Date.now();
235
+ return {
236
+ access_token: data.access_token,
237
+ token_type: data.token_type || 'Bearer',
238
+ scope: typeof data.scope === 'string' ? data.scope : wantedScope,
239
+ expires_at: new Date(now + ttlMs).toISOString(),
240
+ obtained_at: new Date(now).toISOString(),
241
+ };
242
+ }
243
+
244
+ // Fetch a new token for this client, cache it, and return the credential with
245
+ // the new token attached.
246
+ async function renewAgentClientToken(credentials, deps = {}) {
247
+ const secret = readClientSecret(credentials, deps.env || process.env);
248
+ const entry = await requestClientToken({
249
+ clientId: credentials.client_id,
250
+ secret,
251
+ scope: credentials.scope,
252
+ }, deps);
253
+ writeTokenCache(credentials.client_id, entry);
254
+ return {
255
+ ...credentials,
256
+ source: AUTH_TYPE,
257
+ token: entry.access_token,
258
+ token_expires_at: entry.expires_at,
259
+ scopes: scopeList(entry.scope),
260
+ };
261
+ }
262
+
263
+ // Before a call: renew when the token is missing or within 60s of expiry.
264
+ // Another process may have renewed already, so look at the cache first.
265
+ async function ensureAgentClientToken(credentials, deps = {}) {
266
+ if (!needsRenewal(credentials)) return credentials;
267
+ const cached = withCachedToken(credentials);
268
+ if (!needsRenewal(cached)) return cached;
269
+ return renewAgentClientToken(credentials, deps);
270
+ }
271
+
272
+ // Which cached client (if any) issued this exact token. Cheap: one small
273
+ // directory, usually holding zero or one file.
274
+ function cachedClientIdForToken(token) {
275
+ if (!token) return null;
276
+ let dir;
277
+ try {
278
+ dir = path.join(stateHome(), '.atris', 'agent-clients');
279
+ if (!fs.existsSync(dir)) return null;
280
+ } catch {
281
+ return null;
282
+ }
283
+ let names;
284
+ try { names = fs.readdirSync(dir); } catch { return null; }
285
+ for (const name of names) {
286
+ if (!name.endsWith('.token.json')) continue;
287
+ try {
288
+ const parsed = JSON.parse(fs.readFileSync(path.join(dir, name), 'utf8'));
289
+ if (parsed && parsed.access_token === token && typeof parsed.client_id === 'string') return parsed.client_id;
290
+ } catch {}
291
+ }
292
+ return null;
293
+ }
294
+
295
+ // The active agent client credential for this token, or null when the token
296
+ // belongs to anything else (a person's login, an API key, ATRIS_TOKEN).
297
+ function activeClientForToken(token, readActive) {
298
+ const clientId = cachedClientIdForToken(token);
299
+ if (!clientId) return null;
300
+ const active = readActive();
301
+ if (!isAgentClientCredential(active) || active.client_id !== clientId) return null;
302
+ return active;
303
+ }
304
+
305
+ function defaultReadActive() {
306
+ return require('./auth').readActiveCredentials();
307
+ }
308
+
309
+ // Used by the shared API helper right before it sets the Authorization header.
310
+ // Never throws: on any problem the original token goes out unchanged.
311
+ async function freshTokenFor(token, deps = {}) {
312
+ try {
313
+ const active = activeClientForToken(token, deps.readActive || defaultReadActive);
314
+ if (!active || active.token !== token || !needsRenewal(active)) return token;
315
+ const renewed = await renewAgentClientToken(active, deps);
316
+ return renewed.token || token;
317
+ } catch {
318
+ return token;
319
+ }
320
+ }
321
+
322
+ // Used by the shared API helper after a 401. Returns a different token to
323
+ // retry with once, or null. Never throws.
324
+ async function tokenAfterUnauthorized(token, deps = {}) {
325
+ try {
326
+ const active = activeClientForToken(token, deps.readActive || defaultReadActive);
327
+ if (!active) return null;
328
+ if (active.token && active.token !== token && !needsRenewal(active)) {
329
+ // Another process already renewed; use its token.
330
+ return active.token;
331
+ }
332
+ const renewed = await renewAgentClientToken(active, deps);
333
+ return renewed.token && renewed.token !== token ? renewed.token : null;
334
+ } catch {
335
+ return null;
336
+ }
337
+ }
338
+
339
+ module.exports = {
340
+ AUTH_TYPE,
341
+ CLIENT_ID_PREFIX,
342
+ AgentClientError,
343
+ isAgentClientCredential,
344
+ envAgentClient,
345
+ withCachedToken,
346
+ needsRenewal,
347
+ readClientSecret,
348
+ requestClientToken,
349
+ renewAgentClientToken,
350
+ ensureAgentClientToken,
351
+ freshTokenFor,
352
+ tokenAfterUnauthorized,
353
+ writeFileAtomic,
354
+ writeTokenCache,
355
+ readTokenCache,
356
+ managedSecretPath,
357
+ profileNameForClient,
358
+ tokenCachePath,
359
+ deleteAgentClientFiles,
360
+ scopeList,
361
+ scrub,
362
+ getOAuthTokenUrl,
363
+ };
package/utils/api.js CHANGED
@@ -118,11 +118,21 @@ function httpRequest(urlString, options) {
118
118
  });
119
119
  }
120
120
 
121
+ // Agent client tokens are short and renewed by asking again with the secret.
122
+ // Loaded lazily: ./agent-client requires this file.
123
+ function agentClientRenewal() {
124
+ return require('./agent-client');
125
+ }
126
+
121
127
  async function apiRequestJson(pathname, options = {}) {
122
128
  const url = buildApiUrl(pathname);
123
129
  const headers = { ...(options.headers || {}) };
124
- if (options.token) {
125
- headers.Authorization = `Bearer ${options.token}`;
130
+ let authToken = options.token;
131
+ if (authToken) {
132
+ // Renew first if this is the active agent client's token and it is
133
+ // missing or within 60 seconds of expiry. Any other token is untouched.
134
+ authToken = await agentClientRenewal().freshTokenFor(authToken);
135
+ headers.Authorization = `Bearer ${authToken}`;
126
136
  }
127
137
  if (!headers['User-Agent'] && !headers['user-agent']) {
128
138
  headers['User-Agent'] = DEFAULT_USER_AGENT;
@@ -167,6 +177,14 @@ async function apiRequestJson(pathname, options = {}) {
167
177
 
168
178
  const ok = result.status >= 200 && result.status < 300;
169
179
 
180
+ // A 401 on an agent client token: fetch a new token once, retry once.
181
+ if (result.status === 401 && authToken && !options.agentClientRetried) {
182
+ const renewed = await agentClientRenewal().tokenAfterUnauthorized(authToken);
183
+ if (renewed) {
184
+ return apiRequestJson(pathname, { ...options, token: renewed, agentClientRetried: true });
185
+ }
186
+ }
187
+
170
188
  // Retry on transient server errors
171
189
  if (!ok && retryableStatus.has(result.status) && attempt < maxRetries) {
172
190
  await new Promise(r => setTimeout(r, 1000 * (attempt + 1)));
package/utils/auth.js CHANGED
@@ -4,6 +4,7 @@ const { stateHome } = require('../lib/state-home');
4
4
  const fs = require('fs');
5
5
  const { exec } = require('child_process');
6
6
  const readline = require('readline');
7
+ const agentClient = require('./agent-client');
7
8
 
8
9
  /**
9
10
  * Open a URL in the system's default browser.
@@ -395,10 +396,43 @@ function listProfiles() {
395
396
  }
396
397
  }
397
398
 
399
+ // A saved agent client profile holds only the client id and the secret file
400
+ // path; attach its cached token like the credentials.json path does.
401
+ function activeProfile(profile) {
402
+ return agentClient.isAgentClientCredential(profile) ? agentClient.withCachedToken(profile) : profile;
403
+ }
404
+
405
+ function readStoredCredentialsFile() {
406
+ try {
407
+ return JSON.parse(fs.readFileSync(getCredentialsPath(), 'utf8'));
408
+ } catch {
409
+ return null;
410
+ }
411
+ }
412
+
413
+ // Before anything overwrites credentials.json, keep an agent client login as
414
+ // a profile named after its client id, so `atris switch <client_id>` brings
415
+ // it back with its token cache and secret file intact. Returns the name.
416
+ function preserveAgentClientLogin() {
417
+ const stored = readStoredCredentialsFile();
418
+ if (!agentClient.isAgentClientCredential(stored)) return null;
419
+ const name = agentClient.profileNameForClient(stored.client_id);
420
+ saveProfile(name, stored);
421
+ return name;
422
+ }
423
+
398
424
  function deleteProfile(name) {
399
425
  const profilePath = path.join(getProfilesDir(), `${name}.json`);
400
426
  if (fs.existsSync(profilePath)) {
427
+ const profile = loadProfile(name);
401
428
  fs.unlinkSync(profilePath);
429
+ if (agentClient.isAgentClientCredential(profile)) {
430
+ // Last reference to this client gone: drop its token cache and secret copy.
431
+ const stored = readStoredCredentialsFile();
432
+ if (!agentClient.isAgentClientCredential(stored) || stored.client_id !== profile.client_id) {
433
+ agentClient.deleteAgentClientFiles(profile.client_id);
434
+ }
435
+ }
402
436
  return true;
403
437
  }
404
438
  return false;
@@ -415,6 +449,7 @@ function saveCredentials(token, refreshToken, email, userId, provider, extras =
415
449
  if (decodeJwtClaims(token)?.type === 'agent_access') {
416
450
  throw new Error('Refusing to save a scoped agent token as the login token; keep it under agent_token');
417
451
  }
452
+ preserveAgentClientLogin();
418
453
  const credentialsPath = getCredentialsPath();
419
454
  const credentials = {
420
455
  ...extras,
@@ -436,6 +471,11 @@ function saveCredentials(token, refreshToken, email, userId, provider, extras =
436
471
  // Supplying the auth client enables asynchronous repair. Local-only readers stay synchronous.
437
472
  function loadCredentials(apiRequestJson) {
438
473
  const credentials = readCredentials();
474
+ if (agentClient.isAgentClientCredential(credentials)) {
475
+ // Given the API helper, renew a missing or nearly expired token first.
476
+ if (!apiRequestJson || !agentClient.needsRenewal(credentials)) return credentials;
477
+ return agentClient.ensureAgentClientToken(credentials).catch(() => credentials);
478
+ }
439
479
  if (!credentials || credentials.source || decodeJwtClaims(credentials.token)?.type !== 'agent_access') {
440
480
  return credentials;
441
481
  }
@@ -502,6 +542,13 @@ function readCredentials() {
502
542
  return { token: normalizedEnvToken, provider: null, source: 'env' };
503
543
  }
504
544
 
545
+ // 0b. Agent client named by env (ATRIS_CLIENT_ID + secret). ATRIS_TOKEN
546
+ // above still wins, so boxes that inject a raw token are unchanged.
547
+ const envClient = agentClient.envAgentClient();
548
+ if (envClient) {
549
+ return agentClient.withCachedToken(envClient);
550
+ }
551
+
505
552
  // 1. Backend-placed key for per-user cloud computers. Missing, malformed,
506
553
  // incomplete, and expired files are ignored so later sources keep their
507
554
  // existing order.
@@ -513,7 +560,7 @@ function readCredentials() {
513
560
  const profileOverride = process.env.ATRIS_PROFILE;
514
561
  if (profileOverride) {
515
562
  const profile = loadProfile(profileOverride);
516
- if (profile) return { ...profile, source_profile: profileOverride };
563
+ if (profile) return { ...activeProfile(profile), source_profile: profileOverride };
517
564
  const profiles = listProfiles();
518
565
  const q = profileOverride.toLowerCase();
519
566
  const match = profiles.find(p => p.toLowerCase() === q)
@@ -521,7 +568,7 @@ function readCredentials() {
521
568
  || profiles.find(p => p.toLowerCase().includes(q));
522
569
  if (match) {
523
570
  const matched = loadProfile(match);
524
- if (matched) return { ...matched, source_profile: match };
571
+ if (matched) return { ...activeProfile(matched), source_profile: match };
525
572
  }
526
573
  }
527
574
 
@@ -529,7 +576,7 @@ function readCredentials() {
529
576
  const sessionProfile = getSessionProfile();
530
577
  if (sessionProfile) {
531
578
  const profile = loadProfile(sessionProfile);
532
- if (profile) return { ...profile, source_profile: sessionProfile };
579
+ if (profile) return { ...activeProfile(profile), source_profile: sessionProfile };
533
580
  }
534
581
 
535
582
  // 4. Global credentials.json
@@ -542,6 +589,10 @@ function readCredentials() {
542
589
  try {
543
590
  const data = fs.readFileSync(credentialsPath, 'utf8');
544
591
  const parsed = JSON.parse(data);
592
+ if (agentClient.isAgentClientCredential(parsed)) {
593
+ // Saved by `atris login --client-id`. The token lives in its own cache.
594
+ return agentClient.withCachedToken(parsed);
595
+ }
545
596
  if (!parsed.provider) {
546
597
  parsed.provider = null;
547
598
  }
@@ -554,9 +605,32 @@ function readCredentials() {
554
605
  }
555
606
  }
556
607
 
608
+ // Save an agent client as the active login. The secret itself is never
609
+ // written here, only the file that holds it. Atomic, owner-only.
610
+ function saveAgentClientCredentials(record) {
611
+ const credentialsPath = getCredentialsPath();
612
+ agentClient.writeFileAtomic(credentialsPath, JSON.stringify({
613
+ auth_type: agentClient.AUTH_TYPE,
614
+ client_id: record.client_id,
615
+ client_secret_file: record.client_secret_file,
616
+ scope: record.scope || null,
617
+ provider: agentClient.AUTH_TYPE,
618
+ email: null,
619
+ user_id: null,
620
+ saved_at: new Date().toISOString(),
621
+ }, null, 2));
622
+ }
623
+
557
624
  function deleteCredentials() {
558
625
  const credentialsPath = getCredentialsPath();
559
626
 
627
+ const stored = readStoredCredentialsFile();
628
+ if (agentClient.isAgentClientCredential(stored)
629
+ && !loadProfile(agentClient.profileNameForClient(stored.client_id))) {
630
+ // No saved profile still points at this client, so its files go too.
631
+ agentClient.deleteAgentClientFiles(stored.client_id);
632
+ }
633
+
560
634
  if (fs.existsSync(credentialsPath)) {
561
635
  fs.unlinkSync(credentialsPath);
562
636
  }
@@ -709,6 +783,14 @@ async function performTokenRefresh(credentials, apiRequestJson) {
709
783
 
710
784
  async function ensureValidCredentials(apiRequestJson, options = {}) {
711
785
  let credentials = await loadCredentials(apiRequestJson);
786
+ if (agentClient.isAgentClientCredential(credentials)) {
787
+ try {
788
+ const fresh = await agentClient.ensureAgentClientToken(credentials);
789
+ return { credentials: fresh, user: null, source: 'agent_client' };
790
+ } catch (error) {
791
+ return { error: 'token_invalid', detail: agentClient.scrub(error && error.message, []) };
792
+ }
793
+ }
712
794
  if (!credentials || !credentials.token) {
713
795
  return { error: 'not_logged_in' };
714
796
  }
@@ -860,8 +942,11 @@ module.exports = {
860
942
  shouldRefreshToken,
861
943
  getCredentialsPath,
862
944
  saveCredentials,
945
+ saveAgentClientCredentials,
863
946
  loadCredentials,
947
+ readActiveCredentials: readCredentials,
864
948
  deleteCredentials,
949
+ preserveAgentClientLogin,
865
950
  openBrowser,
866
951
  promptUser,
867
952
  validateAccessToken,