Unified · Split
| @@ -11,6 +11,7 @@ import { · 10 unmodified lines | ||
| 11 | 11 | ProviderInstanceId, |
| 12 | 12 | type ServerSettings, |
| 13 | 13 | ServerSettingsError, |
| 14 | + SourceControlRepositoryError, | |
| 14 | 15 | ThreadId, |
| 15 | 16 | } from "@t3tools/contracts"; |
| 16 | 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 | 224 | .pipe(Effect.flip); |
| 224 | 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 | 228 | expect(yield* Ref.get(createCalls)).toBe(0); |
| 226 | 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 | 288 | it.effect("clones before registration and requires explicit cascading for nonempty projects", () => |
| 231 | 289 | Effect.gen(function* () { |
| 232 | 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 | 58 | return new ProjectMcpFailure({ code, message }); |
| 59 | 59 | } |
| 60 | 60 | |
| 61 | −function errorMessage(error: unknown): string { | |
| 62 | − return error instanceof Error ? error.message : String(error); | |
| 61 | +function redactOperationFailure(operation: string, publicMessage: string) { | |
| 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 | 71 | function stablePart(value: string): string { |
| @@ -113,9 +119,7 @@ const make = Effect.gen(function* () { · 47 unmodified lines | ||
| 113 | 119 | ); |
| 114 | 120 | |
| 115 | 121 | const loadSettings = serverSettings.getSettings.pipe( |
| 116 | − Effect.mapError((error) => | |
| 117 | − failure("operation_failed", `Unable to read server settings: ${errorMessage(error)}`), | |
| 118 | − ), | |
| 122 | + redactOperationFailure("read-server-settings", "Unable to read project defaults."), | |
| 119 | 123 | ); |
| 120 | 124 | |
| 121 | 125 | const projectWorkspaceDefaults = Effect.fn("ProjectMcpService.projectWorkspaceDefaults")( |
| @@ -171,14 +175,7 @@ const make = Effect.gen(function* () { · 49 unmodified lines | ||
| 171 | 175 | const loadProject = Effect.fn("ProjectMcpService.loadProject")(function* (projectId: ProjectId) { |
| 172 | 176 | const project = yield* projects |
| 173 | 177 | .getById(projectId) |
| 174 | − .pipe( | |
| 175 | − Effect.mapError((error) => | |
| 176 | − failure( | |
| 177 | − "operation_failed", | |
| 178 | − `Unable to read project ${projectId}: ${errorMessage(error)}`, | |
| 179 | − ), | |
| 180 | − ), | |
| 181 | − ); | |
| 178 | + .pipe(redactOperationFailure("read-project", "Unable to read the requested project.")); | |
| 182 | 179 | if (Option.isNone(project)) { |
| 183 | 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 | 186 | Effect.gen(function* () { |
| 190 | 187 | yield* requireCapability(scope); |
| 191 | 188 | const snapshot = yield* projects.snapshot.pipe( |
| 192 | − Effect.mapError((error) => | |
| 193 | − failure("operation_failed", `Unable to list projects: ${errorMessage(error)}`), | |
| 194 | − ), | |
| 189 | + redactOperationFailure("list-projects", "Unable to list projects."), | |
| 195 | 190 | ); |
| 196 | 191 | const settings = yield* loadSettings; |
| 197 | 192 | const cursor = input.cursor ?? 0; |
| @@ -230,11 +225,9 @@ const make = Effect.gen(function* () { · 32 unmodified lines | ||
| 230 | 225 | const existing = yield* projects |
| 231 | 226 | .getById(projectId, { includeDeleted: true }) |
| 232 | 227 | .pipe( |
| 233 | − Effect.mapError((error) => | |
| 234 | − failure( | |
| 235 | − "operation_failed", | |
| 236 | − `Unable to retry project creation: ${errorMessage(error)}`, | |
| 237 | − ), | |
| 228 | + redactOperationFailure( | |
| 229 | + "read-project-create-receipt", | |
| 230 | + "Unable to check the project creation result.", | |
| 238 | 231 | ), |
| 239 | 232 | ); |
| 240 | 233 | if (Option.isSome(existing)) { |
| @@ -276,8 +269,9 @@ const make = Effect.gen(function* () { · 35 unmodified lines | ||
| 276 | 269 | ...(input.source.protocol === undefined ? {} : { protocol: input.source.protocol }), |
| 277 | 270 | }) |
| 278 | 271 | .pipe( |
| 279 | − Effect.mapError((error) => | |
| 280 | − failure("operation_failed", `Unable to clone project: ${errorMessage(error)}`), | |
| 272 | + redactOperationFailure( | |
| 273 | + "clone-project-repository", | |
| 274 | + "Unable to clone the requested repository.", | |
| 281 | 275 | ), |
| 282 | 276 | ); |
| 283 | 277 | workspaceRoot = cloned.cwd; |
| @@ -300,9 +294,7 @@ const make = Effect.gen(function* () { · 16 unmodified lines | ||
| 300 | 294 | ...(input.scripts === undefined ? {} : { scripts: input.scripts }), |
| 301 | 295 | }) |
| 302 | 296 | .pipe( |
| 303 | − Effect.mapError((error) => | |
| 304 | − failure("operation_failed", `Unable to create project: ${errorMessage(error)}`), | |
| 305 | − ), | |
| 297 | + redactOperationFailure("create-project", "Unable to create the requested project."), | |
| 306 | 298 | ); |
| 307 | 299 | return yield* projectView(project, settings); |
| 308 | 300 | }), |
| @@ -345,10 +337,16 @@ const make = Effect.gen(function* () { · 36 unmodified lines | ||
| 345 | 337 | ...(input.scripts === undefined ? {} : { scripts: input.scripts }), |
| 346 | 338 | }) |
| 347 | 339 | .pipe( |
| 340 | + Effect.tapError((error) => | |
| 341 | + Effect.logWarning("Project MCP operation failed.", { | |
| 342 | + operation: "update-project", | |
| 343 | + error, | |
| 344 | + }), | |
| 345 | + ), | |
| 348 | 346 | Effect.mapError((error) => |
| 349 | 347 | error._tag === "ProjectNotFoundError" |
| 350 | − ? failure("project_not_found", error.message) | |
| 351 | − : failure("operation_failed", `Unable to update project: ${errorMessage(error)}`), | |
| 348 | + ? failure("project_not_found", `Project '${input.projectId}' was not found.`) | |
| 349 | + : failure("operation_failed", "Unable to update the requested project."), | |
| 352 | 350 | ), |
| 353 | 351 | ); |
| 354 | 352 | return yield* projectView(project, settings); |
| @@ -359,14 +357,7 @@ const make = Effect.gen(function* () { · 4 unmodified lines | ||
| 359 | 357 | yield* requireCapability(scope); |
| 360 | 358 | const projectOption = yield* projects |
| 361 | 359 | .getById(input.projectId, { includeDeleted: true }) |
| 362 | − .pipe( | |
| 363 | − Effect.mapError((error) => | |
| 364 | − failure( | |
| 365 | − "operation_failed", | |
| 366 | − `Unable to read project ${input.projectId}: ${errorMessage(error)}`, | |
| 367 | − ), | |
| 368 | − ), | |
| 369 | − ); | |
| 360 | + .pipe(redactOperationFailure("read-project-for-delete", "Unable to read the project.")); | |
| 370 | 361 | if (Option.isNone(projectOption)) { |
| 371 | 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 | 376 | threads.getShellSnapshot({ location: "active" }), |
| 386 | 377 | threads.getShellSnapshot({ location: "archive" }), |
| 387 | 378 | ]).pipe( |
| 388 | − Effect.mapError((error) => | |
| 389 | − failure("operation_failed", `Unable to inspect project threads: ${errorMessage(error)}`), | |
| 379 | + redactOperationFailure( | |
| 380 | + "inspect-project-threads", | |
| 381 | + "Unable to inspect the project's threads.", | |
| 390 | 382 | ), |
| 391 | 383 | ); |
| 392 | 384 | const projectThreads = [ |
| @@ -423,8 +415,9 @@ const make = Effect.gen(function* () { · 30 unmodified lines | ||
| 423 | 415 | }), |
| 424 | 416 | { concurrency: 1, discard: true }, |
| 425 | 417 | ).pipe( |
| 426 | − Effect.mapError((error) => | |
| 427 | − failure("operation_failed", `Unable to delete project threads: ${errorMessage(error)}`), | |
| 418 | + redactOperationFailure( | |
| 419 | + "delete-project-threads", | |
| 420 | + "Unable to delete the project's thread records.", | |
| 428 | 421 | ), |
| 429 | 422 | ); |
| 430 | 423 | yield* projects |
| @@ -437,11 +430,7 @@ const make = Effect.gen(function* () { · 6 unmodified lines | ||
| 437 | 430 | }), |
| 438 | 431 | projectId: input.projectId, |
| 439 | 432 | }) |
| 440 | − .pipe( | |
| 441 | − Effect.mapError((error) => | |
| 442 | − failure("operation_failed", `Unable to delete project: ${errorMessage(error)}`), | |
| 443 | − ), | |
| 444 | − ); | |
| 433 | + .pipe(redactOperationFailure("delete-project", "Unable to delete the requested project.")); | |
| 445 | 434 | return { |
| 446 | 435 | projectId: input.projectId, |
| 447 | 436 | deleted: true, |
| @@ -58,7 +58,7 @@ export const ProjectCreateTool = Tool.make("t3_project_create", { · 57 unmodified lines | ||
| 58 | 58 | .annotate(Tool.Title, "Create a T3 project") |
| 59 | 59 | .annotate(Tool.Readonly, false) |
| 60 | 60 | .annotate(Tool.Destructive, false) |
| 61 | − .annotate(Tool.Idempotent, true) | |
| 61 | + .annotate(Tool.Idempotent, false) | |
| 62 | 62 | .annotate(Tool.OpenWorld, true); |
| 63 | 63 | |
| 64 | 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 | 118 | expect(toolNames).toContain("delegate_task"); |
| 119 | 119 | expect(toolNames).toContain("t3_project_list"); |
| 120 | 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 | 128 | // The handoff tool mutates thread state, reaches the network (origin |
| 123 | 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 | 209 | assert.deepStrictEqual(cloneCalls, [ |
| 210 | 210 | { |
| 211 | 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 | 215 | }).pipe( |
| @@ -310,7 +310,7 @@ export const make = Effect.gen(function* () { · 309 unmodified lines | ||
| 310 | 310 | yield* git.execute({ |
| 311 | 311 | operation: "SourceControlRepositoryService.cloneRepository", |
| 312 | 312 | cwd: preparedDestination.parentPath, |
| 313 | − args: ["clone", remoteUrl, preparedDestination.directoryName], | |
| 313 | + args: ["clone", "--origin", "origin", remoteUrl, preparedDestination.directoryName], | |
| 314 | 314 | timeoutMs: 120_000, |
| 315 | 315 | maxOutputBytes: 256 * 1024, |
| 316 | 316 | }); |
| @@ -26,7 +26,13 @@ import { · 25 unmodified lines | ||
| 26 | 26 | RuntimeMode, |
| 27 | 27 | } from "./providerPolicy.ts"; |
| 28 | 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 | 36 | export { ProjectIconColor, ProjectIconOverride } from "./project.ts"; |
| 31 | 37 | import { RepositoryIdentity, ThreadEnvMode } from "./environment.ts"; |
| 32 | 38 | import { OrchestrationProjectShell } from "./orchestrationProject.ts"; |
| @@ -61,11 +67,7 @@ export { · 28 unmodified lines | ||
| 61 | 67 | export const CorrelationId = CommandId; |
| 62 | 68 | export type CorrelationId = typeof CorrelationId.Type; |
| 63 | 69 | |
| 64 | −export const ProjectFaviconPath = TrimmedNonEmptyString.check( | |
| 65 | − Schema.isMaxLength(1024), | |
| 66 | − Schema.isPattern(/\.(?:avif|gif|ico|jpe?g|png|svg|webp)$/i), | |
| 67 | −); | |
| 68 | −export type ProjectFaviconPath = typeof ProjectFaviconPath.Type; | |
| 70 | +export { ProjectFaviconPath } from "./project.ts"; | |
| 69 | 71 | |
| 70 | 72 | export const OrchestrationProject = Schema.Struct({ |
| 71 | 73 | id: ProjectId, |
| @@ -3,6 +3,7 @@ import { describe, expect, it } from "vite-plus/test"; · 2 unmodified lines | ||
| 3 | 3 | |
| 4 | 4 | import { |
| 5 | 5 | ProjectReadFileError, |
| 6 | + ProjectMutation, | |
| 6 | 7 | ProjectSearchContentsError, |
| 7 | 8 | ProjectSearchContentsInput, |
| 8 | 9 | ProjectSearchEntriesError, |
| @@ -12,6 +13,7 @@ import { · 3 unmodified lines | ||
| 12 | 13 | |
| 13 | 14 | const decodeSearchEntriesInput = Schema.decodeUnknownSync(ProjectSearchEntriesInput); |
| 14 | 15 | const decodeSearchContentsInput = Schema.decodeUnknownSync(ProjectSearchContentsInput); |
| 16 | +const decodeProjectMutation = Schema.decodeUnknownSync(ProjectMutation); | |
| 15 | 17 | |
| 16 | 18 | describe("project search inputs", () => { |
| 17 | 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 | 67 | describe("project RPC errors", () => { |
| 41 | 68 | it("derives stable messages from structured request context while retaining causes", () => { |
| 42 | 69 | const cause = new Error("sensitive platform detail"); |
| @@ -16,6 +16,12 @@ const PROJECT_SEARCH_CONTENTS_MAX_LIMIT = 500; · 15 unmodified lines | ||
| 16 | 16 | const PROJECT_WRITE_FILE_PATH_MAX_LENGTH = 512; |
| 17 | 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 | 25 | export const ProjectScriptIcon = Schema.Literals([ |
| 20 | 26 | "play", |
| 21 | 27 | "test", |
| @@ -84,7 +90,7 @@ export const Project = Schema.Struct({ · 62 unmodified lines | ||
| 84 | 90 | title: TrimmedNonEmptyString, |
| 85 | 91 | workspaceRoot: TrimmedNonEmptyString, |
| 86 | 92 | repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)), |
| 87 | − faviconPath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), | |
| 93 | + faviconPath: Schema.optional(Schema.NullOr(ProjectFaviconPath)), | |
| 88 | 94 | projectIcon: Schema.optional(Schema.NullOr(ProjectIconOverride)), |
| 89 | 95 | defaultModelSelection: Schema.NullOr(ModelSelection), |
| 90 | 96 | defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)), |
| @@ -123,7 +129,7 @@ export const ProjectMutation = Schema.Union([ · 32 unmodified lines | ||
| 123 | 129 | createWorkspaceRootIfMissing: Schema.optional(Schema.Boolean), |
| 124 | 130 | defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)), |
| 125 | 131 | defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)), |
| 126 | − faviconPath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), | |
| 132 | + faviconPath: Schema.optional(Schema.NullOr(ProjectFaviconPath)), | |
| 127 | 133 | scripts: Schema.optional(Schema.Array(ProjectScript)), |
| 128 | 134 | }), |
| 129 | 135 | Schema.Struct({ |
| @@ -135,7 +141,7 @@ export const ProjectMutation = Schema.Union([ · 5 unmodified lines | ||
| 135 | 141 | defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)), |
| 136 | 142 | autoPull: Schema.optional(Schema.Boolean), |
| 137 | 143 | projectIcon: Schema.optional(Schema.NullOr(ProjectIconOverride)), |
| 138 | − faviconPath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), | |
| 144 | + faviconPath: Schema.optional(Schema.NullOr(ProjectFaviconPath)), | |
| 139 | 145 | defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)), |
| 140 | 146 | scripts: Schema.optional(Schema.Array(ProjectScript)), |
| 141 | 147 | }), |
| @@ -1,10 +1,16 @@ | ||
| 1 | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | 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 | 11 | const decodeCreateInput = Schema.decodeUnknownSync(ProjectMcpCreateInput); |
| 7 | 12 | const decodeListInput = Schema.decodeUnknownSync(ProjectMcpListInput); |
| 13 | +const decodeUpdateInput = Schema.decodeUnknownSync(ProjectMcpUpdateInput); | |
| 8 | 14 | const decodeFailure = Schema.decodeUnknownSync(ProjectMcpFailure); |
| 9 | 15 | |
| 10 | 16 | describe("project MCP contracts", () => { |
| @@ -71,4 +77,25 @@ describe("project MCP contracts", () => { · 60 unmodified lines | ||
| 71 | 77 | }), |
| 72 | 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 | 3 | import { NonNegativeInt, PositiveInt, ProjectId, TrimmedNonEmptyString } from "./baseSchemas.ts"; |
| 4 | 4 | import { ThreadEnvMode } from "./environment.ts"; |
| 5 | 5 | import { ModelSelection } from "./modelSelection.ts"; |
| 6 | −import { Project, ProjectScript } from "./project.ts"; | |
| 6 | +import { Project, ProjectFaviconPath, ProjectScript } from "./project.ts"; | |
| 7 | 7 | import { SourceControlCloneProtocol, SourceControlProviderKind } from "./sourceControl.ts"; |
| 8 | 8 | |
| 9 | 9 | const ProjectMcpClientRequestId = TrimmedNonEmptyString.check(Schema.isMaxLength(256)).annotate({ |
| @@ -91,7 +91,7 @@ export const ProjectMcpCreateInput = Schema.Struct({ · 81 unmodified lines | ||
| 91 | 91 | source: ProjectMcpCreateSource, |
| 92 | 92 | defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)), |
| 93 | 93 | defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)), |
| 94 | − faviconPath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), | |
| 94 | + faviconPath: Schema.optional(Schema.NullOr(ProjectFaviconPath)), | |
| 95 | 95 | scripts: Schema.optional(Schema.Array(ProjectScript)), |
| 96 | 96 | clientRequestId: Schema.optional(ProjectMcpClientRequestId), |
| 97 | 97 | }); |
| @@ -103,7 +103,7 @@ export const ProjectMcpUpdateInput = Schema.Struct({ · 5 unmodified lines | ||
| 103 | 103 | workspaceRoot: Schema.optional(TrimmedNonEmptyString), |
| 104 | 104 | defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)), |
| 105 | 105 | defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)), |
| 106 | − faviconPath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), | |
| 106 | + faviconPath: Schema.optional(Schema.NullOr(ProjectFaviconPath)), | |
| 107 | 107 | scripts: Schema.optional(Schema.Array(ProjectScript)), |
| 108 | 108 | clientRequestId: Schema.optional(ProjectMcpClientRequestId), |
| 109 | 109 | }); |