atris 3.82.26 → 3.83.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.
Files changed (39) hide show
  1. package/AGENTS.md +10 -12
  2. package/README.md +77 -0
  3. package/bin/atris.js +95 -44
  4. package/commands/activate.js +39 -33
  5. package/commands/agent-home.js +403 -0
  6. package/commands/agent-signin.js +214 -0
  7. package/commands/agent-spawn.js +1 -0
  8. package/commands/autoland.js +173 -71
  9. package/commands/brain.js +211 -410
  10. package/commands/doctor.js +38 -26
  11. package/commands/init.js +4 -4
  12. package/commands/join.js +159 -0
  13. package/commands/memory.js +2 -2
  14. package/commands/morning.js +132 -0
  15. package/commands/night.js +178 -0
  16. package/commands/recap.js +4 -4
  17. package/commands/task.js +31 -2
  18. package/lib/agent-home.js +289 -0
  19. package/lib/agent-page.js +1257 -0
  20. package/lib/cleanup-summary.js +4 -2
  21. package/lib/first-minute.js +41 -20
  22. package/lib/gm-phone-page.js +8 -2
  23. package/lib/known-commands.js +1 -1
  24. package/lib/member-export.js +31 -0
  25. package/lib/memory-folder.js +122 -24
  26. package/lib/memory-live.js +2 -2
  27. package/lib/night-learned.js +171 -0
  28. package/lib/night-shift.js +123 -0
  29. package/lib/task-core.js +2 -1
  30. package/lib/task-db.js +32 -0
  31. package/package.json +1 -1
  32. package/templates/members/designer-1/MEMBER.md +34 -0
  33. package/templates/members/designer-1/MISSION.md +11 -0
  34. package/templates/members/designer-1/SOUL.md +11 -0
  35. package/templates/members/designer-1/context/why.md +5 -0
  36. package/templates/members/designer-1/goals.json +5 -0
  37. package/templates/members/designer-1/goals.md +3 -0
  38. package/templates/members/designer-1/skills/designer-1/SKILL.md +179 -0
  39. package/templates/members/designer-1/skills/designer-1/parts.md +55 -0
@@ -0,0 +1,403 @@
1
+ 'use strict';
2
+
3
+ // atris agent pull | push | snapshot | spawn: carry an Atris agent's whole
4
+ // home folder between the server and this computer, so any engine that reads
5
+ // files and runs commands (Claude Code, Codex, Muse, Grok) can become the
6
+ // agent with nothing dropped. Backend: /api/agents/{id}/home/... and
7
+ // /api/agents/{id}/spawn. The server copy is the durable one.
8
+
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+ const crypto = require('crypto');
12
+ const { apiRequestJson } = require('../utils/api');
13
+ const { NOT_LOGGED_IN, oneLine } = require('../lib/developer-api');
14
+ const { parseArgs, loginToken } = require('../lib/business-target');
15
+ const { frontmatterOf } = require('../lib/member-export');
16
+ const home = require('../lib/agent-home');
17
+
18
+ // The route for one part of an agent's stored folder.
19
+ const homeRoute = (id, leaf) => ['/agents', encodeURIComponent(id), 'home', leaf].join('/');
20
+
21
+ const HOME_VERBS = new Set(['pull', 'push', 'snapshot']);
22
+ // Twice the 2,000 file limit, even if every answer moves by one file.
23
+ const MAX_BUNDLE_ANSWERS = 4000;
24
+ const PUSH_BATCH_BYTES = 4000000;
25
+ const PUSH_BATCH_FILES = 200;
26
+
27
+ function homeHelp(log = console.log) {
28
+ log(`usage: atris agent pull [<agent>] [--dir <folder>] copy the agent's home folder here and set it up for any engine
29
+ atris agent push [--dir <folder>] send what changed here back to the agent's home
30
+ atris agent snapshot [<agent>] [--reason "..."] save a version of the home you can restore later
31
+ atris agent spawn [<parent>] --name <name> --budget <credits> [--role "..."] [--soul "..."] [--mission "..."]
32
+ make a child agent; its private key is saved to ~/.atris/agents/<name>.json
33
+
34
+ <agent> is an agent id, or a name saved by spawn. Inside a pulled folder it can be left off.
35
+ pull writes AGENTS.md and a CLAUDE.md block from MEMBER.md, SOUL.md and MISSION.md, so
36
+ Claude Code, Codex, Muse or Grok started in that folder boots as the agent.
37
+ --json prints the server's answer for scripts. Uses your sign-in (atris login, or an agent token).`);
38
+ }
39
+
40
+ // True for argv (after `agent`) that these verbs own. `spawn` is shared with
41
+ // the local worker request (`atris agent spawn <role> --task`); a budget makes
42
+ // it a child agent on the server.
43
+ function isHomeCommand(args = []) {
44
+ const verb = args[0];
45
+ if (HOME_VERBS.has(verb)) return true;
46
+ return verb === 'spawn' && args.some((arg) => arg === '--budget' || String(arg).startsWith('--budget='));
47
+ }
48
+
49
+ // Which agent a word names: a spawned name in ~/.atris/agents, a local team
50
+ // member that records its agent id, or the id itself.
51
+ function resolveAgentRef(ref, { cwd, env }) {
52
+ const handle = home.handleOf(ref);
53
+ const saved = home.readKeyFile(handle, env);
54
+ if (saved && saved.agent_id) return { id: String(saved.agent_id), handle };
55
+ const memberFile = path.join(cwd, 'atris', 'team', ref, 'MEMBER.md');
56
+ if (!ref.includes('/') && fs.existsSync(memberFile)) {
57
+ const { fields } = frontmatterOf(fs.readFileSync(memberFile, 'utf8'));
58
+ const id = fields.member_id || fields['agent-id'];
59
+ if (typeof id === 'string' && id.trim()) return { id: id.trim(), handle };
60
+ }
61
+ return { id: ref, handle: null };
62
+ }
63
+
64
+ // The server's answer in one plain sentence.
65
+ function why(res, label) {
66
+ if (!res || res.status === 0) return `could not reach the Atris server${res && res.error ? ` (${oneLine(res.error)})` : ''}`;
67
+ if (res.status === 401) return 'your sign-in was refused. run: atris login';
68
+ if (res.status === 404 || res.status === 405) {
69
+ return `the server has no home for ${label}: agent homes are not turned on there yet, or this sign-in cannot see that agent`;
70
+ }
71
+ const detail = res.data && typeof res.data === 'object' ? (res.data.detail || res.data.error || res.data.message) : null;
72
+ const text = typeof detail === 'string' ? detail : (typeof res.error === 'string' ? res.error : '');
73
+ return oneLine(text || `the server answered ${res.status}`);
74
+ }
75
+
76
+ function codePoints(text) {
77
+ return Array.from(text, (char) => char.codePointAt(0));
78
+ }
79
+
80
+ // The server walks paths in code point order; a cursor that does not move
81
+ // forward would loop forever.
82
+ function movesForward(after, next) {
83
+ const a = codePoints(after);
84
+ const b = codePoints(next);
85
+ for (let i = 0; i < b.length; i += 1) {
86
+ if (i >= a.length) return true;
87
+ if (b[i] !== a[i]) return b[i] > a[i];
88
+ }
89
+ return false;
90
+ }
91
+
92
+ function validBundle(data) {
93
+ return data && Array.isArray(data.pages) && Array.isArray(data.gone) && Array.isArray(data.skipped)
94
+ && (data.next == null || (typeof data.next === 'string' && data.next));
95
+ }
96
+
97
+ function plural(n, one, many = `${one}s`) {
98
+ return `${n} ${n === 1 ? one : many}`;
99
+ }
100
+
101
+ async function pull({ ref, flags, cwd, env, call, log, err }) {
102
+ const insideDir = flags.dir ? null : home.findHomeDir(cwd);
103
+ let agent;
104
+ let dir;
105
+ if (ref) {
106
+ agent = resolveAgentRef(ref, { cwd, env });
107
+ dir = path.resolve(cwd, flags.dir || agent.handle || ref);
108
+ } else {
109
+ dir = flags.dir ? path.resolve(cwd, flags.dir) : insideDir;
110
+ const state = dir && home.loadState(dir);
111
+ if (!state) { err('name the agent to pull, like: atris agent pull <agent> --dir <folder>'); return 1; }
112
+ agent = { id: state.agent_id, handle: state.handle };
113
+ }
114
+ const label = agent.handle || agent.id;
115
+ const made = !fs.existsSync(dir);
116
+ fs.mkdirSync(dir, { recursive: true });
117
+ const rootReal = fs.realpathSync(dir);
118
+ const previous = home.loadState(rootReal);
119
+ if (previous && previous.agent_id !== agent.id) {
120
+ err(`${dir} already holds the agent ${previous.handle || previous.agent_id}. pick another folder with --dir`);
121
+ return 1;
122
+ }
123
+ const state = previous || { schema: 'atris.agent_home.v1', agent_id: agent.id, handle: agent.handle, files: {} };
124
+
125
+ // Advertise only copies still here, so a file deleted here comes back.
126
+ const have = {};
127
+ for (const [rel, base] of Object.entries(state.files)) {
128
+ if (home.homeRel(rel) && base && base.etag && home.readLocal(rootReal, rel) !== null) have[rel] = base.etag;
129
+ }
130
+
131
+ const out = { added: [], updated: [], unchanged: 0, kept: [], gone: [], skipped: [] };
132
+ const take = (page) => {
133
+ const rel = home.homeRel(page.path);
134
+ if (!rel) { out.skipped.push({ path: String(page.path), why: 'unsafe name' }); return; }
135
+ const local = home.readLocal(rootReal, rel);
136
+ const base = state.files[rel];
137
+ const editedHere = local !== null && (!base || base.sha256 !== home.sha256(local));
138
+ if (editedHere && local !== page.text) { out.kept.push(rel); return; }
139
+ state.files[rel] = { etag: page.etag, sha256: home.sha256(page.text) };
140
+ if (local === page.text) { out.unchanged += 1; return; }
141
+ home.writeText(rootReal, rel, page.text);
142
+ (local === null ? out.added : out.updated).push(rel);
143
+ };
144
+
145
+ let after = '';
146
+ let gone = [];
147
+ for (let answers = 1; ; answers += 1) {
148
+ const res = await call(homeRoute(agent.id, 'bundle'), { method: 'POST', body: { have, after } });
149
+ if (!res || !res.ok) {
150
+ if (answers > 1) home.saveState(rootReal, state);
151
+ else if (made) fs.rmSync(rootReal, { recursive: true, force: true });
152
+ err(`could not pull ${label}: ${why(res, label)}`);
153
+ return 1;
154
+ }
155
+ const data = res.data;
156
+ if (!validBundle(data) || data.pages.some((p) => !p || typeof p.path !== 'string' || typeof p.text !== 'string' || typeof p.etag !== 'string')) {
157
+ err(`could not pull ${label}: the server sent an answer this version of atris cannot read`);
158
+ return 1;
159
+ }
160
+ for (const page of data.pages) take(page);
161
+ out.unchanged += Number(data.unchanged) || 0;
162
+ if (answers === 1) gone = data.gone.filter((rel) => typeof rel === 'string');
163
+ for (const item of data.skipped) if (item && typeof item.path === 'string') out.skipped.push({ path: item.path, why: item.why || item.reason || 'skipped' });
164
+ if (!data.next) break;
165
+ if (!movesForward(after, data.next) || answers >= MAX_BUNDLE_ANSWERS) {
166
+ home.saveState(rootReal, state);
167
+ err(`could not pull ${label}: the server's download did not move forward`);
168
+ return 1;
169
+ }
170
+ after = data.next;
171
+ }
172
+
173
+ // Files removed on the server go here too, unless they were edited here.
174
+ for (const rel of gone) {
175
+ const base = state.files[rel];
176
+ if (!base || !home.homeRel(rel)) continue;
177
+ const local = home.readLocal(rootReal, rel);
178
+ if (local !== null && home.sha256(local) !== base.sha256) { out.kept.push(rel); continue; }
179
+ if (local !== null) fs.unlinkSync(path.join(rootReal, rel));
180
+ delete state.files[rel];
181
+ out.gone.push(rel);
182
+ }
183
+
184
+ const member = home.readLocal(rootReal, 'MEMBER.md');
185
+ const name = member !== null ? String(frontmatterOf(member).fields.name || '').trim() : '';
186
+ if (name) state.handle = home.handleOf(name) || state.handle;
187
+ state.pulled_at = new Date().toISOString();
188
+ home.saveState(rootReal, state);
189
+ const adapters = home.writeAdapters(rootReal);
190
+
191
+ const result = { ok: true, agent_id: agent.id, dir: rootReal, ...out, adapters };
192
+ if (flags.json) { log(JSON.stringify(result, null, 2)); return 0; }
193
+ const shown = path.relative(cwd, rootReal) || '.';
194
+ const who = name || label;
195
+ const moved = out.added.length + out.updated.length + out.gone.length;
196
+ if (!moved && !out.kept.length) log(`Pulled ${who} into ${shown}: nothing changed, ${plural(out.unchanged, 'file')} already here.`);
197
+ else {
198
+ const bits = [`${out.added.length} new`, `${out.updated.length} updated`];
199
+ if (out.gone.length) bits.push(`${out.gone.length} removed`);
200
+ bits.push(`${out.unchanged} unchanged`);
201
+ log(`Pulled ${who} into ${shown}: ${bits.join(', ')}.`);
202
+ }
203
+ for (const rel of out.kept) log(`Kept your copy of ${rel}. The server changed it too; push to send yours, or delete it here and pull again to take theirs.`);
204
+ if (out.skipped.length) log(`${plural(out.skipped.length, 'file')} stayed on the server because they are not text or are too big: ${out.skipped.map((s) => s.path).join(', ')}.`);
205
+ if (adapters.length) log(`Wrote ${adapters.join(' and ')} so an engine started in ${shown} boots as ${who}.`);
206
+ else if (member === null) log('The home has no MEMBER.md yet, so no AGENTS.md or CLAUDE.md was written.');
207
+ return 0;
208
+ }
209
+
210
+ function homeDirFor(flags, cwd, err) {
211
+ const dir = flags.dir ? path.resolve(cwd, flags.dir) : home.findHomeDir(cwd);
212
+ const state = dir && home.loadState(dir);
213
+ if (!state) {
214
+ err(`${flags.dir || 'this folder'} is not a pulled agent home. run: atris agent pull <agent> --dir <folder>`);
215
+ return null;
216
+ }
217
+ return { dir: fs.realpathSync(dir), state };
218
+ }
219
+
220
+ function batches(files) {
221
+ const out = [];
222
+ let current = [];
223
+ let bytes = 0;
224
+ for (const file of files) {
225
+ const size = Buffer.byteLength(file.text);
226
+ if (current.length && (current.length >= PUSH_BATCH_FILES || bytes + size > PUSH_BATCH_BYTES)) {
227
+ out.push(current);
228
+ current = [];
229
+ bytes = 0;
230
+ }
231
+ current.push(file);
232
+ bytes += size;
233
+ }
234
+ if (current.length) out.push(current);
235
+ return out;
236
+ }
237
+
238
+ async function push({ flags, cwd, call, log, err }) {
239
+ const found = homeDirFor(flags, cwd, err);
240
+ if (!found) return 1;
241
+ const { dir, state } = found;
242
+ const label = state.handle || state.agent_id;
243
+ const { changed, deleted, skipped } = home.localChanges(dir, state);
244
+ const result = { ok: true, agent_id: state.agent_id, written: [], deleted: [], rejected: [], skipped };
245
+ if (!changed.length && !deleted.length) {
246
+ if (flags.json) log(JSON.stringify(result, null, 2));
247
+ else log(`Nothing to push: ${label}'s home matches the server.`);
248
+ return 0;
249
+ }
250
+ const sha = new Map(changed.map((f) => [f.path, home.sha256(f.text)]));
251
+ const groups = batches(changed);
252
+ if (!groups.length) groups.push([]);
253
+ for (let i = 0; i < groups.length; i += 1) {
254
+ const body = { files: groups[i], delete: i === 0 ? deleted : [] };
255
+ const res = await call(homeRoute(state.agent_id, 'files'), { method: 'PUT', body });
256
+ if (!res || !res.ok || !res.data || !Array.isArray(res.data.written)) {
257
+ if (result.written.length || result.deleted.length) home.saveState(dir, state);
258
+ err(`could not push ${label}: ${res && res.ok ? 'the server sent an answer this version of atris cannot read' : why(res, label)}`);
259
+ return 1;
260
+ }
261
+ const rejected = Array.isArray(res.data.rejected) ? res.data.rejected.filter((r) => r && typeof r.path === 'string') : [];
262
+ const refused = new Set(rejected.map((r) => r.path));
263
+ for (const item of res.data.written) {
264
+ if (!item || typeof item.path !== 'string' || !sha.has(item.path)) continue;
265
+ state.files[item.path] = { etag: String(item.etag || ''), sha256: sha.get(item.path) };
266
+ result.written.push(item.path);
267
+ }
268
+ for (const rel of body.delete) {
269
+ if (refused.has(rel)) continue;
270
+ delete state.files[rel];
271
+ result.deleted.push(rel);
272
+ }
273
+ result.rejected.push(...rejected.map((r) => ({ path: r.path, reason: oneLine(r.reason || 'refused') })));
274
+ }
275
+ home.saveState(dir, state);
276
+ result.ok = result.rejected.length === 0;
277
+ if (flags.json) { log(JSON.stringify(result, null, 2)); return result.ok ? 0 : 1; }
278
+ const bits = [`${plural(result.written.length, 'file')} saved`];
279
+ if (result.deleted.length) bits.push(`${result.deleted.length} removed`);
280
+ log(`Pushed ${label}: ${bits.join(', ')}.`);
281
+ for (const r of result.rejected) {
282
+ const reason = r.path === 'MEMBER.md' && r.reason === 'refused' ? 'only the owner may change the manifest' : r.reason;
283
+ err(`Not saved: ${r.path}. ${reason.replace(/[.\s]*$/, '')}.`);
284
+ }
285
+ if (skipped.length) log(`Left out ${plural(skipped.length, 'file')}: ${skipped.map((s) => `${s.path} ${s.reason}`).join('; ')}.`);
286
+ return result.ok ? 0 : 1;
287
+ }
288
+
289
+ function agentFromArgsOrHome(ref, flags, cwd, env, err, usage) {
290
+ if (ref) return resolveAgentRef(ref, { cwd, env });
291
+ const dir = flags.dir ? path.resolve(cwd, flags.dir) : home.findHomeDir(cwd);
292
+ const state = dir && home.loadState(dir);
293
+ if (state) return { id: state.agent_id, handle: state.handle };
294
+ err(usage);
295
+ return null;
296
+ }
297
+
298
+ async function snapshot({ ref, flags, cwd, env, call, log, err }) {
299
+ const agent = agentFromArgsOrHome(ref, flags, cwd, env, err, 'name the agent, like: atris agent snapshot <agent>, or run it inside a pulled home');
300
+ if (!agent) return 1;
301
+ const label = agent.handle || agent.id;
302
+ const reason = typeof flags.reason === 'string' ? flags.reason : 'saved with atris agent snapshot';
303
+ const res = await call(homeRoute(agent.id, 'snapshots'), { method: 'POST', body: { reason } });
304
+ if (!res || !res.ok) { err(`could not save a snapshot of ${label}: ${why(res, label)}`); return 1; }
305
+ const id = res.data && (res.data.snapshot_id || res.data.id);
306
+ if (flags.json) { log(JSON.stringify({ ok: true, agent_id: agent.id, snapshot_id: id || null }, null, 2)); return 0; }
307
+ log(`Saved a snapshot of ${label}${id ? `: ${id}` : ''}. Every file's version is kept, so you can restore it later.`);
308
+ return 0;
309
+ }
310
+
311
+ async function spawn({ ref, flags, cwd, env, call, log, err }) {
312
+ const parent = agentFromArgsOrHome(ref, flags, cwd, env, err, 'name the parent agent, like: atris agent spawn <parent> --name scout --budget 20');
313
+ if (!parent) return 1;
314
+ const name = typeof flags.name === 'string' ? flags.name.trim() : '';
315
+ const handle = home.handleOf(name);
316
+ if (!handle) { err('give the child a name, like: --name scout'); return 1; }
317
+ const budget = Number(flags.budget);
318
+ if (!Number.isInteger(budget) || budget < 0) { err('--budget takes a whole number of credits, 0 or more'); return 1; }
319
+
320
+ // A retry after a lost answer reuses the first request's key, so the server
321
+ // replays it instead of spending the budget twice.
322
+ const saved = home.readKeyFile(handle, env);
323
+ if (saved && saved.private_key) {
324
+ err(`you already have an agent saved as ${handle} (${saved.agent_id}). pick another --name`);
325
+ return 1;
326
+ }
327
+ const idempotencyKey = saved && saved.status === 'pending' && saved.parent_agent_id === parent.id && saved.idempotency_key
328
+ ? saved.idempotency_key
329
+ : crypto.randomUUID();
330
+ const keyFile = home.writeKeyFile(handle, {
331
+ schema: 'atris.agent_key.v1', status: 'pending', name, handle, parent_agent_id: parent.id,
332
+ idempotency_key: idempotencyKey, created_at: new Date().toISOString(),
333
+ }, env);
334
+
335
+ const body = { name, role: typeof flags.role === 'string' ? flags.role : name, budget_credits: budget, idempotency_key: idempotencyKey };
336
+ if (typeof flags.soul === 'string') body.soul = flags.soul;
337
+ if (typeof flags.mission === 'string') body.mission = flags.mission;
338
+ const res = await call(`/agents/${encodeURIComponent(parent.id)}/spawn`, { method: 'POST', body });
339
+ const label = parent.handle || parent.id;
340
+ if (!res || !res.ok) {
341
+ const sentence = res && (res.status === 404 || res.status === 405)
342
+ ? `the server cannot spawn from ${label}: spawning is not turned on there yet, or this sign-in cannot see that agent`
343
+ : why(res, label);
344
+ err(`could not spawn ${name}: ${sentence}`);
345
+ if (res && res.status !== 0 && res.status < 500) fs.rmSync(keyFile, { force: true });
346
+ return 1;
347
+ }
348
+ const data = res.data || {};
349
+ const childId = String(data.agent_id || data.id || (data.agent && data.agent.id) || '');
350
+ const privateKey = typeof data.private_key === 'string' && data.private_key ? data.private_key : '';
351
+ const record = {
352
+ schema: 'atris.agent_key.v1', status: privateKey ? 'ready' : 'no-key', name, handle,
353
+ agent_id: childId, parent_agent_id: parent.id, public_key: data.public_key || null,
354
+ idempotency_key: idempotencyKey, created_at: new Date().toISOString(),
355
+ };
356
+ if (privateKey) record.private_key = privateKey;
357
+ home.writeKeyFile(handle, record, env);
358
+
359
+ const shown = { ok: true, agent_id: childId, name, parent_agent_id: parent.id, budget_credits: budget, public_key: record.public_key, key_file: keyFile, key_saved: Boolean(privateKey) };
360
+ if (flags.json) { log(JSON.stringify(shown, null, 2)); return 0; }
361
+ log(`Spawned ${name} (${childId}) from ${label} with ${plural(budget, 'credit')}.`);
362
+ if (privateKey) log(`Its private key is saved to ${keyFile}, readable only by you. It is never shown and the server does not keep it.`);
363
+ else log('The server sent no private key (it only sends it once), so none was saved.');
364
+ log(`Pull its home with: atris agent pull ${handle}`);
365
+ return 0;
366
+ }
367
+
368
+ async function agentHomeCommand(args = [], deps = {}) {
369
+ let secret = '';
370
+ const hide = (line) => (secret ? String(line).split(secret).join('[hidden]') : String(line));
371
+ const log = (line) => (deps.log || console.log)(hide(line));
372
+ const err = (line) => (deps.err || console.error)(hide(line));
373
+ const cwd = deps.cwd || process.cwd();
374
+ const env = deps.env || process.env;
375
+
376
+ const verb = args[0];
377
+ const { flags, pos, error } = parseArgs(args.slice(1), {
378
+ switches: ['account'],
379
+ values: ['dir', 'reason', 'name', 'budget', 'role', 'soul', 'mission'],
380
+ });
381
+ if (flags.help || pos[0] === 'help') { homeHelp(log); return 0; }
382
+ if (error) { err(`${error}. see: atris agent pull --help`); return 1; }
383
+ if (pos.length > 1) { err(`atris agent ${verb} takes one agent, got: ${pos.join(' ')}`); return 1; }
384
+
385
+ const token = await loginToken(deps);
386
+ if (!token) { err(NOT_LOGGED_IN); return 1; }
387
+ secret = token;
388
+ const request = deps.apiRequestJson || apiRequestJson;
389
+ const call = (route, options) => request(route, { ...options, token });
390
+ const ctx = { ref: pos[0] || '', flags, cwd, env, call, log, err };
391
+ if (verb === 'pull') return pull(ctx);
392
+ if (verb === 'push') return push(ctx);
393
+ if (verb === 'snapshot') return snapshot(ctx);
394
+ if (verb === 'spawn') return spawn(ctx);
395
+ homeHelp(log);
396
+ return 1;
397
+ }
398
+
399
+ module.exports = {
400
+ agentHomeCommand,
401
+ isHomeCommand,
402
+ movesForward,
403
+ };
@@ -0,0 +1,214 @@
1
+ 'use strict';
2
+
3
+ // atris agent signin <agent> [--key-file <path>]: an agent proves it holds
4
+ // its own Ed25519 private key and gets a short-lived token, with no owner
5
+ // login. Backend: POST /api/agents/{id}/signin/challenge, then
6
+ // POST /api/agents/{id}/signin with {challenge, signature}
7
+ // (atrisos-backend services/agent_signin_service.py).
8
+ //
9
+ // Keys travel as standard base64 of the raw 32 byte private key, the same
10
+ // encoding `atris agent spawn` saves to ~/.atris/agents/<name>.json. The key
11
+ // is never printed, logged, written, or taken as an argument. On success
12
+ // stdout carries only the token, so TOKEN=$(atris agent signin ...) works.
13
+
14
+ const fs = require('fs');
15
+ const path = require('path');
16
+ const crypto = require('crypto');
17
+ const { buildApiUrl, httpRequest, DEFAULT_CLIENT_ID, DEFAULT_USER_AGENT } = require('../utils/api');
18
+ const { oneLine } = require('../lib/developer-api');
19
+ const { parseArgs } = require('../lib/business-target');
20
+ const home = require('../lib/agent-home');
21
+
22
+ // The fixed first line of every sign-in message (MESSAGE_PREFIX on the server).
23
+ const MESSAGE_PREFIX = 'atris-agent-signin-v1';
24
+ // DER header of a PKCS#8 Ed25519 private key; the 32 byte seed follows it.
25
+ const ED25519_PKCS8_PREFIX = Buffer.from('302e020100300506032b657004220420', 'hex');
26
+ const UUID_RE = /^[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}$/i;
27
+ const KEY_B64_RE = /^[A-Za-z0-9+/]{43}=$/;
28
+
29
+ function signinHelp(log) {
30
+ log(`usage: atris agent signin <agent> [--key-file <path>] [--json]
31
+
32
+ Sign in as an agent with its own private key and print a token for it.
33
+ Only the token goes to stdout, so this works: TOKEN=$(atris agent signin scout)
34
+
35
+ <agent> is an agent id, or a name saved by atris agent spawn.
36
+ --key-file a file holding the private key: the JSON file spawn saves, or the
37
+ base64 key on its own. Left off, the key spawn saved for that
38
+ agent in ~/.atris/agents is used.
39
+ --json print the server's whole answer as JSON instead of the token.
40
+ The key is never taken on the command line and never printed.`);
41
+ }
42
+
43
+ function compactId(value) {
44
+ return String(value || '').toLowerCase().replace(/[^0-9a-f]/g, '');
45
+ }
46
+
47
+ // Saved spawn records in ~/.atris/agents: [{ file, record }].
48
+ function savedKeys(env) {
49
+ const dir = path.dirname(home.keyPath('x', env));
50
+ let names = [];
51
+ try { names = fs.readdirSync(dir).filter((n) => n.endsWith('.json')).sort(); } catch { return []; }
52
+ const out = [];
53
+ for (const name of names) {
54
+ const file = path.join(dir, name);
55
+ try { out.push({ file, record: JSON.parse(fs.readFileSync(file, 'utf8')) }); } catch {}
56
+ }
57
+ return out;
58
+ }
59
+
60
+ // { id, label, keyFile } for the word the user typed, or { error }.
61
+ function resolveAgent(ref, flags, cwd, env) {
62
+ const handle = home.handleOf(ref);
63
+ const saved = handle ? home.readKeyFile(handle, env) : null;
64
+ let id = ref;
65
+ let label = ref;
66
+ let keyFile = typeof flags.key_file === 'string' ? path.resolve(cwd, flags.key_file) : null;
67
+ if (saved && saved.agent_id && !UUID_RE.test(ref)) {
68
+ id = String(saved.agent_id);
69
+ label = handle;
70
+ if (!keyFile) keyFile = home.keyPath(handle, env);
71
+ }
72
+ if (!UUID_RE.test(id)) return { error: `${ref} is not an agent id or a name saved by atris agent spawn` };
73
+ if (!keyFile) {
74
+ const match = savedKeys(env).find(({ record }) => record && compactId(record.agent_id) === compactId(id) && record.private_key);
75
+ if (match) keyFile = match.file;
76
+ }
77
+ if (!keyFile) return { error: `no private key for ${label}. pass --key-file <path>` };
78
+ return { id, label, keyFile };
79
+ }
80
+
81
+ // The base64 private key from a key file, or { error } that never quotes the file.
82
+ function readPrivateKey(keyFile, agentId) {
83
+ let text;
84
+ try {
85
+ text = fs.readFileSync(keyFile, 'utf8').trim();
86
+ } catch (e) {
87
+ if (e && e.code === 'ENOENT') return { error: `the key file ${keyFile} does not exist` };
88
+ return { error: `could not read the key file ${keyFile}` };
89
+ }
90
+ let key = text;
91
+ if (text.startsWith('{')) {
92
+ let record = null;
93
+ try { record = JSON.parse(text); } catch {}
94
+ if (!record || typeof record.private_key !== 'string') {
95
+ return { error: `${keyFile} holds no private key${record && record.status === 'pending' ? ' (its spawn never finished)' : ''}` };
96
+ }
97
+ if (record.agent_id && compactId(record.agent_id) !== compactId(agentId)) {
98
+ return { error: `${keyFile} holds the key of another agent (${oneLine(record.agent_id)})` };
99
+ }
100
+ key = record.private_key.trim();
101
+ }
102
+ if (!KEY_B64_RE.test(key) || Buffer.from(key, 'base64').length !== 32) {
103
+ return { error: `${keyFile} does not hold a valid agent private key (expected base64 of 32 bytes)` };
104
+ }
105
+ return { key };
106
+ }
107
+
108
+ // Standard base64 Ed25519 signature over the UTF-8 message, as agent_keys.sign does.
109
+ function sign(privateB64, message) {
110
+ const der = Buffer.concat([ED25519_PKCS8_PREFIX, Buffer.from(privateB64, 'base64')]);
111
+ const keyObject = crypto.createPrivateKey({ key: der, format: 'der', type: 'pkcs8' });
112
+ return crypto.sign(null, Buffer.from(message, 'utf8'), keyObject).toString('base64');
113
+ }
114
+
115
+ // Sign only a real sign-in message for this agent and this challenge, so a
116
+ // server that is not Atris cannot get the key to sign anything else.
117
+ function isSigninMessage(message, agentId, challenge) {
118
+ if (typeof message !== 'string') return false;
119
+ const lines = message.split('\n');
120
+ return lines.length === 3 && lines[0] === MESSAGE_PREFIX
121
+ && compactId(lines[1]) === compactId(agentId) && lines[1] === lines[1].toLowerCase()
122
+ && lines[2] === challenge;
123
+ }
124
+
125
+ async function post(route, body) {
126
+ const headers = { 'User-Agent': DEFAULT_USER_AGENT, 'X-Atris-Client': DEFAULT_CLIENT_ID };
127
+ let payload;
128
+ if (body !== undefined) {
129
+ payload = JSON.stringify(body);
130
+ headers['Content-Type'] = 'application/json';
131
+ }
132
+ try {
133
+ const res = await httpRequest(buildApiUrl(route), { method: 'POST', headers, body: payload, timeoutMs: 30000 });
134
+ let data = null;
135
+ try { data = JSON.parse(res.body.toString('utf8')); } catch {}
136
+ return { status: res.status, headers: res.headers || {}, data };
137
+ } catch (e) {
138
+ return { status: 0, headers: {}, data: null, error: e && e.message ? e.message : 'network error' };
139
+ }
140
+ }
141
+
142
+ // The refusal in one plain sentence.
143
+ function why(res) {
144
+ const detail = res.data && typeof res.data === 'object' && typeof res.data.detail === 'string' ? oneLine(res.data.detail) : '';
145
+ if (res.status === 0) return `could not reach the Atris server${res.error ? ` (${oneLine(res.error)})` : ''}`;
146
+ if (res.status === 404) return 'sign-in by key is switched off on this server';
147
+ if (res.status === 401) return 'the server refused this key for that agent. It never says why: a wrong key, an unknown or removed agent, or a used or expired challenge';
148
+ if (res.status === 429) {
149
+ const wait = Number.parseInt(res.headers['retry-after'], 10);
150
+ const when = Number.isFinite(wait) && wait > 0 ? `in ${wait} second${wait === 1 ? '' : 's'}` : 'later';
151
+ return `too many sign-in attempts. Try again ${when}`;
152
+ }
153
+ if (res.status === 503) return `sign-in by key is not set up on this server right now${detail ? ` (${detail.replace(/[.\s]*$/, '')})` : ''}`;
154
+ return detail || `the server answered ${res.status}`;
155
+ }
156
+
157
+ function lasts(seconds) {
158
+ const s = Number(seconds);
159
+ if (!Number.isFinite(s) || s <= 0) return null;
160
+ if (s % 3600 === 0) return `${s / 3600} hour${s === 3600 ? '' : 's'}`;
161
+ if (s >= 3600) return `about ${Math.round(s / 3600)} hours`;
162
+ return `${Math.max(1, Math.round(s / 60))} minutes`;
163
+ }
164
+
165
+ async function agentSigninCommand(args = [], deps = {}) {
166
+ const secrets = [];
167
+ const hide = (line) => secrets.reduce((text, s) => (s ? text.split(s).join('[hidden]') : text), String(line));
168
+ const out = deps.out || ((text) => process.stdout.write(text));
169
+ const err = (line) => (deps.err || console.error)(hide(line));
170
+ const cwd = deps.cwd || process.cwd();
171
+ const env = deps.env || process.env;
172
+
173
+ const { flags, pos, error } = parseArgs(args, { switches: [], values: ['key_file'] });
174
+ if (flags.help || pos[0] === 'help') { signinHelp((line) => out(`${line}\n`)); return 0; }
175
+ if (error) { err(`atris agent signin: ${error}. see: atris agent signin --help`); return 1; }
176
+ if (pos.length !== 1) { err('atris agent signin takes one agent, like: atris agent signin <agent> --key-file <path>'); return 1; }
177
+
178
+ const agent = resolveAgent(pos[0], flags, cwd, env);
179
+ if (agent.error) { err(`could not sign in: ${agent.error}`); return 1; }
180
+ const { key, error: keyError } = readPrivateKey(agent.keyFile, agent.id);
181
+ if (keyError) { err(`could not sign in ${agent.label}: ${keyError}`); return 1; }
182
+ secrets.push(key);
183
+
184
+ const base = `/agents/${encodeURIComponent(agent.id)}/signin`;
185
+ const asked = await post(`${base}/challenge`);
186
+ if (asked.status !== 200) { err(`could not sign in ${agent.label}: ${why(asked)}`); return 1; }
187
+ const challenge = asked.data && asked.data.challenge;
188
+ if (typeof challenge !== 'string' || !isSigninMessage(asked.data.message, agent.id, challenge)) {
189
+ err(`could not sign in ${agent.label}: the server sent a challenge that is not an Atris sign-in, so nothing was signed`);
190
+ return 1;
191
+ }
192
+
193
+ const signature = sign(key, asked.data.message);
194
+ const res = await post(base, { challenge, signature });
195
+ if (res.status !== 200) { err(`could not sign in ${agent.label}: ${why(res)}`); return 1; }
196
+ const data = res.data || {};
197
+ if (typeof data.token !== 'string' || !data.token) {
198
+ err(`could not sign in ${agent.label}: the server sent an answer this version of atris cannot read`);
199
+ return 1;
200
+ }
201
+ secrets.push(data.token);
202
+
203
+ if (flags.json) { out(`${JSON.stringify(data, null, 2)}\n`); return 0; }
204
+ out(`${data.token}\n`);
205
+ const name = data.home && typeof data.home.name === 'string' && data.home.name.trim() ? data.home.name.trim() : agent.label;
206
+ const id = data.agent_id || agent.id;
207
+ const life = lasts(data.expires_in);
208
+ err(`Signed in as ${name}${name === id ? '' : ` (${id})`}.${life ? ` The token lasts ${life}; sign in again for a fresh one.` : ''}`);
209
+ return 0;
210
+ }
211
+
212
+ module.exports = {
213
+ agentSigninCommand,
214
+ };
@@ -198,6 +198,7 @@ function showSpawnHelp(output = console.log, commandName = 'atris agent spawn')
198
198
  output(`Usage: ${commandName} <role> --task "<bounded task>" [options]`);
199
199
  output('');
200
200
  output('Create a durable worker request that ax, humans, or another runtime can pick up.');
201
+ output('To make a child agent on the server instead: atris agent spawn <parent> --name <name> --budget <credits>');
201
202
  output('');
202
203
  output('Options:');
203
204
  output(' --task <text> Bounded task to delegate');