atris 3.74.9 → 3.74.10
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/commands/auth.js +4 -3
- package/commands/computer.js +9 -7
- package/commands/context-sync.js +2 -1
- package/commands/integrations.js +2 -1
- package/commands/member.js +3 -3
- package/commands/ninety-nine.js +90 -30
- package/commands/orb.js +1 -1
- package/commands/pull.js +3 -3
- package/commands/push.js +2 -2
- package/commands/skill.js +5 -0
- package/commands/soul.js +2 -1
- package/commands/stream.js +3 -2
- package/commands/strings.js +1 -1
- package/commands/unknowns.js +1 -1
- package/commands/workflow.js +1 -1
- package/commands/zen.js +4 -3
- package/lib/auto-accept-certified.js +2 -3
- package/lib/autoland.js +4 -3
- package/lib/campaign-loop.js +12 -4
- package/lib/card.js +1 -1
- package/lib/chat-log-scan.js +1 -1
- package/lib/first-minute.js +1 -0
- package/lib/gm-bet.js +51 -48
- package/lib/gm-card.js +46 -19
- package/lib/gm-phone-icon.js +80 -0
- package/lib/gm-phone-page.js +803 -96
- package/lib/gm-phone.js +15 -4
- package/lib/journal.js +1 -1
- package/lib/noninteractive.js +5 -1
- package/lib/roster-swap.js +8 -3
- package/lib/task-decision.js +1 -1
- package/lib/zen-apply.js +14 -1
- package/lib/zen.js +37 -3
- package/package.json +1 -1
- package/utils/auth.js +69 -7
package/lib/gm-phone.js
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
const fs = require('fs');
|
|
14
14
|
const path = require('path');
|
|
15
15
|
const crypto = require('crypto');
|
|
16
|
+
const zlib = require('zlib');
|
|
16
17
|
|
|
17
18
|
const CARD_LIMIT = 5;
|
|
18
19
|
const CONFIRM_MS = 5000;
|
|
@@ -378,7 +379,7 @@ function appendLog(logPath, row) {
|
|
|
378
379
|
// loadView() -> view (see loadPhoneView)
|
|
379
380
|
// runStep(args) -> Promise<{ code, last_line }>, one atris command
|
|
380
381
|
// writeTarget(target) -> boolean; snapshotTarget() -> restore fn
|
|
381
|
-
// logPath, now(), page(nonce) receipts, clock, html
|
|
382
|
+
// logPath, now(), page(nonce, first) receipts, clock, html (first: the card on screen now)
|
|
382
383
|
// makeBetDesk(log) -> optional bet desk (lib/gm-bet createBetDesk)
|
|
383
384
|
function createPhoneHandler(deps) {
|
|
384
385
|
const now = deps.now || Date.now;
|
|
@@ -559,7 +560,7 @@ function createPhoneHandler(deps) {
|
|
|
559
560
|
'x-frame-options': 'DENY',
|
|
560
561
|
...extra,
|
|
561
562
|
});
|
|
562
|
-
res.end(typeof body === 'string' ? body : JSON.stringify(body));
|
|
563
|
+
res.end(typeof body === 'string' || Buffer.isBuffer(body) ? body : JSON.stringify(body));
|
|
563
564
|
};
|
|
564
565
|
|
|
565
566
|
async function handle(req, res) {
|
|
@@ -570,8 +571,18 @@ function createPhoneHandler(deps) {
|
|
|
570
571
|
try {
|
|
571
572
|
if (req.method === 'GET' && check.path === '/') {
|
|
572
573
|
const nonce = crypto.randomBytes(16).toString('base64');
|
|
573
|
-
|
|
574
|
-
|
|
574
|
+
// the card on screen now goes in the page itself, so its first paint is that card;
|
|
575
|
+
// a desk that cannot read it right now (starting again) still serves the page, without one, and the page asks
|
|
576
|
+
let first = null;
|
|
577
|
+
try { first = bet ? bet.card() : null; } catch { first = null; }
|
|
578
|
+
const html = deps.page(nonce, first);
|
|
579
|
+
// packed when the phone takes it (every browser does): about a third of the bytes over a weak wifi
|
|
580
|
+
const packed = /\bgzip\b/i.test(String(req.headers['accept-encoding'] || ''));
|
|
581
|
+
return send(res, 200, packed ? zlib.gzipSync(html) : html, 'text/html; charset=utf-8', {
|
|
582
|
+
vary: 'accept-encoding',
|
|
583
|
+
...(packed ? { 'content-encoding': 'gzip' } : {}),
|
|
584
|
+
// img-src data: only for the page's own inline icon; nothing else loads an image
|
|
585
|
+
'content-security-policy': `default-src 'none'; script-src 'nonce-${nonce}'; style-src 'nonce-${nonce}'; img-src data:; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'`,
|
|
575
586
|
});
|
|
576
587
|
}
|
|
577
588
|
if (req.method === 'GET' && check.path === '/state') return send(res, 200, state());
|
package/lib/journal.js
CHANGED
|
@@ -53,7 +53,7 @@ function parseJournalSections(content) {
|
|
|
53
53
|
if (line.startsWith('## ')) {
|
|
54
54
|
// Save previous section
|
|
55
55
|
if (currentContent.length > 0 || currentSection === '__header__') {
|
|
56
|
-
sections[currentSection] = currentContent.join('\n');
|
|
56
|
+
sections[currentSection] = currentContent.concat('').join('\n');
|
|
57
57
|
}
|
|
58
58
|
// Start new section
|
|
59
59
|
currentSection = line.substring(3).trim();
|
package/lib/noninteractive.js
CHANGED
|
@@ -69,7 +69,11 @@ function refuseHeadlessUnless(args, { allowYes = true, allowOnce = false, usage
|
|
|
69
69
|
const lines = [];
|
|
70
70
|
if (usage) lines.push(usage);
|
|
71
71
|
else lines.push('This command needs a terminal, or an explicit proceed flag.');
|
|
72
|
-
|
|
72
|
+
if (allowYes) {
|
|
73
|
+
lines.push('Pass --yes to run headless' + (allowOnce ? ', or --once for a single serve op.' : '.'));
|
|
74
|
+
} else if (allowOnce) {
|
|
75
|
+
lines.push('Pass --once for a single serve op.');
|
|
76
|
+
}
|
|
73
77
|
console.error(lines.join('\n'));
|
|
74
78
|
return true;
|
|
75
79
|
}
|
package/lib/roster-swap.js
CHANGED
|
@@ -259,6 +259,10 @@ function swapMember(root, memberName, engineWords, modelWords, options = {}) {
|
|
|
259
259
|
|
|
260
260
|
// The phone's engine card for one member: what it runs on now, and every
|
|
261
261
|
// model this machine offers that can do the member's work, grouped by tool.
|
|
262
|
+
// A model's last 7 days in the owner's words: the phone's swap card shows it
|
|
263
|
+
// under the model, so it says finished, never the team's own word for it.
|
|
264
|
+
const recordWords = seen => `${seen.landed} of ${seen.runs} finished this week`;
|
|
265
|
+
|
|
262
266
|
// The current model is on top; models with a record in the last 7 days come
|
|
263
267
|
// next, most landed first; the rest keep --available's order.
|
|
264
268
|
function memberEngineChoices(root, memberName, options = {}) {
|
|
@@ -284,7 +288,7 @@ function memberEngineChoices(root, memberName, options = {}) {
|
|
|
284
288
|
model: id ? name : '',
|
|
285
289
|
label: !name ? 'its own model' : id ? modelText(id) || name : name,
|
|
286
290
|
current: row.engine === curEngine && sameModel(row.engine, id, curModel),
|
|
287
|
-
record: seen ?
|
|
291
|
+
record: seen ? recordWords(seen) : null,
|
|
288
292
|
landed: seen ? seen.landed : -1,
|
|
289
293
|
runs: seen ? seen.runs : 0,
|
|
290
294
|
index,
|
|
@@ -313,7 +317,7 @@ function memberEngineChoices(root, memberName, options = {}) {
|
|
|
313
317
|
const mark = (option) => {
|
|
314
318
|
const pin = option.model ? pinFor(option.engine, option.model) : '';
|
|
315
319
|
const seen = record(option.engine, pin);
|
|
316
|
-
return { ...option, current: option.engine === curEngine && sameModel(option.engine, pin, curModel), record: seen ?
|
|
320
|
+
return { ...option, current: option.engine === curEngine && sameModel(option.engine, pin, curModel), record: seen ? recordWords(seen) : null };
|
|
317
321
|
};
|
|
318
322
|
const list = swapList(root, { ...options, offer: { registry, tools, shortlist, shortlistFile } });
|
|
319
323
|
const choices = list.options
|
|
@@ -340,7 +344,8 @@ function swapFromCard(root, memberName, engineWords, modelWords, scope, options
|
|
|
340
344
|
const { resolveEngineForMember } = require('./member-engine');
|
|
341
345
|
const after = resolveEngineForMember(member, root, options);
|
|
342
346
|
const moved = after.engine && after.engine.id === out.engine && sameModel(out.engine, after.model || '', out.model);
|
|
343
|
-
|
|
347
|
+
// the owner reads this: the member has its own pick, so it stays on its own model
|
|
348
|
+
if (!moved && after.pick_source === 'team') return { ...out, line: `${out.line} ${member} keeps its own model.` };
|
|
344
349
|
return out;
|
|
345
350
|
}
|
|
346
351
|
|
package/lib/task-decision.js
CHANGED
|
@@ -23,7 +23,7 @@ function taskTagTokens(task) {
|
|
|
23
23
|
const fromTags = Array.isArray(task.tags) ? task.tags : [];
|
|
24
24
|
const fromTag = task.tag ? [task.tag] : [];
|
|
25
25
|
const fromMeta = task.metadata && Array.isArray(task.metadata.tags) ? task.metadata.tags : [];
|
|
26
|
-
const fromTitle = (String(task.title || '').match(/#([a-z0-
|
|
26
|
+
const fromTitle = (String(task.title || '').match(/#([a-z0-9_-]+)/gi) || []).map((t) => t.slice(1));
|
|
27
27
|
return [...fromTag, ...fromTags, ...fromMeta, ...fromTitle]
|
|
28
28
|
.map(normalizeDecisionTag)
|
|
29
29
|
.filter(Boolean);
|
package/lib/zen-apply.js
CHANGED
|
@@ -295,6 +295,19 @@ function applyNotes(root, patchFile, files) {
|
|
|
295
295
|
return { clashed, missed };
|
|
296
296
|
}
|
|
297
297
|
|
|
298
|
+
// The map files whose code links `atris clean` re-points on every zen run.
|
|
299
|
+
const MAP_FILES = new Set(['atris/MAP.md', 'atris/refs/MAP-NOTES.md']);
|
|
300
|
+
|
|
301
|
+
// A map edit that only moved `file:line` numbers is the link fixer's own work
|
|
302
|
+
// from an earlier run, and the workspace step redoes it, so it is not news.
|
|
303
|
+
function lineRefsOnly(root, from, p, mine) {
|
|
304
|
+
if (!MAP_FILES.has(p) || !fs.existsSync(mine)) return false;
|
|
305
|
+
const was = runGit(['show', `${from}:${p}`], root);
|
|
306
|
+
if (was.status !== 0) return false;
|
|
307
|
+
const bare = (text) => String(text).replace(/([\w./-]+\.[A-Za-z]+):\d+(?:-\d+)?/g, '$1:#');
|
|
308
|
+
return bare(was.stdout) === bare(fs.readFileSync(mine, 'utf8'));
|
|
309
|
+
}
|
|
310
|
+
|
|
298
311
|
function sameBytes(a, b) {
|
|
299
312
|
try {
|
|
300
313
|
return fs.readFileSync(a).equals(fs.readFileSync(b));
|
|
@@ -424,7 +437,7 @@ function moveKeepingNotes(root, args, target, tag, from = 'HEAD') {
|
|
|
424
437
|
for (const p of clashed) {
|
|
425
438
|
const mine = path.join(dir, 'mine', p);
|
|
426
439
|
if (isJournal(p) && joinJournal(path.join(root, p), mine)) notes.push(`joined your ${p} onto the new one`);
|
|
427
|
-
else notes.push(`kept the new ${p}; your copy is in ${mine}`);
|
|
440
|
+
else if (!(fromCommit && lineRefsOnly(root, fromCommit, p, mine))) notes.push(`kept the new ${p}; your copy is in ${mine}`);
|
|
428
441
|
}
|
|
429
442
|
for (const p of missed) {
|
|
430
443
|
const mine = path.join(dir, 'mine', p);
|
package/lib/zen.js
CHANGED
|
@@ -501,6 +501,24 @@ function shortPlace(p) {
|
|
|
501
501
|
return p === home || p.startsWith(home + path.sep) ? `~${p.slice(home.length)}` : p;
|
|
502
502
|
}
|
|
503
503
|
|
|
504
|
+
// A place too long for a narrow pane keeps as much of its start as fits,
|
|
505
|
+
// then its own folder name, so the line still says where and which. A cut
|
|
506
|
+
// that saves only a few characters hides more than it helps, so it is skipped.
|
|
507
|
+
const PLACE_WIDTH = 100;
|
|
508
|
+
function fitPlace(text, max = PLACE_WIDTH) {
|
|
509
|
+
if (text.length <= max) return text;
|
|
510
|
+
const parts = text.split('/');
|
|
511
|
+
const name = parts.pop();
|
|
512
|
+
if (parts.length < 2) return text;
|
|
513
|
+
let head = parts[0];
|
|
514
|
+
for (const part of parts.slice(1)) {
|
|
515
|
+
if (`${head}/${part}/…/${name}`.length > max) break;
|
|
516
|
+
head = `${head}/${part}`;
|
|
517
|
+
}
|
|
518
|
+
const cut = `${head}/…/${name}`;
|
|
519
|
+
return text.length - cut.length >= 10 ? cut : text;
|
|
520
|
+
}
|
|
521
|
+
|
|
504
522
|
// Side copies left with unsaved work: at most three named, one place per
|
|
505
523
|
// line so a long path still fits a narrow pane, then when zen will take
|
|
506
524
|
// them over and, when known, how long until the first one goes.
|
|
@@ -514,11 +532,27 @@ function sideCopyLines(paths, days, waitMs = null) {
|
|
|
514
532
|
if (!folders.has(dir)) folders.set(dir, []);
|
|
515
533
|
folders.get(dir).push(p);
|
|
516
534
|
}
|
|
517
|
-
const
|
|
535
|
+
const listed = [];
|
|
518
536
|
for (const [dir, group] of folders) {
|
|
519
|
-
if (group.length >= 3)
|
|
520
|
-
else for (const p of group)
|
|
537
|
+
if (group.length >= 3) listed.push({ text: `${group.length} in ${shortPlace(dir)}/`, count: group.length });
|
|
538
|
+
else for (const p of group) listed.push({ text: fitPlace(shortPlace(p)), count: 1, path: p });
|
|
521
539
|
}
|
|
540
|
+
// A copy made inside another listed copy goes right under it, named from
|
|
541
|
+
// there, so a doubly nested path does not run the width of the pane.
|
|
542
|
+
const parentOf = new Map();
|
|
543
|
+
for (const row of listed) {
|
|
544
|
+
const holders = listed.filter((q) => q.path && row.path && row.path.startsWith(q.path + path.sep));
|
|
545
|
+
if (!holders.length) continue;
|
|
546
|
+
const holder = holders.reduce((a, b) => (b.path.length > a.path.length ? b : a));
|
|
547
|
+
parentOf.set(row, holder);
|
|
548
|
+
row.text = ` inside it: ${path.relative(holder.path, row.path)}`;
|
|
549
|
+
}
|
|
550
|
+
const rows = [];
|
|
551
|
+
const place = (row) => {
|
|
552
|
+
rows.push(row);
|
|
553
|
+
for (const child of listed) if (parentOf.get(child) === row) place(child);
|
|
554
|
+
};
|
|
555
|
+
for (const row of listed) if (!parentOf.has(row)) place(row);
|
|
522
556
|
for (const row of rows.slice(0, 3)) lines.push(` ${row.text}`);
|
|
523
557
|
const rest = rows.slice(3).reduce((sum, row) => sum + row.count, 0);
|
|
524
558
|
if (rest) lines.push(` and ${rest} more`);
|
package/package.json
CHANGED
package/utils/auth.js
CHANGED
|
@@ -318,6 +318,7 @@ function setSessionProfile(profileName) {
|
|
|
318
318
|
// No terminal session ID — fall back to global switch
|
|
319
319
|
return false;
|
|
320
320
|
}
|
|
321
|
+
assertSafeLoginWrite(sessionPath);
|
|
321
322
|
fs.writeFileSync(sessionPath, JSON.stringify({
|
|
322
323
|
profile: profileName,
|
|
323
324
|
set_at: new Date().toISOString(),
|
|
@@ -370,8 +371,31 @@ function profileNameFromEmail(email) {
|
|
|
370
371
|
return name || null; // Guard against emails like "@domain.com"
|
|
371
372
|
}
|
|
372
373
|
|
|
374
|
+
function runningUnderTest() {
|
|
375
|
+
if (process.env.NODE_TEST_CONTEXT) return true;
|
|
376
|
+
if ((process.execArgv || []).includes('--test')) return true;
|
|
377
|
+
return /\.test\.[cm]?js$/.test(path.basename(process.argv[1] || ''));
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function realDir(dir) {
|
|
381
|
+
try { return fs.realpathSync(dir); } catch { return path.resolve(dir); }
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// A test that forgets to point HOME at a scratch dir would overwrite the
|
|
385
|
+
// real login (2026-10-23: a mutant test minted a fake token into
|
|
386
|
+
// ~/.atris/credentials.json and logged the owner out). Under a test runner,
|
|
387
|
+
// login files may only be written inside the OS temp dir.
|
|
388
|
+
function assertSafeLoginWrite(targetPath) {
|
|
389
|
+
if (!runningUnderTest()) return;
|
|
390
|
+
const target = realDir(path.dirname(targetPath));
|
|
391
|
+
const tmp = realDir(os.tmpdir());
|
|
392
|
+
if (target === tmp || target.startsWith(tmp + path.sep)) return;
|
|
393
|
+
throw new Error(`Refusing to write ${targetPath} during a test: set HOME to a temp dir first`);
|
|
394
|
+
}
|
|
395
|
+
|
|
373
396
|
function saveProfile(name, credentials) {
|
|
374
397
|
const profilePath = path.join(getProfilesDir(), `${name}.json`);
|
|
398
|
+
assertSafeLoginWrite(profilePath);
|
|
375
399
|
fs.writeFileSync(profilePath, JSON.stringify(credentials, null, 2));
|
|
376
400
|
restrictToOwner(profilePath);
|
|
377
401
|
}
|
|
@@ -452,6 +476,7 @@ function saveCredentials(token, refreshToken, email, userId, provider, extras =
|
|
|
452
476
|
}
|
|
453
477
|
preserveAgentClientLogin();
|
|
454
478
|
const credentialsPath = getCredentialsPath();
|
|
479
|
+
assertSafeLoginWrite(credentialsPath);
|
|
455
480
|
const credentials = {
|
|
456
481
|
...extras,
|
|
457
482
|
token,
|
|
@@ -695,7 +720,7 @@ function abortOnAuthFailure(result, printedWake = false, exitFn = (code) => proc
|
|
|
695
720
|
return true;
|
|
696
721
|
}
|
|
697
722
|
|
|
698
|
-
async function performTokenRefresh(credentials, apiRequestJson) {
|
|
723
|
+
async function performTokenRefresh(credentials, apiRequestJson, { validate = true } = {}) {
|
|
699
724
|
if (!credentials || !credentials.refresh_token) {
|
|
700
725
|
return { ok: false, error: 'missing_refresh_token' };
|
|
701
726
|
}
|
|
@@ -715,13 +740,18 @@ async function performTokenRefresh(credentials, apiRequestJson) {
|
|
|
715
740
|
const provider = refreshed.data?.provider || credentials.provider;
|
|
716
741
|
const email = refreshUser?.email || credentials.email;
|
|
717
742
|
const userId = refreshUser?.id || credentials.user_id;
|
|
743
|
+
const refreshedCredentials = { ...credentials };
|
|
744
|
+
const refreshedExpiry = getTokenExpiryEpochSeconds(accessToken);
|
|
745
|
+
if (credentials.expires_at != null && refreshedExpiry !== null) {
|
|
746
|
+
refreshedCredentials.expires_at = new Date(refreshedExpiry * 1000).toISOString();
|
|
747
|
+
}
|
|
718
748
|
|
|
719
749
|
// Refreshed tokens must land in the file they were loaded from. Writing a
|
|
720
750
|
// profile's refresh to the global credentials.json leaves the profile token
|
|
721
751
|
// to expire permanently (every ATRIS_PROFILE session then 401s forever).
|
|
722
752
|
const persistRefreshed = () => {
|
|
723
753
|
if (credentials.source_profile) {
|
|
724
|
-
const { source_profile, source, ...rest } =
|
|
754
|
+
const { source_profile, source, ...rest } = refreshedCredentials;
|
|
725
755
|
saveProfile(credentials.source_profile, {
|
|
726
756
|
...rest,
|
|
727
757
|
token: accessToken,
|
|
@@ -732,17 +762,17 @@ async function performTokenRefresh(credentials, apiRequestJson) {
|
|
|
732
762
|
saved_at: new Date().toISOString(),
|
|
733
763
|
});
|
|
734
764
|
} else {
|
|
735
|
-
saveCredentials(accessToken, newRefreshToken, email, userId, provider,
|
|
765
|
+
saveCredentials(accessToken, newRefreshToken, email, userId, provider, refreshedCredentials);
|
|
736
766
|
}
|
|
737
767
|
};
|
|
738
768
|
|
|
739
769
|
persistRefreshed();
|
|
740
770
|
let latestCreds = loadCredentials();
|
|
741
771
|
|
|
742
|
-
const validation = await validateAccessToken(accessToken, apiRequestJson);
|
|
772
|
+
const validation = validate ? await validateAccessToken(accessToken, apiRequestJson) : null;
|
|
743
773
|
let finalUser = refreshUser;
|
|
744
774
|
|
|
745
|
-
if (validation
|
|
775
|
+
if (validation?.ok && validation.data?.valid) {
|
|
746
776
|
finalUser = validation.data.user || refreshUser || null;
|
|
747
777
|
const updatedEmail = finalUser?.email || latestCreds?.email || email;
|
|
748
778
|
const updatedProvider = finalUser?.provider || latestCreds?.provider || provider;
|
|
@@ -755,7 +785,7 @@ async function performTokenRefresh(credentials, apiRequestJson) {
|
|
|
755
785
|
updatedUserId !== latestCreds.user_id
|
|
756
786
|
) {
|
|
757
787
|
if (credentials.source_profile) {
|
|
758
|
-
const { source_profile, source, ...rest } =
|
|
788
|
+
const { source_profile, source, ...rest } = refreshedCredentials;
|
|
759
789
|
saveProfile(credentials.source_profile, {
|
|
760
790
|
...rest,
|
|
761
791
|
token: accessToken,
|
|
@@ -766,7 +796,7 @@ async function performTokenRefresh(credentials, apiRequestJson) {
|
|
|
766
796
|
saved_at: new Date().toISOString(),
|
|
767
797
|
});
|
|
768
798
|
} else {
|
|
769
|
-
saveCredentials(accessToken, newRefreshToken, updatedEmail, updatedUserId, updatedProvider,
|
|
799
|
+
saveCredentials(accessToken, newRefreshToken, updatedEmail, updatedUserId, updatedProvider, refreshedCredentials);
|
|
770
800
|
}
|
|
771
801
|
latestCreds = loadCredentials();
|
|
772
802
|
}
|
|
@@ -782,6 +812,31 @@ async function performTokenRefresh(credentials, apiRequestJson) {
|
|
|
782
812
|
};
|
|
783
813
|
}
|
|
784
814
|
|
|
815
|
+
// Sync only needs local expiry checks, not a validation request per file.
|
|
816
|
+
async function ensureFreshCredentials(apiRequestJson) {
|
|
817
|
+
const credentials = loadCredentials();
|
|
818
|
+
if (!credentials || credentials.source || agentClient.isAgentClientCredential(credentials)) return credentials;
|
|
819
|
+
const claims = decodeJwtClaims(credentials.token);
|
|
820
|
+
if (claims?.type === 'agent_access' || !credentials.refresh_token) return credentials;
|
|
821
|
+
|
|
822
|
+
const selectedExpiry = expiryTimeMs(credentials.expires_at);
|
|
823
|
+
const isExpired = () => (selectedExpiry !== null && selectedExpiry <= Date.now())
|
|
824
|
+
|| (typeof claims?.exp === 'number' && claims.exp * 1000 <= Date.now());
|
|
825
|
+
if (!isExpired() && !shouldRefreshToken(credentials.token)) return credentials;
|
|
826
|
+
|
|
827
|
+
try {
|
|
828
|
+
const refreshed = await performTokenRefresh(credentials, apiRequestJson, { validate: false });
|
|
829
|
+
if (refreshed.ok) return refreshed.payload.credentials;
|
|
830
|
+
} catch {
|
|
831
|
+
// A still-valid token can keep syncing if proactive renewal is unavailable.
|
|
832
|
+
}
|
|
833
|
+
if (isExpired()) {
|
|
834
|
+
console.error('Session expired. Run: atris login');
|
|
835
|
+
process.exit(1);
|
|
836
|
+
}
|
|
837
|
+
return credentials;
|
|
838
|
+
}
|
|
839
|
+
|
|
785
840
|
async function ensureValidCredentials(apiRequestJson, options = {}) {
|
|
786
841
|
let credentials = await loadCredentials(apiRequestJson);
|
|
787
842
|
if (agentClient.isAgentClientCredential(credentials)) {
|
|
@@ -798,6 +853,11 @@ async function ensureValidCredentials(apiRequestJson, options = {}) {
|
|
|
798
853
|
|
|
799
854
|
const selectedExpiry = expiryTimeMs(credentials.expires_at);
|
|
800
855
|
if (selectedExpiry !== null && selectedExpiry <= Date.now()) {
|
|
856
|
+
if (credentials.refresh_token) {
|
|
857
|
+
const refreshed = await performTokenRefresh(credentials, apiRequestJson);
|
|
858
|
+
if (refreshed.ok) return refreshed.payload;
|
|
859
|
+
return { error: 'refresh_failed', detail: refreshed.error };
|
|
860
|
+
}
|
|
801
861
|
return { error: 'token_invalid', detail: AGENT_TOKEN_EXPIRED_DETAIL };
|
|
802
862
|
}
|
|
803
863
|
|
|
@@ -937,6 +997,7 @@ async function displayAccountSummary(apiRequestJson) {
|
|
|
937
997
|
}
|
|
938
998
|
|
|
939
999
|
module.exports = {
|
|
1000
|
+
assertSafeLoginWrite,
|
|
940
1001
|
AGENT_TOKEN_EXPIRED_DETAIL,
|
|
941
1002
|
decodeJwtClaims,
|
|
942
1003
|
getTokenExpiryEpochSeconds,
|
|
@@ -954,6 +1015,7 @@ module.exports = {
|
|
|
954
1015
|
validateAccessToken,
|
|
955
1016
|
refreshAccessToken,
|
|
956
1017
|
performTokenRefresh,
|
|
1018
|
+
ensureFreshCredentials,
|
|
957
1019
|
ensureValidCredentials,
|
|
958
1020
|
isAuthFailure,
|
|
959
1021
|
abortOnAuthFailure,
|