Merge pull request #674 from pikasTech/fix/v02-runtime-resolver-672
fix: assemble device-pod runtime endpoint
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
|
||||
@@ -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 装配的当前集群路径。
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 })
|
||||
});
|
||||
|
||||
@@ -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
@@ -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}`
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user