Merge pull request #3104 from fitzgpt/fix/non-ascii-directory-header

fix(server): percent-encode non-ASCII directory headers to OpenCode
This commit is contained in:
Bohdan Triapitsyn
2026-08-27 23:13:44 +03:00
committed by GitHub
5 changed files with 48 additions and 2 deletions
@@ -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 those use its local filesystem bridge. If called, the grant route returns an
explicit unsupported response because OpenCode temporary images are not explicit unsupported response because OpenCode temporary images are not
supported there. 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.
@@ -200,7 +200,9 @@ const fetchMessage = async ({ sessionId, messageId, directory, buildOpenCodeUrl,
const response = await fetch(url, { const response = await fetch(url, {
headers: { headers: {
accept: 'application/json', 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(), ...getOpenCodeAuthHeaders(),
}, },
signal: AbortSignal.timeout(10_000), signal: AbortSignal.timeout(10_000),
@@ -74,6 +74,20 @@ const prepare = (app, directory, sources) => request(app)
.expect(200); .expect(200);
describe('session image assets', () => { 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 () => { it('prepares workspace and OpenCode temporary images with one message fetch', async () => {
const fixture = await createFixture({ sources: ['workspace.png'] }); const fixture = await createFixture({ sources: ['workspace.png'] });
await fs.writeFile(path.join(fixture.directory, 'workspace.png'), PNG); await fs.writeFile(path.join(fixture.directory, 'workspace.png'), PNG);
@@ -83,7 +83,10 @@ const resolveVariant = (providers, providerID, modelID, variant) => {
const parseConfigModel = (value) => splitModel(value); const parseConfigModel = (value) => splitModel(value);
const buildDirectoryHeaders = (directory) => ({ 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) => { const fetchJson = async (url, authHeaders, fallback, directory) => {
@@ -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 () => { it('parses JSON body without global middleware', async () => {
const originalFetch = globalThis.fetch; const originalFetch = globalThis.fetch;
globalThis.fetch = vi.fn(async () => ({ ok: true, json: async () => ({ id: 'ses_123' }) })); globalThis.fetch = vi.fn(async () => ({ ok: true, json: async () => ({ id: 'ses_123' }) }));