Skip to content

Commit eb00b3b

Browse files
committed
fix(native): classify cascade error traces
1 parent a11c13c commit eb00b3b

5 files changed

Lines changed: 244 additions & 1 deletion

File tree

docs/native-bridge-protocol-notes.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,21 @@ hashes, and safe classifications such as `looksPathLike` and
149149
`looksPromptLike`. Do not use the global raw-string trace switch for production
150150
traffic; it can capture prompts.
151151

152+
Error trajectory steps also have a dedicated redacted summary. For `type=17`
153+
or any step carrying `error_message` field `24` / `error` field `31`, traces
154+
now expose `semantic.steps[].errorStep` with source field numbers, byte
155+
lengths, hashes, nested string paths, and classification flags such as
156+
`permissionDenied`, `failedPrecondition`, `modelNotAvailable`, and
157+
`internalError`. Raw previews stay off by default; for a gated lab run only:
158+
159+
```text
160+
WINDSURFAPI_PROTO_TRACE_ERROR_STRINGS=1
161+
```
162+
163+
This switch is narrower than `WINDSURFAPI_PROTO_TRACE_STRINGS=1` and still
164+
redacts emails and token-like values. It exists to diagnose LS executor
165+
preconditions without dumping complete prompts or account material.
166+
152167
Trajectory parsing now recognizes the web step oneofs observed so far:
153168

154169
- `read_url_content` = field `40`, body `{ url=1, summary=5 }`
@@ -197,6 +212,27 @@ separate first-party API bridge until we find the LS-side web executor
197212
precondition. The confirmed protobuf fields are useful for tracing and future
198213
matrix work, but not sufficient for production native bridge rollout.
199214

215+
The v2.0.132 VPS pass rechecked the same area after the Read wrapper fixes:
216+
217+
- Direct `GetWebSearchResults` succeeded for all ten loaded active/pro
218+
accounts, returning two results per account for the control query.
219+
- A gated LS-native smoke enabled only `WebSearch,WebFetch` for
220+
`claude-sonnet-4.6` with API-key gating and raw web subconfigs.
221+
- `SendUserCascadeMessage` did send web native configs:
222+
`search_web` appeared as allowlist `["search_web"]` with subconfig field
223+
`13`; `read_url_content` appeared as allowlist `["read_url_content"]` with
224+
subconfig field `37`.
225+
- `GetCascadeTrajectorySteps` still emitted no `field=42 search_web` and no
226+
`field=40 read_url_content` native oneof. The repeated shape was
227+
`type=14`, `type=34`, then `type=17` error with field `24`.
228+
- The OpenAI-compatible smoke response surfaced HTTP `403` with
229+
`type="model_not_available"` for both web scenarios.
230+
231+
Updated conclusion: the proxy is not failing to send the WebFetch config; LS
232+
receives the `read_url_content` config but still fails before emitting the web
233+
oneof. The missing piece remains an LS-side web executor precondition or a
234+
descriptor-backed direct WebFetch/read-url API.
235+
200236
## Direct Web Search API
201237

202238
`GetWebSearchResults` is confirmed independently of the LS-native tool path:
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# v2.0.133
2+
3+
- Added redacted semantic summaries for Cascade error trajectory steps. Proto
4+
traces now expose `semantic.steps[].errorStep` for `error_message` field `24`
5+
and `error` field `31`, including field numbers, byte lengths, hashes,
6+
nested string paths, and safe classification flags such as
7+
`permissionDenied`, `failedPrecondition`, `modelNotAvailable`, and
8+
`internalError`.
9+
- Added a dedicated lab-only preview switch:
10+
`WINDSURFAPI_PROTO_TRACE_ERROR_STRINGS=1`. It is narrower than global string
11+
tracing and still redacts emails and token-like values.
12+
- Documented the v2.0.132 VPS WebSearch/WebFetch canary. Direct
13+
`GetWebSearchResults` remains confirmed, but LS-native WebSearch/WebFetch
14+
still emitted no `search_web` / `read_url_content` oneof even though both
15+
native configs were sent. WebFetch direct still has no descriptor-backed
16+
endpoint and is not implemented from guesswork.
17+
18+
Verification:
19+
20+
- `node --check src/proto-trace.js`
21+
- `node --test test/proto-trace.test.js`

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.132",
3+
"version": "2.0.133",
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/proto-trace.js

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ function mostlyText(buf) {
5050
function redactPreview(s) {
5151
return String(s)
5252
.replace(/\b(?:devin-session-token|sessionToken|api[_-]?key|firebase_id_token|idToken|refreshToken)\b\s*[:=]\s*["']?[^"',\s)]+/gi, '<redacted-secret>')
53+
.replace(/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi, '<redacted-email>')
5354
.replace(/\b[A-Za-z0-9_-]{32,}\b/g, '<redacted-token>')
5455
.slice(0, 240);
5556
}
@@ -423,6 +424,115 @@ function summarizeReadWrapperField19(wrapperBuf) {
423424
};
424425
}
425426

427+
function classifyErrorText(text) {
428+
const value = String(text || '').toLowerCase();
429+
return {
430+
permissionDenied: /permission[_\s-]?denied|forbidden|\b403\b|not authorized/.test(value),
431+
failedPrecondition: /failed[_\s-]?precondition|precondition/.test(value),
432+
modelNotAvailable: /model[_\s-]?not[_\s-]?available|model not available/.test(value),
433+
internalError: /internal[_\s-]?error|an internal error occurred/.test(value),
434+
unauthenticated: /unauthenticated|\b401\b|invalid api key|invalid token/.test(value),
435+
rateLimited: /rate[_\s-]?limit|too many requests|\b429\b/.test(value),
436+
quotaOrEntitlement: /quota|credit|billing|subscription|entitlement/.test(value),
437+
};
438+
}
439+
440+
function compactTrueFlags(flags) {
441+
return Object.fromEntries(Object.entries(flags || {}).filter(([, v]) => !!v));
442+
}
443+
444+
function mergeErrorClassifications(target, source) {
445+
for (const [key, value] of Object.entries(source || {})) {
446+
if (value) target[key] = true;
447+
}
448+
return target;
449+
}
450+
451+
function summarizeErrorString(path, buf) {
452+
const text = buf.toString('utf8');
453+
const classifications = compactTrueFlags(classifyErrorText(text));
454+
const out = {
455+
path,
456+
bytes: buf.length,
457+
sha256: shortHash(buf),
458+
classifications,
459+
};
460+
if (process.env.WINDSURFAPI_PROTO_TRACE_ERROR_STRINGS === '1') {
461+
out.preview = redactPreview(text);
462+
}
463+
return out;
464+
}
465+
466+
function collectErrorStrings(buf, path, out, depth = 0) {
467+
if (!Buffer.isBuffer(buf) || out.length >= positiveIntEnv('WINDSURFAPI_PROTO_TRACE_ERROR_STRING_LIMIT', 8)) return;
468+
const maxDepth = positiveIntEnv('WINDSURFAPI_PROTO_TRACE_ERROR_DEPTH', 4);
469+
if (depth < maxDepth && looksLikeMessage(buf)) {
470+
const before = out.length;
471+
try {
472+
const fields = parseFields(buf);
473+
for (const f of fields) {
474+
if (out.length >= positiveIntEnv('WINDSURFAPI_PROTO_TRACE_ERROR_STRING_LIMIT', 8)) return;
475+
if (f.wireType !== 2 || !Buffer.isBuffer(f.value)) continue;
476+
collectErrorStrings(f.value, `${path}.${f.field}`, out, depth + 1);
477+
}
478+
if (out.length > before) return;
479+
} catch {}
480+
}
481+
if (mostlyText(buf)) {
482+
out.push(summarizeErrorString(path, buf));
483+
}
484+
}
485+
486+
function summarizeErrorSource(field, payload) {
487+
const source = {
488+
field,
489+
bytes: payload.length,
490+
sha256: shortHash(payload),
491+
fieldNumbers: [],
492+
varints: [],
493+
strings: [],
494+
classifications: {},
495+
};
496+
try {
497+
const fields = parseFields(payload);
498+
source.fieldNumbers = fields.map(f => f.field);
499+
source.varints = fields
500+
.filter(f => f.wireType === 0)
501+
.slice(0, 8)
502+
.map(f => ({
503+
field: f.field,
504+
value: typeof f.value === 'bigint' ? f.value.toString() : f.value,
505+
}));
506+
collectErrorStrings(payload, String(field), source.strings);
507+
for (const s of source.strings) {
508+
mergeErrorClassifications(source.classifications, s.classifications);
509+
}
510+
} catch (err) {
511+
source.parseError = err.message;
512+
}
513+
source.classifications = compactTrueFlags(source.classifications);
514+
return source;
515+
}
516+
517+
function summarizeErrorStep(fields) {
518+
const sources = [];
519+
for (const fieldNum of [24, 31]) {
520+
for (const f of getAllFields(fields, fieldNum)) {
521+
if (f.wireType !== 2 || !Buffer.isBuffer(f.value)) continue;
522+
sources.push(summarizeErrorSource(fieldNum, f.value));
523+
}
524+
}
525+
if (!sources.length) return null;
526+
const classifications = {};
527+
for (const source of sources) {
528+
mergeErrorClassifications(classifications, source.classifications);
529+
}
530+
return {
531+
sources,
532+
classifications: compactTrueFlags(classifications),
533+
};
534+
}
535+
426536
function summarizeTrajectoryStep(stepBuf, index) {
427537
const fields = parseFields(stepBuf);
428538
const oneofFields = [];
@@ -445,6 +555,7 @@ function summarizeTrajectoryStep(stepBuf, index) {
445555
}));
446556
const type = numberField(fields, 1);
447557
const wrapper19 = type === 14 ? getField(fields, 19, 2) : null;
558+
const errorStep = summarizeErrorStep(fields);
448559
return {
449560
index,
450561
type,
@@ -453,6 +564,7 @@ function summarizeTrajectoryStep(stepBuf, index) {
453564
nativeOneofs: oneofFields,
454565
messageFields: interestingFields,
455566
...(wrapper19 ? { readWrapperField19: summarizeReadWrapperField19(wrapper19.value) } : {}),
567+
...(errorStep ? { errorStep } : {}),
456568
};
457569
}
458570

test/proto-trace.test.js

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -405,4 +405,78 @@ describe('proto trace', () => {
405405
assert.match(promptChild.preview, /<redacted-secret>/);
406406
assert.doesNotMatch(promptChild.preview, /abcdefghijklmnopqrstuvwxyz1234567890abcdef/);
407407
});
408+
409+
it('classifies error trajectory steps without raw strings by default', () => {
410+
process.env.WINDSURFAPI_PROTO_TRACE = '1';
411+
const details = Buffer.concat([
412+
writeStringField(1, 'permission_denied: LS web executor failed precondition for user@example.com'),
413+
writeStringField(2, 'an internal error occurred'),
414+
]);
415+
const errorMessage = Buffer.concat([
416+
writeMessageField(3, details),
417+
writeVarintField(5, 1),
418+
]);
419+
const step = Buffer.concat([
420+
writeVarintField(1, 17),
421+
writeVarintField(4, 3),
422+
writeMessageField(24, errorMessage),
423+
]);
424+
traceGrpcPayload({
425+
port: 42100,
426+
path: '/exa.language_server_pb.LanguageServerService/GetCascadeTrajectorySteps',
427+
direction: 'response',
428+
body: writeMessageField(1, step),
429+
transport: 'grpc',
430+
framed: false,
431+
});
432+
433+
const file = join(dir, `ls-proto-${process.pid}-GetCascadeTrajectorySteps.jsonl`);
434+
const line = readFileSync(file, 'utf8').trim();
435+
const rec = JSON.parse(line);
436+
const errorStep = rec.semantic.steps[0].errorStep;
437+
assert.deepEqual(errorStep.classifications, {
438+
permissionDenied: true,
439+
failedPrecondition: true,
440+
internalError: true,
441+
});
442+
assert.equal(errorStep.sources[0].field, 24);
443+
assert.deepEqual(errorStep.sources[0].fieldNumbers, [3, 5]);
444+
assert.equal(errorStep.sources[0].strings.length, 2);
445+
assert.ok(errorStep.sources[0].strings.every(s => s.sha256 && s.bytes > 0));
446+
assert.ok(errorStep.sources[0].strings.every(s => s.preview === undefined));
447+
assert.doesNotMatch(line, /user@example\.com/);
448+
assert.doesNotMatch(line, /permission_denied: LS web executor/);
449+
});
450+
451+
it('can include redacted error previews behind a dedicated switch', () => {
452+
process.env.WINDSURFAPI_PROTO_TRACE = '1';
453+
process.env.WINDSURFAPI_PROTO_TRACE_ERROR_STRINGS = '1';
454+
const errorMessage = writeStringField(
455+
3,
456+
'model_not_available for tester@example.com api_key=abcdefghijklmnopqrstuvwxyz1234567890abcdef'
457+
);
458+
const step = Buffer.concat([
459+
writeVarintField(1, 17),
460+
writeVarintField(4, 3),
461+
writeMessageField(24, errorMessage),
462+
]);
463+
traceGrpcPayload({
464+
port: 42100,
465+
path: '/exa.language_server_pb.LanguageServerService/GetCascadeTrajectorySteps',
466+
direction: 'response',
467+
body: writeMessageField(1, step),
468+
transport: 'grpc',
469+
framed: false,
470+
});
471+
472+
const file = join(dir, `ls-proto-${process.pid}-GetCascadeTrajectorySteps.jsonl`);
473+
const rec = JSON.parse(readFileSync(file, 'utf8').trim());
474+
const stringSummary = rec.semantic.steps[0].errorStep.sources[0].strings[0];
475+
assert.equal(stringSummary.classifications.modelNotAvailable, true);
476+
assert.match(stringSummary.preview, /model_not_available/);
477+
assert.match(stringSummary.preview, /<redacted-email>/);
478+
assert.match(stringSummary.preview, /<redacted-secret>/);
479+
assert.doesNotMatch(stringSummary.preview, /tester@example\.com/);
480+
assert.doesNotMatch(stringSummary.preview, /abcdefghijklmnopqrstuvwxyz1234567890abcdef/);
481+
});
408482
});

0 commit comments

Comments
 (0)