diff --git a/packages/web/server/lib/markdown-image-grants/DOCUMENTATION.md b/packages/web/server/lib/markdown-image-grants/DOCUMENTATION.md index 1b2a390d..845d2fce 100644 --- a/packages/web/server/lib/markdown-image-grants/DOCUMENTATION.md +++ b/packages/web/server/lib/markdown-image-grants/DOCUMENTATION.md @@ -34,3 +34,7 @@ server implementation. VS Code does not call this route for workspace images; those use its local filesystem bridge. If called, the grant route returns an explicit unsupported response because OpenCode temporary images are not supported there. + +Requests to OpenCode carry the directory in a percent-encoded +`x-opencode-directory` header, matching the SDK wire format; OpenCode rejects +raw non-ASCII header values. diff --git a/packages/web/server/lib/markdown-image-grants/routes.js b/packages/web/server/lib/markdown-image-grants/routes.js index 25b36c7d..a30c83ce 100644 --- a/packages/web/server/lib/markdown-image-grants/routes.js +++ b/packages/web/server/lib/markdown-image-grants/routes.js @@ -200,7 +200,9 @@ const fetchMessage = async ({ sessionId, messageId, directory, buildOpenCodeUrl, const response = await fetch(url, { headers: { accept: 'application/json', - 'x-opencode-directory': directory, + // Percent-encoded to match the SDK wire format; raw non-ASCII values + // are rejected by OpenCode. + 'x-opencode-directory': encodeURIComponent(directory), ...getOpenCodeAuthHeaders(), }, signal: AbortSignal.timeout(10_000), diff --git a/packages/web/server/lib/markdown-image-grants/routes.test.js b/packages/web/server/lib/markdown-image-grants/routes.test.js index d44661b2..8916fbdc 100644 --- a/packages/web/server/lib/markdown-image-grants/routes.test.js +++ b/packages/web/server/lib/markdown-image-grants/routes.test.js @@ -74,6 +74,20 @@ const prepare = (app, directory, sources) => request(app) .expect(200); describe('session image assets', () => { + it('percent-encodes the directory header on the message fetch', async () => { + const fixture = await createFixture(); + await prepare(fixture.app, fixture.directory, ['image.png']); + + expect(fixture.fetchMock).toHaveBeenCalledWith( + expect.any(URL), + expect.objectContaining({ + headers: expect.objectContaining({ + 'x-opencode-directory': encodeURIComponent(fixture.directory), + }), + }), + ); + }); + it('prepares workspace and OpenCode temporary images with one message fetch', async () => { const fixture = await createFixture({ sources: ['workspace.png'] }); await fs.writeFile(path.join(fixture.directory, 'workspace.png'), PNG); diff --git a/packages/web/server/lib/openchamber-sessions/routes.js b/packages/web/server/lib/openchamber-sessions/routes.js index 7c69df0a..6c58b333 100644 --- a/packages/web/server/lib/openchamber-sessions/routes.js +++ b/packages/web/server/lib/openchamber-sessions/routes.js @@ -83,7 +83,10 @@ const resolveVariant = (providers, providerID, modelID, variant) => { const parseConfigModel = (value) => splitModel(value); const buildDirectoryHeaders = (directory) => ({ - ...(directory ? { 'x-opencode-directory': directory } : {}), + // OpenCode rejects non-ASCII header values; the official SDK sends this + // header percent-encoded, so match that wire format (non-ASCII checkout + // paths such as "Masaüstü" otherwise fail every dispatched prompt). + ...(directory ? { 'x-opencode-directory': encodeURIComponent(directory) } : {}), }); const fetchJson = async (url, authHeaders, fallback, directory) => { diff --git a/packages/web/server/lib/openchamber-sessions/routes.test.js b/packages/web/server/lib/openchamber-sessions/routes.test.js index 3f6fbe5b..c95fd6e2 100644 --- a/packages/web/server/lib/openchamber-sessions/routes.test.js +++ b/packages/web/server/lib/openchamber-sessions/routes.test.js @@ -161,6 +161,29 @@ describe('openchamber session routes', () => { } }); + it('percent-encodes the directory header for non-ASCII checkout paths', async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = vi.fn(async () => ({ ok: true, json: async () => ({ id: 'ses_123' }) })); + try { + const { app } = createApp(); + await request(app) + .post('/api/openchamber/sessions') + .send({ directory: '/home/user/Masaüstü/projeler', title: 'Side task' }) + .expect(200); + + expect(globalThis.fetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + headers: expect.objectContaining({ + 'x-opencode-directory': encodeURIComponent('/home/user/Masaüstü/projeler'), + }), + }), + ); + } finally { + globalThis.fetch = originalFetch; + } + }); + it('parses JSON body without global middleware', async () => { const originalFetch = globalThis.fetch; globalThis.fetch = vi.fn(async () => ({ ok: true, json: async () => ({ id: 'ses_123' }) }));