Skip to content

Commit 17a8b4a

Browse files
committed
feat(native): add lab WebFetch approval probe
1 parent 6377cda commit 17a8b4a

9 files changed

Lines changed: 640 additions & 21 deletions

docs/native-bridge-protocol-notes.md

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,24 @@ receives the `read_url_content` config but still fails before emitting the web
249249
oneof. The missing piece remains an LS-side web executor precondition or a
250250
descriptor-backed direct WebFetch/read-url API.
251251

252+
The v2.0.134 VPS pass tested `read_url_content` alone with an explicit
253+
allowlist subconfig for `https://example.com/`:
254+
255+
- `SendUserCascadeMessage` enabled the bridge decision for mapped WebFetch and
256+
sent `read_url_content` in the native allowlist.
257+
- `GetCascadeTrajectorySteps` repeatedly returned a pending
258+
`requested_interaction.read_url_content` for the target URL/origin.
259+
- The matching native step body had fields `[1, 7]`: URL plus
260+
`autoRunDecision=8`; there was no `web_document` yet and no executor error.
261+
- Both streaming and non-streaming canaries timed out because the proxy did not
262+
answer the official permission prompt.
263+
264+
Updated direction: do not search for a guessed direct WebFetch endpoint first.
265+
The observed blocker is now the official LS permission interaction. The next
266+
valid canary must send `HandleCascadeUserInteraction` and then verify whether
267+
the same trajectory advances to `read_url_content.web_document`, an error step,
268+
or another requested interaction.
269+
252270
## Direct Web Search API
253271

254272
`GetWebSearchResults` is confirmed independently of the LS-native tool path:
@@ -319,9 +337,25 @@ User settings that influence auto-approval:
319337
(`1` disabled, `2` allowlist, `3` turbo)
320338

321339
`WINDSURFAPI_PROTO_TRACE` now summarizes `requested_interaction=56` and its
322-
read-url body with byte lengths and hashes only. The next valid WebFetch canary
323-
decision point is: did the LS emit a read-url requested interaction, a completed
324-
`field=40` step with `web_document`, or an error step before either?
340+
read-url body with byte lengths and hashes only. It also summarizes
341+
`HandleCascadeUserInteraction` requests, including cascade/trajectory ID hashes,
342+
step index, action enum, and URL/origin hashes. The next valid WebFetch canary
343+
decision point is: after approval, did the LS emit a completed `field=40` step
344+
with `web_document`, an error step, or another requested interaction?
345+
346+
v2.0.135 adds a lab-only auto-approval hook for this canary:
347+
348+
```text
349+
WINDSURFAPI_NATIVE_TOOL_BRIDGE_WEBFETCH_AUTO_APPROVE=1
350+
WINDSURFAPI_NATIVE_TOOL_BRIDGE_WEBFETCH_AUTO_APPROVE_ORIGINS=https://example.com
351+
```
352+
353+
The hook is still behind normal native bridge gating and only runs when
354+
`read_url_content` is in the native allowlist. It is not a production default.
355+
Pending `read_url_content` steps that only contain the permission request are
356+
not surfaced as completed native tool calls; the proxy waits for an actual
357+
`web_document` or legacy result before returning WebFetch content to the
358+
client. The origin list must explicitly match the requested origin or URL.
325359

326360
## Experiment Hooks
327361

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# v2.0.135
2+
3+
- Added a lab-only WebFetch permission POC. When
4+
`WINDSURFAPI_NATIVE_TOOL_BRIDGE_WEBFETCH_AUTO_APPROVE=1` and
5+
`WINDSURFAPI_NATIVE_TOOL_BRIDGE_WEBFETCH_AUTO_APPROVE_ORIGINS` explicitly
6+
matches the requested origin or URL, the Cascade polling loop responds to
7+
`requested_interaction.read_url_content` through
8+
`HandleCascadeUserInteraction` with `ALLOW_ONCE`.
9+
- Added protobuf builders/parsers for the official interaction path:
10+
`GetCascadeTrajectoryResponse.trajectory.trajectory_id`,
11+
`CortexTrajectoryStep.requested_interaction`, and
12+
`HandleCascadeUserInteractionRequest`.
13+
- Added a redacted proto trace summary for
14+
`HandleCascadeUserInteraction` requests so canaries can prove which
15+
trajectory/step/action was approved without logging raw URLs or IDs.
16+
- Pending WebFetch permission steps are no longer surfaced as completed native
17+
tool calls. The proxy waits for a real `web_document` or legacy result, which
18+
prevents an empty pending step from deduping away the later completed fetch.
19+
- WebFetch remains outside the default native bridge allowlist. This release
20+
only gives protocol canaries a safe way to test whether LS can continue from
21+
the official permission prompt to a completed `read_url_content.web_document`
22+
step.
23+
24+
Verification:
25+
26+
- `node --check src/client.js`
27+
- `node --check src/windsurf.js`
28+
- `node --check src/proto-trace.js`
29+
- `node --test test/proto-trace.test.js`
30+
- `node --test test/v2070-issue-fixes.test.js`
31+
- `node --test test/client-panel-retry.test.js`
32+
- `npm test`

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.134",
3+
"version": "2.0.135",
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: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@ import {
2323
buildUpdatePanelStateWithUserStatusRequest,
2424
buildStartCascadeRequest, parseStartCascadeResponse,
2525
buildSendCascadeMessageRequest,
26-
buildGetTrajectoryRequest, parseTrajectoryStatus,
26+
buildGetTrajectoryRequest,
27+
parseTrajectoryInfo, buildHandleReadUrlContentInteractionRequest,
2728
buildGetTrajectoryStepsRequest, parseTrajectorySteps,
2829
buildGetGeneratorMetadataRequest, parseGeneratorMetadata,
2930
buildGetUserStatusRequest, extractUserStatusBytes, parseGetUserStatusResponse,
@@ -65,6 +66,21 @@ function resetCascadeTransportState(port) {
6566
lsEntry.sessionId = null;
6667
}
6768

69+
function csvSetEnv(name) {
70+
return new Set(String(process.env[name] || '')
71+
.split(',')
72+
.map(s => s.trim())
73+
.filter(Boolean));
74+
}
75+
76+
function isReadUrlAutoApproveAllowed(url, origin) {
77+
if (process.env.WINDSURFAPI_NATIVE_TOOL_BRIDGE_WEBFETCH_AUTO_APPROVE !== '1') return false;
78+
const allow = csvSetEnv('WINDSURFAPI_NATIVE_TOOL_BRIDGE_WEBFETCH_AUTO_APPROVE_ORIGINS');
79+
if (!allow.size) return false;
80+
const candidates = [origin, url].map(v => String(v || '').trim()).filter(Boolean);
81+
return candidates.some(v => allow.has(v) || allow.has(v.replace(/\/+$/, '')));
82+
}
83+
6884
function isImageLikeBlock(part) {
6985
const type = String(part?.type || '').toLowerCase();
7086
return type === 'image' || type === 'image_url' || type === 'input_image'
@@ -836,6 +852,8 @@ export class WindsurfClient {
836852
const usageByStep = new Map();
837853
const seenToolCallIds = new Set();
838854
const toolCalls = [];
855+
const approvedReadUrlInteractions = new Set();
856+
let cascadeTrajectoryId = '';
839857
let totalYielded = 0;
840858
let totalThinking = 0;
841859
let idleCount = 0;
@@ -871,6 +889,48 @@ export class WindsurfClient {
871889
&& steps.some(step => Array.isArray(step?.toolCalls)
872890
&& step.toolCalls.some(tc => tc?.cascade_native));
873891

892+
if (nativeMode && (nativeAllowlist || []).includes('read_url_content')) {
893+
for (let i = 0; i < steps.length; i++) {
894+
const pending = steps[i]?.requestedInteraction;
895+
if (pending?.kind !== 'read_url_content') continue;
896+
const url = pending.url || '';
897+
const origin = pending.origin || '';
898+
if (!isReadUrlAutoApproveAllowed(url, origin)) continue;
899+
if (!cascadeTrajectoryId) {
900+
const statusResp = await grpcUnary(
901+
this.port, this.csrfToken,
902+
`${LS_SERVICE}/GetCascadeTrajectory`,
903+
grpcFrame(buildGetTrajectoryRequest(cascadeId))
904+
);
905+
const info = parseTrajectoryInfo(statusResp);
906+
cascadeTrajectoryId = info.trajectoryId || '';
907+
lastStatus = info.status;
908+
}
909+
if (!cascadeTrajectoryId) {
910+
log.warn('WebFetch auto-approve skipped: missing trajectory_id');
911+
continue;
912+
}
913+
const approvalKey = `${cascadeTrajectoryId}:${stepOffset + i}:${url}:${origin}`;
914+
if (approvedReadUrlInteractions.has(approvalKey)) continue;
915+
approvedReadUrlInteractions.add(approvalKey);
916+
const req = buildHandleReadUrlContentInteractionRequest(cascadeId, {
917+
trajectoryId: cascadeTrajectoryId,
918+
stepIndex: stepOffset + i,
919+
action: 1, // READ_URL_CONTENT_ACTION_ALLOW_ONCE
920+
url,
921+
origin,
922+
});
923+
await grpcUnary(
924+
this.port, this.csrfToken,
925+
`${LS_SERVICE}/HandleCascadeUserInteraction`,
926+
grpcFrame(req),
927+
10000
928+
);
929+
lastGrowthAt = Date.now();
930+
log.warn(`WebFetch auto-approved read_url_content for allowed origin ${origin || '(unknown)'}`);
931+
}
932+
}
933+
874934
// CORTEX_STEP_TYPE_ERROR_MESSAGE = 17. An error step means the cascade
875935
// refused the request (permission denied, model unavailable, etc.) —
876936
// raise it as a model-level error so the account isn't blamed.
@@ -1070,7 +1130,9 @@ export class WindsurfClient {
10701130
const statusResp = await grpcUnary(
10711131
this.port, this.csrfToken, `${LS_SERVICE}/GetCascadeTrajectory`, grpcFrame(statusProto)
10721132
);
1073-
const status = parseTrajectoryStatus(statusResp);
1133+
const statusInfo = parseTrajectoryInfo(statusResp);
1134+
const status = statusInfo.status;
1135+
if (statusInfo.trajectoryId) cascadeTrajectoryId = statusInfo.trajectoryId;
10741136
lastStatus = status;
10751137

10761138
if (status !== 1) sawActive = true;

src/proto-trace.js

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,12 @@ const CASCADE_WEB_REQUESTS_AUTO_EXECUTION = new Map([
196196
[3, 'TURBO'],
197197
]);
198198

199+
const READ_URL_CONTENT_ACTION = new Map([
200+
[1, 'ALLOW_ONCE'],
201+
[2, 'REJECT'],
202+
[3, 'ALWAYS_ALLOW_ORIGIN'],
203+
]);
204+
199205
function enumSummary(value, names) {
200206
return value == null ? null : {
201207
value,
@@ -467,6 +473,42 @@ function summarizeRequestedInteraction(buf) {
467473
};
468474
}
469475

476+
function summarizeHandleCascadeUserInteraction(payload) {
477+
const fields = parseFields(payload);
478+
const cascadeId = stringField(fields, 1);
479+
const interactionField = getField(fields, 2, 2);
480+
const out = {
481+
cascadeIdBytes: cascadeId.length,
482+
cascadeIdHash: cascadeId ? shortHash(Buffer.from(cascadeId, 'utf8')) : null,
483+
fieldNumbers: fields.map(f => f.field),
484+
};
485+
if (!interactionField) return out;
486+
487+
const interaction = parseFields(interactionField.value);
488+
const trajectoryId = stringField(interaction, 1);
489+
const readUrlField = getField(interaction, 15, 2);
490+
out.interaction = {
491+
trajectoryIdBytes: trajectoryId.length,
492+
trajectoryIdHash: trajectoryId ? shortHash(Buffer.from(trajectoryId, 'utf8')) : null,
493+
stepIndex: numberField(interaction, 2),
494+
fieldNumbers: interaction.map(f => f.field),
495+
};
496+
if (!readUrlField) return out;
497+
498+
const readUrl = parseFields(readUrlField.value);
499+
const url = stringField(readUrl, 2);
500+
const origin = stringField(readUrl, 3);
501+
out.interaction.readUrlContent = {
502+
action: enumSummary(numberField(readUrl, 1), READ_URL_CONTENT_ACTION),
503+
urlBytes: url.length,
504+
urlHash: url ? shortHash(Buffer.from(url, 'utf8')) : null,
505+
originBytes: origin.length,
506+
originHash: origin ? shortHash(Buffer.from(origin, 'utf8')) : null,
507+
fieldNumbers: readUrl.map(f => f.field),
508+
};
509+
return out;
510+
}
511+
470512
function summarizeReadWrapperField19(wrapperBuf) {
471513
const fields = parseFields(wrapperBuf);
472514
return {
@@ -676,6 +718,9 @@ function semanticSummary(method, direction, payload) {
676718
if (method === 'GetCascadeTrajectorySteps' && direction === 'response') {
677719
return summarizeGetCascadeTrajectorySteps(payload);
678720
}
721+
if (method === 'HandleCascadeUserInteraction' && direction === 'request') {
722+
return summarizeHandleCascadeUserInteraction(payload);
723+
}
679724
} catch (err) {
680725
return { error: err.message };
681726
}

src/windsurf.js

Lines changed: 90 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -792,6 +792,87 @@ export function buildGetTrajectoryRequest(cascadeId) {
792792
return writeStringField(1, cascadeId);
793793
}
794794

795+
/**
796+
* Parse GetCascadeTrajectoryResponse.
797+
*
798+
* Response {
799+
* CortexTrajectory trajectory = 1; // trajectory_id=1, cascade_id=6
800+
* CascadeRunStatus status = 2;
801+
* }
802+
*/
803+
export function parseTrajectoryInfo(buf) {
804+
const fields = parseFields(buf);
805+
const statusField = getField(fields, 2, 0);
806+
const trajectoryField = getField(fields, 1, 2);
807+
let trajectoryId = '';
808+
let cascadeId = '';
809+
if (trajectoryField) {
810+
try {
811+
const trajectory = parseFields(trajectoryField.value);
812+
trajectoryId = getField(trajectory, 1, 2)?.value?.toString('utf8') || '';
813+
cascadeId = getField(trajectory, 6, 2)?.value?.toString('utf8') || '';
814+
} catch {}
815+
}
816+
return {
817+
status: statusField ? statusField.value : 0,
818+
trajectoryId,
819+
cascadeId,
820+
};
821+
}
822+
823+
function parseReadUrlRequestedInteraction(stepFields) {
824+
const requested = getField(stepFields, 56, 2);
825+
if (!requested) return null;
826+
try {
827+
const requestedFields = parseFields(requested.value);
828+
const readUrl = getField(requestedFields, 14, 2);
829+
if (!readUrl) return null;
830+
const spec = parseFields(readUrl.value);
831+
const url = getField(spec, 1, 2)?.value?.toString('utf8') || '';
832+
const origin = getField(spec, 2, 2)?.value?.toString('utf8') || '';
833+
if (!url) return null;
834+
return { url, origin };
835+
} catch {
836+
return null;
837+
}
838+
}
839+
840+
/**
841+
* Build HandleCascadeUserInteractionRequest for ReadUrlContent approval.
842+
*
843+
* HandleCascadeUserInteractionRequest {
844+
* string cascade_id = 1;
845+
* CascadeUserInteraction interaction = 2;
846+
* }
847+
* CascadeUserInteraction {
848+
* string trajectory_id = 1;
849+
* uint32 step_index = 2;
850+
* CascadeReadUrlContentInteraction read_url_content = 15;
851+
* }
852+
*/
853+
export function buildHandleReadUrlContentInteractionRequest(cascadeId, {
854+
trajectoryId = '',
855+
stepIndex = 0,
856+
action = 1,
857+
url = '',
858+
origin = '',
859+
} = {}) {
860+
const readUrlInteraction = Buffer.concat([
861+
writeVarintField(1, action),
862+
writeStringField(2, url),
863+
writeStringField(3, origin),
864+
]);
865+
const interaction = Buffer.concat([
866+
writeStringField(1, trajectoryId),
867+
writeVarintField(2, stepIndex),
868+
writeMessageField(15, readUrlInteraction),
869+
]);
870+
return Buffer.concat([
871+
writeStringField(1, cascadeId),
872+
writeMessageField(2, interaction),
873+
]);
874+
}
875+
795876
/**
796877
* Build GetCascadeTrajectoryGeneratorMetadataRequest.
797878
*
@@ -892,9 +973,7 @@ export function parseStartCascadeResponse(buf) {
892973

893974
/** Parse GetCascadeTrajectoryResponse → status (field 2). */
894975
export function parseTrajectoryStatus(buf) {
895-
const fields = parseFields(buf);
896-
const f2 = getField(fields, 2, 0);
897-
return f2 ? f2.value : 0;
976+
return parseTrajectoryInfo(buf).status;
898977
}
899978

900979
/**
@@ -963,6 +1042,13 @@ export function parseTrajectorySteps(buf) {
9631042
toolCalls: [], // [{id, name, argumentsJson, result?}]
9641043
usage: null, // {inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens}
9651044
};
1045+
const readUrlRequestedInteraction = parseReadUrlRequestedInteraction(sf);
1046+
if (readUrlRequestedInteraction) {
1047+
entry.requestedInteraction = {
1048+
kind: 'read_url_content',
1049+
...readUrlRequestedInteraction,
1050+
};
1051+
}
9661052

9671053
// CortexTrajectoryStep.metadata (field 5) → CortexStepMetadata.
9681054
// CortexStepMetadata.model_usage (field 9) → ModelUsageStats.
@@ -1176,6 +1262,7 @@ export function parseTrajectorySteps(buf) {
11761262
const webDocument = getField(body, 2, 2);
11771263
result = webDocument ? decodeKnowledgeBaseItemText(webDocument.value) : '';
11781264
if (!result) result = getField(body, 5, 2)?.value?.toString('utf8') || '';
1265+
if (!result && readUrlRequestedInteraction) continue;
11791266
} else if (kind === 'search_web') {
11801267
const args = {
11811268
query: getField(body, 1, 2)?.value?.toString('utf8') || '',

0 commit comments

Comments
 (0)