atris 3.65.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/atris/skills/engines/SKILL.md +2 -2
- package/bin/atris.js +41 -3
- package/commands/agent-client.js +266 -0
- package/commands/auth.js +225 -21
- package/commands/autoland.js +62 -0
- package/commands/brain.js +4 -0
- package/commands/engine.js +23 -1
- package/commands/link.js +367 -0
- package/commands/loops.js +4 -1
- package/commands/member.js +8 -1
- package/commands/orb.js +1 -1
- package/commands/xp.js +2 -0
- package/commands/youtube.js +1 -1
- package/lib/auto-accept-certified.js +89 -9
- package/lib/cleanup-summary.js +129 -0
- package/lib/engine-ask.js +3 -2
- package/lib/engine-registry.js +203 -15
- package/lib/idle-review-sweep.js +130 -0
- package/lib/known-commands.js +2 -2
- package/lib/land-green.js +18 -4
- package/lib/member-result.js +303 -0
- package/lib/roster-models.js +1 -1
- package/lib/runner-command.js +4 -4
- package/lib/task-list-keeper.js +25 -4
- package/lib/trust-tiers.js +131 -14
- package/lib/verify-worktree-rewrite.js +115 -0
- package/lib/voice-gate.js +2 -0
- package/package.json +1 -1
- package/scripts/det/ytnotes +1 -1
- package/utils/agent-client.js +363 -0
- package/utils/api.js +20 -2
- package/utils/auth.js +88 -3
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Member agents build in temporary copies of the repo
|
|
4
|
+
// (<arena>/.agent-worktrees/<project>/<name>, older ones under
|
|
5
|
+
// <arena>/agent-work-trees/...) and record their proof check pointed at that
|
|
6
|
+
// copy: "cd <arena>/.agent-worktrees/<project>/x && node scripts/t.mjs".
|
|
7
|
+
// The landing gate only runs checks relative to the main checkout, so it
|
|
8
|
+
// refused 201 of 218 finished items in project-obelisk every hour. Rewrite
|
|
9
|
+
// such a check relative to the main checkout so the landing pass reruns it
|
|
10
|
+
// there, and say in plain words when it cannot (the file it runs only exists
|
|
11
|
+
// on the agent's branch).
|
|
12
|
+
|
|
13
|
+
const fs = require('fs');
|
|
14
|
+
const path = require('path');
|
|
15
|
+
|
|
16
|
+
function escapeRegex(text) {
|
|
17
|
+
return String(text).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function arenaDirFor(root) {
|
|
21
|
+
let cursor = root;
|
|
22
|
+
while (cursor && cursor !== path.dirname(cursor)) {
|
|
23
|
+
if (path.basename(cursor) === 'arena') return cursor;
|
|
24
|
+
cursor = path.dirname(cursor);
|
|
25
|
+
}
|
|
26
|
+
return path.dirname(root);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function copyRootPattern(root) {
|
|
30
|
+
const arena = escapeRegex(arenaDirFor(root));
|
|
31
|
+
const project = escapeRegex(path.basename(root));
|
|
32
|
+
const name = '[A-Za-z0-9._-]+';
|
|
33
|
+
return `(?:${[
|
|
34
|
+
`${arena}/\\.agent-worktrees/${project}/${name}`,
|
|
35
|
+
`${arena}/agent-work-trees/${project}/${name}`,
|
|
36
|
+
`\\.\\./\\.agent-worktrees/${project}/${name}`,
|
|
37
|
+
escapeRegex(root),
|
|
38
|
+
].join('|')})(?![A-Za-z0-9._-])`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function stripQuotes(token) {
|
|
42
|
+
return token.replace(/^'(.*)'$/, '$1').replace(/^"(.*)"$/, '$1');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Returns { command, rewritten, from }. `rewritten` is true only when the
|
|
46
|
+
// command named an agent copy (or the main checkout by absolute path) and
|
|
47
|
+
// now runs relative to the main checkout. A command that still names an
|
|
48
|
+
// agent copy after the rewrite is returned untouched with rewritten:false.
|
|
49
|
+
function rewriteWorktreeVerify(command, workspaceRoot) {
|
|
50
|
+
const original = String(command || '').trim();
|
|
51
|
+
const unchanged = { command: original, rewritten: false, from: null };
|
|
52
|
+
if (!original || !workspaceRoot) return unchanged;
|
|
53
|
+
const root = path.resolve(workspaceRoot);
|
|
54
|
+
const anyRoot = copyRootPattern(root);
|
|
55
|
+
if (!new RegExp(anyRoot).test(original)) return unchanged;
|
|
56
|
+
|
|
57
|
+
let cmd = original;
|
|
58
|
+
// ( cd X && cmd ) -> cd X && cmd
|
|
59
|
+
const paren = /^\((.*)\)$/s.exec(cmd);
|
|
60
|
+
if (paren) cmd = paren[1].trim();
|
|
61
|
+
// cd '<copy>[/sub]' && rest -> [cd sub && ]rest
|
|
62
|
+
const cd = new RegExp(`^cd\\s+(['"]?)${anyRoot}(/[^'"\\s]*)?\\1\\s+&&\\s+(.+)$`, 's').exec(cmd);
|
|
63
|
+
if (cd) {
|
|
64
|
+
const sub = (cd[2] || '').replace(/^\/+|\/+$/g, '');
|
|
65
|
+
cmd = sub ? `cd ${sub} && ${cd[3]}` : cd[3];
|
|
66
|
+
}
|
|
67
|
+
// npm --prefix <copy> run x -> npm run x
|
|
68
|
+
cmd = cmd.replace(new RegExp(`\\bnpm\\s+--prefix\\s+(['"]?)${anyRoot}/?\\1\\s+`, 'g'), 'npm ');
|
|
69
|
+
// '<copy>/a/b' -> a/b ; bare '<copy>' -> .
|
|
70
|
+
cmd = cmd.replace(new RegExp(`(['"]?)${anyRoot}/([^'"\\s]+)\\1`, 'g'), (_m, _q, rest) => rest);
|
|
71
|
+
cmd = cmd.replace(new RegExp(`(['"]?)${anyRoot}/?\\1(?=\\s|$)`, 'g'), '.');
|
|
72
|
+
// Only plain path quoting is stripped; quoted text with spaces stays put.
|
|
73
|
+
cmd = cmd.split(/(\s+)/).map((part) => (/^\s+$/.test(part) ? part : stripQuotes(part))).join('').trim();
|
|
74
|
+
if (new RegExp(anyRoot).test(cmd) || /\.agent-worktrees|agent-work-trees/.test(cmd)) return unchanged;
|
|
75
|
+
return { command: cmd, rewritten: true, from: original };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Files a parsed check step reads by relative path: its folder, the script
|
|
79
|
+
// it runs, the file it tests. Used to tell "this check cannot run on main
|
|
80
|
+
// because its file only exists on the agent's branch" from a real failure.
|
|
81
|
+
function stepReferencedPaths(step) {
|
|
82
|
+
const argv = Array.isArray(step?.argv) ? step.argv : [];
|
|
83
|
+
const paths = [];
|
|
84
|
+
if (step?.cwd) paths.push({ path: step.cwd, kind: 'dir' });
|
|
85
|
+
const [bin, first, second] = argv;
|
|
86
|
+
const looksLikePath = (token) => Boolean(token) && !String(token).startsWith('-') && /[/.]/.test(String(token));
|
|
87
|
+
if (bin === 'node' && first === '--check' && looksLikePath(second)) paths.push({ path: second, kind: 'file' });
|
|
88
|
+
else if (bin === 'node' && first === '--test') {
|
|
89
|
+
for (const token of argv.slice(2)) {
|
|
90
|
+
if (looksLikePath(token) && !String(token).includes('*') && /\.[cm]?[jt]s$/.test(token)) paths.push({ path: token, kind: 'file' });
|
|
91
|
+
}
|
|
92
|
+
} else if (bin === 'node' && looksLikePath(first)) paths.push({ path: first, kind: 'file' });
|
|
93
|
+
if ((bin === 'python' || bin === 'python3' || /venv\/bin\/python3?$/.test(String(bin || ''))) && looksLikePath(first) && first !== '-m') {
|
|
94
|
+
paths.push({ path: first, kind: 'file' });
|
|
95
|
+
}
|
|
96
|
+
if (bin === 'test' && (first === '-f' || first === '-s') && looksLikePath(second)) paths.push({ path: second, kind: 'file' });
|
|
97
|
+
return paths;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// First referenced path missing from the main checkout, or null.
|
|
101
|
+
function missingOnMain(steps, workspaceRoot) {
|
|
102
|
+
const root = path.resolve(workspaceRoot || process.cwd());
|
|
103
|
+
for (const step of steps || []) {
|
|
104
|
+
const base = step?.cwd ? path.resolve(root, step.cwd) : root;
|
|
105
|
+
for (const ref of stepReferencedPaths(step)) {
|
|
106
|
+
const target = ref.kind === 'dir' ? path.resolve(root, ref.path) : path.resolve(base, ref.path);
|
|
107
|
+
if (!fs.existsSync(target)) {
|
|
108
|
+
return path.relative(root, target) || ref.path;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
module.exports = { rewriteWorktreeVerify, missingOnMain, stepReferencedPaths };
|
package/lib/voice-gate.js
CHANGED
|
@@ -255,6 +255,8 @@ const LANDING_REASON_SENTENCES = {
|
|
|
255
255
|
verifier_is_builder: 'the re-check actor built this row, another actor must re-check',
|
|
256
256
|
verify_command_not_allowed: 'its recorded check is not on the list this machine may run alone, run it yourself to unblock',
|
|
257
257
|
verify_failed: 'its check command failed on re-run, fix the failure and re-certify',
|
|
258
|
+
verify_failed_on_main: 'its check was recorded in an agent copy and fails when rerun from main, so its work is likely still on the agent branch',
|
|
259
|
+
verify_only_on_agent_branch: 'its check runs a file that only exists on the agent branch, not on main yet, so that branch has to land first',
|
|
258
260
|
verify_passed: 'its check command passed on re-run',
|
|
259
261
|
verify_unrunnable: 'its check command could not even start, fix the command and re-certify',
|
|
260
262
|
verify_workdir_missing: 'the folder its check was recorded in is gone, re-run the check from a real folder',
|
package/package.json
CHANGED
package/scripts/det/ytnotes
CHANGED
|
@@ -373,7 +373,7 @@ run_grok() {
|
|
|
373
373
|
}
|
|
374
374
|
|
|
375
375
|
run_haiku() {
|
|
376
|
-
run_with_timeout 240 claude -p "$PROMPT" --model "${1:-claude-haiku-
|
|
376
|
+
run_with_timeout 240 claude -p "$PROMPT" --model "${1:-claude-haiku-5-5}"
|
|
377
377
|
}
|
|
378
378
|
|
|
379
379
|
# Gemini Flash through the Antigravity CLI (agy). On an 85-minute transcript it
|
|
@@ -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)));
|