Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
226 lines
8.0 KiB
JavaScript
226 lines
8.0 KiB
JavaScript
/**
|
|
* Integration-style tests: SCP-mode clients registered in sftpClients are
|
|
* reachable through the same list/mkdir/write entry points the UI and AI tools use.
|
|
*/
|
|
"use strict";
|
|
|
|
const { describe, it, beforeEach, afterEach } = require("node:test");
|
|
const assert = require("node:assert/strict");
|
|
const fs = require("node:fs");
|
|
const os = require("node:os");
|
|
const path = require("node:path");
|
|
const { EventEmitter } = require("node:events");
|
|
const { createScpBackend, isScpModeClient } = require("./scpBackend.cjs");
|
|
const { createFileOpsApi } = require("./fileOps.cjs");
|
|
const { SCP_OK, buildFileControlLine } = require("./scpProtocol.cjs");
|
|
|
|
function createMockStream() {
|
|
const ee = new EventEmitter();
|
|
ee.writable = true;
|
|
ee.readable = true;
|
|
ee.stderr = new EventEmitter();
|
|
ee.write = (buf, cb) => {
|
|
ee.emit("_write", Buffer.from(buf));
|
|
if (typeof cb === "function") cb();
|
|
return true;
|
|
};
|
|
ee.end = (cb) => { if (typeof cb === "function") cb(); };
|
|
ee.close = () => ee.emit("close");
|
|
ee.destroy = () => ee.emit("close");
|
|
ee.pushFromRemote = (buf) => ee.emit("data", Buffer.from(buf));
|
|
return ee;
|
|
}
|
|
|
|
describe("SCP-mode session ops via fileOps entry points (AI/UI shared path)", () => {
|
|
let sftpClients;
|
|
let api;
|
|
let commands;
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-scp-session-"));
|
|
sftpClients = new Map();
|
|
commands = [];
|
|
|
|
const backend = createScpBackend({
|
|
exec: async (command) => {
|
|
commands.push(command);
|
|
if (command.includes("for f in") || command.includes("cd ")) {
|
|
const name = Buffer.from("agent.txt").toString("base64");
|
|
return {
|
|
stdout: `f|-rw-r--r--|4|1700000000|${name}\n`,
|
|
stderr: "",
|
|
code: 0,
|
|
};
|
|
}
|
|
if (command.includes("mkdir")) return { stdout: "", stderr: "", code: 0 };
|
|
if (command.includes("rm ") || command.includes("rmdir")) return { stdout: "", stderr: "", code: 0 };
|
|
if (command.includes("mv ")) return { stdout: "", stderr: "", code: 0 };
|
|
if (command.includes("$HOME") || command.startsWith("printf")) {
|
|
return { stdout: "/home/agent\n", stderr: "", code: 0 };
|
|
}
|
|
if (command.includes("if [ ! -e")) {
|
|
return {
|
|
stdout: "f|-rw-r--r--|4|1700000000|/home/agent/agent.txt\n",
|
|
stderr: "",
|
|
code: 0,
|
|
};
|
|
}
|
|
return { stdout: "", stderr: "", code: 0 };
|
|
},
|
|
execStream: async (command) => {
|
|
commands.push(command);
|
|
const stream = createMockStream();
|
|
if (command.includes("scp -t")) {
|
|
setImmediate(() => stream.pushFromRemote(Buffer.from([SCP_OK])));
|
|
stream.on("_write", (buf) => {
|
|
const text = buf.toString("utf8");
|
|
if (text.startsWith("C") || (buf.length === 1 && buf[0] === 0x00)) {
|
|
setImmediate(() => stream.pushFromRemote(Buffer.from([SCP_OK])));
|
|
}
|
|
});
|
|
} else if (command.includes("scp -f")) {
|
|
let ackCount = 0;
|
|
stream.on("_write", (buf) => {
|
|
if (!(buf[0] === SCP_OK && buf.length === 1)) return;
|
|
ackCount += 1;
|
|
if (ackCount === 1) {
|
|
setImmediate(() => {
|
|
stream.pushFromRemote(buildFileControlLine({ mode: 0o644, size: 4, name: "agent.txt" }));
|
|
});
|
|
} else if (ackCount === 2) {
|
|
setImmediate(() => {
|
|
stream.pushFromRemote(Buffer.concat([Buffer.from("data"), Buffer.from([0x00])]));
|
|
});
|
|
}
|
|
});
|
|
}
|
|
return stream;
|
|
},
|
|
});
|
|
|
|
const client = {
|
|
client: { exec: () => {} },
|
|
sftp: null,
|
|
__netcattyFileProtocol: "scp",
|
|
__netcattyScpBackend: backend,
|
|
async end() {},
|
|
};
|
|
sftpClients.set("scp-session-1", client);
|
|
|
|
// Minimal ctx for createFileOpsApi — only what list/mkdir/rename/delete/write need
|
|
api = createFileOpsApi({
|
|
get sftpClients() { return sftpClients; },
|
|
get electronModule() {
|
|
return {
|
|
webContents: { fromId: () => ({ send: () => {} }) },
|
|
};
|
|
},
|
|
fileWatcherBridge: { stopWatchersForSession: () => {} },
|
|
fs,
|
|
path,
|
|
Buffer,
|
|
console,
|
|
setTimeout,
|
|
clearTimeout,
|
|
jumpConnectionsMap: new Map(),
|
|
sftpEncodingState: new Map(),
|
|
normalizeEncoding: (e) => e || "utf-8",
|
|
isAsciiString: () => true,
|
|
requireSftpChannel: async () => { throw new Error("should not use SFTP channel in SCP mode"); },
|
|
resolveEncodingForRequest: () => "utf-8",
|
|
updateResolvedEncoding: () => "utf-8",
|
|
encodePath: (p) => p,
|
|
decodeName: (n) => n,
|
|
detectEncodingFromList: () => null,
|
|
statResultFromAttrs: (a) => a,
|
|
normalizeRemotePathString: async (_c, p) => p,
|
|
collectReadable: async () => Buffer.alloc(0),
|
|
writeToWritable: async () => {},
|
|
throwIfAborted: () => {},
|
|
pipeStreams: async () => {},
|
|
ensureRemoteDirForSession: async () => true,
|
|
removeRemotePathInternal: async () => {},
|
|
renameRemotePath: async () => {},
|
|
realpathAsync: async () => "/",
|
|
statAsync: async () => ({}),
|
|
readdirAsync: async () => [],
|
|
mkdirAsync: async () => {},
|
|
rmdirAsync: async () => {},
|
|
unlinkAsync: async () => {},
|
|
openFileAsync: async () => ({}),
|
|
writeFileChunkAsync: async () => {},
|
|
closeFileAsync: async () => {},
|
|
createAbortError: (s, m) => new Error(m),
|
|
copySftpEncodingState: () => {},
|
|
clearSftpEncodingState: () => {},
|
|
safeSend: () => {},
|
|
tempDirBridge: { getTempFilePath: (n) => path.join(tmpDir, n) },
|
|
randomUUID: () => "test-uuid",
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
});
|
|
|
|
it("marks clients as SCP-mode", () => {
|
|
assert.equal(isScpModeClient(sftpClients.get("scp-session-1")), true);
|
|
});
|
|
|
|
it("list works for SCP-mode session id (AI sftp.list path)", async () => {
|
|
const entries = await api.listSftp(null, { sftpId: "scp-session-1", path: "/home/agent" });
|
|
assert.equal(entries.length, 1);
|
|
assert.equal(entries[0].name, "agent.txt");
|
|
assert.ok(commands.some((c) => c.includes("cd ") || c.includes("for f in")));
|
|
});
|
|
|
|
it("mkdir write-class op works for SCP-mode session (AI sftp.mkdir path)", async () => {
|
|
const ok = await api.mkdirSftp(null, { sftpId: "scp-session-1", path: "/home/agent/newdir" });
|
|
assert.equal(ok, true);
|
|
assert.ok(commands.some((c) => c.includes("mkdir") && c.includes("/home/agent/newdir")));
|
|
});
|
|
|
|
it("rename and delete work for SCP-mode session", async () => {
|
|
await api.renameSftp(null, {
|
|
sftpId: "scp-session-1",
|
|
oldPath: "/home/agent/a",
|
|
newPath: "/home/agent/b",
|
|
});
|
|
await api.deleteSftp(null, { sftpId: "scp-session-1", path: "/home/agent/b" });
|
|
assert.ok(commands.some((c) => c.includes("mv --")));
|
|
assert.ok(commands.some((c) => c.includes("rm ")));
|
|
});
|
|
|
|
it("write + homeDir work for SCP-mode session", async () => {
|
|
await api.writeSftp(null, {
|
|
sftpId: "scp-session-1",
|
|
path: "/home/agent/out.txt",
|
|
content: "hi\n",
|
|
});
|
|
assert.ok(commands.some((c) => c.includes("scp -t")));
|
|
const home = await api.getSftpHomeDir(null, { sftpId: "scp-session-1" });
|
|
assert.equal(home.success, true);
|
|
assert.equal(home.homeDir, "/home/agent");
|
|
});
|
|
|
|
it("capability catalog still exposes stable sftp.* CLI verbs", () => {
|
|
const { SFTP_CAPABILITIES } = require("../../capabilities/catalog/sftp.cjs");
|
|
const ids = SFTP_CAPABILITIES.map((c) => c.id);
|
|
for (const id of [
|
|
"sftp.list",
|
|
"sftp.mkdir",
|
|
"sftp.write",
|
|
"sftp.upload",
|
|
"sftp.download",
|
|
"sftp.delete",
|
|
"sftp.rename",
|
|
]) {
|
|
assert.ok(ids.includes(id), `missing capability ${id}`);
|
|
}
|
|
const listCap = SFTP_CAPABILITIES.find((c) => c.id === "sftp.list");
|
|
assert.deepEqual(listCap.surfaces.cli.command, ["sftp", "list"]);
|
|
assert.match(listCap.description, /SCP-mode/i);
|
|
});
|
|
});
|