868 lines
23 KiB
JavaScript
868 lines
23 KiB
JavaScript
|
|
const test = require("node:test");
|
|||
|
|
const assert = require("node:assert/strict");
|
|||
|
|
|
|||
|
|
const {
|
|||
|
|
armTerminalInterruptOutputGate,
|
|||
|
|
filterTerminalInterruptOutput,
|
|||
|
|
stashPendingInterruptOutputMeta,
|
|||
|
|
takePendingInterruptOutputMeta,
|
|||
|
|
} = require("./terminalInterruptOutputGate.cjs");
|
|||
|
|
|
|||
|
|
test("pending interrupt metadata clears stale alternate-screen action on later unknown risk", () => {
|
|||
|
|
const session = {};
|
|||
|
|
|
|||
|
|
stashPendingInterruptOutputMeta(session, {
|
|||
|
|
droppedOutputMayAffectTerminalState: true,
|
|||
|
|
droppedOutputAlternateScreenAction: "leave",
|
|||
|
|
});
|
|||
|
|
stashPendingInterruptOutputMeta(session, {
|
|||
|
|
droppedOutputMayAffectTerminalState: true,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
assert.deepEqual(takePendingInterruptOutputMeta(session), {
|
|||
|
|
droppedOutputMayAffectTerminalState: true,
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("drops flood output after Ctrl+C and resumes from the interrupt echo", () => {
|
|||
|
|
const session = {};
|
|||
|
|
|
|||
|
|
armTerminalInterruptOutputGate(session, {
|
|||
|
|
now: 1000,
|
|||
|
|
quietMs: 80,
|
|||
|
|
maxDrainMs: 1000,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
assert.deepEqual(
|
|||
|
|
filterTerminalInterruptOutput(session, "old output\n", { now: 1001 }),
|
|||
|
|
{ accepted: false, data: "", droppedBytes: 11, reason: "draining" },
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
assert.deepEqual(
|
|||
|
|
filterTerminalInterruptOutput(session, "more old output^C\r\n$ ", { now: 1002 }),
|
|||
|
|
{ accepted: true, data: "^C\r\n$ ", droppedBytes: 15, reason: "interrupt-echo" },
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
assert.deepEqual(
|
|||
|
|
filterTerminalInterruptOutput(session, "next output", { now: 1003 }),
|
|||
|
|
{ accepted: true, data: "next output", droppedBytes: 0, reason: "inactive" },
|
|||
|
|
);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("resumes output after a quiet gap when no interrupt echo is visible", () => {
|
|||
|
|
const session = {};
|
|||
|
|
|
|||
|
|
armTerminalInterruptOutputGate(session, {
|
|||
|
|
now: 2000,
|
|||
|
|
quietMs: 80,
|
|||
|
|
maxDrainMs: 1000,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
assert.equal(filterTerminalInterruptOutput(session, "old output", { now: 2001 }).accepted, false);
|
|||
|
|
assert.deepEqual(
|
|||
|
|
filterTerminalInterruptOutput(session, "$ ", { now: 2100 }),
|
|||
|
|
{ accepted: true, data: "$ ", droppedBytes: 0, reason: "prompt-gap" },
|
|||
|
|
);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("accepts an immediate prompt when the remote does not echo Ctrl+C", () => {
|
|||
|
|
const session = {};
|
|||
|
|
|
|||
|
|
armTerminalInterruptOutputGate(session, {
|
|||
|
|
now: 2500,
|
|||
|
|
quietMs: 80,
|
|||
|
|
maxDrainMs: 1000,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
assert.deepEqual(
|
|||
|
|
filterTerminalInterruptOutput(session, "$ ", { now: 2501 }),
|
|||
|
|
{ accepted: true, data: "$ ", droppedBytes: 0, reason: "prompt-candidate" },
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
assert.deepEqual(
|
|||
|
|
filterTerminalInterruptOutput(session, "next output", { now: 2502 }),
|
|||
|
|
{ accepted: true, data: "next output", droppedBytes: 0, reason: "inactive" },
|
|||
|
|
);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("resumes output when interrupt echo is split across chunks", () => {
|
|||
|
|
const session = {};
|
|||
|
|
|
|||
|
|
armTerminalInterruptOutputGate(session, {
|
|||
|
|
now: 3500,
|
|||
|
|
quietMs: 80,
|
|||
|
|
maxDrainMs: 1000,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
assert.deepEqual(
|
|||
|
|
filterTerminalInterruptOutput(session, "old output", { now: 3501 }),
|
|||
|
|
{ accepted: false, data: "", droppedBytes: 10, reason: "draining" },
|
|||
|
|
);
|
|||
|
|
assert.deepEqual(
|
|||
|
|
filterTerminalInterruptOutput(session, "^", { now: 3502 }),
|
|||
|
|
{ accepted: false, data: "", droppedBytes: 1, reason: "draining" },
|
|||
|
|
);
|
|||
|
|
assert.deepEqual(
|
|||
|
|
filterTerminalInterruptOutput(session, "C\r\n$ ", { now: 3503 }),
|
|||
|
|
{ accepted: true, data: "^C\r\n$ ", droppedBytes: 0, reason: "interrupt-echo" },
|
|||
|
|
);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("prompt gap keeps only the prompt suffix and drops stale prefix", () => {
|
|||
|
|
const session = {};
|
|||
|
|
|
|||
|
|
armTerminalInterruptOutputGate(session, {
|
|||
|
|
now: 3600,
|
|||
|
|
quietMs: 500,
|
|||
|
|
promptQuietMs: 80,
|
|||
|
|
maxDrainMs: 1000,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
assert.equal(filterTerminalInterruptOutput(session, "old output", { now: 3601 }).accepted, false);
|
|||
|
|
assert.deepEqual(
|
|||
|
|
filterTerminalInterruptOutput(session, "stale flood\r\n$ ", { now: 3700 }),
|
|||
|
|
{ accepted: true, data: "$ ", droppedBytes: 13, reason: "prompt-gap" },
|
|||
|
|
);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("preserves alternate-screen exit controls while draining stale output", () => {
|
|||
|
|
const session = {};
|
|||
|
|
|
|||
|
|
armTerminalInterruptOutputGate(session, {
|
|||
|
|
now: 3800,
|
|||
|
|
quietMs: 500,
|
|||
|
|
promptQuietMs: 80,
|
|||
|
|
maxDrainMs: 1000,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
assert.deepEqual(
|
|||
|
|
filterTerminalInterruptOutput(session, "stale frame\x1b[?1049l", { now: 3801 }),
|
|||
|
|
{
|
|||
|
|
accepted: true,
|
|||
|
|
data: "\x1b[?1049l",
|
|||
|
|
droppedBytes: "stale frame".length,
|
|||
|
|
reason: "draining",
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
assert.deepEqual(
|
|||
|
|
filterTerminalInterruptOutput(session, "more stale\x1b[?25h\r\n$ ", { now: 3900 }),
|
|||
|
|
{
|
|||
|
|
accepted: true,
|
|||
|
|
data: "\x1b[?25h$ ",
|
|||
|
|
droppedBytes: "more stale\r\n".length,
|
|||
|
|
reason: "prompt-gap",
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("excludes preserved restore controls from held password prefixes", () => {
|
|||
|
|
const session = {};
|
|||
|
|
|
|||
|
|
armTerminalInterruptOutputGate(session, {
|
|||
|
|
now: 3850,
|
|||
|
|
quietMs: 500,
|
|||
|
|
promptQuietMs: 80,
|
|||
|
|
maxDrainMs: 2500,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Restore sequence is preserved once; password prefix hold must not re-count
|
|||
|
|
// it, or stale bytes report as 0 dropped and the restore is re-emitted.
|
|||
|
|
assert.deepEqual(
|
|||
|
|
filterTerminalInterruptOutput(session, "stale\n\x1b[?1049lPass", { now: 3851 }),
|
|||
|
|
{
|
|||
|
|
accepted: true,
|
|||
|
|
data: "\x1b[?1049l",
|
|||
|
|
droppedBytes: "stale\n".length,
|
|||
|
|
reason: "draining",
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
assert.deepEqual(
|
|||
|
|
filterTerminalInterruptOutput(session, "word: ", { now: 3852 }),
|
|||
|
|
{
|
|||
|
|
accepted: true,
|
|||
|
|
data: "Password: ",
|
|||
|
|
droppedBytes: 0,
|
|||
|
|
reason: "prompt-gap",
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("excludes the last of multiple preserved restores from held password prefixes", () => {
|
|||
|
|
const session = {};
|
|||
|
|
|
|||
|
|
armTerminalInterruptOutputGate(session, {
|
|||
|
|
now: 3855,
|
|||
|
|
quietMs: 500,
|
|||
|
|
promptQuietMs: 80,
|
|||
|
|
maxDrainMs: 2500,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
assert.deepEqual(
|
|||
|
|
filterTerminalInterruptOutput(session, "\x1b[?25hstale\n\x1b[?1049lPass", { now: 3856 }),
|
|||
|
|
{
|
|||
|
|
accepted: true,
|
|||
|
|
data: "\x1b[?25h\x1b[?1049l",
|
|||
|
|
droppedBytes: "stale\n".length,
|
|||
|
|
reason: "draining",
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
assert.deepEqual(
|
|||
|
|
filterTerminalInterruptOutput(session, "word: ", { now: 3857 }),
|
|||
|
|
{
|
|||
|
|
accepted: true,
|
|||
|
|
data: "Password: ",
|
|||
|
|
droppedBytes: 0,
|
|||
|
|
reason: "prompt-gap",
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("does not peel preserved restore suffix chars from a later password prefix", () => {
|
|||
|
|
const session = {};
|
|||
|
|
|
|||
|
|
armTerminalInterruptOutputGate(session, {
|
|||
|
|
now: 3860,
|
|||
|
|
quietMs: 500,
|
|||
|
|
promptQuietMs: 80,
|
|||
|
|
maxDrainMs: 2500,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Preserve restore on its own line; next line "login pass" must keep the "l".
|
|||
|
|
assert.deepEqual(
|
|||
|
|
filterTerminalInterruptOutput(session, "\x1b[?1049l\nlogin pass", { now: 3861 }),
|
|||
|
|
{
|
|||
|
|
accepted: true,
|
|||
|
|
data: "\x1b[?1049l",
|
|||
|
|
droppedBytes: 1,
|
|||
|
|
reason: "draining",
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
assert.deepEqual(
|
|||
|
|
filterTerminalInterruptOutput(session, "word: ", { now: 3862 }),
|
|||
|
|
{
|
|||
|
|
accepted: true,
|
|||
|
|
data: "login password: ",
|
|||
|
|
droppedBytes: 0,
|
|||
|
|
reason: "prompt-gap",
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("does not leak held incomplete SGR CSI into a later shell prompt", () => {
|
|||
|
|
const session = {};
|
|||
|
|
|
|||
|
|
armTerminalInterruptOutputGate(session, {
|
|||
|
|
now: 3950,
|
|||
|
|
quietMs: 500,
|
|||
|
|
promptQuietMs: 80,
|
|||
|
|
maxDrainMs: 2500,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Incomplete SGR without a password prefix must be dropped, not held into "$ ".
|
|||
|
|
assert.deepEqual(
|
|||
|
|
filterTerminalInterruptOutput(session, "old\x1b[31", { now: 3951 }),
|
|||
|
|
{
|
|||
|
|
accepted: false,
|
|||
|
|
data: "",
|
|||
|
|
droppedBytes: "old\x1b[31".length,
|
|||
|
|
reason: "draining",
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
assert.deepEqual(
|
|||
|
|
filterTerminalInterruptOutput(session, "$ ", { now: 4100 }),
|
|||
|
|
{
|
|||
|
|
accepted: true,
|
|||
|
|
data: "$ ",
|
|||
|
|
droppedBytes: 0,
|
|||
|
|
reason: "prompt-gap",
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("preserves split alternate-screen exit controls while draining stale output", () => {
|
|||
|
|
const session = {};
|
|||
|
|
|
|||
|
|
armTerminalInterruptOutputGate(session, {
|
|||
|
|
now: 4800,
|
|||
|
|
quietMs: 500,
|
|||
|
|
promptQuietMs: 80,
|
|||
|
|
maxDrainMs: 1000,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
assert.deepEqual(
|
|||
|
|
filterTerminalInterruptOutput(session, "stale frame\x1b[?104", { now: 4801 }),
|
|||
|
|
{
|
|||
|
|
accepted: false,
|
|||
|
|
data: "",
|
|||
|
|
droppedBytes: "stale frame".length,
|
|||
|
|
reason: "draining",
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
assert.deepEqual(
|
|||
|
|
filterTerminalInterruptOutput(session, "9l^C\r\n$ ", { now: 4802 }),
|
|||
|
|
{
|
|||
|
|
accepted: true,
|
|||
|
|
data: "\x1b[?1049l^C\r\n$ ",
|
|||
|
|
droppedBytes: 0,
|
|||
|
|
reason: "interrupt-echo",
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("does not preserve unsafe combined private modes while draining stale output", () => {
|
|||
|
|
const session = {};
|
|||
|
|
|
|||
|
|
armTerminalInterruptOutputGate(session, {
|
|||
|
|
now: 4900,
|
|||
|
|
quietMs: 500,
|
|||
|
|
promptQuietMs: 80,
|
|||
|
|
maxDrainMs: 1000,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const unsafeSequence = "\x1b[?1049;25h";
|
|||
|
|
assert.deepEqual(
|
|||
|
|
filterTerminalInterruptOutput(session, `stale frame${unsafeSequence}^C\r\n$ `, {
|
|||
|
|
now: 4901,
|
|||
|
|
}),
|
|||
|
|
{
|
|||
|
|
accepted: true,
|
|||
|
|
data: "^C\r\n$ ",
|
|||
|
|
droppedBytes: "stale frame".length + unsafeSequence.length,
|
|||
|
|
reason: "interrupt-echo",
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("accepts prompt candidates with OSC title and spaces after stale output", () => {
|
|||
|
|
const session = {};
|
|||
|
|
|
|||
|
|
armTerminalInterruptOutputGate(session, {
|
|||
|
|
now: 5000,
|
|||
|
|
quietMs: 500,
|
|||
|
|
promptQuietMs: 80,
|
|||
|
|
maxDrainMs: 1000,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
assert.equal(filterTerminalInterruptOutput(session, "old output", { now: 5001 }).accepted, false);
|
|||
|
|
const prompt = "\x1b]0;~/My Project\x07~/My Project$ ";
|
|||
|
|
assert.deepEqual(
|
|||
|
|
filterTerminalInterruptOutput(session, prompt, { now: 5100 }),
|
|||
|
|
{
|
|||
|
|
accepted: true,
|
|||
|
|
data: prompt,
|
|||
|
|
droppedBytes: 0,
|
|||
|
|
reason: "prompt-gap",
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("accepts split OSC prompt candidates after stale output", () => {
|
|||
|
|
const session = {};
|
|||
|
|
|
|||
|
|
armTerminalInterruptOutputGate(session, {
|
|||
|
|
now: 5200,
|
|||
|
|
quietMs: 500,
|
|||
|
|
promptQuietMs: 80,
|
|||
|
|
maxDrainMs: 1000,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
assert.deepEqual(
|
|||
|
|
filterTerminalInterruptOutput(session, "old output\x1b]0;~/My ", { now: 5201 }),
|
|||
|
|
{
|
|||
|
|
accepted: false,
|
|||
|
|
data: "",
|
|||
|
|
droppedBytes: "old output".length,
|
|||
|
|
reason: "draining",
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
assert.deepEqual(
|
|||
|
|
filterTerminalInterruptOutput(session, "Project\x07~/My Project$ ", { now: 5300 }),
|
|||
|
|
{
|
|||
|
|
accepted: true,
|
|||
|
|
data: "\x1b]0;~/My Project\x07~/My Project$ ",
|
|||
|
|
droppedBytes: 0,
|
|||
|
|
reason: "prompt-gap",
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("keeps draining large chunks after a short quiet gap", () => {
|
|||
|
|
const session = {};
|
|||
|
|
|
|||
|
|
armTerminalInterruptOutputGate(session, {
|
|||
|
|
now: 3000,
|
|||
|
|
quietMs: 500,
|
|||
|
|
promptQuietMs: 80,
|
|||
|
|
maxDrainMs: 1000,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
assert.equal(filterTerminalInterruptOutput(session, "old output", { now: 3001 }).accepted, false);
|
|||
|
|
assert.deepEqual(
|
|||
|
|
filterTerminalInterruptOutput(session, "x".repeat(32768), { now: 3100 }),
|
|||
|
|
{ accepted: false, data: "", droppedBytes: 32768, reason: "draining" },
|
|||
|
|
);
|
|||
|
|
assert.deepEqual(
|
|||
|
|
filterTerminalInterruptOutput(session, "$ ", { now: 3200 }),
|
|||
|
|
{ accepted: true, data: "$ ", droppedBytes: 0, reason: "prompt-gap" },
|
|||
|
|
);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("accepts a sudo password prompt while draining after Ctrl+C (#2010)", () => {
|
|||
|
|
const session = {};
|
|||
|
|
|
|||
|
|
armTerminalInterruptOutputGate(session, {
|
|||
|
|
now: 6000,
|
|||
|
|
quietMs: 500,
|
|||
|
|
promptQuietMs: 80,
|
|||
|
|
maxDrainMs: 2500,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
assert.equal(
|
|||
|
|
filterTerminalInterruptOutput(session, "Reading package lists...\n", { now: 6001 }).accepted,
|
|||
|
|
false,
|
|||
|
|
);
|
|||
|
|
// One-chunk password prompts must resume before promptQuietMs — they often
|
|||
|
|
// emit nothing further until the user types (#2010 / Codex P1).
|
|||
|
|
assert.deepEqual(
|
|||
|
|
filterTerminalInterruptOutput(session, "[sudo] password for alice: ", { now: 6010 }),
|
|||
|
|
{
|
|||
|
|
accepted: true,
|
|||
|
|
data: "[sudo] password for alice: ",
|
|||
|
|
droppedBytes: 0,
|
|||
|
|
reason: "password-prompt",
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("accepts bare and localized password prompts while draining (#2010)", () => {
|
|||
|
|
for (const prompt of [
|
|||
|
|
"Password: ",
|
|||
|
|
"[sudo] alice 的密码:",
|
|||
|
|
"输入密码",
|
|||
|
|
"Input Password",
|
|||
|
|
"用户 的密码",
|
|||
|
|
"密码",
|
|||
|
|
]) {
|
|||
|
|
const session = {};
|
|||
|
|
armTerminalInterruptOutputGate(session, {
|
|||
|
|
now: 7000,
|
|||
|
|
quietMs: 500,
|
|||
|
|
promptQuietMs: 80,
|
|||
|
|
maxDrainMs: 2500,
|
|||
|
|
});
|
|||
|
|
assert.equal(filterTerminalInterruptOutput(session, "stale\n", { now: 7001 }).accepted, false);
|
|||
|
|
assert.deepEqual(
|
|||
|
|
filterTerminalInterruptOutput(session, prompt, { now: 7010 }),
|
|||
|
|
{
|
|||
|
|
accepted: true,
|
|||
|
|
data: prompt,
|
|||
|
|
droppedBytes: 0,
|
|||
|
|
reason: "password-prompt",
|
|||
|
|
},
|
|||
|
|
`expected password prompt to resume drain: ${JSON.stringify(prompt)}`,
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("does not treat password mentions in ordinary output as prompts", () => {
|
|||
|
|
const session = {};
|
|||
|
|
|
|||
|
|
armTerminalInterruptOutputGate(session, {
|
|||
|
|
now: 8000,
|
|||
|
|
quietMs: 500,
|
|||
|
|
promptQuietMs: 80,
|
|||
|
|
maxDrainMs: 2500,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
assert.equal(filterTerminalInterruptOutput(session, "stale\n", { now: 8001 }).accepted, false);
|
|||
|
|
assert.deepEqual(
|
|||
|
|
filterTerminalInterruptOutput(session, "the password was changed", { now: 8100 }),
|
|||
|
|
{ accepted: false, data: "", droppedBytes: 24, reason: "draining" },
|
|||
|
|
);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("holds a split Password: prompt across chunks while draining (#2010)", () => {
|
|||
|
|
const session = {};
|
|||
|
|
|
|||
|
|
armTerminalInterruptOutputGate(session, {
|
|||
|
|
now: 9000,
|
|||
|
|
quietMs: 500,
|
|||
|
|
promptQuietMs: 80,
|
|||
|
|
maxDrainMs: 2500,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
assert.equal(filterTerminalInterruptOutput(session, "stale\n", { now: 9001 }).accepted, false);
|
|||
|
|
assert.deepEqual(
|
|||
|
|
filterTerminalInterruptOutput(session, "Pass", { now: 9002 }),
|
|||
|
|
{ accepted: false, data: "", droppedBytes: 0, reason: "draining" },
|
|||
|
|
);
|
|||
|
|
assert.deepEqual(
|
|||
|
|
filterTerminalInterruptOutput(session, "word: ", { now: 9003 }),
|
|||
|
|
{
|
|||
|
|
accepted: true,
|
|||
|
|
data: "Password: ",
|
|||
|
|
droppedBytes: 0,
|
|||
|
|
reason: "prompt-gap",
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("holds a split ANSI-colored Password: prompt across chunks while draining", () => {
|
|||
|
|
const session = {};
|
|||
|
|
const red = "\x1b[31m";
|
|||
|
|
const reset = "\x1b[0m";
|
|||
|
|
|
|||
|
|
armTerminalInterruptOutputGate(session, {
|
|||
|
|
now: 9050,
|
|||
|
|
quietMs: 500,
|
|||
|
|
promptQuietMs: 80,
|
|||
|
|
maxDrainMs: 2500,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
assert.equal(filterTerminalInterruptOutput(session, "stale\n", { now: 9051 }).accepted, false);
|
|||
|
|
assert.deepEqual(
|
|||
|
|
filterTerminalInterruptOutput(session, `${red}Pass`, { now: 9052 }),
|
|||
|
|
{ accepted: false, data: "", droppedBytes: 0, reason: "draining" },
|
|||
|
|
);
|
|||
|
|
assert.deepEqual(
|
|||
|
|
filterTerminalInterruptOutput(session, `word:${reset} `, { now: 9053 }),
|
|||
|
|
{
|
|||
|
|
accepted: true,
|
|||
|
|
data: `${red}Password:${reset} `,
|
|||
|
|
droppedBytes: 0,
|
|||
|
|
reason: "prompt-gap",
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("holds a password prefix split before a trailing ANSI control while draining", () => {
|
|||
|
|
const session = {};
|
|||
|
|
const red = "\x1b[31m";
|
|||
|
|
const reset = "\x1b[0m";
|
|||
|
|
|
|||
|
|
armTerminalInterruptOutputGate(session, {
|
|||
|
|
now: 9070,
|
|||
|
|
quietMs: 500,
|
|||
|
|
promptQuietMs: 80,
|
|||
|
|
maxDrainMs: 2500,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
assert.equal(filterTerminalInterruptOutput(session, "stale\n", { now: 9071 }).accepted, false);
|
|||
|
|
// Mid-CSI split: keep both "Pass" and the trailing "\x1b[" so the next chunk
|
|||
|
|
// can complete the reset + "word:" into a password prompt.
|
|||
|
|
assert.deepEqual(
|
|||
|
|
filterTerminalInterruptOutput(session, `${red}Pass\x1b[`, { now: 9072 }),
|
|||
|
|
{ accepted: false, data: "", droppedBytes: 0, reason: "draining" },
|
|||
|
|
);
|
|||
|
|
assert.deepEqual(
|
|||
|
|
filterTerminalInterruptOutput(session, `0mword: `, { now: 9073 }),
|
|||
|
|
{
|
|||
|
|
accepted: true,
|
|||
|
|
data: `${red}Pass${reset}word: `,
|
|||
|
|
droppedBytes: 0,
|
|||
|
|
reason: "prompt-gap",
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("holds a password prefix split mid CSI parameter while draining", () => {
|
|||
|
|
const session = {};
|
|||
|
|
const red = "\x1b[31m";
|
|||
|
|
const reset = "\x1b[0m";
|
|||
|
|
|
|||
|
|
armTerminalInterruptOutputGate(session, {
|
|||
|
|
now: 9080,
|
|||
|
|
quietMs: 500,
|
|||
|
|
promptQuietMs: 80,
|
|||
|
|
maxDrainMs: 2500,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
assert.equal(filterTerminalInterruptOutput(session, "stale\n", { now: 9081 }).accepted, false);
|
|||
|
|
// Incomplete CSI params (ESC[0) must also hold with the password prefix.
|
|||
|
|
assert.deepEqual(
|
|||
|
|
filterTerminalInterruptOutput(session, `${red}Pass\x1b[0`, { now: 9082 }),
|
|||
|
|
{ accepted: false, data: "", droppedBytes: 0, reason: "draining" },
|
|||
|
|
);
|
|||
|
|
assert.deepEqual(
|
|||
|
|
filterTerminalInterruptOutput(session, `mword: `, { now: 9083 }),
|
|||
|
|
{
|
|||
|
|
accepted: true,
|
|||
|
|
data: `${red}Pass${reset}word: `,
|
|||
|
|
droppedBytes: 0,
|
|||
|
|
reason: "prompt-gap",
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("holds a split [sudo] password prompt across chunks while draining (#2010)", () => {
|
|||
|
|
const session = {};
|
|||
|
|
|
|||
|
|
armTerminalInterruptOutputGate(session, {
|
|||
|
|
now: 9100,
|
|||
|
|
quietMs: 500,
|
|||
|
|
promptQuietMs: 80,
|
|||
|
|
maxDrainMs: 2500,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
assert.equal(filterTerminalInterruptOutput(session, "stale\n", { now: 9101 }).accepted, false);
|
|||
|
|
assert.deepEqual(
|
|||
|
|
filterTerminalInterruptOutput(session, "[sudo] pass", { now: 9102 }),
|
|||
|
|
{ accepted: false, data: "", droppedBytes: 0, reason: "draining" },
|
|||
|
|
);
|
|||
|
|
assert.deepEqual(
|
|||
|
|
filterTerminalInterruptOutput(session, "word for alice: ", { now: 9103 }),
|
|||
|
|
{
|
|||
|
|
accepted: true,
|
|||
|
|
data: "[sudo] password for alice: ",
|
|||
|
|
droppedBytes: 0,
|
|||
|
|
reason: "prompt-gap",
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("discards a held password prefix that does not complete as a password prompt", () => {
|
|||
|
|
const session = {};
|
|||
|
|
|
|||
|
|
armTerminalInterruptOutputGate(session, {
|
|||
|
|
now: 9200,
|
|||
|
|
quietMs: 500,
|
|||
|
|
promptQuietMs: 80,
|
|||
|
|
maxDrainMs: 2500,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
assert.equal(filterTerminalInterruptOutput(session, "stale\n", { now: 9201 }).accepted, false);
|
|||
|
|
assert.deepEqual(
|
|||
|
|
filterTerminalInterruptOutput(session, "Pass", { now: 9202 }),
|
|||
|
|
{ accepted: false, data: "", droppedBytes: 0, reason: "draining" },
|
|||
|
|
);
|
|||
|
|
assert.deepEqual(
|
|||
|
|
filterTerminalInterruptOutput(session, "$ ", { now: 9400 }),
|
|||
|
|
{
|
|||
|
|
accepted: true,
|
|||
|
|
data: "$ ",
|
|||
|
|
droppedBytes: 4,
|
|||
|
|
reason: "prompt-gap",
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("discards CSI final bytes when a held mid-CSI password prefix does not complete", () => {
|
|||
|
|
const session = {};
|
|||
|
|
|
|||
|
|
armTerminalInterruptOutputGate(session, {
|
|||
|
|
now: 9220,
|
|||
|
|
quietMs: 500,
|
|||
|
|
promptQuietMs: 80,
|
|||
|
|
maxDrainMs: 2500,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
assert.equal(filterTerminalInterruptOutput(session, "stale\n", { now: 9221 }).accepted, false);
|
|||
|
|
assert.deepEqual(
|
|||
|
|
filterTerminalInterruptOutput(session, "Pass\x1b[0", { now: 9222 }),
|
|||
|
|
{ accepted: false, data: "", droppedBytes: 0, reason: "draining" },
|
|||
|
|
);
|
|||
|
|
// Discard held prefix and the completing "m" so the shell prompt is clean.
|
|||
|
|
assert.deepEqual(
|
|||
|
|
filterTerminalInterruptOutput(session, "m$ ", { now: 9400 }),
|
|||
|
|
{
|
|||
|
|
accepted: true,
|
|||
|
|
data: "$ ",
|
|||
|
|
droppedBytes: "Pass\x1b[0".length + 1,
|
|||
|
|
reason: "prompt-gap",
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("does not hold ordinary password-related output as a prompt prefix", () => {
|
|||
|
|
const session = {};
|
|||
|
|
|
|||
|
|
armTerminalInterruptOutputGate(session, {
|
|||
|
|
now: 9300,
|
|||
|
|
quietMs: 500,
|
|||
|
|
promptQuietMs: 80,
|
|||
|
|
maxDrainMs: 2500,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
assert.equal(filterTerminalInterruptOutput(session, "stale\n", { now: 9301 }).accepted, false);
|
|||
|
|
assert.deepEqual(
|
|||
|
|
filterTerminalInterruptOutput(session, "Password authentication failed", { now: 9302 }),
|
|||
|
|
{
|
|||
|
|
accepted: false,
|
|||
|
|
data: "",
|
|||
|
|
droppedBytes: "Password authentication failed".length,
|
|||
|
|
reason: "draining",
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
assert.deepEqual(
|
|||
|
|
filterTerminalInterruptOutput(session, "$ ", { now: 9500 }),
|
|||
|
|
{
|
|||
|
|
accepted: true,
|
|||
|
|
data: "$ ",
|
|||
|
|
droppedBytes: 0,
|
|||
|
|
reason: "prompt-gap",
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("holds split password prompts that have leading text before the keyword", () => {
|
|||
|
|
const ssh = {};
|
|||
|
|
armTerminalInterruptOutputGate(ssh, {
|
|||
|
|
now: 9600,
|
|||
|
|
quietMs: 500,
|
|||
|
|
promptQuietMs: 80,
|
|||
|
|
maxDrainMs: 2500,
|
|||
|
|
});
|
|||
|
|
assert.equal(filterTerminalInterruptOutput(ssh, "stale\n", { now: 9601 }).accepted, false);
|
|||
|
|
assert.deepEqual(
|
|||
|
|
filterTerminalInterruptOutput(ssh, "alice@host's pass", { now: 9602 }),
|
|||
|
|
{ accepted: false, data: "", droppedBytes: 0, reason: "draining" },
|
|||
|
|
);
|
|||
|
|
assert.deepEqual(
|
|||
|
|
filterTerminalInterruptOutput(ssh, "word: ", { now: 9603 }),
|
|||
|
|
{
|
|||
|
|
accepted: true,
|
|||
|
|
data: "alice@host's password: ",
|
|||
|
|
droppedBytes: 0,
|
|||
|
|
reason: "prompt-gap",
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
const kylin = {};
|
|||
|
|
armTerminalInterruptOutputGate(kylin, {
|
|||
|
|
now: 9700,
|
|||
|
|
quietMs: 500,
|
|||
|
|
promptQuietMs: 80,
|
|||
|
|
maxDrainMs: 2500,
|
|||
|
|
});
|
|||
|
|
assert.equal(filterTerminalInterruptOutput(kylin, "stale\n", { now: 9701 }).accepted, false);
|
|||
|
|
assert.deepEqual(
|
|||
|
|
filterTerminalInterruptOutput(kylin, "用户 的密", { now: 9702 }),
|
|||
|
|
{ accepted: false, data: "", droppedBytes: 0, reason: "draining" },
|
|||
|
|
);
|
|||
|
|
assert.deepEqual(
|
|||
|
|
filterTerminalInterruptOutput(kylin, "码", { now: 9703 }),
|
|||
|
|
{
|
|||
|
|
accepted: true,
|
|||
|
|
data: "用户 的密码",
|
|||
|
|
droppedBytes: 0,
|
|||
|
|
reason: "prompt-gap",
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("accepts a fresh one-chunk password prompt before the quiet gap", () => {
|
|||
|
|
const session = {};
|
|||
|
|
|
|||
|
|
armTerminalInterruptOutputGate(session, {
|
|||
|
|
now: 9800,
|
|||
|
|
quietMs: 500,
|
|||
|
|
promptQuietMs: 80,
|
|||
|
|
maxDrainMs: 2500,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
assert.equal(filterTerminalInterruptOutput(session, "old flood\n", { now: 9801 }).accepted, false);
|
|||
|
|
assert.deepEqual(
|
|||
|
|
filterTerminalInterruptOutput(session, "Password: ", { now: 9810 }),
|
|||
|
|
{
|
|||
|
|
accepted: true,
|
|||
|
|
data: "Password: ",
|
|||
|
|
droppedBytes: 0,
|
|||
|
|
reason: "password-prompt",
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("accepts a last-line password prompt after a large stale prefix", () => {
|
|||
|
|
const session = {};
|
|||
|
|
|
|||
|
|
armTerminalInterruptOutputGate(session, {
|
|||
|
|
now: 9820,
|
|||
|
|
quietMs: 500,
|
|||
|
|
promptQuietMs: 80,
|
|||
|
|
maxDrainMs: 2500,
|
|||
|
|
promptCandidateBytes: 512,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
assert.equal(filterTerminalInterruptOutput(session, "stale\n", { now: 9821 }).accepted, false);
|
|||
|
|
const largeChunk = `${"x".repeat(600)}\n[sudo] password for alice: `;
|
|||
|
|
assert.deepEqual(
|
|||
|
|
filterTerminalInterruptOutput(session, largeChunk, { now: 9830 }),
|
|||
|
|
{
|
|||
|
|
accepted: true,
|
|||
|
|
data: "[sudo] password for alice: ",
|
|||
|
|
droppedBytes: 601,
|
|||
|
|
reason: "password-prompt",
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("does not hold short ASCII trailing letters as password prefixes", () => {
|
|||
|
|
const session = {};
|
|||
|
|
|
|||
|
|
armTerminalInterruptOutputGate(session, {
|
|||
|
|
now: 10000,
|
|||
|
|
quietMs: 500,
|
|||
|
|
promptQuietMs: 80,
|
|||
|
|
maxDrainMs: 2500,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
assert.equal(filterTerminalInterruptOutput(session, "stale\n", { now: 10001 }).accepted, false);
|
|||
|
|
// Full-width "password:" must not lower ASCII minLen to 1, or "copy p" is held
|
|||
|
|
// and a later "assword:" chunk can bypass quiet-gap.
|
|||
|
|
assert.deepEqual(
|
|||
|
|
filterTerminalInterruptOutput(session, "copy p", { now: 10002 }),
|
|||
|
|
{
|
|||
|
|
accepted: false,
|
|||
|
|
data: "",
|
|||
|
|
droppedBytes: "copy p".length,
|
|||
|
|
reason: "draining",
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
assert.deepEqual(
|
|||
|
|
filterTerminalInterruptOutput(session, "assword: ", { now: 10010 }),
|
|||
|
|
{
|
|||
|
|
accepted: false,
|
|||
|
|
data: "",
|
|||
|
|
droppedBytes: "assword: ".length,
|
|||
|
|
reason: "draining",
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
assert.deepEqual(
|
|||
|
|
filterTerminalInterruptOutput(session, "$ ", { now: 10200 }),
|
|||
|
|
{
|
|||
|
|
accepted: true,
|
|||
|
|
data: "$ ",
|
|||
|
|
droppedBytes: 0,
|
|||
|
|
reason: "prompt-gap",
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("rejects held password-prefix completion across a line break", () => {
|
|||
|
|
const session = {};
|
|||
|
|
|
|||
|
|
armTerminalInterruptOutputGate(session, {
|
|||
|
|
now: 9900,
|
|||
|
|
quietMs: 500,
|
|||
|
|
promptQuietMs: 80,
|
|||
|
|
maxDrainMs: 2500,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
assert.equal(filterTerminalInterruptOutput(session, "stale\n", { now: 9901 }).accepted, false);
|
|||
|
|
assert.deepEqual(
|
|||
|
|
filterTerminalInterruptOutput(session, "Pass", { now: 9902 }),
|
|||
|
|
{ accepted: false, data: "", droppedBytes: 0, reason: "draining" },
|
|||
|
|
);
|
|||
|
|
// A newline before Password: discards the held "Pass" prefix (not a same-line
|
|||
|
|
// completion), but the fresh complete password line is still preserved.
|
|||
|
|
assert.deepEqual(
|
|||
|
|
filterTerminalInterruptOutput(session, "\nPassword: ", { now: 9910 }),
|
|||
|
|
{
|
|||
|
|
accepted: true,
|
|||
|
|
data: "Password: ",
|
|||
|
|
droppedBytes: 4 + 1,
|
|||
|
|
reason: "password-prompt",
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
});
|