Merge pull request #528 from pikasTech/fix/device-pod-cli-io-space-20260528

fix: reject spaced io probe selectors
This commit is contained in:
Lyon
2026-05-28 10:22:04 +08:00
committed by GitHub
3 changed files with 47 additions and 6 deletions
+6
View File
@@ -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 最小闭环所需的三类入口:
+6 -3
View File
@@ -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`:
+35 -3
View File
@@ -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 {