Merge pull request #674 from pikasTech/fix/v02-runtime-resolver-672

fix: assemble device-pod runtime endpoint
This commit is contained in:
Lyon
2026-06-02 07:46:38 +08:00
committed by GitHub
13 changed files with 643 additions and 201 deletions
+13 -2
View File
@@ -471,9 +471,20 @@ function buildAgentRunRunnerJobInput({ env, traceId, commandId }) {
function buildAgentRunTransientEnv(env = process.env) {
const entries = [];
for (const name of ["HWLAB_DEVICE_POD_API_URL", "HWLAB_CLOUD_API_URL", "HWLAB_DEVICE_POD_SESSION_TOKEN"]) {
for (const name of [
"HWLAB_DEVICE_POD_API_URL",
"HWLAB_CLOUD_API_URL",
"HWLAB_RUNTIME_API_URL",
"HWLAB_RUNTIME_WEB_URL",
"HWLAB_RUNTIME_NAMESPACE",
"HWLAB_RUNTIME_LANE",
"HWLAB_RUNTIME_ENDPOINT_SOURCE",
"HWLAB_RUNTIME_ENDPOINT_LOCKED",
"HWLAB_CODE_AGENT_ASSEMBLED_RUNTIME",
"HWLAB_DEVICE_POD_SESSION_TOKEN"
]) {
const value = firstNonEmpty(env[name]);
if (value) entries.push({ name, value, sensitive: true });
if (value) entries.push({ name, value, sensitive: name === "HWLAB_DEVICE_POD_SESSION_TOKEN" });
}
return entries;
}
@@ -876,6 +876,7 @@ test("Code Agent PC gateway prompt reaches Codex stdio instead of internal hardw
assert.match(turn.args.prompt, /\/app\/tools\/hwlab-gateway-tran\.mjs/u);
assert.match(turn.args.prompt, /canonical skill location is \/app\/skills\/device-pod-cli\/SKILL\.md/u);
assert.match(turn.args.prompt, /node \/app\/skills\/device-pod-cli\/scripts\/device-pod-cli\.mjs/u);
assert.match(turn.args.prompt, /Do not pass --api-base-url, --api-url, --cloud-api-url, --base-url, or session tokens to hwpod/u);
assert.match(turn.args.prompt, /gws_DESKTOP-1MHOD9I:\/f\/work/u);
assert.match(turn.args.prompt, /gws_DESKTOP-1MHOD9I:f:\/work\/hwlab\/\.tmp/u);
assert.match(turn.args.prompt, /improve \/app\/tools\/hwlab-gateway-tran\.mjs first/u);
@@ -1083,6 +1084,7 @@ test("Code Agent Keil gateway prompt is not blocked by M3 IO intent guard", asyn
assert.match(turn.args.prompt, /\/app\/tools\/hwlab-gateway-tran\.mjs/u);
assert.match(turn.args.prompt, /device-pod-cli/u);
assert.match(turn.args.prompt, /\/app\/skills\/device-pod-cli\/SKILL\.md/u);
assert.match(turn.args.prompt, /hwpod must auto-locate from that assembled environment/u);
assert.match(turn.args.prompt, /gws_DESKTOP-1MHOD9I:\/f\/work/u);
assert.match(turn.args.prompt, /gws_DESKTOP-1MHOD9I:f:\/work\/hwlab\/\.tmp/u);
assert.doesNotMatch(turn.args.prompt, /hwlab-gateway-shell\.mjs|--powershell-stdin/u);
@@ -1657,6 +1657,13 @@ export function childProcessEnv(env = process.env) {
UNIDESK_SKILLS_PATH: "/app/skills",
...(env.HWLAB_DEVICE_POD_API_URL ? { HWLAB_DEVICE_POD_API_URL: env.HWLAB_DEVICE_POD_API_URL } : {}),
...(env.HWLAB_CLOUD_API_URL ? { HWLAB_CLOUD_API_URL: env.HWLAB_CLOUD_API_URL } : {}),
...(env.HWLAB_RUNTIME_API_URL ? { HWLAB_RUNTIME_API_URL: env.HWLAB_RUNTIME_API_URL } : {}),
...(env.HWLAB_RUNTIME_WEB_URL ? { HWLAB_RUNTIME_WEB_URL: env.HWLAB_RUNTIME_WEB_URL } : {}),
...(env.HWLAB_RUNTIME_NAMESPACE ? { HWLAB_RUNTIME_NAMESPACE: env.HWLAB_RUNTIME_NAMESPACE } : {}),
...(env.HWLAB_RUNTIME_LANE ? { HWLAB_RUNTIME_LANE: env.HWLAB_RUNTIME_LANE } : {}),
...(env.HWLAB_RUNTIME_ENDPOINT_SOURCE ? { HWLAB_RUNTIME_ENDPOINT_SOURCE: env.HWLAB_RUNTIME_ENDPOINT_SOURCE } : {}),
...(env.HWLAB_RUNTIME_ENDPOINT_LOCKED ? { HWLAB_RUNTIME_ENDPOINT_LOCKED: env.HWLAB_RUNTIME_ENDPOINT_LOCKED } : {}),
...(env.HWLAB_CODE_AGENT_ASSEMBLED_RUNTIME ? { HWLAB_CODE_AGENT_ASSEMBLED_RUNTIME: env.HWLAB_CODE_AGENT_ASSEMBLED_RUNTIME } : {}),
...(env.HWLAB_DEVICE_POD_SESSION_TOKEN ? { HWLAB_DEVICE_POD_SESSION_TOKEN: env.HWLAB_DEVICE_POD_SESSION_TOKEN } : {}),
NO_PROXY: noProxy,
no_proxy: noProxy,
+1
View File
@@ -205,6 +205,7 @@ export const CODEX_STDIO_BOUNDARY_INSTRUCTIONS = [
"Do not read or print secrets, tokens, kubeconfig files, DB URLs, private keys, or raw environment values.",
"For hardware, gateway, box-simu, patch-panel, DAP, PWM, Keil, serial, and Windows skill requests, execute the requested work through the repo-owned Codex stdio session with the available tran wrapper, skill CLI, or project tool that actually reaches the target.",
"For any request that mentions device-pod, device-pod-cli, device-host-cli, a device-pod profile, device-pod-71-freq, device-pod-71-00075-11, D601-F103-V2, or a registered device-pod Keil build/download/UART/debug-probe operation, first use the HWLAB internal skill named device-pod-cli. The canonical skill location is /app/skills/device-pod-cli/SKILL.md; read that manifest and run hwpod for the operation. Use node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs only as a compatibility fallback when hwpod is absent. After selecting the target pod or resuming context, run bootsharp --pod-id <devicePodId> before edits, build, or download.",
"Do not pass --api-base-url, --api-url, --cloud-api-url, --base-url, or session tokens to hwpod in HWLAB runners. WEB and AgentRun assemble the current runtime endpoint and credential; hwpod must auto-locate from that assembled environment.",
"When a matching device-pod profile exists, do not bypass device-pod-cli with direct hwlab-gateway-tran.mjs, Windows Keil skills, serial-monitor skills, keil-cli.py, or ad hoc path discovery. Only drop to tran or Windows-side skills when the device-pod-cli skill explicitly says to bootstrap, install, or repair the lower layer.",
"For registered PC gateway Windows command or skill requests, use the preloaded tran wrapper: node /app/tools/hwlab-gateway-tran.mjs gws_DESKTOP-1MHOD9I:/f/work <cmd|ps|upload|download> [options] -- <args>. The locator before ':' is the gateway session and the path after ':' is the Windows workspace, with /f/work and f:/work both mapping to F:\\work.",
"For Windows cmd, call tran as: node /app/tools/hwlab-gateway-tran.mjs gws_DESKTOP-1MHOD9I:/f/work cmd -- <command>. For PowerShell, call tran as: node /app/tools/hwlab-gateway-tran.mjs gws_DESKTOP-1MHOD9I:/f/work ps -- <script>. The wrapper owns UTF-8, quoting, cwd, EncodedCommand, and stdout/stderr passthrough; do not hand-build nested cmd /c, cd &&, pipes, or quoting workarounds.",
+21 -3
View File
@@ -193,10 +193,28 @@ test("cloud api /v1/agent/chat delegates v0.2 turns to AgentRun v0.1 over adapte
}
if (request.method === "POST" && url.pathname === "/api/v1/runs/run_hwlab_adapter/runner-jobs") {
assert.equal(body.commandId, "cmd_hwlab_adapter");
assert.deepEqual(body.transientEnv.map((entry) => entry.name), ["HWLAB_DEVICE_POD_API_URL", "HWLAB_CLOUD_API_URL", "HWLAB_DEVICE_POD_SESSION_TOKEN"]);
assert.deepEqual(body.transientEnv.map((entry) => entry.name), [
"HWLAB_DEVICE_POD_API_URL",
"HWLAB_CLOUD_API_URL",
"HWLAB_RUNTIME_API_URL",
"HWLAB_RUNTIME_WEB_URL",
"HWLAB_RUNTIME_NAMESPACE",
"HWLAB_RUNTIME_LANE",
"HWLAB_RUNTIME_ENDPOINT_SOURCE",
"HWLAB_RUNTIME_ENDPOINT_LOCKED",
"HWLAB_CODE_AGENT_ASSEMBLED_RUNTIME",
"HWLAB_DEVICE_POD_SESSION_TOKEN"
]);
const transientEnv = Object.fromEntries(body.transientEnv.map((entry) => [entry.name, entry.value]));
assert.equal(transientEnv.HWLAB_DEVICE_POD_API_URL, "http://hwlab-cloud-web.hwlab-v02.svc.cluster.local:8080");
assert.equal(transientEnv.HWLAB_CLOUD_API_URL, "http://hwlab-cloud-web.hwlab-v02.svc.cluster.local:8080");
assert.equal(transientEnv.HWLAB_RUNTIME_API_URL, "http://hwlab-cloud-web.hwlab-v02.svc.cluster.local:8080");
assert.equal(transientEnv.HWLAB_RUNTIME_WEB_URL, "http://hwlab-cloud-web.hwlab-v02.svc.cluster.local:8080");
assert.equal(transientEnv.HWLAB_RUNTIME_NAMESPACE, "hwlab-v02");
assert.equal(transientEnv.HWLAB_RUNTIME_LANE, "v02");
assert.equal(transientEnv.HWLAB_RUNTIME_ENDPOINT_SOURCE, "runtime-namespace");
assert.equal(transientEnv.HWLAB_RUNTIME_ENDPOINT_LOCKED, "1");
assert.equal(transientEnv.HWLAB_CODE_AGENT_ASSEMBLED_RUNTIME, "1");
assert.equal(transientEnv.HWLAB_DEVICE_POD_SESSION_TOKEN, "test-device-pod-session-token");
return send({
action: "create-kubernetes-job",
@@ -215,7 +233,7 @@ test("cloud api /v1/agent/chat delegates v0.2 turns to AgentRun v0.1 over adapte
return send({ items: [
{ id: second ? "evt_4" : "evt_1", runId: "run_hwlab_adapter", seq: second ? 4 : 1, type: "backend_status", payload: { phase: second ? "turn-started" : "runner-job-created", commandId: second ? "cmd_hwlab_adapter_second" : "cmd_hwlab_adapter", attemptId: "attempt_hwlab_adapter", jobName: "agentrun-v01-runner-hwlab-adapter", namespace: "agentrun-v01" }, createdAt: "2026-06-01T00:00:00.000Z" },
...(second ? [] : [
{ id: "evt_tool", runId: "run_hwlab_adapter", seq: 2, type: "tool_call", payload: { method: "item/completed", item: { type: "commandExecution", id: "call_agentrun_tool", command: "/bin/sh -lc 'bun tools/device-pod-cli.ts profile list --api-base-url http://74.48.78.17:19666'", status: "completed", exitCode: 0, durationMs: 708, aggregatedOutput: '{"ok":true,"action":"profile.list"}' }, summary: { outputBytes: 42, outputTruncated: false }, commandId: "cmd_hwlab_adapter", runnerId: "runner_hwlab_adapter", attemptId: "attempt_hwlab_adapter" }, createdAt: "2026-06-01T00:00:00.500Z" },
{ id: "evt_tool", runId: "run_hwlab_adapter", seq: 2, type: "tool_call", payload: { method: "item/completed", item: { type: "commandExecution", id: "call_agentrun_tool", command: "/bin/sh -lc 'hwpod profile list'", status: "completed", exitCode: 0, durationMs: 708, aggregatedOutput: '{"ok":true,"action":"profile.list"}' }, summary: { outputBytes: 42, outputTruncated: false }, commandId: "cmd_hwlab_adapter", runnerId: "runner_hwlab_adapter", attemptId: "attempt_hwlab_adapter" }, createdAt: "2026-06-01T00:00:00.500Z" },
{ id: "evt_noise", runId: "run_hwlab_adapter", seq: 3, type: "backend_status", payload: { phase: "thread/status/changed", commandId: "cmd_hwlab_adapter" }, createdAt: "2026-06-01T00:00:00.750Z" }
]),
{ id: second ? "evt_5" : "evt_2", runId: "run_hwlab_adapter", seq: second ? 5 : 4, type: "assistant_message", payload: { commandId: second ? "cmd_hwlab_adapter_second" : "cmd_hwlab_adapter", text: second ? "AgentRun adapter 复用已有 runner 完成第二轮。" : "AgentRun adapter 已接管 HWLAB Code Agent。" }, createdAt: "2026-06-01T00:00:01.000Z" },
@@ -356,7 +374,7 @@ test("cloud api /v1/agent/chat delegates v0.2 turns to AgentRun v0.1 over adapte
const commandTraceEvent = traceBody.events.find((event) => event.label === "item/commandExecution:completed");
assert.equal(commandTraceEvent.toolName, "commandExecution");
assert.equal(commandTraceEvent.createdAt, "2026-06-01T00:00:00.500Z");
assert.match(commandTraceEvent.command, /device-pod-cli\.ts profile list/u);
assert.match(commandTraceEvent.command, /hwpod profile list/u);
assert.match(commandTraceEvent.stdoutSummary, /profile\.list/u);
const secondTraceId = "trc_server-test-agentrun-adapter-second";
+64 -13
View File
@@ -151,38 +151,89 @@ async function codeAgentDevicePodAuthEnv(options = {}, env = process.env) {
if (typeof options.accessController?.createCodeAgentDevicePodSession !== "function") return {};
const auth = await options.accessController.createCodeAgentDevicePodSession();
if (!auth?.token) return {};
const runtimeNamespace = runtimeNamespaceForEnv(env);
const runtimeLane = runtimeLaneForNamespace(runtimeNamespace) ?? runtimeLaneForEnv(env);
const inClusterApiUrl = internalCodeAgentApiUrl(env, runtimeNamespace);
const apiUrl = firstNonEmptyValue(
codeAgentAgentRunAdapterEnabled(env) ? inClusterApiUrl : null,
env.HWLAB_RUNTIME_API_URL,
env.HWLAB_CODE_AGENT_DEVICE_POD_API_URL,
env.HWLAB_DEVICE_POD_API_URL,
env.HWLAB_CLOUD_API_URL,
codeAgentAgentRunAdapterEnabled(env) ? internalCodeAgentApiUrl(env) : null,
sameRuntimeEndpoint(env.HWLAB_DEVICE_POD_API_URL, runtimeNamespace, runtimeLane),
sameRuntimeEndpoint(env.HWLAB_CLOUD_API_URL, runtimeNamespace, runtimeLane),
env.HWLAB_PUBLIC_ENDPOINT,
localCloudApiUrl(env)
);
return {
HWLAB_DEVICE_POD_API_URL: apiUrl,
HWLAB_CLOUD_API_URL: apiUrl,
HWLAB_RUNTIME_API_URL: apiUrl,
HWLAB_RUNTIME_WEB_URL: apiUrl,
HWLAB_RUNTIME_NAMESPACE: runtimeNamespace,
HWLAB_RUNTIME_LANE: runtimeLane,
HWLAB_RUNTIME_ENDPOINT_SOURCE: codeAgentAgentRunAdapterEnabled(env) ? "runtime-namespace" : "runtime-env",
HWLAB_RUNTIME_ENDPOINT_LOCKED: "1",
HWLAB_CODE_AGENT_ASSEMBLED_RUNTIME: "1",
HWLAB_DEVICE_POD_SESSION_TOKEN: auth.token
};
}
function internalCodeAgentApiUrl(env = process.env) {
const namespace = firstNonEmptyValue(
env.HWLAB_RUNTIME_NAMESPACE,
env.POD_NAMESPACE,
env.HWLAB_NAMESPACE,
defaultRuntimeNamespace(env)
);
function internalCodeAgentApiUrl(env = process.env, namespace = runtimeNamespaceForEnv(env)) {
// Fetch/undici blocks cloud-api's 6667 as a bad port, so AgentRun runners use the internal web proxy.
const serviceName = firstNonEmptyValue(env.HWLAB_CODE_AGENT_INTERNAL_API_SERVICE_NAME, env.HWLAB_CLOUD_WEB_SERVICE_NAME, "hwlab-cloud-web");
const port = parsePositiveInteger(firstNonEmptyValue(env.HWLAB_CODE_AGENT_INTERNAL_API_PORT, env.HWLAB_CLOUD_WEB_PORT), 8080);
return `http://${serviceName}.${namespace}.svc.cluster.local:${port}`;
}
function runtimeNamespaceForEnv(env = process.env) {
return firstNonEmptyValue(
env.HWLAB_RUNTIME_NAMESPACE,
env.POD_NAMESPACE,
env.HWLAB_NAMESPACE,
defaultRuntimeNamespace(env)
);
}
function runtimeLaneForEnv(env = process.env) {
const profile = String(firstNonEmptyValue(env.HWLAB_RUNTIME_LANE, env.HWLAB_GITOPS_LANE, env.HWLAB_GITOPS_PROFILE, env.HWLAB_ENVIRONMENT, env.HWLAB_BOOT_REF, "dev") ?? "dev").trim().toLowerCase();
if (profile === "prod" || profile === "production") return "prod";
if (profile === "v02" || profile === "v0.2" || profile === "0.2") return "v02";
return "dev";
}
function runtimeLaneForNamespace(namespace) {
if (namespace === "hwlab-v02") return "v02";
if (namespace === "hwlab-prod") return "prod";
if (namespace === "hwlab-dev") return "dev";
return null;
}
function sameRuntimeEndpoint(value, namespace, lane) {
const text = firstNonEmptyValue(value);
if (!text) return null;
const endpointLane = runtimeLaneForEndpoint(text);
const endpointNamespace = runtimeNamespaceForEndpoint(text);
if (namespace && endpointNamespace && endpointNamespace !== namespace) return null;
if (lane && endpointLane && endpointLane !== lane) return null;
return text;
}
function runtimeNamespaceForEndpoint(value) {
const match = String(value ?? "").match(/\.((?:hwlab-[a-z0-9-]+))\.svc\.cluster\.local(?::|\/|$)/iu);
return match?.[1] ?? null;
}
function runtimeLaneForEndpoint(value) {
const text = String(value ?? "").toLowerCase();
if (/hwlab-v02|:19666(?:\/|$)|:19667(?:\/|$)/u.test(text)) return "v02";
if (/hwlab-prod|:18666(?:\/|$)|:18667(?:\/|$)/u.test(text)) return "prod";
if (/hwlab-dev|:17666(?:\/|$)|:17667(?:\/|$)|:16666(?:\/|$)|:16667(?:\/|$)/u.test(text)) return "dev";
return null;
}
function defaultRuntimeNamespace(env = process.env) {
const profile = String(firstNonEmptyValue(env.HWLAB_GITOPS_PROFILE, env.HWLAB_ENVIRONMENT, "dev") ?? "dev").trim().toLowerCase();
if (profile === "prod" || profile === "production") return "hwlab-prod";
if (profile === "v02" || profile === "v0.2" || profile === "0.2") return "hwlab-v02";
const profile = runtimeLaneForEnv(env);
if (profile === "prod") return "hwlab-prod";
if (profile === "v02") return "hwlab-v02";
return "hwlab-dev";
}
+44 -66
View File
@@ -24,55 +24,40 @@ Canonical skill location: `/app/skills/device-pod-cli/SKILL.md`. Do not create d
## Configuration
Pass the API base URL with one of these sources:
In HWLAB code-agent runners, do not pass API URLs or session tokens by hand. WEB / AgentRun assembles the runtime context and credential before the runner starts:
```sh
--api-base-url http://hwlab-cloud-api.hwlab-v02.svc.cluster.local:6667
HWLAB_DEVICE_POD_API_URL=http://hwlab-cloud-api.hwlab-v02.svc.cluster.local:6667
HWLAB_CLOUD_API_URL=http://hwlab-cloud-api.hwlab-v02.svc.cluster.local:6667
HWLAB_CLI_ENDPOINT=http://74.48.78.17:19667
HWLAB_RUNTIME_API_URL=<assembled-current-runtime-api-or-web-proxy>
HWLAB_RUNTIME_NAMESPACE=<assembled-current-namespace>
HWLAB_RUNTIME_LANE=<assembled-current-lane>
HWLAB_RUNTIME_ENDPOINT_LOCKED=1
HWLAB_DEVICE_POD_SESSION_TOKEN=<assembled-device-pod-session-token>
```
Pass authentication with one of these sources:
`hwpod` automatically locates cloud-api from the assembled runtime env and automatically uses `HWLAB_DEVICE_POD_SESSION_TOKEN`. When `HWLAB_RUNTIME_ENDPOINT_LOCKED=1`, passing `--api-base-url`, `--api-url`, `--cloud-api-url`, or `--base-url` is a hard error. This prevents stale trace examples or model guesses from overriding the current cluster/lane.
```sh
--session-token <token>
--cookie 'hwlab_session=<token>'
--bearer-token <token>
HWLAB_DEVICE_POD_SESSION_TOKEN=<token>
HWLAB_CLOUD_API_SESSION_TOKEN=<token>
HWLAB_SESSION_TOKEN=<token>
HWLAB_SESSION_COOKIE='hwlab_session=<token>'
```
Use `login` only when a username/password credential is explicitly available:
```sh
hwpod login --api-base-url <url> --username <user> --password <password>
```
The `setCookie` value in the JSON output can be passed back as `--cookie` or `HWLAB_SESSION_COOKIE`.
`--api-base-url` and `--session-token` are local-debug-only options outside locked HWLAB runners. Do not use them in normal DS / AgentRun / Cloud Workbench flows.
## Primary Commands
List visible Device Pods through cloud-api authority:
```sh
hwpod profile list --api-base-url <url> --session-token <token>
hwpod profile list
```
Show one visible Device Pod status and redacted server profile summary:
```sh
hwpod profile show --pod-id device-pod-71-freq --api-base-url <url> --session-token <token>
hwpod health --pod-id device-pod-71-freq --api-base-url <url> --session-token <token>
hwpod profile show --pod-id device-pod-71-freq
hwpod health --pod-id device-pod-71-freq
```
Start every Device Pod task or resumed context with `bootsharp` so the agent sees the current workspace tree and `AGENTS.md` through the formal REST path:
```sh
hwpod bootsharp --pod-id <devicePodId> --api-base-url <url> --session-token <token>
hwpod <devicePodId>:workspace:/ bootsharp --api-base-url <url> --session-token <token>
hwpod bootsharp --pod-id <devicePodId>
hwpod <devicePodId>:workspace:/ bootsharp
```
For the D601 F103 v2 board, the registered `devicePodId` is `D601-F103-V2`; its server-side profile points to `F:\Work\D601-HWLAB`, Keil project `projects/01_baseline/Projects/MDK-ARM/atk_f103.uvprojx`, target `USART`, `C:\Keil_v5\UV4\UV4.exe`, and UART `COM9` at `115200`.
@@ -80,25 +65,25 @@ For the D601 F103 v2 board, the registered `devicePodId` is `D601-F103-V2`; its
Diagnose auth, grant, lease, and gateway blockers without touching local profiles:
```sh
hwpod doctor --pod-id device-pod-71-freq --api-base-url <url> --session-token <token>
hwpod doctor --pod-id device-pod-71-freq
```
Acquire and inspect a lease for mutating jobs:
```sh
hwpod lease acquire --pod-id device-pod-71-freq --reason "build smoke" --api-base-url <url> --session-token <token>
hwpod lease current --pod-id device-pod-71-freq --api-base-url <url> --session-token <token>
hwpod lease release --pod-id device-pod-71-freq --lease-token <lease-token> --api-base-url <url> --session-token <token>
hwpod lease acquire --pod-id device-pod-71-freq --reason "build smoke"
hwpod lease current --pod-id device-pod-71-freq
hwpod lease release --pod-id device-pod-71-freq --lease-token <lease-token>
```
Run read-only workspace, debug, and I/O jobs:
```sh
hwpod device-pod-71-freq:workspace:/ ls --api-base-url <url> --session-token <token>
hwpod device-pod-71-freq:workspace:/src rg main --api-base-url <url> --session-token <token>
hwpod device-pod-71-freq:workspace:/ rg --pattern "Programming Done|Verify OK|Application running|Error" --path projects/01_baseline/captures --api-base-url <url> --session-token <token>
hwpod device-pod-71-freq:debug-probe chip-id --api-base-url <url> --session-token <token>
hwpod device-pod-71-freq:io-probe:/uart/1 read --duration-ms 5000 --api-base-url <url> --session-token <token>
hwpod device-pod-71-freq:workspace:/ ls
hwpod device-pod-71-freq:workspace:/src rg main
hwpod device-pod-71-freq:workspace:/ rg --pattern "Programming Done|Verify OK|Application running|Error" --path projects/01_baseline/captures
hwpod device-pod-71-freq:debug-probe chip-id
hwpod device-pod-71-freq:io-probe:/uart/1 read --duration-ms 5000
```
Run mutating jobs only with reason and lease token when required by cloud-api:
@@ -106,64 +91,53 @@ Run mutating jobs only with reason and lease token when required by cloud-api:
```sh
hwpod \
device-pod-71-freq:workspace:/ apply-patch \
--reason "DEV edit" --lease-token <lease-token> \
--api-base-url <url> --session-token <token> < patch.diff
--reason "DEV edit" --lease-token <lease-token> < patch.diff
hwpod \
device-pod-71-freq:workspace:/ put User/new.c \
--reason "DEV edit" --lease-token <lease-token> \
--api-base-url <url> --session-token <token> < file
--reason "DEV edit" --lease-token <lease-token> < file
hwpod \
device-pod-71-freq:workspace:/ rm User/old.c --missing-ok \
--reason "remove obsolete source" --lease-token <lease-token> \
--api-base-url <url> --session-token <token>
--reason "remove obsolete source" --lease-token <lease-token>
hwpod \
device-pod-71-freq:workspace:/ rmdir User/empty \
--reason "remove empty folder" --lease-token <lease-token> \
--api-base-url <url> --session-token <token>
--reason "remove empty folder" --lease-token <lease-token>
hwpod \
device-pod-71-freq:workspace:/ keil add-source User/new.c --group User \
--reason "sync Keil project" --lease-token <lease-token> \
--api-base-url <url> --session-token <token>
--reason "sync Keil project" --lease-token <lease-token>
hwpod \
device-pod-71-freq:workspace:/ keil remove-source User/old.c \
--reason "sync Keil project" --lease-token <lease-token> \
--api-base-url <url> --session-token <token>
--reason "sync Keil project" --lease-token <lease-token>
hwpod \
device-pod-71-freq:workspace:/ build start \
--reason "build smoke" --lease-token <lease-token> \
--api-base-url <url> --session-token <token>
--reason "build smoke" --lease-token <lease-token>
hwpod \
device-pod-71-freq:debug-probe download start \
--reason "DEV smoke" --lease-token <lease-token> \
--api-base-url <url> --session-token <token>
--reason "DEV smoke" --lease-token <lease-token>
hwpod \
device-pod-71-freq:debug-probe reset \
--reason "reset smoke" --lease-token <lease-token> \
--api-base-url <url> --session-token <token>
--reason "reset smoke" --lease-token <lease-token>
hwpod \
device-pod-71-freq:io-probe:/uart/1 write "AT" \
--reason "UART write smoke" --lease-token <lease-token> \
--api-base-url <url> --session-token <token>
--reason "UART write smoke" --lease-token <lease-token>
hwpod \
device-pod-71-freq:io-probe:/uart/1 jsonrpc gpio.read \
--params-json '{"pin":"PB5"}' --expect-result-field value \
--reason "UART JSON-RPC smoke" --lease-token <lease-token> \
--api-base-url <url> --session-token <token>
--reason "UART JSON-RPC smoke" --lease-token <lease-token>
```
Inspect or cancel jobs through cloud-api:
```sh
hwpod job status --pod-id device-pod-71-freq <jobId> --api-base-url <url> --session-token <token>
hwpod job output --pod-id device-pod-71-freq <jobId> --api-base-url <url> --session-token <token>
hwpod job cancel --pod-id device-pod-71-freq <jobId> --api-base-url <url> --session-token <token>
hwpod job status --pod-id device-pod-71-freq <jobId>
hwpod job output --pod-id device-pod-71-freq <jobId>
hwpod job cancel --pod-id device-pod-71-freq <jobId>
```
Selector aliases also support job status/output/cancel after build/download:
```sh
hwpod device-pod-71-freq:workspace:/ build status <jobId> --api-base-url <url> --session-token <token>
hwpod device-pod-71-freq:debug-probe download output <jobId> --api-base-url <url> --session-token <token>
hwpod device-pod-71-freq:workspace:/ build status <jobId>
hwpod device-pod-71-freq:debug-probe download output <jobId>
```
Use the canonical `hwpod job output --pod-id <devicePodId> <jobId>` form when in doubt. Selector aliases are convenience forms only for cloud-api job ids such as `job_devicepod_*`; host-side Keil subjobs still use `build status <hostJobId>` or `download status <hostJobId>` as nested device-host actions.
@@ -203,11 +177,11 @@ Do not upload or recreate runner-side `.device-pod/*.json` as the formal profile
## T1
阅读 `/app/skills/device-pod-cli/SKILL.md`,然后用 cli 手动测试以下内容:在未设置 API URL 时运行 `profile list`,确认输出 JSON 且错误码为 `api_base_url_required`
阅读 `/app/skills/device-pod-cli/SKILL.md`,然后用 cli 手动测试以下内容:在未设置 `HWLAB_RUNTIME_*` / `HWLAB_DEVICE_POD_*` 装配环境时运行 `profile list`,确认输出 JSON 且错误码为 `runtime_endpoint_required`,没有静默 fallback 到旧 DEV 入口
## T2
阅读 `/app/skills/device-pod-cli/SKILL.md`,然后用 cli 手动测试以下内容:设置 `HWLAB_DEVICE_POD_API_URL` 和有效 session 后运行 `profile list``profile show --pod-id <devicePodId>``doctor --pod-id <devicePodId>`,确认输出来自 cloud-api authority,并且没有泄露 `gatewaySessionId``hostWorkspaceRoot`、probe UID 或 UART port。
阅读 `/app/skills/device-pod-cli/SKILL.md`,然后用 cli 手动测试以下内容:在 WEB / AgentRun 已装配 `HWLAB_RUNTIME_API_URL``HWLAB_RUNTIME_NAMESPACE``HWLAB_RUNTIME_LANE``HWLAB_RUNTIME_ENDPOINT_LOCKED=1``HWLAB_DEVICE_POD_SESSION_TOKEN` 的真实 runner 中运行 `profile list``profile show --pod-id <devicePodId>``doctor --pod-id <devicePodId>`,确认 CLI 自动定位当前 runtime输出来自 cloud-api authority,并且没有泄露 `gatewaySessionId``hostWorkspaceRoot`、probe UID 或 UART port。
## T3
@@ -220,3 +194,7 @@ Do not upload or recreate runner-side `.device-pod/*.json` as the formal profile
## T5
阅读 `/app/skills/device-pod-cli/SKILL.md`,然后用 cli 手动测试以下内容:运行 `workspace put/rm/rmdir/keil``io-probe jsonrpc``--dry-run`,确认 JSON plan 中 intent 分别为 `workspace.put``workspace.rm``workspace.rmdir``workspace.keil``io.uart.jsonrpc`,且 `contentB64``text``patch` 不以原文泄露。
## T6
阅读 `/app/skills/device-pod-cli/SKILL.md`,然后用 cli 手动测试以下内容:在 `HWLAB_RUNTIME_ENDPOINT_LOCKED=1` 的真实 runner 中额外传入 `--api-base-url``--api-url``--cloud-api-url``--base-url`,确认返回 `runtime_endpoint_manual_url_forbidden`,从而证明 DS、旧 trace 或人工命令不能覆盖 WEB / AgentRun 装配的当前集群路径。
+69 -46
View File
@@ -6,11 +6,23 @@ import { test } from "bun:test";
import { runDevicePodCli } from "./src/device-pod-cli-lib.ts";
const ASSEMBLED_RUNTIME_ENV = {
HWLAB_RUNTIME_API_URL: "http://cloud.test",
HWLAB_RUNTIME_NAMESPACE: "hwlab-v02",
HWLAB_RUNTIME_LANE: "v02",
HWLAB_RUNTIME_ENDPOINT_LOCKED: "1",
HWLAB_CODE_AGENT_ASSEMBLED_RUNTIME: "1",
HWLAB_DEVICE_POD_SESSION_TOKEN: "session-a"
};
function runAssembledDevicePodCli(argv: string[], options: any = {}) {
return runDevicePodCli(argv, { ...options, env: { ...ASSEMBLED_RUNTIME_ENV, ...(options.env ?? {}) } });
}
test("device-pod-cli first-admin setup can seed a cloud-api device pod without local profile authority", async () => {
const seen: any[] = [];
const profile = { schemaVersion: 1, target: { id: "target-71-freq" }, route: { gatewaySessionId: "gws_admin_seed" } };
const result = await runDevicePodCli([
"--api-base-url", "http://cloud.test",
const result = await runAssembledDevicePodCli([
"--username", "admin",
"--password", "admin-pass",
"--pod-id", "device-pod-71-freq",
@@ -42,9 +54,7 @@ test("device-pod-cli admin wrappers call cloud-api profile and grant routes", as
seen.push({ url: String(url), init, body: JSON.parse(String(init?.body ?? "{}")) });
return jsonResponse(201, { ok: true });
};
const upsert = await runDevicePodCli([
"--api-base-url", "http://cloud.test",
"--session-token", "session-a",
const upsert = await runAssembledDevicePodCli([
"--pod-id", "device-pod-71-freq",
"--profile-json", JSON.stringify(profile),
"admin",
@@ -56,9 +66,7 @@ test("device-pod-cli admin wrappers call cloud-api profile and grant routes", as
assert.equal(seen[0].init.headers["x-hwlab-session-token"], "session-a");
assert.deepEqual(seen[0].body, { devicePodId: "device-pod-71-freq", profile });
const grant = await runDevicePodCli([
"--api-base-url", "http://cloud.test",
"--session-token", "session-a",
const grant = await runAssembledDevicePodCli([
"--pod-id", "device-pod-71-freq",
"--user-id", "usr_alice",
"admin",
@@ -75,7 +83,7 @@ test("device-pod-cli lists cloud-api authority and ignores local profile routes"
await mkdir(profileDir, { recursive: true });
await writeFile(path.join(profileDir, "device-pod-71-freq.json"), JSON.stringify({ gatewaySessionId: "gws_local_should_not_be_read", hostWorkspaceRoot: "F:\\legacy" }), "utf8");
const seen: any[] = [];
const result = await runDevicePodCli(["--api-base-url", "http://cloud.test", "--session-token", "session-a", "profile", "list"], {
const result = await runAssembledDevicePodCli(["profile", "list"], {
env: { DEVICE_POD_PROFILE_DIR: profileDir },
fetchImpl: async (url, init) => {
seen.push({ url: String(url), init });
@@ -91,9 +99,46 @@ test("device-pod-cli lists cloud-api authority and ignores local profile routes"
assert.equal(JSON.stringify(result.payload).includes("gws_local_should_not_be_read"), false);
});
test("device-pod-cli auto-locates from assembled runtime and rejects manual URL in locked runners", async () => {
const ok = await runAssembledDevicePodCli(["profile", "list"], {
fetchImpl: async (url, init) => {
assert.equal(String(url), "http://cloud.test/v1/device-pods");
assert.equal(init?.headers?.["x-hwlab-session-token"], "session-a");
return jsonResponse(200, { ok: true, devicePods: [] });
},
now: () => "2026-06-01T00:00:00.000Z"
});
assert.equal(ok.exitCode, 0);
assert.equal(ok.payload.apiBaseUrl, "http://cloud.test");
assert.equal(ok.payload.runtimeEndpoint.source, "runtime-env");
assert.equal(ok.payload.runtimeEndpoint.explicitOverride, false);
const rejected = await runAssembledDevicePodCli(["--api-base-url", "http://74.48.78.17:17667", "profile", "list"], {
fetchImpl: async () => jsonResponse(500, { ok: false }),
now: () => "2026-06-01T00:00:00.000Z"
});
assert.equal(rejected.exitCode, 1);
assert.equal(rejected.payload.error.code, "runtime_endpoint_manual_url_forbidden");
});
test("device-pod-cli keeps manual API URL only as unlocked local debug override", async () => {
const seen: string[] = [];
const result = await runDevicePodCli(["--api-base-url", "http://debug.test", "--session-token", "debug-session", "profile", "list"], {
fetchImpl: async (url, init) => {
seen.push(String(url));
assert.equal(init?.headers?.["x-hwlab-session-token"], "debug-session");
return jsonResponse(200, { ok: true, devicePods: [] });
},
now: () => "2026-06-01T00:00:00.000Z"
});
assert.equal(result.exitCode, 0);
assert.equal(seen[0], "http://debug.test/v1/device-pods");
assert.equal(result.payload.runtimeEndpoint.source, "explicit-override");
});
test("device-pod-cli selector creates REST job instead of gateway RPC", async () => {
const seen: any[] = [];
const result = await runDevicePodCli(["--api-base-url", "http://cloud.test", "--session-token", "session-a", "device-pod-71-freq:workspace:/src", "ls"], {
const result = await runAssembledDevicePodCli(["device-pod-71-freq:workspace:/src", "ls"], {
fetchImpl: async (url, init) => {
seen.push({ url: String(url), init, body: JSON.parse(String(init?.body ?? "{}")) });
return jsonResponse(202, { ok: true, status: "running", job: { id: "job_1", status: "running" }, profileHash: "sha256:abc" });
@@ -108,7 +153,7 @@ test("device-pod-cli selector creates REST job instead of gateway RPC", async ()
test("device-pod-cli bootsharp creates a formal workspace bootsharp REST job", async () => {
const seen: any[] = [];
const result = await runDevicePodCli(["--api-base-url", "http://cloud.test", "--session-token", "session-a", "bootsharp", "--pod-id", "D601-F103-V2", "--depth", "2"], {
const result = await runAssembledDevicePodCli(["bootsharp", "--pod-id", "D601-F103-V2", "--depth", "2"], {
fetchImpl: async (url, init) => {
seen.push({ url: String(url), init, body: JSON.parse(String(init?.body ?? "{}")) });
return jsonResponse(202, { ok: true, status: "running", job: { id: "job_bootsharp", status: "running" }, profileHash: "sha256:abc" });
@@ -138,9 +183,7 @@ test("device-pod-cli job output is compact by default and full with --full", asy
}
};
const fetchImpl = async () => jsonResponse(200, payload);
const compact = await runDevicePodCli([
"--api-base-url", "http://cloud.test",
"--session-token", "session-a",
const compact = await runAssembledDevicePodCli([
"job", "output",
"--pod-id", "D601-F103-V2",
"job_1"
@@ -150,9 +193,7 @@ test("device-pod-cli job output is compact by default and full with --full", asy
assert.equal(compact.payload.body.text.length, 4000);
assert.equal(JSON.stringify(compact.payload).includes("verbose command"), false);
const full = await runDevicePodCli([
"--api-base-url", "http://cloud.test",
"--session-token", "session-a",
const full = await runAssembledDevicePodCli([
"--full",
"job", "output",
"--pod-id", "D601-F103-V2",
@@ -165,7 +206,7 @@ test("device-pod-cli job output is compact by default and full with --full", asy
test("device-pod-cli sends reason and lease token for mutating jobs", async () => {
const seen: any[] = [];
const result = await runDevicePodCli(["--api-base-url", "http://cloud.test", "--session-token", "session-a", "--lease-token", "lease-a", "--reason", "reset smoke", "device-pod-71-freq:debug-probe", "reset"], {
const result = await runAssembledDevicePodCli(["--lease-token", "lease-a", "--reason", "reset smoke", "device-pod-71-freq:debug-probe", "reset"], {
fetchImpl: async (url, init) => {
seen.push({ url: String(url), body: JSON.parse(String(init?.body ?? "{}")) });
return jsonResponse(202, { ok: true, status: "running", job: { id: "job_reset", status: "running" } });
@@ -178,9 +219,7 @@ test("device-pod-cli sends reason and lease token for mutating jobs", async () =
test("device-pod-cli preserves common v0.1 hardware options in REST job args", async () => {
const seen: any[] = [];
const result = await runDevicePodCli([
"--api-base-url", "http://cloud.test",
"--session-token", "session-a",
const result = await runAssembledDevicePodCli([
"--reason", "boot log",
"--lease-token", "lease-a",
"--capture-uart", "uart/1",
@@ -213,9 +252,7 @@ test("device-pod-cli exposes workspace writes, keil source edits, and UART JSON-
return jsonResponse(202, { ok: true, status: "running", job: { id: `job_${seen.length}`, status: "running" } });
};
const put = await runDevicePodCli([
"--api-base-url", "http://cloud.test",
"--session-token", "session-a",
const put = await runAssembledDevicePodCli([
"--reason", "write file",
"--lease-token", "lease-a",
"device-pod-71-freq:workspace:/projects/app",
@@ -229,9 +266,7 @@ test("device-pod-cli exposes workspace writes, keil source edits, and UART JSON-
assert.equal(seen[0].body.args.createDirs, true);
assert.equal(Buffer.from(seen[0].body.args.contentB64, "base64").toString("utf8"), "int main(void) { return 0; }\n");
const keil = await runDevicePodCli([
"--api-base-url", "http://cloud.test",
"--session-token", "session-a",
const keil = await runAssembledDevicePodCli([
"--reason", "add source",
"--lease-token", "lease-a",
"device-pod-71-freq:workspace:/projects/app",
@@ -243,9 +278,7 @@ test("device-pod-cli exposes workspace writes, keil source edits, and UART JSON-
assert.equal(keil.exitCode, 0);
assert.deepEqual(seen[1].body.args, { action: "add-source", base: "projects/app", path: "User/new.c", group: "User" });
const jsonrpc = await runDevicePodCli([
"--api-base-url", "http://cloud.test",
"--session-token", "session-a",
const jsonrpc = await runAssembledDevicePodCli([
"--reason", "gpio read",
"--lease-token", "lease-a",
"device-pod-71-freq:io-probe:/uart/1",
@@ -270,7 +303,7 @@ test("device-pod-cli rejects legacy local profile create in formal mode", async
test("device-pod-cli keeps debug-probe status as a probe operation, not job status", async () => {
const seen: any[] = [];
const result = await runDevicePodCli(["--api-base-url", "http://cloud.test", "--session-token", "session-a", "D601-F103-V2:debug-probe", "status"], {
const result = await runAssembledDevicePodCli(["D601-F103-V2:debug-probe", "status"], {
fetchImpl: async (url, init) => {
seen.push({ url: String(url), init });
return jsonResponse(200, { ok: true, devicePodId: "D601-F103-V2", status: "ok" });
@@ -288,9 +321,7 @@ test("device-pod-cli forwards host build and download job ids as named device-po
seen.push({ url: String(url), body: JSON.parse(String(init?.body ?? "{}")) });
return jsonResponse(202, { ok: true, status: "running", job: { id: `job_${seen.length}`, status: "running" } });
};
const build = await runDevicePodCli([
"--api-base-url", "http://cloud.test",
"--session-token", "session-a",
const build = await runAssembledDevicePodCli([
"D601-F103-V2:workspace:/",
"build",
"status",
@@ -302,9 +333,7 @@ test("device-pod-cli forwards host build and download job ids as named device-po
args: { action: "status", jobId: "20260531101208650-keil-build-030aab" }
});
const download = await runDevicePodCli([
"--api-base-url", "http://cloud.test",
"--session-token", "session-a",
const download = await runAssembledDevicePodCli([
"D601-F103-V2:debug-probe",
"download",
"status",
@@ -324,9 +353,7 @@ test("device-pod-cli maps selector build/download cloud job aliases to job route
return jsonResponse(200, { ok: true, status: "completed", output: { text: "done" } });
};
const build = await runDevicePodCli([
"--api-base-url", "http://cloud.test",
"--session-token", "session-a",
const build = await runAssembledDevicePodCli([
"D601-F103-V2:workspace:/",
"build",
"status",
@@ -336,9 +363,7 @@ test("device-pod-cli maps selector build/download cloud job aliases to job route
assert.equal(build.payload.action, "job.status");
assert.equal(seen[0].url, "http://cloud.test/v1/device-pods/D601-F103-V2/jobs/job_devicepod_build");
const download = await runDevicePodCli([
"--api-base-url", "http://cloud.test",
"--session-token", "session-a",
const download = await runAssembledDevicePodCli([
"D601-F103-V2:debug-probe",
"download",
"output",
@@ -351,9 +376,7 @@ test("device-pod-cli maps selector build/download cloud job aliases to job route
test("device-pod-cli accepts explicit workspace rg pattern and path options", async () => {
const seen: any[] = [];
const result = await runDevicePodCli([
"--api-base-url", "http://cloud.test",
"--session-token", "session-a",
const result = await runAssembledDevicePodCli([
"D601-F103-V2:workspace:/",
"rg",
"--pattern",
+20 -4
View File
@@ -134,6 +134,22 @@ test("hwlab-cli client group help is visible and does not issue HTTP requests",
assert.equal(fetchCount, 0);
});
test("hwlab-cli rejects manual endpoint override in locked assembled runtime", async () => {
const result = await runHwlabCli(["client", "auth", "status", "--base-url", "http://74.48.78.17:17666"], {
env: {
HWLAB_RUNTIME_WEB_URL: "http://cloud-v02.test",
HWLAB_RUNTIME_NAMESPACE: "hwlab-v02",
HWLAB_RUNTIME_LANE: "v02",
HWLAB_RUNTIME_ENDPOINT_LOCKED: "1",
HWLAB_CODE_AGENT_ASSEMBLED_RUNTIME: "1"
}
});
assert.equal(result.exitCode, 1);
assert.equal(result.payload.error.code, "runtime_endpoint_manual_url_forbidden");
assert.equal(result.payload.error.details.kind, "web");
});
test("hwlab-cli client device-pods events compacts event stream by default", async () => {
const result = await runHwlabCli(["client", "device-pods", "events", "pod-1", "--base-url", "http://web.test", "--cookie", "hwlab_session=session-a"], {
fetchImpl: async () => new Response(JSON.stringify({
@@ -489,7 +505,7 @@ test("hwlab-cli Web trace render reports suppressed noise and command tool detai
events: [
{ traceId: "trc_render_agentrun", seq: 1, label: "agentrun:backend:command-created", status: "running", type: "backend", createdAt: "2026-06-01T13:00:00.000Z" },
{ traceId: "trc_render_agentrun", seq: 2, label: "agentrun:backend:thread/status/changed", status: "running", type: "backend", createdAt: "2026-06-01T13:00:01.000Z" },
{ traceId: "trc_render_agentrun", seq: 3, label: "item/commandExecution:completed", type: "tool_call", toolName: "commandExecution", status: "completed", itemId: "call_1", command: "/bin/sh -lc 'bun tools/device-pod-cli.ts profile list --api-base-url http://74.48.78.17:19666'", exitCode: 0, stdoutSummary: '{"ok":true,"action":"profile.list"}', createdAt: "2026-06-01T13:00:02.000Z" },
{ traceId: "trc_render_agentrun", seq: 3, label: "item/commandExecution:completed", type: "tool_call", toolName: "commandExecution", status: "completed", itemId: "call_1", command: "/bin/sh -lc 'hwpod profile list'", exitCode: 0, stdoutSummary: '{"ok":true,"action":"profile.list"}', createdAt: "2026-06-01T13:00:02.000Z" },
{ traceId: "trc_render_agentrun", seq: 4, label: "agentrun:assistant:message", type: "assistant", status: "running", message: "当前可见 device-pod。", createdAt: "2026-06-01T13:00:03.000Z" }
]
}), { status: 200 })
@@ -501,7 +517,7 @@ test("hwlab-cli Web trace render reports suppressed noise and command tool detai
assert.equal(result.payload.body.noiseEventCount, 2);
const text = JSON.stringify(result.payload.body.rows);
assert.equal(text.includes("thread/status/changed"), false);
assert.match(text, /device-pod-cli\.ts profile list/u);
assert.match(text, /hwpod profile list/u);
assert.match(text, /profile\.list/u);
assert.match(text, / device-pod/u);
});
@@ -808,8 +824,8 @@ test("hwlab-cli client harness audit treats hwpod bootsharp flow as clean", asyn
fetchImpl: async () => new Response(JSON.stringify({
traceId: "trc_hwpod",
events: [
{ type: "tool_call", command: "hwpod bootsharp --pod-id D601-F103-V2 --api-base-url http://api.test --session-token session-a" },
{ type: "tool_call", command: "hwpod D601-F103-V2:workspace:/ build status job_123 --api-base-url http://api.test --session-token session-a" }
{ type: "tool_call", command: "hwpod bootsharp --pod-id D601-F103-V2" },
{ type: "tool_call", command: "hwpod D601-F103-V2:workspace:/ build status job_123" }
]
}), { status: 200 })
});
+28 -17
View File
@@ -1,3 +1,5 @@
import { resolveRuntimeEndpoint, runtimeEndpointVisibility } from "./runtime-endpoint-resolver.ts";
const VERSION = "0.2.0-rest";
const CLI_NAME = "device-pod-cli";
const DEFAULT_TIMEOUT_MS = 30000;
@@ -52,26 +54,22 @@ function help() {
profileAuthority: "hwlab-cloud-api",
localProfileAuthority: false,
configuration: {
apiBaseUrl: "--api-base-url or HWLAB_DEVICE_POD_API_URL/HWLAB_CLOUD_API_URL",
auth: "--session-token, --cookie, --bearer-token, or matching HWLAB_* env"
apiBaseUrl: "auto-located from assembled HWLAB_RUNTIME_* / HWLAB_DEVICE_POD_* env; manual endpoint arguments are rejected when HWLAB_RUNTIME_ENDPOINT_LOCKED=1",
auth: "assembled HWLAB_DEVICE_POD_SESSION_TOKEN, --session-token for local debug, --cookie, --bearer-token, or matching HWLAB_* env"
},
usage: [
"hwpod login --api-base-url URL --username USER --password PASS",
"hwpod setup first-admin --api-base-url URL --username USER --password PASS --pod-id device-pod-71-freq --profile-json JSON",
"hwpod admin device-pod upsert --api-base-url URL --session-token TOKEN --pod-id device-pod-71-freq --profile-json JSON",
"hwpod admin grant --api-base-url URL --session-token TOKEN --pod-id device-pod-71-freq --user-id USER_ID",
"hwpod profile list --api-base-url URL --session-token TOKEN",
"hwpod profile show --pod-id device-pod-71-freq --api-base-url URL --session-token TOKEN",
"hwpod bootsharp --pod-id D601-F103-V2 --api-base-url URL --session-token TOKEN",
"hwpod D601-F103-V2:workspace:/ bootsharp --api-base-url URL --session-token TOKEN",
"hwpod device-pod-71-freq:workspace:/ ls --api-base-url URL --session-token TOKEN",
"hwpod profile list",
"hwpod profile show --pod-id device-pod-71-freq",
"hwpod bootsharp --pod-id D601-F103-V2",
"hwpod D601-F103-V2:workspace:/ bootsharp",
"hwpod device-pod-71-freq:workspace:/ ls",
"hwpod device-pod-71-freq:workspace:/ put User/new.c --reason TEXT --lease-token TOKEN < file",
"hwpod device-pod-71-freq:workspace:/ rmdir User/empty --reason TEXT --lease-token TOKEN",
"hwpod device-pod-71-freq:workspace:/ keil add-source User/new.c --group User --reason TEXT --lease-token TOKEN",
"hwpod device-pod-71-freq:workspace:/ build start --reason TEXT --lease-token TOKEN",
"hwpod device-pod-71-freq:io-probe:/uart/1 jsonrpc gpio.read --params-json '{\"pin\":\"PB5\'} --reason TEXT --lease-token TOKEN",
"hwpod job output --pod-id device-pod-71-freq <jobId> --api-base-url URL --session-token TOKEN",
"hwpod lease acquire --pod-id device-pod-71-freq --reason TEXT --api-base-url URL --session-token TOKEN"
"hwpod job output --pod-id device-pod-71-freq <jobId>",
"hwpod lease acquire --pod-id device-pod-71-freq --reason TEXT"
],
startupProbe: "Run bootsharp first after selecting a Device Pod or resuming context; it returns workspace tree and AGENTS.md hints through the formal REST job path."
});
@@ -367,10 +365,12 @@ function workspaceRgPattern(parsed: ParsedArgs, positional: unknown) {
}
async function requestJson({ parsed, env, fetchImpl, method, path, body, auth = true, extraHeaders = {} }: any) {
const baseUrl = apiBaseUrl(parsed, env);
const endpoint = resolveRuntimeEndpoint({ kind: "api", parsed, env });
const baseUrl = endpoint.baseUrl;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), numberOption(parsed.timeoutMs) ?? DEFAULT_TIMEOUT_MS);
try {
const url = `${baseUrl}${path}`;
const response = await fetchImpl(`${baseUrl}${path}`, {
method,
headers: clean({ accept: "application/json", ...(body ? { "content-type": "application/json" } : {}), ...(auth ? authHeaders(parsed, env) : {}), ...extraHeaders }),
@@ -378,7 +378,7 @@ async function requestJson({ parsed, env, fetchImpl, method, path, body, auth =
signal: controller.signal
});
const textBody = await response.text();
return { status: response.status, headers: response.headers, body: parseJson(textBody) };
return { status: response.status, headers: response.headers, body: parseJson(textBody), url, method, path, runtimeEndpoint: endpoint };
} finally {
clearTimeout(timeout);
}
@@ -466,7 +466,6 @@ const uartJsonRpcOptionKeys = [
"baudRate"
];
function defaultOperation(surface: string) { return surface === "workspace" ? "ls" : surface === "debug-probe" ? "status" : "read"; }
function apiBaseUrl(parsed: ParsedArgs, env: EnvLike) { const value = text(parsed.apiBaseUrl ?? parsed.apiUrl ?? env.HWLAB_DEVICE_POD_API_URL ?? env.HWLAB_CLOUD_API_URL); if (!value) throw cliError("api_base_url_required", "hwpod requires --api-base-url or HWLAB_DEVICE_POD_API_URL/HWLAB_CLOUD_API_URL"); return value.replace(/\/+$/u, ""); }
function authHeaders(parsed: ParsedArgs, env: EnvLike) { const cookie = text(parsed.cookie ?? env.HWLAB_SESSION_COOKIE); const sessionToken = text(parsed.sessionToken ?? env.HWLAB_DEVICE_POD_SESSION_TOKEN ?? env.HWLAB_CLOUD_API_SESSION_TOKEN ?? env.HWLAB_SESSION_TOKEN); const bearer = text(parsed.bearerToken ?? env.HWLAB_BEARER_TOKEN); return clean({ ...(cookie ? { cookie: cookie.includes("=") ? cookie : `hwlab_session=${encodeURIComponent(cookie)}` } : {}), ...(sessionToken ? { "x-hwlab-session-token": sessionToken } : {}), ...(bearer ? { authorization: `Bearer ${bearer}` } : {}) }); }
function responsePayload(action: string, response: any, extra: Record<string, unknown> = {}, parsed: ParsedArgs = { _: [] }) {
const success = response.status >= 200 && response.status < 300 && response.body?.ok !== false;
@@ -475,7 +474,18 @@ function responsePayload(action: string, response: any, extra: Record<string, un
const cleanExtra = { ...extra };
delete cleanExtra.compactKind;
const body = !full && compactKind === "device-job-output" ? compactJobOutputBody(response.body) : response.body;
return { ok: success, action, status: success ? "succeeded" : "failed", httpStatus: response.status, ...cleanExtra, body, ...(full ? { full: true } : {}) };
return {
ok: success,
action,
status: success ? "succeeded" : "failed",
apiBaseUrl: response.runtimeEndpoint?.baseUrl,
runtimeEndpoint: runtimeEndpointVisibility(response.runtimeEndpoint),
httpStatus: response.status,
request: pruneUndefined({ method: response.method, path: response.path, url: response.url }),
...cleanExtra,
body,
...(full ? { full: true } : {})
};
}
function withMeta(payload: any, now: () => string) { return { generatedAt: now(), cli: CLI_NAME, version: VERSION, ...payload }; }
function ok(action: string, data: Record<string, unknown> = {}, status = "succeeded") { return { ok: true, action, status, ...data }; }
@@ -497,6 +507,7 @@ function requiredText(value: unknown, field: string) { const valueText = text(va
function text(value: unknown) { return String(value ?? "").trim(); }
function numberOption(value: unknown) { const parsed = Number.parseInt(String(value ?? ""), 10); return Number.isFinite(parsed) ? parsed : undefined; }
function clean<T extends Record<string, any>>(value: T): T { return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined && item !== "" && item !== false)) as T; }
function pruneUndefined<T extends Record<string, unknown>>(value: T): T { return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined)) as T; }
function arrayOption(value: unknown) { return (Array.isArray(value) ? value : value === undefined ? [] : [value]).map((item) => text(item)).filter(Boolean); }
function devicePodSeedFromOptions(parsed: ParsedArgs, { required = false } = {}) {
const devicePodText = text(parsed.devicePodJson ?? parsed.devicePod);
+58 -48
View File
@@ -2,10 +2,10 @@ import { createHash } from "node:crypto";
import { chmod, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import { traceDisplayRows, traceNoiseEventCount } from "../../web/hwlab-cloud-web/app-trace.ts";
import { resolveRuntimeEndpoint, runtimeEndpointVisibility } from "./runtime-endpoint-resolver.ts";
const VERSION = "0.2.0-client";
const CLI_NAME = "hwlab-cli";
const DEFAULT_BASE_URL = "http://74.48.78.17:19666";
const DEFAULT_TIMEOUT_MS = 30000;
const DEFAULT_AGENT_TIMEOUT_MS = 120000;
const DEFAULT_POLL_INTERVAL_MS = 1000;
@@ -96,14 +96,18 @@ function help() {
version: VERSION,
contractVersion: "hwlab-web-equivalent-client-v1",
mode: "short-connection-client",
defaultBaseUrl: DEFAULT_BASE_URL,
endpointResolver: {
defaultBaseUrl: null,
behavior: "fail-closed unless HWLAB_RUNTIME_* env or HWLAB_RUNTIME_NAMESPACE resolves the current lane",
explicitOverride: "local-debug-only; forbidden when HWLAB_RUNTIME_ENDPOINT_LOCKED=1"
},
stateFile: ".state/hwlab-cli/session.json",
serviceRuntime: false,
imagePublished: false,
jobTemplate: false,
usage: [
"hwlab-cli client auth login --base-url URL --username USER --password-env HWLAB_PASSWORD",
"hwlab-cli client auth login --profile NAME --base-url URL --username USER --password-env HWLAB_PASSWORD",
"HWLAB_RUNTIME_NAMESPACE=hwlab-v02 hwlab-cli client auth login --username USER --password-env HWLAB_PASSWORD",
"HWLAB_RUNTIME_NAMESPACE=hwlab-v02 hwlab-cli client auth login --profile NAME --username USER --password-env HWLAB_PASSWORD",
"hwlab-cli client auth status",
"hwlab-cli client auth session",
"hwlab-cli client auth profiles",
@@ -111,7 +115,7 @@ function help() {
"hwlab-cli client device-pods status device-pod-71-freq",
"hwlab-cli client runtime routes",
"hwlab-cli client gateway sessions",
"hwlab-cli client gateway pressure --gateway-session-id gws_D601_F103 --api-base-url http://API:19667",
"hwlab-cli client gateway pressure --gateway-session-id gws_D601_F103",
"hwlab-cli client workbench summary --pod-id device-pod-71-freq",
"hwlab-cli client workbench restore --profile NAME",
"hwlab-cli client workbench status --profile NAME",
@@ -156,27 +160,29 @@ function authHelp() {
serviceRuntime: false,
imagePublished: false,
commands: [
"login --base-url URL --username USER --password-env HWLAB_PASSWORD [--profile NAME]",
"status [--base-url URL] [--profile NAME]",
"session [--base-url URL] [--profile NAME]",
"profiles [--base-url URL]",
"logout [--base-url URL] [--profile NAME]"
"login --username USER --password-env HWLAB_PASSWORD [--profile NAME]",
"status [--profile NAME]",
"session [--profile NAME]",
"profiles",
"logout [--profile NAME]"
]
});
}
async function authStatus(context: any) {
const endpoint = runtimeEndpoint(context.parsed, context.env, "web");
const localSession = sessionStateSummary(await readSessionState({ parsed: context.parsed, env: context.env, cwd: context.cwd ?? process.cwd() }));
return ok("client.auth.status", {
baseUrl: baseUrl(context.parsed, context.env),
baseUrl: endpoint.baseUrl,
runtimeEndpoint: runtimeEndpointVisibility(endpoint),
stateFile: localSession.stateFile,
localSession,
nextCommands: authNextCommands({ baseUrl: baseUrl(context.parsed, context.env), username: text(context.parsed.username ?? context.env.HWLAB_USERNAME) || "admin", stateFile: localSession.stateFile })
nextCommands: authNextCommands({ baseUrl: endpoint.baseUrl, username: text(context.parsed.username ?? context.env.HWLAB_USERNAME) || "admin", stateFile: localSession.stateFile })
});
}
async function authProfiles(context: any) {
const dir = profileStateDir(context.parsed, context.cwd ?? process.cwd());
const dir = profileStateDir(context.parsed, context.cwd ?? process.cwd(), context.env);
let entries: string[] = [];
try {
entries = await readdir(dir);
@@ -191,6 +197,7 @@ async function authProfiles(context: any) {
}
return ok("client.auth.profiles", {
baseUrl: baseUrl(context.parsed, context.env),
runtimeEndpoint: runtimeEndpointVisibility(runtimeEndpoint(context.parsed, context.env, "web")),
profileDir: dir,
profiles,
profileCount: profiles.length
@@ -206,7 +213,7 @@ async function authLogin(context: any) {
const success = isHttpSuccess(response) && response.body?.authenticated === true;
if (success && cookie) {
await saveSession(context, {
baseUrl: baseUrl(parsed, env),
baseUrl: response.runtimeEndpoint?.baseUrl ?? baseUrl(parsed, env),
cookie,
user: safeUser(response.body?.user ?? response.body?.actor),
expiresAt: textOrNull(response.body?.expiresAt),
@@ -249,8 +256,8 @@ function devicePodsHelp() {
serviceRuntime: false,
imagePublished: false,
commands: [
"list [--base-url URL]",
"status POD_ID [--base-url URL]",
"list",
"status POD_ID",
"events POD_ID [--limit N]",
"probe POD_ID [--max-bytes N]"
]
@@ -413,22 +420,15 @@ function gatewayHelp() {
serviceRuntime: false,
imagePublished: false,
commands: [
"sessions [--api-base-url URL|--base-url URL]",
"invoke --command CMD [--gateway-session-id ID] [--api-base-url URL]",
"pressure [--gateway-session-id ID] [--api-base-url URL] [--large-bytes N] [--parallel N]"
"sessions",
"invoke --command CMD [--gateway-session-id ID]",
"pressure [--gateway-session-id ID] [--large-bytes N] [--parallel N]"
]
});
}
function gatewayApiContext(context: any) {
const apiBaseUrl = text(
context.parsed.apiBaseUrl ??
context.parsed.cloudApiUrl ??
context.env.HWLAB_CLOUD_API_URL ??
context.env.HWLAB_CLOUD_API_BASE_URL
);
if (!apiBaseUrl) return context;
return { ...context, parsed: { ...context.parsed, baseUrl: apiBaseUrl } };
return { ...context, endpointKind: "api" };
}
async function gatewayPressure(context: any) {
@@ -457,7 +457,8 @@ async function gatewayPressure(context: any) {
const failures = results.filter((item) => item.ok !== true);
return ok("client.gateway.pressure", {
status: failures.length > 0 ? "failed" : "succeeded",
baseUrl: baseUrl(context.parsed, context.env),
baseUrl: baseUrl(context.parsed, context.env, "api"),
runtimeEndpoint: runtimeEndpointVisibility(runtimeEndpoint(context.parsed, context.env, "api")),
gatewaySessionId: gatewaySelector(context).gatewaySessionId,
scenarioCount: results.length,
failedCount: failures.length,
@@ -1008,7 +1009,8 @@ async function workbenchCommand(context: any) {
}
}));
const failed = probes.some((probe) => probe.ok === false);
return ok("client.workbench.summary", { status: failed ? "degraded" : "succeeded", baseUrl: baseUrl(context.parsed, context.env), devicePodId: podId, probes }, failed ? "degraded" : "succeeded");
const endpoint = runtimeEndpoint(context.parsed, context.env, "web");
return ok("client.workbench.summary", { status: failed ? "degraded" : "succeeded", baseUrl: endpoint.baseUrl, runtimeEndpoint: runtimeEndpointVisibility(endpoint), devicePodId: podId, probes }, failed ? "degraded" : "succeeded");
}
function workbenchHelp() {
@@ -1029,7 +1031,8 @@ async function workbenchRestoreCommand(context: any, subcommand: string) {
const workspace = await restoreWorkbenchWorkspace(context, { quiet: false });
return ok(`client.workbench.${subcommand}`, {
baseUrl: baseUrl(context.parsed, context.env),
stateFile: stateFile(context.parsed, context.cwd ?? process.cwd()),
runtimeEndpoint: runtimeEndpointVisibility(runtimeEndpoint(context.parsed, context.env, "web")),
stateFile: stateFile(context.parsed, context.cwd ?? process.cwd(), context.env),
workspace,
localSession: sessionStateSummary(await readSessionState({ parsed: context.parsed, env: context.env, cwd: context.cwd ?? process.cwd() }))
});
@@ -1194,7 +1197,8 @@ function normalizeRequestPath(value: string) {
async function requestJson(context: any) {
const { parsed, env, cwd, method, path: pathName, body, auth = true, extraHeaders = {}, timeoutMs } = context;
const resolvedBaseUrl = baseUrl(parsed, env);
const endpoint = runtimeEndpoint(parsed, env, context.endpointKind ?? "web");
const resolvedBaseUrl = endpoint.baseUrl;
const url = `${resolvedBaseUrl}${pathName}`;
const requestTimeoutMs = timeoutMs ?? numberOption(parsed.timeoutMs) ?? DEFAULT_TIMEOUT_MS;
const controller = new AbortController();
@@ -1213,7 +1217,7 @@ async function requestJson(context: any) {
required: effectiveAuth,
baseUrl: resolvedBaseUrl,
username: requestedUsername || sessionUsername || (explicitCookie ? undefined : "admin"),
stateFile: stateFile(parsed, cwd ?? process.cwd()),
stateFile: stateFile(parsed, cwd ?? process.cwd(), env),
localSession: sessionState ? sessionStateSummary(sessionState) : null,
cookieSource: cookie ? explicitCookie ? "explicit" : "state" : null,
autoLoginAttempted: false,
@@ -1242,7 +1246,7 @@ async function requestJson(context: any) {
response = await sendJsonRequest({ context, url, method, body, cookie, extraHeaders, signal: controller.signal, pathName, timeoutMs: requestTimeoutMs });
}
}
return { ...response, auth: authVisibility(authState), authDiagnosis: authDiagnosis(response, authState) };
return { ...response, runtimeEndpoint: endpoint, auth: authVisibility(authState), authDiagnosis: authDiagnosis(response, authState) };
} finally {
clearTimeout(timer);
}
@@ -1285,9 +1289,10 @@ async function autoLoginSession(context: any, signal: AbortSignal, { force = fal
const password = await optionalPasswordValue(context);
if (!password) return { status: "credentials_missing", cookie: null };
const username = text(context.parsed.username ?? context.env.HWLAB_USERNAME) || "admin";
const endpoint = runtimeEndpoint(context.parsed, context.env, context.endpointKind ?? "web");
const response = await sendJsonRequest({
context,
url: `${baseUrl(context.parsed, context.env)}/auth/login`,
url: `${endpoint.baseUrl}/auth/login`,
method: "POST",
pathName: "/auth/login",
body: { username, password },
@@ -1299,7 +1304,7 @@ async function autoLoginSession(context: any, signal: AbortSignal, { force = fal
const success = isHttpSuccess(response) && response.body?.authenticated === true && cookie;
if (success) {
await saveSession(context, {
baseUrl: baseUrl(context.parsed, context.env),
baseUrl: endpoint.baseUrl,
cookie,
user: safeUser(response.body?.user ?? response.body?.actor),
expiresAt: textOrNull(response.body?.expiresAt),
@@ -1343,8 +1348,12 @@ async function readStdin() {
return Buffer.concat(chunks).toString("utf8").trimEnd();
}
function baseUrl(parsed: ParsedArgs, env: EnvLike) {
return text(parsed.baseUrl ?? env.HWLAB_CLIENT_BASE_URL ?? env.HWLAB_CLOUD_WEB_URL ?? env.HWLAB_CLI_BASE_URL) || DEFAULT_BASE_URL;
function baseUrl(parsed: ParsedArgs, env: EnvLike, kind: "web" | "api" = "web") {
return runtimeEndpoint(parsed, env, kind).baseUrl;
}
function runtimeEndpoint(parsed: ParsedArgs, env: EnvLike, kind: "web" | "api" = "web") {
return resolveRuntimeEndpoint({ kind, parsed, env });
}
async function authCookie({ parsed, env, cwd }: { parsed: ParsedArgs; env: EnvLike; cwd: string }) {
@@ -1374,7 +1383,7 @@ async function loadStoredState({ parsed, env, cwd }: { parsed: ParsedArgs; env:
}
async function readSessionState({ parsed, env, cwd }: { parsed: ParsedArgs; env: EnvLike; cwd: string }) {
const file = stateFile(parsed, cwd);
const file = stateFile(parsed, cwd, env);
const expectedBaseUrl = baseUrl(parsed, env);
let raw = "";
try {
@@ -1419,21 +1428,21 @@ async function readSessionState({ parsed, env, cwd }: { parsed: ParsedArgs; env:
}
async function saveSession(context: any, session: Record<string, unknown>) {
const file = stateFile(context.parsed, context.cwd);
const file = stateFile(context.parsed, context.cwd, context.env);
await mkdir(path.dirname(file), { recursive: true });
await writeFile(file, `${JSON.stringify(session, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
await chmod(file, 0o600).catch(() => {});
}
async function clearSession(context: any) {
await rm(stateFile(context.parsed, context.cwd), { force: true });
await rm(stateFile(context.parsed, context.cwd, context.env), { force: true });
}
function stateFile(parsed: ParsedArgs, cwd: string) {
function stateFile(parsed: ParsedArgs, cwd: string, env: EnvLike = process.env) {
const explicit = text(parsed.stateFile);
if (explicit) return path.resolve(cwd, explicit);
const profile = profileName(parsed);
if (profile) return path.join(profileStateDir(parsed, cwd), `${profile}.json`);
if (profile) return path.join(profileStateDir(parsed, cwd, env), `${profile}.json`);
return path.resolve(cwd, ".state/hwlab-cli/session.json");
}
@@ -1446,8 +1455,8 @@ function profileName(parsed: ParsedArgs) {
return value;
}
function profileStateDir(parsed: ParsedArgs, cwd: string) {
return path.resolve(cwd, ".state/hwlab-cli/profiles", sha256(baseUrl(parsed, {})).slice(0, 16));
function profileStateDir(parsed: ParsedArgs, cwd: string, env: EnvLike = process.env) {
return path.resolve(cwd, ".state/hwlab-cli/profiles", sha256(baseUrl(parsed, env)).slice(0, 16));
}
function cookieFromResponse(response: any) {
@@ -1466,7 +1475,8 @@ function responsePayload(action: string, response: any, context: any, extra: Rec
ok: success,
action,
status: success ? "succeeded" : "failed",
baseUrl: baseUrl(context.parsed, context.env),
baseUrl: response.runtimeEndpoint?.baseUrl ?? baseUrl(context.parsed, context.env, context.endpointKind ?? "web"),
runtimeEndpoint: runtimeEndpointVisibility(response.runtimeEndpoint ?? runtimeEndpoint(context.parsed, context.env, context.endpointKind ?? "web")),
httpStatus: response.status,
route: extra.route ?? route(response.method, response.path),
request: requestVisibility(response),
@@ -1560,14 +1570,14 @@ function sessionStateSummary(state: any) {
}
function authNextCommands(authState: any) {
const resolvedBaseUrl = text(authState?.baseUrl) || DEFAULT_BASE_URL;
const resolvedBaseUrl = text(authState?.baseUrl);
const username = text(authState?.username) || "admin";
const stateFileArg = text(authState?.stateFile) ? ` --state-file ${shellArg(authState.stateFile)}` : "";
const cli = "bun tools/hwlab-cli/bin/hwlab-cli.ts";
return [
`export HWLAB_PASSWORD='<password>'`,
`${cli} client auth login --base-url ${shellArg(resolvedBaseUrl)} --username ${shellArg(username)} --password-env HWLAB_PASSWORD${stateFileArg}`,
`${cli} client auth status --base-url ${shellArg(resolvedBaseUrl)}${stateFileArg}`
`${cli} client auth login --runtime-namespace <hwlab-namespace> --username ${shellArg(username)} --password-env HWLAB_PASSWORD${stateFileArg}`,
`${cli} client auth status --runtime-namespace <hwlab-namespace>${stateFileArg}`
];
}
+314
View File
@@ -0,0 +1,314 @@
export type RuntimeEndpointKind = "web" | "api";
type EnvLike = Record<string, string | undefined>;
type ParsedArgs = Record<string, unknown>;
type RuntimeHints = {
lane: string | null;
rawLane: string | null;
namespace: string | null;
rawNamespace: string | null;
};
type EndpointCandidate = {
value: string | null;
source: string;
sourceName: string;
explicitOverride?: boolean;
};
export type RuntimeEndpoint = {
kind: RuntimeEndpointKind;
baseUrl: string;
source: string;
sourceName: string;
explicitOverride: boolean;
lane: string | null;
namespace: string | null;
urlLane: string | null;
urlNamespace: string | null;
warnings: Array<Record<string, unknown>>;
};
export class RuntimeEndpointError extends Error {
code: string;
details: Record<string, unknown>;
constructor(code: string, message: string, details: Record<string, unknown> = {}) {
super(message);
this.name = "RuntimeEndpointError";
this.code = code;
this.details = details;
}
}
export function resolveRuntimeEndpoint({ kind, parsed = {}, env = {} }: { kind: RuntimeEndpointKind; parsed?: ParsedArgs; env?: EnvLike }): RuntimeEndpoint {
const hints = runtimeHints(parsed, env);
assertNoManualUrlWhenLocked(kind, parsed, env);
for (const candidate of endpointCandidates(kind, parsed, env, hints)) {
const baseUrl = normalizeUrl(candidate.value);
if (!baseUrl) continue;
return validateEndpoint({ kind, baseUrl, candidate, hints });
}
throw new RuntimeEndpointError(
"runtime_endpoint_required",
`HWLAB ${kind} endpoint could not be resolved from the current runtime context`,
{
kind,
lane: hints.lane,
rawLane: hints.rawLane,
namespace: hints.namespace,
rawNamespace: hints.rawNamespace,
required: kind === "web"
? ["HWLAB_RUNTIME_WEB_URL", "HWLAB_RUNTIME_NAMESPACE", "HWLAB_CLOUD_WEB_URL"]
: ["HWLAB_RUNTIME_API_URL", "HWLAB_RUNTIME_NAMESPACE", "HWLAB_DEVICE_POD_API_URL", "HWLAB_CLOUD_API_URL"],
suggestion: "let WEB/AgentRun assemble HWLAB_RUNTIME_* for the current lane; do not pass endpoint URLs in locked HWLAB runners"
}
);
}
export function runtimeEndpointVisibility(endpoint: RuntimeEndpoint | null | undefined) {
if (!endpoint) return null;
return pruneUndefined({
kind: endpoint.kind,
baseUrl: endpoint.baseUrl,
source: endpoint.source,
sourceName: endpoint.sourceName,
explicitOverride: endpoint.explicitOverride,
lane: endpoint.lane,
namespace: endpoint.namespace,
urlLane: endpoint.urlLane,
urlNamespace: endpoint.urlNamespace,
warnings: endpoint.warnings.length > 0 ? endpoint.warnings : undefined
});
}
export function runtimeHints(parsed: ParsedArgs = {}, env: EnvLike = {}): RuntimeHints {
const rawNamespace = firstText(
parsed.runtimeNamespace,
parsed.hwlabNamespace,
env.HWLAB_RUNTIME_NAMESPACE,
env.HWLAB_NAMESPACE,
validHwlabNamespace(env.POD_NAMESPACE),
validHwlabNamespace(env.KUBERNETES_NAMESPACE)
);
const rawLane = firstText(
parsed.runtimeLane,
parsed.runtimeProfile,
parsed.hwlabLane,
env.HWLAB_RUNTIME_LANE,
env.HWLAB_RUNTIME_PROFILE,
env.HWLAB_GITOPS_LANE,
env.HWLAB_ARTIFACT_LANE,
env.HWLAB_GITOPS_PROFILE,
env.HWLAB_ENVIRONMENT,
env.HWLAB_BOOT_REF
);
const laneFromRaw = normalizeLane(rawLane);
const namespace = normalizeNamespace(rawNamespace) ?? namespaceForLane(laneFromRaw);
return {
lane: laneFromRaw ?? laneForNamespace(namespace),
rawLane: rawLane || null,
namespace,
rawNamespace: rawNamespace || null
};
}
function endpointCandidates(kind: RuntimeEndpointKind, parsed: ParsedArgs, env: EnvLike, hints: RuntimeHints): EndpointCandidate[] {
const namespaceUrl = hints.namespace ? serviceUrlForNamespace(kind, hints.namespace, parsed, env) : null;
if (kind === "web") {
return withLocalDebugOverride([
candidate(firstText(env.HWLAB_RUNTIME_WEB_URL, env.HWLAB_CURRENT_WEB_URL), "runtime-env", "HWLAB_RUNTIME_WEB_URL"),
candidate(namespaceUrl, "runtime-namespace", "HWLAB_RUNTIME_NAMESPACE"),
candidate(firstText(env.HWLAB_PUBLIC_WEB_URL, env.HWLAB_CLOUD_WEB_PUBLIC_URL), "public-env", "HWLAB_PUBLIC_WEB_URL"),
candidate(firstText(env.HWLAB_CLIENT_BASE_URL, env.HWLAB_CLOUD_WEB_URL, env.HWLAB_CLI_BASE_URL), "env", "HWLAB_CLIENT_BASE_URL/HWLAB_CLOUD_WEB_URL/HWLAB_CLI_BASE_URL")
], candidate(parsed.baseUrl, "explicit-override", "--base-url", true), env);
}
return withLocalDebugOverride([
candidate(firstText(env.HWLAB_RUNTIME_API_URL, env.HWLAB_CURRENT_API_URL), "runtime-env", "HWLAB_RUNTIME_API_URL"),
candidate(namespaceUrl, "runtime-namespace", "HWLAB_RUNTIME_NAMESPACE"),
candidate(firstText(env.HWLAB_PUBLIC_API_URL, env.HWLAB_PUBLIC_ENDPOINT), "public-env", "HWLAB_PUBLIC_API_URL/HWLAB_PUBLIC_ENDPOINT"),
candidate(firstText(env.HWLAB_CODE_AGENT_DEVICE_POD_API_URL, env.HWLAB_DEVICE_POD_API_URL, env.HWLAB_CLOUD_API_URL, env.HWLAB_CLOUD_API_BASE_URL), "env", "HWLAB_DEVICE_POD_API_URL/HWLAB_CLOUD_API_URL")
], candidate(firstText(parsed.apiBaseUrl, parsed.apiUrl, parsed.cloudApiUrl, parsed.baseUrl), "explicit-override", "--api-base-url", true), env);
}
function withLocalDebugOverride(candidates: EndpointCandidate[], manual: EndpointCandidate, env: EnvLike) {
if (endpointLocked(env)) return candidates;
return manual.value ? [manual, ...candidates] : candidates;
}
function assertNoManualUrlWhenLocked(kind: RuntimeEndpointKind, parsed: ParsedArgs, env: EnvLike) {
if (!endpointLocked(env)) return;
const manualValue = kind === "web"
? firstText(parsed.baseUrl)
: firstText(parsed.apiBaseUrl, parsed.apiUrl, parsed.cloudApiUrl, parsed.baseUrl);
if (!manualValue) return;
throw new RuntimeEndpointError(
"runtime_endpoint_manual_url_forbidden",
`HWLAB ${kind} endpoint is assembled by runtime context; manual URL parameters are forbidden in locked runner environments`,
{
kind,
forbidden: kind === "web" ? ["--base-url"] : ["--api-base-url", "--api-url", "--cloud-api-url", "--base-url"],
source: "HWLAB_RUNTIME_ENDPOINT_LOCKED",
suggestion: "drop the URL argument and let device-pod-cli/hwlab-cli auto-locate from the assembled runtime env"
}
);
}
function endpointLocked(env: EnvLike) {
return [env.HWLAB_RUNTIME_ENDPOINT_LOCKED, env.HWLAB_DEVICE_POD_RUNTIME_LOCKED, env.HWLAB_CODE_AGENT_ASSEMBLED_RUNTIME].some((value) => ["1", "true", "yes", "locked"].includes(firstText(value).toLowerCase()));
}
function serviceUrlForNamespace(kind: RuntimeEndpointKind, namespace: string, parsed: ParsedArgs, env: EnvLike) {
if (kind === "web") {
const serviceName = firstText(parsed.webServiceName, env.HWLAB_CLOUD_WEB_SERVICE_NAME) || "hwlab-cloud-web";
const port = positiveInteger(firstText(parsed.webServicePort, env.HWLAB_CLOUD_WEB_PORT), 8080);
return `http://${serviceName}.${namespace}.svc.cluster.local:${port}`;
}
// Device-pod/API CLI calls use the Web service as the in-cluster proxy by default because undici blocks port 6667.
const serviceName = firstText(parsed.apiServiceName, env.HWLAB_CODE_AGENT_INTERNAL_API_SERVICE_NAME, env.HWLAB_CLOUD_WEB_SERVICE_NAME) || "hwlab-cloud-web";
const port = positiveInteger(firstText(parsed.apiServicePort, env.HWLAB_CODE_AGENT_INTERNAL_API_PORT, env.HWLAB_CLOUD_WEB_PORT), 8080);
return `http://${serviceName}.${namespace}.svc.cluster.local:${port}`;
}
function validateEndpoint({ kind, baseUrl, candidate, hints }: { kind: RuntimeEndpointKind; baseUrl: string; candidate: EndpointCandidate; hints: RuntimeHints }): RuntimeEndpoint {
const urlLane = laneForUrl(baseUrl);
const urlNamespace = namespaceForUrl(baseUrl);
const explicitOverride = candidate.explicitOverride === true;
const warnings: Array<Record<string, unknown>> = [];
if (!explicitOverride) {
if (hints.namespace && urlNamespace && urlNamespace !== hints.namespace) {
throw endpointMismatch(kind, baseUrl, candidate, hints, { urlLane, urlNamespace, field: "namespace" });
}
if (hints.lane && urlLane && urlLane !== hints.lane) {
throw endpointMismatch(kind, baseUrl, candidate, hints, { urlLane, urlNamespace, field: "lane" });
}
if (!hints.lane && !hints.namespace && candidate.source === "env" && urlLane === "dev") {
throw new RuntimeEndpointError(
"runtime_context_required",
`HWLAB ${kind} endpoint ${candidate.sourceName} points at the legacy DEV lane but no current runtime lane/namespace is set`,
{
kind,
baseUrl,
source: candidate.source,
sourceName: candidate.sourceName,
urlLane,
suggestion: "use assembled HWLAB_RUNTIME_* env for the current lane; manual URL is only a local debug override outside locked runners"
}
);
}
} else if ((hints.lane && urlLane && urlLane !== hints.lane) || (hints.namespace && urlNamespace && urlNamespace !== hints.namespace)) {
warnings.push({
code: "runtime_endpoint_explicit_mismatch",
message: "explicit endpoint override does not match current runtime hints",
expectedLane: hints.lane,
expectedNamespace: hints.namespace,
urlLane,
urlNamespace
});
}
return pruneUndefined({
kind,
baseUrl,
source: candidate.source,
sourceName: candidate.sourceName,
explicitOverride,
lane: hints.lane,
namespace: hints.namespace,
urlLane,
urlNamespace,
warnings
}) as RuntimeEndpoint;
}
function endpointMismatch(kind: RuntimeEndpointKind, baseUrl: string, candidate: EndpointCandidate, hints: RuntimeHints, observed: Record<string, unknown>) {
return new RuntimeEndpointError(
"runtime_endpoint_mismatch",
`HWLAB ${kind} endpoint source ${candidate.sourceName} does not match the current runtime lane/namespace`,
{
kind,
baseUrl,
source: candidate.source,
sourceName: candidate.sourceName,
expectedLane: hints.lane,
expectedNamespace: hints.namespace,
rawLane: hints.rawLane,
rawNamespace: hints.rawNamespace,
...observed,
suggestion: "fix the runtime env injected by WEB/AgentRun; manual URL is only a local debug override outside locked runners"
}
);
}
function candidate(value: unknown, source: string, sourceName: string, explicitOverride = false): EndpointCandidate {
return { value: firstText(value), source, sourceName, explicitOverride };
}
function normalizeUrl(value: unknown) {
const raw = firstText(value);
if (!raw) return null;
return raw.replace(/\/+$/u, "");
}
function laneForUrl(value: string) {
const text = value.toLowerCase();
if (/hwlab-v02|:19666(?:\/|$)|:19667(?:\/|$)/u.test(text)) return "v02";
if (/hwlab-prod|:18666(?:\/|$)|:18667(?:\/|$)/u.test(text)) return "prod";
if (/hwlab-dev|:17666(?:\/|$)|:17667(?:\/|$)|:16666(?:\/|$)|:16667(?:\/|$)/u.test(text)) return "dev";
return null;
}
function namespaceForUrl(value: string) {
const match = value.match(/\.((?:hwlab-[a-z0-9-]+))\.svc\.cluster\.local(?::|\/|$)/iu);
return normalizeNamespace(match?.[1]);
}
function normalizeNamespace(value: unknown) {
const namespace = firstText(value);
if (!namespace || !/^hwlab-[a-z0-9-]+$/u.test(namespace)) return null;
return namespace;
}
function validHwlabNamespace(value: unknown) {
return normalizeNamespace(value) ?? null;
}
function normalizeLane(value: unknown) {
const raw = firstText(value).toLowerCase();
if (!raw) return null;
if (["v02", "v0.2", "0.2", "v2", "v0_2", "v0-2"].includes(raw)) return "v02";
if (["g14", "dev", "development"].includes(raw)) return "dev";
if (["prod", "production"].includes(raw)) return "prod";
return null;
}
function namespaceForLane(lane: string | null) {
if (lane === "v02") return "hwlab-v02";
if (lane === "prod") return "hwlab-prod";
if (lane === "dev") return "hwlab-dev";
return null;
}
function laneForNamespace(namespace: string | null) {
if (namespace === "hwlab-v02") return "v02";
if (namespace === "hwlab-prod") return "prod";
if (namespace === "hwlab-dev") return "dev";
return null;
}
function positiveInteger(value: unknown, fallback: number) {
const parsed = Number.parseInt(firstText(value), 10);
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
}
function firstText(...values: unknown[]) {
for (const value of values) {
const text = String(value ?? "").trim();
if (text) return text;
}
return "";
}
function pruneUndefined<T extends Record<string, unknown>>(value: T): T {
return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined)) as T;
}
+2 -2
View File
@@ -83,7 +83,7 @@ test("trace display rows suppress low-value AgentRun backend noise", () => {
event(12, "agentrun:backend:item/agentMessage:completed"),
commandEvent(13, "item/commandExecution:completed", {
itemId: "call_1",
command: "/bin/sh -lc 'bun tools/device-pod-cli.ts profile list --api-base-url http://74.48.78.17:19666'",
command: "/bin/sh -lc 'hwpod profile list'",
status: "completed",
exitCode: 0,
durationMs: 708,
@@ -118,7 +118,7 @@ test("trace display rows suppress low-value AgentRun backend noise", () => {
assert.equal(/terminal completed/u.test(text), false);
assert.equal(/tokenUsage/u.test(text), false);
assert.match(text, //u);
assert.match(text, /device-pod-cli\.ts profile list/u);
assert.match(text, /hwpod profile list/u);
assert.match(text, /profile\.list/u);
assert.match(text, //u);
assert.match(text, / 1 device-pod/u);