@indigoai-us/hq-cli 5.340.1-beta.0 → 5.341.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 +3 -2
- package/assets/lanes-reconcile/hq-lanes-reconcile.timer +1 -1
- package/dist/command-catalog.generated.d.ts +25 -0
- package/dist/command-catalog.generated.js +31 -0
- package/dist/commands/cloud.js +0 -1
- package/dist/commands/daemon.d.ts +2 -0
- package/dist/commands/daemon.js +66 -0
- package/dist/lib/daemon/catalog.js +1 -1
- package/dist/lib/daemon/machine-login.d.ts +20 -0
- package/dist/lib/daemon/machine-login.js +102 -0
- package/dist/lib/daemon/ops/mcp.d.ts +1 -0
- package/dist/lib/daemon/ops/mcp.js +1 -1
- package/dist/lib/daemon/protocol.d.ts +1 -1
- package/dist/lib/daemon/protocol.js +2 -0
- package/dist/lib/daemon/server.d.ts +5 -0
- package/dist/lib/daemon/server.js +57 -3
- package/dist/lib/lanes/reconcile-install.d.ts +1 -1
- package/dist/lib/lanes/reconcile-install.js +1 -1
- package/dist/lib/lanes/reconcile.js +46 -5
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
-
## [5.
|
|
6
|
-
-
|
|
5
|
+
## [5.341.0] — 2026-10-03
|
|
6
|
+
- Keep-alive reconciliation polls every 15 seconds and admits due lane resumes within the same sweep.
|
|
7
|
+
- Add flag-gated `hq daemon login --stdin` for machine sign-in on disposable hosts. Credentials are piped over the local daemon socket, and the resulting token stays in hqd memory until restart or token expiry. Login is deadline-bounded, and the generic argv-based `hq daemon call` command cannot submit credentials.
|
|
7
8
|
|
|
8
9
|
## [5.340.0] — 2026-10-03
|
|
9
10
|
- Preserve resolver output when child processes exit before Windows pipe data has drained.
|
|
@@ -13278,6 +13278,31 @@ export declare const COMMAND_CATALOG: readonly [{
|
|
|
13278
13278
|
readonly hidden: false;
|
|
13279
13279
|
}];
|
|
13280
13280
|
readonly subcommands: readonly [];
|
|
13281
|
+
}, {
|
|
13282
|
+
readonly name: "login";
|
|
13283
|
+
readonly description: "Sign the running daemon in from machine credentials piped on stdin; credentials and tokens stay off disk";
|
|
13284
|
+
readonly aliases: readonly [];
|
|
13285
|
+
readonly hidden: false;
|
|
13286
|
+
readonly helpOption: true;
|
|
13287
|
+
readonly helpCommand: false;
|
|
13288
|
+
readonly usage: "[options] [extra...]";
|
|
13289
|
+
readonly helpText: readonly [];
|
|
13290
|
+
readonly arguments: readonly [{
|
|
13291
|
+
readonly name: "extra";
|
|
13292
|
+
readonly required: false;
|
|
13293
|
+
readonly variadic: true;
|
|
13294
|
+
readonly description: "";
|
|
13295
|
+
}];
|
|
13296
|
+
readonly options: readonly [{
|
|
13297
|
+
readonly flags: "--stdin";
|
|
13298
|
+
readonly description: "Read a machine credentials JSON object from stdin";
|
|
13299
|
+
readonly hidden: false;
|
|
13300
|
+
}, {
|
|
13301
|
+
readonly flags: "--status";
|
|
13302
|
+
readonly description: "Show whether hqd has an in-memory machine sign-in";
|
|
13303
|
+
readonly hidden: false;
|
|
13304
|
+
}];
|
|
13305
|
+
readonly subcommands: readonly [];
|
|
13281
13306
|
}, {
|
|
13282
13307
|
readonly name: "call";
|
|
13283
13308
|
readonly description: "Send one request to hqd (policy.check, journal.append, memory.get, memory.put, mesh.presence, mcp.call, …) and print the result as JSON";
|
|
@@ -16149,6 +16149,37 @@ export const COMMAND_CATALOG = [
|
|
|
16149
16149
|
],
|
|
16150
16150
|
"subcommands": []
|
|
16151
16151
|
},
|
|
16152
|
+
{
|
|
16153
|
+
"name": "login",
|
|
16154
|
+
"description": "Sign the running daemon in from machine credentials piped on stdin; credentials and tokens stay off disk",
|
|
16155
|
+
"aliases": [],
|
|
16156
|
+
"hidden": false,
|
|
16157
|
+
"helpOption": true,
|
|
16158
|
+
"helpCommand": false,
|
|
16159
|
+
"usage": "[options] [extra...]",
|
|
16160
|
+
"helpText": [],
|
|
16161
|
+
"arguments": [
|
|
16162
|
+
{
|
|
16163
|
+
"name": "extra",
|
|
16164
|
+
"required": false,
|
|
16165
|
+
"variadic": true,
|
|
16166
|
+
"description": ""
|
|
16167
|
+
}
|
|
16168
|
+
],
|
|
16169
|
+
"options": [
|
|
16170
|
+
{
|
|
16171
|
+
"flags": "--stdin",
|
|
16172
|
+
"description": "Read a machine credentials JSON object from stdin",
|
|
16173
|
+
"hidden": false
|
|
16174
|
+
},
|
|
16175
|
+
{
|
|
16176
|
+
"flags": "--status",
|
|
16177
|
+
"description": "Show whether hqd has an in-memory machine sign-in",
|
|
16178
|
+
"hidden": false
|
|
16179
|
+
}
|
|
16180
|
+
],
|
|
16181
|
+
"subcommands": []
|
|
16182
|
+
},
|
|
16152
16183
|
{
|
|
16153
16184
|
"name": "call",
|
|
16154
16185
|
"description": "Send one request to hqd (policy.check, journal.append, memory.get, memory.put, mesh.presence, mcp.call, …) and print the result as JSON",
|
package/dist/commands/cloud.js
CHANGED
|
@@ -1751,7 +1751,6 @@ async function runNowSingle(hqRoot, company, personal, message, onConflict, mode
|
|
|
1751
1751
|
hqRoot,
|
|
1752
1752
|
progressRecorder: trackingProgressRecorder(planLimitTracker, pushProgressCompany),
|
|
1753
1753
|
skipUnchanged: true,
|
|
1754
|
-
serverOwnedPathPolicy: "skip",
|
|
1755
1754
|
propagateDeletes: true,
|
|
1756
1755
|
propagateDeletePolicy: resolveDeletePolicy(),
|
|
1757
1756
|
...(onConflict ? { onConflict } : {}),
|
|
@@ -42,6 +42,8 @@ export interface DaemonCommandDeps {
|
|
|
42
42
|
daemonAlive: (pid: number) => boolean;
|
|
43
43
|
/** Test seam for the company sync-mode commands. */
|
|
44
44
|
syncModeClient?: () => Promise<SyncModeVaultClient>;
|
|
45
|
+
/** Reads stdin for the one-shot daemon machine login; never reads env or argv credentials. */
|
|
46
|
+
readStdin?: () => Promise<string>;
|
|
45
47
|
windowsUser?: string;
|
|
46
48
|
windowsHeadlessLauncher?: string;
|
|
47
49
|
systemUnitDir?: string;
|
package/dist/commands/daemon.js
CHANGED
|
@@ -32,6 +32,8 @@ import { writeControlRequest } from "../lib/daemon/requests.js";
|
|
|
32
32
|
import { isSyncRunnerCommandLine, readWatchRunnerOwnerStatus, watchRunnerOwnerStatusPath } from "../lib/daemon/sync-runner.js";
|
|
33
33
|
import { getSyncModeForCompany, setSyncMode } from "./sync-mode.js";
|
|
34
34
|
import { requireAnywhereRuntime } from "./anywhere-runtime-flag.js";
|
|
35
|
+
import { hqdRequest, hqdSocketPath, HqdClientError } from "../lib/daemon/protocol.js";
|
|
36
|
+
import { parseStdinMachineCredentials } from "../lib/daemon/machine-login.js";
|
|
35
37
|
import { resolveDefaultHqRoot } from "../utils/cognito-session.js";
|
|
36
38
|
class DaemonCommandError extends Error {
|
|
37
39
|
}
|
|
@@ -81,6 +83,7 @@ async function loadDefaults(over) {
|
|
|
81
83
|
isRoot: over.isRoot ?? (typeof process.getuid === "function" && process.getuid() === 0),
|
|
82
84
|
fileExists: over.fileExists ?? fs.existsSync,
|
|
83
85
|
readFile: over.readFile ?? ((p) => fs.readFileSync(p, "utf8")),
|
|
86
|
+
readStdin: over.readStdin,
|
|
84
87
|
writeFile: over.writeFile ??
|
|
85
88
|
((p, text) => {
|
|
86
89
|
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
@@ -105,6 +108,27 @@ async function loadDefaults(over) {
|
|
|
105
108
|
systemRecordPath: over.systemRecordPath ?? path.join(SYSTEM_ADOPTED_ROOT, "adopted.json"),
|
|
106
109
|
};
|
|
107
110
|
}
|
|
111
|
+
async function readLoginStdin() {
|
|
112
|
+
if (process.stdin.isTTY)
|
|
113
|
+
throw new DaemonCommandError("Pipe machine credentials to `hq daemon login --stdin`.");
|
|
114
|
+
const chunks = [];
|
|
115
|
+
let bytes = 0;
|
|
116
|
+
try {
|
|
117
|
+
for await (const chunk of process.stdin) {
|
|
118
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
119
|
+
bytes += buffer.byteLength;
|
|
120
|
+
if (bytes > 64 * 1024)
|
|
121
|
+
throw new DaemonCommandError("stdin credentials are too large");
|
|
122
|
+
chunks.push(buffer);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
catch (err) {
|
|
126
|
+
if (err instanceof DaemonCommandError)
|
|
127
|
+
throw err;
|
|
128
|
+
throw new DaemonCommandError("Could not read machine credentials from stdin.");
|
|
129
|
+
}
|
|
130
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
131
|
+
}
|
|
108
132
|
function detect(d, flag, config) {
|
|
109
133
|
return detectMachineType(flag, {
|
|
110
134
|
platform: d.platform,
|
|
@@ -767,6 +791,45 @@ export function registerDaemonCommand(program, over = {}) {
|
|
|
767
791
|
hqBinary: d.hqBinary,
|
|
768
792
|
});
|
|
769
793
|
}));
|
|
794
|
+
daemon
|
|
795
|
+
.command("login [extra...]")
|
|
796
|
+
.description("Sign the running daemon in from machine credentials piped on stdin; credentials and tokens stay off disk")
|
|
797
|
+
.option("--stdin", "Read a machine credentials JSON object from stdin")
|
|
798
|
+
.option("--status", "Show whether hqd has an in-memory machine sign-in")
|
|
799
|
+
.allowUnknownOption(true)
|
|
800
|
+
.action(wrap(async (d, extra, opts) => {
|
|
801
|
+
requireAnywhereRuntime();
|
|
802
|
+
if (extra?.length)
|
|
803
|
+
throw new DaemonCommandError("Machine credentials are accepted from stdin only; do not pass credentials as arguments.");
|
|
804
|
+
const socketPath = hqdSocketPath(d.home, d.env, d.platform);
|
|
805
|
+
if (opts.status) {
|
|
806
|
+
let result;
|
|
807
|
+
try {
|
|
808
|
+
result = await hqdRequest("auth.status", {}, { socketPath });
|
|
809
|
+
}
|
|
810
|
+
catch {
|
|
811
|
+
throw new DaemonCommandError("Could not read the daemon sign-in status.");
|
|
812
|
+
}
|
|
813
|
+
d.out(`hqd signed in: ${result.signedIn === true ? "yes" : "no"} (memory only)`);
|
|
814
|
+
return;
|
|
815
|
+
}
|
|
816
|
+
if (!opts.stdin)
|
|
817
|
+
throw new DaemonCommandError("Use `hq daemon login --stdin` to provide machine credentials.");
|
|
818
|
+
const raw = await (d.readStdin ?? readLoginStdin)();
|
|
819
|
+
const credentials = parseStdinMachineCredentials(raw);
|
|
820
|
+
try {
|
|
821
|
+
await hqdRequest("auth.login", { credentials }, { socketPath, timeoutMs: 30_000 });
|
|
822
|
+
}
|
|
823
|
+
catch (error) {
|
|
824
|
+
if (error instanceof HqdClientError) {
|
|
825
|
+
throw new DaemonCommandError(error.code === "anywhere_disabled"
|
|
826
|
+
? "HQ Anywhere is not enabled. Enable the hq-anywhere-runtime flag to use daemon login."
|
|
827
|
+
: "Could not sign hqd in. Check the machine credentials and daemon status.");
|
|
828
|
+
}
|
|
829
|
+
throw new DaemonCommandError("Could not sign hqd in. Check the machine credentials and daemon status.");
|
|
830
|
+
}
|
|
831
|
+
d.out("Signed in hqd. The token is held in daemon memory and will be lost when hqd restarts.");
|
|
832
|
+
}));
|
|
770
833
|
daemon
|
|
771
834
|
.command("call <op>")
|
|
772
835
|
.description("Send one request to hqd (policy.check, journal.append, memory.get, memory.put, mesh.presence, mcp.call, …) and print the result as JSON")
|
|
@@ -774,6 +837,9 @@ export function registerDaemonCommand(program, over = {}) {
|
|
|
774
837
|
.option("--timeout <ms>", "Give up after this many milliseconds")
|
|
775
838
|
.action(wrap(async (d, op, opts) => {
|
|
776
839
|
requireAnywhereRuntime();
|
|
840
|
+
if (op === "auth.login") {
|
|
841
|
+
throw new Error("Use `hq daemon login --stdin`; credentials are accepted from stdin only.");
|
|
842
|
+
}
|
|
777
843
|
let args;
|
|
778
844
|
try {
|
|
779
845
|
args = JSON.parse(opts.json);
|
|
@@ -230,7 +230,7 @@ function planService(service, ctx) {
|
|
|
230
230
|
return [
|
|
231
231
|
base(service, {
|
|
232
232
|
kind: "scheduled",
|
|
233
|
-
intervalSec:
|
|
233
|
+
intervalSec: 15,
|
|
234
234
|
initialDelaySec: 30,
|
|
235
235
|
command: { type: "hq", args: ["lanes", "reconcile"] },
|
|
236
236
|
env: ctx.hqRoot ? { HQ_ROOT: ctx.hqRoot } : {},
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** Ephemeral Cognito machine login for a resident hqd process. */
|
|
2
|
+
import type { MachineCreds } from "@indigoai-us/hq-cloud";
|
|
3
|
+
export declare class MachineLoginError extends Error {
|
|
4
|
+
readonly code: string;
|
|
5
|
+
constructor(code: string, message: string);
|
|
6
|
+
}
|
|
7
|
+
export type StdinMachineCredentials = Pick<MachineCreds, "username" | "secret"> & Partial<Pick<MachineCreds, "clientId" | "region" | "entityType" | "entityUid">>;
|
|
8
|
+
/** Parse only the established machine-creds fields. Parse errors never include input text. */
|
|
9
|
+
export declare function parseStdinMachineCredentials(raw: string): StdinMachineCredentials;
|
|
10
|
+
export interface MachineAuthenticatorOptions {
|
|
11
|
+
endpoint?: string;
|
|
12
|
+
fetchImpl?: typeof fetch;
|
|
13
|
+
env?: NodeJS.ProcessEnv;
|
|
14
|
+
signal?: AbortSignal;
|
|
15
|
+
}
|
|
16
|
+
/** Read the Cognito ID token expiry without exposing or persisting the token. */
|
|
17
|
+
export declare function machineTokenExpiresAt(token: string): number;
|
|
18
|
+
/** Authenticate without using hq-cloud's persistent machine token cache. */
|
|
19
|
+
export declare function authenticateMachineCredentials(credentials: StdinMachineCredentials, options?: MachineAuthenticatorOptions): Promise<string>;
|
|
20
|
+
//# sourceMappingURL=machine-login.d.ts.map
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/** Ephemeral Cognito machine login for a resident hqd process. */
|
|
2
|
+
import { DEFAULT_COGNITO } from "../../utils/cognito-session.js";
|
|
3
|
+
const MAX_STDIN_BYTES = 64 * 1024;
|
|
4
|
+
export class MachineLoginError extends Error {
|
|
5
|
+
code;
|
|
6
|
+
constructor(code, message) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.code = code;
|
|
9
|
+
this.name = "MachineLoginError";
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
/** Parse only the established machine-creds fields. Parse errors never include input text. */
|
|
13
|
+
export function parseStdinMachineCredentials(raw) {
|
|
14
|
+
if (Buffer.byteLength(raw, "utf8") > MAX_STDIN_BYTES) {
|
|
15
|
+
throw new MachineLoginError("bad_credentials", "stdin credentials are too large");
|
|
16
|
+
}
|
|
17
|
+
let value;
|
|
18
|
+
try {
|
|
19
|
+
value = JSON.parse(raw);
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
throw new MachineLoginError("bad_credentials", "stdin must contain a machine credentials JSON object");
|
|
23
|
+
}
|
|
24
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
25
|
+
throw new MachineLoginError("bad_credentials", "stdin must contain a machine credentials JSON object");
|
|
26
|
+
}
|
|
27
|
+
const input = value;
|
|
28
|
+
const allowed = new Set(["username", "secret", "clientId", "region", "entityType", "entityUid"]);
|
|
29
|
+
if (Object.keys(input).some((key) => !allowed.has(key))) {
|
|
30
|
+
throw new MachineLoginError("bad_credentials", "stdin credentials contain an unsupported field");
|
|
31
|
+
}
|
|
32
|
+
if (typeof input.username !== "string" || !input.username.trim() || typeof input.secret !== "string" || !input.secret) {
|
|
33
|
+
throw new MachineLoginError("bad_credentials", "stdin credentials require non-empty username and secret fields");
|
|
34
|
+
}
|
|
35
|
+
for (const name of ["clientId", "region", "entityUid"]) {
|
|
36
|
+
if (input[name] !== undefined && (typeof input[name] !== "string" || !input[name].trim())) {
|
|
37
|
+
throw new MachineLoginError("bad_credentials", `stdin credentials have an invalid ${name} field`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if (input.entityType !== undefined && input.entityType !== "agent" && input.entityType !== "outpost") {
|
|
41
|
+
throw new MachineLoginError("bad_credentials", "stdin credentials have an invalid entityType field");
|
|
42
|
+
}
|
|
43
|
+
return input;
|
|
44
|
+
}
|
|
45
|
+
/** Read the Cognito ID token expiry without exposing or persisting the token. */
|
|
46
|
+
export function machineTokenExpiresAt(token) {
|
|
47
|
+
try {
|
|
48
|
+
const encodedPayload = token.split(".")[1];
|
|
49
|
+
if (!encodedPayload)
|
|
50
|
+
throw new Error("missing JWT payload");
|
|
51
|
+
const payload = JSON.parse(Buffer.from(encodedPayload, "base64url").toString("utf8"));
|
|
52
|
+
if (typeof payload.exp !== "number" || !Number.isFinite(payload.exp))
|
|
53
|
+
throw new Error("missing JWT expiry");
|
|
54
|
+
return payload.exp * 1000;
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
throw new MachineLoginError("auth_failed", "hqd sign-in failed; the identity provider returned an invalid token");
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/** Authenticate without using hq-cloud's persistent machine token cache. */
|
|
61
|
+
export async function authenticateMachineCredentials(credentials, options = {}) {
|
|
62
|
+
const env = options.env ?? process.env;
|
|
63
|
+
const region = credentials.region ?? env.AWS_REGION ?? DEFAULT_COGNITO.region;
|
|
64
|
+
const clientId = credentials.clientId ?? env.HQ_COGNITO_CLIENT_ID ?? DEFAULT_COGNITO.clientId;
|
|
65
|
+
const endpoint = options.endpoint ?? `https://cognito-idp.${region}.amazonaws.com/`;
|
|
66
|
+
let response;
|
|
67
|
+
try {
|
|
68
|
+
response = await (options.fetchImpl ?? fetch)(endpoint, {
|
|
69
|
+
method: "POST",
|
|
70
|
+
headers: {
|
|
71
|
+
"Content-Type": "application/x-amz-json-1.1",
|
|
72
|
+
"X-Amz-Target": "AWSCognitoIdentityProviderService.InitiateAuth",
|
|
73
|
+
},
|
|
74
|
+
signal: options.signal,
|
|
75
|
+
body: JSON.stringify({
|
|
76
|
+
AuthFlow: "USER_PASSWORD_AUTH",
|
|
77
|
+
ClientId: clientId,
|
|
78
|
+
AuthParameters: { USERNAME: credentials.username, PASSWORD: credentials.secret },
|
|
79
|
+
}),
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
throw new MachineLoginError("auth_failed", "hqd sign-in failed; check the machine credentials and try again");
|
|
84
|
+
}
|
|
85
|
+
let body;
|
|
86
|
+
try {
|
|
87
|
+
body = await response.json();
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
throw new MachineLoginError("auth_failed", "hqd sign-in failed; check the machine credentials and try again");
|
|
91
|
+
}
|
|
92
|
+
if (!response.ok || !body || typeof body !== "object" || Array.isArray(body)) {
|
|
93
|
+
throw new MachineLoginError("auth_failed", "hqd sign-in failed; check the machine credentials and try again");
|
|
94
|
+
}
|
|
95
|
+
const result = body.AuthenticationResult;
|
|
96
|
+
const idToken = result && typeof result === "object" ? result.IdToken : undefined;
|
|
97
|
+
if (typeof idToken !== "string" || !idToken) {
|
|
98
|
+
throw new MachineLoginError("auth_failed", "hqd sign-in failed; check the machine credentials and try again");
|
|
99
|
+
}
|
|
100
|
+
return idToken;
|
|
101
|
+
}
|
|
102
|
+
//# sourceMappingURL=machine-login.js.map
|
|
@@ -14,6 +14,7 @@ export interface McpToolResult {
|
|
|
14
14
|
export declare function jsonResult(value: unknown): McpToolResult;
|
|
15
15
|
export declare const HQ_MCP_HTTP_TIMEOUT_MS = 30000;
|
|
16
16
|
export declare function resolveHqMcpEndpoint(env?: NodeJS.ProcessEnv): URL;
|
|
17
|
+
export declare function defaultMcpToken(): Promise<string>;
|
|
17
18
|
export interface HqMcpBridgeOptions {
|
|
18
19
|
endpoint?: URL;
|
|
19
20
|
env?: NodeJS.ProcessEnv;
|
|
@@ -27,7 +27,7 @@ export function resolveHqMcpEndpoint(env = process.env) {
|
|
|
27
27
|
throw error;
|
|
28
28
|
}
|
|
29
29
|
}
|
|
30
|
-
async function defaultMcpToken() {
|
|
30
|
+
export async function defaultMcpToken() {
|
|
31
31
|
const { ensureCognitoToken, resolveCognitoTokenSource } = await import("../../../utils/cognito-session.js");
|
|
32
32
|
if (resolveCognitoTokenSource() !== "machine") {
|
|
33
33
|
const { getMcpAudienceToken, McpOAuthError } = await import("../mcp-oauth.js");
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* out of order, so clients match them by `id`.
|
|
8
8
|
*/
|
|
9
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", "mcp.call", "shutdown"];
|
|
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", "mcp.call", "auth.login", "auth.status", "shutdown"];
|
|
11
11
|
export type HqdOp = (typeof HQD_OPS)[number];
|
|
12
12
|
/** A request line larger than this is rejected and the connection closed. */
|
|
13
13
|
export declare const HQD_MAX_LINE_BYTES: number;
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import { type ResolveResult } from "../registry/registry.js";
|
|
10
10
|
import { SessionRegistry } from "./session-registry.js";
|
|
11
11
|
import { HqMcpBridge, type RunWorkerRequest } from "./ops/mcp.js";
|
|
12
|
+
import { type StdinMachineCredentials } from "./machine-login.js";
|
|
12
13
|
export interface HqdServerOptions {
|
|
13
14
|
socketPath: string;
|
|
14
15
|
/** registry.json to resolve against (US-004). */
|
|
@@ -27,6 +28,10 @@ export interface HqdServerOptions {
|
|
|
27
28
|
mcpEnabled?: () => boolean | Promise<boolean>;
|
|
28
29
|
/** Test seam for the hosted Streamable HTTP MCP transport. */
|
|
29
30
|
mcpBridge?: HqMcpBridge;
|
|
31
|
+
/** Test seam for machine login while keeping production credentials off disk. */
|
|
32
|
+
machineAuthenticator?: (credentials: StdinMachineCredentials, signal?: AbortSignal) => Promise<string>;
|
|
33
|
+
/** Test seam for the bounded Cognito login request. */
|
|
34
|
+
machineAuthTimeoutMs?: number;
|
|
30
35
|
/** Test seam for reduced host parity runs without spawning an agent process. */
|
|
31
36
|
workerDispatch?: (request: RunWorkerRequest, hqRoot: string, env: NodeJS.ProcessEnv, now: Date) => Promise<Record<string, unknown>>;
|
|
32
37
|
/** Crash reporter for internal op failures (US-012); run.ts wires Sentry here. */
|
|
@@ -17,7 +17,8 @@ import { journalAppend, JournalOpError } from "./ops/journal.js";
|
|
|
17
17
|
import { memoryGet, memoryPut, MemoryOpError } from "./ops/memory.js";
|
|
18
18
|
import { meshPresence, MeshOpError } from "./ops/mesh.js";
|
|
19
19
|
import { PolicyIndex, PolicyOpError, policyCheck } from "./ops/policy.js";
|
|
20
|
-
import { HqMcpBridge, jsonResult, McpOpError, runWorker } from "./ops/mcp.js";
|
|
20
|
+
import { defaultMcpToken, HqMcpBridge, jsonResult, McpOpError, runWorker } from "./ops/mcp.js";
|
|
21
|
+
import { authenticateMachineCredentials, machineTokenExpiresAt, MachineLoginError, parseStdinMachineCredentials } from "./machine-login.js";
|
|
21
22
|
import { HQ_MCP_TOOL_NAMES, HQD_NATIVE_TOOL_NAMES } from "../mcp/tools.js";
|
|
22
23
|
import { anywhereRuntimeEnabled, HQ_ANYWHERE_RUNTIME_FLAG } from "../../commands/anywhere-runtime-flag.js";
|
|
23
24
|
// The allowlist is deliberately read-only. A newly added hosted MCP tool must
|
|
@@ -110,6 +111,7 @@ export async function startHqdServer(opts) {
|
|
|
110
111
|
const clock = opts.now ?? (() => new Date());
|
|
111
112
|
const env = opts.env ?? process.env;
|
|
112
113
|
const policyIndex = opts.hqRoot ? new PolicyIndex(opts.hqRoot, log) : null;
|
|
114
|
+
let ephemeralMachineToken;
|
|
113
115
|
const requireRoot = () => {
|
|
114
116
|
if (!opts.hqRoot)
|
|
115
117
|
throw new OpError("no_hq_root", "hqd has no HQ root configured (set hqRoot in daemon config or HQ_ROOT)");
|
|
@@ -157,6 +159,45 @@ export async function startHqdServer(opts) {
|
|
|
157
159
|
pid: process.pid,
|
|
158
160
|
sessions: sessions.size,
|
|
159
161
|
};
|
|
162
|
+
case "auth.status":
|
|
163
|
+
return Promise.resolve(mcpEnabled()).then((enabled) => {
|
|
164
|
+
if (!enabled)
|
|
165
|
+
throw new OpError("anywhere_disabled", `Daemon login is disabled by the ${HQ_ANYWHERE_RUNTIME_FLAG} flag.`);
|
|
166
|
+
if (ephemeralMachineToken && ephemeralMachineToken.expiresAt <= clock().getTime())
|
|
167
|
+
ephemeralMachineToken = undefined;
|
|
168
|
+
return { signedIn: Boolean(ephemeralMachineToken), tokenSource: ephemeralMachineToken ? "stdin-memory" : null };
|
|
169
|
+
});
|
|
170
|
+
case "auth.login":
|
|
171
|
+
return Promise.resolve(mcpEnabled()).then(async (enabled) => {
|
|
172
|
+
if (!enabled)
|
|
173
|
+
throw new OpError("anywhere_disabled", `Daemon login is disabled by the ${HQ_ANYWHERE_RUNTIME_FLAG} flag.`);
|
|
174
|
+
const credentials = parseStdinMachineCredentials(JSON.stringify(args.credentials) ?? "");
|
|
175
|
+
const controller = new AbortController();
|
|
176
|
+
const timeoutMs = opts.machineAuthTimeoutMs ?? 25_000;
|
|
177
|
+
let timer;
|
|
178
|
+
try {
|
|
179
|
+
const authentication = (opts.machineAuthenticator ?? ((input, signal) => authenticateMachineCredentials(input, { env, signal })))(credentials, controller.signal);
|
|
180
|
+
const deadline = new Promise((_, reject) => {
|
|
181
|
+
timer = setTimeout(() => {
|
|
182
|
+
controller.abort();
|
|
183
|
+
reject(new MachineLoginError("auth_timeout", "hqd sign-in timed out; try again"));
|
|
184
|
+
}, timeoutMs);
|
|
185
|
+
});
|
|
186
|
+
const token = await Promise.race([authentication, deadline]);
|
|
187
|
+
const expiresAt = machineTokenExpiresAt(token);
|
|
188
|
+
if (expiresAt <= clock().getTime())
|
|
189
|
+
throw new MachineLoginError("auth_failed", "hqd sign-in failed; the identity provider returned an expired token");
|
|
190
|
+
ephemeralMachineToken = { value: token, expiresAt };
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
throw new MachineLoginError("auth_failed", "hqd sign-in failed; check the machine credentials and try again");
|
|
194
|
+
}
|
|
195
|
+
finally {
|
|
196
|
+
if (timer)
|
|
197
|
+
clearTimeout(timer);
|
|
198
|
+
}
|
|
199
|
+
return { signedIn: true, tokenSource: "stdin-memory" };
|
|
200
|
+
});
|
|
160
201
|
case "resolve": {
|
|
161
202
|
const cwd = stringArg(args, "cwd");
|
|
162
203
|
return resolve(cwd);
|
|
@@ -309,7 +350,19 @@ export async function startHqdServer(opts) {
|
|
|
309
350
|
if (remainingMs <= 0) {
|
|
310
351
|
throw new McpOpError("hq_mcp_timeout", `Hosted hq-mcp request exceeded its ${timeoutMs}ms end-to-end deadline before forwarding.`);
|
|
311
352
|
}
|
|
312
|
-
hqMcp ??= new HqMcpBridge({
|
|
353
|
+
hqMcp ??= new HqMcpBridge({
|
|
354
|
+
env,
|
|
355
|
+
getToken: async () => {
|
|
356
|
+
if (ephemeralMachineToken) {
|
|
357
|
+
if (ephemeralMachineToken.expiresAt <= clock().getTime()) {
|
|
358
|
+
ephemeralMachineToken = undefined;
|
|
359
|
+
throw new Error("The stdin machine sign-in expired; sign hqd in again.");
|
|
360
|
+
}
|
|
361
|
+
return ephemeralMachineToken.value;
|
|
362
|
+
}
|
|
363
|
+
return defaultMcpToken();
|
|
364
|
+
},
|
|
365
|
+
});
|
|
313
366
|
return hqMcp.callTool(name, raw, remainingMs);
|
|
314
367
|
});
|
|
315
368
|
return !MCP_READ_ONLY_TOOL_NAMES.has(name)
|
|
@@ -353,7 +406,8 @@ export async function startHqdServer(opts) {
|
|
|
353
406
|
err instanceof JournalOpError ||
|
|
354
407
|
err instanceof MemoryOpError ||
|
|
355
408
|
err instanceof MeshOpError ||
|
|
356
|
-
err instanceof McpOpError
|
|
409
|
+
err instanceof McpOpError ||
|
|
410
|
+
err instanceof MachineLoginError
|
|
357
411
|
? { code: err.code, message: err.message }
|
|
358
412
|
: { code: "internal", message: err instanceof Error ? err.message : String(err) };
|
|
359
413
|
if (error.code === "internal") {
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* unit files under a systemd user dir and is idempotent and reversible.
|
|
7
7
|
* Enabling the timer is optional — the CLI pass works without it.
|
|
8
8
|
*/
|
|
9
|
-
export declare const RECONCILE_TIMER_INTERVAL = "
|
|
9
|
+
export declare const RECONCILE_TIMER_INTERVAL = "15s";
|
|
10
10
|
export interface ProviderPathEnv {
|
|
11
11
|
/**
|
|
12
12
|
* For each resolved provider: the directory where the command name was
|
|
@@ -13,7 +13,7 @@ import { LanesError } from "./errors.js";
|
|
|
13
13
|
import { RECONCILE_TIMER_NAME, RECONCILE_UNIT_NAME } from "./reconcile.js";
|
|
14
14
|
import { resolveHqBinary, resolveNodeBinary, } from "../mesh/live/daemon/install.js";
|
|
15
15
|
import { SYSTEMD_PATH_ENV } from "../service-manager/systemd.js";
|
|
16
|
-
export const RECONCILE_TIMER_INTERVAL = "
|
|
16
|
+
export const RECONCILE_TIMER_INTERVAL = "15s";
|
|
17
17
|
/**
|
|
18
18
|
* Resolve provider commands with the installing shell's PATH. The resulting
|
|
19
19
|
* directories are carried into the systemd unit, whose default PATH is too
|
|
@@ -1209,13 +1209,17 @@ export async function runPassBody(hqRoot, deps) {
|
|
|
1209
1209
|
cpuObservation,
|
|
1210
1210
|
skipAdmissionWithoutCpuSample: !hasAdmissionCandidates,
|
|
1211
1211
|
};
|
|
1212
|
+
let dueKeepAliveLaneIds = new Set();
|
|
1212
1213
|
await withAllLaneAdmissionLocks(hqRoot, async () => {
|
|
1213
1214
|
const freshLanes = loadLaneRecords(hqRoot, summary);
|
|
1214
1215
|
const freshQuestions = loadQuestionRecords(hqRoot, summary);
|
|
1215
1216
|
await closeInterruptedDoneStoryWorkers(hqRoot, freshLanes, freshQuestions, summary, admissionDeps, now, storyCloseEnabledByCompany);
|
|
1216
|
-
await resumeKeepAlive(hqRoot, freshLanes, freshQuestions, summary, admissionDeps, now, keepAliveFlagByCompany, supersessionLineageAwareByCompany, blockedResumeFlagByCompany, immediateFirstResumeFlagByCompany, deps.terminalKeepAliveCandidate)
|
|
1217
|
+
const dueKeepAlivesAdmitted = await resumeKeepAlive(hqRoot, freshLanes, freshQuestions, summary, admissionDeps, now, keepAliveFlagByCompany, supersessionLineageAwareByCompany, blockedResumeFlagByCompany, immediateFirstResumeFlagByCompany, deps.terminalKeepAliveCandidate, async (laneIds) => {
|
|
1218
|
+
dueKeepAliveLaneIds = new Set(laneIds);
|
|
1219
|
+
await admitQueued(hqRoot, loadLaneRecords(hqRoot, summary), summary, admissionDeps, now, laneIds);
|
|
1220
|
+
});
|
|
1217
1221
|
const drainLanes = loadLaneRecords(hqRoot, summary);
|
|
1218
|
-
await admitQueued(hqRoot, drainLanes, summary, admissionDeps, now);
|
|
1222
|
+
await admitQueued(hqRoot, drainLanes, summary, admissionDeps, now, undefined, dueKeepAlivesAdmitted ? dueKeepAliveLaneIds : undefined);
|
|
1219
1223
|
await resumeAwaiting(hqRoot, drainLanes, freshQuestions, summary, admissionDeps);
|
|
1220
1224
|
});
|
|
1221
1225
|
if (!deps.dryRun && cpuObservation.snapshot !== undefined) {
|
|
@@ -1715,7 +1719,7 @@ async function heartbeatAndDeadHosts(hqRoot, lanes, summary, deps, now) {
|
|
|
1715
1719
|
}
|
|
1716
1720
|
}
|
|
1717
1721
|
}
|
|
1718
|
-
async function admitQueued(hqRoot, lanes, summary, deps, now, onlyLaneIds) {
|
|
1722
|
+
async function admitQueued(hqRoot, lanes, summary, deps, now, onlyLaneIds, excludeLaneIds) {
|
|
1719
1723
|
if (onlyLaneIds === undefined)
|
|
1720
1724
|
expireAbandonedPickupValidation(hqRoot, lanes, summary, deps, now);
|
|
1721
1725
|
// A lane whose pickup probe is still out is not admitted: its record is
|
|
@@ -1724,6 +1728,7 @@ async function admitQueued(hqRoot, lanes, summary, deps, now, onlyLaneIds) {
|
|
|
1724
1728
|
const localQueued = lanes
|
|
1725
1729
|
.filter((lane) => lane.state === "queued" &&
|
|
1726
1730
|
(onlyLaneIds === undefined || onlyLaneIds.has(lane.lane_id)) &&
|
|
1731
|
+
(excludeLaneIds === undefined || !excludeLaneIds.has(lane.lane_id)) &&
|
|
1727
1732
|
lane.pickup_validation_pending !== true &&
|
|
1728
1733
|
laneRunsHere(lane, deps.host))
|
|
1729
1734
|
.filter((lane) => !hasFreshAdmissionReservation(lane, now.getTime()))
|
|
@@ -2324,7 +2329,7 @@ function appendBlockedKeepAliveHistory(hqRoot, lane, terminalAt, at, reason, dep
|
|
|
2324
2329
|
}),
|
|
2325
2330
|
}), false);
|
|
2326
2331
|
}
|
|
2327
|
-
async function resumeKeepAlive(hqRoot, lanes, questions, summary, deps, now, flagByCompany, supersessionLineageAwareByCompany, blockedResumeFlagByCompany, immediateFirstResumeFlagByCompany, terminalKeepAliveCandidate) {
|
|
2332
|
+
async function resumeKeepAlive(hqRoot, lanes, questions, summary, deps, now, flagByCompany, supersessionLineageAwareByCompany, blockedResumeFlagByCompany, immediateFirstResumeFlagByCompany, terminalKeepAliveCandidate, admitDueCandidates) {
|
|
2328
2333
|
const nowMs = now.getTime();
|
|
2329
2334
|
const nowIso = now.toISOString();
|
|
2330
2335
|
const config = loadLanesConfig(hqRoot);
|
|
@@ -2347,7 +2352,39 @@ async function resumeKeepAlive(hqRoot, lanes, questions, summary, deps, now, fla
|
|
|
2347
2352
|
markError(summary, err);
|
|
2348
2353
|
}
|
|
2349
2354
|
}
|
|
2350
|
-
|
|
2355
|
+
const dueCandidateIds = new Set();
|
|
2356
|
+
if (admitDueCandidates !== undefined && terminalKeepAliveCandidate === undefined) {
|
|
2357
|
+
for (const lane of lanes) {
|
|
2358
|
+
if (lane.state !== "done" || lane.keep_alive !== true || !laneRunsHere(lane, deps.host))
|
|
2359
|
+
continue;
|
|
2360
|
+
const envelope = readLatestEnvelopeRecord(hqRoot, lane.lane_id)?.envelope;
|
|
2361
|
+
if (!envelope || envelope.decision !== "done" || hasPendingKeepAliveWork(hqRoot, lane, questions))
|
|
2362
|
+
continue;
|
|
2363
|
+
const anchorMs = Date.parse(lane.last_envelope?.at ?? lane.updated_at);
|
|
2364
|
+
const nextCheckMs = Date.parse(envelope.next_check_at ?? "");
|
|
2365
|
+
if (!Number.isFinite(anchorMs) || !Number.isFinite(nextCheckMs))
|
|
2366
|
+
continue;
|
|
2367
|
+
const cappedNextCheckMs = Math.min(nextCheckMs, anchorMs + LEAD_LANE_KEEP_ALIVE_NEXT_CHECK_MAX_DELAY_MS);
|
|
2368
|
+
if (cappedNextCheckMs <= nowMs &&
|
|
2369
|
+
doneKeepAliveVerdict(lane, envelope, false, { nowMs, nextCheckAnchorMs: anchorMs }) === "eligible")
|
|
2370
|
+
dueCandidateIds.add(lane.lane_id);
|
|
2371
|
+
}
|
|
2372
|
+
}
|
|
2373
|
+
const candidates = dueCandidateIds.size === 0
|
|
2374
|
+
? lanes
|
|
2375
|
+
: [...lanes].sort((a, b) => Number(dueCandidateIds.has(b.lane_id)) - Number(dueCandidateIds.has(a.lane_id)));
|
|
2376
|
+
const queuedDueCandidateIds = new Set();
|
|
2377
|
+
let dueAdmissionRan = false;
|
|
2378
|
+
const admitQueuedDueCandidates = async () => {
|
|
2379
|
+
if (dueAdmissionRan || queuedDueCandidateIds.size === 0 || admitDueCandidates === undefined)
|
|
2380
|
+
return;
|
|
2381
|
+
dueAdmissionRan = true;
|
|
2382
|
+
await admitDueCandidates(new Set(queuedDueCandidateIds));
|
|
2383
|
+
};
|
|
2384
|
+
for (const originalLane of candidates) {
|
|
2385
|
+
if (admitDueCandidates !== undefined && dueCandidateIds.size > 0 && !dueCandidateIds.has(originalLane.lane_id)) {
|
|
2386
|
+
await admitQueuedDueCandidates();
|
|
2387
|
+
}
|
|
2351
2388
|
if (terminalKeepAliveCandidate && (originalLane.lane_id !== terminalKeepAliveCandidate.laneId ||
|
|
2352
2389
|
originalLane.version !== terminalKeepAliveCandidate.version ||
|
|
2353
2390
|
originalLane.state !== "done"))
|
|
@@ -3012,6 +3049,8 @@ async function resumeKeepAlive(hqRoot, lanes, questions, summary, deps, now, fla
|
|
|
3012
3049
|
}
|
|
3013
3050
|
if (!queued.changed)
|
|
3014
3051
|
continue;
|
|
3052
|
+
if (resumeReason === "next_check_at_due")
|
|
3053
|
+
queuedDueCandidateIds.add(current.lane_id);
|
|
3015
3054
|
summary.resumed.push({
|
|
3016
3055
|
id: current.lane_id,
|
|
3017
3056
|
from: resumedFrom,
|
|
@@ -3032,6 +3071,8 @@ async function resumeKeepAlive(hqRoot, lanes, questions, summary, deps, now, fla
|
|
|
3032
3071
|
});
|
|
3033
3072
|
}
|
|
3034
3073
|
}
|
|
3074
|
+
await admitQueuedDueCandidates();
|
|
3075
|
+
return dueAdmissionRan;
|
|
3035
3076
|
}
|
|
3036
3077
|
async function resumeAwaiting(hqRoot, lanes, questions, summary, deps, onlyLaneIds) {
|
|
3037
3078
|
const config = loadLanesConfig(hqRoot);
|