@indigoai-us/hq-cli 5.323.0 → 5.325.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 +11 -1
- package/dist/bin/hq-auth-refresh.d.ts +5 -1
- package/dist/bin/hq-auth-refresh.js +33 -1
- package/dist/command-catalog.generated.d.ts +125 -6
- package/dist/command-catalog.generated.js +162 -6
- package/dist/command-registration-plan.d.ts +24 -0
- package/dist/command-registration-plan.js +4 -0
- package/dist/commands/anywhere-runtime-flag.d.ts +9 -0
- package/dist/commands/anywhere-runtime-flag.js +22 -0
- package/dist/commands/core-derive-trigger-facts-native.js +37 -3
- package/dist/commands/daemon.js +39 -2
- package/dist/commands/db-provision.js +6 -1
- package/dist/commands/doctor.js +8 -4
- package/dist/commands/feedback.d.ts +2 -0
- package/dist/commands/feedback.js +10 -0
- package/dist/commands/install-global.d.ts +46 -0
- package/dist/commands/install-global.js +674 -0
- package/dist/commands/link.d.ts +30 -0
- package/dist/commands/link.js +184 -0
- package/dist/commands/mcp-status.d.ts +6 -0
- package/dist/commands/mcp-status.js +21 -4
- package/dist/commands/pkg-install.js +20 -1
- package/dist/commands/update.d.ts +19 -0
- package/dist/commands/update.js +52 -6
- package/dist/fast-core.d.ts +2 -1
- package/dist/fast-core.js +12 -8
- package/dist/index.js +9 -7
- package/dist/lib/core-utils/timeout-guard-native-flags.d.ts +6 -0
- package/dist/lib/core-utils/timeout-guard-native-flags.js +30 -0
- package/dist/lib/core-utils/timeout-guard-native.d.ts +0 -3
- package/dist/lib/core-utils/timeout-guard-native.js +4 -30
- package/dist/lib/core-utils/timeout-guard.d.ts +2 -0
- package/dist/lib/core-utils/timeout-guard.js +2 -1
- package/dist/lib/daemon/convergence.d.ts +42 -0
- package/dist/lib/daemon/convergence.js +66 -0
- package/dist/lib/daemon/heartbeat.d.ts +43 -0
- package/dist/lib/daemon/heartbeat.js +70 -0
- package/dist/lib/daemon/ops/journal.d.ts +29 -0
- package/dist/lib/daemon/ops/journal.js +141 -0
- package/dist/lib/daemon/ops/memory.d.ts +20 -0
- package/dist/lib/daemon/ops/memory.js +65 -0
- package/dist/lib/daemon/ops/mesh.d.ts +38 -0
- package/dist/lib/daemon/ops/mesh.js +70 -0
- package/dist/lib/daemon/ops/policy.d.ts +78 -0
- package/dist/lib/daemon/ops/policy.js +283 -0
- package/dist/lib/daemon/protocol.d.ts +68 -0
- package/dist/lib/daemon/protocol.js +154 -0
- package/dist/lib/daemon/run.d.ts +10 -0
- package/dist/lib/daemon/run.js +85 -3
- package/dist/lib/daemon/server.d.ts +36 -0
- package/dist/lib/daemon/server.js +306 -0
- package/dist/lib/daemon/session-registry.d.ts +30 -0
- package/dist/lib/daemon/session-registry.js +42 -0
- package/dist/lib/doctor/checks/anywhere.d.ts +38 -0
- package/dist/lib/doctor/checks/anywhere.js +182 -0
- package/dist/lib/doctor/registry.js +6 -0
- package/dist/lib/flag-registry-worker-source.generated.d.ts +1 -1
- package/dist/lib/flag-registry-worker-source.generated.js +1 -1
- package/dist/lib/install/claude-targets.d.ts +47 -0
- package/dist/lib/install/claude-targets.js +203 -0
- package/dist/lib/install/codex-targets.d.ts +57 -0
- package/dist/lib/install/codex-targets.js +187 -0
- package/dist/lib/mesh/live/session-seq.js +62 -9
- package/dist/lib/registry/normalize-remote.d.ts +21 -0
- package/dist/lib/registry/normalize-remote.js +68 -0
- package/dist/lib/registry/registry.d.ts +75 -0
- package/dist/lib/registry/registry.js +261 -0
- package/dist/utils/cognito-session.d.ts +5 -1
- package/dist/utils/cognito-session.js +8 -12
- package/dist/utils/feedback-diagnostics.d.ts +2 -0
- package/dist/utils/version-check.d.ts +2 -0
- package/dist/utils/version-check.js +7 -0
- package/package.json +2 -2
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* policy.check and policy.index ops (hq-anywhere US-009).
|
|
3
|
+
*
|
|
4
|
+
* Verdicts come from the hq-core hook scripts under HQ_ROOT, run as child
|
|
5
|
+
* processes with a synthesized hook payload, so hookless hosts and sub-agents
|
|
6
|
+
* get the same answer a hook-dispatching session would. No rule is
|
|
7
|
+
* reimplemented here:
|
|
8
|
+
* - .claude/hooks/mandatory-scope-authorizer.sh decides allow/block
|
|
9
|
+
* (exit 2 = block, reason on stderr).
|
|
10
|
+
* - .claude/hooks/inject-policy-on-trigger.sh supplies additionalContext.
|
|
11
|
+
*
|
|
12
|
+
* The policy index (which policy files exist for a company) is cached
|
|
13
|
+
* in-process and invalidated by fs.watch on core/policies, personal/policies
|
|
14
|
+
* and companies/<co>/policies.
|
|
15
|
+
*/
|
|
16
|
+
import { spawn } from "node:child_process";
|
|
17
|
+
import * as fs from "node:fs";
|
|
18
|
+
import * as path from "node:path";
|
|
19
|
+
export const SCOPE_AUTHORIZER = ".claude/hooks/mandatory-scope-authorizer.sh";
|
|
20
|
+
export const POLICY_TRIGGER = ".claude/hooks/inject-policy-on-trigger.sh";
|
|
21
|
+
const SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
22
|
+
const SESSION_RE = /^[A-Za-z0-9._-]+$/;
|
|
23
|
+
export class PolicyOpError extends Error {
|
|
24
|
+
code;
|
|
25
|
+
constructor(code, message) {
|
|
26
|
+
super(message);
|
|
27
|
+
this.code = code;
|
|
28
|
+
this.name = "PolicyOpError";
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/** Cached list of policy files under HQ_ROOT, invalidated by fs.watch. */
|
|
32
|
+
export class PolicyIndex {
|
|
33
|
+
hqRoot;
|
|
34
|
+
log;
|
|
35
|
+
entries = null;
|
|
36
|
+
watchers = [];
|
|
37
|
+
closed = false;
|
|
38
|
+
/** Bumped on every invalidation; lets callers see that the cache was rebuilt. */
|
|
39
|
+
version = 0;
|
|
40
|
+
constructor(hqRoot, log = () => { }) {
|
|
41
|
+
this.hqRoot = hqRoot;
|
|
42
|
+
this.log = log;
|
|
43
|
+
}
|
|
44
|
+
/** Policies visible to `company`: core, personal, and that company only. */
|
|
45
|
+
list(company) {
|
|
46
|
+
if (!this.entries)
|
|
47
|
+
this.rebuild();
|
|
48
|
+
return this.entries.filter((e) => e.scope !== "company" || e.company === company);
|
|
49
|
+
}
|
|
50
|
+
invalidate() {
|
|
51
|
+
if (this.entries === null)
|
|
52
|
+
return;
|
|
53
|
+
this.entries = null;
|
|
54
|
+
this.version += 1;
|
|
55
|
+
}
|
|
56
|
+
close() {
|
|
57
|
+
this.closed = true;
|
|
58
|
+
for (const w of this.watchers)
|
|
59
|
+
w.close();
|
|
60
|
+
this.watchers = [];
|
|
61
|
+
}
|
|
62
|
+
rebuild() {
|
|
63
|
+
const dirs = [
|
|
64
|
+
{ dir: path.join(this.hqRoot, "core", "policies"), scope: "core" },
|
|
65
|
+
{ dir: path.join(this.hqRoot, "personal", "policies"), scope: "personal" },
|
|
66
|
+
];
|
|
67
|
+
const companiesDir = path.join(this.hqRoot, "companies");
|
|
68
|
+
const companyDirs = [];
|
|
69
|
+
for (const co of readDirNames(companiesDir)) {
|
|
70
|
+
if (!SLUG_RE.test(co))
|
|
71
|
+
continue;
|
|
72
|
+
const companyDir = path.join(companiesDir, co);
|
|
73
|
+
if (isDir(companyDir))
|
|
74
|
+
companyDirs.push(companyDir);
|
|
75
|
+
const dir = path.join(companiesDir, co, "policies");
|
|
76
|
+
if (isDir(dir))
|
|
77
|
+
dirs.push({ dir, scope: "company", company: co });
|
|
78
|
+
}
|
|
79
|
+
const entries = [];
|
|
80
|
+
for (const d of dirs) {
|
|
81
|
+
for (const name of readDirNames(d.dir)) {
|
|
82
|
+
if (!name.endsWith(".md") || name.startsWith("_"))
|
|
83
|
+
continue;
|
|
84
|
+
entries.push({ slug: name.slice(0, -3), path: path.join(d.dir, name), scope: d.scope, ...(d.company ? { company: d.company } : {}) });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
this.entries = entries;
|
|
88
|
+
this.rewatch([...dirs.map((d) => d.dir), ...companyDirs, companiesDir]);
|
|
89
|
+
}
|
|
90
|
+
rewatch(dirs) {
|
|
91
|
+
for (const w of this.watchers)
|
|
92
|
+
w.close();
|
|
93
|
+
this.watchers = [];
|
|
94
|
+
if (this.closed)
|
|
95
|
+
return;
|
|
96
|
+
for (const dir of dirs) {
|
|
97
|
+
if (!isDir(dir))
|
|
98
|
+
continue;
|
|
99
|
+
try {
|
|
100
|
+
const w = fs.watch(dir, () => this.invalidate());
|
|
101
|
+
w.on("error", (err) => {
|
|
102
|
+
this.log("warn", `policy index watch on ${dir} failed: ${err.message}`);
|
|
103
|
+
this.invalidate();
|
|
104
|
+
});
|
|
105
|
+
this.watchers.push(w);
|
|
106
|
+
}
|
|
107
|
+
catch (err) {
|
|
108
|
+
this.log("warn", `could not watch ${dir}: ${err.message}`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
function readDirNames(dir) {
|
|
114
|
+
try {
|
|
115
|
+
return fs.readdirSync(dir);
|
|
116
|
+
}
|
|
117
|
+
catch (err) {
|
|
118
|
+
if (err.code === "ENOENT" || err.code === "ENOTDIR")
|
|
119
|
+
return [];
|
|
120
|
+
throw err;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
function isDir(dir) {
|
|
124
|
+
try {
|
|
125
|
+
return fs.statSync(dir).isDirectory();
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
function runScript(script, payload, env, cwd, timeoutMs) {
|
|
132
|
+
return new Promise((resolve, reject) => {
|
|
133
|
+
const child = spawn("bash", [script], { cwd, env, stdio: ["pipe", "pipe", "pipe"] });
|
|
134
|
+
let stdout = "";
|
|
135
|
+
let stderr = "";
|
|
136
|
+
let timedOut = false;
|
|
137
|
+
const timer = setTimeout(() => {
|
|
138
|
+
timedOut = true;
|
|
139
|
+
child.kill("SIGKILL");
|
|
140
|
+
}, timeoutMs);
|
|
141
|
+
child.stdout.on("data", (c) => (stdout += c.toString("utf8")));
|
|
142
|
+
child.stderr.on("data", (c) => (stderr += c.toString("utf8")));
|
|
143
|
+
child.on("error", (err) => {
|
|
144
|
+
clearTimeout(timer);
|
|
145
|
+
reject(err);
|
|
146
|
+
});
|
|
147
|
+
child.on("close", (code) => {
|
|
148
|
+
clearTimeout(timer);
|
|
149
|
+
resolve({ code, stdout, stderr, timedOut });
|
|
150
|
+
});
|
|
151
|
+
child.stdin.on("error", () => {
|
|
152
|
+
// The script may exit before reading stdin; its exit code still decides.
|
|
153
|
+
});
|
|
154
|
+
child.stdin.end(payload);
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* The scope authorizer reads the session's company from
|
|
159
|
+
* workspace/sessions/<sid>/meta.yaml (or scope-capability.json). hqd binds a
|
|
160
|
+
* session id of its own per company + caller, so a check never borrows the
|
|
161
|
+
* binding of an unrelated session.
|
|
162
|
+
*/
|
|
163
|
+
export function bindCheckSession(hqRoot, company, callerSession, now) {
|
|
164
|
+
const caller = (callerSession ?? "default").replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 80) || "default";
|
|
165
|
+
const sid = `hqd-${company}-${caller}`;
|
|
166
|
+
if (!SESSION_RE.test(sid))
|
|
167
|
+
throw new PolicyOpError("bad_args", "sessionId has no usable characters");
|
|
168
|
+
const dir = path.join(hqRoot, "workspace", "sessions", sid);
|
|
169
|
+
const meta = path.join(dir, "meta.yaml");
|
|
170
|
+
const body = `session_id: ${sid}\nstarted_at: ${now.toISOString()}\nsenior: user\ncompany_slug: ${company}\nsource: hqd\n`;
|
|
171
|
+
let current = "";
|
|
172
|
+
try {
|
|
173
|
+
current = fs.readFileSync(meta, "utf8");
|
|
174
|
+
}
|
|
175
|
+
catch (err) {
|
|
176
|
+
if (err.code !== "ENOENT")
|
|
177
|
+
throw err;
|
|
178
|
+
}
|
|
179
|
+
if (!current.includes(`\ncompany_slug: ${company}\n`)) {
|
|
180
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
181
|
+
const tmp = `${meta}.${process.pid}.tmp`;
|
|
182
|
+
fs.writeFileSync(tmp, body);
|
|
183
|
+
fs.renameSync(tmp, meta);
|
|
184
|
+
}
|
|
185
|
+
return sid;
|
|
186
|
+
}
|
|
187
|
+
function extractContext(stdout) {
|
|
188
|
+
const text = stdout.trim();
|
|
189
|
+
if (!text)
|
|
190
|
+
return "";
|
|
191
|
+
try {
|
|
192
|
+
const parsed = JSON.parse(text);
|
|
193
|
+
const ctx = parsed.hookSpecificOutput?.additionalContext ?? parsed.additionalContext;
|
|
194
|
+
return typeof ctx === "string" ? ctx : "";
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
return text;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
function blockFromJson(stdout) {
|
|
201
|
+
try {
|
|
202
|
+
const parsed = JSON.parse(stdout.trim());
|
|
203
|
+
if (parsed.decision === "block")
|
|
204
|
+
return typeof parsed.reason === "string" ? parsed.reason : "blocked";
|
|
205
|
+
if (parsed.hookSpecificOutput?.permissionDecision === "deny") {
|
|
206
|
+
const r = parsed.hookSpecificOutput.permissionDecisionReason;
|
|
207
|
+
return typeof r === "string" ? r : "denied";
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
// Plain-text stdout is not a verdict.
|
|
212
|
+
}
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
export async function policyCheck(args, deps) {
|
|
216
|
+
const { hqRoot } = deps;
|
|
217
|
+
if (!SLUG_RE.test(args.company))
|
|
218
|
+
throw new PolicyOpError("bad_args", "company must be a company slug");
|
|
219
|
+
if (!isDir(path.join(hqRoot, "companies", args.company))) {
|
|
220
|
+
throw new PolicyOpError("unknown_company", `no companies/${args.company} under ${hqRoot}`);
|
|
221
|
+
}
|
|
222
|
+
const authorizer = path.join(hqRoot, SCOPE_AUTHORIZER);
|
|
223
|
+
const trigger = path.join(hqRoot, POLICY_TRIGGER);
|
|
224
|
+
if (!fs.existsSync(authorizer)) {
|
|
225
|
+
throw new PolicyOpError("policy_engine_unavailable", `scope authorizer not found at ${authorizer}`);
|
|
226
|
+
}
|
|
227
|
+
const event = args.event ?? "PreToolUse";
|
|
228
|
+
const session = bindCheckSession(hqRoot, args.company, args.sessionId, (deps.now ?? (() => new Date()))());
|
|
229
|
+
const payload = JSON.stringify({
|
|
230
|
+
session_id: session,
|
|
231
|
+
hook_event_name: event,
|
|
232
|
+
tool_name: args.tool,
|
|
233
|
+
tool_input: args.input ?? {},
|
|
234
|
+
cwd: args.cwd ?? hqRoot,
|
|
235
|
+
});
|
|
236
|
+
const env = {
|
|
237
|
+
...(deps.env ?? process.env),
|
|
238
|
+
HQ_ROOT: hqRoot,
|
|
239
|
+
CLAUDE_PROJECT_DIR: hqRoot,
|
|
240
|
+
HQ_POLICY_COMPANY: args.company,
|
|
241
|
+
BASH_ENV: "/dev/null",
|
|
242
|
+
};
|
|
243
|
+
delete env.HQ_HOOK_SESSION_ID;
|
|
244
|
+
delete env.HQ_HOOK_TOOL_NAME;
|
|
245
|
+
const timeoutMs = deps.timeoutMs ?? 15_000;
|
|
246
|
+
const reasons = [];
|
|
247
|
+
let decision = "allow";
|
|
248
|
+
if (event === "PreToolUse") {
|
|
249
|
+
const r = await runScript(authorizer, payload, env, hqRoot, timeoutMs);
|
|
250
|
+
const jsonBlock = blockFromJson(r.stdout);
|
|
251
|
+
if (r.timedOut) {
|
|
252
|
+
decision = "block";
|
|
253
|
+
reasons.push(`scope authorizer timed out after ${timeoutMs}ms; failing closed`);
|
|
254
|
+
}
|
|
255
|
+
else if (r.code === 2 || jsonBlock) {
|
|
256
|
+
decision = "block";
|
|
257
|
+
reasons.push((jsonBlock ?? r.stderr).trim() || "blocked by scope authorizer");
|
|
258
|
+
}
|
|
259
|
+
else if (r.code !== 0) {
|
|
260
|
+
decision = "block";
|
|
261
|
+
reasons.push(`scope authorizer exited ${r.code}; failing closed: ${r.stderr.trim()}`);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
let additionalContext = "";
|
|
265
|
+
if (fs.existsSync(trigger)) {
|
|
266
|
+
const r = await runScript(trigger, payload, env, hqRoot, timeoutMs);
|
|
267
|
+
if (r.code === 0 && !r.timedOut)
|
|
268
|
+
additionalContext = extractContext(r.stdout);
|
|
269
|
+
else
|
|
270
|
+
reasons.push(`policy trigger hook did not complete (exit ${r.code}${r.timedOut ? ", timed out" : ""}); no policy context`);
|
|
271
|
+
}
|
|
272
|
+
else {
|
|
273
|
+
reasons.push(`policy trigger hook not found at ${trigger}; no policy context`);
|
|
274
|
+
}
|
|
275
|
+
return {
|
|
276
|
+
decision,
|
|
277
|
+
reasons,
|
|
278
|
+
additionalContext,
|
|
279
|
+
session,
|
|
280
|
+
policies: { count: deps.index.list(args.company).length, version: deps.index.version },
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
//# sourceMappingURL=policy.js.map
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* hqd wire protocol (hq-anywhere US-008).
|
|
3
|
+
*
|
|
4
|
+
* One Unix domain socket (a named pipe on Windows) carrying newline-delimited
|
|
5
|
+
* JSON. Requests are `{id, op, args}`; each gets exactly one response
|
|
6
|
+
* `{id, ok: true, result}` or `{id, ok: false, error}`. Responses may arrive
|
|
7
|
+
* out of order, so clients match them by `id`.
|
|
8
|
+
*/
|
|
9
|
+
export declare const HQD_SOCKET_NAME = "hqd.sock";
|
|
10
|
+
export declare const HQD_OPS: readonly ["ping", "resolve", "session.open", "session.get", "session.close", "policy.check", "policy.index", "journal.append", "memory.get", "memory.put", "mesh.presence", "shutdown"];
|
|
11
|
+
export type HqdOp = (typeof HQD_OPS)[number];
|
|
12
|
+
/** A request line larger than this is rejected and the connection closed. */
|
|
13
|
+
export declare const HQD_MAX_LINE_BYTES: number;
|
|
14
|
+
export interface HqdRequest {
|
|
15
|
+
id: string | number;
|
|
16
|
+
op: string;
|
|
17
|
+
args?: Record<string, unknown>;
|
|
18
|
+
}
|
|
19
|
+
export interface HqdError {
|
|
20
|
+
code: string;
|
|
21
|
+
message: string;
|
|
22
|
+
}
|
|
23
|
+
export type HqdResponse = {
|
|
24
|
+
id: string | number | null;
|
|
25
|
+
ok: true;
|
|
26
|
+
result: unknown;
|
|
27
|
+
} | {
|
|
28
|
+
id: string | number | null;
|
|
29
|
+
ok: false;
|
|
30
|
+
error: HqdError;
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* Socket path: HQ_HQD_SOCKET, else <HQ_REGISTRY_DIR or ~/.hq>/hqd.sock.
|
|
34
|
+
* On Windows the socket is a per-user named pipe.
|
|
35
|
+
*/
|
|
36
|
+
export declare function hqdSocketPath(home?: string, env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform): string;
|
|
37
|
+
export declare function encodeMessage(message: HqdRequest | HqdResponse): string;
|
|
38
|
+
export type ParsedLine = {
|
|
39
|
+
ok: true;
|
|
40
|
+
request: HqdRequest;
|
|
41
|
+
} | {
|
|
42
|
+
ok: false;
|
|
43
|
+
id: string | number | null;
|
|
44
|
+
error: HqdError;
|
|
45
|
+
};
|
|
46
|
+
/** Validate one request line. Never throws. */
|
|
47
|
+
export declare function parseRequestLine(line: string): ParsedLine;
|
|
48
|
+
/**
|
|
49
|
+
* Split a byte stream into lines. Returns a push function that hands each
|
|
50
|
+
* complete line to `onLine`, and reports overflow when a partial line grows
|
|
51
|
+
* past `maxBytes`.
|
|
52
|
+
*/
|
|
53
|
+
export declare function createLineSplitter(onLine: (line: string) => void, onOverflow: () => void, maxBytes?: number): (chunk: Buffer | string) => void;
|
|
54
|
+
export declare class HqdClientError extends Error {
|
|
55
|
+
readonly code: string;
|
|
56
|
+
constructor(code: string, message: string);
|
|
57
|
+
}
|
|
58
|
+
export interface HqdRequestOptions {
|
|
59
|
+
socketPath?: string;
|
|
60
|
+
/** Whole-request budget, including connect. Default 2000ms. */
|
|
61
|
+
timeoutMs?: number;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Send one request and wait for its response. A daemon that is not running
|
|
65
|
+
* fails fast with the socket error code (ENOENT or ECONNREFUSED).
|
|
66
|
+
*/
|
|
67
|
+
export declare function hqdRequest(op: HqdOp | string, args?: Record<string, unknown>, opts?: HqdRequestOptions): Promise<unknown>;
|
|
68
|
+
//# sourceMappingURL=protocol.d.ts.map
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* hqd wire protocol (hq-anywhere US-008).
|
|
3
|
+
*
|
|
4
|
+
* One Unix domain socket (a named pipe on Windows) carrying newline-delimited
|
|
5
|
+
* JSON. Requests are `{id, op, args}`; each gets exactly one response
|
|
6
|
+
* `{id, ok: true, result}` or `{id, ok: false, error}`. Responses may arrive
|
|
7
|
+
* out of order, so clients match them by `id`.
|
|
8
|
+
*/
|
|
9
|
+
import * as net from "node:net";
|
|
10
|
+
import * as os from "node:os";
|
|
11
|
+
import * as path from "node:path";
|
|
12
|
+
export const HQD_SOCKET_NAME = "hqd.sock";
|
|
13
|
+
export const HQD_OPS = [
|
|
14
|
+
"ping",
|
|
15
|
+
"resolve",
|
|
16
|
+
"session.open",
|
|
17
|
+
"session.get",
|
|
18
|
+
"session.close",
|
|
19
|
+
"policy.check",
|
|
20
|
+
"policy.index",
|
|
21
|
+
"journal.append",
|
|
22
|
+
"memory.get",
|
|
23
|
+
"memory.put",
|
|
24
|
+
"mesh.presence",
|
|
25
|
+
"shutdown",
|
|
26
|
+
];
|
|
27
|
+
/** A request line larger than this is rejected and the connection closed. */
|
|
28
|
+
export const HQD_MAX_LINE_BYTES = 1024 * 1024;
|
|
29
|
+
/**
|
|
30
|
+
* Socket path: HQ_HQD_SOCKET, else <HQ_REGISTRY_DIR or ~/.hq>/hqd.sock.
|
|
31
|
+
* On Windows the socket is a per-user named pipe.
|
|
32
|
+
*/
|
|
33
|
+
export function hqdSocketPath(home = os.homedir(), env = process.env, platform = process.platform) {
|
|
34
|
+
const override = env.HQ_HQD_SOCKET?.trim();
|
|
35
|
+
if (override)
|
|
36
|
+
return override;
|
|
37
|
+
if (platform === "win32") {
|
|
38
|
+
const user = (env.USERNAME ?? os.userInfo().username).replace(/[^A-Za-z0-9_.-]/g, "-");
|
|
39
|
+
return `\\\\.\\pipe\\hqd-${user}`;
|
|
40
|
+
}
|
|
41
|
+
const dir = env.HQ_REGISTRY_DIR?.trim() ? path.resolve(env.HQ_REGISTRY_DIR.trim()) : path.join(home, ".hq");
|
|
42
|
+
return path.join(dir, HQD_SOCKET_NAME);
|
|
43
|
+
}
|
|
44
|
+
export function encodeMessage(message) {
|
|
45
|
+
return `${JSON.stringify(message)}\n`;
|
|
46
|
+
}
|
|
47
|
+
/** Validate one request line. Never throws. */
|
|
48
|
+
export function parseRequestLine(line) {
|
|
49
|
+
let raw;
|
|
50
|
+
try {
|
|
51
|
+
raw = JSON.parse(line);
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return { ok: false, id: null, error: { code: "bad_json", message: "request is not valid JSON" } };
|
|
55
|
+
}
|
|
56
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
57
|
+
return { ok: false, id: null, error: { code: "bad_request", message: "request must be a JSON object" } };
|
|
58
|
+
}
|
|
59
|
+
const obj = raw;
|
|
60
|
+
const id = typeof obj.id === "string" || typeof obj.id === "number" ? obj.id : null;
|
|
61
|
+
if (id === null)
|
|
62
|
+
return { ok: false, id: null, error: { code: "bad_request", message: "request needs a string or number id" } };
|
|
63
|
+
if (typeof obj.op !== "string" || !obj.op)
|
|
64
|
+
return { ok: false, id, error: { code: "bad_request", message: "request needs an op" } };
|
|
65
|
+
if (obj.args !== undefined && (obj.args === null || typeof obj.args !== "object" || Array.isArray(obj.args))) {
|
|
66
|
+
return { ok: false, id, error: { code: "bad_request", message: "args must be an object" } };
|
|
67
|
+
}
|
|
68
|
+
return { ok: true, request: { id, op: obj.op, args: obj.args ?? {} } };
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Split a byte stream into lines. Returns a push function that hands each
|
|
72
|
+
* complete line to `onLine`, and reports overflow when a partial line grows
|
|
73
|
+
* past `maxBytes`.
|
|
74
|
+
*/
|
|
75
|
+
export function createLineSplitter(onLine, onOverflow, maxBytes = HQD_MAX_LINE_BYTES) {
|
|
76
|
+
let buffer = "";
|
|
77
|
+
let overflowed = false;
|
|
78
|
+
return (chunk) => {
|
|
79
|
+
if (overflowed)
|
|
80
|
+
return;
|
|
81
|
+
buffer += typeof chunk === "string" ? chunk : chunk.toString("utf8");
|
|
82
|
+
let nl = buffer.indexOf("\n");
|
|
83
|
+
while (nl !== -1) {
|
|
84
|
+
const line = buffer.slice(0, nl).replace(/\r$/, "");
|
|
85
|
+
buffer = buffer.slice(nl + 1);
|
|
86
|
+
if (Buffer.byteLength(line, "utf8") > maxBytes) {
|
|
87
|
+
overflowed = true;
|
|
88
|
+
buffer = "";
|
|
89
|
+
onOverflow();
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
if (line.trim())
|
|
93
|
+
onLine(line);
|
|
94
|
+
nl = buffer.indexOf("\n");
|
|
95
|
+
}
|
|
96
|
+
if (Buffer.byteLength(buffer, "utf8") > maxBytes) {
|
|
97
|
+
overflowed = true;
|
|
98
|
+
buffer = "";
|
|
99
|
+
onOverflow();
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
export class HqdClientError extends Error {
|
|
104
|
+
code;
|
|
105
|
+
constructor(code, message) {
|
|
106
|
+
super(message);
|
|
107
|
+
this.code = code;
|
|
108
|
+
this.name = "HqdClientError";
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
let nextClientId = 0;
|
|
112
|
+
/**
|
|
113
|
+
* Send one request and wait for its response. A daemon that is not running
|
|
114
|
+
* fails fast with the socket error code (ENOENT or ECONNREFUSED).
|
|
115
|
+
*/
|
|
116
|
+
export function hqdRequest(op, args = {}, opts = {}) {
|
|
117
|
+
const socketPath = opts.socketPath ?? hqdSocketPath();
|
|
118
|
+
const timeoutMs = opts.timeoutMs ?? 2_000;
|
|
119
|
+
const id = `c${process.pid}-${(nextClientId += 1)}`;
|
|
120
|
+
return new Promise((resolve, reject) => {
|
|
121
|
+
const socket = net.createConnection(socketPath);
|
|
122
|
+
let settled = false;
|
|
123
|
+
const finish = (fn) => {
|
|
124
|
+
if (settled)
|
|
125
|
+
return;
|
|
126
|
+
settled = true;
|
|
127
|
+
clearTimeout(timer);
|
|
128
|
+
socket.destroy();
|
|
129
|
+
fn();
|
|
130
|
+
};
|
|
131
|
+
const timer = setTimeout(() => finish(() => reject(new HqdClientError("ETIMEDOUT", `hqd did not answer ${op} within ${timeoutMs}ms`))), timeoutMs);
|
|
132
|
+
const push = createLineSplitter((line) => {
|
|
133
|
+
let msg;
|
|
134
|
+
try {
|
|
135
|
+
msg = JSON.parse(line);
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
finish(() => reject(new HqdClientError("bad_response", "hqd sent a line that is not JSON")));
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (msg.id !== id)
|
|
142
|
+
return;
|
|
143
|
+
if (msg.ok)
|
|
144
|
+
finish(() => resolve(msg.result));
|
|
145
|
+
else
|
|
146
|
+
finish(() => reject(new HqdClientError(msg.error.code, msg.error.message)));
|
|
147
|
+
}, () => finish(() => reject(new HqdClientError("bad_response", "hqd response too large"))));
|
|
148
|
+
socket.on("connect", () => socket.write(encodeMessage({ id, op, args })));
|
|
149
|
+
socket.on("data", push);
|
|
150
|
+
socket.on("error", (err) => finish(() => reject(new HqdClientError(err.code ?? "ESOCKET", `hqd is not reachable at ${socketPath}: ${err.message}`))));
|
|
151
|
+
socket.on("close", () => finish(() => reject(new HqdClientError("ECONNRESET", "hqd closed the connection before answering"))));
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
//# sourceMappingURL=protocol.js.map
|
package/dist/lib/daemon/run.d.ts
CHANGED
|
@@ -7,11 +7,21 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { type UnitPlan } from "./catalog.js";
|
|
9
9
|
import { type LegacyUnit } from "./legacy.js";
|
|
10
|
+
import { type DaemonLockResult } from "./lock.js";
|
|
10
11
|
import { type MachineType } from "./machine-type.js";
|
|
11
12
|
import { type HostApp } from "./supervisor.js";
|
|
12
13
|
export declare const LOG_ROTATE_BYTES: number;
|
|
13
14
|
/** Exit code for "restart me": non-zero so every service manager relaunches (Windows tasks only restart on failure). */
|
|
14
15
|
export declare const RESTART_EXIT_CODE = 75;
|
|
16
|
+
export interface DaemonLockConflictOutcome {
|
|
17
|
+
exitCode: 0 | 1;
|
|
18
|
+
stdout?: string;
|
|
19
|
+
stderr?: string;
|
|
20
|
+
}
|
|
21
|
+
/** Preserve the legacy lock failure unless Anywhere explicitly enables the no-op behavior. */
|
|
22
|
+
export declare function daemonLockConflictOutcome(lock: DaemonLockResult, anywhereEnabled: boolean): DaemonLockConflictOutcome | null;
|
|
23
|
+
/** Node's Windows named-pipe API uses a default DACL that is not user-scoped. */
|
|
24
|
+
export declare function hqdStartupRefusal(platform: NodeJS.Platform, anywhereEnabled: boolean): string | null;
|
|
15
25
|
/** Route an updater request through the daemon's existing graceful handoff. */
|
|
16
26
|
export declare function handoffForPackageUpdateRequest(requested: boolean, restartDaemon: () => void): void;
|
|
17
27
|
export interface RunDaemonOptions {
|
package/dist/lib/daemon/run.js
CHANGED
|
@@ -12,6 +12,7 @@ import * as os from "node:os";
|
|
|
12
12
|
import * as path from "node:path";
|
|
13
13
|
import { loadCachedTokens } from "@indigoai-us/hq-cloud";
|
|
14
14
|
import { CLI_VERSION } from "../../cli-version.js";
|
|
15
|
+
import { anywhereRuntimeEnabled } from "../../commands/anywhere-runtime-flag.js";
|
|
15
16
|
import { currentPackageUseUpdateRequested } from "../../utils/package-use-lease.js";
|
|
16
17
|
import { isLocalDependencyInstall, resolveRunningInstall } from "../../utils/version-gate.js";
|
|
17
18
|
import { agentKitPaths } from "../agent-kit/paths.js";
|
|
@@ -26,9 +27,13 @@ import { acquireDaemonLock, isProcessAlive, processCommandLine, releaseDaemonLoc
|
|
|
26
27
|
import { retireLegacyMeshUnits } from "./migrate.js";
|
|
27
28
|
import { daemonDir as meshDaemonDir, daemonPidPath as meshDaemonPidPath } from "../mesh/live/daemon/paths.js";
|
|
28
29
|
import { readPidLock } from "../mesh/live/daemon/pid-lock.js";
|
|
30
|
+
import { registryDir, registryFile } from "../registry/registry.js";
|
|
29
31
|
import { daemonRootRefusal } from "./install.js";
|
|
30
32
|
import { detectMachineType } from "./machine-type.js";
|
|
31
33
|
import { appendPrivate, daemonPaths, ensurePrivateDir, openPrivateAppend } from "./paths.js";
|
|
34
|
+
import { hqdSocketPath } from "./protocol.js";
|
|
35
|
+
import { startHqdServer } from "./server.js";
|
|
36
|
+
import { heartbeatFile, startHeartbeat } from "./heartbeat.js";
|
|
32
37
|
import { createProtocolTail, ensureProtocolFifo, openProtocolWriter } from "./protocol-tail.js";
|
|
33
38
|
import { takeControlRequests } from "./requests.js";
|
|
34
39
|
import { currentHqCloud, hqCloudRunners } from "./hq-cloud.js";
|
|
@@ -40,6 +45,26 @@ import { userToolDirs } from "./tools.js";
|
|
|
40
45
|
export const LOG_ROTATE_BYTES = 5 * 1024 * 1024;
|
|
41
46
|
/** Exit code for "restart me": non-zero so every service manager relaunches (Windows tasks only restart on failure). */
|
|
42
47
|
export const RESTART_EXIT_CODE = 75;
|
|
48
|
+
/** Preserve the legacy lock failure unless Anywhere explicitly enables the no-op behavior. */
|
|
49
|
+
export function daemonLockConflictOutcome(lock, anywhereEnabled) {
|
|
50
|
+
if (lock.ok)
|
|
51
|
+
return null;
|
|
52
|
+
if (lock.owner && anywhereEnabled) {
|
|
53
|
+
return { exitCode: 0, stdout: `hq daemon is already running (pid ${lock.owner.pid}).\n` };
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
exitCode: 1,
|
|
57
|
+
stderr: lock.owner
|
|
58
|
+
? `hq daemon is already running (pid ${lock.owner.pid}).\n`
|
|
59
|
+
: "another hq daemon is starting up.\n",
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
/** Node's Windows named-pipe API uses a default DACL that is not user-scoped. */
|
|
63
|
+
export function hqdStartupRefusal(platform, anywhereEnabled) {
|
|
64
|
+
if (platform !== "win32" || !anywhereEnabled)
|
|
65
|
+
return null;
|
|
66
|
+
return "hqd is disabled on Windows because the default named-pipe ACL grants read access to Everyone and Anonymous accounts.";
|
|
67
|
+
}
|
|
43
68
|
/** Route an updater request through the daemon's existing graceful handoff. */
|
|
44
69
|
export function handoffForPackageUpdateRequest(requested, restartDaemon) {
|
|
45
70
|
if (requested)
|
|
@@ -280,9 +305,13 @@ export async function runDaemon(opts) {
|
|
|
280
305
|
const paths = daemonPaths(home, env);
|
|
281
306
|
const log = makeLogger(paths, opts.foreground === true);
|
|
282
307
|
const lock = acquireDaemonLock(paths.lockPath);
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
308
|
+
const lockConflict = daemonLockConflictOutcome(lock, !lock.ok && lock.owner ? anywhereRuntimeEnabled(env) : false);
|
|
309
|
+
if (lockConflict) {
|
|
310
|
+
if (lockConflict.stdout)
|
|
311
|
+
process.stdout.write(lockConflict.stdout);
|
|
312
|
+
if (lockConflict.stderr)
|
|
313
|
+
process.stderr.write(lockConflict.stderr);
|
|
314
|
+
return lockConflict.exitCode;
|
|
286
315
|
}
|
|
287
316
|
const initial = readDaemonConfig(paths.configPath);
|
|
288
317
|
const detected = detectMachineType(undefined, {
|
|
@@ -659,6 +688,45 @@ export async function runDaemon(opts) {
|
|
|
659
688
|
clearTimeout(timer);
|
|
660
689
|
return on;
|
|
661
690
|
};
|
|
691
|
+
// hqd: the session/company socket every runtime talks to (hq-anywhere US-008).
|
|
692
|
+
let hqd = null;
|
|
693
|
+
let hqdShutdown = null;
|
|
694
|
+
if (anywhereRuntimeEnabled()) {
|
|
695
|
+
const refusal = hqdStartupRefusal(platform, true);
|
|
696
|
+
if (refusal) {
|
|
697
|
+
log("error", refusal);
|
|
698
|
+
}
|
|
699
|
+
else {
|
|
700
|
+
try {
|
|
701
|
+
const startConfig = readDaemonConfig(paths.configPath);
|
|
702
|
+
hqd = await startHqdServer({
|
|
703
|
+
socketPath: hqdSocketPath(home, env),
|
|
704
|
+
registryPath: registryFile(registryDir(home, env)),
|
|
705
|
+
hqRoot: startConfig.hqRoot ?? (env.HQ_ROOT?.trim() || undefined),
|
|
706
|
+
log,
|
|
707
|
+
onShutdown: () => hqdShutdown?.(),
|
|
708
|
+
reportError: reportHqdCrash,
|
|
709
|
+
});
|
|
710
|
+
}
|
|
711
|
+
catch (err) {
|
|
712
|
+
log("error", `hqd socket did not start; services run without it: ${err.message}`);
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
// US-012: 60s presence heartbeat with version and session count.
|
|
717
|
+
const liveHqd = hqd;
|
|
718
|
+
const stopHeartbeat = liveHqd
|
|
719
|
+
? startHeartbeat({
|
|
720
|
+
version: CLI_VERSION,
|
|
721
|
+
sessionCount: () => liveHqd.sessions.size,
|
|
722
|
+
file: heartbeatFile(home, env),
|
|
723
|
+
log,
|
|
724
|
+
})
|
|
725
|
+
: () => { };
|
|
726
|
+
const closeHqd = () => {
|
|
727
|
+
stopHeartbeat();
|
|
728
|
+
void hqd?.close().catch((err) => log("warn", `hqd socket did not close cleanly: ${err.message}`));
|
|
729
|
+
};
|
|
662
730
|
return await new Promise((resolve) => {
|
|
663
731
|
let stopping = false;
|
|
664
732
|
let restartPending = false;
|
|
@@ -667,6 +735,7 @@ export async function runDaemon(opts) {
|
|
|
667
735
|
return;
|
|
668
736
|
stopping = true;
|
|
669
737
|
log("info", `${why}; stopping services`);
|
|
738
|
+
closeHqd();
|
|
670
739
|
// The supervisor escalates to SIGKILL after STOP_ESCALATION_MS; the extra
|
|
671
740
|
// margin only covers a child that survives even that.
|
|
672
741
|
let capTimer;
|
|
@@ -691,6 +760,7 @@ export async function runDaemon(opts) {
|
|
|
691
760
|
if (stopping)
|
|
692
761
|
return;
|
|
693
762
|
stopping = true;
|
|
763
|
+
closeHqd();
|
|
694
764
|
const released = supervisor.release();
|
|
695
765
|
// What these readers have not read stays in the FIFO for the next daemon.
|
|
696
766
|
for (const tail of protocolTails)
|
|
@@ -748,6 +818,7 @@ export async function runDaemon(opts) {
|
|
|
748
818
|
return true;
|
|
749
819
|
});
|
|
750
820
|
};
|
|
821
|
+
hqdShutdown = () => shutdown("hqd shutdown request");
|
|
751
822
|
process.on("SIGTERM", () => shutdown("received SIGTERM"));
|
|
752
823
|
process.on("SIGINT", () => shutdown("received SIGINT"));
|
|
753
824
|
if (platform !== "win32")
|
|
@@ -757,9 +828,20 @@ export async function runDaemon(opts) {
|
|
|
757
828
|
}
|
|
758
829
|
catch (err) {
|
|
759
830
|
log("error", `supervisor failed to start: ${err.message}`);
|
|
831
|
+
closeHqd();
|
|
760
832
|
releaseDaemonLock(paths.lockPath);
|
|
761
833
|
resolve(1);
|
|
762
834
|
}
|
|
763
835
|
});
|
|
764
836
|
}
|
|
837
|
+
/** Report an hqd op crash to the CLI's existing Sentry integration, tagged with the failing op. */
|
|
838
|
+
function reportHqdCrash(op, err) {
|
|
839
|
+
void import("../../sentry.js").then(({ Sentry }) => {
|
|
840
|
+
Sentry.withScope((scope) => {
|
|
841
|
+
scope.setTag("component", "hqd");
|
|
842
|
+
scope.setTag("hqd.op", op);
|
|
843
|
+
Sentry.captureException(err);
|
|
844
|
+
});
|
|
845
|
+
}, (importErr) => process.stderr.write(`hqd could not load Sentry to report a ${op} failure: ${String(importErr)}\n`));
|
|
846
|
+
}
|
|
765
847
|
//# sourceMappingURL=run.js.map
|