atris 3.72.0 → 3.72.1

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 CHANGED
@@ -1,5 +1,5 @@
1
1
  const { AGENT_TOKEN_EXPIRED_DETAIL, decodeJwtClaims, loadCredentials, saveCredentials, saveAgentClientCredentials, preserveAgentClientLogin, deleteCredentials, getCredentialsPath, openBrowser, promptUser, displayAccountSummary, ensureValidCredentials, loadProfile, listProfiles, profileNameFromEmail, deleteProfile, saveProfile, getTokenExpiryEpochSeconds, getTerminalSessionId, setSessionProfile, getSessionProfile, clearSessionProfile, cleanStaleSessions, getSessionsDir } = require('../utils/auth');
2
- const { getAppBaseUrl, apiRequestJson } = require('../utils/api');
2
+ const { getAppBaseUrl, getApiBaseUrl, apiRequestJson } = require('../utils/api');
3
3
  const { isNonInteractive, wantsJson } = require('../lib/noninteractive');
4
4
  const { hasFlag, readFlag } = require('../lib/arg-parser');
5
5
  const agentClient = require('../utils/agent-client');
@@ -17,6 +17,7 @@ const COMMERCE_SCOPES = [COMMERCE_QUOTE_SCOPE, 'transactions:read'];
17
17
  const COMMERCE_MAX_USD_LIMIT = 500;
18
18
  const COMMERCE_NEVER_PAYS = 'this key can ask for prices and start your approval, it can never pay.';
19
19
  const NO_STORED_JWT_MESSAGE = 'not signed in. run atris login first.';
20
+ let envBilledAuthCache = null;
20
21
 
21
22
  function firstNonEmptyString(...values) {
22
23
  for (const value of values) {
@@ -171,6 +172,7 @@ function persistMintedAgentToken(credentials, token, extras = {}) {
171
172
  refresh_token: extras.refresh_token || credentials.refresh_token || null,
172
173
  ...slot,
173
174
  };
175
+ if (credentials.source === 'env') return next;
174
176
  if (credentials.source_profile) {
175
177
  saveProfile(credentials.source_profile, next);
176
178
  return next;
@@ -270,7 +272,9 @@ async function mintScopedAgentToken(requested = {}, deps = {}) {
270
272
  ok: true,
271
273
  token: minted,
272
274
  meta,
273
- storedIn: `${credentials.source_profile ? `profile ${credentials.source_profile}` : '~/.atris/credentials.json'}, ${isCommerceKey(meta.scopes) ? 'commerce_agent_token' : 'agent_token'}`,
275
+ storedIn: credentials.source === 'env'
276
+ ? 'process memory'
277
+ : `${credentials.source_profile ? `profile ${credentials.source_profile}` : '~/.atris/credentials.json'}, ${isCommerceKey(meta.scopes) ? 'commerce_agent_token' : 'agent_token'}`,
274
278
  };
275
279
  }
276
280
 
@@ -306,10 +310,33 @@ async function ensureBilledCommandAuth(scope, deps = {}) {
306
310
  return { ok: false, error: NO_STORED_JWT_MESSAGE };
307
311
  }
308
312
 
313
+ let cache;
314
+ if (credentials.source === 'env') {
315
+ const baseUrl = getApiBaseUrl();
316
+ if (!envBilledAuthCache || envBilledAuthCache.login !== credentials.token || envBilledAuthCache.api !== api || envBilledAuthCache.baseUrl !== baseUrl) {
317
+ envBilledAuthCache = { login: credentials.token, api, baseUrl, tokens: new Map() };
318
+ }
319
+ cache = envBilledAuthCache.tokens;
320
+ const cached = cache.get(wanted);
321
+ if (!deps.forceMint && cached && cached.expiry > Date.now()) {
322
+ return { ok: true, token: cached.token, minted: false, credentials, meta: cached.meta };
323
+ }
324
+ // A rejected or expired token must stay evicted even if its replacement fails.
325
+ cache.delete(wanted);
326
+ }
327
+
309
328
  const minted = await mint({ scopes: [wanted] }, deps);
310
329
  if (!minted.ok) {
311
330
  return { ok: false, error: minted.error || NO_STORED_JWT_MESSAGE };
312
331
  }
332
+ if (cache) {
333
+ const claims = decodeJwtClaims(minted.token);
334
+ const expiry = claims?.exp ? claims.exp * 1000 : Date.parse(minted.meta?.expiresAt);
335
+ const scopes = claims?.scopes || minted.meta?.scopes || [];
336
+ if (Array.isArray(scopes) && scopes.includes(wanted) && Number.isFinite(expiry) && expiry > Date.now()) {
337
+ cache.set(wanted, { token: minted.token, meta: minted.meta, expiry });
338
+ }
339
+ }
313
340
  return {
314
341
  ok: true,
315
342
  token: minted.token,
@@ -322,15 +322,29 @@ async function run(argv, deps = {}) {
322
322
  let response;
323
323
  let parsed;
324
324
  try {
325
- response = await fetchFn(url, {
325
+ const requestOptions = {
326
326
  method: 'POST',
327
+ redirect: 'manual',
327
328
  headers: {
328
329
  Authorization: `Bearer ${token}`,
329
330
  Accept: 'application/json',
330
331
  'Content-Type': 'application/json',
331
332
  },
332
333
  body: JSON.stringify(body),
333
- });
334
+ };
335
+ let requestUrl = url;
336
+ for (let redirects = 0; ; redirects++) {
337
+ response = await fetchFn(requestUrl, requestOptions);
338
+ const location = response.headers && response.headers.get('location');
339
+ if (![301, 302, 303, 307, 308].includes(response.status) || !location) break;
340
+ if (response.body) await response.body.cancel();
341
+ const target = new URL(location, requestUrl);
342
+ if (target.origin !== new URL(url).origin) {
343
+ throw new Error(`refusing cross-origin redirect to ${target.host || target.protocol}`);
344
+ }
345
+ if (redirects >= 20) throw new Error('too many redirects');
346
+ requestUrl = target.toString();
347
+ }
334
348
  parsed = await readResponse(response);
335
349
  } catch (error) {
336
350
  errorLog(`publish failed: ${error.message}`);
package/lib/site.js CHANGED
@@ -97,18 +97,28 @@ const MIME = { '.html': 'text/html; charset=utf-8', '.css': 'text/css', '.js': '
97
97
  // minimal static preview server for a built site dir (no deps)
98
98
  function serveSite(dir, port = 4321) {
99
99
  const root = path.resolve(dir);
100
+ let realRoot = root;
101
+ try { realRoot = fs.realpathSync(root); } catch { /* missing folder: every request 404s */ }
100
102
  const server = http.createServer((req, res) => {
101
- let rel = decodeURIComponent((req.url || '/').split('?')[0]);
103
+ let rel;
104
+ try { rel = decodeURIComponent((req.url || '/').split('?')[0]); }
105
+ catch { res.writeHead(400); res.end('bad request'); return; }
102
106
  if (rel.endsWith('/')) rel += 'index.html';
103
107
  const file = path.normalize(path.join(root, rel));
104
- if (!file.startsWith(root)) { res.writeHead(403); res.end('forbidden'); return; }
105
- fs.readFile(file, (err, data) => {
106
- if (err) { res.writeHead(404, { 'Content-Type': 'text/plain' }); res.end('not found'); return; }
107
- res.writeHead(200, { 'Content-Type': MIME[path.extname(file)] || 'application/octet-stream' });
108
- res.end(data);
108
+ // compare against root + separator so a sibling like "site-evil" can't pass for "site"
109
+ if (file !== root && !file.startsWith(root + path.sep)) { res.writeHead(403); res.end('forbidden'); return; }
110
+ // Resolve symlinks too: a link inside the site folder must not reach files outside it.
111
+ fs.realpath(file, (linkErr, real) => {
112
+ if (linkErr) { res.writeHead(404, { 'Content-Type': 'text/plain' }); res.end('not found'); return; }
113
+ if (real !== realRoot && !real.startsWith(realRoot + path.sep)) { res.writeHead(403); res.end('forbidden'); return; }
114
+ fs.readFile(real, (err, data) => {
115
+ if (err) { res.writeHead(404, { 'Content-Type': 'text/plain' }); res.end('not found'); return; }
116
+ res.writeHead(200, { 'Content-Type': MIME[path.extname(real)] || 'application/octet-stream' });
117
+ res.end(data);
118
+ });
109
119
  });
110
120
  });
111
- return new Promise((resolve) => server.listen(port, () => resolve({ server, url: `http://localhost:${port}` })));
121
+ return new Promise((resolve) => server.listen(port, '127.0.0.1', () => resolve({ server, url: `http://localhost:${port}` })));
112
122
  }
113
123
 
114
124
  module.exports = { buildSite, serveSite, collectMd, slugFor, firstHeading, firstParagraph };
package/lib/zen-apply.js CHANGED
@@ -284,26 +284,94 @@ function sameBytes(a, b) {
284
284
  }
285
285
  }
286
286
 
287
+ // A live journal only grows, so two versions of one day's log hold no real
288
+ // conflict: keep the incoming log and add each line of yours it lacks, in
289
+ // your order. Archived journals are finished and keep the usual handling.
290
+ function isJournal(p) {
291
+ return /(^|\/)logs\/.+\.md$/.test(p) && !/(^|\/)archive\//.test(p);
292
+ }
293
+
294
+ function joinJournal(file, mine) {
295
+ let incoming;
296
+ let yours;
297
+ try {
298
+ incoming = fs.readFileSync(file, 'utf8');
299
+ yours = fs.readFileSync(mine, 'utf8');
300
+ } catch {
301
+ return false;
302
+ }
303
+ const have = new Set(incoming.split('\n').map((line) => line.trim()).filter(Boolean));
304
+ const extra = yours.split('\n').filter((line) => line.trim() && !have.has(line.trim()));
305
+ if (extra.length) fs.writeFileSync(file, `${incoming.replace(/\n*$/, '\n')}\n${extra.join('\n')}\n`);
306
+ return true;
307
+ }
308
+
309
+ // Notes moved to a new path between two commits, old path to new.
310
+ function movedNotes(root, from, to) {
311
+ const out = runGit(['diff', '--name-status', '-z', '-M', from, to], root).stdout || '';
312
+ const parts = out.split('\0');
313
+ const moves = new Map();
314
+ for (let i = 0; i < parts.length; i += 1) {
315
+ if (/^[RC]\d*$/.test(parts[i])) {
316
+ if (parts[i][0] === 'R') moves.set(parts[i + 1], parts[i + 2]);
317
+ i += 2;
318
+ } else if (parts[i]) {
319
+ i += 1;
320
+ }
321
+ }
322
+ return moves;
323
+ }
324
+
325
+ // Carry your edits to a note onto the path it moved to, with a 3-way merge
326
+ // against the version you started from. Only a clean merge is written.
327
+ function followMove(root, fromCommit, from, to, mine) {
328
+ const dest = path.join(root, to);
329
+ const base = runGit(['show', `${fromCommit}:${from}`], root);
330
+ if (base.status !== 0 || !fs.existsSync(mine) || !fs.existsSync(dest)) return false;
331
+ const work = fs.mkdtempSync(path.join(os.tmpdir(), 'atris-zen-move-'));
332
+ try {
333
+ fs.writeFileSync(path.join(work, 'base'), base.stdout);
334
+ const merged = spawnSync('git', ['merge-file', '-p', dest, path.join(work, 'base'), mine], { encoding: 'utf8' });
335
+ if (merged.status !== 0) return false;
336
+ fs.writeFileSync(dest, merged.stdout);
337
+ return true;
338
+ } finally {
339
+ fs.rmSync(work, { recursive: true, force: true });
340
+ }
341
+ }
342
+
287
343
  // Run a fast-forward or reset. If uncommitted team notes are in the way, save
288
- // them, move, then lay them back on top.
289
- function moveKeepingNotes(root, args, target, tag) {
344
+ // them, move, then lay them back on top. `from` is where the saved changes
345
+ // start: dropping unpushed commits or leaving a side branch passes the base,
346
+ // so a note committed there and edited again since comes back whole, not
347
+ // just the later edit.
348
+ function moveKeepingNotes(root, args, target, tag, from = 'HEAD') {
290
349
  const first = runGit(args, root);
291
- if (first.status === 0) return { ok: true, notes: [] };
350
+ if (first.status === 0) return { ok: true, notes: [], tracked: [], landed: [] };
292
351
 
293
- const changed = new Set((gitOut(['diff', '--name-only', 'HEAD', target], root) || '').split('\n').filter(Boolean));
352
+ // Without rename detection a note moved upstream shows up under its old
353
+ // path too, so an edit to it is saved instead of blocking the move.
354
+ const changed = new Set((gitOut(['diff', '--name-only', '--no-renames', 'HEAD', target], root) || '').split('\n').filter(Boolean));
355
+ const moves = movedNotes(root, 'HEAD', target);
356
+ const fromCommit = gitOut(['rev-parse', '--verify', '--quiet', `${from}^{commit}`], root);
294
357
  const dirty = dirtyEntries(root);
295
- const tracked = dirty.filter((e) => e.xy !== '??' && changed.has(e.path)).map((e) => e.path);
358
+ // A new note that was only staged has nothing in HEAD to go back to, so it
359
+ // is unstaged and handled like any untracked note.
360
+ const added = dirty.filter((e) => e.xy[0] === 'A' && changed.has(e.path)).map((e) => e.path);
361
+ if (added.length) runGit(['reset', '-q', '--', ...added], root);
362
+ const tracked = dirty.filter((e) => e.xy !== '??' && e.xy[0] !== 'A' && changed.has(e.path)).map((e) => e.path);
296
363
  const untracked = dirty
297
364
  .filter((e) => e.xy === '??')
298
365
  .flatMap((e) => (e.path.endsWith('/') ? untrackedFiles(root, e.path) : [e.path]))
299
- .filter((p) => changed.has(p));
366
+ .filter((p) => changed.has(p))
367
+ .concat(added);
300
368
  if (!tracked.length && !untracked.length) return { ok: false, error: firstLine(first.stderr) };
301
369
 
302
370
  const dir = path.join(commonDir(root), 'atris-zen', tag);
303
371
  fs.mkdirSync(dir, { recursive: true });
304
372
  const patchFile = path.join(dir, 'team-notes.patch');
305
373
  if (tracked.length) {
306
- const diff = runGit(['diff', '--binary', 'HEAD', '--', ...tracked], root);
374
+ const diff = runGit(['diff', '--binary', from, '--', ...tracked], root);
307
375
  fs.writeFileSync(patchFile, diff.stdout);
308
376
  for (const p of tracked) {
309
377
  if (!fs.existsSync(path.join(root, p))) continue;
@@ -322,12 +390,25 @@ function moveKeepingNotes(root, args, target, tag) {
322
390
 
323
391
  const second = runGit(args, root);
324
392
  const notes = [];
393
+ let landed = [];
325
394
  if (tracked.length) {
326
395
  const { clashed, missed } = applyNotes(root, patchFile, tracked);
327
- for (const p of clashed) notes.push(`kept the new ${p}; your copy is in ${path.join(dir, 'mine', p)}`);
396
+ landed = tracked.filter((p) => !clashed.includes(p) && !missed.includes(p));
397
+ for (const p of clashed) {
398
+ const mine = path.join(dir, 'mine', p);
399
+ if (isJournal(p) && joinJournal(path.join(root, p), mine)) notes.push(`joined your ${p} onto the new one`);
400
+ else notes.push(`kept the new ${p}; your copy is in ${mine}`);
401
+ }
328
402
  for (const p of missed) {
329
403
  const mine = path.join(dir, 'mine', p);
330
- notes.push(`${p} no longer fits the new changes; your copy is in ${fs.existsSync(mine) ? mine : patchFile}`);
404
+ const to = moves.get(p);
405
+ if (to && fromCommit && followMove(root, fromCommit, p, to, mine)) {
406
+ notes.push(`${p} moved to ${to}, and your edits went with it`);
407
+ } else if (to) {
408
+ notes.push(`${p} moved to ${to} and your edits clash there; your copy is in ${fs.existsSync(mine) ? mine : patchFile}`);
409
+ } else {
410
+ notes.push(`${p} no longer fits the new changes; your copy is in ${fs.existsSync(mine) ? mine : patchFile}`);
411
+ }
331
412
  }
332
413
  }
333
414
  for (const p of untracked) {
@@ -335,32 +416,56 @@ function moveKeepingNotes(root, args, target, tag) {
335
416
  if (fs.existsSync(path.join(root, p))) {
336
417
  // A copy that matches the incoming file loses nothing, so say nothing.
337
418
  if (sameBytes(saved, path.join(root, p))) fs.rmSync(saved, { force: true });
419
+ else if (isJournal(p) && joinJournal(path.join(root, p), saved)) notes.push(`joined your ${p} onto the new one`);
338
420
  else notes.push(`kept the new ${p}; your copy is in ${saved}`);
339
421
  } else {
340
422
  fs.mkdirSync(path.dirname(path.join(root, p)), { recursive: true });
341
423
  fs.copyFileSync(saved, path.join(root, p));
342
424
  }
343
425
  }
344
- if (second.status !== 0) return { ok: false, error: firstLine(second.stderr), notes };
345
- return { ok: true, notes };
426
+ if (second.status !== 0) return { ok: false, error: firstLine(second.stderr), notes, tracked, landed };
427
+ return { ok: true, notes, tracked, landed };
346
428
  }
347
429
 
348
430
  // Unpushed commits can carry team-note edits. Their commits move to a backup
349
431
  // branch, but the notes stay in the tree, uncommitted, like any team note.
350
- function carryTeamNotes(root, base, commit, teamState, tag) {
351
- const files = (gitOut(['diff', '--name-only', base, commit], root) || '')
432
+ // A note that was also edited since came back with the move already, commit
433
+ // and all, so it is counted and not laid down twice.
434
+ function carryTeamNotes(root, base, commit, teamState, tag, moved = { tracked: [], landed: [] }) {
435
+ const touched = (gitOut(['diff', '--name-only', base, commit], root) || '')
352
436
  .split('\n').filter((p) => p && isTeamState(p, teamState));
353
- if (!files.length) return { carried: 0, notes: [] };
437
+ const already = touched.filter((p) => moved.landed.includes(p)).length;
438
+ const files = touched.filter((p) => !moved.tracked.includes(p));
439
+ if (!files.length) return { carried: already, notes: [] };
354
440
  const diff = runGit(['diff', '--binary', base, commit, '--', ...files], root);
355
- if (diff.status !== 0 || !diff.stdout) return { carried: 0, notes: [] };
441
+ if (diff.status !== 0 || !diff.stdout) return { carried: already, notes: [] };
356
442
  const dir = path.join(commonDir(root), 'atris-zen', tag);
357
443
  fs.mkdirSync(dir, { recursive: true });
358
444
  const patchFile = path.join(dir, 'unpushed-team-notes.patch');
359
445
  fs.writeFileSync(patchFile, diff.stdout);
360
446
  const { clashed, missed } = applyNotes(root, patchFile, files);
361
447
  const notes = clashed.map((p) => `kept the new ${p}; yours is on ${commit}`);
362
- for (const p of missed) notes.push(`${p} no longer fits the new changes; yours is on ${commit}`);
363
- return { carried: files.length - clashed.length - missed.length, notes };
448
+ // A note moved upstream takes the commit's edits to its new path.
449
+ const moves = missed.length ? movedNotes(root, base, 'HEAD') : new Map();
450
+ let followed = 0;
451
+ for (const p of missed) {
452
+ const to = moves.get(p);
453
+ const theirs = to ? runGit(['show', `${commit}:${p}`], root) : null;
454
+ const mine = path.join(dir, 'unpushed', p);
455
+ if (theirs?.status === 0) {
456
+ fs.mkdirSync(path.dirname(mine), { recursive: true });
457
+ fs.writeFileSync(mine, theirs.stdout);
458
+ }
459
+ if (theirs?.status === 0 && followMove(root, base, p, to, mine)) {
460
+ notes.push(`${p} moved to ${to}, and the edits from ${commit} went with it`);
461
+ followed += 1;
462
+ } else if (to) {
463
+ notes.push(`${p} moved to ${to} and the edits clash there; yours is on ${commit}`);
464
+ } else {
465
+ notes.push(`${p} no longer fits the new changes; yours is on ${commit}`);
466
+ }
467
+ }
468
+ return { carried: already + followed + files.length - clashed.length - missed.length, notes };
364
469
  }
365
470
 
366
471
  function applyZen({ cwd = process.cwd(), fetch = true, freshMinutes, abandonedDays, now = new Date() } = {}) {
@@ -426,7 +531,10 @@ function applyZen({ cwd = process.cwd(), fetch = true, freshMinutes, abandonedDa
426
531
  runGit(['update-ref', `refs/heads/${keep}`, 'HEAD'], root);
427
532
  saved.push(`refs/heads/${keep}`);
428
533
  }
429
- const moved = moveKeepingNotes(root, ['checkout', '-q', main], main, tag);
534
+ // Saved from where the branch left main, so a note committed on the
535
+ // branch and edited again since comes back whole.
536
+ const base = gitOut(['merge-base', 'HEAD', main], root);
537
+ const moved = moveKeepingNotes(root, ['checkout', '-q', main], main, tag, base || 'HEAD');
430
538
  notes.push(...(moved.notes || []));
431
539
  if (moved.ok) {
432
540
  done.push(`moved back to ${main}${branch ? ` (${branch} is kept)` : ''}`);
@@ -445,12 +553,12 @@ function applyZen({ cwd = process.cwd(), fetch = true, freshMinutes, abandonedDa
445
553
  } else if (ahead) {
446
554
  const keep = `zen-backup/${tag}-unpushed`;
447
555
  runGit(['update-ref', `refs/heads/${keep}`, 'HEAD'], root);
448
- const moved = moveKeepingNotes(root, ['reset', '-q', '--keep', before.upstream], before.upstream, tag);
556
+ const base = gitOut(['merge-base', keep, before.upstream], root);
557
+ const moved = moveKeepingNotes(root, ['reset', '-q', '--keep', before.upstream], before.upstream, tag, base || 'HEAD');
449
558
  notes.push(...(moved.notes || []));
450
559
  if (moved.ok) {
451
560
  done.push(`moved ${plural(ahead, 'unpushed commit', 'unpushed commits')} to ${keep}`);
452
- const base = gitOut(['merge-base', keep, before.upstream], root);
453
- const carried = base ? carryTeamNotes(root, base, keep, teamState, tag) : { carried: 0, notes: [] };
561
+ const carried = base ? carryTeamNotes(root, base, keep, teamState, tag, moved) : { carried: 0, notes: [] };
454
562
  notes.push(...carried.notes);
455
563
  if (carried.carried) {
456
564
  done.push(`kept ${plural(carried.carried, 'team note', 'team notes')} from ${ahead === 1 ? 'it' : 'them'} in the tree`);
package/lib/zen.js CHANGED
@@ -233,9 +233,23 @@ function headMovedSince(checkout, cutoff) {
233
233
  return Math.max(mtime(path.join(dir, 'logs', 'HEAD')), mtime(path.join(dir, 'HEAD'))) > cutoff;
234
234
  }
235
235
 
236
+ // A checkout whose core.bare got set to true still has its .git folder and
237
+ // every file, but git stops looking at the files, so "not a git repository"
238
+ // would send the reader the wrong way. Name it, with the one-line undo.
239
+ const BARE_FLIPPED = 'git here is set to bare (core.bare is true), so it ignores the files in this folder; git config core.bare false undoes it';
240
+
241
+ function flippedToBare(cwd) {
242
+ if (gitOut(['rev-parse', '--is-bare-repository'], cwd) !== 'true') return false;
243
+ const gitDir = gitOut(['rev-parse', '--absolute-git-dir'], cwd);
244
+ if (!gitDir || path.basename(gitDir) !== '.git') return false;
245
+ const folder = path.dirname(gitDir);
246
+ const here = fs.realpathSync(cwd);
247
+ return here === folder || here.startsWith(folder + path.sep);
248
+ }
249
+
236
250
  function checkZen({ cwd = process.cwd(), fetch = true, freshMinutes = freshMinutesFromEnv(), now = Date.now() } = {}) {
237
251
  const root = gitOut(['rev-parse', '--show-toplevel'], cwd);
238
- if (!root) return { ok: false, error: 'not a git repository' };
252
+ if (!root) return { ok: false, error: flippedToBare(cwd) ? BARE_FLIPPED : 'not a git repository' };
239
253
 
240
254
  const remote = gitOut(['remote'], root)?.split('\n').includes('origin') ? 'origin' : null;
241
255
  let fetched = null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atris",
3
- "version": "3.72.0",
3
+ "version": "3.72.1",
4
4
  "description": "you say what you want in plain words. atris builds it, checks it, and shows you proof.",
5
5
  "main": "bin/atris.js",
6
6
  "bin": {
package/utils/api.js CHANGED
@@ -77,8 +77,21 @@ function httpRequest(urlString, options) {
77
77
  const req = transport.request(requestOptions, (res) => {
78
78
  // Follow redirects (301, 302, 307, 308)
79
79
  if ([301, 302, 307, 308].includes(res.statusCode) && res.headers.location) {
80
- const redirectUrl = new URL(res.headers.location, urlString).toString();
81
- resolve(httpRequest(redirectUrl, options));
80
+ res.resume();
81
+ let redirectUrl;
82
+ try {
83
+ redirectUrl = new URL(res.headers.location, parsed);
84
+ } catch {
85
+ reject(new Error('invalid redirect target'));
86
+ return;
87
+ }
88
+ // Headers and bodies can both contain credentials. Never replay them
89
+ // to another origin, including a different port or an HTTPS downgrade.
90
+ if (redirectUrl.origin !== parsed.origin) {
91
+ reject(new Error(`refusing cross-origin redirect to ${redirectUrl.host || redirectUrl.protocol}`));
92
+ return;
93
+ }
94
+ resolve(httpRequest(redirectUrl.toString(), options));
82
95
  return;
83
96
  }
84
97
 
package/utils/auth.js CHANGED
@@ -2,7 +2,7 @@ const os = require('os');
2
2
  const path = require('path');
3
3
  const { stateHome } = require('../lib/state-home');
4
4
  const fs = require('fs');
5
- const { exec } = require('child_process');
5
+ const { execFile } = require('child_process');
6
6
  const readline = require('readline');
7
7
  const agentClient = require('./agent-client');
8
8
 
@@ -12,19 +12,20 @@ const agentClient = require('./agent-client');
12
12
  */
13
13
  function openBrowser(url) {
14
14
  const platform = os.platform();
15
- // Sanitize URL to prevent shell injection — only allow valid URL characters
16
- const sanitizedUrl = url.replace(/[^a-zA-Z0-9\-._~:/?#\[\]@!$&'()*+,;=%]/g, '');
17
15
  let command;
16
+ let args = [url];
18
17
 
19
18
  if (platform === 'darwin') {
20
- command = `open "${sanitizedUrl}"`;
19
+ command = 'open';
21
20
  } else if (platform === 'win32') {
22
- command = `start "" "${sanitizedUrl}"`;
21
+ command = 'rundll32.exe';
22
+ args = ['url.dll,FileProtocolHandler', url];
23
23
  } else {
24
- command = `xdg-open "${sanitizedUrl}"`;
24
+ command = 'xdg-open';
25
25
  }
26
26
 
27
- exec(command, (error) => {
27
+ // Pass the URL as data, including on Windows, without a command shell.
28
+ execFile(command, args, (error) => {
28
29
  if (error) {
29
30
  console.log(`\nCouldn't open browser automatically. Please visit:\n${url}`);
30
31
  }