atris 3.66.0 → 3.67.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -0
- package/bin/atris.js +13 -1
- package/commands/agent-client.js +266 -0
- package/commands/auth.js +225 -21
- package/lib/known-commands.js +1 -1
- package/package.json +1 -1
- package/utils/agent-client.js +363 -0
- package/utils/api.js +20 -2
- package/utils/auth.js +88 -3
package/README.md
CHANGED
|
@@ -173,6 +173,33 @@ No proof, no AgentXP. `atris task done --proof` records review/RL context, but
|
|
|
173
173
|
only human `atris task accept` mints Career XP for the local card or hosted
|
|
174
174
|
leaderboard.
|
|
175
175
|
|
|
176
|
+
## Agents That Sign In On Their Own
|
|
177
|
+
|
|
178
|
+
`atris login` signs a person in. An agent can sign itself in with an agent
|
|
179
|
+
client, so no one has to hand it a login token.
|
|
180
|
+
|
|
181
|
+
The owner makes the client once. The secret is shown one time, or written to a
|
|
182
|
+
private file with `--secret-file`:
|
|
183
|
+
|
|
184
|
+
```bash
|
|
185
|
+
atris agent-client create --name nightly-bot --scopes mcp:read,x-search --secret-file ./bot.secret
|
|
186
|
+
atris agent-client list
|
|
187
|
+
atris agent-client rotate <client_id>
|
|
188
|
+
atris agent-client revoke <client_id>
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
The agent signs in with the id and the secret file. Its short token renews on
|
|
192
|
+
its own; the secret and token are never printed:
|
|
193
|
+
|
|
194
|
+
```bash
|
|
195
|
+
atris login --client-id atc_... --client-secret-file ./bot.secret
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
Or set `ATRIS_CLIENT_ID` plus `ATRIS_CLIENT_SECRET_FILE` (or
|
|
199
|
+
`ATRIS_CLIENT_SECRET`) and run `atris login` once. It reads them, no flags
|
|
200
|
+
needed, and opens no browser. After that every command renews the token on
|
|
201
|
+
its own. `ATRIS_TOKEN`, when set, still wins.
|
|
202
|
+
|
|
176
203
|
## Business Owners
|
|
177
204
|
|
|
178
205
|
If you want a shared owner for a company, lab, collective, community, artist, team, or project, use the business command instead of raw `atris init`.
|
package/bin/atris.js
CHANGED
|
@@ -717,6 +717,7 @@ function showHelpAll() {
|
|
|
717
717
|
console.log(' balance - Show credit balance in dollars');
|
|
718
718
|
console.log(' usage - Show developer API usage');
|
|
719
719
|
console.log(' api-key - Create, list, rotate, or revoke a developer API key');
|
|
720
|
+
console.log(' agent-client - Let an agent sign itself in: create, list, revoke, rotate (secret shown once)');
|
|
720
721
|
console.log(' topup - Buy credits and print a Stripe checkout URL');
|
|
721
722
|
console.log(' buy - Quote a purchase or stock order; the person approves before anything is charged');
|
|
722
723
|
console.log(' transactions - List what you bought, paid in fees, traded, or sent');
|
|
@@ -983,7 +984,7 @@ function showReleaseHelp() {
|
|
|
983
984
|
|
|
984
985
|
function showAuthHelp(commandName) {
|
|
985
986
|
const usage = {
|
|
986
|
-
login: 'Usage: atris login [--token <token>] [--force] [--agent]',
|
|
987
|
+
login: 'Usage: atris login [--token <token>] [--force] [--agent] [--client-id <atc_...> --client-secret-file <path>]',
|
|
987
988
|
logout: 'Usage: atris logout',
|
|
988
989
|
whoami: 'Usage: atris whoami [--json]',
|
|
989
990
|
switch: 'Usage: atris switch [account] [--global]',
|
|
@@ -997,6 +998,8 @@ function showAuthHelp(commandName) {
|
|
|
997
998
|
if (commandName === 'login') {
|
|
998
999
|
console.log(' Sign in with browser OAuth or a pasted API token.');
|
|
999
1000
|
console.log(' --agent mints a scoped agent token from stored credentials. No browser.');
|
|
1001
|
+
console.log(' --client-id signs an agent in by itself with an agent client (see atris agent-client).');
|
|
1002
|
+
console.log(' The token renews on its own; the secret is never printed.');
|
|
1000
1003
|
} else if (commandName === 'logout') {
|
|
1001
1004
|
console.log(' Sign out of the current Atris account.');
|
|
1002
1005
|
} else if (commandName === 'whoami') {
|
|
@@ -1019,6 +1022,10 @@ function showAuthHelp(commandName) {
|
|
|
1019
1022
|
console.log(' --commerce Commerce key: scopes commerce:quote,transactions:read. Can quote, never pay.');
|
|
1020
1023
|
console.log(' --commerce-max-usd <n> Largest single quote a commerce key may ask for, 1 to 500 (default: 200).');
|
|
1021
1024
|
console.log(' --print-key Print the minted key once, for an agent on another machine.');
|
|
1025
|
+
console.log(' --client-id <atc_...> Sign in as an agent client (no browser, no person).');
|
|
1026
|
+
console.log(' --client-secret-file <p> File holding the agent client secret (chmod 600).');
|
|
1027
|
+
console.log(' --scope "a b" Ask for fewer scopes than the client has.');
|
|
1028
|
+
console.log(' Or set ATRIS_CLIENT_ID plus ATRIS_CLIENT_SECRET_FILE or ATRIS_CLIENT_SECRET, then run atris login once.');
|
|
1022
1029
|
} else if (commandName === 'switch') {
|
|
1023
1030
|
console.log(' --global, -g Switch the account for all terminals.');
|
|
1024
1031
|
}
|
|
@@ -2395,6 +2402,11 @@ if (command === 'guide') {
|
|
|
2395
2402
|
Promise.resolve(require('../commands/usage').usageCommand(process.argv.slice(3)))
|
|
2396
2403
|
.then((code) => process.exit(typeof code === 'number' ? code : 0))
|
|
2397
2404
|
.catch((err) => { console.error(String(err.message || err).replace(/\s+/g, ' ')); process.exit(1); });
|
|
2405
|
+
} else if (command === 'agent-client') {
|
|
2406
|
+
// Owner side of agent self-login: create, list, revoke, rotate agent clients.
|
|
2407
|
+
Promise.resolve(require('../commands/agent-client').agentClientCommand(process.argv.slice(3)))
|
|
2408
|
+
.then((code) => process.exit(typeof code === 'number' ? code : 0))
|
|
2409
|
+
.catch((err) => { console.error(require('../utils/agent-client').scrub(err && err.message, [])); process.exit(1); });
|
|
2398
2410
|
} else if (command === 'api-key') {
|
|
2399
2411
|
Promise.resolve(require('../commands/api-key').apiKeyCommand(process.argv.slice(3)))
|
|
2400
2412
|
.then((code) => process.exit(typeof code === 'number' ? code : 0))
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Owner side of agent self-login. A person creates an agent client once and
|
|
4
|
+
// hands its id and secret to the agent; the agent then signs itself in with
|
|
5
|
+
// `atris login --client-id ... --client-secret-file ...`.
|
|
6
|
+
//
|
|
7
|
+
// The secret is shown exactly once, at create or rotate, or written to a
|
|
8
|
+
// private file with --secret-file. It never goes to stderr or error text.
|
|
9
|
+
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
const { apiRequestJson } = require('../utils/api');
|
|
13
|
+
const { ensureValidCredentials, readActiveCredentials } = require('../utils/auth');
|
|
14
|
+
const agentClient = require('../utils/agent-client');
|
|
15
|
+
const { readFlag } = require('../lib/arg-parser');
|
|
16
|
+
const { wantsJson, wantsHelp, oneLine } = require('../lib/developer-api');
|
|
17
|
+
|
|
18
|
+
const BASE_PATH = '/auth/agent-clients';
|
|
19
|
+
const ACTIONS = new Set(['create', 'list', 'revoke', 'rotate']);
|
|
20
|
+
const VALUE_FLAGS = ['--name', '--scopes', '--daily-credit-cap', '--ttl', '--secret-file'];
|
|
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';
|
|
23
|
+
|
|
24
|
+
function showAgentClientHelp(log = console.log) {
|
|
25
|
+
log('usage: atris agent-client create --name <name> --scopes <a,b> [--daily-credit-cap N] [--ttl SECONDS] [--secret-file <path>]');
|
|
26
|
+
log(' atris agent-client list');
|
|
27
|
+
log(' atris agent-client revoke <client_id>');
|
|
28
|
+
log(' atris agent-client rotate <client_id> [--secret-file <path>]');
|
|
29
|
+
log('');
|
|
30
|
+
log('an agent client lets an agent sign itself in, with no person in the loop.');
|
|
31
|
+
log('create and rotate show the secret once. --secret-file writes it to a private file instead.');
|
|
32
|
+
log('the agent then runs: atris login --client-id <atc_...> --client-secret-file <path>');
|
|
33
|
+
log('needs your own login. add --json for machine output.');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function positiveInt(raw, label) {
|
|
37
|
+
const value = Number(raw);
|
|
38
|
+
if (!Number.isInteger(value) || value <= 0) return { error: `${label} must be a positive whole number` };
|
|
39
|
+
return { value };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function parseAgentClientArgs(args = []) {
|
|
43
|
+
const positionals = [];
|
|
44
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
45
|
+
const arg = String(args[i]);
|
|
46
|
+
if (arg === '--json') continue;
|
|
47
|
+
const valueFlag = VALUE_FLAGS.find((flag) => arg === flag || arg.startsWith(`${flag}=`));
|
|
48
|
+
if (valueFlag) {
|
|
49
|
+
if (arg === valueFlag) {
|
|
50
|
+
const next = args[i + 1];
|
|
51
|
+
if (next == null || String(next).startsWith('--')) return { error: `${valueFlag} needs a value` };
|
|
52
|
+
i += 1;
|
|
53
|
+
}
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (arg.startsWith('--')) return { error: `unknown agent-client option: ${arg}` };
|
|
57
|
+
positionals.push(arg);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const parsed = {
|
|
61
|
+
action: positionals[0] || '',
|
|
62
|
+
clientId: positionals[1] || '',
|
|
63
|
+
name: readFlag(args, '--name', ''),
|
|
64
|
+
scopes: agentClient.scopeList(readFlag(args, '--scopes', '')),
|
|
65
|
+
secretFile: readFlag(args, '--secret-file', ''),
|
|
66
|
+
dailyCreditCap: null,
|
|
67
|
+
ttlSeconds: null,
|
|
68
|
+
};
|
|
69
|
+
const cap = readFlag(args, '--daily-credit-cap', '');
|
|
70
|
+
if (cap) {
|
|
71
|
+
const checked = positiveInt(cap, '--daily-credit-cap');
|
|
72
|
+
if (checked.error) return checked;
|
|
73
|
+
parsed.dailyCreditCap = checked.value;
|
|
74
|
+
}
|
|
75
|
+
const ttl = readFlag(args, '--ttl', '');
|
|
76
|
+
if (ttl) {
|
|
77
|
+
const checked = positiveInt(ttl, '--ttl');
|
|
78
|
+
if (checked.error) return checked;
|
|
79
|
+
parsed.ttlSeconds = checked.value;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (!ACTIONS.has(parsed.action)) return { error: 'usage: atris agent-client create|list|revoke|rotate' };
|
|
83
|
+
if (parsed.action === 'create') {
|
|
84
|
+
if (!parsed.name) return { error: 'create needs --name <name>' };
|
|
85
|
+
if (parsed.scopes.length === 0) return { error: 'create needs --scopes <a,b>' };
|
|
86
|
+
}
|
|
87
|
+
if ((parsed.action === 'revoke' || parsed.action === 'rotate') && !parsed.clientId) {
|
|
88
|
+
return { error: `usage: atris agent-client ${parsed.action} <client_id>` };
|
|
89
|
+
}
|
|
90
|
+
if (parsed.clientId && !parsed.clientId.startsWith(agentClient.CLIENT_ID_PREFIX)) {
|
|
91
|
+
return { error: `client ids start with ${agentClient.CLIENT_ID_PREFIX}` };
|
|
92
|
+
}
|
|
93
|
+
if (parsed.secretFile && parsed.action !== 'create' && parsed.action !== 'rotate') {
|
|
94
|
+
return { error: '--secret-file only works with create and rotate' };
|
|
95
|
+
}
|
|
96
|
+
return parsed;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Check before calling the backend: a secret that cannot be saved is lost.
|
|
100
|
+
function checkSecretFileTarget(filePath) {
|
|
101
|
+
const resolved = path.resolve(filePath);
|
|
102
|
+
try {
|
|
103
|
+
if (fs.statSync(resolved).isDirectory()) return { error: `${resolved} is a folder, not a file` };
|
|
104
|
+
} catch (error) {
|
|
105
|
+
if (error.code !== 'ENOENT') return { error: `cannot use ${resolved} (${error.code})` };
|
|
106
|
+
}
|
|
107
|
+
const dir = path.dirname(resolved);
|
|
108
|
+
try {
|
|
109
|
+
fs.accessSync(dir, fs.constants.W_OK);
|
|
110
|
+
} catch {
|
|
111
|
+
return { error: `cannot write to ${dir}` };
|
|
112
|
+
}
|
|
113
|
+
return { path: resolved };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function clientsFrom(data) {
|
|
117
|
+
if (Array.isArray(data)) return data;
|
|
118
|
+
if (!data || typeof data !== 'object') return [];
|
|
119
|
+
for (const key of ['clients', 'agent_clients', 'items', 'data']) {
|
|
120
|
+
if (Array.isArray(data[key])) return data[key];
|
|
121
|
+
}
|
|
122
|
+
return [];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function scopesText(scopes) {
|
|
126
|
+
const list = agentClient.scopeList(scopes);
|
|
127
|
+
return list.length ? list.join(', ') : 'none';
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function listText(data) {
|
|
131
|
+
const clients = clientsFrom(data);
|
|
132
|
+
if (clients.length === 0) return 'no agent clients yet. make one: atris agent-client create --name <name> --scopes <a,b>';
|
|
133
|
+
return clients.map((client) => {
|
|
134
|
+
const parts = [client.client_id || client.id || '?', client.name || 'unnamed', `scopes: ${scopesText(client.scopes)}`];
|
|
135
|
+
if (client.revoked_at) parts.push('revoked');
|
|
136
|
+
else if (client.last_used_at) parts.push(`last used ${client.last_used_at}`);
|
|
137
|
+
return parts.join(' ');
|
|
138
|
+
}).join('\n');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Everything the backend sent, minus the secret.
|
|
142
|
+
function withoutSecret(data) {
|
|
143
|
+
const { client_secret, ...rest } = data && typeof data === 'object' ? data : {};
|
|
144
|
+
return rest;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function agentClientCommand(args = [], deps = {}) {
|
|
148
|
+
const log = deps.log || console.log;
|
|
149
|
+
const err = deps.err || console.error;
|
|
150
|
+
if (args.length === 0 || wantsHelp(args)) {
|
|
151
|
+
showAgentClientHelp(log);
|
|
152
|
+
return args.length === 0 ? 1 : 0;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const json = wantsJson(args);
|
|
156
|
+
const fail = (message, extra = {}) => {
|
|
157
|
+
if (json) log(JSON.stringify({ ok: false, error: message, ...extra }));
|
|
158
|
+
else err(message);
|
|
159
|
+
return 1;
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const parsed = parseAgentClientArgs(args);
|
|
163
|
+
if (parsed.error) return fail(parsed.error);
|
|
164
|
+
|
|
165
|
+
let secretTarget = null;
|
|
166
|
+
if (parsed.secretFile) {
|
|
167
|
+
secretTarget = checkSecretFileTarget(parsed.secretFile);
|
|
168
|
+
if (secretTarget.error) return fail(secretTarget.error);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const readActive = deps.readActiveCredentials || readActiveCredentials;
|
|
172
|
+
if (agentClient.isAgentClientCredential(readActive())) {
|
|
173
|
+
return fail(AGENT_CLIENT_CANNOT_MANAGE);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const request = deps.apiRequestJson || apiRequestJson;
|
|
177
|
+
const ensure = deps.ensureValidCredentials || ((opts) => ensureValidCredentials(request, opts));
|
|
178
|
+
const ensured = await ensure();
|
|
179
|
+
if (!ensured || ensured.error || !ensured.credentials || !ensured.credentials.token) {
|
|
180
|
+
return fail('not logged in. run: atris login');
|
|
181
|
+
}
|
|
182
|
+
if (ensured.source === agentClient.AUTH_TYPE || agentClient.isAgentClientCredential(ensured.credentials)) {
|
|
183
|
+
return fail(AGENT_CLIENT_CANNOT_MANAGE);
|
|
184
|
+
}
|
|
185
|
+
const token = ensured.credentials.token;
|
|
186
|
+
|
|
187
|
+
let result;
|
|
188
|
+
try {
|
|
189
|
+
if (parsed.action === 'list') {
|
|
190
|
+
result = await request(BASE_PATH, { method: 'GET', token });
|
|
191
|
+
} else if (parsed.action === 'create') {
|
|
192
|
+
const body = { name: parsed.name, scopes: parsed.scopes };
|
|
193
|
+
if (parsed.dailyCreditCap != null) body.daily_credit_cap = parsed.dailyCreditCap;
|
|
194
|
+
if (parsed.ttlSeconds != null) body.token_ttl_seconds = parsed.ttlSeconds;
|
|
195
|
+
result = await request(BASE_PATH, { method: 'POST', token, body, retries: 0 });
|
|
196
|
+
} else if (parsed.action === 'revoke') {
|
|
197
|
+
result = await request(`${BASE_PATH}/${encodeURIComponent(parsed.clientId)}`, { method: 'DELETE', token });
|
|
198
|
+
} else {
|
|
199
|
+
result = await request(`${BASE_PATH}/${encodeURIComponent(parsed.clientId)}/rotate-secret`, {
|
|
200
|
+
method: 'POST',
|
|
201
|
+
token,
|
|
202
|
+
retries: 0,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
} catch (error) {
|
|
206
|
+
return fail(agentClient.scrub(error && error.message, [token]));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (!result || !result.ok) {
|
|
210
|
+
const status = result && result.status;
|
|
211
|
+
const secret = result && result.data && result.data.client_secret;
|
|
212
|
+
const message = status === 404 && parsed.action !== 'revoke' && parsed.action !== 'rotate'
|
|
213
|
+
? '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]);
|
|
215
|
+
return fail(message, { status: status || 0 });
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const data = result.data && typeof result.data === 'object' ? result.data : {};
|
|
219
|
+
|
|
220
|
+
if (parsed.action === 'list') {
|
|
221
|
+
if (json) log(JSON.stringify({ ok: true, clients: clientsFrom(data).map(withoutSecret) }));
|
|
222
|
+
else log(listText(data));
|
|
223
|
+
return 0;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (parsed.action === 'revoke') {
|
|
227
|
+
if (json) log(JSON.stringify({ ok: true, client_id: parsed.clientId, revoked: true, ...withoutSecret(data) }));
|
|
228
|
+
else log(`revoked ${parsed.clientId}. its tokens stop working now.`);
|
|
229
|
+
return 0;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// create or rotate: the one moment the secret exists outside the server.
|
|
233
|
+
const secret = typeof data.client_secret === 'string' ? data.client_secret : '';
|
|
234
|
+
const clientId = data.client_id || parsed.clientId;
|
|
235
|
+
if (!secret) return fail('the server did not return a client secret');
|
|
236
|
+
|
|
237
|
+
if (secretTarget) {
|
|
238
|
+
try {
|
|
239
|
+
agentClient.writeFileAtomic(secretTarget.path, `${secret}\n`);
|
|
240
|
+
} catch (error) {
|
|
241
|
+
return fail(`could not write ${secretTarget.path}${error.code ? ` (${error.code})` : ''}. run atris agent-client rotate ${clientId} --secret-file <other path> to get a new secret.`);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (json) {
|
|
246
|
+
const payload = { ok: true, ...withoutSecret(data), client_id: clientId };
|
|
247
|
+
if (secretTarget) payload.secret_file = secretTarget.path;
|
|
248
|
+
else payload.client_secret = secret;
|
|
249
|
+
log(JSON.stringify(payload));
|
|
250
|
+
return 0;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
log(parsed.action === 'create' ? `created agent client ${data.name || parsed.name}` : `new secret for ${clientId}. the old one stops working.`);
|
|
254
|
+
log(`client id: ${clientId}`);
|
|
255
|
+
if (data.scopes) log(`scopes: ${scopesText(data.scopes)}`);
|
|
256
|
+
if (secretTarget) {
|
|
257
|
+
log(`secret saved to ${secretTarget.path} (only you can read it). it will not be shown again.`);
|
|
258
|
+
} else {
|
|
259
|
+
log(SHOWN_ONCE);
|
|
260
|
+
log(secret);
|
|
261
|
+
}
|
|
262
|
+
log(`agent sign-in: atris login --client-id ${clientId} --client-secret-file ${secretTarget ? secretTarget.path : '<file holding the secret>'}`);
|
|
263
|
+
return 0;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
module.exports = { agentClientCommand, parseAgentClientArgs };
|
package/commands/auth.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
const { AGENT_TOKEN_EXPIRED_DETAIL, decodeJwtClaims, loadCredentials, saveCredentials, deleteCredentials, getCredentialsPath, openBrowser, promptUser, displayAccountSummary, ensureValidCredentials, loadProfile, listProfiles, profileNameFromEmail, deleteProfile, saveProfile, getTokenExpiryEpochSeconds, getTerminalSessionId, setSessionProfile, getSessionProfile, clearSessionProfile, cleanStaleSessions, getSessionsDir } = require('../utils/auth');
|
|
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
2
|
const { getAppBaseUrl, apiRequestJson } = require('../utils/api');
|
|
3
3
|
const { isNonInteractive, wantsJson } = require('../lib/noninteractive');
|
|
4
4
|
const { hasFlag, readFlag } = require('../lib/arg-parser');
|
|
5
|
+
const agentClient = require('../utils/agent-client');
|
|
5
6
|
const fs = require('fs');
|
|
6
7
|
const path = require('path');
|
|
7
8
|
|
|
@@ -128,7 +129,8 @@ function isAgentAccessToken(token) {
|
|
|
128
129
|
function scopedTokenCandidate(credentials = {}) {
|
|
129
130
|
if (credentials.agent_token) return credentials.agent_token;
|
|
130
131
|
if (
|
|
131
|
-
credentials.source ===
|
|
132
|
+
credentials.source === agentClient.AUTH_TYPE
|
|
133
|
+
|| credentials.source === 'env'
|
|
132
134
|
|| credentials.source === 'agent_token_file'
|
|
133
135
|
|| isAgentAccessToken(credentials.token)
|
|
134
136
|
) {
|
|
@@ -290,11 +292,16 @@ async function ensureBilledCommandAuth(scope, deps = {}) {
|
|
|
290
292
|
const scopes = claims?.scopes || credentials.agent_token_scopes || credentials.scopes || [];
|
|
291
293
|
const expiry = claims?.exp
|
|
292
294
|
? claims.exp * 1000
|
|
293
|
-
: Date.parse(credentials.agent_token_expires_at || credentials.expires_at);
|
|
295
|
+
: Date.parse(credentials.agent_token_expires_at || credentials.expires_at || credentials.token_expires_at);
|
|
294
296
|
if (!deps.forceMint && candidate && Array.isArray(scopes) && scopes.includes(wanted) && Number.isFinite(expiry) && expiry > Date.now()) {
|
|
295
297
|
return { ok: true, token: candidate, minted: false, credentials };
|
|
296
298
|
}
|
|
297
299
|
|
|
300
|
+
if (credentials.source === agentClient.AUTH_TYPE) {
|
|
301
|
+
// An agent client cannot mint itself more scope; the owner grants it.
|
|
302
|
+
return { ok: false, error: `this agent client does not have the ${wanted} scope. ask its owner to create one that does.` };
|
|
303
|
+
}
|
|
304
|
+
|
|
298
305
|
if (!canMintFromLogin(credentials)) {
|
|
299
306
|
return { ok: false, error: NO_STORED_JWT_MESSAGE };
|
|
300
307
|
}
|
|
@@ -382,6 +389,26 @@ async function mintAgentToken(args = [], deps = {}) {
|
|
|
382
389
|
|
|
383
390
|
async function printWhoamiPayload(asJson) {
|
|
384
391
|
const ensured = await ensureValidCredentials(apiRequestJson);
|
|
392
|
+
if (!ensured.error && ensured.source === 'agent_client') {
|
|
393
|
+
const creds = ensured.credentials || {};
|
|
394
|
+
const payload = {
|
|
395
|
+
ok: true,
|
|
396
|
+
logged_in: true,
|
|
397
|
+
auth_type: agentClient.AUTH_TYPE,
|
|
398
|
+
client_id: creds.client_id,
|
|
399
|
+
scopes: creds.scopes || [],
|
|
400
|
+
token_expires_at: creds.token_expires_at || null,
|
|
401
|
+
};
|
|
402
|
+
if (asJson) {
|
|
403
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
404
|
+
} else {
|
|
405
|
+
console.log('Status: Logged in ✓ (agent client)');
|
|
406
|
+
console.log(`Client ID: ${payload.client_id}`);
|
|
407
|
+
console.log(`Scopes: ${payload.scopes.length ? payload.scopes.join(', ') : 'unknown'}`);
|
|
408
|
+
if (payload.token_expires_at) console.log(`Token renews before: ${payload.token_expires_at}`);
|
|
409
|
+
}
|
|
410
|
+
process.exit(0);
|
|
411
|
+
}
|
|
385
412
|
if (ensured.error) {
|
|
386
413
|
if (!asJson && ensured.detail === AGENT_TOKEN_EXPIRED_DETAIL) {
|
|
387
414
|
console.error('the agent key expired.');
|
|
@@ -430,6 +457,144 @@ async function printWhoamiPayload(asJson) {
|
|
|
430
457
|
process.exit(0);
|
|
431
458
|
}
|
|
432
459
|
|
|
460
|
+
function flagPresent(args, name) {
|
|
461
|
+
return args.some((arg) => arg === name || String(arg).startsWith(`${name}=`));
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function envValue(env, name) {
|
|
465
|
+
return typeof env[name] === 'string' ? env[name].trim() : '';
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// Decide whether this `atris login` is an agent client sign-in. Returns null
|
|
469
|
+
// for every other login path so those stay exactly as they were.
|
|
470
|
+
function agentClientLoginRequest(args = [], env = process.env) {
|
|
471
|
+
if (flagPresent(args, '--client-secret')) {
|
|
472
|
+
return { error: 'do not put the secret on the command line. use --client-secret-file <path> or ATRIS_CLIENT_SECRET.' };
|
|
473
|
+
}
|
|
474
|
+
const usesFlags = flagPresent(args, '--client-id') || flagPresent(args, '--client-secret-file');
|
|
475
|
+
const envId = envValue(env, 'ATRIS_CLIENT_ID');
|
|
476
|
+
if (!usesFlags && (!envId || flagPresent(args, '--token') || wantsAgentToken(args))) return null;
|
|
477
|
+
const clientId = readFlag(args, '--client-id', '') || envId;
|
|
478
|
+
if (!clientId) return { error: '--client-id needs a client id (atc_...)' };
|
|
479
|
+
if (!clientId.startsWith(agentClient.CLIENT_ID_PREFIX)) {
|
|
480
|
+
return { error: `client ids start with ${agentClient.CLIENT_ID_PREFIX}` };
|
|
481
|
+
}
|
|
482
|
+
const flagFile = readFlag(args, '--client-secret-file', '');
|
|
483
|
+
if (flagPresent(args, '--client-secret-file') && !flagFile) {
|
|
484
|
+
return { error: '--client-secret-file needs a path' };
|
|
485
|
+
}
|
|
486
|
+
const secretFile = flagFile || envValue(env, 'ATRIS_CLIENT_SECRET_FILE');
|
|
487
|
+
if (!secretFile && !envValue(env, 'ATRIS_CLIENT_SECRET')) {
|
|
488
|
+
return { error: 'no client secret. pass --client-secret-file <path>, or set ATRIS_CLIENT_SECRET_FILE or ATRIS_CLIENT_SECRET.' };
|
|
489
|
+
}
|
|
490
|
+
return {
|
|
491
|
+
clientId,
|
|
492
|
+
secretFile: secretFile ? path.resolve(secretFile) : null,
|
|
493
|
+
scope: agentClient.scopeList(readFlag(args, '--scope', '') || readFlag(args, '--scopes', '')).join(' ') || null,
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// Keep the person's own login reachable with `atris switch` after the agent
|
|
498
|
+
// client takes over credentials.json.
|
|
499
|
+
function keepHumanLoginAsProfile(nextClientId) {
|
|
500
|
+
try {
|
|
501
|
+
const stored = JSON.parse(fs.readFileSync(getCredentialsPath(), 'utf8'));
|
|
502
|
+
if (agentClient.isAgentClientCredential(stored)) {
|
|
503
|
+
// A different agent client was signed in: keep it switchable too.
|
|
504
|
+
return stored.client_id === nextClientId ? null : preserveAgentClientLogin();
|
|
505
|
+
}
|
|
506
|
+
if (!stored || !stored.token) return null;
|
|
507
|
+
const name = profileNameFromEmail(stored.email);
|
|
508
|
+
if (!name) return null;
|
|
509
|
+
if (!loadProfile(name)) saveProfile(name, stored);
|
|
510
|
+
return name;
|
|
511
|
+
} catch {
|
|
512
|
+
return null;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
async function loginAgentClient(request, deps = {}) {
|
|
517
|
+
const env = deps.env || process.env;
|
|
518
|
+
const json = Boolean(deps.json);
|
|
519
|
+
const output = deps.output || console.log;
|
|
520
|
+
const outputError = deps.outputError || console.error;
|
|
521
|
+
const fail = (message, code) => {
|
|
522
|
+
if (json) output(JSON.stringify({ ok: false, logged_in: false, error: code || 'agent_client_login_failed', detail: message }, null, 2));
|
|
523
|
+
else outputError(message);
|
|
524
|
+
return 1;
|
|
525
|
+
};
|
|
526
|
+
|
|
527
|
+
const warnings = [];
|
|
528
|
+
if (request.secretFile) {
|
|
529
|
+
let stat;
|
|
530
|
+
try {
|
|
531
|
+
stat = fs.statSync(request.secretFile);
|
|
532
|
+
} catch (error) {
|
|
533
|
+
return fail(`could not read the client secret file ${request.secretFile}${error.code ? ` (${error.code})` : ''}`, 'secret_missing');
|
|
534
|
+
}
|
|
535
|
+
if (!stat.isFile()) return fail(`${request.secretFile} is not a file`, 'secret_missing');
|
|
536
|
+
if (process.platform !== 'win32' && (stat.mode & 0o077)) {
|
|
537
|
+
warnings.push(`other users on this machine can read ${request.secretFile}. run: chmod 600 ${request.secretFile}`);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
const pending = {
|
|
542
|
+
auth_type: agentClient.AUTH_TYPE,
|
|
543
|
+
client_id: request.clientId,
|
|
544
|
+
client_secret_file: request.secretFile,
|
|
545
|
+
client_secret_env: request.secretFile ? null : 'ATRIS_CLIENT_SECRET',
|
|
546
|
+
scope: request.scope,
|
|
547
|
+
};
|
|
548
|
+
|
|
549
|
+
let secret;
|
|
550
|
+
let entry;
|
|
551
|
+
try {
|
|
552
|
+
secret = agentClient.readClientSecret(pending, env);
|
|
553
|
+
entry = await agentClient.requestClientToken({ clientId: request.clientId, secret, scope: request.scope }, deps);
|
|
554
|
+
} catch (error) {
|
|
555
|
+
return fail(agentClient.scrub(error && error.message, [secret]), error && error.code);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
let secretFile = request.secretFile;
|
|
559
|
+
let copiedSecretTo = null;
|
|
560
|
+
if (!secretFile) {
|
|
561
|
+
// The secret came from ATRIS_CLIENT_SECRET. Keep a private copy so later
|
|
562
|
+
// commands can renew without the variable.
|
|
563
|
+
secretFile = agentClient.managedSecretPath(request.clientId);
|
|
564
|
+
agentClient.writeFileAtomic(secretFile, `${secret}\n`);
|
|
565
|
+
copiedSecretTo = secretFile;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
const keptProfile = keepHumanLoginAsProfile(request.clientId);
|
|
569
|
+
saveAgentClientCredentials({ client_id: request.clientId, client_secret_file: secretFile, scope: request.scope });
|
|
570
|
+
agentClient.writeTokenCache(request.clientId, entry);
|
|
571
|
+
|
|
572
|
+
const scopes = agentClient.scopeList(entry.scope);
|
|
573
|
+
if (envValue(env, 'ATRIS_TOKEN')) {
|
|
574
|
+
warnings.push('ATRIS_TOKEN is set in this shell and takes priority. unset it to use the agent client.');
|
|
575
|
+
}
|
|
576
|
+
if (json) {
|
|
577
|
+
output(JSON.stringify({
|
|
578
|
+
ok: true,
|
|
579
|
+
logged_in: true,
|
|
580
|
+
auth_type: agentClient.AUTH_TYPE,
|
|
581
|
+
client_id: request.clientId,
|
|
582
|
+
scopes,
|
|
583
|
+
token_expires_at: entry.expires_at,
|
|
584
|
+
...(copiedSecretTo ? { secret_copied_to: copiedSecretTo } : {}),
|
|
585
|
+
warnings,
|
|
586
|
+
}, null, 2));
|
|
587
|
+
return 0;
|
|
588
|
+
}
|
|
589
|
+
output(`signed in as agent client ${request.clientId}`);
|
|
590
|
+
output(`scopes: ${scopes.length ? scopes.join(', ') : 'all granted to this client'}`);
|
|
591
|
+
output('the token renews on its own before it runs out.');
|
|
592
|
+
if (copiedSecretTo) output(`copied the secret from ATRIS_CLIENT_SECRET to ${copiedSecretTo} (only you can read it).`);
|
|
593
|
+
if (keptProfile) output(`the previous login is saved. switch back: atris switch ${keptProfile} --global`);
|
|
594
|
+
warnings.forEach((line) => outputError(line));
|
|
595
|
+
return 0;
|
|
596
|
+
}
|
|
597
|
+
|
|
433
598
|
async function loginAtris(options = {}) {
|
|
434
599
|
// Support: atris login --token <token> --force
|
|
435
600
|
const args = process.argv.slice(3);
|
|
@@ -440,6 +605,16 @@ async function loginAtris(options = {}) {
|
|
|
440
605
|
const nonInteractive = isNonInteractive(args);
|
|
441
606
|
|
|
442
607
|
try {
|
|
608
|
+
const clientLogin = agentClientLoginRequest(args, process.env);
|
|
609
|
+
if (clientLogin) {
|
|
610
|
+
if (clientLogin.error) {
|
|
611
|
+
if (asJson) console.log(JSON.stringify({ ok: false, logged_in: false, error: clientLogin.error }, null, 2));
|
|
612
|
+
else console.error(clientLogin.error);
|
|
613
|
+
process.exit(1);
|
|
614
|
+
}
|
|
615
|
+
process.exit(await loginAgentClient(clientLogin, { json: asJson }));
|
|
616
|
+
}
|
|
617
|
+
|
|
443
618
|
const existing = loadCredentials();
|
|
444
619
|
|
|
445
620
|
if (wantsAgentToken(args)) {
|
|
@@ -487,12 +662,13 @@ async function loginAtris(options = {}) {
|
|
|
487
662
|
email,
|
|
488
663
|
user_id: userId,
|
|
489
664
|
provider,
|
|
665
|
+
...(existing.client_id ? { auth_type: agentClient.AUTH_TYPE, client_id: existing.client_id } : {}),
|
|
490
666
|
credentials_saved: existing.saved_at || null,
|
|
491
667
|
next: 'atris whoami --json',
|
|
492
668
|
}, null, 2));
|
|
493
669
|
process.exit(0);
|
|
494
670
|
}
|
|
495
|
-
console.log(`Currently signed in as: ${email || userId || 'unknown'}`);
|
|
671
|
+
console.log(`Currently signed in as: ${email || userId || (existing.client_id ? `agent client ${existing.client_id}` : 'unknown')}`);
|
|
496
672
|
if (provider) console.log(`Provider: ${provider}`);
|
|
497
673
|
console.log('Next: atris whoami');
|
|
498
674
|
process.exit(0);
|
|
@@ -608,11 +784,17 @@ function logoutAtris() {
|
|
|
608
784
|
process.exit(0);
|
|
609
785
|
}
|
|
610
786
|
|
|
787
|
+
if (credentials.from_env) {
|
|
788
|
+
console.log(`This agent client (${credentials.client_id}) comes from ATRIS_CLIENT_ID in your environment.`);
|
|
789
|
+
console.log('Unset ATRIS_CLIENT_ID to sign it out.');
|
|
790
|
+
process.exit(0);
|
|
791
|
+
}
|
|
792
|
+
|
|
611
793
|
const profiles = listProfiles();
|
|
612
794
|
const currentName = profileNameFromEmail(credentials?.email);
|
|
613
795
|
|
|
614
796
|
deleteCredentials();
|
|
615
|
-
console.log(`✓ Signed out from ${credentials.email || 'current account'}`);
|
|
797
|
+
console.log(`✓ Signed out from ${credentials.email || (credentials.client_id ? `agent client ${credentials.client_id}` : 'current account')}`);
|
|
616
798
|
|
|
617
799
|
// Remind about other profiles
|
|
618
800
|
const remaining = profiles.filter(p => p !== currentName);
|
|
@@ -658,14 +840,14 @@ async function switchAccount() {
|
|
|
658
840
|
}
|
|
659
841
|
|
|
660
842
|
const current = loadCredentials();
|
|
661
|
-
const currentName =
|
|
843
|
+
const currentName = activeProfileName(current);
|
|
662
844
|
|
|
663
845
|
if (!targetName) {
|
|
664
846
|
// Interactive: show list and let user pick
|
|
665
847
|
console.log('Switch account:\n');
|
|
666
848
|
profiles.forEach((name, i) => {
|
|
667
849
|
const profile = loadProfile(name);
|
|
668
|
-
const email = profile
|
|
850
|
+
const email = profile ? profileLabel(profile, 'unknown') : 'unknown';
|
|
669
851
|
const marker = name === currentName ? ' ← active' : '';
|
|
670
852
|
console.log(` ${i + 1}. ${name}, ${email}${marker}`);
|
|
671
853
|
});
|
|
@@ -709,6 +891,32 @@ async function switchAccount() {
|
|
|
709
891
|
return activateProfile(match, currentName, { global: globalFlag });
|
|
710
892
|
}
|
|
711
893
|
|
|
894
|
+
// A profile can be switched to if it has a login token or is a saved agent client.
|
|
895
|
+
function usableProfile(profile) {
|
|
896
|
+
return Boolean(profile && (profile.token || agentClient.isAgentClientCredential(profile)));
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
function profileLabel(profile, name) {
|
|
900
|
+
if (profile?.email) return profile.email;
|
|
901
|
+
if (agentClient.isAgentClientCredential(profile)) return `agent client ${profile.client_id}`;
|
|
902
|
+
return name;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
function activeProfileName(credentials) {
|
|
906
|
+
if (agentClient.isAgentClientCredential(credentials)) return agentClient.profileNameForClient(credentials.client_id);
|
|
907
|
+
return profileNameFromEmail(credentials?.email);
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
// Overwrite credentials.json with a profile. An agent client login there is
|
|
911
|
+
// saved as a profile first, so its token cache and secret file stay reachable.
|
|
912
|
+
function writeGlobalCredentials(profile) {
|
|
913
|
+
const kept = preserveAgentClientLogin();
|
|
914
|
+
const credentialsPath = getCredentialsPath();
|
|
915
|
+
fs.writeFileSync(credentialsPath, JSON.stringify(profile, null, 2));
|
|
916
|
+
try { fs.chmodSync(credentialsPath, 0o600); } catch {}
|
|
917
|
+
return kept;
|
|
918
|
+
}
|
|
919
|
+
|
|
712
920
|
function activateProfile(name, currentName, { global = false } = {}) {
|
|
713
921
|
if (name === currentName) {
|
|
714
922
|
console.log(`Already on "${name}".`);
|
|
@@ -716,21 +924,20 @@ function activateProfile(name, currentName, { global = false } = {}) {
|
|
|
716
924
|
}
|
|
717
925
|
|
|
718
926
|
const profile = loadProfile(name);
|
|
719
|
-
if (!profile
|
|
927
|
+
if (!usableProfile(profile)) {
|
|
720
928
|
console.error(`Profile "${name}" is corrupted. Run "atris login" to fix.`);
|
|
721
929
|
process.exit(1);
|
|
722
930
|
}
|
|
723
931
|
|
|
724
932
|
if (global || !getTerminalSessionId()) {
|
|
725
933
|
// Global switch, write to credentials.json (affects all terminals)
|
|
726
|
-
const
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
console.log(`Switched to ${profile.email || name} (global, all terminals)`);
|
|
934
|
+
const kept = writeGlobalCredentials(profile);
|
|
935
|
+
console.log(`Switched to ${profileLabel(profile, name)} (global, all terminals)`);
|
|
936
|
+
if (kept && kept !== name) console.log(`The agent client login is saved. Switch back: atris switch ${kept} --global`);
|
|
730
937
|
} else {
|
|
731
938
|
// Per-terminal switch, write session file (only this terminal)
|
|
732
939
|
setSessionProfile(name);
|
|
733
|
-
console.log(`Switched to ${profile
|
|
940
|
+
console.log(`Switched to ${profileLabel(profile, name)}`);
|
|
734
941
|
}
|
|
735
942
|
}
|
|
736
943
|
|
|
@@ -958,10 +1165,8 @@ function activateGlobal() {
|
|
|
958
1165
|
const name = process.argv[3];
|
|
959
1166
|
if (!name) { process.exit(1); }
|
|
960
1167
|
const profile = loadProfile(name);
|
|
961
|
-
if (!profile
|
|
962
|
-
|
|
963
|
-
fs.writeFileSync(credentialsPath, JSON.stringify(profile, null, 2));
|
|
964
|
-
try { fs.chmodSync(credentialsPath, 0o600); } catch {}
|
|
1168
|
+
if (!usableProfile(profile)) { process.exit(1); }
|
|
1169
|
+
writeGlobalCredentials(profile);
|
|
965
1170
|
process.exit(0);
|
|
966
1171
|
}
|
|
967
1172
|
|
|
@@ -974,7 +1179,7 @@ function switchSession() {
|
|
|
974
1179
|
if (!name) { process.exit(1); }
|
|
975
1180
|
|
|
976
1181
|
const profile = loadProfile(name);
|
|
977
|
-
if (!profile
|
|
1182
|
+
if (!usableProfile(profile)) { process.exit(1); }
|
|
978
1183
|
|
|
979
1184
|
// Accept explicit session ID from the shell wrapper (more reliable than
|
|
980
1185
|
// detecting it from inside a child process where TTY env vars may be missing).
|
|
@@ -996,9 +1201,7 @@ function switchSession() {
|
|
|
996
1201
|
setSessionProfile(name);
|
|
997
1202
|
} else {
|
|
998
1203
|
// No terminal ID, global fallback
|
|
999
|
-
|
|
1000
|
-
fs.writeFileSync(credentialsPath, JSON.stringify(profile, null, 2));
|
|
1001
|
-
try { fs.chmodSync(credentialsPath, 0o600); } catch {}
|
|
1204
|
+
writeGlobalCredentials(profile);
|
|
1002
1205
|
}
|
|
1003
1206
|
}
|
|
1004
1207
|
process.exit(0);
|
|
@@ -1087,4 +1290,5 @@ module.exports = {
|
|
|
1087
1290
|
persistMintedAgentToken,
|
|
1088
1291
|
ensureBilledCommandAuth,
|
|
1089
1292
|
wantsAgentToken,
|
|
1293
|
+
agentClientLoginRequest,
|
|
1090
1294
|
};
|
package/lib/known-commands.js
CHANGED
|
@@ -4,7 +4,7 @@ const knownCommands = ['init', 'guide', 'doctor', 'doc-health', 'install', 'work
|
|
|
4
4
|
'activate', '_activate', 'agent', 'team', 'chat', 'fast', 'ax', 'console', 'serve', 'login', 'logout', 'whoami', 'switch', 'use', 'accounts', '_resolve', '_profile-email', '_switch-session', 'shell-init', 'update', 'upgrade', 'version', 'help', 'next', 'atris',
|
|
5
5
|
'clean', 'close', 'harvest', 'verify', 'recover', 'search', 'scout', 'skill', 'member', 'codex-goal', 'app', 'apps', 'learn', 'lesson', 'taste', 'teach', 'plugin', 'experiments', 'bench', 'router', 'tree', 'receipt', 'proof', 'openclaw', 'pull', 'push', 'watch', 'cloud', 'live', 'align', 'terminal', 'computer', 'diff', 'business', 'sync', 'youtube', 'x-search',
|
|
6
6
|
'ingest', 'query', 'lint', 'loop', 'pulse', 'task', 'mission', 'decide', 'agents', 'probe', 'worktree', 'land', 'caretaker', 'autoland', 'drive', 'aeo', 'slop', 'voice', 'strings', 'write', 'security-review', 'secure', 'deck', 'site', 'theme', 'card', 'reel', 'improve', 'study', 'rainmaker', 'xp', 'play', 'gm', 'game', 'x', 'recap', 'report', 'signup', 'clarity', 'interview', 'meet', 'moves', 'unknowns', 'avail', 'sync-checkout',
|
|
7
|
-
'ci', 'github', 'vercel', 'supabase', 'linear', 'stripe', 'balance', 'usage', 'api-key', 'topup', 'buy', 'transactions', 'hire', 'hires', 'mail', 'design', 'mcp', 'gmail', 'calendar', 'twitter', 'slack', 'imessage', 'integrations', 'setup', 'clean-workspace', 'cw',
|
|
7
|
+
'ci', 'github', 'vercel', 'supabase', 'linear', 'stripe', 'balance', 'usage', 'api-key', 'agent-client', 'topup', 'buy', 'transactions', 'hire', 'hires', 'mail', 'design', 'mcp', 'gmail', 'calendar', 'twitter', 'slack', 'imessage', 'integrations', 'setup', 'clean-workspace', 'cw',
|
|
8
8
|
'fork', 'browse', 'publish', 'pack', 'sleep', 'wake', 'feedback', 'errors', 'wiki', 'code-review', 'cr', 'soul', 'fleet', 'fleet-report', 'loops', 'self-improve', 'compile', 'spaceship', 'truth', 'sign', 'engine', 'engines', 'link', 'playbook', 'feed', 'brief', 'social', 'host', 'people', 'follow', 'unfollow', 'friends', 'msg', 'inbox', 'invite', 'join', 'rsi'];
|
|
9
9
|
|
|
10
10
|
// Damerau-Levenshtein edit distance between two short strings. Counts an
|
package/package.json
CHANGED
|
@@ -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
|
-
|
|
125
|
-
|
|
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,
|