Skip to content

Commit 8de4146

Browse files
committed
fix(native): parse read wrapper trajectory
1 parent b0bca38 commit 8de4146

6 files changed

Lines changed: 332 additions & 2 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# v2.0.130
2+
3+
- Native bridge Read protocol fix: `parseTrajectorySteps()` now recognizes the newer `type=14` / `field=19` view-file wrapper observed in live LS traces and promotes it to the existing `view_file` native tool-call path.
4+
- Native proposal mode now returns a cascade-native tool proposal before surfacing same-batch remote executor errors, so OpenAI clients can execute the tool locally instead of receiving the LS workspace failure.
5+
- Read tool-call repair now maps internal `/home/user/projects/workspace-*` and `/tmp/windsurf-workspace` paths back to caller cwd-relative paths before sanitization. The repair is conservative and only runs for Read/read_file/view_file arguments that already contain one of those internal workspace paths.
6+
- Added focused tests for Read wrapper decoding, same-batch error handling, and path repair. Default native bridge scope remains unchanged: Read/Grep/Glob/WebSearch/WebFetch still require explicit gray allowlists.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "windsurf-api",
3-
"version": "2.0.129",
3+
"version": "2.0.130",
44
"description": "Windsurf to OpenAI + Anthropic compatible API proxy. Turns Windsurf's 107 AI models (Claude, GPT, Gemini, DeepSeek, Grok, Qwen, Kimi, GLM, SWE) into dual-protocol API endpoints. Zero npm deps.",
55
"type": "module",
66
"main": "src/index.js",

src/client.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -867,11 +867,14 @@ export class WindsurfClient {
867867
this.port, this.csrfToken, `${LS_SERVICE}/GetCascadeTrajectorySteps`, grpcFrame(stepsProto)
868868
);
869869
const steps = parseTrajectorySteps(stepsResp);
870+
const hasNativeProposalInBatch = nativeMode && !nativeBridgePollAfterTool
871+
&& steps.some(step => Array.isArray(step?.toolCalls)
872+
&& step.toolCalls.some(tc => tc?.cascade_native));
870873

871874
// CORTEX_STEP_TYPE_ERROR_MESSAGE = 17. An error step means the cascade
872875
// refused the request (permission denied, model unavailable, etc.) —
873876
// raise it as a model-level error so the account isn't blamed.
874-
for (const step of steps) {
877+
for (const step of hasNativeProposalInBatch ? [] : steps) {
875878
if (step.type === 17 && step.errorText) {
876879
// Log the full trajectory context so we can see WHICH tool call
877880
// (if any) the error refers to. "invalid tool call" without

src/handlers/chat.js

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -726,7 +726,92 @@ function extractRequestedBashCommands(text) {
726726
return [...new Set(out)];
727727
}
728728

729+
function readPathKey(args) {
730+
if (Object.prototype.hasOwnProperty.call(args, 'file_path')) return 'file_path';
731+
if (Object.prototype.hasOwnProperty.call(args, 'path')) return 'path';
732+
if (Object.prototype.hasOwnProperty.call(args, 'absolute_path')) return 'absolute_path';
733+
if (Object.prototype.hasOwnProperty.call(args, 'absolute_path_uri')) return 'absolute_path_uri';
734+
return 'file_path';
735+
}
736+
737+
function stripFileUriForRepair(path) {
738+
const raw = String(path || '').replace(/^file:\/\//i, '');
739+
try { return decodeURIComponent(raw); } catch { return raw; }
740+
}
741+
742+
function internalWorkspaceTail(path) {
743+
let s = stripFileUriForRepair(path).replace(/\\/g, '/');
744+
s = s.replace(/^\/([A-Za-z]:\/)/, '$1');
745+
const patterns = [
746+
/^(?:[A-Za-z]:)?\/home\/user\/projects\/workspace-[a-z0-9]+(?:\/(.*))?$/i,
747+
/^\/tmp\/windsurf-workspace(?:\/(.*))?$/i,
748+
];
749+
for (const re of patterns) {
750+
const m = s.match(re);
751+
if (m) return (m[1] || '').replace(/^\/+/, '');
752+
}
753+
if (String(path || '').trim() === '<workspace>') return '';
754+
return null;
755+
}
756+
757+
function callerWorkingDirectory(messages) {
758+
const env = extractCallerEnvironment(messages);
759+
const m = String(env || '').match(/(?:^|\n)- Working directory:\s*([^\n]+)/);
760+
return m ? m[1].trim() : '';
761+
}
762+
763+
function joinCallerPath(cwd, rel) {
764+
const cleanRel = String(rel || '').replace(/^[\\/]+/, '');
765+
if (!cleanRel) return '';
766+
if (!cwd || internalWorkspaceTail(cwd) !== null || cwd === '<workspace>') return cleanRel;
767+
const sep = cwd.includes('\\') && !cwd.includes('/') ? '\\' : '/';
768+
return `${cwd.replace(/[\\/]+$/, '')}${sep}${cleanRel.replace(/[\\/]+/g, sep)}`;
769+
}
770+
771+
function repairedPathForKey(key, path) {
772+
const s = String(path || '');
773+
if (key !== 'absolute_path_uri' || !s || /^file:\/\//i.test(s)) return s;
774+
if (/^[A-Za-z]:[\\/]/.test(s)) return `file:///${s.replace(/\\/g, '/')}`;
775+
if (s.startsWith('/')) return `file://${s}`;
776+
return s;
777+
}
778+
779+
function extractRequestedReadPath(messages) {
780+
const text = recentUserText(messages);
781+
if (!text) return '';
782+
const backtick = [...text.matchAll(/`([^`\r\n]+)`/g)].map(m => m[1]);
783+
const bare = [...text.matchAll(/((?:[A-Za-z]:[\\/]|\/|~[\\/]|\.{1,2}[\\/])[^"'`<>\s]+|[A-Za-z0-9._-]+(?:[\\/][A-Za-z0-9._-]+)*\.[A-Za-z0-9]{1,12})/g)].map(m => m[1]);
784+
for (const candidate of [...backtick, ...bare]) {
785+
const tail = internalWorkspaceTail(candidate);
786+
if (tail !== null) return tail || '';
787+
if (candidate && candidate !== '<workspace>') return candidate;
788+
}
789+
return '';
790+
}
791+
792+
function repairReadToolCallArguments(tc, messages) {
793+
const name = String(tc?.name || '').toLowerCase();
794+
if (!['read', 'read_file', 'view_file'].includes(name) || typeof tc.argumentsJson !== 'string') return tc;
795+
let args;
796+
try { args = JSON.parse(tc.argumentsJson); } catch { return tc; }
797+
if (!args || typeof args !== 'object' || Array.isArray(args)) return tc;
798+
const key = readPathKey(args);
799+
const current = args[key];
800+
if (typeof current !== 'string') return tc;
801+
const tail = internalWorkspaceTail(current);
802+
if (tail === null) return tc;
803+
const replacement = tail
804+
? joinCallerPath(callerWorkingDirectory(messages), tail)
805+
: extractRequestedReadPath(messages);
806+
if (!replacement || replacement === current) return tc;
807+
const outputKey = key === 'absolute_path_uri' && name !== 'view_file' ? 'file_path' : key;
808+
const next = { ...args, [outputKey]: repairedPathForKey(outputKey, replacement) };
809+
if (outputKey !== key) delete next[key];
810+
return { ...tc, argumentsJson: JSON.stringify(next) };
811+
}
812+
729813
export function repairToolCallArguments(tc, messages) {
814+
tc = repairReadToolCallArguments(tc, messages);
730815
if (!tc || String(tc.name || '').toLowerCase() !== 'bash' || typeof tc.argumentsJson !== 'string') return tc;
731816
let args;
732817
try { args = JSON.parse(tc.argumentsJson); } catch { return tc; }

src/windsurf.js

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1162,6 +1162,34 @@ export function parseTrajectorySteps(buf) {
11621162
});
11631163
}
11641164

1165+
// Newer LS builds sometimes emit a completed read_file/view_file step as
1166+
// type=14 with the body wrapped on field 19 instead of the historical
1167+
// oneof field 14. Live traces show wrapper fields [2,3,4], where field 2
1168+
// carries the file URI/path and field 4 carries the observed content.
1169+
// Promote that shape to the same cascade-native tool call so native
1170+
// bridge can return the proposal before the remote workspace executor
1171+
// reports its follow-up "invalid tool call" error.
1172+
if (entry.type === 14 && !entry.toolCalls.some(tc => tc.cascade_native && tc.name === 'view_file')) {
1173+
const wrapper = getField(sf, 19, 2);
1174+
if (wrapper) {
1175+
try {
1176+
const body = parseFields(wrapper.value);
1177+
const uri = getField(body, 1, 2)?.value?.toString('utf8')
1178+
|| getField(body, 2, 2)?.value?.toString('utf8') || '';
1179+
if (uri) {
1180+
const args = { absolute_path_uri: uri, offset: 0, limit: 0, start_line: 0, end_line: 0 };
1181+
entry.toolCalls.push({
1182+
id: `native:view_file:${results.length}`,
1183+
name: 'view_file',
1184+
argumentsJson: JSON.stringify(args),
1185+
result: getField(body, 4, 2)?.value?.toString('utf8') || '',
1186+
cascade_native: true,
1187+
});
1188+
}
1189+
} catch {}
1190+
}
1191+
}
1192+
11651193
if (plannerField) {
11661194
const pf = parseFields(plannerField.value);
11671195
const textField = getField(pf, 1, 2);

test/native-read-wrapper.test.js

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
import { describe, it } from 'node:test';
2+
import assert from 'node:assert/strict';
3+
import http2 from 'http2';
4+
import { WindsurfClient } from '../src/client.js';
5+
import { parseTrajectorySteps } from '../src/windsurf.js';
6+
import { repairToolCallArguments } from '../src/handlers/chat.js';
7+
import { parseFields, getField, writeMessageField, writeStringField, writeVarintField } from '../src/proto.js';
8+
import { endOfStreamEnvelope, unwrapRequest, wrapEnvelope } from '../src/connect.js';
9+
10+
function grpcFrame(payload) {
11+
const buf = Buffer.isBuffer(payload) ? payload : Buffer.from(payload);
12+
const frame = Buffer.alloc(5 + buf.length);
13+
frame[0] = 0;
14+
frame.writeUInt32BE(buf.length, 1);
15+
buf.copy(frame, 5);
16+
return frame;
17+
}
18+
19+
function extractGrpcFrames(buf) {
20+
const frames = [];
21+
let offset = 0;
22+
while (offset + 5 <= buf.length) {
23+
const compressed = buf[offset];
24+
const msgLen = buf.readUInt32BE(offset + 1);
25+
if (compressed !== 0 || offset + 5 + msgLen > buf.length) break;
26+
frames.push(buf.subarray(offset + 5, offset + 5 + msgLen));
27+
offset += 5 + msgLen;
28+
}
29+
return frames;
30+
}
31+
32+
function requestPayload(body, headers) {
33+
const contentType = String(headers['content-type'] || '');
34+
if (contentType.includes('application/connect+proto')) return unwrapRequest(body, headers);
35+
const frames = extractGrpcFrames(body);
36+
return frames.length ? Buffer.concat(frames) : body.subarray(5);
37+
}
38+
39+
function responseBody(payload, headers) {
40+
const contentType = String(headers['content-type'] || '');
41+
if (contentType.includes('application/connect+proto')) {
42+
return Buffer.concat([wrapEnvelope(payload, { compress: false }), endOfStreamEnvelope()]);
43+
}
44+
return grpcFrame(payload);
45+
}
46+
47+
function startCascadeResponse(cascadeId) {
48+
return writeStringField(1, cascadeId);
49+
}
50+
51+
function trajectoryStatusResponse(status) {
52+
return writeVarintField(2, status);
53+
}
54+
55+
function viewFileWrapperStep() {
56+
const wrapper = Buffer.concat([
57+
writeStringField(2, 'file:///home/user/projects/workspace-abc123/README.md'),
58+
writeMessageField(3, writeStringField(1, 'nested request')),
59+
writeStringField(4, 'observed content'),
60+
]);
61+
return Buffer.concat([
62+
writeVarintField(1, 14),
63+
writeVarintField(4, 3),
64+
writeMessageField(19, wrapper),
65+
]);
66+
}
67+
68+
function errorStep(message) {
69+
const details = writeStringField(1, message);
70+
const errorMessage = writeMessageField(3, details);
71+
return Buffer.concat([
72+
writeVarintField(1, 17),
73+
writeVarintField(4, 3),
74+
writeMessageField(24, errorMessage),
75+
]);
76+
}
77+
78+
function trajectoryStepsResponse(...steps) {
79+
return Buffer.concat(steps.map(step => writeMessageField(1, step)));
80+
}
81+
82+
async function withFakeLanguageServer(handler, fn) {
83+
const server = http2.createServer();
84+
server.on('stream', handler);
85+
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
86+
const port = server.address().port;
87+
try {
88+
return await fn(port);
89+
} finally {
90+
await new Promise(resolve => server.close(resolve));
91+
}
92+
}
93+
94+
describe('native Read wrapper trajectory parsing', () => {
95+
it('promotes type 14 field 19 wrapper into a view_file native tool call', () => {
96+
const steps = parseTrajectorySteps(trajectoryStepsResponse(viewFileWrapperStep()));
97+
const calls = steps[0].toolCalls.filter(tc => tc.cascade_native);
98+
assert.equal(calls.length, 1);
99+
assert.equal(calls[0].name, 'view_file');
100+
const args = JSON.parse(calls[0].argumentsJson);
101+
assert.equal(args.absolute_path_uri, 'file:///home/user/projects/workspace-abc123/README.md');
102+
assert.equal(calls[0].result, 'observed content');
103+
});
104+
105+
it('repairs native Read workspace paths to caller cwd-relative paths before sanitizing', () => {
106+
const tc = {
107+
name: 'Read',
108+
argumentsJson: JSON.stringify({ file_path: '/home/user/projects/workspace-abc123/README.md', limit: 20 }),
109+
};
110+
const repaired = repairToolCallArguments(tc, [
111+
{ role: 'system', content: '- Working directory: D:\\Project\\WindsurfAPI\n- Platform: windows' },
112+
{ role: 'user', content: 'Use Read for README.md.' },
113+
]);
114+
assert.equal(JSON.parse(repaired.argumentsJson).file_path, 'D:\\Project\\WindsurfAPI\\README.md');
115+
});
116+
117+
it('does not rename view_file absolute_path_uri for cascade-style callers', () => {
118+
const tc = {
119+
name: 'view_file',
120+
argumentsJson: JSON.stringify({ absolute_path_uri: 'file:///home/user/projects/workspace-abc123/README.md' }),
121+
};
122+
const repaired = repairToolCallArguments(tc, [
123+
{ role: 'system', content: '- Working directory: /repo' },
124+
{ role: 'user', content: 'view README.md' },
125+
]);
126+
const args = JSON.parse(repaired.argumentsJson);
127+
assert.equal(args.absolute_path_uri, 'file:///repo/README.md');
128+
assert.equal(Object.prototype.hasOwnProperty.call(args, 'file_path'), false);
129+
});
130+
131+
it('keeps native proposal when the same trajectory batch also contains a remote execution error', async () => {
132+
process.env.CASCADE_POLL_INTERVAL_MS = '10';
133+
process.env.CASCADE_IDLE_GRACE_MS = '1';
134+
process.env.CASCADE_MAX_WAIT_MS = '500';
135+
process.env.CASCADE_COLD_STALL_BASE_MS = '500';
136+
process.env.CASCADE_WARM_STALL_MS = '500';
137+
process.env.GRPC_PROTOCOL = 'connect';
138+
139+
let statusPolls = 0;
140+
let stepPolls = 0;
141+
const streamed = [];
142+
143+
await withFakeLanguageServer((stream, headers) => {
144+
const chunks = [];
145+
stream.on('data', chunk => chunks.push(chunk));
146+
stream.on('end', () => {
147+
const method = String(headers[':path'] || '').split('/').pop();
148+
const payload = requestPayload(Buffer.concat(chunks), headers);
149+
if (payload.length) {
150+
try { parseFields(payload); } catch {}
151+
}
152+
153+
if (method === 'StartCascade') {
154+
stream.respond({ ':status': 200, 'content-type': headers['content-type'] || 'application/grpc' });
155+
stream.end(responseBody(startCascadeResponse('native-read-cascade'), headers));
156+
return;
157+
}
158+
159+
if (method === 'SendUserCascadeMessage') {
160+
stream.respond({ ':status': 200, 'content-type': headers['content-type'] || 'application/grpc' });
161+
stream.end(responseBody(Buffer.alloc(0), headers));
162+
return;
163+
}
164+
165+
if (method === 'GetCascadeTrajectorySteps') {
166+
stepPolls++;
167+
stream.respond({ ':status': 200, 'content-type': headers['content-type'] || 'application/grpc' });
168+
stream.end(responseBody(trajectoryStepsResponse(
169+
viewFileWrapperStep(),
170+
errorStep('invalid tool call: model_not_available'),
171+
), headers));
172+
return;
173+
}
174+
175+
if (method === 'GetCascadeTrajectory') {
176+
statusPolls++;
177+
stream.respond({ ':status': 200, 'content-type': headers['content-type'] || 'application/grpc' });
178+
stream.end(responseBody(trajectoryStatusResponse(2), headers));
179+
return;
180+
}
181+
182+
if (method === 'GetCascadeTrajectoryGeneratorMetadata') {
183+
stream.respond({ ':status': 200, 'content-type': headers['content-type'] || 'application/grpc' });
184+
stream.end(responseBody(Buffer.alloc(0), headers));
185+
return;
186+
}
187+
188+
stream.respond({ ':status': 404 });
189+
stream.end();
190+
});
191+
}, async (port) => {
192+
const client = new WindsurfClient('test-api-key', port, 'csrf-token');
193+
const chunks = await client.cascadeChat([{ role: 'user', content: 'read README.md' }], 0, 'claude-4.5-haiku', {
194+
nativeMode: true,
195+
nativeAllowlist: ['read_file'],
196+
onChunk: c => streamed.push(c),
197+
});
198+
199+
assert.equal(stepPolls, 1);
200+
assert.equal(statusPolls, 0);
201+
assert.equal(chunks.toolCalls.length, 1);
202+
assert.equal(chunks.toolCalls[0].name, 'view_file');
203+
const args = JSON.parse(chunks.toolCalls[0].argumentsJson);
204+
assert.equal(args.absolute_path_uri, 'file:///home/user/projects/workspace-abc123/README.md');
205+
assert.equal(streamed.filter(c => c.nativeToolCall).length, 1);
206+
});
207+
});
208+
});

0 commit comments

Comments
 (0)