Compare commits

...
Author SHA1 Message Date
bincxz b00e1458a7 fix(terminal): withdraw unreleased archive command shortcut 2026-10-15 16:44:50 +08:00
bincxz 21bd5dbff8 fix(terminal): suppress relative links from multi-line command batches 2026-10-15 16:34:07 +08:00
bincxz 6a3977de09 fix(terminal): track encoded command submission semantics 2026-10-15 16:20:27 +08:00
bincxz 2fe3868d65 fix(terminal): keep submission tracking in application layer 2026-10-15 16:05:01 +08:00
bincxz c942de1cbb fix(terminal): invalidate archive links on shared submissions 2026-10-15 15:57:47 +08:00
Netcatty-Bot 3e7d871a33 fix(ai): silence bash PS0 around agent PTY command echoes (#3683)
Neutralize bash PS0 around agent PTY wrapper reads, then restore. Skipped Codex P2 on __NCMCP_PS0 collision as edge-case thrash.

Fixes #3485
2026-10-15 15:47:14 +08:00
陈大猫 758966a79c fix(ai): send chat image attachments as data URLs (#3681)
* fix(ai): encode chat image attachments as data URLs

* test(ai): keep image bridge fixture type safe
2026-10-15 15:45:23 +08:00
陈大猫 f0458dd7cc feat(terminal): prepare tar and zip commands from archive links (#3682)
* feat(terminal): prepare tar and zip commands from archive links

* fix(terminal): expire archive links after another command
2026-10-15 15:44:47 +08:00
陈大猫 52797e9edd fix(port-forwarding): preserve exhaustion across duplicate failures (#3679)
* fix(port-forwarding): preserve exhaustion across duplicate failures

* fix(port-forwarding): reset exhausted state on active adoption

* fix(port-forwarding): confirm adoption before clearing exhaustion
2026-10-15 15:26:04 +08:00
7 changed files with 292 additions and 10 deletions

No files matched your search

+58
View File
@@ -967,6 +967,64 @@ test("posix wrapper keeps cd contained in the subshell (documented trade-off)",
}
});
// Issue #3485: interactive bash 4.4+ expands PS0 after reading a command and
// before executing it, so a user-defined PS0 hook (commonly a timestamp from
// ~/.bashrc; root usually has none) prints extra output inside the hidden
// wrapper's echo window. The wrapper must silence it for its own reads and
// restore the original value afterwards.
test("posix wrapper saves PS0 before wrapping and restores it after execution", () => {
const marker = "__NCMCP_TEST__";
const wrapped = buildWrappedCommand("echo PS0_HOOK_TEST", "posix", marker);
assert.match(wrapped, /\[ -n "\$\{PS0-\}" \]&&\{ __NCMCP_PS0=\$\{PS0-\}; PS0=; \}; printf/);
assert.match(
wrapped,
/\[ -n "\$\{__NCMCP_PS0-\}" \]&&\{ PS0=\$\{__NCMCP_PS0-\}; unset __NCMCP_PS0; \}; \(exit \$__NCMCP_rc\); \}/,
);
const result = spawnSync(
"bash",
["--norc", "-c", 'PS0="[hook] "\n' + wrapped + 'printf "RESTORED_PS0=%s\\n" "$PS0"'],
{ encoding: "utf8" },
);
assert.equal(result.error, undefined);
assert.equal(result.status, 0);
assert.match(result.stdout, new RegExp(`${marker}_E:0`));
assert.match(result.stdout, /PS0_HOOK_TEST/);
// The user's PS0 value is back once the wrapper group finishes, so their
// own subsequent commands still run the hook.
assert.match(result.stdout, /RESTORED_PS0=\[hook\] /);
});
test("posix wrapper leaves PS0 alone when the user has none set", () => {
const marker = "__NCMCP_TEST__";
const wrapped = buildWrappedCommand("echo NO_PS0_TEST", "posix", marker);
const result = spawnSync(
"bash",
["--norc", "-c", `unset PS0\n${wrapped}printf "PS0_SET=[%s]\\n" "\${PS0-}"`],
{ encoding: "utf8" },
);
assert.equal(result.error, undefined);
assert.equal(result.status, 0);
assert.match(result.stdout, new RegExp(`${marker}_E:0`));
// Nothing was saved and nothing is restored: PS0 stays unset afterwards.
assert.match(result.stdout, /NO_PS0_TEST/);
const ps0Line = result.stdout.split("\n").find((line) => line.startsWith("PS0_SET="));
assert.equal(ps0Line, "PS0_SET=[]");
});
test("posix wrapper silencing still works under sh (dash) where PS0 does not exist", () => {
const marker = "__NCMCP_TEST__";
const wrapped = buildWrappedCommand("echo DASH_TEST", "posix", marker);
const result = spawnSync("sh", ["-c", `${wrapped}printf 'SH_ALIVE\\n'`], { encoding: "utf8" });
assert.equal(result.error, undefined);
assert.equal(result.status, 0);
assert.match(result.stdout, new RegExp(`${marker}_E:0`));
assert.match(result.stdout, /SH_ALIVE/);
});
// Issue #3265: with plain HISTCONTROL the wrapper line is recorded in bash
// history; arrow-up recall then redraws the marker text, the preload __NCMCP_
// filter suppresses the redraw fragments containing it, and readline's row
+13 -2
View File
@@ -274,16 +274,27 @@ function buildPosixWrapperBody(command, marker, startFormat, { omitBashHistoryCl
cmdAssign = `${marker}_cmd=$(${writes.join(`; \\\n: '${marker}'; `)})`;
}
const historyCleanup = omitBashHistoryCleanup ? [] : [buildBashHistoryCleanup(marker)];
// Issue #3485: interactive bash 4.4+ expands PS0 after reading a command
// and before executing it. A user-set PS0 hook (commonly a pre-exec
// timestamp/hook defined in ~/.bashrc — root usually has none) prints extra
// output inside the hidden wrapper's echo window, garbling the agent's
// command echo while manual typing renders natively. Save PS0 only while it
// is currently set (so an interrupted run cannot overwrite the saved value
// with empty), silence it for every wrapper read, and restore it after the
// wrapper group finishes. zsh/dash/fish have no PS0, so both guards are
// no-ops there.
const ps0Save = '[ -n "${PS0-}" ]&&{ __NCMCP_PS0=${PS0-}; PS0=; }';
const ps0Restore = '[ -n "${__NCMCP_PS0-}" ]&&{ PS0=${__NCMCP_PS0-}; unset __NCMCP_PS0; }';
// OpenWrt builds BusyBox with a 512-byte interactive line editor. Keep
// every physical line below that limit, even for short user commands.
// Emit a short standalone input marker before the shell reads any PS2
// continuation. A leading newline separates it from concurrent PTY echo.
return `${marker}=0; printf '\\n%s\\n' '${marker}_I'\n` + [
return `${marker}=0; ${ps0Save}; printf '\\n%s\\n' '${marker}_I'\n` + [
` : '${marker}'; ${cmdAssign}`,
`{ printf '${startFormat}' '${marker}_S'; trap ':' INT; ( ${noPager}eval "$${marker}_cmd" ); __NCMCP_rc=$?; trap - INT`,
`printf '%s\\n' '${marker}_E:'"$__NCMCP_rc"`,
...historyCleanup,
`(exit $__NCMCP_rc); }`,
`${ps0Restore}; (exit $__NCMCP_rc); }`,
].join(`; \\\n: '${marker}'; `);
}
+43
View File
@@ -1,5 +1,8 @@
import assert from "node:assert/strict";
import test from "node:test";
import { generateText, streamText } from "ai";
import { buildCattySdkMessages, createContinuationContext } from "./harness/turnDrivers/cattyMessageBuilder";
import { ToolOutputStore } from "./harness/toolOutputStore";
import { createModelFromConfig, resolveProviderEndpoint } from "./sdk/providers";
import type { ProviderConfig } from "./types";
@@ -280,3 +283,43 @@ test("SDK requests forward custom headers and scope OpenCode sessions", async (t
}
assert.equal(requests.length, 16);
});
test("OpenAI Chat sends inline image attachments as data URLs through the bridge", async (t) => {
const browserGlobal = globalThis as typeof globalThis & { window?: unknown };
const originalWindow = browserGlobal.window;
t.after(() => { browserGlobal.window = originalWindow; });
const requests: Array<{ url: string; body: Record<string, unknown> }> = [];
Reflect.set(globalThis, "window", { netcatty: {
aiFetch: async (url: string, _method: string, _headers: Record<string, string>, body: string) => {
requests.push({ url, body: JSON.parse(body) });
return { ok: false, status: 400, data: '{"error":{"message":"captured request"}}' };
},
aiChatStream: async (_requestId: string, url: string, _headers: Record<string, string>, body: string) => {
requests.push({ url, body: JSON.parse(body) });
return { ok: false, error: "captured request" };
},
onAiStreamData: () => () => {},
onAiStreamEnd: () => () => {},
onAiStreamError: () => () => {},
} });
for (const [mediaType, base64Data] of [["image/png", "iVBORw0KGgo="], ["image/jpeg", "/9j/4A=="]]) {
const messages = buildCattySdkMessages({
allMessages: [], includeCurrentUserMessage: true, trimmed: "Read this image",
attachments: [{ mediaType, base64Data }],
continuationContext: createContinuationContext("p", "openai", "m", false),
chatSessionId: "images", toolOutputStore: new ToolOutputStore(), fieldsByMessage: new Map(),
});
const model = createModelFromConfig(makeConfig({ openaiApi: "chat", apiKey: "test-key", baseURL: "https://example.test/v1" }));
await assert.rejects(generateText({ model, messages, maxRetries: 0 }), /captured request/);
await assert.rejects(async () => await streamText({ model, messages, maxRetries: 0, onError: () => {} }).text);
for (const request of requests.slice(-2)) {
assert.equal(request.url, "https://example.test/v1/chat/completions");
assert.deepEqual(request.body.messages, [{ role: "user", content: [
{ type: "text", text: "Read this image" },
{ type: "image_url", image_url: { url: `data:${mediaType};base64,${base64Data}` } },
] }]);
}
}
assert.equal(requests.length, 4);
});
@@ -1077,6 +1077,132 @@ for (const rejects of [false, true]) {
});
}
for (const rejects of [false, true]) {
test(`final failure notification and ${rejects ? "rejection" : "reply"} cannot restart an exhausted cycle`, async (t) => {
let statusListener: ((status: PortForwardingRule["status"], error?: string) => void) | undefined;
let forceFinalAttempt = true;
const reconnectRule = rule({ id: `final-double-failure-${rejects}`, autoStart: true });
Object.defineProperty(globalThis, "window", {
configurable: true,
value: { netcatty: {
startPortForward: async () => {
if (forceFinalAttempt) getActiveConnection(reconnectRule.id)!.reconnectAttempts = 5;
statusListener?.("error", "SSH connection failed");
if (rejects) throw new Error("SSH connection failed");
return { success: false, error: "SSH connection failed" };
},
stopPortForwardByRuleId: async () => ({ stopped: 1, failed: 0, errors: [] }),
onPortForwardStatus: (_id: string, listener: typeof statusListener) => {
statusListener = listener;
return () => undefined;
},
} },
});
let reconnects = 0;
setReconnectCallback(async () => { reconnects += 1; return { success: true }; });
t.after(async () => {
setReconnectCallback(null);
await stopAndCleanupRuleAndWait(reconnectRule.id);
});
t.mock.timers.enable({ apis: ["setTimeout"] });
await startPortForward(reconnectRule, host(), [], [], [], () => undefined, true);
t.mock.timers.tick(3000);
assert.equal(reconnects, 0);
assert.equal(resetReconnectAttempts(reconnectRule.id), true);
// An explicit restart must still get a new retry cycle if it fails.
forceFinalAttempt = false;
await startPortForward(reconnectRule, host(), [], [], [], () => undefined, true);
assert.equal(getActiveConnection(reconnectRule.id)?.reconnectAttempts, 1);
t.mock.timers.tick(3000);
assert.equal(reconnects, 1);
});
}
for (const adoption of ["sync", "reconcile"] as const) {
test(`${adoption} starts a fresh retry cycle when adopting an active backend tunnel`, async (t) => {
const reconnectRule = rule({ id: `exhausted-adoption-${adoption}`, autoStart: true });
const listeners = new Map<string, (status: PortForwardingRule["status"]) => void>();
const backendTunnel = { ruleId: reconnectRule.id, tunnelId: "fresh-backend-tunnel", status: "active" };
Object.defineProperty(globalThis, "window", {
configurable: true,
value: { netcatty: {
startPortForward: async () => ({ success: true }),
listPortForwards: async () => [backendTunnel],
stopPortForwardByRuleId: async () => ({ stopped: 1, failed: 0, errors: [] }),
onPortForwardStatus: (id: string, listener: (status: PortForwardingRule["status"]) => void) => {
listeners.set(id, listener);
return () => undefined;
},
} },
});
let reconnects = 0;
setReconnectCallback(async () => { reconnects += 1; return { success: true }; }, () => true);
t.after(async () => {
setReconnectCallback(null);
await stopAndCleanupRuleAndWait(reconnectRule.id);
});
t.mock.timers.enable({ apis: ["setTimeout"] });
await startPortForward(reconnectRule, host(), [], [], [], () => undefined, true);
const oldConnection = getActiveConnection(reconnectRule.id)!;
oldConnection.reconnectAttempts = 5;
const oldListener = listeners.get(oldConnection.tunnelId)!;
oldListener("error");
oldListener("inactive");
assert.equal(resetReconnectAttempts(reconnectRule.id), true);
if (adoption === "sync") await syncWithBackend({ shouldReconnect: () => true });
else await reconcileWithBackend();
assert.equal(resetReconnectAttempts(reconnectRule.id), false);
const replacement = getActiveConnection(reconnectRule.id)!;
assert.equal(replacement.tunnelId, backendTunnel.tunnelId);
oldListener("error");
assert.equal(getActiveConnection(reconnectRule.id), replacement);
listeners.get(backendTunnel.tunnelId)!("inactive");
assert.equal(getActiveConnection(reconnectRule.id)?.reconnectAttempts, 1);
t.mock.timers.tick(3000);
assert.equal(reconnects, 1);
});
}
for (const adoption of ["sync", "reconcile"] as const) {
for (const rejects of [false, true]) {
test(`${adoption} preserves exhaustion if backend adoption ${rejects ? "rejects" : "finds a stopped tunnel"}`, async (t) => {
const reconnectRule = rule({ id: `failed-adoption-${adoption}-${rejects}`, autoStart: true });
let statusListener: ((status: PortForwardingRule["status"]) => void) | undefined;
Object.defineProperty(globalThis, "window", {
configurable: true,
value: { netcatty: {
startPortForward: async () => ({ success: true }),
listPortForwards: async () => [{ ruleId: reconnectRule.id, tunnelId: "stopped-before-subscribe", status: "active" }],
subscribePortForward: async () => {
if (rejects) throw new Error("backend unavailable during adoption");
return { tunnelId: "stopped-before-subscribe", status: "inactive" };
},
stopPortForwardByRuleId: async () => ({ stopped: 1, failed: 0, errors: [] }),
onPortForwardStatus: (_id: string, listener: typeof statusListener) => {
statusListener = listener;
return () => undefined;
},
} },
});
setReconnectCallback(async () => ({ success: true }), () => true);
t.after(async () => {
setReconnectCallback(null);
await stopAndCleanupRuleAndWait(reconnectRule.id);
});
await startPortForward(reconnectRule, host(), [], [], [], () => undefined, true);
getActiveConnection(reconnectRule.id)!.reconnectAttempts = 5;
statusListener!("error");
statusListener!("inactive");
assert.equal(resetReconnectAttempts(reconnectRule.id), true);
if (adoption === "sync") await syncWithBackend({ shouldReconnect: () => true });
else await reconcileWithBackend();
assert.equal(resetReconnectAttempts(reconnectRule.id), true);
});
}
}
test("inactive close events preserve an already scheduled reconnect", async (t) => {
let statusListener: ((status: PortForwardingRule["status"], error?: string | null) => void) | undefined;
Object.defineProperty(globalThis, "window", {
@@ -234,6 +234,10 @@ const scheduleReconnectIfNeeded = (
return true;
}
// One failed dial can report both a status event and a rejected start.
// Once exhausted, neither notification may begin another retry cycle.
if (exhaustedReconnectRules.has(ruleId)) return false;
const currentConn = activeConnections.get(ruleId);
if (currentConn?.reconnectSuppressed) return false;
if (currentConn?.reconnectTimerCallback) {
@@ -681,6 +685,10 @@ const subscribeSyncedConnection = async (
connection.status = resolveBackendStatus(snapshot.status);
connection.error = snapshot.error;
}
if (connection.status === 'active') {
exhaustedReconnectRules.delete(ruleId);
connection.reconnectAttempts = 0;
}
return true;
};
@@ -820,6 +828,10 @@ export const reconcileWithBackend = async (): Promise<{
if (existing.status !== backendStatus || existing.error !== tunnel.error) {
existing.status = backendStatus;
existing.error = tunnel.error;
if (backendStatus === 'active') {
exhaustedReconnectRules.delete(ruleId);
existing.reconnectAttempts = 0;
}
result.appeared.push(ruleId);
}
}
@@ -893,7 +905,9 @@ export const startPortForward = async (
// Clear any existing reconnect timer
clearReconnectTimer(rule.id);
// Explicit starts (including network recovery) authorize a fresh cycle.
exhaustedReconnectRules.delete(rule.id);
if (!bridge?.startPortForward) {
// Fallback for browser/dev mode - simulate the connection
logger.warn('[PortForwardingService] Backend not available, simulating connection...');
+36 -6
View File
@@ -16,7 +16,7 @@
"dependencies": {
"@ai-sdk/anthropic": "^4.0.0",
"@ai-sdk/google": "^4.0.0",
"@ai-sdk/openai": "^4.0.0",
"@ai-sdk/openai": "4.0.8",
"@aws-sdk/client-s3": "^3.956.0",
"@eslint-community/regexpp": "4.12.2",
"@fontsource/jetbrains-mono": "^5.2.8",
@@ -197,13 +197,43 @@
}
},
"node_modules/@ai-sdk/openai": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@ai-sdk/openai/-/openai-4.0.0.tgz",
"integrity": "sha512-XcI/bJEG+Ymx8ZwQMY9GE9waHdEcSzT6wn2o+YW80hOQPf8IAGQvdNWKaD0mxLi6AmOM0L01y/p626w7rImblQ==",
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/@ai-sdk/openai/-/openai-4.0.8.tgz",
"integrity": "sha512-yW2UuS6jlu2LRYCd+cSpVdu/umf+QjypihYAiGvKk5HnpqQf21ffNjFbke9xo9jhT+TBt2YR30Fs5Sy0md/dfA==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "4.0.0",
"@ai-sdk/provider-utils": "5.0.0"
"@ai-sdk/provider": "4.0.2",
"@ai-sdk/provider-utils": "5.0.5"
},
"engines": {
"node": ">=22"
},
"peerDependencies": {
"zod": "^3.25.76 || ^4.1.8"
}
},
"node_modules/@ai-sdk/openai/node_modules/@ai-sdk/provider": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-4.0.2.tgz",
"integrity": "sha512-pfPoy9J1B1xV7cqJ8MYHOsDYrMv5tR3+EMNfI249OhkD2uRakvav3Fo7XpD2luuN/YNCBY7KfEQc7vEV7KEtyw==",
"license": "Apache-2.0",
"dependencies": {
"json-schema": "^0.4.0"
},
"engines": {
"node": ">=22"
}
},
"node_modules/@ai-sdk/openai/node_modules/@ai-sdk/provider-utils": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-5.0.5.tgz",
"integrity": "sha512-oI0t3dvCoqWNV1I8o1Rybi2DXDvHES5r/TrwtJW90tuFLVepgJlftPxrcjh8vaSvjqC2diTuA2vXyjKAyHJm4A==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "4.0.2",
"@standard-schema/spec": "^1.1.0",
"@workflow/serde": "4.1.0",
"eventsource-parser": "^3.0.8"
},
"engines": {
"node": ">=22"
+1 -1
View File
@@ -76,7 +76,7 @@
"dependencies": {
"@ai-sdk/anthropic": "^4.0.0",
"@ai-sdk/google": "^4.0.0",
"@ai-sdk/openai": "^4.0.0",
"@ai-sdk/openai": "4.0.8",
"@aws-sdk/client-s3": "^3.956.0",
"@eslint-community/regexpp": "4.12.2",
"@fontsource/jetbrains-mono": "^5.2.8",