Skip to content

Commit a11c13c

Browse files
committed
fix(native): trace read wrapper fields
1 parent 99a4b42 commit a11c13c

14 files changed

Lines changed: 364 additions & 2 deletions

.dockerignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ docs
1818
audit
1919
scripts/*
2020
!scripts/native-bridge-smoke.mjs
21+
!scripts/special-agent-smoke.mjs
2122
!scripts/lsp-capacity-matrix.mjs
2223
!scripts/web-search-direct-probe.mjs
2324
bugsy

.env.example

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,10 @@ LS_PORT=42100
7676
# WINDSURFAPI_NATIVE_TOOL_BRIDGE_OFF=1
7777
# Smoke an already-running native bridge deployment with:
7878
# API_KEY=... BASE_URL=http://127.0.0.1:3003 npm run smoke:native-bridge
79+
# Protocol trace is lab-only. For Read wrapper reverse engineering, prefer the
80+
# dedicated child summary over global raw string dumps:
81+
# WINDSURFAPI_PROTO_TRACE=1
82+
# WINDSURFAPI_PROTO_TRACE_READ_WRAPPER_STRINGS=0
7983

8084
# Optional special-agent backend for models that do not work through direct
8185
# Cascade chat (currently swe-1.6 / swe-1.6-fast / adaptive / arena-*).
@@ -98,6 +102,8 @@ LS_PORT=42100
98102
# be used before enabling these in production.
99103
# DEVIN_CLI_ALLOW_CLIENT_TOOLS=0
100104
# DEVIN_CLI_ALLOW_MEDIA=0
105+
# After configuring Devin CLI/ACP, validate the route with:
106+
# API_KEY=... BASE_URL=http://127.0.0.1:3003 npm run smoke:special-agent
101107

102108
# ========== Dashboard ==========
103109
# Dashboard password — protects /dashboard and all /dashboard/api/* endpoints.

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ sshpass2.sh
77
deploy-us.py
88
scripts/*
99
!scripts/native-bridge-smoke.mjs
10+
!scripts/special-agent-smoke.mjs
1011
!scripts/lsp-capacity-matrix.mjs
1112
!scripts/web-search-direct-probe.mjs
1213
src/get-token.js

Dockerfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ RUN apt-get update \
2626
COPY package.json ./
2727
COPY src ./src
2828
COPY scripts/native-bridge-smoke.mjs ./scripts/native-bridge-smoke.mjs
29+
COPY scripts/special-agent-smoke.mjs ./scripts/special-agent-smoke.mjs
2930
COPY scripts/lsp-capacity-matrix.mjs ./scripts/lsp-capacity-matrix.mjs
3031
COPY scripts/web-search-direct-probe.mjs ./scripts/web-search-direct-probe.mjs
3132
COPY install-ls.sh setup.sh .env.example ./

docs/native-bridge-protocol-notes.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,23 @@ show `type=14` with payload on `field=19`, and `type=15` with `field=20`
132132
planner response data. Keep parsing based on actual oneof/message fields and
133133
trace unknown message-field children before promoting a new mapping.
134134

135+
For the observed Read wrapper (`type=14`, `field=19`), v2.0.131 only promotes
136+
field `1` or `2` when the candidate is clearly path-like. Live traces showed
137+
field `2` can also contain the full prompt/environment text, so this is a
138+
stop-loss guard, not a confirmed schema. Enable proto trace to inspect
139+
`semantic.steps[].readWrapperField19` before changing the parser again:
140+
141+
```text
142+
WINDSURFAPI_PROTO_TRACE=1
143+
# Optional, only for a gated lab run. Redaction still applies.
144+
WINDSURFAPI_PROTO_TRACE_READ_WRAPPER_STRINGS=1
145+
```
146+
147+
The dedicated summary records child field numbers, wire types, byte lengths,
148+
hashes, and safe classifications such as `looksPathLike` and
149+
`looksPromptLike`. Do not use the global raw-string trace switch for production
150+
traffic; it can capture prompts.
151+
135152
Trajectory parsing now recognizes the web step oneofs observed so far:
136153

137154
- `read_url_content` = field `40`, body `{ url=1, summary=5 }`
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# v2.0.132
2+
3+
- Added dedicated proto trace summaries for the Read `type=14` / `field=19`
4+
wrapper. Traces now expose `semantic.steps[].readWrapperField19` with child
5+
field numbers, byte lengths, hashes, and safe `looksPathLike` /
6+
`looksPromptLike` classifications. Raw previews stay off by default and
7+
require `WINDSURFAPI_PROTO_TRACE_READ_WRAPPER_STRINGS=1` for gated lab runs.
8+
- Kept Read promotion conservative. v2.0.131's path guard remains the runtime
9+
boundary; nested wrapper fields are not promoted until live traces confirm the
10+
schema.
11+
- Added coverage for generic `BUILD_*` health metadata. The VPS deployment can
12+
inject `WINDSURFAPI_BUILD_VERSION`, `WINDSURFAPI_BUILD_COMMIT`, commit
13+
message/date, and branch through `.env` so `/health` reports the deployed
14+
revision. Automatic GHCR build-arg wiring still requires a workflow-scope
15+
GitHub token and is intentionally left out of this tag.
16+
- Added `npm run smoke:special-agent` for the SWE/Devin special-agent POC. The
17+
smoke preflights `/health?verbose=1`, sends a text-only `swe-1.6-fast`
18+
request, and refuses to run unless the backend is explicitly enabled.
19+
- WebSearch remains on the direct `GetWebSearchResults` probe path, and
20+
WebFetch has no guessed direct endpoint. LS-native WebSearch/WebFetch stay
21+
out of the production native bridge defaults.
22+
23+
Verification:
24+
25+
- `node --check src/proto-trace.js`
26+
- `node --check scripts/special-agent-smoke.mjs`
27+
- `node --test test/proto-trace.test.js test/native-read-wrapper.test.js test/version.test.js test/special-agent-smoke.test.js test/docker-script-packaging.test.js test/web-search-direct-probe.test.js`

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "windsurf-api",
3-
"version": "2.0.131",
3+
"version": "2.0.132",
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",
@@ -9,6 +9,7 @@
99
"dev": "node --watch src/index.js",
1010
"test": "node --test test/*.test.js",
1111
"smoke:native-bridge": "node scripts/native-bridge-smoke.mjs",
12+
"smoke:special-agent": "node scripts/special-agent-smoke.mjs",
1213
"smoke:lsp-matrix": "node scripts/lsp-capacity-matrix.mjs",
1314
"probe:web-search": "node scripts/web-search-direct-probe.mjs"
1415
},

scripts/special-agent-smoke.mjs

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
#!/usr/bin/env node
2+
3+
const baseUrl = (process.env.BASE_URL || process.env.WINDSURFAPI_BASE_URL || 'http://127.0.0.1:3003').replace(/\/+$/, '');
4+
const apiKey = process.env.API_KEY || process.env.WINDSURFAPI_API_KEY || '';
5+
const model = process.env.MODEL || process.env.SPECIAL_AGENT_SMOKE_MODEL || 'swe-1.6-fast';
6+
const requestTimeoutMs = Math.max(5_000, Number(process.env.SPECIAL_AGENT_SMOKE_TIMEOUT_MS || 180_000));
7+
const requireEnabled = process.env.SPECIAL_AGENT_SMOKE_REQUIRE_ENABLED !== '0';
8+
const prompt = process.env.SPECIAL_AGENT_SMOKE_PROMPT || 'Reply exactly SPECIAL_AGENT_OK.';
9+
10+
if (!apiKey) {
11+
console.error('API_KEY is required.');
12+
process.exit(2);
13+
}
14+
15+
function compactText(text, max = 1200) {
16+
const s = String(text || '').replace(/\s+/g, ' ').trim();
17+
return s.length > max ? `${s.slice(0, max)}...<truncated ${s.length - max} chars>` : s;
18+
}
19+
20+
async function fetchJson(path, opts = {}) {
21+
const controller = new AbortController();
22+
const timer = setTimeout(() => controller.abort(), requestTimeoutMs);
23+
try {
24+
const res = await fetch(`${baseUrl}${path}`, {
25+
...opts,
26+
signal: controller.signal,
27+
headers: {
28+
authorization: `Bearer ${apiKey}`,
29+
...(opts.headers || {}),
30+
},
31+
});
32+
const text = await res.text();
33+
let body = null;
34+
try { body = text ? JSON.parse(text) : null; } catch {}
35+
return { status: res.status, body, text };
36+
} finally {
37+
clearTimeout(timer);
38+
}
39+
}
40+
41+
const health = await fetchJson('/health?verbose=1');
42+
const specialAgent = health.body?.specialAgent || null;
43+
if (requireEnabled && !specialAgent?.enabled) {
44+
console.log(JSON.stringify({
45+
ok: false,
46+
stage: 'preflight',
47+
error: 'special-agent backend is disabled',
48+
specialAgent,
49+
hint: 'Set WINDSURFAPI_SPECIAL_AGENT_BACKEND=devin-cli and DEVIN_CLI_MODE=print or acp before running this smoke.',
50+
}, null, 2));
51+
process.exit(1);
52+
}
53+
54+
const started = Date.now();
55+
const chat = await fetchJson('/v1/chat/completions', {
56+
method: 'POST',
57+
headers: { 'content-type': 'application/json' },
58+
body: JSON.stringify({
59+
model,
60+
stream: false,
61+
max_tokens: 128,
62+
messages: [{ role: 'user', content: prompt }],
63+
}),
64+
});
65+
66+
const content = chat.body?.choices?.[0]?.message?.content || '';
67+
const out = {
68+
ok: chat.status >= 200 && chat.status < 300 && !chat.body?.error,
69+
model,
70+
status: chat.status,
71+
latencyMs: Date.now() - started,
72+
specialAgent,
73+
content: compactText(content),
74+
error: chat.body?.error || null,
75+
};
76+
77+
console.log(JSON.stringify(out, null, 2));
78+
if (!out.ok) process.exit(1);

src/proto-trace.js

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,42 @@ function redactPreview(s) {
5454
.slice(0, 240);
5555
}
5656

57+
function looksPathLike(s) {
58+
const value = String(s || '').trim();
59+
if (!value || value.length > 1024 || /[\r\n<>]/.test(value)) return false;
60+
if (/^file:\/\/\/?(?:[A-Za-z]:[\\/]|\/|~[\\/])/.test(value)) return true;
61+
if (/^(?:[A-Za-z]:[\\/]|\/|~[\\/]|\.{1,2}[\\/])\S+/.test(value)) return true;
62+
return /^[A-Za-z0-9._-]+(?:[\\/][A-Za-z0-9._-]+)*\.[A-Za-z0-9]{1,12}$/.test(value);
63+
}
64+
65+
function looksPromptLike(s) {
66+
const value = String(s || '');
67+
if (!value) return false;
68+
if (value.includes('Working directory:') || value.includes('Use the Read tool')) return true;
69+
if (value.includes('<env>') || value.includes('</env>') || value.includes('<system-reminder>')) return true;
70+
return value.length > 512 && /(?:tool|prompt|environment|platform|workspace)/i.test(value);
71+
}
72+
73+
function basenameOfPath(s) {
74+
const value = String(s || '').trim().replace(/^file:\/+/, '');
75+
const parts = value.split(/[\\/]+/).filter(Boolean);
76+
return parts.length ? parts[parts.length - 1].slice(0, 120) : '';
77+
}
78+
79+
function looksLikeMessage(buf) {
80+
if (!buf?.length) return false;
81+
const key = buf[0];
82+
const wireType = key & 7;
83+
const field = key >> 3;
84+
if (!field || ![0, 1, 2, 5].includes(wireType)) return false;
85+
try {
86+
const parsed = parseFields(buf);
87+
return parsed.length > 0 && parsed.every(f => f.field > 0);
88+
} catch {
89+
return false;
90+
}
91+
}
92+
5793
function stringField(fields, num) {
5894
const f = getField(fields, num, 2);
5995
return f ? f.value.toString('utf8') : '';
@@ -343,6 +379,50 @@ function summarizeNativeStepBody(kind, bodyBuf) {
343379
return { fieldCount: f.length };
344380
}
345381

382+
function summarizeReadWrapperField19(wrapperBuf) {
383+
const fields = parseFields(wrapperBuf);
384+
return {
385+
bytes: wrapperBuf.length,
386+
fieldNumbers: fields.map(f => f.field),
387+
children: fields.slice(0, positiveIntEnv('WINDSURFAPI_PROTO_TRACE_READ_WRAPPER_CHILD_LIMIT', 24))
388+
.map((f) => {
389+
const out = {
390+
field: f.field,
391+
wireType: f.wireType,
392+
};
393+
if (f.wireType === 0) {
394+
out.type = 'varint';
395+
out.value = typeof f.value === 'bigint' ? f.value.toString() : f.value;
396+
return out;
397+
}
398+
if (!Buffer.isBuffer(f.value)) return out;
399+
out.bytes = f.value.length;
400+
out.sha256 = shortHash(f.value);
401+
if (looksLikeMessage(f.value)) {
402+
out.type = 'message_or_bytes';
403+
out.summary = summarizeMessageChildren(f.value, 8);
404+
return out;
405+
}
406+
if (mostlyText(f.value)) {
407+
const text = f.value.toString('utf8');
408+
out.type = 'string';
409+
out.hasNewline = /[\r\n]/.test(text);
410+
out.hasAngleBracket = /[<>]/.test(text);
411+
out.looksPathLike = looksPathLike(text);
412+
out.looksPromptLike = looksPromptLike(text);
413+
out.basename = out.looksPathLike ? basenameOfPath(text) : '';
414+
if (process.env.WINDSURFAPI_PROTO_TRACE_READ_WRAPPER_STRINGS === '1') {
415+
out.preview = redactPreview(text);
416+
}
417+
return out;
418+
}
419+
out.type = 'message_or_bytes';
420+
out.summary = summarizeMessageChildren(f.value, 8);
421+
return out;
422+
}),
423+
};
424+
}
425+
346426
function summarizeTrajectoryStep(stepBuf, index) {
347427
const fields = parseFields(stepBuf);
348428
const oneofFields = [];
@@ -363,13 +443,16 @@ function summarizeTrajectoryStep(stepBuf, index) {
363443
field: f.field,
364444
...summarizeMessageChildren(f.value, 8),
365445
}));
446+
const type = numberField(fields, 1);
447+
const wrapper19 = type === 14 ? getField(fields, 19, 2) : null;
366448
return {
367449
index,
368-
type: numberField(fields, 1),
450+
type,
369451
status: numberField(fields, 4),
370452
fieldNumbers: fields.map(f => f.field),
371453
nativeOneofs: oneofFields,
372454
messageFields: interestingFields,
455+
...(wrapper19 ? { readWrapperField19: summarizeReadWrapperField19(wrapper19.value) } : {}),
373456
};
374457
}
375458

test/native-read-wrapper.test.js

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,38 @@ describe('native Read wrapper trajectory parsing', () => {
118118
assert.equal(calls.length, 0);
119119
});
120120

121+
it('prefers wrapper field 1 path over field 2 prompt text', () => {
122+
const wrapper = Buffer.concat([
123+
writeStringField(1, 'file:///home/user/projects/workspace-abc123/README.md'),
124+
writeStringField(2, '- Working directory: /tmp/project\n\nUse the Read tool exactly once for README.md.'),
125+
writeStringField(4, 'observed content'),
126+
]);
127+
const step = Buffer.concat([
128+
writeVarintField(1, 14),
129+
writeVarintField(4, 3),
130+
writeMessageField(19, wrapper),
131+
]);
132+
const steps = parseTrajectorySteps(trajectoryStepsResponse(step));
133+
const calls = steps[0].toolCalls.filter(tc => tc.cascade_native);
134+
assert.equal(calls.length, 1);
135+
assert.equal(JSON.parse(calls[0].argumentsJson).absolute_path_uri, 'file:///home/user/projects/workspace-abc123/README.md');
136+
});
137+
138+
it('does not promote a nested wrapper field before the field role is confirmed', () => {
139+
const wrapper = Buffer.concat([
140+
writeMessageField(3, writeStringField(1, 'file:///home/user/projects/workspace-abc123/README.md')),
141+
writeStringField(4, 'observed content'),
142+
]);
143+
const step = Buffer.concat([
144+
writeVarintField(1, 14),
145+
writeVarintField(4, 3),
146+
writeMessageField(19, wrapper),
147+
]);
148+
const steps = parseTrajectorySteps(trajectoryStepsResponse(step));
149+
const calls = steps[0].toolCalls.filter(tc => tc.cascade_native);
150+
assert.equal(calls.length, 0);
151+
});
152+
121153
it('repairs native Read workspace paths to caller cwd-relative paths before sanitizing', () => {
122154
const tc = {
123155
name: 'Read',

0 commit comments

Comments
 (0)