diff --git a/docs/reference/device-pod.md b/docs/reference/device-pod.md index 70a7f759..95d9d5d8 100644 --- a/docs/reference/device-pod.md +++ b/docs/reference/device-pod.md @@ -52,6 +52,12 @@ MVP 不使用刚性的中心 profile 注册表。`device-pod` profile 的灵活 profile 必须只描述 target、debugInterface、projectWorkspace、ioInterface、gateway route、host CLI 能力和受控路径边界;不得放入 Git key、云端 token、kubeconfig、数据库 URL 或其他长期 secret。profile 校验失败时应返回 `profile-invalid` 或 `profile-missing` blocker,而不是回退到默认设备或任意 shell。 +## Selector 路径纪律 + +`device-pod-cli` selector 是稳定 API 语法,不是自然语言路径猜测器。`workspace`、`debug-probe` 和 `io-probe` selector 后的路径必须由调用方拼对,尤其是 I/O probe 必须把路径写成一个 shell token,例如 `device-pod-71-freq:io-probe:/uart/1 read`。 + +不要迁就透传路径拼错、`/` 两侧空格或 argv 被模型拆开的写法。`io-probe:/uart / 1 read`、`io-probe:/uart/ 1 read` 这类输入应在 runner 侧直接返回 `invalid-request` 并给出正确写法 hint,不应被自动归一化成 `uart/1`,更不应继续透传到 gateway 或 Windows `device-host-cli`。这样可以把“命令写错”和“硬件/串口不可达”明确区分,避免下游工具为模型 spacing drift 背锅。 + ## HWLAB coder 预装合同 HWLAB coder / code agent runner 镜像必须通过正式 CI/CD 预装 device-pod 最小闭环所需的三类入口: diff --git a/skills/device-pod-cli/SKILL.md b/skills/device-pod-cli/SKILL.md index 515fc753..261e0250 100644 --- a/skills/device-pod-cli/SKILL.md +++ b/skills/device-pod-cli/SKILL.md @@ -45,9 +45,12 @@ not the HWLAB runner contract and cause stale instructions. queried without approval friction. If `download status` includes UART capture, read the top-level `hostUartCapture` field first before falling back to job files or direct `tran`. -- UART ids are slash-normalized by the CLI, but write them as `uart/1` in - prompts and commands. Do not write `uart / 1`; that form is only tolerated for - recovery from model spacing drift. +- I/O probe selectors are strict path tokens. Always write + `device-pod-71-freq:io-probe:/uart/1 read` as one selector token; do not write + `io-probe:/uart / 1 read`, `io-probe:/uart/ 1 read`, or other spaced path + variants. The CLI should fail these forms with an `invalid-request` hint + instead of normalizing the path or forwarding the malformed command to the + Windows host. Fast build smoke for `device-pod-71-freq`: diff --git a/tools/device-pod-cli.mjs b/tools/device-pod-cli.mjs index b4f88a20..ebe42af1 100644 --- a/tools/device-pod-cli.mjs +++ b/tools/device-pod-cli.mjs @@ -31,7 +31,7 @@ function classifyBlocker(message) { if (/profile not found/u.test(message)) return 'profile-missing'; if (/profile invalid|requires/u.test(message)) return 'profile-invalid'; if (/approval/u.test(message)) return 'approval-required'; - if (/unsupported|invalid target selector/u.test(message)) return 'invalid-request'; + if (/unsupported|invalid target selector|invalid io-probe (?:path|selector)/u.test(message)) return 'invalid-request'; return 'operation-failed'; } @@ -41,7 +41,9 @@ function parseTarget(raw) { if (raw === 'profile') return { kind: 'profile' }; const match = String(raw).trim().match(/^([^:]+):(workspace|debug-probe|io-probe)(?::(.*))?$/u); if (!match) throw new Error(`invalid target selector: ${raw}`); - return { kind: 'invoke', podId: match[1], surface: match[2], path: normalizeDevicePath(match[3] || '') }; + const surface = match[2]; + const selectorPath = surface === 'io-probe' ? normalizeProbePath(match[3] || '') : normalizeDevicePath(match[3] || ''); + return { kind: 'invoke', podId: match[1], surface, path: selectorPath }; } function normalizeDevicePath(value) { @@ -65,10 +67,16 @@ function joinDevicePath(base, child) { } function normalizeProbePath(value) { - return String(value || '').trim().replace(/\\/gu, '/').replace(/\s*\/\s*/gu, '/').replace(/^\/+/u, ''); + const raw = String(value || '').trim().replace(/\\/gu, '/'); + if (!raw) return ''; + if (/\s/u.test(raw)) { + throw new Error(`invalid io-probe path: "${raw}". Use io-probe:/uart/1 with no spaces; do not write io-probe:/uart / 1`); + } + return raw.replace(/^\/+/u, '').replace(/\/+$/u, ''); } const WORKSPACE_COMMANDS = new Set(['ls', 'cat', 'rg', 'apply-patch', 'build']); +const IO_PROBE_COMMANDS = new Set(['ports', 'read', 'write', 'read-after-launch-flash']); function normalizeWorkspaceSelector(selector, parsed) { let workspacePath = normalizeDevicePath(selector.path || '.'); @@ -276,6 +284,29 @@ function hostOptionArgs(parsed) { return out; } +function ioProbeHint(selector, probePath, parsed) { + const parts = parsed._ || []; + if ((parts[0] === '/' || parts[0] === '\\') && parts[1]) { + return `${selector.podId}:io-probe:/${normalizeProbePath(`${probePath}/${parts[1]}`)} ${parts[2] || 'read'}`; + } + if (parts[0] && !IO_PROBE_COMMANDS.has(parts[0]) && parts[1] && IO_PROBE_COMMANDS.has(parts[1])) { + return `${selector.podId}:io-probe:/${normalizeProbePath(`${probePath}/${parts[0]}`)} ${parts[1]}`; + } + return `${selector.podId}:io-probe:/${probePath || 'uart/1'} ${IO_PROBE_COMMANDS.has(parts[0]) ? parts[0] : 'read'}`; +} + +function validateIoProbeCommand(selector, probePath, operation, parsed) { + if ((parsed._[0] === '/' || parsed._[0] === '\\') || (parsed._[0] && !IO_PROBE_COMMANDS.has(parsed._[0]) && parsed._[1] && IO_PROBE_COMMANDS.has(parsed._[1]))) { + throw new Error(`invalid io-probe selector path: path appears split across argv near /${probePath}. Use ${ioProbeHint(selector, probePath, parsed)}; do not put spaces around '/' in selector paths`); + } + if (probePath === 'uart') { + throw new Error(`invalid io-probe path: /uart is not a concrete endpoint. Use ${selector.podId}:io-probe:/uart/1 ${operation || 'read'}`); + } + if (!IO_PROBE_COMMANDS.has(operation)) { + throw new Error(`unsupported io-probe operation: ${operation || '(empty)'}. Use ${ioProbeHint(selector, probePath, parsed)}; paths must be a single selector token with no spaces`); + } +} + async function buildHostCommand(selector, parsed) { const args = []; const options = hostOptionArgs(parsed); @@ -301,6 +332,7 @@ async function buildHostCommand(selector, parsed) { args.push('debug-probe', operation, ...parsed._.slice(1), ...options); } else if (selector.surface === 'io-probe') { const probePath = normalizeProbePath(selector.path) || 'uart/1'; + validateIoProbeCommand(selector, probePath, operation, parsed); if (probePath.startsWith('inner/')) throw new Error(`unsupported io-probe path in MVP: ${probePath}`); args.push('io-probe', probePath, operation, ...parsed._.slice(1), ...options); } else {