feat: add HWLAB web probe credential injection
This commit is contained in:
+236
-1
@@ -14,6 +14,22 @@ type SecretPreset = "openfga" | "master-server-admin-api-key" | "bootstrap-admin
|
||||
type DelegatedNodeDomain = "control-plane" | "git-mirror";
|
||||
type NodeRuntimeRenderLocation = "node-host" | "local";
|
||||
|
||||
interface NodeWebProbeOptions {
|
||||
action: "run";
|
||||
node: string;
|
||||
lane: string;
|
||||
url: string;
|
||||
timeoutMs: number;
|
||||
waitAfterSubmitMs: number;
|
||||
waitMessagesMs: number;
|
||||
waitAgentTerminalMs: number;
|
||||
message: string | null;
|
||||
conversationId: string | null;
|
||||
freshSession: boolean;
|
||||
cancelRunning: boolean;
|
||||
commandTimeoutSeconds: number;
|
||||
}
|
||||
|
||||
interface NodeRuntimeRenderResult {
|
||||
readonly result: CommandResult;
|
||||
readonly renderDir: string;
|
||||
@@ -30,6 +46,7 @@ interface NodeSecretOptions {
|
||||
preset: SecretPreset;
|
||||
dryRun: boolean;
|
||||
confirm: boolean;
|
||||
force?: boolean;
|
||||
timeoutSeconds: number;
|
||||
}
|
||||
|
||||
@@ -44,6 +61,17 @@ interface BootstrapAdminSecretMaterial {
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
interface BootstrapAdminPasswordMaterial {
|
||||
ok: boolean;
|
||||
sourceRef: string | null;
|
||||
sourceKey: string | null;
|
||||
sourcePath: string | null;
|
||||
sourcePresent: boolean;
|
||||
sourceFingerprint: string | null;
|
||||
password: string | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
interface NodePublicExposureOptions {
|
||||
action: "public-exposure";
|
||||
node: string;
|
||||
@@ -138,12 +166,16 @@ export async function runHwlabNodeCommand(_config: Config, args: string[]): Prom
|
||||
const { runHwlabTestAccountsCommand } = await import("./hwlab-test-accounts");
|
||||
return runHwlabTestAccountsCommand(args.slice(1));
|
||||
}
|
||||
if (domain === "web-probe") {
|
||||
if (args.length === 1 || args.includes("--help") || args.includes("-h") || args[1] === "help") return hwlabNodeWebProbeHelp();
|
||||
return runNodeWebProbe(parseNodeWebProbeOptions(args.slice(1)));
|
||||
}
|
||||
if (args.includes("--help") || args.includes("-h")) return hwlabNodeHelp();
|
||||
if (domain === "control-plane" || domain === "git-mirror") {
|
||||
return runNodeDelegatedDomain(_config, domain, args.slice(1));
|
||||
}
|
||||
if (domain !== "secret") {
|
||||
return { ok: false, command: `hwlab nodes ${domain ?? ""}`.trim(), message: "supported commands: hwlab nodes control-plane, hwlab nodes git-mirror, hwlab nodes secret, hwlab nodes test-accounts" };
|
||||
return { ok: false, command: `hwlab nodes ${domain ?? ""}`.trim(), message: "supported commands: hwlab nodes control-plane, hwlab nodes git-mirror, hwlab nodes secret, hwlab nodes test-accounts, hwlab nodes web-probe" };
|
||||
}
|
||||
const options = parseSecretOptions(args.slice(1));
|
||||
return runNodeSecret(options);
|
||||
@@ -182,6 +214,7 @@ export function hwlabNodeHelp(): Record<string, unknown> {
|
||||
"bun scripts/cli.ts hwlab nodes secret ensure --node G14 --lane v03 --name hwlab-v03-master-server-admin-api-key --confirm",
|
||||
"bun scripts/cli.ts hwlab nodes secret status --node G14 --lane v03 --name hwlab-v03-bootstrap-admin",
|
||||
"bun scripts/cli.ts hwlab nodes secret ensure --node G14 --lane v03 --name hwlab-v03-bootstrap-admin --confirm",
|
||||
"bun scripts/cli.ts hwlab nodes secret ensure --node D601 --lane v03 --name hwlab-v03-bootstrap-admin --confirm --force",
|
||||
"bun scripts/cli.ts hwlab nodes secret status --node G14 --lane v03 --name hwlab-cloud-api-v03-db",
|
||||
"bun scripts/cli.ts hwlab nodes secret cleanup-owned-postgres --node G14 --lane v03 --dry-run",
|
||||
"bun scripts/cli.ts hwlab nodes secret cleanup-owned-postgres --node G14 --lane v03 --confirm",
|
||||
@@ -191,6 +224,19 @@ export function hwlabNodeHelp(): Record<string, unknown> {
|
||||
"bun scripts/cli.ts hwlab nodes secret ensure --node G14 --lane v03 --name hwlab-v03-code-agent-provider --confirm",
|
||||
"bun scripts/cli.ts hwlab nodes test-accounts status --node D601 --lane v03",
|
||||
"bun scripts/cli.ts hwlab nodes test-accounts sync --node D601 --lane v03 --confirm",
|
||||
"bun scripts/cli.ts hwlab nodes web-probe run --node D601 --lane v03 --wait-messages-ms 1000",
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function hwlabNodeWebProbeHelp(): Record<string, unknown> {
|
||||
return {
|
||||
ok: true,
|
||||
command: "hwlab nodes web-probe",
|
||||
description: "Run the target node/lane HWLAB Cloud Web DOM probe with Web login credentials resolved from YAML-declared bootstrap admin sourceRef and injected only as one-shot stdin/env.",
|
||||
examples: [
|
||||
"bun scripts/cli.ts hwlab nodes web-probe run --node D601 --lane v03 --wait-messages-ms 1000",
|
||||
"bun scripts/cli.ts hwlab nodes web-probe run --node D601 --lane v03 --url https://hwlab.pikapython.com --fresh-session --message 'ping'",
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -3246,6 +3292,34 @@ function readBootstrapAdminSecretMaterial(spec: RuntimeSecretSpec): BootstrapAdm
|
||||
};
|
||||
}
|
||||
|
||||
function readBootstrapAdminPasswordMaterial(spec: RuntimeSecretSpec): BootstrapAdminPasswordMaterial {
|
||||
const sourceRef = spec.bootstrapAdminPasswordSourceRef;
|
||||
const sourceKey = spec.bootstrapAdminPasswordSourceKey;
|
||||
if (sourceRef === undefined || sourceKey === undefined || spec.bootstrapAdminPasswordHashTransform === undefined) {
|
||||
return { ok: false, sourceRef: sourceRef ?? null, sourceKey: sourceKey ?? null, sourcePath: null, sourcePresent: false, sourceFingerprint: null, password: null, error: "bootstrap-admin-yaml-source-missing" };
|
||||
}
|
||||
const paths = secretSourcePaths(sourceRef);
|
||||
const sourcePath = paths.find((candidate) => existsSync(candidate)) ?? paths[0] ?? join(repoRoot, ".state", "secrets", sourceRef);
|
||||
if (!existsSync(sourcePath)) {
|
||||
return { ok: false, sourceRef, sourceKey, sourcePath, sourcePresent: false, sourceFingerprint: null, password: null, error: "secret-source-missing" };
|
||||
}
|
||||
const values = parseEnvFile(readFileSync(sourcePath, "utf8"));
|
||||
const password = values[sourceKey];
|
||||
if (password === undefined || password.length === 0) {
|
||||
return { ok: false, sourceRef, sourceKey, sourcePath, sourcePresent: true, sourceFingerprint: null, password: null, error: "secret-key-missing" };
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
sourceRef,
|
||||
sourceKey,
|
||||
sourcePath,
|
||||
sourcePresent: true,
|
||||
sourceFingerprint: `sha256:${createHash("sha256").update(password).digest("hex").slice(0, 16)}`,
|
||||
password,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
function parseEnvFile(text: string): Record<string, string> {
|
||||
const values: Record<string, string> = {};
|
||||
for (const rawLine of text.split(/\r?\n/u)) {
|
||||
@@ -3414,6 +3488,127 @@ function rewriteDelegatedNodeString(value: string, scoped: ReturnType<typeof par
|
||||
return replaceCommand(replaceCommand(value, "control-plane"), "git-mirror");
|
||||
}
|
||||
|
||||
function parseNodeWebProbeOptions(args: string[]): NodeWebProbeOptions {
|
||||
const [actionRaw] = args;
|
||||
if (actionRaw !== "run") throw new Error("web-probe usage: run --node NODE --lane vNN [--url URL] [--wait-messages-ms N]");
|
||||
const node = requiredOption(args, "--node");
|
||||
assertNodeId(node);
|
||||
const lane = requiredOption(args, "--lane");
|
||||
assertLane(lane);
|
||||
if (!isHwlabRuntimeLane(lane)) throw new Error(`web-probe only supports HWLAB runtime lanes, got ${lane}`);
|
||||
const spec = hwlabRuntimeLaneSpecForNode(lane, node);
|
||||
assertKnownOptions(args.slice(1), new Set([
|
||||
"--node",
|
||||
"--lane",
|
||||
"--url",
|
||||
"--timeout-ms",
|
||||
"--wait-after-submit-ms",
|
||||
"--wait-messages-ms",
|
||||
"--wait-agent-terminal-ms",
|
||||
"--message",
|
||||
"--conversation-id",
|
||||
"--command-timeout-seconds",
|
||||
]), new Set([
|
||||
"--fresh-session",
|
||||
"--no-cancel-running",
|
||||
]));
|
||||
return {
|
||||
action: "run",
|
||||
node,
|
||||
lane,
|
||||
url: optionValue(args, "--url") ?? spec.publicWebUrl,
|
||||
timeoutMs: positiveIntegerOption(args, "--timeout-ms", 30000, 60000),
|
||||
waitAfterSubmitMs: positiveIntegerOption(args, "--wait-after-submit-ms", 1500, 60000),
|
||||
waitMessagesMs: positiveIntegerOption(args, "--wait-messages-ms", 2500, 60000),
|
||||
waitAgentTerminalMs: positiveIntegerOption(args, "--wait-agent-terminal-ms", 0, 600000),
|
||||
message: optionValue(args, "--message") ?? null,
|
||||
conversationId: optionValue(args, "--conversation-id") ?? null,
|
||||
freshSession: args.includes("--fresh-session"),
|
||||
cancelRunning: !args.includes("--no-cancel-running"),
|
||||
commandTimeoutSeconds: positiveIntegerOption(args, "--command-timeout-seconds", 60, 60),
|
||||
};
|
||||
}
|
||||
|
||||
function assertKnownOptions(args: string[], valueOptions: Set<string>, flagOptions: Set<string>): void {
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index] ?? "";
|
||||
if (!arg.startsWith("--")) continue;
|
||||
if (flagOptions.has(arg)) continue;
|
||||
if (valueOptions.has(arg)) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
throw new Error(`unknown option: ${arg}`);
|
||||
}
|
||||
}
|
||||
|
||||
function runNodeWebProbe(options: NodeWebProbeOptions): Record<string, unknown> {
|
||||
const lane = options.lane;
|
||||
if (!isHwlabRuntimeLane(lane)) throw new Error(`web-probe only supports HWLAB runtime lanes, got ${lane}`);
|
||||
const spec = hwlabRuntimeLaneSpecForNode(lane, options.node);
|
||||
const secretSpec = runtimeSecretSpec({ node: options.node, lane });
|
||||
const material = readBootstrapAdminPasswordMaterial(secretSpec);
|
||||
const credential = {
|
||||
username: secretSpec.bootstrapAdminUsername,
|
||||
sourceRef: material.sourceRef,
|
||||
sourceKey: material.sourceKey,
|
||||
sourcePath: material.sourcePath === null ? null : displayRepoPath(material.sourcePath),
|
||||
sourcePresent: material.sourcePresent,
|
||||
sourceFingerprint: material.sourceFingerprint,
|
||||
injectedVia: material.ok ? "stdin-env" : null,
|
||||
valuesRedacted: true,
|
||||
error: material.error,
|
||||
};
|
||||
if (!material.ok || material.password === null) {
|
||||
return {
|
||||
ok: false,
|
||||
status: "blocked",
|
||||
command: `hwlab nodes web-probe run --node ${options.node} --lane ${options.lane}`,
|
||||
node: options.node,
|
||||
lane: options.lane,
|
||||
workspace: spec.workspace,
|
||||
url: options.url,
|
||||
degradedReason: "web_login_secret_missing",
|
||||
credential,
|
||||
next: { secretStatus: `bun scripts/cli.ts hwlab nodes secret status --node ${options.node} --lane ${options.lane} --name ${secretSpec.bootstrapAdminSecret}` },
|
||||
};
|
||||
}
|
||||
const probeArgs = [
|
||||
"node",
|
||||
"scripts/web-live-dom-probe.mjs",
|
||||
"run",
|
||||
"--url", options.url,
|
||||
"--timeout-ms", String(options.timeoutMs),
|
||||
"--wait-after-submit-ms", String(options.waitAfterSubmitMs),
|
||||
"--wait-messages-ms", String(options.waitMessagesMs),
|
||||
];
|
||||
if (options.waitAgentTerminalMs > 0) probeArgs.push("--wait-agent-terminal-ms", String(options.waitAgentTerminalMs));
|
||||
if (options.freshSession) probeArgs.push("--fresh-session");
|
||||
if (!options.cancelRunning) probeArgs.push("--no-cancel-running");
|
||||
if (options.conversationId !== null) probeArgs.push("--conversation-id", options.conversationId);
|
||||
if (options.message !== null) probeArgs.push("--message", options.message);
|
||||
const script = [
|
||||
"set -eu",
|
||||
`HWLAB_WEB_USER=${shellQuote(secretSpec.bootstrapAdminUsername)} HWLAB_WEB_PASS=${shellQuote(material.password)} ${probeArgs.map(shellQuote).join(" ")}`,
|
||||
].join("\n");
|
||||
const result = runTransWorkspaceStdinScript(options.node, spec.workspace, script, options.commandTimeoutSeconds);
|
||||
const probe = compactWebProbeResult(parseJsonObject(result.stdout));
|
||||
const passed = result.exitCode === 0 && probe?.status === "pass";
|
||||
return {
|
||||
ok: passed,
|
||||
status: passed ? "pass" : "blocked",
|
||||
command: `hwlab nodes web-probe run --node ${options.node} --lane ${options.lane}`,
|
||||
node: options.node,
|
||||
lane: options.lane,
|
||||
workspace: spec.workspace,
|
||||
url: options.url,
|
||||
credential,
|
||||
probe,
|
||||
result: compactCommandResult(result),
|
||||
valuesRedacted: true,
|
||||
};
|
||||
}
|
||||
|
||||
function parseSecretOptions(args: string[]): NodeSecretOptions {
|
||||
const [actionRaw] = args;
|
||||
if (actionRaw !== "status" && actionRaw !== "ensure" && actionRaw !== "cleanup-owned-postgres" && actionRaw !== "cleanup-obsolete") {
|
||||
@@ -3475,6 +3670,8 @@ function parseSecretOptions(args: string[]): NodeSecretOptions {
|
||||
}
|
||||
if (name === spec.bootstrapAdminSecret) {
|
||||
if (key !== undefined && key !== BOOTSTRAP_ADMIN_PASSWORD_HASH_KEY) throw new Error(`secret ${name} supports only key ${BOOTSTRAP_ADMIN_PASSWORD_HASH_KEY}`);
|
||||
const force = args.includes("--force");
|
||||
if (force && actionRaw !== "ensure") throw new Error("secret --force is only supported with ensure");
|
||||
return {
|
||||
action: actionRaw,
|
||||
node,
|
||||
@@ -3483,6 +3680,7 @@ function parseSecretOptions(args: string[]): NodeSecretOptions {
|
||||
key: key ?? BOOTSTRAP_ADMIN_PASSWORD_HASH_KEY,
|
||||
preset: "bootstrap-admin",
|
||||
confirm,
|
||||
force,
|
||||
dryRun: actionRaw === "status" ? true : explicitDryRun || !confirm,
|
||||
timeoutSeconds: positiveIntegerOption(args, "--timeout-seconds", 180, 900),
|
||||
};
|
||||
@@ -4234,6 +4432,10 @@ function runTransHostScript(node: string, script: string, input: string, timeout
|
||||
return runCommand([transPath(), node, "sh", "--", script], repoRoot, { input, timeoutMs: timeoutSeconds * 1000 });
|
||||
}
|
||||
|
||||
function runTransWorkspaceStdinScript(node: string, workspace: string, script: string, timeoutSeconds: number): CommandResult {
|
||||
return runCommand([transPath(), `${node}:${workspace}`, "sh"], repoRoot, { input: script, timeoutMs: timeoutSeconds * 1000 });
|
||||
}
|
||||
|
||||
function runObsoleteSecretCleanup(options: NodeSecretOptions, spec: RuntimeSecretSpec): Record<string, unknown> {
|
||||
const kubernetesResult = runTransScript(options.node, obsoleteSecretCleanupScript(options, spec), "", options.timeoutSeconds);
|
||||
const kubernetesStatus = secretStatusFromText(statusText(kubernetesResult), kubernetesResult.exitCode === 0, kubernetesResult.exitCode, kubernetesResult.stderr, spec);
|
||||
@@ -5135,6 +5337,7 @@ function bootstrapAdminSecretScript(options: NodeSecretOptions, spec: RuntimeSec
|
||||
`source_error=${shellQuote(material?.error ?? "")}`,
|
||||
`transform=${shellQuote(spec.bootstrapAdminPasswordHashTransform ?? "")}`,
|
||||
`material_ok=${shellQuote(materialOk ? "true" : "false")}`,
|
||||
`force_sync=${shellQuote(options.force === true ? "true" : "false")}`,
|
||||
`cloud_api_deployment=${shellQuote(spec.cloudApiDeployment)}`,
|
||||
`action_request=${shellQuote(options.action)}`,
|
||||
`dry_run=${shellQuote(options.dryRun ? "true" : "false")}`,
|
||||
@@ -5161,6 +5364,7 @@ function bootstrapAdminSecretScript(options: NodeSecretOptions, spec: RuntimeSec
|
||||
" needs_sync=false",
|
||||
" [ \"$before_exists\" = yes ] && [ \"$before_hash_bytes\" -gt 0 ] || needs_sync=true",
|
||||
" [ \"$before_source_ref\" = \"$source_ref\" ] && [ \"$before_source_key\" = \"$source_key\" ] && [ \"$before_source_fingerprint\" = \"$source_fingerprint\" ] && [ \"$before_username\" = \"$username\" ] || needs_sync=true",
|
||||
" [ \"$force_sync\" = true ] && needs_sync=true",
|
||||
" if [ \"$material_ok\" != true ]; then",
|
||||
" action=${source_error:-secret-source-invalid}",
|
||||
" apply_exit=44",
|
||||
@@ -5228,6 +5432,7 @@ function bootstrapAdminSecretScript(options: NodeSecretOptions, spec: RuntimeSec
|
||||
"printf 'sourceExists\\t%s\\n' \"$source_present\"",
|
||||
"printf 'sourceFingerprint\\t%s\\n' \"$source_fingerprint\"",
|
||||
"printf 'passwordHashTransform\\t%s\\n' \"$transform\"",
|
||||
"printf 'forceSync\\t%s\\n' \"$force_sync\"",
|
||||
"printf 'action\\t%s\\n' \"$action\"",
|
||||
"printf 'dryRun\\t%s\\n' \"$dry_run\"",
|
||||
"printf 'mutation\\t%s\\n' \"$mutation\"",
|
||||
@@ -5756,6 +5961,7 @@ function secretStatusFromText(text: string, commandOk: boolean, exitCode: number
|
||||
},
|
||||
action: fields.action || null,
|
||||
dryRun: fields.dryRun === "true",
|
||||
forceSync: fields.forceSync === "true",
|
||||
mutation: fields.mutation === "true",
|
||||
before: {
|
||||
exists: fields.beforeExists === "yes",
|
||||
@@ -6214,6 +6420,35 @@ function compactCommandResult(result: CommandResult): Record<string, unknown> {
|
||||
};
|
||||
}
|
||||
|
||||
function parseJsonObject(text: string): Record<string, unknown> | null {
|
||||
try {
|
||||
const parsed = JSON.parse(text) as unknown;
|
||||
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed as Record<string, unknown> : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function compactWebProbeResult(report: Record<string, unknown> | null): Record<string, unknown> | null {
|
||||
if (report === null) return null;
|
||||
const dom = record(report.dom);
|
||||
return {
|
||||
ok: report.ok === true,
|
||||
status: typeof report.status === "string" ? report.status : null,
|
||||
finalUrl: typeof report.finalUrl === "string" ? report.finalUrl : null,
|
||||
error: typeof report.error === "string" ? report.error : null,
|
||||
actions: Array.isArray(report.actions) ? report.actions : [],
|
||||
dom: {
|
||||
authState: typeof dom.authState === "string" ? dom.authState : null,
|
||||
requiredSelectors: record(dom.requiredSelectors),
|
||||
messageCount: typeof dom.messageCount === "number" ? dom.messageCount : null,
|
||||
},
|
||||
failureDom: record(report.failureDom),
|
||||
artifacts: record(report.artifacts),
|
||||
safety: record(report.safety),
|
||||
};
|
||||
}
|
||||
|
||||
function compactCommand(command: string[]): string[] {
|
||||
const scriptIndex = command.indexOf("--");
|
||||
if (scriptIndex >= 0 && scriptIndex + 1 < command.length) return [...command.slice(0, scriptIndex + 1), "<script omitted>"];
|
||||
|
||||
Reference in New Issue
Block a user