Skip to content

Commit 898e8c6

Browse files
committed
fix(native): trace web steps without widening defaults
1 parent 306a07c commit 898e8c6

12 files changed

Lines changed: 220 additions & 15 deletions

.env.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,10 @@ LS_PORT=42100
5353
# Also prewarm/probe LS when adding accounts from Dashboard/batch/OAuth.
5454
# Default off so bulk account import cannot spawn many heavy LSPs at once.
5555
# LS_PREWARM_ON_ACCOUNT_ADD=0
56+
# Background credit/token refresh skips accounts currently serving chat,
57+
# account maintenance, or LS maintenance by default. Set 0 only if you want
58+
# scheduled maintenance to run even when an account is busy.
59+
# WINDSURFAPI_BACKGROUND_MAINTENANCE_SKIP_BUSY=1
5660

5761
# Native Cascade tool bridge. Default off because Cascade executes native
5862
# Read/Bash-style tools in the remote Windsurf workspace, while most clients

docs/native-bridge-protocol-notes.md

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,9 @@ is a default production enablement decision.
88
Default production canary scope is intentionally limited to
99
`Bash` / `shell_command` / `run_command`.
1010

11-
`Read`, `Grep`, and `Glob` stay in `TOOL_MAP` for protocol matrix testing, but
12-
they are not in the default native bridge tool allowlist. To test them, set
11+
`Read`, `Grep`, `Glob`, `WebSearch`, and `WebFetch` stay in `TOOL_MAP` for
12+
protocol matrix testing, but they are not in the default native bridge tool
13+
allowlist. To test them, set
1314
`WINDSURFAPI_NATIVE_TOOL_BRIDGE_TOOLS=Read,Bash,Grep,Glob` or a narrower list
1415
for a gated account/API key/model. Do not treat successful protobuf
1516
encode/decode round-trips as production readiness.
@@ -27,6 +28,13 @@ encode/decode round-trips as production readiness.
2728

2829
Confirmed from LS binary protobuf struct tags and runtime trace.
2930

31+
Not confirmed yet:
32+
33+
- `search_web` tool-config submessage field.
34+
- `read_url_content` tool-config submessage field.
35+
- Exact web result/document payload shape beyond the summary field currently
36+
surfaced in trajectory steps.
37+
3038
`FindToolConfig`:
3139

3240
- `max_find_results` = field `1`
@@ -69,6 +77,15 @@ show `type=14` with payload on `field=19`, and `type=15` with `field=20`
6977
planner response data. Keep parsing based on actual oneof/message fields and
7078
trace unknown message-field children before promoting a new mapping.
7179

80+
Trajectory parsing now recognizes the web step oneofs observed so far:
81+
82+
- `read_url_content` = field `40`, body `{ url=1, summary=5 }`
83+
- `search_web` = field `42`, body `{ query=1, domain=3, summary=5 }`
84+
85+
This is trace visibility, not a production enablement decision. The bridge can
86+
decode these steps when Cascade emits them, but WebSearch/WebFetch still need
87+
gated live smoke before they can join the default native bridge allowlist.
88+
7289
## Experiment Hooks
7390

7491
`WINDSURFAPI_NATIVE_TOOL_BRIDGE_CONFIG_RAW` can inject exact protobuf bytes
@@ -81,6 +98,8 @@ read_file:<hex>;grep_v2:base64:<base64>;find:<hex>;list_dir:<hex>
8198
The hook is default-off and exists only for matrix testing. Smoke must still
8299
require native source plus argument validation; a raw subconfig that merely
83100
causes natural-language or degraded `pattern:"*"` output is not a success.
101+
There is intentionally no `search_web` / `read_url_content` raw-config alias
102+
until the tool-config field numbers are proven.
84103

85104
## Next Matrix
86105

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
## v2.0.124 - JSON-safe identity cleanup, web-step tracing, and quieter LS maintenance
2+
3+
- #185 follow-up: response-side Cascade identity neutralization now leaves
4+
parseable JSON payloads unchanged, including fenced JSON and JSON arrays. This
5+
prevents the cleanup layer from rewriting model-info JSON bodies that some
6+
clients surface during upstream failures.
7+
- Native trajectory parsing now recognizes web tool steps emitted as
8+
`read_url_content` field `40` and `search_web` field `42`, surfacing the
9+
observed argument fields plus summary/result text for protocol tracing.
10+
- WebSearch/WebFetch remain outside the default native bridge allowlist. Their
11+
trajectory steps are visible now, but the tool-config submessage fields are
12+
still unproven and require gated live smoke before production enablement.
13+
- `scripts/native-bridge-smoke.mjs` can now explicitly run `WebSearch` and
14+
`WebFetch` scenarios for protocol experiments. `NATIVE_BRIDGE_SMOKE_TOOLS=all`
15+
still excludes them.
16+
- Background credit refresh and Firebase token refresh now skip accounts that
17+
are currently serving chat, account maintenance, or LS maintenance by
18+
default. Set `WINDSURFAPI_BACKGROUND_MAINTENANCE_SKIP_BUSY=0` only if you
19+
want scheduled maintenance to compete with production traffic.
20+
- `docs/native-bridge-protocol-notes.md` and `.env.example` document the new
21+
web-step tracing boundary and maintenance skip knob.
22+
23+
Verification:
24+
25+
- `node --check src\handlers\chat.js`
26+
- `node --check src\windsurf.js`
27+
- `node --check src\auth.js`
28+
- `node --check scripts\native-bridge-smoke.mjs`
29+
- `node --test test\identity-neutralization.test.js test\v2070-issue-fixes.test.js test\langserver-resource.test.js`
30+
- `node --test test\cascade-native-bridge.test.js test\native-tool-routing.test.js test\native-bridge-smoke.test.js test\identity-neutralization.test.js test\v2070-issue-fixes.test.js test\langserver-resource.test.js`
31+
- `node --test --test-timeout=120000 --test-force-exit test\*.test.js` passes: 1021/1021.

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.123",
3+
"version": "2.0.124",
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",

scripts/native-bridge-smoke.mjs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,13 @@ const TOOL = {
9494
pattern: { type: 'string' },
9595
path: { type: 'string' },
9696
}, ['pattern']),
97+
WebSearch: fnTool('WebSearch', {
98+
query: { type: 'string' },
99+
domains: { type: 'array', items: { type: 'string' } },
100+
}, ['query']),
101+
WebFetch: fnTool('WebFetch', {
102+
url: { type: 'string' },
103+
}, ['url']),
97104
};
98105

99106
const SCENARIOS = {
@@ -140,6 +147,24 @@ const SCENARIOS = {
140147
choice: null,
141148
prompt: `Choose exactly one appropriate tool from Read, Bash, Grep, Glob to inspect ${smokeFile} for ${marker}. Do not answer in prose.`,
142149
},
150+
WebSearch: {
151+
tools: [TOOL.WebSearch],
152+
choice: 'WebSearch',
153+
prompt: `Use the WebSearch tool exactly once with query "WindsurfAPI native bridge ${marker}". Do not answer in prose.`,
154+
expectArgs: `query containing ${marker}`,
155+
validateArgs(args) {
156+
return String(args.query || '').includes(marker);
157+
},
158+
},
159+
WebFetch: {
160+
tools: [TOOL.WebFetch],
161+
choice: 'WebFetch',
162+
prompt: `Use the WebFetch tool exactly once with url https://example.com/. Marker: ${marker}. Do not answer in prose.`,
163+
expectArgs: 'url exactly https://example.com/',
164+
validateArgs(args) {
165+
return String(args.url || '').trim() === 'https://example.com/';
166+
},
167+
},
143168
};
144169

145170
function expandScenarios(names) {

src/auth.js

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,17 @@ function isAccountBusyForProbe(account) {
5252
return accountInflight(account) > 0 || accountMaintenance(account) > 0 || accountLsMaintenance(account) > 0;
5353
}
5454

55+
function maintenanceBusyReason(account) {
56+
if (accountInflight(account) > 0) return 'account_inflight';
57+
if (accountMaintenance(account) > 0) return 'account_maintenance';
58+
if (accountLsMaintenance(account) > 0) return 'ls_maintenance';
59+
return '';
60+
}
61+
62+
function shouldSkipBusyBackgroundMaintenance() {
63+
return process.env.WINDSURFAPI_BACKGROUND_MAINTENANCE_SKIP_BUSY !== '0';
64+
}
65+
5566
function isAccountInMaintenance(account) {
5667
return accountMaintenance(account) > 0 || accountLsMaintenance(account) > 0;
5768
}
@@ -1400,10 +1411,17 @@ export async function refreshCredits(id) {
14001411
}
14011412
}
14021413

1403-
export async function refreshAllCredits() {
1414+
export async function refreshAllCredits({ skipBusy = false } = {}) {
14041415
const results = [];
14051416
for (const a of accounts) {
14061417
if (a.status !== 'active') continue;
1418+
if (skipBusy) {
1419+
const busyReason = maintenanceBusyReason(a);
1420+
if (busyReason) {
1421+
results.push({ id: a.id, email: a.email, ok: false, skipped: true, reason: busyReason });
1422+
continue;
1423+
}
1424+
}
14071425
const r = await refreshCredits(a.id);
14081426
results.push({ id: a.id, email: a.email, ok: r.ok, error: r.error });
14091427
}
@@ -1931,10 +1949,17 @@ export function emitNoAuthWarnings(bindHost = '0.0.0.0') {
19311949
* Refresh Firebase tokens for all accounts that have a stored refreshToken.
19321950
* Re-registers with Codeium to get a fresh API key and updates the account.
19331951
*/
1934-
async function refreshAllFirebaseTokens() {
1952+
async function refreshAllFirebaseTokens({ skipBusy = false } = {}) {
19351953
const { refreshFirebaseToken, reRegisterWithCodeium } = await import('./dashboard/windsurf-login.js');
19361954
for (const a of accounts) {
19371955
if (a.status !== 'active' || !a.refreshToken) continue;
1956+
if (skipBusy) {
1957+
const busyReason = maintenanceBusyReason(a);
1958+
if (busyReason) {
1959+
log.debug(`Firebase refresh ${a.id} skipped: ${busyReason}`);
1960+
continue;
1961+
}
1962+
}
19381963
try {
19391964
const proxy = getEffectiveProxy(a.id) || null;
19401965
const { idToken, refreshToken: newRefresh } = await refreshFirebaseToken(a.refreshToken, proxy);
@@ -2002,9 +2027,10 @@ export async function initAuth() {
20022027
// Periodic credit refresh (every 15 min). First run is fire-and-forget so
20032028
// startup isn't blocked by cloud round-trips.
20042029
const CREDIT_INTERVAL = 15 * 60 * 1000;
2005-
refreshAllCredits().catch(e => log.warn(`Initial credit refresh: ${e.message}`));
2030+
const skipBusyMaintenance = shouldSkipBusyBackgroundMaintenance();
2031+
refreshAllCredits({ skipBusy: skipBusyMaintenance }).catch(e => log.warn(`Initial credit refresh: ${e.message}`));
20062032
setInterval(() => {
2007-
refreshAllCredits().catch(e => log.warn(`Scheduled credit refresh: ${e.message}`));
2033+
refreshAllCredits({ skipBusy: skipBusyMaintenance }).catch(e => log.warn(`Scheduled credit refresh: ${e.message}`));
20082034
}, CREDIT_INTERVAL).unref?.();
20092035

20102036
// Fetch live model catalog from cloud and merge into hardcoded catalog.
@@ -2014,9 +2040,9 @@ export async function initAuth() {
20142040
// Periodic Firebase token refresh (every 50 min). Firebase ID tokens expire
20152041
// after 60 min; refreshing at 50 keeps a comfortable margin.
20162042
const TOKEN_REFRESH_INTERVAL = 50 * 60 * 1000;
2017-
refreshAllFirebaseTokens().catch(e => log.warn(`Initial token refresh: ${e.message}`));
2043+
refreshAllFirebaseTokens({ skipBusy: skipBusyMaintenance }).catch(e => log.warn(`Initial token refresh: ${e.message}`));
20182044
setInterval(() => {
2019-
refreshAllFirebaseTokens().catch(e => log.warn(`Scheduled token refresh: ${e.message}`));
2045+
refreshAllFirebaseTokens({ skipBusy: skipBusyMaintenance }).catch(e => log.warn(`Scheduled token refresh: ${e.message}`));
20202046
}, TOKEN_REFRESH_INTERVAL).unref?.();
20212047

20222048
// Warm up the default LS so first chat avoids spawn cost. Proxy-specific

src/handlers/chat.js

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -775,6 +775,7 @@ const MODEL_PROVIDERS = {
775775

776776
export function neutralizeCascadeIdentity(text, modelName) {
777777
if (!text || !modelName) return text;
778+
if (looksLikeJsonPayload(text)) return text;
778779
const provider = MODEL_PROVIDERS[Object.keys(MODEL_PROVIDERS).find(k => modelName.toLowerCase().startsWith(k)) || ''];
779780
if (!provider) return text;
780781
return text
@@ -801,6 +802,22 @@ export function neutralizeCascadeIdentity(text, modelName) {
801802
.replace(/\b(?:the )?Cascade(?:[']s)? workspace\b/gi, 'the workspace');
802803
}
803804

805+
function looksLikeJsonPayload(text) {
806+
if (typeof text !== 'string') return false;
807+
const s = text.trim();
808+
if (!s) return false;
809+
if ((s.startsWith('{') && s.endsWith('}')) || (s.startsWith('[') && s.endsWith(']'))) {
810+
return safeJsonParse(s) !== undefined;
811+
}
812+
const fenced = s.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i);
813+
if (!fenced) return false;
814+
const inner = fenced[1].trim();
815+
if (!((inner.startsWith('{') && inner.endsWith('}')) || (inner.startsWith('[') && inner.endsWith(']')))) {
816+
return false;
817+
}
818+
return safeJsonParse(inner) !== undefined;
819+
}
820+
804821
/**
805822
* Lift authoritative environment facts from the caller's request so they
806823
* can be re-emitted into the proto-level tool_calling_section override.

src/windsurf.js

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1002,6 +1002,8 @@ export function parseTrajectorySteps(buf) {
10021002
// 23 CortexStepWriteToFile {target_file_uri=1, code_content=2*}
10031003
// 28 CortexStepRunCommand {command_line=23, combined_output=21, ...}
10041004
// 34 CortexStepFind {pattern=1, search_directory=10, ...}
1005+
// 40 CortexStepReadUrlContent {url=1, summary=5}
1006+
// 42 CortexStepSearchWeb {query=1, domain=3, summary=5}
10051007
// 105 CortexStepGrepSearchV2 {pattern=2, path=3, raw_output=15, ...}
10061008
//
10071009
// We surface these as toolCalls entries shaped like the wrapped
@@ -1018,6 +1020,8 @@ export function parseTrajectorySteps(buf) {
10181020
[28, 'run_command'],
10191021
[13, 'grep_search'],
10201022
[34, 'find'],
1023+
[40, 'read_url_content'],
1024+
[42, 'search_web'],
10211025
[105, 'grep_search_v2'],
10221026
];
10231027
for (const [fieldNum, kind] of NATIVE_STEP_FIELDS) {
@@ -1098,6 +1102,19 @@ export function parseTrajectorySteps(buf) {
10981102
code_content: lines,
10991103
};
11001104
argumentsJson = JSON.stringify(args);
1105+
} else if (kind === 'read_url_content') {
1106+
const args = {
1107+
url: getField(body, 1, 2)?.value?.toString('utf8') || '',
1108+
};
1109+
argumentsJson = JSON.stringify(args);
1110+
result = getField(body, 5, 2)?.value?.toString('utf8') || '';
1111+
} else if (kind === 'search_web') {
1112+
const args = {
1113+
query: getField(body, 1, 2)?.value?.toString('utf8') || '',
1114+
domain: getField(body, 3, 2)?.value?.toString('utf8') || '',
1115+
};
1116+
argumentsJson = JSON.stringify(args);
1117+
result = getField(body, 5, 2)?.value?.toString('utf8') || '';
11011118
}
11021119
} catch {
11031120
argumentsJson = argumentsJson || '{}';

test/identity-neutralization.test.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,21 @@ describe('neutralizeCascadeIdentity', () => {
8484
assert.equal(neutralizeCascadeIdentity(text, model), text);
8585
});
8686

87+
it('does not rewrite parseable JSON payloads', () => {
88+
const json = '{"model":"Cascade","message":"I am Cascade","provider":"Codeium"}';
89+
assert.equal(neutralizeCascadeIdentity(json, model), json);
90+
});
91+
92+
it('does not rewrite fenced JSON payloads', () => {
93+
const json = '```json\n{"message":"Cascade is an AI coding assistant built by Windsurf."}\n```';
94+
assert.equal(neutralizeCascadeIdentity(json, model), json);
95+
});
96+
97+
it('does not rewrite parseable JSON arrays', () => {
98+
const json = '[{"message":"I was created by Windsurf."}]';
99+
assert.equal(neutralizeCascadeIdentity(json, model), json);
100+
});
101+
87102
it('returns text unchanged when modelName has no known provider mapping', () => {
88103
const text = 'I am Cascade.';
89104
assert.equal(neutralizeCascadeIdentity(text, 'mystery-model'), text);

test/langserver-resource.test.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,18 @@ describe('language server resource policy', () => {
156156
assert.match(AUTH_JS, /probeAccount\(a\.id, \{ allowLsStart: false \}\)/);
157157
});
158158

159+
test('background account maintenance skips busy production accounts by default', () => {
160+
assert.match(AUTH_JS, /function maintenanceBusyReason\(account\)/);
161+
assert.match(AUTH_JS, /function shouldSkipBusyBackgroundMaintenance\(\)/);
162+
assert.match(AUTH_JS, /WINDSURFAPI_BACKGROUND_MAINTENANCE_SKIP_BUSY !== '0'/);
163+
assert.match(AUTH_JS, /export async function refreshAllCredits\(\{ skipBusy = false \} = \{\}\)/);
164+
assert.match(AUTH_JS, /async function refreshAllFirebaseTokens\(\{ skipBusy = false \} = \{\}\)/);
165+
assert.match(AUTH_JS, /const skipBusyMaintenance = shouldSkipBusyBackgroundMaintenance\(\)/);
166+
assert.match(AUTH_JS, /refreshAllCredits\(\{ skipBusy: skipBusyMaintenance \}\)/);
167+
assert.match(AUTH_JS, /refreshAllFirebaseTokens\(\{ skipBusy: skipBusyMaintenance \}\)/);
168+
assert.match(AUTH_JS, /skipped: true, reason: busyReason/);
169+
});
170+
159171
test('predictive prewarm is admission-gated and reports structured failures', () => {
160172
assert.match(AUTH_JS, /const admission = getLsAdmissionForAccount\(nextAccount\.id\)/);
161173
assert.match(AUTH_JS, /admission\.reason !== 'already_running'/);

0 commit comments

Comments
 (0)