can1357/oh-my-pi · error

vault:// URL must resolve to a file: ${parsed.url}

Error message

vault:// URL must resolve to a file: ${parsed.url}

What it means

The write path pre-checks the destination: if realpath+stat succeed and the target is a directory, writing file content is rejected with this error. The check happens inside a try/catch that otherwise tolerates ENOENT (allowing writes to new files whose parents may not exist yet). It prevents silently clobbering a directory path with a file write.

Source

Thrown at packages/coding-agent/src/internal-urls/vault-protocol.ts:910

			content,
			contentType: getContentType(realTargetPath),
			size: Buffer.byteLength(content, "utf-8"),
			sourcePath: realTargetPath,
		};
	}

	async #writeFile(
		parsed: Extract<ParsedVaultUrl, { kind: "fs-file" }>,
		content: string,
		context?: WriteContext,
	): Promise<void> {
		const { root, targetPath } = await this.#resolveFsTarget(parsed, context);
		try {
			const realTargetPath = await fs.promises.realpath(targetPath);
			ensureWithinRoot(realTargetPath, root);
			const stat = await fs.promises.stat(realTargetPath);
			if (stat.isDirectory()) {
				throw new Error(`vault:// URL must resolve to a file: ${parsed.url}`);
			}
		} catch (error) {
			if (!isEnoent(error)) throw error;
			const parentDir = path.dirname(targetPath);
			const existingAncestor = await findExistingAncestor(parentDir, root);
			ensureWithinRoot(existingAncestor, root);
			await fs.promises.mkdir(parentDir, { recursive: true });
			const realParent = await fs.promises.realpath(parentDir);
			ensureWithinRoot(realParent, root);
		}
		await Bun.write(targetPath, content);
	}

	async #runCli(
		parsed: Extract<ParsedVaultUrl, { kind: "file-op" | "vault-op" }>,
		context?: ResolveContext,
	): Promise<InternalResource> {
		const invocation = buildObsidianCliInvocation(parsed);

View on GitHub (pinned to 9690622007)

Solutions

  1. Append a concrete file name to the URL: vault://<vault>/dir/note.md.
  2. Check the target with parseVaultUrl/stat before writing if your code derives paths dynamically.
  3. If the intent is to create a directory, use a directory-creation flow instead of write().

Example fix

// before
await handler.write(parseInternalUrl("vault://_/notes"), text); // notes is a dir
// after
await handler.write(parseInternalUrl("vault://_/notes/todo.md"), text);
Defensive patterns

Strategy: validation

Validate before calling

import * as fs from "node:fs/promises";
try {
  if ((await fs.stat(targetPath)).isDirectory()) {
    throw new Error(`Refusing to write: ${targetPath} is a directory; append a file name`);
  }
} catch (err) {
  if (!(err as NodeJS.ErrnoException)?.code?.startsWith("ENOENT") && err instanceof Error && !err.message.includes("Refusing")) throw err;
}

Try / catch

try {
  await handler.write(url, content);
} catch (err) {
  if (err instanceof Error && err.message.includes("must resolve to a file:")) {
    // append a default file name and retry
    return handler.write(new InternalUrl(`${url.href.replace(/\/$/, "")}/untitled.md`), content);
  }
  throw err;
}

Prevention

When it happens

Trigger: Calling handler.write(url, content) with a vault:// URL that resolves to an existing directory, e.g. writing to vault://_/notes when "notes" is a directory.

Common situations: Building the write target from a directory path and forgetting a filename; users pasting a folder URL into a write flow; path-joining bugs producing a directory as the final target.

Related errors


AI-assisted analysis of can1357/oh-my-pi@9690622007 (2026-08-31). Data as JSON: /api/errors/45c61021f21fa5a7. Report an issue: GitHub.