atris 3.82.15 → 3.82.17

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
@@ -269,7 +269,7 @@ The owner makes the client once. The secret is shown one time, or written to a
269
269
  private file with `--secret-file`:
270
270
 
271
271
  ```bash
272
- atris agent-client create --name nightly-bot --scopes mcp:read,x-search --secret-file ./bot.secret
272
+ atris agent-client create --name nightly-bot --scopes mcp:workspace:read,x-search --secret-file ./bot.secret
273
273
  atris agent-client list
274
274
  atris agent-client rotate <client_id>
275
275
  atris agent-client revoke <client_id>
package/bin/atris.js CHANGED
@@ -153,7 +153,7 @@ const dryRunRequested = updateArgs.includes('--dry-run');
153
153
  const skipUpdateCheck = Boolean(process.env.ATRIS_SKIP_UPDATE_CHECK || process.env.NO_UPDATE_NOTIFIER || helpRequested || jsonRequested);
154
154
  // Local-only commands make no registry call and start no background install.
155
155
  // sentinel prints its own one-line notice at the end of the run instead.
156
- if (!skipUpdateCheck && (!updateCommand || (updateCommand && !['version', 'update', 'mcp', 'recap', '99', 'sentinel'].includes(updateCommand)))) {
156
+ if (!skipUpdateCheck && (!updateCommand || (updateCommand && !['version', 'update', 'mcp', 'recap', '99', 'sentinel', 'login'].includes(updateCommand)))) {
157
157
  updateCheckPromise = checkForUpdates()
158
158
  .then((updateInfo) => {
159
159
  if (updateInfo) {
@@ -1047,7 +1047,7 @@ function showReleaseHelp() {
1047
1047
 
1048
1048
  function showAuthHelp(commandName) {
1049
1049
  const usage = {
1050
- login: 'Usage: atris login [--token <token>] [--force] [--agent] [--client-id <atc_...> --client-secret-file <path>]',
1050
+ login: 'Usage: atris login [--code <code>] [--token <token>] [--force] [--agent] [--client-id <atc_...> --client-secret-file <path>]',
1051
1051
  logout: 'Usage: atris logout',
1052
1052
  whoami: 'Usage: atris whoami [--json]',
1053
1053
  switch: 'Usage: atris switch [account] [--global]',
@@ -1059,10 +1059,13 @@ function showAuthHelp(commandName) {
1059
1059
  console.log('');
1060
1060
  console.log('Description:');
1061
1061
  if (commandName === 'login') {
1062
- console.log(' Sign in with browser OAuth or a pasted API token.');
1063
- console.log(' --agent mints a scoped agent token from stored credentials. No browser.');
1064
- console.log(' --client-id signs an agent in by itself with an agent client (see atris agent-client).');
1065
- console.log(' The token renews on its own; the secret is never printed.');
1062
+ console.log(' Pick the one that fits you:');
1063
+ console.log(' A person: run atris login. It opens the browser, then you paste the one-time code.');
1064
+ console.log(' No terminal? Open atris.ai/auth/cli and run atris login --code <code>.');
1065
+ console.log(' An agent on its own: atris login --client-id <atc_...> --client-secret-file <path>.');
1066
+ console.log(' Make the client once with atris agent-client. It renews itself and never prints the secret.');
1067
+ console.log(' A script calling the API: atris login --token <atris_... key>.');
1068
+ console.log(' The one-time code is not a token. If you paste it as one, login signs you in with it.');
1066
1069
  } else if (commandName === 'logout') {
1067
1070
  console.log(' Sign out of the current Atris account.');
1068
1071
  } else if (commandName === 'whoami') {
@@ -1077,6 +1080,7 @@ function showAuthHelp(commandName) {
1077
1080
  console.log('');
1078
1081
  console.log('Options:');
1079
1082
  if (commandName === 'login') {
1083
+ console.log(' --code <code> Sign in with the one-time code from atris.ai/auth/cli. --code - reads it from stdin.');
1080
1084
  console.log(' --token <token> Save an API token without prompting.');
1081
1085
  console.log(' --force, -f Re-run login even if credentials already exist.');
1082
1086
  console.log(' --agent Mint a scoped agent token from the stored login.');
@@ -10,7 +10,7 @@
10
10
  const fs = require('fs');
11
11
  const path = require('path');
12
12
  const { apiRequestJson } = require('../utils/api');
13
- const { ensureValidCredentials, readActiveCredentials } = require('../utils/auth');
13
+ const { ensureValidCredentials, readActiveCredentials, listProfiles, loadProfile, getSessionProfile } = require('../utils/auth');
14
14
  const agentClient = require('../utils/agent-client');
15
15
  const { readFlag } = require('../lib/arg-parser');
16
16
  const { wantsJson, wantsHelp, oneLine } = require('../lib/developer-api');
@@ -19,7 +19,52 @@ const BASE_PATH = '/auth/agent-clients';
19
19
  const ACTIONS = new Set(['create', 'list', 'revoke', 'rotate']);
20
20
  const VALUE_FLAGS = ['--name', '--scopes', '--daily-credit-cap', '--ttl', '--secret-file'];
21
21
  const SHOWN_ONCE = 'store this secret now, it will not be shown again:';
22
- const AGENT_CLIENT_CANNOT_MANAGE = 'agent clients are managed with your own login, not an agent client. run: atris switch <you> or atris login --force';
22
+ // Scopes an agent client can carry today. The server holds the real list and
23
+ // rejects anything else; this is what help and the unknown-scope hint show.
24
+ const COMMON_SCOPES = [
25
+ 'x-search', 'youtube', 'gmail-read', 'calendar-read',
26
+ 'files:read', 'files:write', 'scoreboard:read', 'ai:complete',
27
+ 'mcp:workspace:read', 'mcp:publish',
28
+ ];
29
+
30
+ // The person's own saved logins, so the error can name a real switch command.
31
+ function humanProfileNames(deps = {}) {
32
+ const list = deps.listProfiles || listProfiles;
33
+ const load = deps.loadProfile || loadProfile;
34
+ try {
35
+ return list().filter((name) => {
36
+ const profile = load(name);
37
+ return profile && profile.token && !agentClient.isAgentClientCredential(profile);
38
+ });
39
+ } catch {
40
+ return [];
41
+ }
42
+ }
43
+
44
+ // The switch command that actually reaches the login in use. ATRIS_PROFILE
45
+ // beats everything, then this terminal's own pick, then the global login, so
46
+ // a --global switch would not help a terminal that picked its own account.
47
+ function switchCommandFor(name, deps = {}) {
48
+ const env = deps.env || process.env;
49
+ if (String(env.ATRIS_PROFILE || '').trim()) return `export ATRIS_PROFILE=${name}`;
50
+ let session = null;
51
+ try { session = (deps.getSessionProfile || getSessionProfile)(); } catch { session = null; }
52
+ return session ? `atris switch ${name}` : `atris switch ${name} --global`;
53
+ }
54
+
55
+ function cannotManageMessage(deps = {}) {
56
+ const names = humanProfileNames(deps);
57
+ const lead = 'agent clients are managed with your own login, not an agent client.';
58
+ if (names.length === 1) return `${lead} switch back: ${switchCommandFor(names[0], deps)}`;
59
+ if (names.length > 1) return `${lead} switch back with one of: ${names.map((n) => switchCommandFor(n, deps)).join(' | ')}`;
60
+ return `${lead} sign in as yourself: atris login --force`;
61
+ }
62
+
63
+ function withScopeHint(message) {
64
+ const text = String(message || '');
65
+ if (!/unknown scope/i.test(text)) return text;
66
+ return `${text}. scopes you can use: ${COMMON_SCOPES.join(', ')}`;
67
+ }
23
68
 
24
69
  function showAgentClientHelp(log = console.log) {
25
70
  log('usage: atris agent-client create --name <name> --scopes <a,b> [--daily-credit-cap N] [--ttl SECONDS] [--secret-file <path>]');
@@ -30,6 +75,7 @@ function showAgentClientHelp(log = console.log) {
30
75
  log('an agent client lets an agent sign itself in, with no person in the loop.');
31
76
  log('create and rotate show the secret once. --secret-file writes it to a private file instead.');
32
77
  log('the agent then runs: atris login --client-id <atc_...> --client-secret-file <path>');
78
+ log(`scopes you can use: ${COMMON_SCOPES.join(', ')}`);
33
79
  log('needs your own login. add --json for machine output.');
34
80
  }
35
81
 
@@ -170,7 +216,7 @@ async function agentClientCommand(args = [], deps = {}) {
170
216
 
171
217
  const readActive = deps.readActiveCredentials || readActiveCredentials;
172
218
  if (agentClient.isAgentClientCredential(readActive())) {
173
- return fail(AGENT_CLIENT_CANNOT_MANAGE);
219
+ return fail(cannotManageMessage(deps));
174
220
  }
175
221
 
176
222
  const request = deps.apiRequestJson || apiRequestJson;
@@ -180,7 +226,7 @@ async function agentClientCommand(args = [], deps = {}) {
180
226
  return fail('not logged in. run: atris login');
181
227
  }
182
228
  if (ensured.source === agentClient.AUTH_TYPE || agentClient.isAgentClientCredential(ensured.credentials)) {
183
- return fail(AGENT_CLIENT_CANNOT_MANAGE);
229
+ return fail(cannotManageMessage(deps));
184
230
  }
185
231
  const token = ensured.credentials.token;
186
232
 
@@ -211,7 +257,7 @@ async function agentClientCommand(args = [], deps = {}) {
211
257
  const secret = result && result.data && result.data.client_secret;
212
258
  const message = status === 404 && parsed.action !== 'revoke' && parsed.action !== 'rotate'
213
259
  ? 'agent clients are not live on the server yet. try again after the backend update.'
214
- : agentClient.scrub(oneLine(result && (result.data || result.error)), [token, secret]);
260
+ : withScopeHint(agentClient.scrub(oneLine(result && (result.data || result.error)), [token, secret]));
215
261
  return fail(message, { status: status || 0 });
216
262
  }
217
263
 
package/commands/auth.js CHANGED
@@ -622,6 +622,108 @@ async function loginAgentClient(request, deps = {}) {
622
622
  return 0;
623
623
  }
624
624
 
625
+ // The browser page at /auth/cli shows a one-time code: secrets.token_urlsafe(32)
626
+ // in the backend, so 43 url-safe characters with no dots. Tokens are JWTs
627
+ // (three dot-joined parts) and API keys start with atris_. A code pasted where
628
+ // a token belongs used to be saved as a token and fail later with a JWT error.
629
+ const CLI_CODE_PATTERN = /^(?!atris_)[A-Za-z0-9_-]{43}$/;
630
+ const CODE_NOT_TOKEN_NOTE = 'that is the one-time code from the browser, not a token. signing you in with it.';
631
+
632
+ function looksLikeCliCode(value) {
633
+ return CLI_CODE_PATTERN.test(String(value || '').trim());
634
+ }
635
+
636
+ // readFlag treats a value starting with "-" as the next flag, but one code in
637
+ // 64 starts with a dash. Take --code=<v> as is, and the next arg when it is
638
+ // code-shaped or not a flag. "-" or nothing means read stdin.
639
+ function readCodeFlag(args = []) {
640
+ const eq = args.find((arg) => String(arg).startsWith('--code='));
641
+ if (eq !== undefined) {
642
+ const value = String(eq).slice('--code='.length);
643
+ return value === '-' ? '' : value;
644
+ }
645
+ const next = args[args.indexOf('--code') + 1];
646
+ if (next === undefined || next === '-') return '';
647
+ if (looksLikeCliCode(next) || !String(next).startsWith('-')) return String(next);
648
+ return '';
649
+ }
650
+
651
+ function headlessLoginHelp() {
652
+ const url = `${getAppBaseUrl()}/auth/cli`;
653
+ return [
654
+ `open ${url}, sign in, then pass the one-time code it shows:`,
655
+ ' atris login --code <code> or echo <code> | atris login',
656
+ 'agents without a person: atris login --client-id <atc_...> --client-secret-file <path>',
657
+ ];
658
+ }
659
+
660
+ // Reads a code piped into stdin. Only reads a pipe, socket, or file, and
661
+ // gives up after a short wait so an open, silent stdin never hangs login.
662
+ function readPipedLoginInput(timeoutMs = 1500) {
663
+ return new Promise((resolve) => {
664
+ let stat;
665
+ try { stat = fs.fstatSync(0); } catch { resolve(''); return; }
666
+ // Node-launched agents hand stdin over as a socket, shells as a FIFO.
667
+ if (!stat || !(stat.isFIFO() || stat.isFile() || stat.isSocket())) { resolve(''); return; }
668
+ let data = '';
669
+ let finished = false;
670
+ const finish = () => {
671
+ if (finished) return;
672
+ finished = true;
673
+ clearTimeout(timer);
674
+ process.stdin.removeAllListeners('data');
675
+ process.stdin.pause();
676
+ resolve(data.split(/\r?\n/).map((line) => line.trim()).find(Boolean) || '');
677
+ };
678
+ const timer = setTimeout(finish, timeoutMs);
679
+ if (timer.unref) timer.unref();
680
+ process.stdin.setEncoding('utf8');
681
+ process.stdin.on('data', (chunk) => {
682
+ data += chunk;
683
+ if (data.length > 8192 || /\n/.test(data)) finish();
684
+ });
685
+ process.stdin.on('end', finish);
686
+ process.stdin.on('error', finish);
687
+ process.stdin.resume();
688
+ });
689
+ }
690
+
691
+ async function exchangeCliCodeAndSave(code, { asJson = false, note = null, quiet = false } = {}) {
692
+ if (note && !asJson) console.log(note);
693
+ const exchange = await apiRequestJson('/auth/cli/exchange', {
694
+ method: 'POST',
695
+ body: { code: String(code).trim() },
696
+ });
697
+ const payload = exchange && exchange.ok ? exchange.data : null;
698
+ if (!payload || !payload.token || !payload.refresh_token) {
699
+ const error = !payload
700
+ ? (exchange && exchange.error) || 'invalid or expired code'
701
+ : 'the server did not return tokens. try again.';
702
+ const unreachable = !exchange || !exchange.status;
703
+ const next = unreachable
704
+ ? `could not reach ${getApiBaseUrl()}. check the connection and run the same command again.`
705
+ : `get a fresh code at ${getAppBaseUrl()}/auth/cli. codes last five minutes and work once.`;
706
+ if (asJson) {
707
+ console.log(JSON.stringify({ ok: false, logged_in: false, error, next }, null, 2));
708
+ } else {
709
+ console.error(`Error: ${error}`);
710
+ console.error(next);
711
+ }
712
+ return 1;
713
+ }
714
+ const email = payload.email || null;
715
+ saveCredentials(payload.token, payload.refresh_token, email, payload.user_id || null, payload.provider || 'atris');
716
+ if (quiet) return 0;
717
+ if (asJson) {
718
+ console.log(JSON.stringify({ ok: true, logged_in: true, email, user_id: payload.user_id || null, provider: payload.provider || 'atris', next: 'atris whoami --json' }, null, 2));
719
+ return 0;
720
+ }
721
+ const name = profileNameFromEmail(email);
722
+ console.log(`\n✓ Signed in as ${email || 'unknown'}${name ? ` (profile: ${name})` : ''}`);
723
+ await displayAccountSummary(apiRequestJson);
724
+ return 0;
725
+ }
726
+
625
727
  async function loginAtris(options = {}) {
626
728
  // Support: atris login --token <token> --force
627
729
  const args = process.argv.slice(3);
@@ -645,7 +747,11 @@ async function loginAtris(options = {}) {
645
747
  const existing = loadCredentials();
646
748
 
647
749
  if (wantsAgentToken(args)) {
648
- if (directToken) {
750
+ if (directToken && looksLikeCliCode(directToken)) {
751
+ // sign in with the code first, then mint from that real login
752
+ const exchanged = await exchangeCliCodeAndSave(directToken, { asJson, note: CODE_NOT_TOKEN_NOTE, quiet: true });
753
+ if (exchanged !== 0) process.exit(exchanged);
754
+ } else if (directToken) {
649
755
  saveCredentials(
650
756
  String(directToken).trim(),
651
757
  existing?.refresh_token || null,
@@ -658,7 +764,26 @@ async function loginAtris(options = {}) {
658
764
  process.exit(code);
659
765
  }
660
766
 
767
+ // One-time code from the browser, no prompt: --code <code>, --code=<code>,
768
+ // or --code - to read it from stdin. An explicit code means sign in now,
769
+ // even over an existing login.
770
+ const codeFlagPresent = args.some((arg) => arg === '--code' || String(arg).startsWith('--code='));
771
+ if (codeFlagPresent) {
772
+ let code = readCodeFlag(args);
773
+ if (!code) code = await readPipedLoginInput(5000);
774
+ if (!code) {
775
+ const error = 'no code given.';
776
+ if (asJson) console.log(JSON.stringify({ ok: false, logged_in: false, error, next: 'atris login --code <code>' }, null, 2));
777
+ else { console.error(`Error: ${error}`); headlessLoginHelp().forEach((line) => console.error(line)); }
778
+ process.exit(1);
779
+ }
780
+ process.exit(await exchangeCliCodeAndSave(code, { asJson }));
781
+ }
782
+
661
783
  // Direct token mode (non-interactive)
784
+ if (directToken && looksLikeCliCode(directToken)) {
785
+ process.exit(await exchangeCliCodeAndSave(directToken, { asJson, note: CODE_NOT_TOKEN_NOTE }));
786
+ }
662
787
  if (directToken) {
663
788
  const trimmed = directToken.trim();
664
789
  saveCredentials(trimmed, null, existing?.email || null, existing?.user_id || null, existing?.provider || 'manual');
@@ -675,6 +800,13 @@ async function loginAtris(options = {}) {
675
800
  process.exit(0);
676
801
  }
677
802
 
803
+ // echo <code> | atris login signs in with no terminal, over an existing
804
+ // login too. Read once here so the shortcut below cannot swallow it.
805
+ const piped = (!process.stdin.isTTY && !directToken) ? await readPipedLoginInput() : '';
806
+ if (piped && looksLikeCliCode(piped)) {
807
+ process.exit(await exchangeCliCodeAndSave(piped, { asJson }));
808
+ }
809
+
678
810
  // Already signed in without --force: behave like whoami (no menu).
679
811
  // Print local identity without waiting on the network so headless agents
680
812
  // never hang on a stale token validation.
@@ -706,13 +838,17 @@ async function loginAtris(options = {}) {
706
838
  console.log(JSON.stringify({
707
839
  ok: false,
708
840
  logged_in: false,
709
- error: 'login requires --token in non-interactive mode',
710
- next: 'atris login --token <token>',
841
+ error: piped
842
+ ? 'what came in on stdin is not a one-time code from the browser'
843
+ : 'login has no terminal here. pass the one-time code from the browser.',
844
+ next: 'atris login --code <code>',
845
+ code_url: `${getAppBaseUrl()}/auth/cli`,
846
+ agents: 'atris login --client-id <atc_...> --client-secret-file <path>',
711
847
  }, null, 2));
712
848
  } else {
713
849
  console.log('Not signed in.');
714
- console.log('login needs a terminal for browser OAuth, or pass a token.');
715
- console.log('Next: atris login --token <token>');
850
+ if (piped) console.log('what came in on stdin is not a one-time code from the browser. for an API token use --token.');
851
+ headlessLoginHelp().forEach((line) => console.log(line));
716
852
  }
717
853
  process.exit(1);
718
854
  }
@@ -723,7 +859,7 @@ async function loginAtris(options = {}) {
723
859
 
724
860
  console.log('Choose login method:');
725
861
  console.log(' 1. Browser OAuth (recommended)');
726
- console.log(' 2. Paste existing API token');
862
+ console.log(' 2. Paste an API token');
727
863
  console.log(' 3. Cancel');
728
864
 
729
865
  const methodChoice = await promptUser('\nEnter choice (1-3): ');
@@ -743,39 +879,17 @@ async function loginAtris(options = {}) {
743
879
  process.exit(1);
744
880
  }
745
881
 
746
- const exchange = await apiRequestJson('/auth/cli/exchange', {
747
- method: 'POST',
748
- body: { code: code.trim() },
749
- });
750
-
751
- if (!exchange.ok || !exchange.data) {
752
- console.error(`Error: ${exchange.error || 'Invalid or expired code'}`);
753
- process.exit(1);
754
- }
755
-
756
- const payload = exchange.data;
757
- const token = payload.token;
758
- const refreshToken = payload.refresh_token;
759
-
760
- if (!token || !refreshToken) {
761
- console.error('Error: Backend did not return tokens. Try again.');
762
- process.exit(1);
763
- }
764
-
765
- const email = payload.email || null;
766
- const userId = payload.user_id || null;
767
- const provider = payload.provider || 'atris';
768
-
769
- saveCredentials(token, refreshToken, email, userId, provider);
770
- const name = profileNameFromEmail(email);
771
- console.log(`\n✓ Signed in as ${email || 'unknown'}${name ? ` (profile: ${name})` : ''}`);
772
- await displayAccountSummary(apiRequestJson);
773
- process.exit(0);
882
+ process.exit(await exchangeCliCodeAndSave(code));
774
883
  } else if (methodChoice === '2') {
775
- console.log('\nGet your token from: https://atris.ai/auth/cli\n');
884
+ console.log('\nPaste an API token (starts with atris_) or a saved session token.');
885
+ console.log('The one-time code from the browser works here too.\n');
776
886
 
777
887
  const tokenInput = await promptUser('API token: ', { secret: true });
778
888
 
889
+ if (tokenInput && looksLikeCliCode(tokenInput)) {
890
+ process.exit(await exchangeCliCodeAndSave(tokenInput, { note: CODE_NOT_TOKEN_NOTE }));
891
+ }
892
+
779
893
  if (!tokenInput) {
780
894
  console.error('Error: Token is required.');
781
895
  process.exit(1);
@@ -821,7 +821,9 @@ async function driveLoop(args, ctx, verb, view) {
821
821
  if (verb === 'propose') {
822
822
  const proposals = await loop.propose(view.id);
823
823
  const after = getCampaign(ctx.store, view.id);
824
- ctx.out(proposals.length ? `${proposals.length} proposed and claimed\n${renderShow(after)}` : 'nothing usable was proposed', { ok: proposals.length > 0, proposals, campaign: after, next: decide(after) });
824
+ const stall = proposals.length ? null : loop.lastStall();
825
+ const empty = stall ? `nothing usable was proposed: ${stall.line}` : 'nothing usable was proposed';
826
+ ctx.out(proposals.length ? `${proposals.length} proposed and claimed\n${renderShow(after)}` : empty, { ok: proposals.length > 0, proposals, campaign: after, next: decide(after), ...(stall ? { engine: stall.engine, cause: stall.kind, next_step: stall.next } : {}) });
825
827
  return proposals.length ? 0 : 1;
826
828
  }
827
829
  if (verb === 'step') {