Merge PR #6222: fix(write): reject unknown URI-like targets (@roboomp)

This commit is contained in:
can1357
2026-07-22 21:13:20 +02:00
3 changed files with 67 additions and 0 deletions
+4
View File
@@ -184,6 +184,10 @@
- Fixed the TUI `/usage` matrix mis-aligning multi-account columns across quota windows: each window row was sorted independently by used fraction, so the positional `account N` labels denoted different credentials per row and an exhausted limit (e.g. a Kimi Code account's 5h window) could render under a sibling that still had quota. Account columns are now ordered once per provider (worst-first) and held stable across every window row ([#6067](https://github.com/can1357/oh-my-pi/issues/6067)).
### Fixed
- Fixed near-miss `xd://` write targets silently creating filesystem paths instead of surfacing a corrective URI error ([#6123](https://github.com/can1357/oh-my-pi/issues/6123)).
## [17.0.5] - 2026-07-18
### Added
+31
View File
@@ -85,6 +85,36 @@ import { renderXdevCall, renderXdevResult, type XdevDispatch } from "./xdev";
const LOOSE_HASHLINE_HEADER_RE = /^\s*\[[^#\r\n]+#[^ \t\r\n]*\]\s*$/;
const EXECUTABLE_NOTICE = "[Notice: Made executable via chmod +x]";
const URI_LIKE_WRITE_PATH_RE = /^([a-z][a-z0-9+.-]*):\/{1,2}(.*)$/i;
const XD_MISSING_DELIMITER_RE = /^xd\/+(.*)$/i;
const XD_SCHEME_NEAR_MISSES: Record<string, true> = { dx: true, xdd: true, xdt: true };
function assertWriteTargetAddressable(target: string, router: InternalUrlRouter): void {
const trimmed = target.trim();
if (path.win32.isAbsolute(trimmed) || router.canHandle(trimmed)) return;
const missingDelimiter = trimmed.match(XD_MISSING_DELIMITER_RE);
if (missingDelimiter) {
throw new ToolError(
`Unknown URI-like write target '${trimmed}'. Did you mean 'xd://${missingDelimiter[1]}'? Prefix the path with './' to write it as a filesystem path.`,
);
}
const uriLike = trimmed.match(URI_LIKE_WRITE_PATH_RE);
if (!uriLike) return;
const scheme = uriLike[1]!.toLowerCase();
// conflict:// has no router handler but is spliced downstream by
// parseConflictUri (which emits its own precise id/scope errors); let it pass.
if (scheme === "conflict") return;
const canonicalScheme = router.getHandler(scheme) ? scheme : XD_SCHEME_NEAR_MISSES[scheme] ? "xd" : undefined;
const suggestion = canonicalScheme
? ` Did you mean '${canonicalScheme}://${uriLike[2]}'?`
: " Tool devices use 'xd://<tool>'.";
throw new ToolError(
`Unknown URI-like write target '${trimmed}'.${suggestion} Prefix the path with './' to write it as a filesystem path.`,
);
}
const BULK_DIRECTIVE_RE = /^#?(\d+)\s*[:=]\s*(@ours|@theirs|@base|@both)$/;
/**
@@ -987,6 +1017,7 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
// Strip hashline display prefixes ([PATH#HASH] + LINE:) if the model copied them from read output
const { text: cleanContent, stripped } = stripWriteContent(this.session, content);
const internalRouter = InternalUrlRouter.instance();
assertWriteTargetAddressable(path, internalRouter);
if (internalRouter.canHandle(path)) {
const parsed = parseInternalUrl(path);
const scheme = parsed.protocol.replace(/:$/, "").toLowerCase();
@@ -86,6 +86,38 @@ describe("read and write route xd:// device URLs", () => {
}
});
it("rejects near-miss xd addresses before filesystem fallback", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "write-xdev-near-miss-"));
try {
const tools = await createTools(xdevSession(tempDir));
const write = tools.find(entry => entry.name === "write");
expect(write).toBeDefined();
for (const target of ["xdt://web_search", "xd:/web_search", "xd/web_search"]) {
await expect(write!.execute(`write-${target}`, { path: target, content: "{}" })).rejects.toThrow(
"Did you mean 'xd://web_search'?",
);
}
expect(await Bun.file(path.join(tempDir, "xdt:/web_search")).exists()).toBe(false);
expect(await Bun.file(path.join(tempDir, "xd/web_search")).exists()).toBe(false);
const escaped = await write!.execute("write-explicit-path", {
path: "./xd/web_search",
content: "intentional file",
});
expect(escaped.isError).toBeUndefined();
expect(await Bun.file(path.join(tempDir, "xd/web_search")).text()).toBe("intentional file");
// conflict:// has no router handler but is a documented write scheme —
// the guard must let it reach the conflict resolver, not reject it.
await expect(write!.execute("write-conflict", { path: "conflict://1", content: "x" })).rejects.toThrow(
"Conflict #1 not found",
);
} finally {
await removeWithRetries(tempDir);
}
});
it("resolves function-valued device approvals per payload and fails closed on bad content", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "write-xdev-approval-"));
try {