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/CHANGELOG.md +7 -0
- package/README.md +26 -3
- package/bin/acp-client.js +332 -0
- package/docker/agent.Dockerfile +15 -0
- package/docker/compose.runner.yml +22 -0
- package/docker/runner-entrypoint.sh +12 -0
- package/docker/runner.Dockerfile +31 -0
- package/docs/cloud-runner.md +62 -0
- package/package.json +2 -1
- package/public/app.js +32 -2
- package/public/index.html +14 -1
- package/public/menu.js +39 -0
- package/public/style.css +6 -0
- package/src/backlog.js +4 -1
- package/src/cli.js +36 -0
- package/src/container.js +189 -0
- package/src/engines/acp.js +222 -0
- package/src/engines/index.js +10 -2
- package/src/fleet.js +5 -4
- package/src/hub.js +13 -3
- package/src/hublink.js +4 -2
- package/src/inbox.js +14 -0
- package/src/manager.js +50 -8
- package/src/server.js +35 -2
- package/src/trust.js +1 -2
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
1237
|
-
|
|
1238
|
-
rt.
|
|
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
|
-
|
|
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
|
-
|
|
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');
|