ai-agentmanager 1.10.0 → 1.11.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/src/fleet.js CHANGED
@@ -158,19 +158,20 @@ export const isLoopback = (h) => ['127.0.0.1', 'localhost', '[::1]', '::1'].incl
158
158
 
159
159
  /**
160
160
  * Hub URLs a runner accepts: https anywhere; plain http only on this machine or over
161
- * Tailscale, which already encrypts the traffic.
161
+ * Tailscale, which already encrypts the traffic, or on a private network you vouch for
162
+ * (AGENTMGR_HUB_ALLOW_HTTP=1: a hub and runners in one docker-compose or VPC).
162
163
  */
163
- export function checkHubUrl(raw) {
164
+ export function checkHubUrl(raw, env = process.env) {
164
165
  let u;
165
166
  try {
166
167
  u = new URL(String(raw ?? '').trim());
167
168
  } catch {
168
169
  throw Object.assign(new Error('Hub URL must look like https://hub.example.com'), { status: 400 });
169
170
  }
170
- if (u.protocol === 'https:' || (u.protocol === 'http:' && (isLoopback(u.hostname) || isTailscaleHost(u.hostname)))) {
171
+ if (u.protocol === 'https:' || (u.protocol === 'http:' && (isLoopback(u.hostname) || isTailscaleHost(u.hostname) || env.AGENTMGR_HUB_ALLOW_HTTP === '1'))) {
171
172
  return u.origin;
172
173
  }
173
- throw Object.assign(new Error('Use https:// for the hub (plain http only for localhost or a Tailscale address)'), { status: 400 });
174
+ throw Object.assign(new Error('Use https:// for the hub (plain http only for localhost, a Tailscale address, or a private network with AGENTMGR_HUB_ALLOW_HTTP=1)'), { status: 400 });
174
175
  }
175
176
 
176
177
  /** Signed by the runner's key on every connection, so the hub knows which runner it is. */
package/src/hub.js CHANGED
@@ -61,6 +61,12 @@ function newCode() {
61
61
  const chars = [...bytes].map((b) => CODE_ALPHABET[b % CODE_ALPHABET.length]).join('');
62
62
  return chars.match(/.{4}/g).join('-');
63
63
  }
64
+ /** Constant-time comparison of two strings (different lengths are simply unequal). */
65
+ const safeEqual = (a, b) => {
66
+ const x = Buffer.from(String(a));
67
+ const y = Buffer.from(String(b));
68
+ return x.length === y.length && crypto.timingSafeEqual(x, y);
69
+ };
64
70
  const normalizeCode = (c) => String(c ?? '').toUpperCase().replace(/[^A-Z0-9]/g, '');
65
71
 
66
72
  /** Google sign-in settings from the environment, or null when not configured. */
@@ -459,7 +465,11 @@ export async function startHub({
459
465
  if (!loginAllowed(req.ip)) throw fail(429, 'Too many attempts; try again in 15 minutes');
460
466
  const key = sha256(normalizeCode(req.body?.code));
461
467
  const invite = store.data.invites[key];
462
- if (!invite || Date.parse(invite.expiresAt) < Date.now()) {
468
+ // The runner token (AGENTMGR_HUB_RUNNER_TOKEN): a reusable secret for machines that join by
469
+ // themselves, such as cloud runners; compared in constant time, and failures count like any.
470
+ const token = process.env.AGENTMGR_HUB_RUNNER_TOKEN;
471
+ const viaToken = Boolean(token && token.length >= 24 && safeEqual(String(req.body?.code ?? ''), token));
472
+ if (!viaToken && (!invite || Date.parse(invite.expiresAt) < Date.now())) {
463
473
  loginFailed(req.ip);
464
474
  throw fail(401, 'Invite code is wrong, used or expired; create a new one on the hub');
465
475
  }
@@ -471,7 +481,7 @@ export async function startHub({
471
481
  } catch {
472
482
  throw fail(400, 'publicKey must be an Ed25519 public key (PEM)');
473
483
  }
474
- delete store.data.invites[key]; // single use
484
+ if (!viaToken) delete store.data.invites[key]; // single use
475
485
  const base = String(req.body?.name ?? '').toLowerCase().replace(/[^\w.-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 32) || 'machine';
476
486
  const taken = new Set(Object.values(store.data.runners).map((r) => r.name));
477
487
  let runnerName = base;
@@ -479,7 +489,7 @@ export async function startHub({
479
489
  const id = crypto.randomBytes(4).toString('hex');
480
490
  store.data.runners[id] = { id, name: runnerName, publicKey, enrolledAt: now(), lastSeen: null, version: String(req.body?.version ?? '').slice(0, 20) || null };
481
491
  store.save();
482
- log(`runner ${runnerName} (${id}) joined from ${req.ip}`);
492
+ log(`runner ${runnerName} (${id}) joined from ${req.ip}${viaToken ? ' with the runner token' : ''}`);
483
493
  changed();
484
494
  return { runnerId: id, name: runnerName, hubName: name };
485
495
  }),
package/src/hublink.js CHANGED
@@ -47,10 +47,12 @@ export function audit(line) {
47
47
  } catch {}
48
48
  }
49
49
 
50
- export function hubPolicy(config) {
50
+ export function hubPolicy(config, env = process.env) {
51
51
  const h = config.hub ?? {};
52
+ // A cloud runner sets it in its environment (AGENTMGR_HUB_ALLOW=view,control,move): no config file to edit.
53
+ const fromEnv = env.AGENTMGR_HUB_ALLOW ? env.AGENTMGR_HUB_ALLOW.split(/[\s,]+/).filter(Boolean) : null;
52
54
  return {
53
- allow: Array.isArray(h.allow) ? h.allow.map(String) : DEFAULT_HUB_POLICY.allow,
55
+ allow: fromEnv ?? (Array.isArray(h.allow) ? h.allow.map(String) : DEFAULT_HUB_POLICY.allow),
54
56
  projects: Array.isArray(h.projects) && h.projects.length ? h.projects.map(String) : DEFAULT_HUB_POLICY.projects,
55
57
  };
56
58
  }
package/src/inbox.js CHANGED
@@ -87,6 +87,20 @@ export function promptChoices(screen) {
87
87
  break;
88
88
  }
89
89
  }
90
+ // Newer Claude Code can draw a menu as one run of text ("Do you want to proceed? ❯ 1. Yes 2. Yes,
91
+ // and switch to auto mode 3. No Esc to cancel"): read the flattened screen after the last question.
92
+ if (!choices.size) {
93
+ const flat = lines.join(' ').replace(/\s+/g, ' ');
94
+ const at = flat.search(/\?\s*(?:[❯>›]\s*)?1[.)]\s*\S[^?]*$/);
95
+ if (at >= 0) {
96
+ const head = flat.slice(0, at + 1);
97
+ // Drop what's left of the box around it (╌ ─ │ and stray letters before them).
98
+ question = (/([^.?!│]*\?)$/.exec(head)?.[1] ?? '').replace(/^.*[╌─│━]+\s*/, '').trim().slice(-200);
99
+ for (const m of flat.slice(at + 1).matchAll(/(?:[❯>›]\s*)?\b(\d)[.)]\s*(.+?)(?=\s+(?:[❯>›]\s*)?\d[.)]\s|\s+Esc to cancel|\s+Enter to|$)/g)) {
100
+ if (!choices.has(m[1])) choices.set(m[1], m[2].replace(/\s*·.*$/, '').trim().slice(0, 80));
101
+ }
102
+ }
103
+ }
90
104
  return {
91
105
  question,
92
106
  choices: [...choices.entries()].sort(([a], [b]) => a - b).map(([key, label]) => ({ key, label })),
package/src/manager.js CHANGED
@@ -102,6 +102,7 @@ import * as git from './git.js';
102
102
  import { parseDiff } from '../public/review.js';
103
103
  import { issueSummary, leadingIssue, wordsAfterLink } from './jira.js';
104
104
  import { combine, facts as scoreFacts, scorecardMarkdown, scorecardPrompt } from './scorecard.js';
105
+ import { CONTAINER_ENGINES, claudeToken, containerName, containerRunning, engineMounts, gitCommonDir, removeContainer, runArgs, stopContainer } from './container.js';
105
106
  import { conflictMessage, findConflicts, related, repoKey, summarize as summarizeConflicts } from './conflicts.js';
106
107
  import { answerMessage, loadQuestions, questionsPath, questionsPrompt } from './inbox.js';
107
108
  import { STUCK_DEFAULTS, detectStuck } from './stuck.js';
@@ -594,6 +595,7 @@ export class AgentManager extends EventEmitter {
594
595
  startedBy,
595
596
  trigger,
596
597
  attachments,
598
+ isolation,
597
599
  }) {
598
600
  intent = (intent ?? '').trim();
599
601
  const staged = stagedFiles(attachments);
@@ -607,6 +609,12 @@ export class AgentManager extends EventEmitter {
607
609
  }
608
610
  // Planning while cloning moves the session between folders, which needs the engine's support.
609
611
  if (!engineDef.supports.planHandoff) plan = false;
612
+ // Isolation: "container" runs it in Docker (config "isolation": { "default": "container" } for all).
613
+ isolation = isolation === undefined ? this.config.isolation?.default ?? null : isolation || null;
614
+ if (isolation && isolation !== 'container') throw Object.assign(new Error('isolation is "container" or nothing'), { status: 400 });
615
+ if (isolation && !CONTAINER_ENGINES.has(engineId)) throw Object.assign(new Error(`${engineDef.label} can't run in a container yet (Claude Code and Codex can)`), { status: 400 });
616
+ // The session moves between folders mid-plan; a container is bound to one folder.
617
+ if (isolation) plan = false;
610
618
  const general = NO_REPO.has(kind);
611
619
  // A project shortcut (or name) fills in the repo / folder / base branch.
612
620
  const entry = !general && project ? this.resolveProject(project) : null;
@@ -639,6 +647,7 @@ export class AgentManager extends EventEmitter {
639
647
  priority: parsePriority(priority),
640
648
  ...(startedBy ? { startedBy } : {}),
641
649
  ...(trigger ? { trigger } : {}),
650
+ ...(isolation ? { isolation } : {}),
642
651
  name: name?.trim() || jiraKey || slug,
643
652
  slug,
644
653
  intent,
@@ -995,6 +1004,26 @@ export class AgentManager extends EventEmitter {
995
1004
  return this.view(this.store.get(agent.id));
996
1005
  }
997
1006
 
1007
+ /**
1008
+ * `docker run` for an agent in a container (see container.js): its folder, its repository's git
1009
+ * data, its brief and uploads, the engine's homes and sign-in; nothing else of this computer.
1010
+ */
1011
+ containerSpec(agent, engine, cwd, args) {
1012
+ const name = containerName(agent.id);
1013
+ removeContainer(name); // a leftover from a crash or a restart would block the name
1014
+ const em = engineMounts(engine.id, cwd);
1015
+ const real = (p) => (fs.existsSync(p) ? fs.realpathSync(p) : null);
1016
+ const git = gitCommonDir(cwd);
1017
+ // Plus any folders config.json adds ("isolation": { "mounts": [{ "host": "~/.npm", "readOnly": true }] }).
1018
+ const extra = (this.config.isolation?.mounts ?? []).map((m) => ({ ...m, host: real(expandHome(m.host)) }));
1019
+ const mounts = [...em.mounts, ...(git ? [{ host: git }] : []), { host: real(briefDir(agent.id)) }, { host: real(path.join(paths.uploads, agent.id)), readOnly: true }, ...extra].filter((m) => m.host);
1020
+ const bin = this.config.isolation?.bin?.[engine.id] ?? em.bin;
1021
+ const env = { ...this.childEnv(agent), ...(claudeToken() ? { CLAUDE_CODE_OAUTH_TOKEN: claudeToken() } : {}) };
1022
+ // Variables passed in by name (values stay out of the command line): the engine's, plus config's "isolation.env".
1023
+ const inner = ['TERM=xterm-256color', 'COLORTERM=truecolor', `AGENTMGR_AGENT_ID=${agent.id}`, 'AGENTMGR_TERMINAL=1', ...em.env, ...(this.config.isolation?.env ?? [])];
1024
+ return { name, cmd: 'docker', env, args: runArgs({ agentId: agent.id, engineId: engine.id, cwd: fs.realpathSync(cwd), bin, args, config: this.config, mounts, env: inner }) };
1025
+ }
1026
+
998
1027
  /** What every launch of this agent passes its engine: options, its brief folder, instructions. */
999
1028
  launchOptions(agent, { planning = false } = {}) {
1000
1029
  const engine = engineOf(agent);
@@ -1098,19 +1127,23 @@ export class AgentManager extends EventEmitter {
1098
1127
  }
1099
1128
  }
1100
1129
 
1101
- if (!installed(engine, this.config)) {
1130
+ // In a container the engine is inside the image, not on this computer.
1131
+ const container = agent.isolation === 'container' && !NO_REPO.has(agent.kind);
1132
+ if (!container && !installed(engine, this.config)) {
1102
1133
  const error = notInstalled(engine, this.config);
1103
1134
  this.patch(agent.id, { status: 'error', error });
1104
1135
  throw Object.assign(new Error(error), { status: 400 });
1105
1136
  }
1106
1137
  let proc;
1107
1138
  try {
1108
- proc = pty.spawn(engine.bin(this.config), args, {
1139
+ const spec = container ? this.containerSpec(agent, engine, cwd, args) : { cmd: engine.bin(this.config), args, env: this.childEnv(agent) };
1140
+ rt.container = spec.name ?? null;
1141
+ proc = pty.spawn(spec.cmd, spec.args, {
1109
1142
  name: 'xterm-256color',
1110
1143
  cols: rt.cols,
1111
1144
  rows: rt.rows,
1112
1145
  cwd,
1113
- env: this.childEnv(agent),
1146
+ env: spec.env,
1114
1147
  });
1115
1148
  } catch (err) {
1116
1149
  this.patch(agent.id, { status: 'error', error: `Failed to launch ${engine.bin(this.config)}: ${err.message}` });
@@ -1233,9 +1266,14 @@ export class AgentManager extends EventEmitter {
1233
1266
  done.dispose();
1234
1267
  resolve(this.view(this.store.get(agent.id) ?? agent));
1235
1268
  });
1236
- // A hangup, as when a terminal closes, unless the engine shuts down cleanly on another signal.
1237
- rt.pty.kill(engineOf(agent).stopSignal ?? 'SIGHUP');
1238
- rt.killTimer = setTimeout(() => rt.pty?.kill('SIGKILL'), timeoutMs);
1269
+ // A container: docker stop (the engine gets SIGTERM inside; the docker command then ends).
1270
+ // Otherwise a hangup, as when a terminal closes, unless the engine ends cleanly on another signal.
1271
+ if (rt.container) stopContainer(rt.container, 10);
1272
+ else rt.pty.kill(engineOf(agent).stopSignal ?? 'SIGHUP');
1273
+ rt.killTimer = setTimeout(() => {
1274
+ if (rt.container) removeContainer(rt.container);
1275
+ rt.pty?.kill('SIGKILL');
1276
+ }, timeoutMs);
1239
1277
  });
1240
1278
  }
1241
1279
 
@@ -1509,7 +1547,10 @@ export class AgentManager extends EventEmitter {
1509
1547
  rows: prev?.rows ?? 34,
1510
1548
  };
1511
1549
  if (rt.size) this.appendOutput(rt, '\r\n\x1b[2m── new shell ──\x1b[0m\r\n');
1512
- const proc = pty.spawn(this.config.shell, ['-l'], {
1550
+ // An agent in a running container: the shell opens inside it, with what the agent sees.
1551
+ const inside = agent.isolation === 'container' && containerRunning(containerName(agent.id));
1552
+ const [cmd, cmdArgs] = inside ? ['docker', ['exec', '-it', '-w', fs.realpathSync(agent.workdir), containerName(agent.id), 'bash', '-l']] : [this.config.shell, ['-l']];
1553
+ const proc = pty.spawn(cmd, cmdArgs, {
1513
1554
  name: 'xterm-256color',
1514
1555
  cols: rt.cols,
1515
1556
  rows: rt.rows,
@@ -2812,7 +2853,8 @@ export class AgentManager extends EventEmitter {
2812
2853
  const keys = { esc: '\x1b', enter: '\r', up: '\x1b[A', down: '\x1b[B' };
2813
2854
  const data = keys[key] ?? (/^\d$/.test(key) ? key : null);
2814
2855
  if (!data) throw Object.assign(new Error('Unsupported key'), { status: 400 });
2815
- this.write(agent.id, data);
2856
+ // Menus that read a line (the ACP client's) take Enter after the choice.
2857
+ this.write(agent.id, engineOf(agent).promptKeyEnter && key !== 'enter' ? `${data}\r` : data);
2816
2858
  return { ok: true };
2817
2859
  }
2818
2860
 
package/src/server.js CHANGED
@@ -25,7 +25,7 @@ import { Studio } from './studio.js';
25
25
  import { Memory } from './memory.js';
26
26
  import { doctor } from './doctor.js';
27
27
  import { Triggers, tokenMatches } from './triggers.js';
28
- import { listEngines, registerEngine } from './engines/index.js';
28
+ import { listEngines, registerAcpEngines, registerEngine } from './engines/index.js';
29
29
  import { Plugins, pluginSpecs } from './plugins.js';
30
30
  import { HubLink, linkUrls } from './hublink.js';
31
31
  import { Notifier, isWorkflowUrl, loadNotify, publicNotify, saveNotify, sendSlack } from './notify.js';
@@ -34,6 +34,7 @@ import { LiveBrowser } from './livebrowser.js';
34
34
  import * as remote from './remote.js';
35
35
  import * as service from './service.js';
36
36
  import { jiraView, saveJira } from './jira.js';
37
+ import { CONTAINER_ENGINES, buildImage, claudeToken, dockerStatus, isBuilding, saveSecrets } from './container.js';
37
38
  import { Store } from './store.js';
38
39
  import { about, latestRelease } from './about.js';
39
40
 
@@ -63,7 +64,7 @@ function isAllowedOrigin(origin, port) {
63
64
  const PUBLIC_REMOTE_API = new Set(['POST /api/login', 'GET /api/session']);
64
65
  // Updates are not here: a signed-in device (a phone) can install the newest release too. It only
65
66
  // ever installs an official release from where this copy came from (git tag or npm) and restarts.
66
- const LOCAL_ONLY_API = /^\/api\/(remote(\/|$)|notify(\/|$)|hub(\/|$)|triggers(\/|$)|service$|jira$|editors$|agents\/[\w-]+\/open-in$|shutdown$)/;
67
+ const LOCAL_ONLY_API = /^\/api\/(remote(\/|$)|notify(\/|$)|hub(\/|$)|triggers(\/|$)|service$|jira$|isolation\/|editors$|agents\/[\w-]+\/open-in$|shutdown$)/;
67
68
  const HOOK_PATH = '/api/hooks/agents';
68
69
  const NO_REPO_KINDS = new Set(['general', 'assistant']);
69
70
 
@@ -73,6 +74,8 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
73
74
  const store = new Store(paths.state);
74
75
  const plugins = await new Plugins({ log: (m) => console.log(`[agentmgr] ${m}`) }).load(pluginSpecs(config), { config, base: paths.home });
75
76
  for (const { def } of plugins.engines) registerEngine(def);
77
+ // ACP agents (Gemini CLI, OpenCode, Goose, … and config's "acp") become engines.
78
+ registerAcpEngines(config);
76
79
  const manager = new AgentManager({ store, config, plugins });
77
80
  const backlog = new Backlog({ manager, store, config });
78
81
  const races = new Races({ manager, store });
@@ -513,6 +516,28 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
513
516
  api.get('/agents/:id/memory', wrap((req) => memory.read(req.params.id)));
514
517
  api.put('/agents/:id/memory', wrap((req) => memory.write(req.params.id, req.body?.text)));
515
518
  api.post('/agents/:id/learn', wrap((req) => memory.learn(req.params.id, req.body?.text, { by: req.headers['x-agentmgr-agent'] ? undefined : 'you' })));
519
+ // Isolation: Docker, the agent image, the Claude token for containers, and the default for new agents.
520
+ let buildError = null;
521
+ const isolationView = async () => ({ ...(await dockerStatus(config)), building: isBuilding(), buildError, hasClaudeToken: Boolean(claudeToken()), engines: [...CONTAINER_ENGINES], default: config.isolation?.default ?? null });
522
+ api.get('/isolation', wrap(isolationView));
523
+ api.post(
524
+ '/isolation/build',
525
+ wrap(async () => {
526
+ buildError = null;
527
+ buildImage(config).catch((err) => (buildError = err.message));
528
+ return isolationView();
529
+ }),
530
+ );
531
+ api.put('/isolation/token', wrap(async (req) => (saveSecrets({ claudeOauthToken: String(req.body?.token ?? '').trim() || null }), isolationView())));
532
+ api.put(
533
+ '/isolation/default',
534
+ wrap(async (req) => {
535
+ const value = req.body?.container ? 'container' : null;
536
+ config.isolation = { ...config.isolation, default: value };
537
+ saveConfig({ isolation: config.isolation });
538
+ return isolationView();
539
+ }),
540
+ );
516
541
  // Jira: the site and API token used to show an issue's summary for agents created from its link.
517
542
  api.get('/jira', wrap(() => jiraView()));
518
543
  api.put(
@@ -971,6 +996,14 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
971
996
  }
972
997
  manager.refreshAll().catch(() => {});
973
998
  hubLink.start();
999
+ // A cloud runner joins its hub by itself on first start (AGENTMGR_HUB_URL and AGENTMGR_HUB_CODE: an
1000
+ // invite, or the hub's runner token), and stays joined across restarts (the link is kept on disk).
1001
+ if (process.env.AGENTMGR_HUB_URL && process.env.AGENTMGR_HUB_CODE && !hubLink.status().joined) {
1002
+ hubLink
1003
+ .join(process.env.AGENTMGR_HUB_URL, process.env.AGENTMGR_HUB_CODE, process.env.AGENTMGR_RUNNER_NAME || undefined)
1004
+ .then((st) => console.log(`[agentmgr] joined the hub ${st.url} as ${st.name}`))
1005
+ .catch((err) => console.error(`[agentmgr] could not join the hub ${process.env.AGENTMGR_HUB_URL}: ${err.message}`));
1006
+ }
974
1007
  return {
975
1008
  hubLink,
976
1009
  server,
package/src/trust.js CHANGED
@@ -16,9 +16,8 @@ export function claudeConfigFile() {
16
16
  * before writing, only written when something changes, and replaced atomically.
17
17
  * Returns true if it changed anything.
18
18
  */
19
- export function trustFolder(dir) {
19
+ export function trustFolder(dir, file = claudeConfigFile()) {
20
20
  const real = fs.realpathSync(dir);
21
- const file = claudeConfigFile();
22
21
  let raw = '{}';
23
22
  try {
24
23
  raw = fs.readFileSync(file, 'utf8');