mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-30 02:21:47 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b00e1458a7 | ||
|
|
21bd5dbff8 | ||
|
|
6a3977de09 | ||
|
|
2fe3868d65 | ||
|
|
c942de1cbb | ||
|
|
3e7d871a33 | ||
|
|
758966a79c | ||
|
|
f0458dd7cc | ||
|
|
52797e9edd | ||
|
|
cc001c9a4b |
No files matched your search
@@ -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
|
||||
|
||||
@@ -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}'; `);
|
||||
}
|
||||
|
||||
|
||||
@@ -560,6 +560,7 @@ async function runOpenCodeTurn({
|
||||
let opencode = null;
|
||||
let sessionId = resumeSessionId || null;
|
||||
let hasContent = false;
|
||||
let backgroundWarningCount = 0;
|
||||
let failed = false;
|
||||
let abortSent = false;
|
||||
let removeAbortListener = null;
|
||||
@@ -619,6 +620,13 @@ async function runOpenCodeTurn({
|
||||
if (abortController?.signal?.aborted) break;
|
||||
const eventSessionId = getOpenCodeSessionIdFromEvent(event);
|
||||
if (eventSessionId && eventSessionId !== sessionId) continue;
|
||||
if (!eventSessionId && getOpenCodeEventPayload(event)?.type === "session.error") {
|
||||
const message = extractOpenCodeErrorMessage(getOpenCodeEventPayload(event).properties?.error)
|
||||
|| "OpenCode reported a background error";
|
||||
if (emitter.warning) emitter.warning(`opencode-background-error:${++backgroundWarningCount}`, message);
|
||||
else emitter.status(message);
|
||||
continue;
|
||||
}
|
||||
const result = translateOpenCodeEvent(event, emitter, state);
|
||||
if (result.content) hasContent = true;
|
||||
if (result.error) {
|
||||
|
||||
@@ -500,6 +500,52 @@ test("runOpenCodeTurn continues after a tool error part and reaches session.idle
|
||||
assert.equal(events.some((event) => event.k === "error"), false);
|
||||
});
|
||||
|
||||
for (const scoped of [false, true]) {
|
||||
test(`runOpenCodeTurn ${scoped ? "fails on the current session error" : "warns about plugin errors without stopping the session"} (#3173)`, async () => {
|
||||
const { events, emitter } = collector();
|
||||
emitter.warning = (id, message) => events.push({ k: "warning", id, message });
|
||||
const message = scoped ? "bad key" : "Failed to install plugin: invalid package name";
|
||||
const stream = {
|
||||
async *[Symbol.asyncIterator]() {
|
||||
yield {
|
||||
type: "session.error",
|
||||
properties: { ...(scoped ? { sessionID: "sess-1" } : {}), error: { data: { message } } },
|
||||
};
|
||||
if (!scoped) {
|
||||
yield {
|
||||
type: "session.error",
|
||||
properties: { error: { data: { message: "Failed to install another plugin" } } },
|
||||
};
|
||||
}
|
||||
yield {
|
||||
type: "message.part.updated",
|
||||
properties: { part: { type: "text", sessionID: "sess-1", id: "p1", text: "reply" }, delta: "reply" },
|
||||
};
|
||||
yield { type: "session.idle", properties: { sessionID: "sess-1" } };
|
||||
},
|
||||
};
|
||||
await runOpenCodeTurn({
|
||||
prompt: "continue",
|
||||
resumeSessionId: "sess-1",
|
||||
emitter,
|
||||
openCodeFactory: async () => ({
|
||||
client: {
|
||||
global: { event: async () => ({ stream }) },
|
||||
session: { promptAsync: async () => ({ data: true }) },
|
||||
},
|
||||
server: { close() {} },
|
||||
}),
|
||||
});
|
||||
assert.equal(events.some((event) => event.k === "error" && event.m === message), scoped);
|
||||
assert.equal(events.some((event) => event.k === "warning" && event.message === message), !scoped);
|
||||
assert.equal(events.some((event) => event.k === "text" && event.t === "reply"), !scoped);
|
||||
assert.equal(events.some((event) => event.k === "done"), !scoped);
|
||||
const warnings = events.filter((event) => event.k === "warning");
|
||||
assert.equal(warnings.length, scoped ? 0 : 2);
|
||||
assert.equal(new Set(warnings.map((event) => event.id)).size, warnings.length);
|
||||
});
|
||||
}
|
||||
|
||||
test("mapOpenCodeModels flattens providers", () => {
|
||||
assert.deepEqual(mapOpenCodeModels({
|
||||
providers: [
|
||||
|
||||
@@ -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...');
|
||||
|
||||
Reference in new issue
Block a user