Skip to content

Commit 74e814e

Browse files
committed
fix(native): add web tool protocol lab hooks
1 parent 898e8c6 commit 74e814e

9 files changed

Lines changed: 277 additions & 9 deletions

docs/native-bridge-protocol-notes.md

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -89,17 +89,24 @@ gated live smoke before they can join the default native bridge allowlist.
8989
## Experiment Hooks
9090

9191
`WINDSURFAPI_NATIVE_TOOL_BRIDGE_CONFIG_RAW` can inject exact protobuf bytes
92-
for native tool subconfigs:
92+
for native tool subconfigs or unknown top-level `CascadeToolConfig` fields:
9393

9494
```text
95-
read_file:<hex>;grep_v2:base64:<base64>;find:<hex>;list_dir:<hex>
95+
read_file:<hex>;grep_v2:base64:<base64>;find:<hex>;list_dir:<hex>;field42:<hex>;field40:
9696
```
9797

9898
The hook is default-off and exists only for matrix testing. Smoke must still
9999
require native source plus argument validation; a raw subconfig that merely
100100
causes natural-language or degraded `pattern:"*"` output is not a success.
101101
There is intentionally no `search_web` / `read_url_content` raw-config alias
102-
until the tool-config field numbers are proven.
102+
until the tool-config field numbers are proven. Use `fieldNN:<hex>`,
103+
`field_NN:<hex>`, or `fNN:<hex>` only in a gated lab account.
104+
105+
`WINDSURFAPI_NATIVE_TOOL_BRIDGE_POLL_AFTER_TOOL=1` is also lab-only. It keeps
106+
polling Cascade after the first `cascade_native` tool call so protobuf traces
107+
can capture post-tool result/document payloads. Production bridge traffic should
108+
leave it unset; the default behavior stops at the tool proposal so OpenAI
109+
clients execute the tool locally instead of the remote LS workspace doing it.
103110

104111
## Next Matrix
105112

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
## v2.0.125 - Web tool protocol-lab hooks
2+
3+
- Added a lab-only `WINDSURFAPI_NATIVE_TOOL_BRIDGE_POLL_AFTER_TOOL=1` switch
4+
for protocol tracing. When enabled with the native bridge, Cascade polling
5+
continues after the first `cascade_native` tool proposal so traces can capture
6+
post-tool result payloads. The default production behavior is unchanged: stop
7+
at the native tool proposal and let the OpenAI client execute the tool.
8+
- `WINDSURFAPI_NATIVE_TOOL_BRIDGE_CONFIG_RAW` can now inject unknown top-level
9+
`CascadeToolConfig` fields with `fieldNN:<hex>`, `field_NN:<hex>`, or
10+
`fNN:<hex>`. This is for WebSearch/WebFetch field-matrix canaries; field 32
11+
remains reserved for the managed allowlist.
12+
- Proto tracing now summarizes unknown native tool-config fields and richer
13+
`search_web` / `read_url_content` trajectory shapes, including nested message
14+
fields that should reveal `web_documents` payload placement during real
15+
smoke tests.
16+
- WebSearch/WebFetch are still not in the default native bridge allowlist. This
17+
release only makes the gated canary measurable enough to decide the next
18+
mapping safely.
19+
20+
Verification:
21+
22+
- `node --check src\client.js`
23+
- `node --check src\windsurf.js`
24+
- `node --check src\proto-trace.js`
25+
- `node --test test\client-panel-retry.test.js`
26+
- `node --test test\cascade-native-bridge.test.js test\proto-trace.test.js test\native-tool-routing.test.js`
27+
- `node --test --test-timeout=120000 --test-force-exit test\*.test.js` passes: 1025/1025.

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.124",
3+
"version": "2.0.125",
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: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -852,6 +852,8 @@ export class WindsurfClient {
852852
const { maxWaitMs: maxWait, pollIntervalMs: pollInterval, idleGraceMs: IDLE_GRACE_MS, warmStallMs: NO_GROWTH_STALL_MS, stallRetryMinText: STALL_RETRY_MIN_TEXT } = CASCADE_TIMEOUTS;
853853
const startTime = Date.now();
854854
let endReason = 'unknown';
855+
const nativeBridgePollAfterTool = nativeMode && process.env.WINDSURFAPI_NATIVE_TOOL_BRIDGE_POLL_AFTER_TOOL === '1';
856+
let nativeBridgePollAfterToolLogged = false;
855857

856858
while (Date.now() - startTime < maxWait) {
857859
if (aborted()) { endReason = 'aborted'; break; }
@@ -959,8 +961,14 @@ export class WindsurfClient {
959961
// polling immediately so the LS does not keep executing the
960962
// built-in tool in the remote workspace while the client waits.
961963
if (nativeMode && step.toolCalls.some(tc => tc.cascade_native)) {
962-
endReason = 'native_tool_call';
963-
break;
964+
if (!nativeBridgePollAfterTool) {
965+
endReason = 'native_tool_call';
966+
break;
967+
}
968+
if (!nativeBridgePollAfterToolLogged) {
969+
nativeBridgePollAfterToolLogged = true;
970+
log.warn('Native bridge protocol lab is polling after a cascade-native tool call; remote LS may execute the built-in tool');
971+
}
964972
}
965973
}
966974

src/proto-trace.js

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,9 +120,19 @@ function summarizeNativeToolConfig(toolCfgBuf) {
120120
const subconfigs = [...NATIVE_TOOL_CONFIG_FIELDS.keys()]
121121
.map(num => summarizeNativeToolSubconfig(fields, num))
122122
.filter(Boolean);
123+
const known = new Set([...NATIVE_TOOL_CONFIG_FIELDS.keys(), 32]);
124+
const unknownFields = fields
125+
.filter(f => f.wireType === 2 && !known.has(f.field))
126+
.slice(0, positiveIntEnv('WINDSURFAPI_PROTO_TRACE_TOOL_CONFIG_UNKNOWN_LIMIT', 24))
127+
.map(f => ({
128+
field: f.field,
129+
bytes: f.value.length,
130+
summary: summarizeMessageChildren(f.value, 10),
131+
}));
123132
return {
124133
subconfigFields: subconfigs.map(s => s.field),
125134
subconfigs,
135+
unknownFields,
126136
allowlist: getAllFields(fields, 32)
127137
.filter(f => f.wireType === 2)
128138
.map(f => f.value.toString('utf8')),
@@ -232,6 +242,30 @@ function summarizeNativeStepBody(kind, bodyBuf) {
232242
childCount: getAllFields(f, 2).filter(x => x.wireType === 2).length,
233243
};
234244
}
245+
if (kind === 'search_web') {
246+
return {
247+
queryBytes: stringField(f, 1).length,
248+
webDocumentCount: getAllFields(f, 2).filter(x => x.wireType === 2).length,
249+
domainBytes: stringField(f, 3).length,
250+
summaryBytes: stringField(f, 5).length,
251+
fieldNumbers: f.map(x => x.field),
252+
messageFields: f
253+
.filter(x => x.wireType === 2 && ![1, 3, 5].includes(x.field))
254+
.slice(0, 8)
255+
.map(x => ({ field: x.field, ...summarizeMessageChildren(x.value, 8) })),
256+
};
257+
}
258+
if (kind === 'read_url_content') {
259+
return {
260+
urlBytes: stringField(f, 1).length,
261+
summaryBytes: stringField(f, 5).length,
262+
fieldNumbers: f.map(x => x.field),
263+
messageFields: f
264+
.filter(x => x.wireType === 2 && ![1, 5].includes(x.field))
265+
.slice(0, 8)
266+
.map(x => ({ field: x.field, ...summarizeMessageChildren(x.value, 8) })),
267+
};
268+
}
235269
return { fieldCount: f.length };
236270
}
237271

src/windsurf.js

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -657,6 +657,9 @@ function buildNativeCascadeToolConfig(allowlist = null) {
657657
if (enabled.find) {
658658
parts.push(writeNativeToolConfigField(5, 'find', rawSubconfigs));
659659
}
660+
for (const [field, payload] of rawSubconfigs.unknownFields) {
661+
parts.push(writeBytesField(field, payload));
662+
}
660663
// tool_allowlist (field 32, repeated string)
661664
for (const name of list) {
662665
parts.push(writeStringField(32, name));
@@ -672,22 +675,32 @@ function writeNativeToolConfigField(field, kind, rawSubconfigs) {
672675

673676
function parseNativeToolConfigRawOverrides() {
674677
const raw = String(process.env.WINDSURFAPI_NATIVE_TOOL_BRIDGE_CONFIG_RAW || '').trim();
675-
if (!raw) return new Map();
676678
const out = new Map();
679+
out.unknownFields = new Map();
680+
if (!raw) return out;
677681
for (const entry of raw.split(';')) {
678682
const item = entry.trim();
679683
if (!item) continue;
680684
const sep = item.indexOf(':');
681685
if (sep <= 0) throw new Error(`Invalid WINDSURFAPI_NATIVE_TOOL_BRIDGE_CONFIG_RAW entry: ${item}`);
682686
const kind = normalizeNativeToolConfigKind(item.slice(0, sep));
683687
const payload = decodeNativeToolConfigRawPayload(item.slice(sep + 1));
684-
out.set(kind, payload);
688+
if (typeof kind === 'number') out.unknownFields.set(kind, payload);
689+
else out.set(kind, payload);
685690
}
686691
return out;
687692
}
688693

689694
function normalizeNativeToolConfigKind(kind) {
690695
const key = String(kind || '').trim();
696+
const fieldMatch = key.match(/^(?:field_|field|f)([1-9]\d{0,8})$/i);
697+
if (fieldMatch) {
698+
const n = Number(fieldMatch[1]);
699+
if (!Number.isInteger(n) || n <= 0 || n > 536870911 || n === 32) {
700+
throw new Error(`Invalid native tool config field override: ${key}`);
701+
}
702+
return n;
703+
}
691704
const map = new Map([
692705
['run_command', 'run_command'],
693706
['shell_command', 'run_command'],

test/cascade-native-bridge.test.js

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -599,6 +599,25 @@ describe('buildSendCascadeMessageRequest — additional_steps on field 9', () =>
599599
}
600600
});
601601

602+
it('native tool config raw overrides can inject unknown top-level fields for matrix experiments', () => {
603+
process.env.WINDSURFAPI_NATIVE_TOOL_BRIDGE_CONFIG_RAW = 'field42:0a03776562;field40:';
604+
try {
605+
const proto = buildSendCascadeMessageRequest('k', 'cid', 'hi', 12345, 'MODEL_TEST', 'sess', {
606+
nativeMode: true,
607+
nativeAllowlist: ['search_web', 'read_url_content'],
608+
});
609+
const top = parseFields(proto);
610+
const cfg = parseFields(getField(top, 5, 2).value);
611+
const planner = parseFields(getField(cfg, 1, 2).value);
612+
const tc = parseFields(getField(planner, 13, 2).value);
613+
assert.equal(getField(tc, 42, 2).value.toString('hex'), '0a03776562');
614+
assert.equal(getField(tc, 40, 2).value.length, 0);
615+
assert.deepEqual(getAllFields(tc, 32).map(f => f.value.toString('utf8')), ['search_web', 'read_url_content']);
616+
} finally {
617+
delete process.env.WINDSURFAPI_NATIVE_TOOL_BRIDGE_CONFIG_RAW;
618+
}
619+
});
620+
602621
it('nativeMode=true can carry environment facts without tool emulation schema', () => {
603622
const proto = buildSendCascadeMessageRequest('k', 'cid', 'hi', 12345, 'MODEL_TEST', 'sess', {
604623
nativeMode: true,

test/client-panel-retry.test.js

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -368,4 +368,83 @@ describe('WindsurfClient cascade panel retry', () => {
368368
assert.equal(streamed.filter(c => c.nativeToolCall).length, 1);
369369
});
370370
});
371+
372+
it('native bridge protocol lab can poll after a cascade-native tool call', async () => {
373+
const prevPollAfterTool = process.env.WINDSURFAPI_NATIVE_TOOL_BRIDGE_POLL_AFTER_TOOL;
374+
process.env.WINDSURFAPI_NATIVE_TOOL_BRIDGE_POLL_AFTER_TOOL = '1';
375+
process.env.CASCADE_POLL_INTERVAL_MS = '10';
376+
process.env.CASCADE_IDLE_GRACE_MS = '1';
377+
process.env.CASCADE_MAX_WAIT_MS = '700';
378+
process.env.CASCADE_COLD_STALL_BASE_MS = '700';
379+
process.env.CASCADE_WARM_STALL_MS = '700';
380+
process.env.GRPC_PROTOCOL = 'connect';
381+
382+
let statusPolls = 0;
383+
let stepPolls = 0;
384+
const streamed = [];
385+
386+
try {
387+
await withFakeLanguageServer((stream, headers) => {
388+
const chunks = [];
389+
stream.on('data', chunk => chunks.push(chunk));
390+
stream.on('end', () => {
391+
const path = String(headers[':path'] || '');
392+
const method = path.split('/').pop();
393+
394+
if (method === 'StartCascade') {
395+
stream.respond({ ':status': 200, 'content-type': headers['content-type'] || 'application/grpc' });
396+
stream.end(responseBody(startCascadeResponse('native-cascade-lab'), headers));
397+
return;
398+
}
399+
400+
if (method === 'SendUserCascadeMessage') {
401+
stream.respond({ ':status': 200, 'content-type': headers['content-type'] || 'application/grpc' });
402+
stream.end(responseBody(Buffer.alloc(0), headers));
403+
return;
404+
}
405+
406+
if (method === 'GetCascadeTrajectorySteps') {
407+
stepPolls++;
408+
stream.respond({ ':status': 200, 'content-type': headers['content-type'] || 'application/grpc' });
409+
stream.end(responseBody(runCommandStepResponse('printf LAB'), headers));
410+
return;
411+
}
412+
413+
if (method === 'GetCascadeTrajectory') {
414+
statusPolls++;
415+
stream.respond({ ':status': 200, 'content-type': headers['content-type'] || 'application/grpc' });
416+
stream.end(responseBody(trajectoryStatusResponse(1), headers));
417+
return;
418+
}
419+
420+
if (method === 'GetCascadeTrajectoryGeneratorMetadata') {
421+
stream.respond({ ':status': 200, 'content-type': headers['content-type'] || 'application/grpc' });
422+
stream.end(responseBody(Buffer.alloc(0), headers));
423+
return;
424+
}
425+
426+
stream.respond({ ':status': 404 });
427+
stream.end();
428+
});
429+
}, async (port) => {
430+
const { WindsurfClient } = await import('../src/client.js');
431+
const client = new WindsurfClient('test-api-key', port, 'csrf-token');
432+
const chunks = await client.cascadeChat([{ role: 'user', content: 'run it' }], 0, 'claude-4.5-haiku', {
433+
nativeMode: true,
434+
nativeAllowlist: ['run_command'],
435+
onChunk: c => streamed.push(c),
436+
});
437+
438+
assert.ok(stepPolls > 1, 'lab mode should keep polling trajectory steps after the native tool call');
439+
assert.ok(statusPolls > 0, 'lab mode should poll trajectory status instead of returning immediately');
440+
assert.equal(chunks.toolCalls.length, 1);
441+
assert.equal(chunks.toolCalls[0].name, 'run_command');
442+
assert.match(chunks.toolCalls[0].argumentsJson, /printf LAB/);
443+
assert.equal(streamed.filter(c => c.nativeToolCall).length, 1);
444+
});
445+
} finally {
446+
if (prevPollAfterTool === undefined) delete process.env.WINDSURFAPI_NATIVE_TOOL_BRIDGE_POLL_AFTER_TOOL;
447+
else process.env.WINDSURFAPI_NATIVE_TOOL_BRIDGE_POLL_AFTER_TOOL = prevPollAfterTool;
448+
}
449+
});
371450
});

test/proto-trace.test.js

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { tmpdir } from 'node:os';
55
import { join } from 'node:path';
66

77
import { grpcFrame } from '../src/grpc.js';
8-
import { writeBoolField, writeMessageField, writeStringField, writeVarintField } from '../src/proto.js';
8+
import { writeBoolField, writeBytesField, writeMessageField, writeStringField, writeVarintField } from '../src/proto.js';
99
import { buildSendCascadeMessageRequest } from '../src/windsurf.js';
1010
import {
1111
_resetProtoTraceForTests,
@@ -139,6 +139,36 @@ describe('proto trace', () => {
139139
assert.ok(grep.bytes > 0);
140140
});
141141

142+
it('summarizes unknown native tool config fields for web matrix experiments', () => {
143+
process.env.WINDSURFAPI_PROTO_TRACE = '1';
144+
const toolConfig = Buffer.concat([
145+
writeMessageField(42, writeStringField(1, 'web')),
146+
writeBytesField(40, Buffer.alloc(0)),
147+
writeStringField(32, 'search_web'),
148+
writeStringField(32, 'read_url_content'),
149+
]);
150+
const planner = writeMessageField(13, toolConfig);
151+
const cascadeConfig = writeMessageField(1, planner);
152+
const proto = writeMessageField(5, cascadeConfig);
153+
154+
traceGrpcPayload({
155+
port: 42100,
156+
path: '/exa.language_server_pb.LanguageServerService/SendUserCascadeMessage',
157+
direction: 'request',
158+
body: grpcFrame(proto),
159+
transport: 'grpc',
160+
framed: true,
161+
});
162+
163+
const file = join(dir, `ls-proto-${process.pid}-SendUserCascadeMessage.jsonl`);
164+
const rec = JSON.parse(readFileSync(file, 'utf8').trim());
165+
assert.deepEqual(rec.semantic.nativeToolConfig.allowlist, ['search_web', 'read_url_content']);
166+
assert.deepEqual(rec.semantic.nativeToolConfig.subconfigFields, []);
167+
assert.deepEqual(rec.semantic.nativeToolConfig.unknownFields.map(f => f.field), [42, 40]);
168+
assert.deepEqual(rec.semantic.nativeToolConfig.unknownFields[0].summary.fieldNumbers, [1]);
169+
assert.equal(rec.semantic.nativeToolConfig.unknownFields[1].bytes, 0);
170+
});
171+
142172
it('adds semantic GetCascadeTrajectorySteps native oneof summaries', () => {
143173
process.env.WINDSURFAPI_PROTO_TRACE = '1';
144174
const grepBody = Buffer.concat([
@@ -172,6 +202,57 @@ describe('proto trace', () => {
172202
assert.equal(rec.semantic.steps[0].nativeOneofs[0].body.rawOutputBytes, 'README.md\n'.length);
173203
});
174204

205+
it('summarizes web trajectory payload shapes for protocol diffing', () => {
206+
process.env.WINDSURFAPI_PROTO_TRACE = '1';
207+
const doc = Buffer.concat([
208+
writeStringField(1, 'title'),
209+
writeStringField(2, 'https://example.com/'),
210+
]);
211+
const searchBody = Buffer.concat([
212+
writeStringField(1, 'WindsurfAPI native bridge'),
213+
writeMessageField(2, doc),
214+
writeStringField(3, 'example.com'),
215+
writeStringField(5, 'summary'),
216+
]);
217+
const fetchBody = Buffer.concat([
218+
writeStringField(1, 'https://example.com/'),
219+
writeMessageField(2, doc),
220+
writeStringField(5, 'body summary'),
221+
]);
222+
const response = Buffer.concat([
223+
writeMessageField(1, Buffer.concat([
224+
writeVarintField(1, 42),
225+
writeVarintField(4, 3),
226+
writeMessageField(42, searchBody),
227+
])),
228+
writeMessageField(1, Buffer.concat([
229+
writeVarintField(1, 40),
230+
writeVarintField(4, 3),
231+
writeMessageField(40, fetchBody),
232+
])),
233+
]);
234+
traceGrpcPayload({
235+
port: 42100,
236+
path: '/exa.language_server_pb.LanguageServerService/GetCascadeTrajectorySteps',
237+
direction: 'response',
238+
body: response,
239+
transport: 'grpc',
240+
framed: false,
241+
});
242+
243+
const file = join(dir, `ls-proto-${process.pid}-GetCascadeTrajectorySteps.jsonl`);
244+
const rec = JSON.parse(readFileSync(file, 'utf8').trim());
245+
const search = rec.semantic.steps[0].nativeOneofs[0];
246+
assert.equal(search.kind, 'search_web');
247+
assert.equal(search.body.webDocumentCount, 1);
248+
assert.deepEqual(search.body.fieldNumbers, [1, 2, 3, 5]);
249+
assert.equal(search.body.messageFields[0].field, 2);
250+
const fetch = rec.semantic.steps[1].nativeOneofs[0];
251+
assert.equal(fetch.kind, 'read_url_content');
252+
assert.deepEqual(fetch.body.fieldNumbers, [1, 2, 5]);
253+
assert.equal(fetch.body.messageFields[0].field, 2);
254+
});
255+
175256
it('summarizes non-oneof step message fields for protocol diffing', () => {
176257
process.env.WINDSURFAPI_PROTO_TRACE = '1';
177258
const viewWrapper = Buffer.concat([

0 commit comments

Comments
 (0)