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
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.
@@ -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),
@@ -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);
@@ -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) => {
@@ -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' }) }));