@indigoai-us/hq-cli 5.345.104 → 5.345.105
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 +93 -0
- package/assets/bot-workers/setup/skills/add-a-pack.md +130 -0
- package/assets/bot-workers/setup/worker.yaml +3 -0
- package/dist/command-catalog.generated.d.ts +206 -0
- package/dist/command-catalog.generated.js +246 -0
- package/dist/commands/bot.d.ts +10 -0
- package/dist/commands/bot.js +20 -12
- package/dist/commands/daemon.js +385 -1
- package/dist/commands/integrations-connect.d.ts +6 -0
- package/dist/commands/integrations-connect.js +36 -0
- package/dist/commands/integrations-slack.d.ts +30 -0
- package/dist/commands/integrations-slack.js +81 -0
- package/dist/commands/integrations.js +2 -0
- package/dist/commands/pack-install.d.ts +20 -1
- package/dist/commands/pack-install.js +80 -5
- package/dist/commands/pack-start-bot.d.ts +24 -0
- package/dist/commands/pack-start-bot.js +76 -0
- package/dist/commands/packs.d.ts +9 -0
- package/dist/commands/packs.js +26 -3
- package/dist/commands/pkg-install.js +11 -2
- package/dist/commands/secrets.d.ts +1 -5
- package/dist/commands/secrets.js +2 -92
- package/dist/lib/bot/config.d.ts +7 -0
- package/dist/lib/bot/daemon.js +2 -1
- package/dist/lib/bot/index.d.ts +2 -0
- package/dist/lib/bot/index.js +2 -0
- package/dist/lib/bot/lean-settings.d.ts +16 -0
- package/dist/lib/bot/lean-settings.js +34 -0
- package/dist/lib/bot/pack-link.d.ts +66 -0
- package/dist/lib/bot/pack-link.js +153 -0
- package/dist/lib/bot/pack-setup-driver.d.ts +37 -0
- package/dist/lib/bot/pack-setup-driver.js +83 -0
- package/dist/lib/bot/pack-setup.d.ts +62 -0
- package/dist/lib/bot/pack-setup.js +103 -0
- package/dist/lib/bot/prompt.d.ts +8 -0
- package/dist/lib/bot/prompt.js +5 -0
- package/dist/lib/bot/run.d.ts +18 -2
- package/dist/lib/bot/run.js +163 -25
- package/dist/lib/bot/runtime/claude.d.ts +27 -2
- package/dist/lib/bot/runtime/claude.js +49 -4
- package/dist/lib/bot/runtime/types.d.ts +5 -0
- package/dist/lib/bot/runtime-failure.d.ts +18 -2
- package/dist/lib/bot/runtime-failure.js +21 -1
- package/dist/lib/bot/toolchain.d.ts +25 -0
- package/dist/lib/bot/toolchain.js +33 -0
- package/dist/lib/bot/worker-source.d.ts +1 -0
- package/dist/lib/bot/worker-source.js +7 -1
- package/dist/lib/daemon/catalog.d.ts +15 -0
- package/dist/lib/daemon/catalog.js +49 -0
- package/dist/lib/daemon/paths.d.ts +1 -0
- package/dist/lib/daemon/paths.js +1 -0
- package/dist/lib/daemon/remote-control-forwarder.d.ts +14 -0
- package/dist/lib/daemon/remote-control-forwarder.js +104 -0
- package/dist/lib/daemon/remote-control-pools.d.ts +44 -0
- package/dist/lib/daemon/remote-control-pools.js +245 -0
- package/dist/lib/daemon/remote-control.d.ts +4 -0
- package/dist/lib/daemon/remote-control.js +17 -2
- package/dist/lib/daemon/run.js +126 -0
- package/dist/lib/doctor/fix/refresh-sync-progress.d.ts +5 -4
- package/dist/lib/doctor/fix/refresh-sync-progress.js +5 -4
- package/dist/lib/doctor/json-output.d.ts +5 -5
- package/dist/lib/doctor/json-output.js +2 -5
- package/dist/lib/packages/setup-block.d.ts +43 -0
- package/dist/lib/packages/setup-block.js +236 -0
- package/dist/lib/packages/setup-prompt.d.ts +34 -0
- package/dist/lib/packages/setup-prompt.js +128 -0
- package/dist/lib/packages/setup-takeover.d.ts +50 -0
- package/dist/lib/packages/setup-takeover.js +75 -0
- package/dist/lib/packages/start-bot.d.ts +47 -0
- package/dist/lib/packages/start-bot.js +66 -0
- package/dist/lib/session-company-lock.d.ts +7 -0
- package/dist/lib/session-company-lock.js +97 -0
- package/dist/types.d.ts +82 -0
- package/package.json +1 -1
|
@@ -111,7 +111,13 @@ function scanCoreWorkerDirs(hqRoot, io) {
|
|
|
111
111
|
return;
|
|
112
112
|
}
|
|
113
113
|
for (const e of entries) {
|
|
114
|
-
if (
|
|
114
|
+
if (e.name.startsWith(".") || SKIP_DIRS.has(e.name))
|
|
115
|
+
continue;
|
|
116
|
+
// A pack worker is a symlink (core/workers/public/<id> ->
|
|
117
|
+
// core/packages/<pack>/workers/<id>); a Dirent reports it as a link, not a
|
|
118
|
+
// directory, so follow it when it holds a worker.yaml.
|
|
119
|
+
const linkedWorker = !e.isDirectory() && e.isSymbolicLink?.() === true && io.existsSync(path.join(hqRoot, rel, e.name, "worker.yaml"));
|
|
120
|
+
if (!e.isDirectory() && !linkedWorker)
|
|
115
121
|
continue;
|
|
116
122
|
walk(path.join(rel, e.name), depth + 1);
|
|
117
123
|
}
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import type { MachineType } from "./machine-type.js";
|
|
13
13
|
import type { SyncOwner } from "./config.js";
|
|
14
|
+
import type { RemoteControlPoolsConfig } from "./remote-control-pools.js";
|
|
14
15
|
import { type SyncRunnerProcess } from "./sync-runner.js";
|
|
15
16
|
export declare const SERVICE_NAMES: readonly ["sync", "mesh", "heartbeat", "inbox", "bots", "remote-control", "codex-remote-control", "session-host", "lanes-reconcile", "search-index", "jobs-reconcile", "updater", "housekeeping"];
|
|
16
17
|
export type ServiceName = (typeof SERVICE_NAMES)[number];
|
|
@@ -125,6 +126,20 @@ export interface PlanContext {
|
|
|
125
126
|
tmuxPath: string | null;
|
|
126
127
|
/** Short host name, used to name this machine's remote-control session. */
|
|
127
128
|
hostName: string;
|
|
129
|
+
/** Main A Claude config dir shared by the pool's control plane. */
|
|
130
|
+
claudeConfigDir?: string;
|
|
131
|
+
/** Optional host-local RC pool desired state; absent/empty keeps legacy behavior. */
|
|
132
|
+
remoteControlPools?: RemoteControlPoolsConfig;
|
|
133
|
+
/** Invalid host-local desired state remains visible instead of disappearing. */
|
|
134
|
+
remoteControlPoolsError?: string;
|
|
135
|
+
/** Secret names whose presence was verified through HQ Secrets. */
|
|
136
|
+
availablePoolSecrets?: ReadonlySet<string>;
|
|
137
|
+
/** Secret names whose presence check failed for a reason other than absence. */
|
|
138
|
+
unverifiablePoolSecrets?: ReadonlySet<string>;
|
|
139
|
+
/** Pool accounts whose configured personal service-session capability is invalid or missing. */
|
|
140
|
+
invalidPoolServiceSessions?: ReadonlySet<string>;
|
|
141
|
+
availablePoolFrontends?: ReadonlySet<string>;
|
|
142
|
+
unverifiablePoolFrontends?: ReadonlySet<string>;
|
|
128
143
|
/** The session name the box's own runner used (`--name` in its script), kept when the daemon takes over. */
|
|
129
144
|
remoteControlName: string | null;
|
|
130
145
|
/** Absolute path of the login user's codex binary, or null. */
|
|
@@ -364,6 +364,55 @@ export function planUnits(services, ctx) {
|
|
|
364
364
|
units.push(unit);
|
|
365
365
|
}
|
|
366
366
|
}
|
|
367
|
+
// Pool units are desired-state instances rather than service-name toggles.
|
|
368
|
+
// This leaves the existing A unit and no-pools plan byte-for-byte unchanged.
|
|
369
|
+
for (const pool of ctx.remoteControlPools?.pools ?? []) {
|
|
370
|
+
units.push(base("remote-control", {
|
|
371
|
+
id: `remote-control-pool-forwarder:${pool.accountId}`,
|
|
372
|
+
description: `keeps the account ${pool.accountId} inference forwarder on 127.0.0.1:${pool.forwarderPort}`,
|
|
373
|
+
command: { type: "hq", args: ["daemon", "service", "remote-control-pool-forwarder-host", "--account-id", pool.accountId] },
|
|
374
|
+
env: { ...(ctx.hqRoot ? { HQ_ROOT: ctx.hqRoot } : {}), HQ_POOL_CONFIG: JSON.stringify([pool, ctx.remoteControlPools?.frontendCaPath, ctx.remoteControlPools?.serviceSessionId]) },
|
|
375
|
+
keepAcrossCliUpdates: true,
|
|
376
|
+
blocked: firstBlock([
|
|
377
|
+
[ctx.platform !== "linux", "Remote Control account pools require Linux mount namespaces"],
|
|
378
|
+
[!ctx.hqRoot, NO_HQ_ROOT],
|
|
379
|
+
[ctx.invalidPoolServiceSessions?.has(pool.accountId) === true, "configured serviceSessionId must reference an existing personal-only scope capability"],
|
|
380
|
+
[ctx.unverifiablePoolSecrets?.has(pool.secretName) === true, "could not verify the configured HQ Secret"],
|
|
381
|
+
[ctx.availablePoolSecrets?.has(pool.secretName) !== true, "the configured HQ Secret is missing"],
|
|
382
|
+
]),
|
|
383
|
+
}));
|
|
384
|
+
const unit = base("remote-control", {
|
|
385
|
+
id: `remote-control-pool:${pool.accountId}`,
|
|
386
|
+
description: `keeps Claude remote control running for account ${pool.accountId}`,
|
|
387
|
+
command: { type: "hq", args: ["daemon", "service", "remote-control-pool", "--account-id", pool.accountId] },
|
|
388
|
+
env: {
|
|
389
|
+
...(ctx.hqRoot ? { HQ_ROOT: ctx.hqRoot } : {}),
|
|
390
|
+
...(ctx.claudeConfigDir ? { CLAUDE_CONFIG_DIR: ctx.claudeConfigDir } : {}),
|
|
391
|
+
HQ_POOL_CONFIG: JSON.stringify([pool, ctx.remoteControlPools?.frontendCaPath, ctx.remoteControlPools?.serviceSessionId]),
|
|
392
|
+
},
|
|
393
|
+
keepAcrossCliUpdates: true,
|
|
394
|
+
blocked: firstBlock([
|
|
395
|
+
[ctx.platform !== "linux", "Remote Control account pools require Linux mount namespaces"],
|
|
396
|
+
[!ctx.claudePath, "the claude CLI is not installed"],
|
|
397
|
+
[!ctx.tmuxPath, "tmux is not installed (remote control runs Claude inside tmux)"],
|
|
398
|
+
[!ctx.hqRoot, NO_HQ_ROOT],
|
|
399
|
+
[ctx.invalidPoolServiceSessions?.has(pool.accountId) === true, "configured serviceSessionId must reference an existing personal-only scope capability"],
|
|
400
|
+
[ctx.unverifiablePoolSecrets?.has(pool.secretName) === true, "could not verify the configured HQ Secret"],
|
|
401
|
+
[ctx.availablePoolSecrets?.has(pool.secretName) !== true, "the configured HQ Secret is missing"],
|
|
402
|
+
[ctx.unverifiablePoolFrontends?.has(pool.accountId) === true, "could not verify the configured nginx frontend"],
|
|
403
|
+
[ctx.availablePoolFrontends?.has(pool.accountId) !== true, "the configured nginx frontend is not responding on HTTPS port 443"],
|
|
404
|
+
]),
|
|
405
|
+
});
|
|
406
|
+
units.push(unit);
|
|
407
|
+
}
|
|
408
|
+
if (ctx.remoteControlPoolsError) {
|
|
409
|
+
units.push(base("remote-control", {
|
|
410
|
+
id: "remote-control-pools:config",
|
|
411
|
+
description: "validates Claude Remote Control account pool desired state",
|
|
412
|
+
command: null,
|
|
413
|
+
blocked: { state: "unavailable", reason: `invalid remote-control pool config: ${ctx.remoteControlPoolsError}` },
|
|
414
|
+
}));
|
|
415
|
+
}
|
|
367
416
|
return units;
|
|
368
417
|
}
|
|
369
418
|
//# sourceMappingURL=catalog.js.map
|
package/dist/lib/daemon/paths.js
CHANGED
|
@@ -12,6 +12,7 @@ export function daemonPaths(home = os.homedir(), env = process.env) {
|
|
|
12
12
|
return {
|
|
13
13
|
root,
|
|
14
14
|
configPath: path.join(root, "config.json"),
|
|
15
|
+
remoteControlPoolsPath: path.join(root, "remote-control-pools.json"),
|
|
15
16
|
statePath: path.join(root, "state.json"),
|
|
16
17
|
lockPath: path.join(root, "daemon.pid"),
|
|
17
18
|
logsDir,
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** A narrow, loopback-only Anthropic inference proxy for one RC account pool. */
|
|
2
|
+
import * as https from "node:https";
|
|
3
|
+
import { type IncomingMessage, type Server } from "node:http";
|
|
4
|
+
export declare const POOL_UPSTREAM_TIMEOUT_MS = 600000;
|
|
5
|
+
export declare const POOL_UPSTREAM_CONNECT_TIMEOUT_MS = 30000;
|
|
6
|
+
export declare function inferenceForwardHeaders(incoming: IncomingMessage["headers"], secret: string): Record<string, string | string[]>;
|
|
7
|
+
export declare function poolInferencePathAllowed(method: string | undefined, requestUrl: string | undefined): boolean;
|
|
8
|
+
export declare function createInferenceForwarder(options: {
|
|
9
|
+
port: number;
|
|
10
|
+
secret: () => string | undefined;
|
|
11
|
+
upstream?: typeof https.request;
|
|
12
|
+
upstreamTimeoutMs?: number;
|
|
13
|
+
}): Server;
|
|
14
|
+
//# sourceMappingURL=remote-control-forwarder.d.ts.map
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/** A narrow, loopback-only Anthropic inference proxy for one RC account pool. */
|
|
2
|
+
import * as https from "node:https";
|
|
3
|
+
import * as dns from "node:dns";
|
|
4
|
+
import { createServer } from "node:http";
|
|
5
|
+
const STRIPPED_HEADERS = new Set([
|
|
6
|
+
"authorization", "x-api-key", "proxy-authorization", "proxy-authenticate", "proxy-connection", "host", "content-length",
|
|
7
|
+
"connection", "keep-alive", "te", "trailer", "transfer-encoding", "upgrade",
|
|
8
|
+
]);
|
|
9
|
+
export const POOL_UPSTREAM_TIMEOUT_MS = 600_000;
|
|
10
|
+
export const POOL_UPSTREAM_CONNECT_TIMEOUT_MS = 30_000;
|
|
11
|
+
export function inferenceForwardHeaders(incoming, secret) {
|
|
12
|
+
if (!secret)
|
|
13
|
+
throw new Error("the configured HQ Secret is empty");
|
|
14
|
+
const headers = {};
|
|
15
|
+
const hopByHop = new Set(STRIPPED_HEADERS);
|
|
16
|
+
const connection = incoming.connection;
|
|
17
|
+
for (const value of Array.isArray(connection) ? connection : connection ? [connection] : []) {
|
|
18
|
+
for (const name of value.split(","))
|
|
19
|
+
if (name.trim())
|
|
20
|
+
hopByHop.add(name.trim().toLowerCase());
|
|
21
|
+
}
|
|
22
|
+
for (const [name, value] of Object.entries(incoming)) {
|
|
23
|
+
if (value === undefined || hopByHop.has(name.toLowerCase()))
|
|
24
|
+
continue;
|
|
25
|
+
headers[name] = value;
|
|
26
|
+
}
|
|
27
|
+
headers.host = "api.anthropic.com";
|
|
28
|
+
headers.authorization = `Bearer ${secret}`;
|
|
29
|
+
return headers;
|
|
30
|
+
}
|
|
31
|
+
export function poolInferencePathAllowed(method, requestUrl) {
|
|
32
|
+
if (method !== "POST")
|
|
33
|
+
return false;
|
|
34
|
+
const pathname = new URL(requestUrl ?? "/", "http://127.0.0.1").pathname;
|
|
35
|
+
return pathname === "/v1/messages" || pathname.startsWith("/v1/messages/");
|
|
36
|
+
}
|
|
37
|
+
function sendJson(response, status, message) {
|
|
38
|
+
response.writeHead(status, { "content-type": "application/json", connection: "close" });
|
|
39
|
+
response.end(JSON.stringify({ error: { type: "proxy_error", message } }));
|
|
40
|
+
}
|
|
41
|
+
export function createInferenceForwarder(options) {
|
|
42
|
+
const requestUpstream = options.upstream ?? https.request;
|
|
43
|
+
const server = createServer((incoming, response) => {
|
|
44
|
+
if (!poolInferencePathAllowed(incoming.method, incoming.url)) {
|
|
45
|
+
sendJson(response, 404, "only POST /v1/messages is available through this inference forwarder");
|
|
46
|
+
incoming.resume();
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const secret = options.secret();
|
|
50
|
+
if (!secret) {
|
|
51
|
+
sendJson(response, 503, "the pool inference secret is unavailable");
|
|
52
|
+
incoming.resume();
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const headers = inferenceForwardHeaders(incoming.headers, secret);
|
|
56
|
+
const upstream = requestUpstream({
|
|
57
|
+
protocol: "https:",
|
|
58
|
+
hostname: "api.anthropic.com",
|
|
59
|
+
lookup: (hostname, _options, callback) => {
|
|
60
|
+
// The host can map this name to A's local proxy in /etc/hosts. DNS
|
|
61
|
+
// resolution bypasses that mapping for the pool's outbound request.
|
|
62
|
+
dns.resolve4(hostname, (error, addresses) => {
|
|
63
|
+
if (_options.all)
|
|
64
|
+
callback(error, addresses?.map((address) => ({ address, family: 4 })) ?? []);
|
|
65
|
+
else
|
|
66
|
+
callback(error, addresses?.[0] ?? "", 4);
|
|
67
|
+
});
|
|
68
|
+
},
|
|
69
|
+
port: 443,
|
|
70
|
+
method: "POST",
|
|
71
|
+
path: incoming.url,
|
|
72
|
+
headers,
|
|
73
|
+
}, (upstreamResponse) => {
|
|
74
|
+
response.writeHead(upstreamResponse.statusCode ?? 502, upstreamResponse.headers);
|
|
75
|
+
upstreamResponse.pipe(response);
|
|
76
|
+
});
|
|
77
|
+
const connectTimeout = setTimeout(() => {
|
|
78
|
+
upstream.destroy(new Error("pool inference upstream connection timed out"));
|
|
79
|
+
}, POOL_UPSTREAM_CONNECT_TIMEOUT_MS);
|
|
80
|
+
upstream.once("socket", (socket) => {
|
|
81
|
+
socket.once("secureConnect", () => clearTimeout(connectTimeout));
|
|
82
|
+
});
|
|
83
|
+
upstream.setTimeout(options.upstreamTimeoutMs ?? POOL_UPSTREAM_TIMEOUT_MS, () => {
|
|
84
|
+
upstream.destroy(new Error("pool inference upstream timed out"));
|
|
85
|
+
});
|
|
86
|
+
upstream.on("error", () => {
|
|
87
|
+
clearTimeout(connectTimeout);
|
|
88
|
+
if (!response.headersSent && !response.destroyed)
|
|
89
|
+
sendJson(response, 502, "the pool inference backend is unavailable");
|
|
90
|
+
else
|
|
91
|
+
response.destroy();
|
|
92
|
+
});
|
|
93
|
+
upstream.once("close", () => clearTimeout(connectTimeout));
|
|
94
|
+
response.once("close", () => {
|
|
95
|
+
clearTimeout(connectTimeout);
|
|
96
|
+
if (!response.writableEnded)
|
|
97
|
+
upstream.destroy();
|
|
98
|
+
});
|
|
99
|
+
incoming.pipe(upstream);
|
|
100
|
+
});
|
|
101
|
+
server.listen(options.port, "127.0.0.1");
|
|
102
|
+
return server;
|
|
103
|
+
}
|
|
104
|
+
//# sourceMappingURL=remote-control-forwarder.js.map
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/** Host-local desired state for opt-in Claude Remote Control account pools. */
|
|
2
|
+
export interface RemoteControlPool {
|
|
3
|
+
accountId: string;
|
|
4
|
+
capacity: number;
|
|
5
|
+
/** Private loopback address bound by the external nginx TLS listener. */
|
|
6
|
+
frontendAddress: string;
|
|
7
|
+
/** The Anthropic API hostname is HTTPS and the hosts overlay cannot set a port. */
|
|
8
|
+
frontendPort: 443;
|
|
9
|
+
/** Loopback-only inference forwarder owned by hq daemon. */
|
|
10
|
+
forwarderPort: number;
|
|
11
|
+
/** A real mount-point path, not a symlink, populated in a private namespace. */
|
|
12
|
+
mountPath: string;
|
|
13
|
+
/** HQ Secrets name; the secret value is never part of this file. */
|
|
14
|
+
secretName: string;
|
|
15
|
+
}
|
|
16
|
+
export interface RemoteControlPoolsConfig {
|
|
17
|
+
version: 1;
|
|
18
|
+
/** CA certificate trusted by Claude and used to verify the external nginx TLS listener. */
|
|
19
|
+
frontendCaPath?: string;
|
|
20
|
+
/** Stable HQ session whose scope capability is locked to personal for Secrets access. */
|
|
21
|
+
serviceSessionId?: string;
|
|
22
|
+
pools: RemoteControlPool[];
|
|
23
|
+
}
|
|
24
|
+
/** Parse and validate the complete desired state; unknown keys fail closed. */
|
|
25
|
+
export declare function parseRemoteControlPoolsConfig(value: unknown, knownAccountIds?: Iterable<string>, home?: string): RemoteControlPoolsConfig;
|
|
26
|
+
/** Override ambient session selection only for a Secrets child process. */
|
|
27
|
+
export declare function remoteControlPoolSecretsEnv(env: NodeJS.ProcessEnv, serviceSessionId: string): NodeJS.ProcessEnv;
|
|
28
|
+
/** Require an explicit, existing capability; never inherit the mutable .current session. */
|
|
29
|
+
export declare function assertPersonalServiceSessionCapability(root: string, serviceSessionId: string): void;
|
|
30
|
+
/** Hosts file overlay consumed inside this pool's private mount namespace. */
|
|
31
|
+
export declare function remoteControlPoolHostsOverlay(pool: RemoteControlPool): string;
|
|
32
|
+
export declare function poolHostsFile(baseHosts: string, pool: RemoteControlPool): string;
|
|
33
|
+
export declare function readRemoteControlPoolsConfig(file: string, knownAccountIds?: Iterable<string>, home?: string): RemoteControlPoolsConfig;
|
|
34
|
+
export declare function writeRemoteControlPoolsConfig(file: string, config: RemoteControlPoolsConfig, home?: string): void;
|
|
35
|
+
export declare function remoteControlPoolArgs(pool: RemoteControlPool): string[];
|
|
36
|
+
/** Reject an existing symlink anywhere in the requested path. */
|
|
37
|
+
export declare function assertNoSymlinkPathComponents(target: string): void;
|
|
38
|
+
/** Compare paths after resolving their real filesystem identities. */
|
|
39
|
+
export declare function assertDistinctRealMountPath(mountPath: string, hqRoot: string): void;
|
|
40
|
+
/** Validate the private readiness marker against the live forwarder process identity. */
|
|
41
|
+
export declare function isOwnedPoolForwarder(readyFile: string, accountId: string, isAlive: (pid: number) => boolean, commandLine: (pid: number) => string | null): boolean;
|
|
42
|
+
/** Fail the pool host when its separately supervised inference process disappears. */
|
|
43
|
+
export declare function monitorOwnedPoolForwarder(isOwned: () => boolean, signal: AbortSignal, sleep?: () => Promise<void>): Promise<void>;
|
|
44
|
+
//# sourceMappingURL=remote-control-pools.d.ts.map
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/** Host-local desired state for opt-in Claude Remote Control account pools. */
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import * as os from "node:os";
|
|
5
|
+
import { isIPv4 } from "node:net";
|
|
6
|
+
import { readSessionCompanyLock } from "../session-company-lock.js";
|
|
7
|
+
import { ensurePrivateDir } from "./paths.js";
|
|
8
|
+
const ACCOUNT_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
|
|
9
|
+
const SECRET_NAME = /^[A-Z][A-Z0-9_]{0,127}$/;
|
|
10
|
+
const CAPACITY_MIN = 1;
|
|
11
|
+
const CAPACITY_MAX = 100;
|
|
12
|
+
const PORT_MIN = 1024;
|
|
13
|
+
const PORT_MAX = 65535;
|
|
14
|
+
function object(value) {
|
|
15
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
16
|
+
}
|
|
17
|
+
function absoluteRealPath(value, field) {
|
|
18
|
+
if (typeof value !== "string" || !path.isAbsolute(value) || value.includes("\0")) {
|
|
19
|
+
throw new Error(`${field} must be an absolute path`);
|
|
20
|
+
}
|
|
21
|
+
return path.normalize(value);
|
|
22
|
+
}
|
|
23
|
+
function port(value, field) {
|
|
24
|
+
if (!Number.isInteger(value) || value < PORT_MIN || value > PORT_MAX) {
|
|
25
|
+
throw new Error(`${field} must be an integer from ${PORT_MIN} to ${PORT_MAX}`);
|
|
26
|
+
}
|
|
27
|
+
return value;
|
|
28
|
+
}
|
|
29
|
+
function frontendAddress(value, field) {
|
|
30
|
+
if (typeof value !== "string" || !isIPv4(value) || !value.startsWith("127.")) {
|
|
31
|
+
throw new Error(`${field} must be a distinct 127.0.0.0/8 IPv4 loopback address`);
|
|
32
|
+
}
|
|
33
|
+
return value;
|
|
34
|
+
}
|
|
35
|
+
/** Parse and validate the complete desired state; unknown keys fail closed. */
|
|
36
|
+
export function parseRemoteControlPoolsConfig(value, knownAccountIds, home = os.homedir()) {
|
|
37
|
+
if (!object(value))
|
|
38
|
+
throw new Error("remote-control pools config must be a JSON object");
|
|
39
|
+
if (Object.keys(value).some((key) => !["version", "pools", "frontendCaPath", "serviceSessionId"].includes(key))) {
|
|
40
|
+
throw new Error("remote-control pools config contains an unknown field");
|
|
41
|
+
}
|
|
42
|
+
if (value.version !== 1)
|
|
43
|
+
throw new Error("remote-control pools config version must be 1");
|
|
44
|
+
if (!Array.isArray(value.pools))
|
|
45
|
+
throw new Error("remote-control pools config pools must be an array");
|
|
46
|
+
let frontendCaPath;
|
|
47
|
+
if (value.frontendCaPath !== undefined)
|
|
48
|
+
frontendCaPath = absoluteRealPath(value.frontendCaPath, "frontendCaPath");
|
|
49
|
+
if (value.pools.length > 0 && !frontendCaPath)
|
|
50
|
+
throw new Error("frontendCaPath is required when remote-control pools are configured");
|
|
51
|
+
let serviceSessionId;
|
|
52
|
+
if (value.serviceSessionId !== undefined) {
|
|
53
|
+
if (typeof value.serviceSessionId !== "string" || !/^[A-Za-z0-9._-]+$/.test(value.serviceSessionId) || value.serviceSessionId === "." || value.serviceSessionId === "..") {
|
|
54
|
+
throw new Error("serviceSessionId must be a valid HQ session ID");
|
|
55
|
+
}
|
|
56
|
+
serviceSessionId = value.serviceSessionId;
|
|
57
|
+
}
|
|
58
|
+
if (value.pools.length > 0 && !serviceSessionId) {
|
|
59
|
+
throw new Error("serviceSessionId is required when remote-control pools are configured");
|
|
60
|
+
}
|
|
61
|
+
const seenAccounts = new Set();
|
|
62
|
+
const known = knownAccountIds === undefined ? null : new Set(knownAccountIds);
|
|
63
|
+
const seenEndpoints = new Set();
|
|
64
|
+
const seenPaths = new Set();
|
|
65
|
+
const pools = value.pools.map((raw, index) => {
|
|
66
|
+
const prefix = `pools[${index}]`;
|
|
67
|
+
if (!object(raw))
|
|
68
|
+
throw new Error(`${prefix} must be a JSON object`);
|
|
69
|
+
const allowed = ["accountId", "capacity", "frontendAddress", "frontendPort", "forwarderPort", "mountPath", "secretName"];
|
|
70
|
+
if (Object.keys(raw).some((key) => !allowed.includes(key)))
|
|
71
|
+
throw new Error(`${prefix} contains an unknown field`);
|
|
72
|
+
if (typeof raw.accountId !== "string" || !ACCOUNT_ID.test(raw.accountId)) {
|
|
73
|
+
throw new Error(`${prefix}.accountId is invalid`);
|
|
74
|
+
}
|
|
75
|
+
if (known && !known.has(raw.accountId))
|
|
76
|
+
throw new Error(`${prefix}.accountId "${raw.accountId}" is not configured in hq lanes accounts`);
|
|
77
|
+
if (seenAccounts.has(raw.accountId))
|
|
78
|
+
throw new Error(`duplicate accountId "${raw.accountId}"`);
|
|
79
|
+
seenAccounts.add(raw.accountId);
|
|
80
|
+
if (!Number.isInteger(raw.capacity) || raw.capacity < CAPACITY_MIN || raw.capacity > CAPACITY_MAX) {
|
|
81
|
+
throw new Error(`${prefix}.capacity must be an integer from ${CAPACITY_MIN} to ${CAPACITY_MAX}`);
|
|
82
|
+
}
|
|
83
|
+
const address = frontendAddress(raw.frontendAddress, `${prefix}.frontendAddress`);
|
|
84
|
+
if (raw.frontendPort !== 443)
|
|
85
|
+
throw new Error(`${prefix}.frontendPort must be 443`);
|
|
86
|
+
const frontendPort = 443;
|
|
87
|
+
const forwarderPort = port(raw.forwarderPort, `${prefix}.forwarderPort`);
|
|
88
|
+
const forwarderEndpoint = `127.0.0.1:${forwarderPort}`;
|
|
89
|
+
if (seenEndpoints.has(forwarderEndpoint))
|
|
90
|
+
throw new Error(`duplicate pool endpoint ${forwarderEndpoint}`);
|
|
91
|
+
seenEndpoints.add(forwarderEndpoint);
|
|
92
|
+
const endpoint = `${address}:${frontendPort}`;
|
|
93
|
+
if (seenEndpoints.has(endpoint))
|
|
94
|
+
throw new Error(`duplicate pool frontend endpoint ${endpoint}`);
|
|
95
|
+
seenEndpoints.add(endpoint);
|
|
96
|
+
const mountPath = absoluteRealPath(raw.mountPath, `${prefix}.mountPath`);
|
|
97
|
+
if (mountPath !== path.join(home, ".local", "state", "hq-remote-control", raw.accountId)) {
|
|
98
|
+
throw new Error(`${prefix}.mountPath must be ${path.join(home, ".local", "state", "hq-remote-control", raw.accountId)}`);
|
|
99
|
+
}
|
|
100
|
+
if (seenPaths.has(mountPath))
|
|
101
|
+
throw new Error(`duplicate mountPath "${mountPath}"`);
|
|
102
|
+
seenPaths.add(mountPath);
|
|
103
|
+
if (typeof raw.secretName !== "string" || !SECRET_NAME.test(raw.secretName)) {
|
|
104
|
+
throw new Error(`${prefix}.secretName is invalid`);
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
accountId: raw.accountId,
|
|
108
|
+
capacity: raw.capacity,
|
|
109
|
+
frontendAddress: address,
|
|
110
|
+
frontendPort,
|
|
111
|
+
forwarderPort,
|
|
112
|
+
mountPath,
|
|
113
|
+
secretName: raw.secretName,
|
|
114
|
+
};
|
|
115
|
+
});
|
|
116
|
+
return { version: 1, ...(frontendCaPath ? { frontendCaPath } : {}), ...(serviceSessionId ? { serviceSessionId } : {}), pools };
|
|
117
|
+
}
|
|
118
|
+
/** Override ambient session selection only for a Secrets child process. */
|
|
119
|
+
export function remoteControlPoolSecretsEnv(env, serviceSessionId) {
|
|
120
|
+
return { ...env, HQ_SESSION_ID: serviceSessionId };
|
|
121
|
+
}
|
|
122
|
+
/** Require an explicit, existing capability; never inherit the mutable .current session. */
|
|
123
|
+
export function assertPersonalServiceSessionCapability(root, serviceSessionId) {
|
|
124
|
+
if (!/^[A-Za-z0-9._-]+$/.test(serviceSessionId) || serviceSessionId === "." || serviceSessionId === "..") {
|
|
125
|
+
throw new Error("configured serviceSessionId is not a valid HQ session ID");
|
|
126
|
+
}
|
|
127
|
+
const capabilityPath = path.join(root, "workspace", "sessions", serviceSessionId, "scope-capability.json");
|
|
128
|
+
let stat;
|
|
129
|
+
try {
|
|
130
|
+
stat = fs.lstatSync(capabilityPath);
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
throw new Error("configured serviceSessionId has no readable scope capability");
|
|
134
|
+
}
|
|
135
|
+
if (!stat.isFile() || stat.isSymbolicLink()) {
|
|
136
|
+
throw new Error("configured serviceSessionId has no readable scope capability");
|
|
137
|
+
}
|
|
138
|
+
const lock = readSessionCompanyLock({ ...process.env, HQ_ROOT: root, HQ_SESSION_ID: serviceSessionId });
|
|
139
|
+
if (!lock || lock.sessionId !== serviceSessionId || lock.companies.length !== 1 || lock.companies[0] !== "personal") {
|
|
140
|
+
throw new Error("configured serviceSessionId must have a personal-only scope capability");
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
/** Hosts file overlay consumed inside this pool's private mount namespace. */
|
|
144
|
+
export function remoteControlPoolHostsOverlay(pool) {
|
|
145
|
+
return `${pool.frontendAddress} api.anthropic.com\n`;
|
|
146
|
+
}
|
|
147
|
+
export function poolHostsFile(baseHosts, pool) {
|
|
148
|
+
const retained = baseHosts
|
|
149
|
+
.split(/\r?\n/)
|
|
150
|
+
.filter((line) => !/(^|\s)api\.anthropic\.com(?:\s|$)/.test(line))
|
|
151
|
+
.filter((line) => line.trim() !== "");
|
|
152
|
+
return `${retained.join("\n")}\n${remoteControlPoolHostsOverlay(pool)}`;
|
|
153
|
+
}
|
|
154
|
+
export function readRemoteControlPoolsConfig(file, knownAccountIds, home = os.homedir()) {
|
|
155
|
+
let parsed;
|
|
156
|
+
try {
|
|
157
|
+
parsed = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
158
|
+
}
|
|
159
|
+
catch (error) {
|
|
160
|
+
if (error.code === "ENOENT")
|
|
161
|
+
return { version: 1, pools: [] };
|
|
162
|
+
throw new Error(`could not read remote-control pools config: ${error.message}`);
|
|
163
|
+
}
|
|
164
|
+
return parseRemoteControlPoolsConfig(parsed, knownAccountIds, home);
|
|
165
|
+
}
|
|
166
|
+
export function writeRemoteControlPoolsConfig(file, config, home = os.homedir()) {
|
|
167
|
+
const validated = parseRemoteControlPoolsConfig(config, undefined, home);
|
|
168
|
+
ensurePrivateDir(path.dirname(file));
|
|
169
|
+
const temp = `${file}.${process.pid}.tmp`;
|
|
170
|
+
fs.writeFileSync(temp, `${JSON.stringify(validated, null, 2)}\n`, { mode: 0o600 });
|
|
171
|
+
fs.renameSync(temp, file);
|
|
172
|
+
fs.chmodSync(file, 0o600);
|
|
173
|
+
}
|
|
174
|
+
export function remoteControlPoolArgs(pool) {
|
|
175
|
+
return [
|
|
176
|
+
"remote-control",
|
|
177
|
+
"--no-create-session-in-dir",
|
|
178
|
+
"--capacity",
|
|
179
|
+
String(pool.capacity),
|
|
180
|
+
"--name",
|
|
181
|
+
`hq-${pool.accountId}`,
|
|
182
|
+
"--spawn=same-dir",
|
|
183
|
+
"--permission-mode",
|
|
184
|
+
"bypassPermissions",
|
|
185
|
+
];
|
|
186
|
+
}
|
|
187
|
+
/** Reject an existing symlink anywhere in the requested path. */
|
|
188
|
+
export function assertNoSymlinkPathComponents(target) {
|
|
189
|
+
const parsed = path.parse(target);
|
|
190
|
+
let cursor = parsed.root;
|
|
191
|
+
for (const part of target.slice(parsed.root.length).split(path.sep).filter(Boolean)) {
|
|
192
|
+
cursor = path.join(cursor, part);
|
|
193
|
+
try {
|
|
194
|
+
if (fs.lstatSync(cursor).isSymbolicLink())
|
|
195
|
+
throw new Error("pool mountPath cannot contain symlinks");
|
|
196
|
+
}
|
|
197
|
+
catch (error) {
|
|
198
|
+
if (error.code !== "ENOENT")
|
|
199
|
+
throw error;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
/** Compare paths after resolving their real filesystem identities. */
|
|
204
|
+
export function assertDistinctRealMountPath(mountPath, hqRoot) {
|
|
205
|
+
const realRoot = fs.realpathSync(hqRoot);
|
|
206
|
+
assertNoSymlinkPathComponents(mountPath);
|
|
207
|
+
let resolvedMount;
|
|
208
|
+
try {
|
|
209
|
+
resolvedMount = fs.realpathSync(mountPath);
|
|
210
|
+
}
|
|
211
|
+
catch (error) {
|
|
212
|
+
if (error.code !== "ENOENT")
|
|
213
|
+
throw error;
|
|
214
|
+
const parent = path.dirname(mountPath);
|
|
215
|
+
resolvedMount = path.join(fs.realpathSync(parent), path.basename(mountPath));
|
|
216
|
+
}
|
|
217
|
+
if (resolvedMount === realRoot || resolvedMount.startsWith(`${realRoot}${path.sep}`) || realRoot.startsWith(`${resolvedMount}${path.sep}`)) {
|
|
218
|
+
throw new Error("pool mountPath must be distinct from the HQ root after realpath resolution");
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
/** Validate the private readiness marker against the live forwarder process identity. */
|
|
222
|
+
export function isOwnedPoolForwarder(readyFile, accountId, isAlive, commandLine) {
|
|
223
|
+
try {
|
|
224
|
+
const marker = JSON.parse(fs.readFileSync(readyFile, "utf8"));
|
|
225
|
+
if (marker.accountId !== accountId || !Number.isSafeInteger(marker.pid) || marker.pid <= 0)
|
|
226
|
+
return false;
|
|
227
|
+
const pid = marker.pid;
|
|
228
|
+
const line = isAlive(pid) ? commandLine(pid) : null;
|
|
229
|
+
const args = line?.split(/\s+/) ?? [];
|
|
230
|
+
const accountFlag = args.indexOf("--account-id");
|
|
231
|
+
return args.includes("remote-control-pool-forwarder") && accountFlag >= 0 && args[accountFlag + 1] === accountId;
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
/** Fail the pool host when its separately supervised inference process disappears. */
|
|
238
|
+
export async function monitorOwnedPoolForwarder(isOwned, signal, sleep = () => new Promise((resolve) => setTimeout(resolve, 500))) {
|
|
239
|
+
while (!signal.aborted) {
|
|
240
|
+
if (!isOwned())
|
|
241
|
+
throw new Error("the owned pool inference forwarder exited or lost its listener");
|
|
242
|
+
await sleep();
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
//# sourceMappingURL=remote-control-pools.js.map
|
|
@@ -53,6 +53,10 @@ export declare const IDLE_BEFORE_RESTART_MS: number;
|
|
|
53
53
|
export declare const MAX_RESTART_WAIT_MS: number;
|
|
54
54
|
export interface RemoteControlHostDeps {
|
|
55
55
|
tmux: string;
|
|
56
|
+
/** Optional private tmux server per managed pool; A keeps the legacy socket. */
|
|
57
|
+
tmuxSocket?: string;
|
|
58
|
+
/** Environment applied only to this private tmux server before its session starts. */
|
|
59
|
+
sessionEnv?: Record<string, string | null>;
|
|
56
60
|
/** tmux session name on the daemon's private socket. */
|
|
57
61
|
session: string;
|
|
58
62
|
/** Folder the command starts in (the HQ folder). */
|
|
@@ -204,7 +204,7 @@ function safePaneTail(output) {
|
|
|
204
204
|
.join("\n");
|
|
205
205
|
}
|
|
206
206
|
export async function runRemoteControlHost(deps) {
|
|
207
|
-
const tmux = (...args) => deps.run(deps.tmux, ["-L", TMUX_SOCKET, ...args]);
|
|
207
|
+
const tmux = (...args) => deps.run(deps.tmux, ["-L", deps.tmuxSocket ?? TMUX_SOCKET, ...args]);
|
|
208
208
|
const invokerPid = process.pid;
|
|
209
209
|
const killSession = () => {
|
|
210
210
|
if (tmux("has-session", "-t", deps.session).status !== 0)
|
|
@@ -279,11 +279,26 @@ export async function runRemoteControlHost(deps) {
|
|
|
279
279
|
await deps.sleep(deps.pollMs);
|
|
280
280
|
}
|
|
281
281
|
// Start the replacement only after no live foreign Claude owner remains.
|
|
282
|
-
|
|
282
|
+
// A private pool socket has no server until its first session is created.
|
|
283
|
+
// Keep that first pane inert, then set only this session's environment before
|
|
284
|
+
// replacing it with the actual command. The ordinary A service keeps its
|
|
285
|
+
// existing shell startup path when no session environment is configured.
|
|
286
|
+
const bootstrap = deps.sessionEnv ? ["/usr/bin/sleep", "2147483647"] : [];
|
|
287
|
+
const created = tmux("new-session", "-d", "-s", deps.session, "-x", "200", "-y", "50", "-c", deps.cwd, ...bootstrap);
|
|
283
288
|
if (created.status !== 0) {
|
|
284
289
|
deps.log(`could not create the tmux session: ${created.stderr.trim() || `tmux exited ${created.status}`}`);
|
|
285
290
|
return 1;
|
|
286
291
|
}
|
|
292
|
+
for (const [name, value] of Object.entries(deps.sessionEnv ?? {})) {
|
|
293
|
+
const result = value === null
|
|
294
|
+
? tmux("set-environment", "-u", "-t", deps.session, name)
|
|
295
|
+
: tmux("set-environment", "-t", deps.session, name, value);
|
|
296
|
+
if (result.status !== 0) {
|
|
297
|
+
deps.log(`could not configure the private tmux environment for ${name}`);
|
|
298
|
+
tmux("kill-session", "-t", deps.session);
|
|
299
|
+
return 1;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
287
302
|
const creatorRecord = JSON.stringify({ pid: invokerPid, cwd: deps.cwd });
|
|
288
303
|
const recorded = tmux("set-option", "-t", deps.session, TMUX_CREATOR_OPTION, creatorRecord);
|
|
289
304
|
if (recorded.status !== 0) {
|