Unified · Split
| @@ -11,6 +11,7 @@ import { · 10 unmodified lines | |||
| 11 | ProviderInstanceId, | 11 | ProviderInstanceId, |
| 12 | type ServerSettings, | 12 | type ServerSettings, |
| 13 | ServerSettingsError, | 13 | ServerSettingsError, |
| 14 | SourceControlRepositoryError, | ||
| 14 | ThreadId, | 15 | ThreadId, |
| 15 | } from "@t3tools/contracts"; | 16 | } from "@t3tools/contracts"; |
| 16 | import * as Deferred from "effect/Deferred"; | 17 | import * as Deferred from "effect/Deferred"; |
| @@ -222,11 +223,68 @@ it.effect("loads response settings before committing project mutations", () => · 205 unmodified lines | |||
| 222 | }) | 223 | }) |
| 223 | .pipe(Effect.flip); | 224 | .pipe(Effect.flip); |
| 224 | expect(error.code).toBe("operation_failed"); | 225 | expect(error.code).toBe("operation_failed"); |
| 226 | expect(error.message).toBe("Unable to read project defaults."); | ||
| 227 | expect(error.message).not.toMatch(/settings unavailable|settings\.json/); | ||
| 225 | expect(yield* Ref.get(createCalls)).toBe(0); | 228 | expect(yield* Ref.get(createCalls)).toBe(0); |
| 226 | }).pipe(Effect.provide(testLayer)); | 229 | }).pipe(Effect.provide(testLayer)); |
| 227 | }), | 230 | }), |
| 228 | ); | 231 | ); |
| 229 | 232 | ||
| 233 | it.effect("redacts repository failures from public project MCP results", () => | ||
| 234 | Effect.gen(function* () { | ||
| 235 | const secretRemote = "https://oauth-token@example.com/private/repo.git"; | ||
| 236 | const testLayer = ProjectMcp.layer.pipe( | ||
| 237 | Layer.provide( | ||
| 238 | Layer.mock(ProjectService.ProjectService)({ | ||
| 239 | getById: () => Effect.succeed(Option.none()), | ||
| 240 | }), | ||
| 241 | ), | ||
| 242 | Layer.provide(Layer.mock(T3ProjectFileLoader.T3ProjectFileLoader)({})), | ||
| 243 | Layer.provide( | ||
| 244 | Layer.mock(ServerSettingsService.ServerSettingsService)({ | ||
| 245 | getSettings: Effect.succeed({ defaultThreadEnvMode: "local" } as ServerSettings), | ||
| 246 | }), | ||
| 247 | ), | ||
| 248 | Layer.provide( | ||
| 249 | Layer.mock(SourceControlRepositoryService.SourceControlRepositoryService)({ | ||
| 250 | cloneRepository: () => | ||
| 251 | Effect.fail( | ||
| 252 | new SourceControlRepositoryError({ | ||
| 253 | provider: "unknown", | ||
| 254 | operation: "cloneRepository", | ||
| 255 | detail: `fatal: authentication failed for ${secretRemote}`, | ||
| 256 | }), | ||
| 257 | ), | ||
| 258 | }), | ||
| 259 | ), | ||
| 260 | Layer.provide(Layer.mock(ThreadManagement.ThreadManagementService)({})), | ||
| 261 | Layer.provide(NodeServices.layer), | ||
| 262 | ); | ||
| 263 | |||
| 264 | const error = yield* Effect.gen(function* () { | ||
| 265 | const service = yield* ProjectMcp.ProjectMcpService; | ||
| 266 | return yield* service | ||
| 267 | .create(scope, { | ||
| 268 | title: "Private clone", | ||
| 269 | source: { | ||
| 270 | type: "clone", | ||
| 271 | destinationPath: "/work/private-clone", | ||
| 272 | remoteUrl: secretRemote, | ||
| 273 | }, | ||
| 274 | clientRequestId: "private-clone", | ||
| 275 | }) | ||
| 276 | .pipe(Effect.flip); | ||
| 277 | }).pipe(Effect.provide(testLayer)); | ||
| 278 | |||
| 279 | expect(error).toMatchObject({ | ||
| 280 | code: "operation_failed", | ||
| 281 | message: "Unable to clone the requested repository.", | ||
| 282 | }); | ||
| 283 | expect(error.message).not.toMatch(/oauth-token|example\.com|fatal|authentication/i); | ||
| 284 | expect(error).not.toHaveProperty("cause"); | ||
| 285 | }), | ||
| 286 | ); | ||
| 287 | |||
| 230 | it.effect("clones before registration and requires explicit cascading for nonempty projects", () => | 288 | it.effect("clones before registration and requires explicit cascading for nonempty projects", () => |
| 231 | Effect.gen(function* () { | 289 | Effect.gen(function* () { |
| 232 | const projectId = ProjectId.make("project:mcp:provider-session-project-mcp:clone-and-delete"); | 290 | const projectId = ProjectId.make("project:mcp:provider-session-project-mcp:clone-and-delete"); |
| @@ -58,8 +58,14 @@ function failure(code: ProjectMcpFailure["code"], message: string): ProjectMcpFa · 57 unmodified lines | |||
| 58 | return new ProjectMcpFailure({ code, message }); | 58 | return new ProjectMcpFailure({ code, message }); |
| 59 | } | 59 | } |
| 60 | 60 | ||
| 61 | function errorMessage(error: unknown): string { | 61 | function redactOperationFailure(operation: string, publicMessage: string) { |
| 62 | return error instanceof Error ? error.message : String(error); | 62 | return <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, ProjectMcpFailure, R> => |
| 63 | effect.pipe( | ||
| 64 | Effect.tapError((error) => | ||
| 65 | Effect.logWarning("Project MCP operation failed.", { operation, error }), | ||
| 66 | ), | ||
| 67 | Effect.mapError(() => failure("operation_failed", publicMessage)), | ||
| 68 | ); | ||
| 63 | } | 69 | } |
| 64 | 70 | ||
| 65 | function stablePart(value: string): string { | 71 | function stablePart(value: string): string { |
| @@ -113,9 +119,7 @@ const make = Effect.gen(function* () { · 47 unmodified lines | |||
| 113 | ); | 119 | ); |
| 114 | 120 | ||
| 115 | const loadSettings = serverSettings.getSettings.pipe( | 121 | const loadSettings = serverSettings.getSettings.pipe( |
| 116 | Effect.mapError((error) => | 122 | redactOperationFailure("read-server-settings", "Unable to read project defaults."), |
| 117 | failure("operation_failed", `Unable to read server settings: ${errorMessage(error)}`), | ||
| 118 | ), | ||
| 119 | ); | 123 | ); |
| 120 | 124 | ||
| 121 | const projectWorkspaceDefaults = Effect.fn("ProjectMcpService.projectWorkspaceDefaults")( | 125 | const projectWorkspaceDefaults = Effect.fn("ProjectMcpService.projectWorkspaceDefaults")( |
| @@ -171,14 +175,7 @@ const make = Effect.gen(function* () { · 49 unmodified lines | |||
| 171 | const loadProject = Effect.fn("ProjectMcpService.loadProject")(function* (projectId: ProjectId) { | 175 | const loadProject = Effect.fn("ProjectMcpService.loadProject")(function* (projectId: ProjectId) { |
| 172 | const project = yield* projects | 176 | const project = yield* projects |
| 173 | .getById(projectId) | 177 | .getById(projectId) |
| 174 | .pipe( | 178 | .pipe(redactOperationFailure("read-project", "Unable to read the requested project.")); |
| 175 | Effect.mapError((error) => | ||
| 176 | failure( | ||
| 177 | "operation_failed", | ||
| 178 | `Unable to read project ${projectId}: ${errorMessage(error)}`, | ||
| 179 | ), | ||
| 180 | ), | ||
| 181 | ); | ||
| 182 | if (Option.isNone(project)) { | 179 | if (Option.isNone(project)) { |
| 183 | return yield* failure("project_not_found", `Project '${projectId}' was not found.`); | 180 | return yield* failure("project_not_found", `Project '${projectId}' was not found.`); |
| 184 | } | 181 | } |
| @@ -189,9 +186,7 @@ const make = Effect.gen(function* () { · 4 unmodified lines | |||
| 189 | Effect.gen(function* () { | 186 | Effect.gen(function* () { |
| 190 | yield* requireCapability(scope); | 187 | yield* requireCapability(scope); |
| 191 | const snapshot = yield* projects.snapshot.pipe( | 188 | const snapshot = yield* projects.snapshot.pipe( |
| 192 | Effect.mapError((error) => | 189 | redactOperationFailure("list-projects", "Unable to list projects."), |
| 193 | failure("operation_failed", `Unable to list projects: ${errorMessage(error)}`), | ||
| 194 | ), | ||
| 195 | ); | 190 | ); |
| 196 | const settings = yield* loadSettings; | 191 | const settings = yield* loadSettings; |
| 197 | const cursor = input.cursor ?? 0; | 192 | const cursor = input.cursor ?? 0; |
| @@ -230,11 +225,9 @@ const make = Effect.gen(function* () { · 32 unmodified lines | |||
| 230 | const existing = yield* projects | 225 | const existing = yield* projects |
| 231 | .getById(projectId, { includeDeleted: true }) | 226 | .getById(projectId, { includeDeleted: true }) |
| 232 | .pipe( | 227 | .pipe( |
| 233 | Effect.mapError((error) => | 228 | redactOperationFailure( |
| 234 | failure( | 229 | "read-project-create-receipt", |
| 235 | "operation_failed", | 230 | "Unable to check the project creation result.", |
| 236 | `Unable to retry project creation: ${errorMessage(error)}`, | ||
| 237 | ), | ||
| 238 | ), | 231 | ), |
| 239 | ); | 232 | ); |
| 240 | if (Option.isSome(existing)) { | 233 | if (Option.isSome(existing)) { |
| @@ -276,8 +269,9 @@ const make = Effect.gen(function* () { · 35 unmodified lines | |||
| 276 | ...(input.source.protocol === undefined ? {} : { protocol: input.source.protocol }), | 269 | ...(input.source.protocol === undefined ? {} : { protocol: input.source.protocol }), |
| 277 | }) | 270 | }) |
| 278 | .pipe( | 271 | .pipe( |
| 279 | Effect.mapError((error) => | 272 | redactOperationFailure( |
| 280 | failure("operation_failed", `Unable to clone project: ${errorMessage(error)}`), | 273 | "clone-project-repository", |
| 274 | "Unable to clone the requested repository.", | ||
| 281 | ), | 275 | ), |
| 282 | ); | 276 | ); |
| 283 | workspaceRoot = cloned.cwd; | 277 | workspaceRoot = cloned.cwd; |
| @@ -300,9 +294,7 @@ const make = Effect.gen(function* () { · 16 unmodified lines | |||
| 300 | ...(input.scripts === undefined ? {} : { scripts: input.scripts }), | 294 | ...(input.scripts === undefined ? {} : { scripts: input.scripts }), |
| 301 | }) | 295 | }) |
| 302 | .pipe( | 296 | .pipe( |
| 303 | Effect.mapError((error) => | 297 | redactOperationFailure("create-project", "Unable to create the requested project."), |
| 304 | failure("operation_failed", `Unable to create project: ${errorMessage(error)}`), | ||
| 305 | ), | ||
| 306 | ); | 298 | ); |
| 307 | return yield* projectView(project, settings); | 299 | return yield* projectView(project, settings); |
| 308 | }), | 300 | }), |
| @@ -345,10 +337,16 @@ const make = Effect.gen(function* () { · 36 unmodified lines | |||
| 345 | ...(input.scripts === undefined ? {} : { scripts: input.scripts }), | 337 | ...(input.scripts === undefined ? {} : { scripts: input.scripts }), |
| 346 | }) | 338 | }) |
| 347 | .pipe( | 339 | .pipe( |
| 340 | Effect.tapError((error) => | ||
| 341 | Effect.logWarning("Project MCP operation failed.", { | ||
| 342 | operation: "update-project", | ||
| 343 | error, | ||
| 344 | }), | ||
| 345 | ), | ||
| 348 | Effect.mapError((error) => | 346 | Effect.mapError((error) => |
| 349 | error._tag === "ProjectNotFoundError" | 347 | error._tag === "ProjectNotFoundError" |
| 350 | ? failure("project_not_found", error.message) | 348 | ? failure("project_not_found", `Project '${input.projectId}' was not found.`) |
| 351 | : failure("operation_failed", `Unable to update project: ${errorMessage(error)}`), | 349 | : failure("operation_failed", "Unable to update the requested project."), |
| 352 | ), | 350 | ), |
| 353 | ); | 351 | ); |
| 354 | return yield* projectView(project, settings); | 352 | return yield* projectView(project, settings); |
| @@ -359,14 +357,7 @@ const make = Effect.gen(function* () { · 4 unmodified lines | |||
| 359 | yield* requireCapability(scope); | 357 | yield* requireCapability(scope); |
| 360 | const projectOption = yield* projects | 358 | const projectOption = yield* projects |
| 361 | .getById(input.projectId, { includeDeleted: true }) | 359 | .getById(input.projectId, { includeDeleted: true }) |
| 362 | .pipe( | 360 | .pipe(redactOperationFailure("read-project-for-delete", "Unable to read the project.")); |
| 363 | Effect.mapError((error) => | ||
| 364 | failure( | ||
| 365 | "operation_failed", | ||
| 366 | `Unable to read project ${input.projectId}: ${errorMessage(error)}`, | ||
| 367 | ), | ||
| 368 | ), | ||
| 369 | ); | ||
| 370 | if (Option.isNone(projectOption)) { | 361 | if (Option.isNone(projectOption)) { |
| 371 | return yield* failure("project_not_found", `Project '${input.projectId}' was not found.`); | 362 | return yield* failure("project_not_found", `Project '${input.projectId}' was not found.`); |
| 372 | } | 363 | } |
| @@ -385,8 +376,9 @@ const make = Effect.gen(function* () { · 12 unmodified lines | |||
| 385 | threads.getShellSnapshot({ location: "active" }), | 376 | threads.getShellSnapshot({ location: "active" }), |
| 386 | threads.getShellSnapshot({ location: "archive" }), | 377 | threads.getShellSnapshot({ location: "archive" }), |
| 387 | ]).pipe( | 378 | ]).pipe( |
| 388 | Effect.mapError((error) => | 379 | redactOperationFailure( |
| 389 | failure("operation_failed", `Unable to inspect project threads: ${errorMessage(error)}`), | 380 | "inspect-project-threads", |
| 381 | "Unable to inspect the project's threads.", | ||
| 390 | ), | 382 | ), |
| 391 | ); | 383 | ); |
| 392 | const projectThreads = [ | 384 | const projectThreads = [ |
| @@ -423,8 +415,9 @@ const make = Effect.gen(function* () { · 30 unmodified lines | |||
| 423 | }), | 415 | }), |
| 424 | { concurrency: 1, discard: true }, | 416 | { concurrency: 1, discard: true }, |
| 425 | ).pipe( | 417 | ).pipe( |
| 426 | Effect.mapError((error) => | 418 | redactOperationFailure( |
| 427 | failure("operation_failed", `Unable to delete project threads: ${errorMessage(error)}`), | 419 | "delete-project-threads", |
| 420 | "Unable to delete the project's thread records.", | ||
| 428 | ), | 421 | ), |
| 429 | ); | 422 | ); |
| 430 | yield* projects | 423 | yield* projects |
| @@ -437,11 +430,7 @@ const make = Effect.gen(function* () { · 6 unmodified lines | |||
| 437 | }), | 430 | }), |
| 438 | projectId: input.projectId, | 431 | projectId: input.projectId, |
| 439 | }) | 432 | }) |
| 440 | .pipe( | 433 | .pipe(redactOperationFailure("delete-project", "Unable to delete the requested project.")); |
| 441 | Effect.mapError((error) => | ||
| 442 | failure("operation_failed", `Unable to delete project: ${errorMessage(error)}`), | ||
| 443 | ), | ||
| 444 | ); | ||
| 445 | return { | 434 | return { |
| 446 | projectId: input.projectId, | 435 | projectId: input.projectId, |
| 447 | deleted: true, | 436 | deleted: true, |
| @@ -58,7 +58,7 @@ export const ProjectCreateTool = Tool.make("t3_project_create", { · 57 unmodified lines | |||
| 58 | .annotate(Tool.Title, "Create a T3 project") | 58 | .annotate(Tool.Title, "Create a T3 project") |
| 59 | .annotate(Tool.Readonly, false) | 59 | .annotate(Tool.Readonly, false) |
| 60 | .annotate(Tool.Destructive, false) | 60 | .annotate(Tool.Destructive, false) |
| 61 | .annotate(Tool.Idempotent, true) | 61 | .annotate(Tool.Idempotent, false) |
| 62 | .annotate(Tool.OpenWorld, true); | 62 | .annotate(Tool.OpenWorld, true); |
| 63 | 63 | ||
| 64 | export const ProjectUpdateTool = Tool.make("t3_project_update", { | 64 | export const ProjectUpdateTool = Tool.make("t3_project_update", { |
| @@ -118,6 +118,12 @@ it.effect("production mcp layer lists worktree tools over http", () => · 117 unmodified lines | |||
| 118 | expect(toolNames).toContain("delegate_task"); | 118 | expect(toolNames).toContain("delegate_task"); |
| 119 | expect(toolNames).toContain("t3_project_list"); | 119 | expect(toolNames).toContain("t3_project_list"); |
| 120 | expect(toolNames).toContain("t3_project_delete"); | 120 | expect(toolNames).toContain("t3_project_delete"); |
| 121 | const projectCreate = tools.find((tool) => tool.name === "t3_project_create"); | ||
| 122 | expect( | ||
| 123 | projectCreate?.annotations && "idempotentHint" in projectCreate.annotations | ||
| 124 | ? projectCreate.annotations.idempotentHint | ||
| 125 | : undefined, | ||
| 126 | ).not.toBe(true); | ||
| 121 | 127 | ||
| 122 | // The handoff tool mutates thread state, reaches the network (origin | 128 | // The handoff tool mutates thread state, reaches the network (origin |
| 123 | // fetch), and runs project setup scripts, so its MCP hints must not | 129 | // fetch), and runs project setup scripts, so its MCP hints must not |
| @@ -209,7 +209,7 @@ it.effect("clones a looked-up repository into the requested destination", () => · 208 unmodified lines | |||
| 209 | assert.deepStrictEqual(cloneCalls, [ | 209 | assert.deepStrictEqual(cloneCalls, [ |
| 210 | { | 210 | { |
| 211 | cwd: `${parent}/missing/nested`, | 211 | cwd: `${parent}/missing/nested`, |
| 212 | args: ["clone", CLONE_URLS.url, "t3code"], | 212 | args: ["clone", "--origin", "origin", CLONE_URLS.url, "t3code"], |
| 213 | }, | 213 | }, |
| 214 | ]); | 214 | ]); |
| 215 | }).pipe( | 215 | }).pipe( |
| @@ -310,7 +310,7 @@ export const make = Effect.gen(function* () { · 309 unmodified lines | |||
| 310 | yield* git.execute({ | 310 | yield* git.execute({ |
| 311 | operation: "SourceControlRepositoryService.cloneRepository", | 311 | operation: "SourceControlRepositoryService.cloneRepository", |
| 312 | cwd: preparedDestination.parentPath, | 312 | cwd: preparedDestination.parentPath, |
| 313 | args: ["clone", remoteUrl, preparedDestination.directoryName], | 313 | args: ["clone", "--origin", "origin", remoteUrl, preparedDestination.directoryName], |
| 314 | timeoutMs: 120_000, | 314 | timeoutMs: 120_000, |
| 315 | maxOutputBytes: 256 * 1024, | 315 | maxOutputBytes: 256 * 1024, |
| 316 | }); | 316 | }); |
| @@ -26,7 +26,13 @@ import { · 25 unmodified lines | |||
| 26 | RuntimeMode, | 26 | RuntimeMode, |
| 27 | } from "./providerPolicy.ts"; | 27 | } from "./providerPolicy.ts"; |
| 28 | import { ProviderInstanceId } from "./providerInstance.ts"; | 28 | import { ProviderInstanceId } from "./providerInstance.ts"; |
| 29 | import { ProjectScript, ProjectIconOverride } from "./project.ts"; | 29 | import { |
| 30 | Project, | ||
| 31 | ProjectFaviconPath, | ||
| 32 | ProjectIconOverride, | ||
| 33 | ProjectScript, | ||
| 34 | ProjectScriptIcon, | ||
| 35 | } from "./project.ts"; | ||
| 30 | export { ProjectIconColor, ProjectIconOverride } from "./project.ts"; | 36 | export { ProjectIconColor, ProjectIconOverride } from "./project.ts"; |
| 31 | import { RepositoryIdentity, ThreadEnvMode } from "./environment.ts"; | 37 | import { RepositoryIdentity, ThreadEnvMode } from "./environment.ts"; |
| 32 | import { OrchestrationProjectShell } from "./orchestrationProject.ts"; | 38 | import { OrchestrationProjectShell } from "./orchestrationProject.ts"; |
| @@ -61,11 +67,7 @@ export { · 28 unmodified lines | |||
| 61 | export const CorrelationId = CommandId; | 67 | export const CorrelationId = CommandId; |
| 62 | export type CorrelationId = typeof CorrelationId.Type; | 68 | export type CorrelationId = typeof CorrelationId.Type; |
| 63 | 69 | ||
| 64 | export const ProjectFaviconPath = TrimmedNonEmptyString.check( | 70 | export { ProjectFaviconPath } from "./project.ts"; |
| 65 | Schema.isMaxLength(1024), | ||
| 66 | Schema.isPattern(/\.(?:avif|gif|ico|jpe?g|png|svg|webp)$/i), | ||
| 67 | ); | ||
| 68 | export type ProjectFaviconPath = typeof ProjectFaviconPath.Type; | ||
| 69 | 71 | ||
| 70 | export const OrchestrationProject = Schema.Struct({ | 72 | export const OrchestrationProject = Schema.Struct({ |
| 71 | id: ProjectId, | 73 | id: ProjectId, |
| @@ -3,6 +3,7 @@ import { describe, expect, it } from "vite-plus/test"; · 2 unmodified lines | |||
| 3 | 3 | ||
| 4 | import { | 4 | import { |
| 5 | ProjectReadFileError, | 5 | ProjectReadFileError, |
| 6 | ProjectMutation, | ||
| 6 | ProjectSearchContentsError, | 7 | ProjectSearchContentsError, |
| 7 | ProjectSearchContentsInput, | 8 | ProjectSearchContentsInput, |
| 8 | ProjectSearchEntriesError, | 9 | ProjectSearchEntriesError, |
| @@ -12,6 +13,7 @@ import { · 3 unmodified lines | |||
| 12 | 13 | ||
| 13 | const decodeSearchEntriesInput = Schema.decodeUnknownSync(ProjectSearchEntriesInput); | 14 | const decodeSearchEntriesInput = Schema.decodeUnknownSync(ProjectSearchEntriesInput); |
| 14 | const decodeSearchContentsInput = Schema.decodeUnknownSync(ProjectSearchContentsInput); | 15 | const decodeSearchContentsInput = Schema.decodeUnknownSync(ProjectSearchContentsInput); |
| 16 | const decodeProjectMutation = Schema.decodeUnknownSync(ProjectMutation); | ||
| 15 | 17 | ||
| 16 | describe("project search inputs", () => { | 18 | describe("project search inputs", () => { |
| 17 | it("allows an empty entries query for bounded frecency browsing", () => { | 19 | it("allows an empty entries query for bounded frecency browsing", () => { |
| @@ -37,6 +39,31 @@ describe("project search inputs", () => { · 19 unmodified lines | |||
| 37 | }); | 39 | }); |
| 38 | }); | 40 | }); |
| 39 | 41 | ||
| 42 | describe("project mutations", () => { | ||
| 43 | it("rejects favicon paths the asset endpoint cannot serve", () => { | ||
| 44 | const created = decodeProjectMutation({ | ||
| 45 | type: "project.create", | ||
| 46 | commandId: "command-project-create", | ||
| 47 | projectId: "project-1", | ||
| 48 | title: "Project", | ||
| 49 | workspaceRoot: "/work/project", | ||
| 50 | faviconPath: "/work/project/favicon.png", | ||
| 51 | }); | ||
| 52 | expect(created).toMatchObject({ | ||
| 53 | type: "project.create", | ||
| 54 | faviconPath: "/work/project/favicon.png", | ||
| 55 | }); | ||
| 56 | expect(() => | ||
| 57 | decodeProjectMutation({ | ||
| 58 | type: "project.update", | ||
| 59 | commandId: "command-project-update", | ||
| 60 | projectId: "project-1", | ||
| 61 | faviconPath: "/work/project/.env", | ||
| 62 | }), | ||
| 63 | ).toThrow(); | ||
| 64 | }); | ||
| 65 | }); | ||
| 66 | |||
| 40 | describe("project RPC errors", () => { | 67 | describe("project RPC errors", () => { |
| 41 | it("derives stable messages from structured request context while retaining causes", () => { | 68 | it("derives stable messages from structured request context while retaining causes", () => { |
| 42 | const cause = new Error("sensitive platform detail"); | 69 | const cause = new Error("sensitive platform detail"); |
| @@ -16,6 +16,12 @@ const PROJECT_SEARCH_CONTENTS_MAX_LIMIT = 500; · 15 unmodified lines | |||
| 16 | const PROJECT_WRITE_FILE_PATH_MAX_LENGTH = 512; | 16 | const PROJECT_WRITE_FILE_PATH_MAX_LENGTH = 512; |
| 17 | const PROJECT_READ_FILE_PATH_MAX_LENGTH = 512; | 17 | const PROJECT_READ_FILE_PATH_MAX_LENGTH = 512; |
| 18 | 18 | ||
| 19 | export const ProjectFaviconPath = TrimmedNonEmptyString.check( | ||
| 20 | Schema.isMaxLength(1024), | ||
| 21 | Schema.isPattern(/\.(?:avif|gif|ico|jpe?g|png|svg|webp)$/i), | ||
| 22 | ); | ||
| 23 | export type ProjectFaviconPath = typeof ProjectFaviconPath.Type; | ||
| 24 | |||
| 19 | export const ProjectScriptIcon = Schema.Literals([ | 25 | export const ProjectScriptIcon = Schema.Literals([ |
| 20 | "play", | 26 | "play", |
| 21 | "test", | 27 | "test", |
| @@ -84,7 +90,7 @@ export const Project = Schema.Struct({ · 62 unmodified lines | |||
| 84 | title: TrimmedNonEmptyString, | 90 | title: TrimmedNonEmptyString, |
| 85 | workspaceRoot: TrimmedNonEmptyString, | 91 | workspaceRoot: TrimmedNonEmptyString, |
| 86 | repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)), | 92 | repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)), |
| 87 | faviconPath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), | 93 | faviconPath: Schema.optional(Schema.NullOr(ProjectFaviconPath)), |
| 88 | projectIcon: Schema.optional(Schema.NullOr(ProjectIconOverride)), | 94 | projectIcon: Schema.optional(Schema.NullOr(ProjectIconOverride)), |
| 89 | defaultModelSelection: Schema.NullOr(ModelSelection), | 95 | defaultModelSelection: Schema.NullOr(ModelSelection), |
| 90 | defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)), | 96 | defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)), |
| @@ -123,7 +129,7 @@ export const ProjectMutation = Schema.Union([ · 32 unmodified lines | |||
| 123 | createWorkspaceRootIfMissing: Schema.optional(Schema.Boolean), | 129 | createWorkspaceRootIfMissing: Schema.optional(Schema.Boolean), |
| 124 | defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)), | 130 | defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)), |
| 125 | defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)), | 131 | defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)), |
| 126 | faviconPath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), | 132 | faviconPath: Schema.optional(Schema.NullOr(ProjectFaviconPath)), |
| 127 | scripts: Schema.optional(Schema.Array(ProjectScript)), | 133 | scripts: Schema.optional(Schema.Array(ProjectScript)), |
| 128 | }), | 134 | }), |
| 129 | Schema.Struct({ | 135 | Schema.Struct({ |
| @@ -135,7 +141,7 @@ export const ProjectMutation = Schema.Union([ · 5 unmodified lines | |||
| 135 | defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)), | 141 | defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)), |
| 136 | autoPull: Schema.optional(Schema.Boolean), | 142 | autoPull: Schema.optional(Schema.Boolean), |
| 137 | projectIcon: Schema.optional(Schema.NullOr(ProjectIconOverride)), | 143 | projectIcon: Schema.optional(Schema.NullOr(ProjectIconOverride)), |
| 138 | faviconPath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), | 144 | faviconPath: Schema.optional(Schema.NullOr(ProjectFaviconPath)), |
| 139 | defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)), | 145 | defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)), |
| 140 | scripts: Schema.optional(Schema.Array(ProjectScript)), | 146 | scripts: Schema.optional(Schema.Array(ProjectScript)), |
| 141 | }), | 147 | }), |
| @@ -1,10 +1,16 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | import * as Schema from "effect/Schema"; | 2 | import * as Schema from "effect/Schema"; |
| 3 | 3 | ||
| 4 | import { ProjectMcpCreateInput, ProjectMcpFailure, ProjectMcpListInput } from "./projectMcp.ts"; | 4 | import { |
| 5 | ProjectMcpCreateInput, | ||
| 6 | ProjectMcpFailure, | ||
| 7 | ProjectMcpListInput, | ||
| 8 | ProjectMcpUpdateInput, | ||
| 9 | } from "./projectMcp.ts"; | ||
| 5 | 10 | ||
| 6 | const decodeCreateInput = Schema.decodeUnknownSync(ProjectMcpCreateInput); | 11 | const decodeCreateInput = Schema.decodeUnknownSync(ProjectMcpCreateInput); |
| 7 | const decodeListInput = Schema.decodeUnknownSync(ProjectMcpListInput); | 12 | const decodeListInput = Schema.decodeUnknownSync(ProjectMcpListInput); |
| 13 | const decodeUpdateInput = Schema.decodeUnknownSync(ProjectMcpUpdateInput); | ||
| 8 | const decodeFailure = Schema.decodeUnknownSync(ProjectMcpFailure); | 14 | const decodeFailure = Schema.decodeUnknownSync(ProjectMcpFailure); |
| 9 | 15 | ||
| 10 | describe("project MCP contracts", () => { | 16 | describe("project MCP contracts", () => { |
| @@ -71,4 +77,25 @@ describe("project MCP contracts", () => { · 60 unmodified lines | |||
| 71 | }), | 77 | }), |
| 72 | ).toMatchObject({ code: "project_deleted" }); | 78 | ).toMatchObject({ code: "project_deleted" }); |
| 73 | }); | 79 | }); |
| 80 | |||
| 81 | it("accepts only favicon paths served by the project asset route", () => { | ||
| 82 | expect( | ||
| 83 | decodeCreateInput({ | ||
| 84 | title: "Icon project", | ||
| 85 | source: { type: "existing_directory", workspaceRoot: "/work/icon" }, | ||
| 86 | faviconPath: "/work/icon/favicon.svg", | ||
| 87 | }).faviconPath, | ||
| 88 | ).toBe("/work/icon/favicon.svg"); | ||
| 89 | expect(() => | ||
| 90 | decodeCreateInput({ | ||
| 91 | title: "Secret project", | ||
| 92 | source: { type: "existing_directory", workspaceRoot: "/work/secret" }, | ||
| 93 | faviconPath: "/work/secret/.env", | ||
| 94 | }), | ||
| 95 | ).toThrow(); | ||
| 96 | expect(decodeUpdateInput({ projectId: "project-1", faviconPath: null }).faviconPath).toBeNull(); | ||
| 97 | expect(() => | ||
| 98 | decodeUpdateInput({ projectId: "project-1", faviconPath: "/work/secret/.env" }), | ||
| 99 | ).toThrow(); | ||
| 100 | }); | ||
| 74 | }); | 101 | }); |
| @@ -3,7 +3,7 @@ import * as Schema from "effect/Schema"; · 2 unmodified lines | |||
| 3 | import { NonNegativeInt, PositiveInt, ProjectId, TrimmedNonEmptyString } from "./baseSchemas.ts"; | 3 | import { NonNegativeInt, PositiveInt, ProjectId, TrimmedNonEmptyString } from "./baseSchemas.ts"; |
| 4 | import { ThreadEnvMode } from "./environment.ts"; | 4 | import { ThreadEnvMode } from "./environment.ts"; |
| 5 | import { ModelSelection } from "./modelSelection.ts"; | 5 | import { ModelSelection } from "./modelSelection.ts"; |
| 6 | import { Project, ProjectScript } from "./project.ts"; | 6 | import { Project, ProjectFaviconPath, ProjectScript } from "./project.ts"; |
| 7 | import { SourceControlCloneProtocol, SourceControlProviderKind } from "./sourceControl.ts"; | 7 | import { SourceControlCloneProtocol, SourceControlProviderKind } from "./sourceControl.ts"; |
| 8 | 8 | ||
| 9 | const ProjectMcpClientRequestId = TrimmedNonEmptyString.check(Schema.isMaxLength(256)).annotate({ | 9 | const ProjectMcpClientRequestId = TrimmedNonEmptyString.check(Schema.isMaxLength(256)).annotate({ |
| @@ -91,7 +91,7 @@ export const ProjectMcpCreateInput = Schema.Struct({ · 81 unmodified lines | |||
| 91 | source: ProjectMcpCreateSource, | 91 | source: ProjectMcpCreateSource, |
| 92 | defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)), | 92 | defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)), |
| 93 | defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)), | 93 | defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)), |
| 94 | faviconPath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), | 94 | faviconPath: Schema.optional(Schema.NullOr(ProjectFaviconPath)), |
| 95 | scripts: Schema.optional(Schema.Array(ProjectScript)), | 95 | scripts: Schema.optional(Schema.Array(ProjectScript)), |
| 96 | clientRequestId: Schema.optional(ProjectMcpClientRequestId), | 96 | clientRequestId: Schema.optional(ProjectMcpClientRequestId), |
| 97 | }); | 97 | }); |
| @@ -103,7 +103,7 @@ export const ProjectMcpUpdateInput = Schema.Struct({ · 5 unmodified lines | |||
| 103 | workspaceRoot: Schema.optional(TrimmedNonEmptyString), | 103 | workspaceRoot: Schema.optional(TrimmedNonEmptyString), |
| 104 | defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)), | 104 | defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)), |
| 105 | defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)), | 105 | defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)), |
| 106 | faviconPath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), | 106 | faviconPath: Schema.optional(Schema.NullOr(ProjectFaviconPath)), |
| 107 | scripts: Schema.optional(Schema.Array(ProjectScript)), | 107 | scripts: Schema.optional(Schema.Array(ProjectScript)), |
| 108 | clientRequestId: Schema.optional(ProjectMcpClientRequestId), | 108 | clientRequestId: Schema.optional(ProjectMcpClientRequestId), |
| 109 | }); | 109 | }); |