@indigoai-us/hq-cli 5.345.147 → 5.345.149
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 +65 -0
- package/README.md +2 -0
- package/assets/bot-workers/setup/worker.yaml +11 -0
- package/dist/command-catalog.generated.d.ts +341 -11
- package/dist/command-catalog.generated.js +421 -11
- package/dist/command-help.js +2 -1
- package/dist/commands/access.d.ts +45 -2
- package/dist/commands/access.js +537 -223
- package/dist/commands/agent-inbox.js +2 -1
- package/dist/commands/billing-usage.d.ts +33 -2
- package/dist/commands/billing-usage.js +75 -4
- package/dist/commands/bot.js +2 -2
- package/dist/commands/channels.d.ts +27 -0
- package/dist/commands/channels.js +108 -4
- package/dist/commands/cloud.js +31 -1
- package/dist/commands/core-refresh-vault-access-native.d.ts +33 -0
- package/dist/commands/core-refresh-vault-access-native.js +356 -28
- package/dist/commands/core-work-mesh-bind-trusted-native.d.ts +9 -0
- package/dist/commands/core-work-mesh-bind-trusted-native.js +26 -19
- package/dist/commands/core.js +9 -0
- package/dist/commands/daemon.d.ts +7 -0
- package/dist/commands/daemon.js +37 -3
- package/dist/commands/dm.js +18 -3
- package/dist/commands/doctor.d.ts +11 -1
- package/dist/commands/doctor.js +56 -1
- package/dist/commands/install-global.d.ts +8 -8
- package/dist/commands/install-global.js +139 -61
- package/dist/commands/lanes-shared.d.ts +3 -1
- package/dist/commands/lanes.d.ts +4 -4
- package/dist/commands/lanes.js +380 -25
- package/dist/commands/library-add.d.ts +31 -1
- package/dist/commands/library-add.js +69 -2
- package/dist/commands/library.js +37 -3
- package/dist/commands/pack-install.d.ts +2 -2
- package/dist/commands/pack-install.js +133 -35
- package/dist/commands/reindex.js +19 -0
- package/dist/commands/sync-manifest.d.ts +2 -9
- package/dist/commands/sync-manifest.js +4 -15
- package/dist/hook-engine-path.d.ts +4 -0
- package/dist/hook-engine-path.js +43 -0
- package/dist/hq-cloud-sync-runner/bin/sync-runner.js +2783 -2354
- package/dist/hq-cloud-sync-runner/package.json +1 -1
- package/dist/index.d.ts +10 -0
- package/dist/index.js +76 -5
- package/dist/lazy-commands.js +2 -0
- package/dist/lib/agent-kit/inbox-redact.d.ts +44 -0
- package/dist/lib/agent-kit/inbox-redact.js +361 -0
- package/dist/lib/agent-kit/inbox-state.js +11 -2
- package/dist/lib/agent-kit/mcp/tools.js +2 -1
- package/dist/lib/agent-kit/run/inbox.js +36 -7
- package/dist/lib/anywhere-runtime-flag-key.d.ts +3 -0
- package/dist/lib/anywhere-runtime-flag-key.js +9 -0
- package/dist/lib/billing/usage-cost.d.ts +31 -1
- package/dist/lib/billing/usage-cost.js +46 -0
- package/dist/lib/company-skill-wrapper.js +6 -4
- package/dist/lib/daemon/install.d.ts +47 -1
- package/dist/lib/daemon/install.js +181 -15
- package/dist/lib/daemon/lock.d.ts +32 -0
- package/dist/lib/daemon/lock.js +55 -2
- package/dist/lib/db/local.d.ts +2 -2
- package/dist/lib/db/local.js +2 -2
- package/dist/lib/db/sql.js +2 -2
- package/dist/lib/db/sqlite-binding.d.ts +22 -0
- package/dist/lib/db/sqlite-binding.js +168 -0
- package/dist/lib/doctor/__testing__/run-doctor.js +4 -0
- package/dist/lib/doctor/checks/anywhere.d.ts +15 -0
- package/dist/lib/doctor/checks/anywhere.js +77 -11
- package/dist/lib/doctor/checks/cli-path.d.ts +12 -0
- package/dist/lib/doctor/checks/cli-path.js +46 -0
- package/dist/lib/doctor/checks/hook-engine.d.ts +4 -0
- package/dist/lib/doctor/checks/hook-engine.js +17 -0
- package/dist/lib/doctor/checks/sync-health.d.ts +7 -0
- package/dist/lib/doctor/checks/sync-health.js +38 -2
- package/dist/lib/doctor/checks/temp-homes.d.ts +19 -0
- package/dist/lib/doctor/checks/temp-homes.js +54 -0
- package/dist/lib/doctor/fix/apply.d.ts +90 -1
- package/dist/lib/doctor/fix/apply.js +176 -10
- package/dist/lib/doctor/fix/json-output.d.ts +4 -1
- package/dist/lib/doctor/fix/json-output.js +6 -0
- package/dist/lib/doctor/fix/remediation.d.ts +7 -1
- package/dist/lib/doctor/fix/remediation.js +23 -1
- package/dist/lib/doctor/registry.js +10 -0
- package/dist/lib/doctor/temp-home-sweeper.d.ts +149 -0
- package/dist/lib/doctor/temp-home-sweeper.js +418 -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/flag-registry.js +9 -1
- package/dist/lib/flag-snapshot-file.js +8 -3
- package/dist/lib/hook-engine/reconcile.d.ts +9 -0
- package/dist/lib/hook-engine/reconcile.js +134 -0
- package/dist/lib/hook-engine/registration.d.ts +17 -0
- package/dist/lib/hook-engine/registration.js +50 -0
- package/dist/lib/hook-engine/resolve.d.ts +31 -0
- package/dist/lib/hook-engine/resolve.js +134 -0
- package/dist/lib/install/claude-targets.d.ts +2 -1
- package/dist/lib/install/claude-targets.js +8 -3
- package/dist/lib/install/codex-targets.d.ts +3 -0
- package/dist/lib/install/codex-targets.js +15 -4
- package/dist/lib/install/hook-target.d.ts +9 -0
- package/dist/lib/install/hook-target.js +39 -0
- package/dist/lib/lanes/admission-host.d.ts +1 -0
- package/dist/lib/lanes/admission-host.js +8 -1
- package/dist/lib/lanes/admission.d.ts +4 -0
- package/dist/lib/lanes/admission.js +6 -2
- package/dist/lib/lanes/envelope-repair-dispatch.d.ts +5 -0
- package/dist/lib/lanes/envelope-repair-dispatch.js +12 -1
- package/dist/lib/lanes/index.d.ts +2 -2
- package/dist/lib/lanes/index.js +1 -1
- package/dist/lib/lanes/keep-alive.js +4 -1
- package/dist/lib/lanes/local-host-identity.d.ts +40 -0
- package/dist/lib/lanes/local-host-identity.js +314 -0
- package/dist/lib/lanes/reaper.d.ts +1 -1
- package/dist/lib/lanes/reaper.js +7 -0
- package/dist/lib/lanes/reconcile.d.ts +4 -0
- package/dist/lib/lanes/reconcile.js +16 -8
- package/dist/lib/lanes/spawn.d.ts +20 -0
- package/dist/lib/lanes/spawn.js +48 -2
- package/dist/lib/lanes/status-fields.js +6 -1
- package/dist/lib/lanes/store.js +8 -1
- package/dist/lib/lanes/throttle.d.ts +49 -6
- package/dist/lib/lanes/throttle.js +154 -17
- package/dist/lib/lanes/types.d.ts +22 -1
- package/dist/lib/lanes/types.js +27 -3
- package/dist/lib/lanes/watcher.js +27 -2
- package/dist/lib/lanes/worker-continuity.d.ts +2 -0
- package/dist/lib/lanes/worker-continuity.js +18 -11
- package/dist/lib/manifest-disabled.d.ts +13 -0
- package/dist/lib/manifest-disabled.js +19 -0
- package/dist/lib/service-manager/launchd.d.ts +1 -0
- package/dist/lib/service-manager/launchd.js +1 -1
- package/dist/lib/windows/console-encoding.d.ts +24 -0
- package/dist/lib/windows/console-encoding.js +83 -0
- package/dist/lib/windows/install-followup.d.ts +35 -0
- package/dist/lib/windows/install-followup.js +142 -0
- package/dist/lib/windows/user-path.d.ts +125 -0
- package/dist/lib/windows/user-path.js +275 -0
- package/dist/lib/work-context/auto-bind.d.ts +1 -1
- package/dist/lib/work-context/auto-bind.js +22 -3
- package/dist/lib/work-context/lane-bind.d.ts +31 -0
- package/dist/lib/work-context/lane-bind.js +101 -0
- package/dist/lib/work-context/organize.js +1 -0
- package/dist/lib/work-context/prd-sync.js +7 -0
- package/dist/lib/work-context/project.d.ts +36 -10
- package/dist/lib/work-context/project.js +130 -41
- package/dist/lib/work-context/reconcile.d.ts +6 -0
- package/dist/lib/work-context/reconcile.js +25 -5
- package/dist/lib/work-context/state.d.ts +6 -0
- package/dist/lib/work-context/state.js +29 -1
- package/dist/main.js +2 -1
- package/dist/unknown-command.d.ts +18 -0
- package/dist/unknown-command.js +88 -0
- package/dist/utils/cognito-session.d.ts +8 -0
- package/dist/utils/cognito-session.js +14 -2
- package/dist/utils/copy-tree.d.ts +61 -0
- package/dist/utils/copy-tree.js +249 -0
- package/dist/utils/environmental-error.js +7 -3
- package/dist/utils/hook-trust.d.ts +9 -0
- package/dist/utils/hook-trust.js +30 -2
- package/dist/utils/npm-invocation.d.ts +27 -0
- package/dist/utils/npm-invocation.js +54 -0
- package/dist/utils/qmd-native-binding-error.js +3 -1
- package/dist/utils/resolve-executable.d.ts +2 -0
- package/dist/utils/resolve-executable.js +6 -2
- package/dist/utils/safe-link.d.ts +50 -0
- package/dist/utils/safe-link.js +300 -0
- package/package.json +17 -5
- package/scripts/hook-engine-manifest.mjs +22 -0
- package/scripts/install-hook-engine.mjs +10 -0
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
/** Native local manifest refresh for core/scripts/refresh-vault-access.sh. */
|
|
2
2
|
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { randomBytes } from "node:crypto";
|
|
3
4
|
import * as fs from "node:fs";
|
|
5
|
+
import * as os from "node:os";
|
|
4
6
|
import * as path from "node:path";
|
|
5
7
|
import { resolveWindowsExecutable } from "../lib/bot/runtime/windows.js";
|
|
6
8
|
import { serializeJqJson } from "../lib/core-utils/jq-json-serializer.js";
|
|
@@ -36,22 +38,152 @@ export const REFRESH_VAULT_ACCESS_HELP = `# hq-core: public
|
|
|
36
38
|
# the authoritative security boundary — this manifest only powers the local
|
|
37
39
|
# early-warning block for read-only shares.
|
|
38
40
|
#
|
|
41
|
+
# Scope: with no --company the roll-up rewrites the whole manifest. With
|
|
42
|
+
# --company, only the named companies' entries are replaced; every other
|
|
43
|
+
# company's entry is kept as it was. Writes are atomic and serialized by a
|
|
44
|
+
# lock file next to the manifest.
|
|
45
|
+
#
|
|
39
46
|
# Usage:
|
|
40
47
|
# bash core/scripts/refresh-vault-access.sh [--company <slug>]... [--root <hqRoot>]
|
|
41
48
|
#
|
|
42
49
|
`.replace(/^# ?/gm, "");
|
|
43
|
-
|
|
44
|
-
|
|
50
|
+
/**
|
|
51
|
+
* Per-call ceiling for each child `hq` call. SessionStart runs this helper, so
|
|
52
|
+
* a stalled vault request must not outlive the session that started it.
|
|
53
|
+
*/
|
|
54
|
+
export const DEFAULT_HQ_CALL_TIMEOUT_MS = 30_000;
|
|
55
|
+
function hqCallTimeoutMs(env) {
|
|
56
|
+
const parsed = Number(env.HQ_VAULT_ACCESS_TIMEOUT_MS);
|
|
57
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_HQ_CALL_TIMEOUT_MS;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Supervisor for one child call when the whole process tree must die on
|
|
61
|
+
* timeout. On Windows `hq` is a cmd.exe shim around node, and spawnSync's
|
|
62
|
+
* timeout ends only cmd.exe: the node child keeps running and keeps the
|
|
63
|
+
* output pipes open. The supervisor runs the call asynchronously, kills the
|
|
64
|
+
* tree on timeout (`taskkill /T /F` on Windows, the process group elsewhere),
|
|
65
|
+
* and writes the result as JSON. It exits without waiting for pipes still
|
|
66
|
+
* held by a grandchild, so the parent's spawnSync returns.
|
|
67
|
+
*/
|
|
68
|
+
const TREE_KILL_SUPERVISOR = String.raw `
|
|
69
|
+
const { spawn, spawnSync } = require("node:child_process");
|
|
70
|
+
const path = require("node:path");
|
|
71
|
+
const plan = JSON.parse(process.env.HQ_TREE_KILL_PLAN);
|
|
72
|
+
delete process.env.HQ_TREE_KILL_PLAN;
|
|
73
|
+
const win = process.platform === "win32";
|
|
74
|
+
let done = false;
|
|
75
|
+
const finish = (result) => {
|
|
76
|
+
if (done) return;
|
|
77
|
+
done = true;
|
|
78
|
+
process.stdout.write(JSON.stringify(result), () => process.exit(0));
|
|
79
|
+
};
|
|
80
|
+
const out = [];
|
|
81
|
+
const err = [];
|
|
82
|
+
let child;
|
|
83
|
+
try {
|
|
84
|
+
child = spawn(plan.cmd, plan.args, {
|
|
85
|
+
cwd: plan.cwd,
|
|
86
|
+
env: process.env,
|
|
87
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
88
|
+
shell: plan.shell,
|
|
89
|
+
windowsHide: true,
|
|
90
|
+
windowsVerbatimArguments: plan.verbatim,
|
|
91
|
+
detached: !win,
|
|
92
|
+
});
|
|
93
|
+
} catch (error) {
|
|
94
|
+
finish({ error: String(error && error.message), errorCode: error && error.code });
|
|
95
|
+
}
|
|
96
|
+
if (child) {
|
|
97
|
+
const killTree = () => {
|
|
98
|
+
if (child.pid === undefined) return;
|
|
99
|
+
if (win) {
|
|
100
|
+
const taskkill = process.env.SystemRoot ? path.join(process.env.SystemRoot, "System32", "taskkill.exe") : "taskkill";
|
|
101
|
+
spawnSync(taskkill, ["/T", "/F", "/PID", String(child.pid)], { stdio: "ignore", windowsHide: true, timeout: 10000 });
|
|
102
|
+
} else {
|
|
103
|
+
try { process.kill(-child.pid, "SIGKILL"); } catch {}
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
const timer = setTimeout(() => { killTree(); finish({ timedOut: true }); }, plan.timeoutMs);
|
|
107
|
+
child.stdout.on("data", (chunk) => out.push(chunk));
|
|
108
|
+
child.stderr.on("data", (chunk) => err.push(chunk));
|
|
109
|
+
const result = (code) => ({ code, stdout: Buffer.concat(out).toString("utf8"), stderr: Buffer.concat(err).toString("utf8") });
|
|
110
|
+
child.on("error", (error) => { clearTimeout(timer); finish({ error: error.message, errorCode: error.code }); });
|
|
111
|
+
child.on("close", (code) => { clearTimeout(timer); finish(result(code ?? 128)); });
|
|
112
|
+
// A grandchild that outlives the call can hold the pipes open. Report once
|
|
113
|
+
// the direct child has exited and the pipes have had a moment to drain.
|
|
114
|
+
child.on("exit", (code) => { setTimeout(() => { clearTimeout(timer); finish(result(code ?? 128)); }, 500).unref(); });
|
|
115
|
+
}
|
|
116
|
+
`;
|
|
117
|
+
/** Extra time the parent allows the supervisor beyond the call timeout, for taskkill and startup. */
|
|
118
|
+
const SUPERVISOR_GRACE_MS = 15_000;
|
|
119
|
+
/**
|
|
120
|
+
* Run one child `hq` call with a timeout and with browser sign-in disabled.
|
|
121
|
+
* Without `HQ_NO_BROWSER_LOGIN` (read by cognito-session's
|
|
122
|
+
* resolveInteractiveLogin) a signed-out child opens a browser login and
|
|
123
|
+
* waits on it indefinitely. On Windows the call runs under a supervisor so a
|
|
124
|
+
* timeout ends the cmd.exe shim and every process under it.
|
|
125
|
+
*/
|
|
126
|
+
/** Windows npm shims run hq under cmd.exe, so only there does a plain kill leave the real hq running. */
|
|
127
|
+
export function usesTreeKillByDefault(platform = process.platform) {
|
|
128
|
+
return platform === "win32";
|
|
129
|
+
}
|
|
130
|
+
export function runHqChild(args, cwd, env, killTree = usesTreeKillByDefault()) {
|
|
131
|
+
const childEnv = { ...env, HQ_NO_BROWSER_LOGIN: "1" };
|
|
132
|
+
const timeoutMs = hqCallTimeoutMs(env);
|
|
133
|
+
const timedOut = () => ({ code: 124, stdout: "", stderr: `hq ${args.join(" ")} timed out after ${timeoutMs}ms`, timedOut: true });
|
|
134
|
+
const failed = (message, code) => ({ code: 127, stdout: "", stderr: message, missing: code === "ENOENT" });
|
|
135
|
+
const spawnPlan = planHqSpawn(args, childEnv, cwd);
|
|
136
|
+
if (killTree) {
|
|
137
|
+
const plan = JSON.stringify({
|
|
138
|
+
cmd: spawnPlan.cmd,
|
|
139
|
+
args: spawnPlan.args,
|
|
140
|
+
cwd,
|
|
141
|
+
shell: spawnPlan.shell,
|
|
142
|
+
verbatim: Boolean(spawnPlan.windowsVerbatimArguments),
|
|
143
|
+
timeoutMs,
|
|
144
|
+
});
|
|
145
|
+
const supervised = spawnSync(process.execPath, ["-e", TREE_KILL_SUPERVISOR], {
|
|
146
|
+
cwd,
|
|
147
|
+
env: { ...childEnv, HQ_TREE_KILL_PLAN: plan },
|
|
148
|
+
encoding: "utf8",
|
|
149
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
150
|
+
timeout: timeoutMs + SUPERVISOR_GRACE_MS,
|
|
151
|
+
killSignal: "SIGKILL",
|
|
152
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
153
|
+
windowsHide: true,
|
|
154
|
+
});
|
|
155
|
+
if (supervised.error) {
|
|
156
|
+
const code = supervised.error.code;
|
|
157
|
+
return code === "ETIMEDOUT" ? timedOut() : failed(supervised.error.message, code);
|
|
158
|
+
}
|
|
159
|
+
let parsed;
|
|
160
|
+
try {
|
|
161
|
+
parsed = JSON.parse(String(supervised.stdout ?? ""));
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
return { code: supervised.status ?? 128, stdout: "", stderr: String(supervised.stderr ?? "") };
|
|
165
|
+
}
|
|
166
|
+
if (parsed.timedOut)
|
|
167
|
+
return timedOut();
|
|
168
|
+
if (parsed.error !== undefined)
|
|
169
|
+
return failed(parsed.error, parsed.errorCode);
|
|
170
|
+
return { code: parsed.code ?? 128, stdout: parsed.stdout ?? "", stderr: parsed.stderr ?? "" };
|
|
171
|
+
}
|
|
45
172
|
const result = spawnSync(spawnPlan.cmd, spawnPlan.args, {
|
|
46
173
|
cwd,
|
|
47
|
-
env,
|
|
174
|
+
env: childEnv,
|
|
48
175
|
encoding: "utf8",
|
|
49
176
|
stdio: ["ignore", "pipe", "pipe"],
|
|
50
177
|
shell: spawnPlan.shell,
|
|
178
|
+
timeout: timeoutMs,
|
|
179
|
+
killSignal: "SIGKILL",
|
|
180
|
+
windowsHide: true,
|
|
51
181
|
...(spawnPlan.windowsVerbatimArguments ? { windowsVerbatimArguments: true } : {}),
|
|
52
182
|
});
|
|
53
|
-
if (result.error)
|
|
54
|
-
|
|
183
|
+
if (result.error) {
|
|
184
|
+
const code = result.error.code;
|
|
185
|
+
return code === "ETIMEDOUT" ? timedOut() : failed(result.error.message, code);
|
|
186
|
+
}
|
|
55
187
|
return { code: result.status ?? 128, stdout: String(result.stdout ?? ""), stderr: String(result.stderr ?? "") };
|
|
56
188
|
}
|
|
57
189
|
function executableOnPath(name, value) {
|
|
@@ -78,7 +210,7 @@ export function createRefreshVaultAccessDependencies(cwd, env, now) {
|
|
|
78
210
|
env,
|
|
79
211
|
now,
|
|
80
212
|
hqAvailable: executableOnPath("hq", env.PATH),
|
|
81
|
-
runHq: (args, directory) =>
|
|
213
|
+
runHq: (args, directory) => runHqChild(args, directory, env),
|
|
82
214
|
};
|
|
83
215
|
}
|
|
84
216
|
function isHqRoot(root) {
|
|
@@ -167,13 +299,18 @@ function roleFromTable(output, email) {
|
|
|
167
299
|
}
|
|
168
300
|
return "unknown";
|
|
169
301
|
}
|
|
302
|
+
/** `hq whoami` prints this when no person session is cached. */
|
|
303
|
+
const SIGNED_OUT_PATTERN = /^Not logged in\b/m;
|
|
170
304
|
function resolveEmail(deps, runHq) {
|
|
171
305
|
const override = deps.env.HQ_VAULT_ACCESS_EMAIL ?? "";
|
|
172
306
|
if (override)
|
|
173
|
-
return { email: override, stderr: "" };
|
|
307
|
+
return { email: override, stderr: "", signedOut: false };
|
|
174
308
|
const result = runHq(["whoami"], deps.cwd);
|
|
175
309
|
const email = getEmail(result.stdout);
|
|
176
|
-
|
|
310
|
+
// An API key reaches the vault without a person session, so only a
|
|
311
|
+
// key-less caller whose whoami reports no session is treated as signed out.
|
|
312
|
+
const signedOut = !email && !deps.env.HQ_API_KEY?.trim() && SIGNED_OUT_PATTERN.test(cleanAnsi(result.stdout));
|
|
313
|
+
return { email, signedOut, stderr: email ? "" : log("could not determine caller email — roles will be 'unknown' (hook will not enforce)") };
|
|
177
314
|
}
|
|
178
315
|
export function runRefreshVaultAccess(args, deps) {
|
|
179
316
|
const parsed = parseOptions(args);
|
|
@@ -186,11 +323,19 @@ export function runRefreshVaultAccess(args, deps) {
|
|
|
186
323
|
return { code: 1, stdout: "", stderr: log("could not resolve the HQ root (pass --root <hqRoot>)") };
|
|
187
324
|
if (!deps.hqAvailable)
|
|
188
325
|
return { code: 0, stdout: "", stderr: log("hq CLI not found — nothing refreshed (the write-access hook stays fail-open without a manifest)") };
|
|
189
|
-
const runHq = deps.runHq ?? ((hqArgs, cwd) =>
|
|
326
|
+
const runHq = deps.runHq ?? ((hqArgs, cwd) => runHqChild(hqArgs, cwd, deps.env));
|
|
190
327
|
const identity = resolveEmail(deps, runHq);
|
|
328
|
+
if (identity.signedOut) {
|
|
329
|
+
return { code: 0, stdout: "", stderr: log("not signed in to HQ — nothing refreshed (run `hq login`; the write-access hook stays fail-open without a manifest)") };
|
|
330
|
+
}
|
|
191
331
|
let stderr = identity.stderr;
|
|
192
|
-
const explicit = parsed.slugs;
|
|
332
|
+
const explicit = [...new Set(parsed.slugs)];
|
|
193
333
|
const allGrants = new Map();
|
|
334
|
+
// Companies whose entry this run replaces. A scoped run (--company) owns only
|
|
335
|
+
// the companies it queried successfully; every other company already in the
|
|
336
|
+
// manifest is carried over untouched. The unscoped roll-up owns the whole
|
|
337
|
+
// manifest, so a company that lost all its grants drops out of it.
|
|
338
|
+
const refreshed = new Set();
|
|
194
339
|
if (explicit.length > 0) {
|
|
195
340
|
for (const slug of explicit) {
|
|
196
341
|
const result = runHq(["files", "shared-with-me", "--company", slug], deps.cwd);
|
|
@@ -198,45 +343,228 @@ export function runRefreshVaultAccess(args, deps) {
|
|
|
198
343
|
stderr += log(`skipping ${slug} — shared-with-me query failed`);
|
|
199
344
|
continue;
|
|
200
345
|
}
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
346
|
+
refreshed.add(slug);
|
|
347
|
+
const grants = parseGrantTable(result.stdout).get(slug) ?? [];
|
|
348
|
+
allGrants.set(slug, [...(allGrants.get(slug) ?? []), ...grants]);
|
|
204
349
|
}
|
|
205
350
|
}
|
|
206
351
|
else {
|
|
207
352
|
const result = runHq(["files", "shared-with-me"], deps.cwd);
|
|
208
353
|
if (result.code !== 0)
|
|
209
354
|
return { code: 0, stdout: "", stderr: `${stderr}${log("shared-with-me roll-up failed — nothing refreshed")}` };
|
|
210
|
-
for (const [company, grants] of parseGrantTable(result.stdout))
|
|
355
|
+
for (const [company, grants] of parseGrantTable(result.stdout)) {
|
|
211
356
|
allGrants.set(company, grants);
|
|
357
|
+
refreshed.add(company);
|
|
358
|
+
}
|
|
212
359
|
}
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
const sortedSlugs = [...companies].sort();
|
|
217
|
-
if (sortedSlugs.length === 0)
|
|
360
|
+
if (explicit.length > 0 && refreshed.size === 0)
|
|
361
|
+
return { code: 0, stdout: "", stderr: `${stderr}${log("no requested company could be queried — nothing refreshed")}` };
|
|
362
|
+
if (explicit.length === 0 && refreshed.size === 0)
|
|
218
363
|
stderr += log("no explicit grants found — writing an empty manifest (hook stays fail-open everywhere)");
|
|
219
|
-
const
|
|
220
|
-
for (const slug of
|
|
364
|
+
const fresh = new Map();
|
|
365
|
+
for (const slug of [...refreshed].sort()) {
|
|
221
366
|
let role = "unknown";
|
|
222
367
|
if (identity.email) {
|
|
223
368
|
const result = runHq(["members", "--company", slug, "list"], deps.cwd);
|
|
224
369
|
role = result.code === 0 ? roleFromTable(result.stdout, identity.email) : "unknown";
|
|
225
370
|
}
|
|
226
371
|
const grants = (allGrants.get(slug) ?? []).map((grant) => ({ path: grant.path, permission: grant.permission }));
|
|
227
|
-
|
|
372
|
+
fresh.set(slug, { role, grants });
|
|
228
373
|
}
|
|
229
|
-
const manifest = { version: 1, generatedAt: deps.now().toISOString().replace(/\.\d{3}Z$/, "Z"), companies: companyData };
|
|
230
374
|
const directory = path.join(root, ".hq");
|
|
231
375
|
fs.mkdirSync(directory, { recursive: true });
|
|
232
376
|
const target = path.join(directory, "vault-access.json");
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
377
|
+
// Read the current manifest only once the lock is held, after the slow hq
|
|
378
|
+
// queries, so a refresh for another company that finished meanwhile is kept.
|
|
379
|
+
const written = withManifestLock(target, deps.lockWaitMs ?? LOCK_WAIT_MS, (stillOwned) => {
|
|
380
|
+
// Company slugs come from --company and from the manifest, so they are
|
|
381
|
+
// kept in a Map and a null-prototype object; "__proto__" stays a plain key.
|
|
382
|
+
const companyData = new Map();
|
|
383
|
+
if (explicit.length > 0) {
|
|
384
|
+
const existing = readCompanies(target);
|
|
385
|
+
stderr += existing.warning;
|
|
386
|
+
for (const [slug, entry] of Object.entries(existing.companies))
|
|
387
|
+
if (!refreshed.has(slug))
|
|
388
|
+
companyData.set(slug, entry);
|
|
389
|
+
}
|
|
390
|
+
for (const [slug, entry] of fresh)
|
|
391
|
+
companyData.set(slug, entry);
|
|
392
|
+
const sorted = Object.create(null);
|
|
393
|
+
for (const slug of [...companyData.keys()].sort())
|
|
394
|
+
sorted[slug] = companyData.get(slug);
|
|
395
|
+
const manifest = { version: 1, generatedAt: deps.now().toISOString().replace(/\.\d{3}Z$/, "Z"), companies: sorted };
|
|
396
|
+
const temp = `${target}.${process.pid}.${randomBytes(8).toString("hex")}`;
|
|
397
|
+
fs.writeFileSync(temp, serializeJqJson(manifest, "pretty"), { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
398
|
+
try {
|
|
399
|
+
fs.chmodSync(temp, 0o600);
|
|
400
|
+
// A refresh that held the lock past the stale threshold may have been
|
|
401
|
+
// taken over; it must not overwrite the new holder's merge.
|
|
402
|
+
if (!stillOwned()) {
|
|
403
|
+
fs.rmSync(temp, { force: true });
|
|
404
|
+
return undefined;
|
|
405
|
+
}
|
|
406
|
+
fs.renameSync(temp, target);
|
|
407
|
+
}
|
|
408
|
+
catch (err) {
|
|
409
|
+
fs.rmSync(temp, { force: true });
|
|
410
|
+
throw err;
|
|
411
|
+
}
|
|
412
|
+
return companyData.size;
|
|
413
|
+
});
|
|
414
|
+
if (written === undefined)
|
|
415
|
+
return { code: 0, stdout: "", stderr: `${stderr}${log("another refresh is still writing .hq/vault-access.json — nothing refreshed")}` };
|
|
416
|
+
const scope = explicit.length > 0 ? `, refreshed: ${[...refreshed].sort().join(", ")}` : "";
|
|
417
|
+
stderr += log(`wrote .hq/vault-access.json (${written} companies${scope}, identity: ${identity.email || "unknown"})`);
|
|
238
418
|
return { code: 0, stdout: "", stderr };
|
|
239
419
|
}
|
|
420
|
+
/** Company entries already in the manifest; empty, with a warning, when it is missing or unreadable. */
|
|
421
|
+
function readCompanies(target) {
|
|
422
|
+
let text;
|
|
423
|
+
try {
|
|
424
|
+
text = fs.readFileSync(target, "utf8");
|
|
425
|
+
}
|
|
426
|
+
catch (err) {
|
|
427
|
+
const code = err.code;
|
|
428
|
+
return { companies: {}, warning: code === "ENOENT" ? "" : log(`could not read the existing manifest (${code ?? "error"}) — other companies' entries are not carried over`) };
|
|
429
|
+
}
|
|
430
|
+
try {
|
|
431
|
+
const companies = JSON.parse(text)?.companies;
|
|
432
|
+
if (companies && typeof companies === "object" && !Array.isArray(companies))
|
|
433
|
+
return { companies: companies, warning: "" };
|
|
434
|
+
}
|
|
435
|
+
catch { /* reported below */ }
|
|
436
|
+
return { companies: {}, warning: log("the existing manifest is not valid — other companies' entries are not carried over") };
|
|
437
|
+
}
|
|
438
|
+
/** A lock older than this belongs to a refresh that died holding it. */
|
|
439
|
+
const LOCK_STALE_MS = 30_000;
|
|
440
|
+
const LOCK_WAIT_MS = 5_000;
|
|
441
|
+
function sleepSync(ms) {
|
|
442
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
443
|
+
}
|
|
444
|
+
function readLockToken(lock) {
|
|
445
|
+
try {
|
|
446
|
+
return fs.readFileSync(lock, "utf8");
|
|
447
|
+
}
|
|
448
|
+
catch {
|
|
449
|
+
return undefined;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
/** True when the token names a process on this host that no longer exists. */
|
|
453
|
+
function holderIsGone(token) {
|
|
454
|
+
const match = /^(\d+):(.*):[0-9a-f]+$/.exec(token);
|
|
455
|
+
if (!match || match[2] !== os.hostname())
|
|
456
|
+
return false;
|
|
457
|
+
const pid = Number(match[1]);
|
|
458
|
+
if (pid === process.pid)
|
|
459
|
+
return false;
|
|
460
|
+
try {
|
|
461
|
+
process.kill(pid, 0);
|
|
462
|
+
return false;
|
|
463
|
+
}
|
|
464
|
+
catch (err) {
|
|
465
|
+
return err.code === "ESRCH";
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
/**
|
|
469
|
+
* Remove the lock only if it still carries `expected`. The lock is first
|
|
470
|
+
* renamed to a name private to this caller, which only one process can do for
|
|
471
|
+
* a given lock file. If the moved file is not the expected lock (another
|
|
472
|
+
* process replaced it in the meantime), it is linked back under the lock name
|
|
473
|
+
* unchanged.
|
|
474
|
+
*/
|
|
475
|
+
function removeLockIf(lock, expected, id) {
|
|
476
|
+
const moved = `${lock}.${id}.moved`;
|
|
477
|
+
try {
|
|
478
|
+
fs.renameSync(lock, moved);
|
|
479
|
+
}
|
|
480
|
+
catch {
|
|
481
|
+
return false;
|
|
482
|
+
}
|
|
483
|
+
const ours = readLockToken(moved) === expected;
|
|
484
|
+
if (!ours) {
|
|
485
|
+
try {
|
|
486
|
+
fs.linkSync(moved, lock);
|
|
487
|
+
}
|
|
488
|
+
catch { /* a newer lock already exists; leave it */ }
|
|
489
|
+
}
|
|
490
|
+
fs.rmSync(moved, { force: true });
|
|
491
|
+
return ours;
|
|
492
|
+
}
|
|
493
|
+
/**
|
|
494
|
+
* Take the manifest lock and return its owner token, or undefined when the
|
|
495
|
+
* lock stays busy past `waitMs`. The lock file is created by hard-linking a
|
|
496
|
+
* file that already holds the token, so it never exists without an owner. A
|
|
497
|
+
* stale lock (older than LOCK_STALE_MS, or whose holder on this host has
|
|
498
|
+
* exited) is taken over through removeLockIf, so two waiters that both judge
|
|
499
|
+
* it stale cannot remove each other's new lock.
|
|
500
|
+
*/
|
|
501
|
+
export function acquireManifestLock(lock, waitMs, hooks = {}) {
|
|
502
|
+
const nonce = randomBytes(8).toString("hex");
|
|
503
|
+
const id = `${process.pid}.${nonce}`;
|
|
504
|
+
const token = `${process.pid}:${os.hostname()}:${nonce}`;
|
|
505
|
+
const pending = `${lock}.${id}.new`;
|
|
506
|
+
const deadline = Date.now() + Math.max(0, waitMs);
|
|
507
|
+
fs.writeFileSync(pending, token, { flag: "wx", mode: 0o600 });
|
|
508
|
+
try {
|
|
509
|
+
for (;;) {
|
|
510
|
+
try {
|
|
511
|
+
fs.linkSync(pending, lock);
|
|
512
|
+
return token;
|
|
513
|
+
}
|
|
514
|
+
catch (err) {
|
|
515
|
+
if (err.code !== "EEXIST")
|
|
516
|
+
throw err;
|
|
517
|
+
}
|
|
518
|
+
const held = readLockToken(lock);
|
|
519
|
+
let age;
|
|
520
|
+
// lstat, so a lock name that is a dangling link still counts as held.
|
|
521
|
+
try {
|
|
522
|
+
age = Date.now() - fs.lstatSync(lock).mtimeMs;
|
|
523
|
+
}
|
|
524
|
+
catch {
|
|
525
|
+
continue;
|
|
526
|
+
}
|
|
527
|
+
if (held !== undefined && (age > LOCK_STALE_MS || holderIsGone(held))) {
|
|
528
|
+
hooks.afterStaleCheck?.();
|
|
529
|
+
if (removeLockIf(lock, held, id))
|
|
530
|
+
continue;
|
|
531
|
+
}
|
|
532
|
+
// Every path that did not just free the lock waits and honours the
|
|
533
|
+
// deadline, so a lock that cannot be read or taken over never spins.
|
|
534
|
+
if (Date.now() >= deadline)
|
|
535
|
+
return undefined;
|
|
536
|
+
sleepSync(25);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
finally {
|
|
540
|
+
fs.rmSync(pending, { force: true });
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
/** Release the lock if `token` still owns it. Returns false when another process has taken it over. */
|
|
544
|
+
export function releaseManifestLock(lock, token) {
|
|
545
|
+
if (readLockToken(lock) !== token)
|
|
546
|
+
return false;
|
|
547
|
+
return removeLockIf(lock, token, `${process.pid}.${randomBytes(8).toString("hex")}`);
|
|
548
|
+
}
|
|
549
|
+
/**
|
|
550
|
+
* Serialize the read-merge-write of the manifest across processes. Without it
|
|
551
|
+
* two scoped refreshes for different companies can each read the old file and
|
|
552
|
+
* the second rename drops the first one's company. `fn` gets a check that the
|
|
553
|
+
* lock is still held, to call just before it commits. Returns undefined when
|
|
554
|
+
* the lock stays busy past `waitMs`.
|
|
555
|
+
*/
|
|
556
|
+
function withManifestLock(target, waitMs, fn) {
|
|
557
|
+
const lock = `${target}.lock`;
|
|
558
|
+
const token = acquireManifestLock(lock, waitMs);
|
|
559
|
+
if (token === undefined)
|
|
560
|
+
return undefined;
|
|
561
|
+
try {
|
|
562
|
+
return fn(() => readLockToken(lock) === token);
|
|
563
|
+
}
|
|
564
|
+
finally {
|
|
565
|
+
releaseManifestLock(lock, token);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
240
568
|
export function registerCoreRefreshVaultAccessNative(core) {
|
|
241
569
|
registerNativeUtility(core, {
|
|
242
570
|
name: "refresh-vault-access", root: "cwd", summary: "Refresh the local vault access manifest",
|
|
@@ -45,6 +45,15 @@ type DetachedSpawnOptions = {
|
|
|
45
45
|
};
|
|
46
46
|
};
|
|
47
47
|
export declare function spawnWorkMeshHqDetached(args: string[], options: DetachedSpawnOptions): void;
|
|
48
|
+
/**
|
|
49
|
+
* Resolve the bind session. Only a session id the caller owns (the process env)
|
|
50
|
+
* is trusted; workspace/sessions/.current is last-writer-wins and can name
|
|
51
|
+
* another session, so it is a fallback that never carries trusted authority.
|
|
52
|
+
*/
|
|
53
|
+
export declare function resolveWorkMeshBindSession(root: string, env: NodeJS.ProcessEnv, fileSystem?: typeof fs): {
|
|
54
|
+
sid: string;
|
|
55
|
+
trusted: boolean;
|
|
56
|
+
};
|
|
48
57
|
export declare function resolveWorkMeshBindSessionId(root: string, env: NodeJS.ProcessEnv, fileSystem?: typeof fs): string;
|
|
49
58
|
export declare function runWorkMeshBindTrusted(args: string[], deps: WorkMeshBindDeps): UtilityOutput;
|
|
50
59
|
export declare function registerCoreWorkMeshBindTrustedNative(core: Command): void;
|
|
@@ -3,6 +3,7 @@ import * as fs from "node:fs";
|
|
|
3
3
|
import * as path from "node:path";
|
|
4
4
|
import { spawn, spawnSync } from "node:child_process";
|
|
5
5
|
import { resolveWindowsExecutable } from "../lib/bot/runtime/windows.js";
|
|
6
|
+
import { sessionIdFromEnv, validSessionId } from "../lib/core-utils/session-id.js";
|
|
6
7
|
import { generateUlid } from "../lib/mesh/live/ulid.js";
|
|
7
8
|
import { resolveLiveRoot } from "../utils/hq-roots.js";
|
|
8
9
|
import { planResolvedCommandSpawn } from "../utils/windows-spawn.js";
|
|
@@ -72,20 +73,26 @@ export function spawnWorkMeshHqDetached(args, options) {
|
|
|
72
73
|
});
|
|
73
74
|
child.unref();
|
|
74
75
|
}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
76
|
+
/**
|
|
77
|
+
* Resolve the bind session. Only a session id the caller owns (the process env)
|
|
78
|
+
* is trusted; workspace/sessions/.current is last-writer-wins and can name
|
|
79
|
+
* another session, so it is a fallback that never carries trusted authority.
|
|
80
|
+
*/
|
|
81
|
+
export function resolveWorkMeshBindSession(root, env, fileSystem = fs) {
|
|
82
|
+
const fromEnv = sessionIdFromEnv(env);
|
|
83
|
+
if (fromEnv)
|
|
84
|
+
return { sid: fromEnv, trusted: true };
|
|
81
85
|
try {
|
|
82
|
-
const value = fileSystem.readFileSync(path.join(root, "workspace/sessions/.current"), "utf8").replace(
|
|
83
|
-
return value;
|
|
86
|
+
const value = fileSystem.readFileSync(path.join(root, "workspace/sessions/.current"), "utf8").replace(/[\t\n\v\f\r ]/g, "");
|
|
87
|
+
return { sid: validSessionId(value) ? value : "", trusted: false };
|
|
84
88
|
}
|
|
85
89
|
catch {
|
|
86
|
-
return "";
|
|
90
|
+
return { sid: "", trusted: false };
|
|
87
91
|
}
|
|
88
92
|
}
|
|
93
|
+
export function resolveWorkMeshBindSessionId(root, env, fileSystem = fs) {
|
|
94
|
+
return resolveWorkMeshBindSession(root, env, fileSystem).sid;
|
|
95
|
+
}
|
|
89
96
|
function hasExecutable(deps, command) {
|
|
90
97
|
if (process.platform === "win32") {
|
|
91
98
|
return Boolean(resolveWindowsExecutable(command, deps.env, (candidate) => {
|
|
@@ -158,17 +165,15 @@ export function runWorkMeshBindTrusted(args, deps) {
|
|
|
158
165
|
else
|
|
159
166
|
i += 1;
|
|
160
167
|
}
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
}
|
|
167
|
-
catch {
|
|
168
|
-
sid = "";
|
|
169
|
-
}
|
|
170
|
-
}
|
|
168
|
+
// Trusted authority needs a session id the caller owns: --session or the
|
|
169
|
+
// process env. An invalid --session stops the bind, so it cannot fall back to
|
|
170
|
+
// whichever session .current names.
|
|
171
|
+
if (sid && !validSessionId(sid)) {
|
|
172
|
+
return { code: 2, stdout: "", stderr: "work-mesh-live-bind-trusted: invalid --session id\n" };
|
|
171
173
|
}
|
|
174
|
+
let sessionTrusted = Boolean(sid);
|
|
175
|
+
if (!sid)
|
|
176
|
+
({ sid, trusted: sessionTrusted } = resolveWorkMeshBindSession(root, deps.env, deps.fs));
|
|
172
177
|
const childEnv = { ...deps.env, HQ_ROOT: root };
|
|
173
178
|
const pin = sid ? ["--session-id", sid] : [];
|
|
174
179
|
let stderr = "";
|
|
@@ -207,6 +212,8 @@ export function runWorkMeshBindTrusted(args, deps) {
|
|
|
207
212
|
trusted.project = project;
|
|
208
213
|
if (task)
|
|
209
214
|
trusted.task = task;
|
|
215
|
+
if (project && sessionTrusted)
|
|
216
|
+
trusted.authority = "trusted";
|
|
210
217
|
observation.trustedContext = trusted;
|
|
211
218
|
}
|
|
212
219
|
const directory = path.join(deps.tmpDir, "hq-work-mesh-obs");
|
package/dist/commands/core.js
CHANGED
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
*/
|
|
31
31
|
import { Option } from "commander";
|
|
32
32
|
import { registerCoreCheckpointCommand } from "./core-checkpoint.js";
|
|
33
|
+
import { runHookEnginePath } from '../hook-engine-path.js';
|
|
33
34
|
import { registerCoreDelegateCommand } from "./core-delegate.js";
|
|
34
35
|
import { registerCoreDelegateW1b } from "./core-delegate-w1b.js";
|
|
35
36
|
import { registerCoreHandoffCommand } from "./core-handoff.js";
|
|
@@ -439,6 +440,14 @@ export function registerCoreCommands(program) {
|
|
|
439
440
|
// This group primarily hosts manifest-driven bundled assets, but it also
|
|
440
441
|
// hosts native TypeScript plumbing when a scaffold contract needs it.
|
|
441
442
|
registerCoreCheckpointCommand(core);
|
|
443
|
+
const hookEnginePath = core.command('hook-engine-path')
|
|
444
|
+
.description('Print the offline, version-pinned native hook engine path')
|
|
445
|
+
.option('--repair', 'Repair the pinned copy after closing running hook processes')
|
|
446
|
+
.option('--for-registration', 'Print a path only when the offline launcher flag is enabled')
|
|
447
|
+
.action((options) => runHookEnginePath([
|
|
448
|
+
...(options.repair ? ['--repair'] : []), ...(options.forRegistration ? ['--for-registration'] : []),
|
|
449
|
+
]));
|
|
450
|
+
describeCoreCommand(hookEnginePath, { execution: 'native', root: 'cwd' });
|
|
442
451
|
registerCoreOntologyCommand(core);
|
|
443
452
|
registerCoreCheckpointGateSettingCommand(core);
|
|
444
453
|
registerCoreDelegateCommand(core, registerCoreDelegateW1b);
|
|
@@ -45,6 +45,13 @@ export interface DaemonCommandDeps {
|
|
|
45
45
|
userHome: (user: string) => string;
|
|
46
46
|
/** The pid is a live `hq daemon run`. */
|
|
47
47
|
daemonAlive: (pid: number) => boolean;
|
|
48
|
+
/** Blocking sleep for install's wait for the Windows task to start (tests inject). */
|
|
49
|
+
sleep?: (ms: number) => void;
|
|
50
|
+
/**
|
|
51
|
+
* Windows: this user has an active signed-in session, so the logon task
|
|
52
|
+
* can start now. Null when the sessions cannot be listed.
|
|
53
|
+
*/
|
|
54
|
+
interactiveSession?: () => boolean | null | "unavailable";
|
|
48
55
|
/** Test seam for the combined hq-flags and person-setting gate. */
|
|
49
56
|
runtimeAllowed?: () => boolean | Promise<boolean>;
|
|
50
57
|
/** Test seam for the company sync-mode commands. */
|