diff --git a/scripts/src/help.ts b/scripts/src/help.ts index 7a8c160c..efa6922d 100644 --- a/scripts/src/help.ts +++ b/scripts/src/help.ts @@ -58,7 +58,7 @@ export function rootHelp(): unknown { { command: "auth-broker contract|health --dry-run|credential-request --dry-run|pr-preflight --dry-run", description: "Inspect the P0 Rust auth broker and CLI adapter contract without reading token values, writing GitHub, or starting services." }, { command: "gh preflight|auth|issue|pr", description: "Run safe GitHub issue and PR CRUD/lifecycle operations through REST with body-file update replace/append, issue/comment apply_patch body patching, comment delete, token diagnostics, PR closeout preflight, hard delete unsupported, and guarded PR merge." }, { command: "commander contract|plan --dry-run|smoke --dry-run|approval request --dry-run", description: "Host Codex commander skeleton contract, no-daemon smoke plan, and dry-run approval preview without live bridges or message sends." }, - { command: "hwlab nodes control-plane|git-mirror|secret|test-accounts --node --lane ", description: "Manage HWLAB node/lane runtime prerequisites, including D601 YAML-declared infra/tools-image/Argo bootstrap, redacted test-account preparation, and G14 v0.3+ runtime lanes, with the node identity passed as data." }, + { command: "hwlab nodes control-plane|git-mirror|secret|test-accounts|web-probe --node --lane ", description: "Manage HWLAB node/lane runtime prerequisites, including D601 YAML-declared infra/tools-image/Argo bootstrap, redacted test-account preparation, Web DOM probe credential injection, and G14 v0.3+ runtime lanes, with the node identity passed as data." }, { command: "hwlab g14 monitor-prs | hwlab g14 control-plane status|apply|trigger-current|runtime-migration|cleanup-runs|cleanup-released-pvs | hwlab g14 git-mirror status|apply|sync|flush | hwlab g14 tools-image status|build", description: "Start the legacy G14 PR monitor, run bounded v0.2 Tekton/Argo control-plane, manual PipelineRun trigger, runtime migration, CI workspace retention, manual devops-infra git mirror/relay maintenance, or fixed HWLAB CI tools image actions; long confirmed trigger/sync/flush actions return async jobs by default." }, { command: "agentrun get|describe|events|logs|result|ack|cancel|dispatch|create|apply|send|control-plane|git-mirror", description: "Use AgentRun v0.1 resource primitives with low-noise human output by default; session follow-up uses send only and the server decides internal steer vs turn." }, { command: "platform-infra sub2api|langbot|n8n|wechat-archive ...", description: "Deploy platform-infra services such as Sub2API, LangBot and n8n, manage YAML-controlled public FRP/Caddy exposure and WeChat archive workflows, and inspect status/logs without printing secrets." }, @@ -643,7 +643,7 @@ function platformInfraHelpSummary(): unknown { function hwlabNodeHelpSummary(): unknown { return { - command: "hwlab nodes control-plane|git-mirror|secret|test-accounts --node --lane ", + command: "hwlab nodes control-plane|git-mirror|secret|test-accounts|web-probe --node --lane ", output: "json", usage: [ "bun scripts/cli.ts hwlab nodes control-plane infra plan --node D601 --lane v03", @@ -655,8 +655,9 @@ function hwlabNodeHelpSummary(): unknown { "bun scripts/cli.ts hwlab nodes secret status --node G14 --lane v03 --name ", "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", ], - description: "Operate HWLAB node/lane runtime prerequisites with node and lane passed as data. The infra subcommand manages YAML-controlled node-local CI/CD, git-mirror, public Dockerfile tools image, and declarative Argo CD prerequisites for D601 v03 while keeping cross-node work semi-automatic; test-accounts prepares UniDesk YAML-declared admin/test account API keys with redacted sourceRef/fingerprint output.", + description: "Operate HWLAB node/lane runtime prerequisites with node and lane passed as data. The infra subcommand manages YAML-controlled node-local CI/CD, git-mirror, public Dockerfile tools image, and declarative Argo CD prerequisites for D601 v03 while keeping cross-node work semi-automatic; test-accounts prepares UniDesk YAML-declared admin/test account API keys with redacted sourceRef/fingerprint output; web-probe runs the target workspace DOM probe with bootstrap Web credentials injected only as one-shot stdin/env.", }; } diff --git a/scripts/src/hwlab-node.ts b/scripts/src/hwlab-node.ts index 44226acf..28e543c3 100644 --- a/scripts/src/hwlab-node.ts +++ b/scripts/src/hwlab-node.ts @@ -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 { "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 { "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 { + 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 { const values: Record = {}; for (const rawLine of text.split(/\r?\n/u)) { @@ -3414,6 +3488,127 @@ function rewriteDelegatedNodeString(value: string, scoped: ReturnType, flagOptions: Set): 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 { + 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 { 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 { }; } +function parseJsonObject(text: string): Record | null { + try { + const parsed = JSON.parse(text) as unknown; + return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed as Record : null; + } catch { + return null; + } +} + +function compactWebProbeResult(report: Record | null): Record | 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), "