Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 57 additions & 7 deletions scripts/generate.js
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ const SCHEMA_CONFIGS = [
stagingDir: "./src/.schema-v2-staging",
previousDir: "./src/.schema-v2-previous",
schemaDeserializeImport: "../../schema-deserialize.js",
absolutePathImport: "../absolute-path.js",
expectedExtensibleUnions: V2_EXTENSIBLE_UNIONS,
openApiVersion: "2.0.0",
releaseTag: CURRENT_V2_SCHEMA_RELEASE,
Expand Down Expand Up @@ -183,6 +184,13 @@ async function generateSchema(config, checkGenerated) {
// behavior that isn't in the schema descriptions: custom variants bypass
// the lenient-field salvage known variants get.
let zodDocs = updateDocs(zodSrc, schemaDefs);
if (config.absolutePathImport) {
zodDocs = addAbsolutePathValidation(
zodDocs,
config.absolutePathImport,
config.name,
);
}
for (const [name, exclusion] of defExclusions) {
zodDocs = appendDocNote(
zodDocs,
Expand Down Expand Up @@ -212,15 +220,17 @@ async function generateSchema(config, checkGenerated) {

const tsPath = `${stagingDir}/types.gen.ts`;
const tsSrc = await fs.readFile(tsPath, "utf8");
const ts = await formatStable(
updateDocs(
tsSrc.replace(
`export type ClientOptions`,
`// eslint-disable-next-line @typescript-eslint/no-unused-vars\ntype ClientOptions`,
),
schemaDefs,
let tsDocs = updateDocs(
tsSrc.replace(
`export type ClientOptions`,
`// eslint-disable-next-line @typescript-eslint/no-unused-vars\ntype ClientOptions`,
),
schemaDefs,
);
if (config.absolutePathImport) {
tsDocs = addAbsolutePathBrand(tsDocs, config.name);
}
const ts = await formatStable(tsDocs);
await fs.writeFile(tsPath, ts);

// Always write the file: the staging swap replaces the whole directory, so
Expand Down Expand Up @@ -419,6 +429,46 @@ async function formatStable(source) {
);
}

function addAbsolutePathValidation(source, importPath, lane) {
const zodImport = source.match(/import \* as z from ["']zod\/v4["'];/)?.[0];
const absolutePathSchema = "export const zAbsolutePath = z.string();";
if (!zodImport || !source.includes(absolutePathSchema)) {
throw new Error(
`[${lane}] Could not attach absolute-path validation to generated Zod schemas`,
);
}

return source
.replace(
zodImport,
`import { isAbsolutePath } from ${JSON.stringify(importPath)};\n` +
`import type { AbsolutePath } from "./types.gen.js";\n` +
`${zodImport}`,
)
.replace(
absolutePathSchema,
`export const zAbsolutePath = z.string().refine(isAbsolutePath, {\n` +
` message: "Expected an absolute filesystem path",\n` +
`}).transform((value) => value as AbsolutePath);`,
);
}

function addAbsolutePathBrand(source, lane) {
const absolutePathType = "export type AbsolutePath = string;";
if (!source.includes(absolutePathType)) {
throw new Error(
`[${lane}] Could not brand the generated AbsolutePath type`,
);
}

return source.replace(
absolutePathType,
`export type AbsolutePath = string & {\n` +
` readonly __brand: "AbsolutePath";\n` +
`};`,
);
}

/**
* Downloads a file from a URL to a local path
* @param {string} url - The URL to download from
Expand Down
2 changes: 1 addition & 1 deletion src/examples/dual-version-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ type V2Turn = {
};

type V2Session = {
cwd: string;
cwd: v2.AbsolutePath;
active: boolean;
history: v2.SessionUpdate[];
turn?: V2Turn;
Expand Down
47 changes: 47 additions & 0 deletions src/v2/absolute-path.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";

import { absolutePath, isAbsolutePath } from "./absolute-path.js";
import { zAbsolutePath } from "./schema/zod.gen.js";
import type { AbsolutePath } from "./schema/types.gen.js";

const typedAbsolutePath: AbsolutePath = absolutePath("/workspace");
const parsedAbsolutePath: AbsolutePath = zAbsolutePath.parse("/workspace");
// @ts-expect-error Raw strings must be validated before use as protocol paths.
const unvalidatedAbsolutePath: AbsolutePath = "/workspace";
void typedAbsolutePath;
void parsedAbsolutePath;
void unvalidatedAbsolutePath;

describe("absolute protocol paths", () => {
it.each([
"/",
"/tmp/project",
"C:\\",
"C:\\Users\\agent\\project",
"D:/projects/acp",
"\\\\server\\share",
"\\\\server/share/directory",
"//server/share/directory",
"\\\\?\\C:\\long\\path",
])("accepts %s", (value) => {
expect(isAbsolutePath(value)).toBe(true);
expect(absolutePath(value)).toBe(value);
expect(zAbsolutePath.parse(value)).toBe(value);
});

it.each([
"",
".",
"./project",
"../project",
"tmp/project",
"C:relative",
"\\rooted-without-drive",
"~/project",
"/tmp/\0project",
])("rejects %s", (value) => {
expect(isAbsolutePath(value)).toBe(false);
expect(() => absolutePath(value)).toThrow(TypeError);
expect(zAbsolutePath.safeParse(value).success).toBe(false);
});
});
35 changes: 35 additions & 0 deletions src/v2/absolute-path.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import type { AbsolutePath } from "./schema/types.gen.js";

const WINDOWS_DRIVE_ABSOLUTE_PATH = /^[A-Za-z]:[\\/]/;
const WINDOWS_UNC_ABSOLUTE_PATH = /^\\\\[^\\/]+[\\/][^\\/]+(?:[\\/].*)?$/;

/**
* Returns whether a string is an absolute POSIX, Windows drive, or Windows UNC
* filesystem path. The check is host-independent so ACP messages can be
* validated while crossing operating-system boundaries.
*
* @experimental
*/
export function isAbsolutePath(value: string): value is AbsolutePath {
if (value.includes("\0")) return false;
return (
value.startsWith("/") ||
WINDOWS_DRIVE_ABSOLUTE_PATH.test(value) ||
WINDOWS_UNC_ABSOLUTE_PATH.test(value)
);
}

/**
* Validates and returns an absolute protocol path.
*
* @throws {TypeError} If `value` is not an absolute filesystem path.
* @experimental
*/
export function absolutePath(value: string): AbsolutePath {
if (!isAbsolutePath(value)) {
throw new TypeError(
`Expected an absolute filesystem path, received ${JSON.stringify(value)}`,
);
}
return value;
}
31 changes: 20 additions & 11 deletions src/v2/acp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
import {
PROTOCOL_VERSION,
agent,
absolutePath,
batchNotification,
batchRequest,
client,
Expand Down Expand Up @@ -129,7 +130,7 @@ describe("experimental v2 app API", () => {
const stdioServer = {
type: "stdio",
name: "stdio-server",
command: "/usr/bin/example-mcp",
command: absolutePath("/usr/bin/example-mcp"),
args,
env,
_meta: stdioMeta,
Expand All @@ -150,9 +151,9 @@ describe("experimental v2 app API", () => {
settings: customSettings,
} satisfies McpServer;

const additionalDirectories = ["/workspace/other"];
const additionalDirectories = [absolutePath("/workspace/other")];
const request = {
cwd: "/workspace",
cwd: absolutePath("/workspace"),
additionalDirectories,
mcpServers: [httpServer, stdioServer, acpServer, customServer],
_meta: requestMeta,
Expand All @@ -162,7 +163,7 @@ describe("experimental v2 app API", () => {
await client().connectWith(agent(), (agentClient) => {
const builder = agentClient.buildSession(request);

additionalDirectories[0] = "/mutated-input";
additionalDirectories[0] = absolutePath("/mutated-input");
requestMeta.nested.value = "mutated-input";
httpServer.name = "mutated-input";
headers[0].value = "mutated-input";
Expand All @@ -179,7 +180,7 @@ describe("experimental v2 app API", () => {
expect(builder.toRequest()).toEqual(expected);

const returned = builder.toRequest();
returned.additionalDirectories![0] = "/mutated-output";
returned.additionalDirectories![0] = absolutePath("/mutated-output");
(returned._meta as NestedMeta).nested.value = "mutated-output";

const returnedHttp = returned.mcpServers![0] as typeof httpServer;
Expand Down Expand Up @@ -217,7 +218,7 @@ describe("experimental v2 app API", () => {
const mcpServer = {
type: "stdio",
name: "stdio-server",
command: "/usr/bin/example-mcp",
command: absolutePath("/usr/bin/example-mcp"),
args,
env,
_meta: serverMeta,
Expand All @@ -226,7 +227,7 @@ describe("experimental v2 app API", () => {

await client().connectWith(agent(), (agentClient) => {
const builder = agentClient
.buildSession("/workspace")
.buildSession(absolutePath("/workspace"))
.withMcpServer(mcpServer);

args[0] = "mutated-input";
Expand Down Expand Up @@ -285,7 +286,9 @@ describe("experimental v2 app API", () => {
}),
).resolves.toMatchObject({ protocolVersion: PROTOCOL_VERSION });

const session = await agentClient.buildSession("/workspace").start();
const session = await agentClient
.buildSession(absolutePath("/workspace"))
.start();
try {
await updateClient!.notify(methods.client.session.update, {
sessionId: session.sessionId,
Expand Down Expand Up @@ -363,7 +366,9 @@ describe("experimental v2 app API", () => {
protocolVersion: PROTOCOL_VERSION,
info: clientInfo,
});
const session = await agentClient.buildSession("/workspace").start();
const session = await agentClient
.buildSession(absolutePath("/workspace"))
.start();
try {
const first = session.prompt("First");
const firstText = session.readText();
Expand Down Expand Up @@ -420,7 +425,9 @@ describe("experimental v2 app API", () => {
protocolVersion: PROTOCOL_VERSION,
info: clientInfo,
});
const session = await agentClient.buildSession("/workspace").start();
const session = await agentClient
.buildSession(absolutePath("/workspace"))
.start();
try {
await session.prompt("Hello");
const text = session.readText();
Expand Down Expand Up @@ -489,7 +496,9 @@ describe("experimental v2 app API", () => {
protocolVersion: PROTOCOL_VERSION,
info: clientInfo,
});
const session = await agentClient.buildSession("/workspace").start();
const session = await agentClient
.buildSession(absolutePath("/workspace"))
.start();
try {
await session.prompt("First");
for (const update of [
Expand Down
1 change: 1 addition & 0 deletions src/v2/acp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import * as validate from "./schema/zod.gen.js";
import * as guards from "./schema/guards.gen.js";
import { ndJsonStream as createJsonStream } from "../stream.js";
export type * from "./schema/types.gen.js";
export { absolutePath, isAbsolutePath } from "./absolute-path.js";
// Runtime narrowing helpers for extensible unions, exposed as companion values
// that merge (declaration merging) with the like-named types — e.g.
// `CreateElicitationResponse.isAccept(response)`. See schema/guards.gen.ts.
Expand Down
4 changes: 3 additions & 1 deletion src/v2/schema/types.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -688,7 +688,9 @@ export type DiffFileType = "text" | "binary" | "directory" | "symlink" | string;
/**
* An absolute filesystem path used by the protocol.
*/
export type AbsolutePath = string;
export type AbsolutePath = string & {
readonly __brand: "AbsolutePath";
};

/**
* Operation metadata for add, delete, and modify changes.
Expand Down
9 changes: 8 additions & 1 deletion src/v2/schema/zod.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import {
requiredDefaultOnError,
vecSkipError,
} from "../../schema-deserialize.js";
import { isAbsolutePath } from "../absolute-path.js";
import type { AbsolutePath } from "./types.gen.js";
import * as z from "zod/v4";

/**
Expand Down Expand Up @@ -312,7 +314,12 @@ export const zDiffFileType = z.union([
/**
* An absolute filesystem path used by the protocol.
*/
export const zAbsolutePath = z.string();
export const zAbsolutePath = z
.string()
.refine(isAbsolutePath, {
message: "Expected an absolute filesystem path",
})
.transform((value) => value as AbsolutePath);

/**
* Operation metadata for add, delete, and modify changes.
Expand Down