@indigoai-us/hq-cli 5.345.104 → 5.345.106
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 +100 -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/logout.d.ts +6 -1
- package/dist/commands/logout.js +18 -2
- 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/fast-scaffold.d.ts +13 -0
- package/dist/fast-scaffold.js +136 -0
- package/dist/index.js +15 -1
- 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/dist/utils/cognito-config.d.ts +5 -0
- package/dist/utils/cognito-config.js +14 -0
- package/dist/utils/cognito-revoke.d.ts +33 -0
- package/dist/utils/cognito-revoke.js +42 -0
- package/package.json +1 -1
|
@@ -19,6 +19,7 @@ import * as fs from "node:fs";
|
|
|
19
19
|
import * as os from "node:os";
|
|
20
20
|
import * as path from "node:path";
|
|
21
21
|
import { messagesStreamEvents, messagesStreamFailure, messagesStreamResult } from "./messages-stream.js";
|
|
22
|
+
import { hqAppToolchainBin } from "../toolchain.js";
|
|
22
23
|
import { resolveRuntimeBinary } from "./windows.js";
|
|
23
24
|
export const CLAUDE_PERMISSION_MODE_ENV = "HQ_BOT_CLAUDE_PERMISSION_MODE";
|
|
24
25
|
export const DEFAULT_CLAUDE_PERMISSION_MODE = "bypassPermissions";
|
|
@@ -104,18 +105,59 @@ export function bundledClaudeBin(homeDir = os.homedir()) {
|
|
|
104
105
|
});
|
|
105
106
|
return candidates.at(-1)?.bin ?? null;
|
|
106
107
|
}
|
|
107
|
-
|
|
108
|
+
/**
|
|
109
|
+
* Where a bot looks for `claude`, in order, outside the PATH walk. The HQ
|
|
110
|
+
* desktop app's toolchain comes first: on a clean Mac it is the only copy, and
|
|
111
|
+
* it is the one the app keeps updated. Then the usual install prefixes, so a
|
|
112
|
+
* bot under launchd (no shell profile) still finds a Homebrew or npm global
|
|
113
|
+
* install even when its PATH is thin.
|
|
114
|
+
*/
|
|
115
|
+
export const CLAUDE_SYSTEM_BIN_DIRS = ["/usr/local/bin", "/opt/homebrew/bin", "/usr/bin"];
|
|
116
|
+
function executableAt(file) {
|
|
117
|
+
try {
|
|
118
|
+
fs.accessSync(file, fs.constants.X_OK);
|
|
119
|
+
return fs.statSync(file).isFile();
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* The `claude` a bot spawns. Order (contract "Claude resolution for bots"):
|
|
127
|
+
*
|
|
128
|
+
* 1. `~/Library/Application Support/Indigo HQ/toolchain/npm-global/bin/claude`
|
|
129
|
+
* (the HQ app's own install; macOS only)
|
|
130
|
+
* 2. /usr/local/bin, /opt/homebrew/bin, /usr/bin
|
|
131
|
+
* 3. PATH (returned as the bare name, so the spawn uses the same lookup)
|
|
132
|
+
* 4. the Claude desktop app's bundled Claude Code
|
|
133
|
+
* 5. the bare name, so the failure reads "claude is not installed".
|
|
134
|
+
*
|
|
135
|
+
* Absolute paths come back for 1, 2 and 4 so the bot log can say which one
|
|
136
|
+
* ran.
|
|
137
|
+
*/
|
|
138
|
+
export function resolveClaudeBinary(env, homeDir, platform = process.platform, cwd = process.cwd(), systemBinDirs = CLAUDE_SYSTEM_BIN_DIRS) {
|
|
108
139
|
if (platform === "win32")
|
|
109
140
|
return resolveRuntimeBinary("claude", env, platform, fs.existsSync, cwd);
|
|
141
|
+
const home = homeDir ?? env.HOME ?? os.homedir();
|
|
142
|
+
if (platform === "darwin") {
|
|
143
|
+
const toolchain = hqAppToolchainBin("claude", home);
|
|
144
|
+
if (executableAt(toolchain))
|
|
145
|
+
return toolchain;
|
|
146
|
+
}
|
|
147
|
+
for (const dir of systemBinDirs) {
|
|
148
|
+
const candidate = path.join(dir, "claude");
|
|
149
|
+
if (executableAt(candidate))
|
|
150
|
+
return candidate;
|
|
151
|
+
}
|
|
110
152
|
if (onPath("claude", env))
|
|
111
153
|
return "claude";
|
|
112
|
-
return bundledClaudeBin(
|
|
154
|
+
return bundledClaudeBin(home) ?? "claude";
|
|
113
155
|
}
|
|
114
|
-
export function createClaudeRuntime(env = process.env) {
|
|
156
|
+
export function createClaudeRuntime(env = process.env, opts = {}) {
|
|
115
157
|
return {
|
|
116
158
|
id: "claude",
|
|
117
159
|
binary: "claude",
|
|
118
|
-
resolveBinary: (turnEnv, cwd, platform) => resolveClaudeBinary(turnEnv, undefined, platform ?? process.platform, cwd),
|
|
160
|
+
resolveBinary: (turnEnv, cwd, platform) => resolveClaudeBinary(turnEnv, undefined, platform ?? process.platform, cwd, opts.systemBinDirs ?? CLAUDE_SYSTEM_BIN_DIRS),
|
|
119
161
|
buildArgs(input, scratch) {
|
|
120
162
|
const usesWindowsStdin = input.platform === "win32";
|
|
121
163
|
const args = [
|
|
@@ -142,6 +184,9 @@ export function createClaudeRuntime(env = process.env) {
|
|
|
142
184
|
}
|
|
143
185
|
if (input.model)
|
|
144
186
|
args.push("--model", input.model);
|
|
187
|
+
if (input.leanSettingsFile) {
|
|
188
|
+
args.push("--strict-mcp-config", "--setting-sources", "", "--settings", input.leanSettingsFile);
|
|
189
|
+
}
|
|
145
190
|
if (input.sessionId)
|
|
146
191
|
args.push("--resume", input.sessionId);
|
|
147
192
|
else
|
|
@@ -33,6 +33,11 @@ export interface RuntimeTurnInput {
|
|
|
33
33
|
sessionId?: string;
|
|
34
34
|
/** Optional model override. */
|
|
35
35
|
model?: string;
|
|
36
|
+
/**
|
|
37
|
+
* Claude only: a lean turn. The settings file holds HQ's tool hooks; the turn
|
|
38
|
+
* loads no other settings, plugins or MCP servers (BotConfig.lean).
|
|
39
|
+
*/
|
|
40
|
+
leanSettingsFile?: string;
|
|
36
41
|
/** Thinking level for the CLI (runtimes fall back to medium). */
|
|
37
42
|
effort?: string;
|
|
38
43
|
/**
|
|
@@ -89,7 +89,7 @@ export interface RuntimeRepairNotice {
|
|
|
89
89
|
cls: RuntimeFailureClass;
|
|
90
90
|
/** The plain DM body. */
|
|
91
91
|
text: string;
|
|
92
|
-
/** Absent for `not-installed` and `unknown`: those get the plain text
|
|
92
|
+
/** Absent for `not-installed` and `unknown`: those get the plain text (plus a Retry suggestion in a DM). */
|
|
93
93
|
richContent?: RuntimeRichContent;
|
|
94
94
|
}
|
|
95
95
|
export declare function runtimeRepairBlock(args: {
|
|
@@ -99,7 +99,23 @@ export declare function runtimeRepairBlock(args: {
|
|
|
99
99
|
retryOf?: string;
|
|
100
100
|
suggestedModel?: string;
|
|
101
101
|
}): RuntimeRepairBlock;
|
|
102
|
-
/** The
|
|
102
|
+
/** The tap-to-answer button under a failure that carries no repair card. */
|
|
103
|
+
export declare const RETRY_SUGGESTION = "Retry";
|
|
104
|
+
/**
|
|
105
|
+
* The `hq-block` suggestions envelope the desktop draws as buttons (the same
|
|
106
|
+
* one the setup bot uses for its questions). Tapping sends the text back as
|
|
107
|
+
* the person's next message, so a failed turn is never a dead end.
|
|
108
|
+
*/
|
|
109
|
+
export declare function suggestionsBlock(items: string[]): string;
|
|
110
|
+
/** `text` followed by a one-button Retry envelope. */
|
|
111
|
+
export declare function withRetrySuggestion(text: string): string;
|
|
112
|
+
/**
|
|
113
|
+
* The plain body plus, for a repairable class, the repair block. Classes
|
|
114
|
+
* without a card (`not-installed`, `unknown`) get the plain text; the DM post
|
|
115
|
+
* path adds a Retry suggestion under it (see run.ts postRepair): on 2026-10-11
|
|
116
|
+
* a fresh bot whose Claude Code was missing left the person with nothing to
|
|
117
|
+
* press.
|
|
118
|
+
*/
|
|
103
119
|
export declare function runtimeRepairNotice(args: {
|
|
104
120
|
cls: RuntimeFailureClass;
|
|
105
121
|
runtime: BotRuntimeId | string;
|
|
@@ -155,7 +155,27 @@ export function runtimeRepairBlock(args) {
|
|
|
155
155
|
...(args.suggestedModel ? { suggestedModel: args.suggestedModel.slice(0, 128) } : {}),
|
|
156
156
|
};
|
|
157
157
|
}
|
|
158
|
-
/** The
|
|
158
|
+
/** The tap-to-answer button under a failure that carries no repair card. */
|
|
159
|
+
export const RETRY_SUGGESTION = "Retry";
|
|
160
|
+
/**
|
|
161
|
+
* The `hq-block` suggestions envelope the desktop draws as buttons (the same
|
|
162
|
+
* one the setup bot uses for its questions). Tapping sends the text back as
|
|
163
|
+
* the person's next message, so a failed turn is never a dead end.
|
|
164
|
+
*/
|
|
165
|
+
export function suggestionsBlock(items) {
|
|
166
|
+
return "```hq-block\n" + JSON.stringify({ v: 1, blocks: [{ kind: "suggestions", items }] }) + "\n```";
|
|
167
|
+
}
|
|
168
|
+
/** `text` followed by a one-button Retry envelope. */
|
|
169
|
+
export function withRetrySuggestion(text) {
|
|
170
|
+
return `${text}\n\n${suggestionsBlock([RETRY_SUGGESTION])}`;
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* The plain body plus, for a repairable class, the repair block. Classes
|
|
174
|
+
* without a card (`not-installed`, `unknown`) get the plain text; the DM post
|
|
175
|
+
* path adds a Retry suggestion under it (see run.ts postRepair): on 2026-10-11
|
|
176
|
+
* a fresh bot whose Claude Code was missing left the person with nothing to
|
|
177
|
+
* press.
|
|
178
|
+
*/
|
|
159
179
|
export function runtimeRepairNotice(args) {
|
|
160
180
|
const text = args.text ?? runtimeFailureText(args.cls, args.runtime, args.context);
|
|
161
181
|
if (!isRuntimeRepairClass(args.cls))
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The HQ desktop app's managed toolchain.
|
|
3
|
+
*
|
|
4
|
+
* The app installs node, hq-cli and @anthropic-ai/claude-code under one
|
|
5
|
+
* prefix so a clean Mac can run bots without the person installing anything:
|
|
6
|
+
*
|
|
7
|
+
* ~/Library/Application Support/Indigo HQ/toolchain/
|
|
8
|
+
* node/bin/node
|
|
9
|
+
* npm-global/bin/hq
|
|
10
|
+
* npm-global/bin/claude
|
|
11
|
+
*
|
|
12
|
+
* The bot daemon's PATH, the hq-cli update watcher and the Claude Code
|
|
13
|
+
* resolver all key on the same root, so it lives here once.
|
|
14
|
+
*/
|
|
15
|
+
/** Path segments under the home directory. macOS only; other platforms have no app toolchain. */
|
|
16
|
+
export declare const HQ_APP_TOOLCHAIN_SEGMENTS: readonly ["Library", "Application Support", "Indigo HQ", "toolchain"];
|
|
17
|
+
/** The npm prefix the app installs CLIs into, relative to the toolchain root. */
|
|
18
|
+
export declare const HQ_APP_TOOLCHAIN_NPM_GLOBAL_BIN: string;
|
|
19
|
+
/** `~/Library/Application Support/Indigo HQ/toolchain`. */
|
|
20
|
+
export declare function hqAppToolchainRoot(homeDir?: string): string;
|
|
21
|
+
/** `<toolchain>/npm-global/bin`, where `hq` and `claude` live. */
|
|
22
|
+
export declare function hqAppToolchainBinDir(homeDir?: string): string;
|
|
23
|
+
/** `<toolchain>/npm-global/bin/<name>`: where the app puts a CLI it manages. */
|
|
24
|
+
export declare function hqAppToolchainBin(name: string, homeDir?: string): string;
|
|
25
|
+
//# sourceMappingURL=toolchain.d.ts.map
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The HQ desktop app's managed toolchain.
|
|
3
|
+
*
|
|
4
|
+
* The app installs node, hq-cli and @anthropic-ai/claude-code under one
|
|
5
|
+
* prefix so a clean Mac can run bots without the person installing anything:
|
|
6
|
+
*
|
|
7
|
+
* ~/Library/Application Support/Indigo HQ/toolchain/
|
|
8
|
+
* node/bin/node
|
|
9
|
+
* npm-global/bin/hq
|
|
10
|
+
* npm-global/bin/claude
|
|
11
|
+
*
|
|
12
|
+
* The bot daemon's PATH, the hq-cli update watcher and the Claude Code
|
|
13
|
+
* resolver all key on the same root, so it lives here once.
|
|
14
|
+
*/
|
|
15
|
+
import * as os from "node:os";
|
|
16
|
+
import * as path from "node:path";
|
|
17
|
+
/** Path segments under the home directory. macOS only; other platforms have no app toolchain. */
|
|
18
|
+
export const HQ_APP_TOOLCHAIN_SEGMENTS = ["Library", "Application Support", "Indigo HQ", "toolchain"];
|
|
19
|
+
/** The npm prefix the app installs CLIs into, relative to the toolchain root. */
|
|
20
|
+
export const HQ_APP_TOOLCHAIN_NPM_GLOBAL_BIN = path.join("npm-global", "bin");
|
|
21
|
+
/** `~/Library/Application Support/Indigo HQ/toolchain`. */
|
|
22
|
+
export function hqAppToolchainRoot(homeDir = os.homedir()) {
|
|
23
|
+
return path.join(homeDir, ...HQ_APP_TOOLCHAIN_SEGMENTS);
|
|
24
|
+
}
|
|
25
|
+
/** `<toolchain>/npm-global/bin`, where `hq` and `claude` live. */
|
|
26
|
+
export function hqAppToolchainBinDir(homeDir = os.homedir()) {
|
|
27
|
+
return path.join(hqAppToolchainRoot(homeDir), HQ_APP_TOOLCHAIN_NPM_GLOBAL_BIN);
|
|
28
|
+
}
|
|
29
|
+
/** `<toolchain>/npm-global/bin/<name>`: where the app puts a CLI it manages. */
|
|
30
|
+
export function hqAppToolchainBin(name, homeDir = os.homedir()) {
|
|
31
|
+
return path.join(hqAppToolchainBinDir(homeDir), name);
|
|
32
|
+
}
|
|
33
|
+
//# sourceMappingURL=toolchain.js.map
|
|
@@ -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
|