@xingular/harness 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +86 -47
- package/package.json +9 -2
- package/src/cli.ts +7 -3
- package/src/mcp/scopeMigration.test.ts +79 -0
- package/src/mcp/service.test.ts +1 -1
- package/src/mcp/service.ts +1 -1
- package/src/terminal/socket.test.ts +71 -0
- package/src/terminal/socket.ts +52 -13
- package/src/terminal/standalone.test.ts +5 -0
- package/src/terminal/standalone.ts +9 -1
package/dist/cli.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { resolve as resolve9 } from "node:path";
|
|
5
|
+
|
|
3
6
|
// src/core/bus.ts
|
|
4
7
|
var Bus = class {
|
|
5
8
|
listeners = /* @__PURE__ */ new Set();
|
|
@@ -887,9 +890,9 @@ var Accounts = class {
|
|
|
887
890
|
const interaction = {
|
|
888
891
|
signal: controller.signal,
|
|
889
892
|
notify: (notification) => event(notification),
|
|
890
|
-
prompt: (prompt2) => new Promise((
|
|
893
|
+
prompt: (prompt2) => new Promise((resolve10, reject) => {
|
|
891
894
|
const promptId = randomUUID2();
|
|
892
|
-
login.prompts.set(promptId, { resolve:
|
|
895
|
+
login.prompts.set(promptId, { resolve: resolve10, reject });
|
|
893
896
|
prompt2.signal?.addEventListener("abort", () => {
|
|
894
897
|
login.prompts.delete(promptId);
|
|
895
898
|
event({ type: "promptClosed", promptId });
|
|
@@ -4622,14 +4625,14 @@ var piResolver = async ({ cwd, agentDir, install: install3, packages }) => {
|
|
|
4622
4625
|
const settings = pi.SettingsManager.create(cwd, agentDir);
|
|
4623
4626
|
const shared = new Set((settings.getGlobalSettings().packages ?? []).map((entry) => typeof entry === "string" ? entry : entry.source));
|
|
4624
4627
|
const manager2 = () => new pi.DefaultPackageManager({ cwd, agentDir, settingsManager: settings, builtinExtensions: [...PI_BUILTINS] });
|
|
4625
|
-
const
|
|
4628
|
+
const resolve10 = (installing) => manager2().resolve(async (source2) => installing && shared.has(source2) ? "install" : "skip");
|
|
4626
4629
|
let resolved2;
|
|
4627
4630
|
try {
|
|
4628
|
-
resolved2 = install3 ? await withoutInstallScripts(() =>
|
|
4631
|
+
resolved2 = install3 ? await withoutInstallScripts(() => resolve10(true)) : await resolve10(false);
|
|
4629
4632
|
} catch (error) {
|
|
4630
4633
|
if (!install3) throw error;
|
|
4631
4634
|
log("a plugin package could not be installed", error instanceof Error ? error.message : String(error));
|
|
4632
|
-
resolved2 = await
|
|
4635
|
+
resolved2 = await resolve10(false);
|
|
4633
4636
|
}
|
|
4634
4637
|
const found = [];
|
|
4635
4638
|
const add = (paths) => {
|
|
@@ -6249,8 +6252,8 @@ var cancelActive = null;
|
|
|
6249
6252
|
function listenForCallback(expectedState, clientName) {
|
|
6250
6253
|
return new Promise((ready, failed) => {
|
|
6251
6254
|
let settle;
|
|
6252
|
-
const result = new Promise((
|
|
6253
|
-
settle = { resolve:
|
|
6255
|
+
const result = new Promise((resolve10, reject) => {
|
|
6256
|
+
settle = { resolve: resolve10, reject };
|
|
6254
6257
|
});
|
|
6255
6258
|
result.catch(() => void 0);
|
|
6256
6259
|
const server = createServer((request2, response3) => {
|
|
@@ -6274,7 +6277,7 @@ function listenForCallback(expectedState, clientName) {
|
|
|
6274
6277
|
clearTimeout(timer);
|
|
6275
6278
|
failed(new HarnessError("invalid_request", error.code === "EADDRINUSE" ? `Port ${MCP_OAUTH_PORT} is in use. Finish or cancel the other sign-in, or quit the program using that port, then try again.` : "The sign-in listener could not start."));
|
|
6276
6279
|
});
|
|
6277
|
-
const closed = new Promise((
|
|
6280
|
+
const closed = new Promise((resolve10) => server.once("close", resolve10));
|
|
6278
6281
|
const close = () => {
|
|
6279
6282
|
clearTimeout(timer);
|
|
6280
6283
|
if (cancelActive === cancel) cancelActive = null;
|
|
@@ -6459,7 +6462,7 @@ var UNATTENDED_SCOPE = "plugin-hooks";
|
|
|
6459
6462
|
var ConnectedApps = class {
|
|
6460
6463
|
constructor(options2) {
|
|
6461
6464
|
this.options = options2;
|
|
6462
|
-
this.connections = new Connections(options2.stateDir, options2.secrets, options2.bus, () => options2.env.
|
|
6465
|
+
this.connections = new Connections(options2.stateDir, options2.secrets, options2.bus, () => options2.env.root);
|
|
6463
6466
|
this.oauth = {
|
|
6464
6467
|
clientName: options2.env.productName,
|
|
6465
6468
|
load: (id) => this.connections.oauthLoad(id),
|
|
@@ -8479,7 +8482,7 @@ var RunRegistry = class {
|
|
|
8479
8482
|
}
|
|
8480
8483
|
emit("approval.requested", { approvalId, ...payload });
|
|
8481
8484
|
emit("status.changed", { status: "waiting_for_approval" });
|
|
8482
|
-
return new Promise((
|
|
8485
|
+
return new Promise((resolve10) => {
|
|
8483
8486
|
const timer = timeoutMs ? setTimeout(() => this.approvals.get(approvalId)?.resolve("denied"), timeoutMs) : null;
|
|
8484
8487
|
this.approvals.set(approvalId, {
|
|
8485
8488
|
runId,
|
|
@@ -8488,7 +8491,7 @@ var RunRegistry = class {
|
|
|
8488
8491
|
this.approvals.delete(approvalId);
|
|
8489
8492
|
emit("approval.resolved", { approvalId, decision });
|
|
8490
8493
|
emit("status.changed", { status: waitingStatus() });
|
|
8491
|
-
|
|
8494
|
+
resolve10(decision);
|
|
8492
8495
|
}
|
|
8493
8496
|
});
|
|
8494
8497
|
});
|
|
@@ -8499,7 +8502,7 @@ var RunRegistry = class {
|
|
|
8499
8502
|
if (finished || this.nativeCleared.delete(questionId)) return Promise.resolve({ selected: [] });
|
|
8500
8503
|
emit("question.requested", { questionId, ...payload });
|
|
8501
8504
|
emit("status.changed", { status: "waiting_for_input" });
|
|
8502
|
-
return new Promise((
|
|
8505
|
+
return new Promise((resolve10) => {
|
|
8503
8506
|
this.questions.set(questionId, {
|
|
8504
8507
|
runId,
|
|
8505
8508
|
input,
|
|
@@ -8507,7 +8510,7 @@ var RunRegistry = class {
|
|
|
8507
8510
|
this.questions.delete(questionId);
|
|
8508
8511
|
emit("question.answered", { questionId, ...answer });
|
|
8509
8512
|
emit("status.changed", { status: waitingStatus() });
|
|
8510
|
-
|
|
8513
|
+
resolve10(answer);
|
|
8511
8514
|
}
|
|
8512
8515
|
});
|
|
8513
8516
|
});
|
|
@@ -8586,7 +8589,7 @@ var RunRegistry = class {
|
|
|
8586
8589
|
}
|
|
8587
8590
|
/** Interrupts every live run, giving each a moment to stop cleanly. */
|
|
8588
8591
|
async interruptAll(timeoutMs = 3e3) {
|
|
8589
|
-
await Promise.race([Promise.allSettled(this.activeRunIds().map((id) => this.interrupt(id))), new Promise((
|
|
8592
|
+
await Promise.race([Promise.allSettled(this.activeRunIds().map((id) => this.interrupt(id))), new Promise((resolve10) => setTimeout(resolve10, timeoutMs))]);
|
|
8590
8593
|
}
|
|
8591
8594
|
resolveApproval(runId, approvalId, decision) {
|
|
8592
8595
|
if (decision !== "approved" && decision !== "denied" && decision !== "native") throw new HarnessError("invalid_request", "Unknown decision.");
|
|
@@ -8786,7 +8789,7 @@ ${text6}`);
|
|
|
8786
8789
|
async function waitForChildren(root, runId, signal) {
|
|
8787
8790
|
while (!signal?.aborted) {
|
|
8788
8791
|
if (settled(root, runId)) return true;
|
|
8789
|
-
await new Promise((
|
|
8792
|
+
await new Promise((resolve10) => setTimeout(resolve10, 250));
|
|
8790
8793
|
}
|
|
8791
8794
|
return false;
|
|
8792
8795
|
}
|
|
@@ -8867,11 +8870,11 @@ var MAX_TEXT = 4e3;
|
|
|
8867
8870
|
var clip2 = (text6) => text6.length > MAX_TEXT ? `${text6.slice(0, MAX_TEXT)}\u2026` : text6;
|
|
8868
8871
|
function dismissable(answer, fallback, options2) {
|
|
8869
8872
|
if (!options2?.signal && !options2?.timeout) return answer;
|
|
8870
|
-
return new Promise((
|
|
8873
|
+
return new Promise((resolve10) => {
|
|
8871
8874
|
let timer;
|
|
8872
8875
|
const done = (value) => {
|
|
8873
8876
|
if (timer) clearTimeout(timer);
|
|
8874
|
-
|
|
8877
|
+
resolve10(value);
|
|
8875
8878
|
};
|
|
8876
8879
|
if (options2.signal?.aborted) return done(fallback);
|
|
8877
8880
|
options2.signal?.addEventListener("abort", () => done(fallback), { once: true });
|
|
@@ -9564,7 +9567,7 @@ function connectionFromEnv(env = process.env) {
|
|
|
9564
9567
|
return { socket, token };
|
|
9565
9568
|
}
|
|
9566
9569
|
function request(connection, body2, what, timeoutMs) {
|
|
9567
|
-
return new Promise((
|
|
9570
|
+
return new Promise((resolve10, reject) => {
|
|
9568
9571
|
const stream = createConnection(connection.socket);
|
|
9569
9572
|
let buffer = "";
|
|
9570
9573
|
let settled2 = false;
|
|
@@ -9590,7 +9593,7 @@ function request(connection, body2, what, timeoutMs) {
|
|
|
9590
9593
|
if (reply.error) {
|
|
9591
9594
|
const error = typeof reply.error === "string" ? { message: reply.error } : reply.error;
|
|
9592
9595
|
finish(() => reject(new SocketError(error.code ?? "error", error.message ?? `${what} failed.`)));
|
|
9593
|
-
} else finish(() =>
|
|
9596
|
+
} else finish(() => resolve10(reply.result));
|
|
9594
9597
|
} catch {
|
|
9595
9598
|
finish(() => reject(new SocketError("invalid_reply", "The harness sent an unreadable reply.")));
|
|
9596
9599
|
}
|
|
@@ -9794,15 +9797,15 @@ var TerminalController = class {
|
|
|
9794
9797
|
if (this.disposed) throw new HarnessError("terminal_not_ready", "Reopen the terminal before sending instructions.");
|
|
9795
9798
|
if (this.activeRun || this.delivery) throw new HarnessError("session_busy", "This terminal has an active task or delivery.");
|
|
9796
9799
|
const deadline = Date.now() + READY_TIMEOUT_MS;
|
|
9797
|
-
while (!this.ready && !this.startupError && !this.disposed && Date.now() < deadline) await new Promise((
|
|
9800
|
+
while (!this.ready && !this.startupError && !this.disposed && Date.now() < deadline) await new Promise((resolve10) => setTimeout(resolve10, 50));
|
|
9798
9801
|
if (this.disposed) throw new HarnessError("terminal_not_ready", "The terminal stopped during initialization.");
|
|
9799
9802
|
if (this.startupError) throw new HarnessError("terminal_not_ready", this.startupError);
|
|
9800
9803
|
if (!this.ready) throw new HarnessError("terminal_not_ready", `The native telemetry handshake is missing (${this.startupPhase}). Inspect the terminal sign-in or trust screen.`);
|
|
9801
9804
|
if (this.dirty) throw new HarnessError("terminal_has_draft", "The terminal contains user input. Submit or clear it before handing back control.");
|
|
9802
9805
|
let timer;
|
|
9803
|
-
const ack = new Promise((
|
|
9806
|
+
const ack = new Promise((resolve10, reject) => {
|
|
9804
9807
|
timer = setTimeout(() => reject(new HarnessError("delivery_uncertain", "The native prompt acknowledgement was not received. Inspect the terminal; this instruction will not be resent.")), ACK_TIMEOUT_MS);
|
|
9805
|
-
this.delivery = { runId, text: text6, resolve:
|
|
9808
|
+
this.delivery = { runId, text: text6, resolve: resolve10, reject };
|
|
9806
9809
|
});
|
|
9807
9810
|
void ack.catch(() => {
|
|
9808
9811
|
});
|
|
@@ -9918,18 +9921,44 @@ var TerminalController = class {
|
|
|
9918
9921
|
};
|
|
9919
9922
|
|
|
9920
9923
|
// src/terminal/socket.ts
|
|
9921
|
-
import { createHash as createHash7 } from "node:crypto";
|
|
9922
|
-
import { chmodSync as chmodSync10, mkdirSync as mkdirSync12, rmSync as rmSync8 } from "node:fs";
|
|
9924
|
+
import { createHash as createHash7, randomBytes } from "node:crypto";
|
|
9925
|
+
import { chmodSync as chmodSync10, mkdirSync as mkdirSync12, rmSync as rmSync8, statSync as statSync6 } from "node:fs";
|
|
9923
9926
|
import { createServer as createServer2 } from "node:net";
|
|
9924
9927
|
import { tmpdir } from "node:os";
|
|
9925
9928
|
import { dirname as dirname12, join as join37 } from "node:path";
|
|
9926
9929
|
var MAX_REQUEST_BYTES = 1024 * 1024;
|
|
9927
9930
|
var MAX_SOCKET_PATH = 100;
|
|
9928
9931
|
function socketPath(stateDir) {
|
|
9932
|
+
if (process.platform === "win32") return `\\\\.\\pipe\\xingular-harness-${randomBytes(12).toString("hex")}`;
|
|
9929
9933
|
const path3 = join37(stateDir, "run", "harness.sock");
|
|
9930
9934
|
if (Buffer.byteLength(path3) <= MAX_SOCKET_PATH) return path3;
|
|
9931
9935
|
return join37(tmpdir(), `xh-${createHash7("sha256").update(path3).digest("hex").slice(0, 16)}.sock`);
|
|
9932
9936
|
}
|
|
9937
|
+
var isPipePath = (path3) => path3.startsWith("\\\\.\\pipe\\");
|
|
9938
|
+
function refusedBind(path3, error) {
|
|
9939
|
+
const code = error?.code;
|
|
9940
|
+
if (code !== "EACCES" && code !== "EPERM") return error;
|
|
9941
|
+
if (isPipePath(path3)) {
|
|
9942
|
+
return new HarnessError(
|
|
9943
|
+
"session_socket_denied",
|
|
9944
|
+
`The session pipe ${path3} could not be created (${code}).`,
|
|
9945
|
+
"Start the harness again from your own user session. If it keeps failing, report this code and the Windows version."
|
|
9946
|
+
);
|
|
9947
|
+
}
|
|
9948
|
+
let folder;
|
|
9949
|
+
try {
|
|
9950
|
+
const stat = statSync6(dirname12(path3));
|
|
9951
|
+
folder = `${dirname12(path3)} is owned by uid ${stat.uid} with mode ${(stat.mode & 511).toString(8)}`;
|
|
9952
|
+
} catch {
|
|
9953
|
+
folder = `${dirname12(path3)} could not be read`;
|
|
9954
|
+
}
|
|
9955
|
+
const uid = process.getuid?.() ?? "unknown";
|
|
9956
|
+
return new HarnessError(
|
|
9957
|
+
"session_socket_denied",
|
|
9958
|
+
`The session socket ${path3} could not be created (${code}). ${folder}; this process runs as uid ${uid}.`,
|
|
9959
|
+
"Run the harness from your own terminal, as the user that owns its state folder, or choose a state folder you own with --state."
|
|
9960
|
+
);
|
|
9961
|
+
}
|
|
9933
9962
|
var SessionSocket = class {
|
|
9934
9963
|
constructor(path3, handlers) {
|
|
9935
9964
|
this.path = path3;
|
|
@@ -10025,22 +10054,26 @@ var SessionSocket = class {
|
|
|
10025
10054
|
}
|
|
10026
10055
|
/** Binds the socket once; later calls wait for the same bind. */
|
|
10027
10056
|
listen() {
|
|
10028
|
-
this.listening ??= new Promise((
|
|
10029
|
-
|
|
10030
|
-
|
|
10057
|
+
this.listening ??= new Promise((resolve10, reject) => {
|
|
10058
|
+
if (!isPipePath(this.path)) {
|
|
10059
|
+
mkdirSync12(dirname12(this.path), { recursive: true, mode: 448 });
|
|
10060
|
+
rmSync8(this.path, { force: true });
|
|
10061
|
+
}
|
|
10031
10062
|
const server = createServer2((client) => this.serve(client));
|
|
10032
10063
|
server.once("error", (error) => {
|
|
10033
10064
|
this.listening = null;
|
|
10034
|
-
reject(error);
|
|
10065
|
+
reject(refusedBind(this.path, error));
|
|
10035
10066
|
});
|
|
10036
10067
|
server.listen(this.path, () => {
|
|
10037
|
-
|
|
10038
|
-
|
|
10039
|
-
|
|
10040
|
-
|
|
10068
|
+
if (!isPipePath(this.path)) {
|
|
10069
|
+
try {
|
|
10070
|
+
chmodSync10(this.path, 384);
|
|
10071
|
+
} catch (error) {
|
|
10072
|
+
log("session socket permissions", error instanceof Error ? error.message : String(error));
|
|
10073
|
+
}
|
|
10041
10074
|
}
|
|
10042
10075
|
this.server = server;
|
|
10043
|
-
|
|
10076
|
+
resolve10();
|
|
10044
10077
|
});
|
|
10045
10078
|
});
|
|
10046
10079
|
return this.listening;
|
|
@@ -10051,8 +10084,8 @@ var SessionSocket = class {
|
|
|
10051
10084
|
this.server = null;
|
|
10052
10085
|
this.listening = null;
|
|
10053
10086
|
for (const client of this.clients) client.destroy();
|
|
10054
|
-
if (server) await new Promise((
|
|
10055
|
-
rmSync8(this.path, { force: true });
|
|
10087
|
+
if (server) await new Promise((resolve10) => server.close(() => resolve10()));
|
|
10088
|
+
if (!isPipePath(this.path)) rmSync8(this.path, { force: true });
|
|
10056
10089
|
}
|
|
10057
10090
|
};
|
|
10058
10091
|
|
|
@@ -10558,7 +10591,7 @@ import { join as join41, resolve as resolve7 } from "node:path";
|
|
|
10558
10591
|
import { createInterface } from "node:readline/promises";
|
|
10559
10592
|
|
|
10560
10593
|
// src/plugins/install.ts
|
|
10561
|
-
import { existsSync as existsSync15, mkdirSync as mkdirSync15, renameSync as renameSync8, statSync as
|
|
10594
|
+
import { existsSync as existsSync15, mkdirSync as mkdirSync15, renameSync as renameSync8, statSync as statSync7 } from "node:fs";
|
|
10562
10595
|
import { homedir as homedir4 } from "node:os";
|
|
10563
10596
|
import { basename as basename8, isAbsolute as isAbsolute5, join as join40, resolve as resolve6 } from "node:path";
|
|
10564
10597
|
function normalizeSource(input) {
|
|
@@ -10578,7 +10611,7 @@ async function manager(harness) {
|
|
|
10578
10611
|
var changed = (harness) => harness.bus.emit({ type: "plugins.changed" });
|
|
10579
10612
|
async function install2(harness, params) {
|
|
10580
10613
|
const source2 = normalizeSource(params.source);
|
|
10581
|
-
if (isAbsolute5(source2) && !(existsSync15(source2) &&
|
|
10614
|
+
if (isAbsolute5(source2) && !(existsSync15(source2) && statSync7(source2).isDirectory())) throw invalid("That folder does not exist.");
|
|
10582
10615
|
await ensureAdopted(harness);
|
|
10583
10616
|
const before = new Set((await list2(harness, params)).map((plugin) => plugin.id));
|
|
10584
10617
|
const { settings, packages } = await manager(harness);
|
|
@@ -10888,7 +10921,7 @@ var RpcPeer = class {
|
|
|
10888
10921
|
/** Asks the host to perform a named operation. */
|
|
10889
10922
|
callHost(call, params, timeoutMs = 3e4) {
|
|
10890
10923
|
const id = `s${++this.nextCallId}`;
|
|
10891
|
-
return new Promise((
|
|
10924
|
+
return new Promise((resolve10, reject) => {
|
|
10892
10925
|
const timer = setTimeout(() => {
|
|
10893
10926
|
this.pendingCalls.delete(id);
|
|
10894
10927
|
reject(new HarnessError("host_timeout", `The host did not answer ${call}.`));
|
|
@@ -10896,7 +10929,7 @@ var RpcPeer = class {
|
|
|
10896
10929
|
this.pendingCalls.set(id, {
|
|
10897
10930
|
resolve: (value) => {
|
|
10898
10931
|
clearTimeout(timer);
|
|
10899
|
-
|
|
10932
|
+
resolve10(value);
|
|
10900
10933
|
},
|
|
10901
10934
|
reject: (error) => {
|
|
10902
10935
|
clearTimeout(timer);
|
|
@@ -10947,7 +10980,7 @@ var RpcPeer = class {
|
|
|
10947
10980
|
};
|
|
10948
10981
|
|
|
10949
10982
|
// src/server/methods.ts
|
|
10950
|
-
import { readFileSync as readFileSync20, statSync as
|
|
10983
|
+
import { readFileSync as readFileSync20, statSync as statSync8 } from "node:fs";
|
|
10951
10984
|
import { basename as basename9 } from "node:path";
|
|
10952
10985
|
|
|
10953
10986
|
// src/memory/graph.ts
|
|
@@ -11161,7 +11194,7 @@ function methods(harness) {
|
|
|
11161
11194
|
"wiki.sources": () => listSources(wikiDir2()),
|
|
11162
11195
|
"wiki.ingest": (params) => {
|
|
11163
11196
|
const path3 = text5(params, "path");
|
|
11164
|
-
if (
|
|
11197
|
+
if (statSync8(path3).size > MAX_SOURCE_BYTES) throw invalid("Sources larger than 50 MB are not imported.");
|
|
11165
11198
|
const source2 = maintenance.ingest(optional2(params, "name") ?? basename9(path3), readFileSync20(path3), optional2(params, "namespace") ?? LIBRARY_NAMESPACE);
|
|
11166
11199
|
harness.bus.emit({ type: "wiki.changed" });
|
|
11167
11200
|
return source2;
|
|
@@ -11327,7 +11360,7 @@ function attach3(harness, peer) {
|
|
|
11327
11360
|
import { spawn as nodeSpawn } from "node:child_process";
|
|
11328
11361
|
import { createHash as createHash8, randomUUID as randomUUID8 } from "node:crypto";
|
|
11329
11362
|
import { homedir as homedir6 } from "node:os";
|
|
11330
|
-
import { join as join42, resolve as resolve8 } from "node:path";
|
|
11363
|
+
import { isAbsolute as isAbsolute6, join as join42, resolve as resolve8 } from "node:path";
|
|
11331
11364
|
function standaloneArguments(argv) {
|
|
11332
11365
|
const command = argv[0];
|
|
11333
11366
|
if (!command || command.startsWith("--") || command === "-h") return argv;
|
|
@@ -11339,14 +11372,19 @@ function parseStandaloneOptions(argv, home = homedir6(), cwd = process.cwd()) {
|
|
|
11339
11372
|
dataRoot: join42(root, "data"),
|
|
11340
11373
|
stateDir: join42(root, "state")
|
|
11341
11374
|
};
|
|
11375
|
+
const given = /* @__PURE__ */ new Set();
|
|
11342
11376
|
for (let index = 0; index < argv.length; index += 1) {
|
|
11343
11377
|
const argument = argv[index];
|
|
11344
11378
|
if (argument !== "--data" && argument !== "--state") throw new Error(`Unknown standalone option: ${argument}`);
|
|
11345
11379
|
const value = argv[index + 1];
|
|
11346
11380
|
if (!value || value.startsWith("--")) throw new Error(`${argument} needs a directory path.`);
|
|
11347
11381
|
paths[argument === "--data" ? "dataRoot" : "stateDir"] = resolve8(cwd, value);
|
|
11382
|
+
given.add(argument);
|
|
11348
11383
|
index += 1;
|
|
11349
11384
|
}
|
|
11385
|
+
if (!isAbsolute6(home) && !(given.has("--data") && given.has("--state"))) {
|
|
11386
|
+
throw new Error(`The home folder "${home}" is not an absolute path, so the default data and state folders cannot be placed. Set HOME to your home folder, or pass --data and --state with absolute folders.`);
|
|
11387
|
+
}
|
|
11350
11388
|
return paths;
|
|
11351
11389
|
}
|
|
11352
11390
|
function processEnvironment(overrides) {
|
|
@@ -11451,15 +11489,16 @@ function flags(argv) {
|
|
|
11451
11489
|
}
|
|
11452
11490
|
function serveCommand(argv) {
|
|
11453
11491
|
const { values: values2, switches } = flags(argv);
|
|
11454
|
-
const
|
|
11455
|
-
if (!
|
|
11492
|
+
const stateArgument = values2.state?.[0];
|
|
11493
|
+
if (!stateArgument) {
|
|
11456
11494
|
process.stderr.write("serve needs --state <dir>.\n");
|
|
11457
11495
|
return Promise.resolve(2);
|
|
11458
11496
|
}
|
|
11497
|
+
const stateDir = resolve9(stateArgument);
|
|
11459
11498
|
const peer = new RpcPeer(stdioTransport());
|
|
11460
11499
|
const options2 = {
|
|
11461
11500
|
stateDir,
|
|
11462
|
-
dataRoot: values2.data?.[0]
|
|
11501
|
+
dataRoot: values2.data?.[0] ? resolve9(values2.data[0]) : null,
|
|
11463
11502
|
productName: values2.name?.[0],
|
|
11464
11503
|
builtins: values2.builtins,
|
|
11465
11504
|
...switches.has("host-secrets") ? { secrets: remoteSecrets(peer) } : {},
|
|
@@ -11469,12 +11508,12 @@ function serveCommand(argv) {
|
|
|
11469
11508
|
const harness = createHarness(options2);
|
|
11470
11509
|
if (switches.has("host-tools")) harness.sessions.provide(remoteTools(peer));
|
|
11471
11510
|
attach3(harness, peer);
|
|
11472
|
-
return new Promise((
|
|
11511
|
+
return new Promise((resolve10) => {
|
|
11473
11512
|
let closing = false;
|
|
11474
11513
|
const shutdown = () => {
|
|
11475
11514
|
if (closing) return;
|
|
11476
11515
|
closing = true;
|
|
11477
|
-
void Promise.race([harness.close(), new Promise((done) => setTimeout(done, 5e3))]).then(() =>
|
|
11516
|
+
void Promise.race([harness.close(), new Promise((done) => setTimeout(done, 5e3))]).then(() => resolve10(0));
|
|
11478
11517
|
};
|
|
11479
11518
|
peer.handle("harness.shutdown", () => void setImmediate(shutdown));
|
|
11480
11519
|
process.stdin.on("end", shutdown);
|
package/package.json
CHANGED
|
@@ -1,14 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xingular/harness",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Xingular Harness — a Pi agent harness with multi-account sign-in, a compiled system prompt, a memory lifecycle (Wiki), tools, MCP, and skills",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/tanntnny/xingular-harness.git"
|
|
9
|
+
},
|
|
10
|
+
"publishConfig": {
|
|
11
|
+
"access": "public"
|
|
12
|
+
},
|
|
6
13
|
"exports": {
|
|
7
14
|
".": "./src/index.ts",
|
|
8
15
|
"./*": "./src/*"
|
|
9
16
|
},
|
|
10
17
|
"bin": {
|
|
11
|
-
"xingular-harness": "
|
|
18
|
+
"xingular-harness": "dist/cli.js"
|
|
12
19
|
},
|
|
13
20
|
"scripts": {
|
|
14
21
|
"build": "esbuild src/cli.ts --bundle --platform=node --target=node22 --format=esm --packages=external --outfile=dist/cli.js && esbuild src/terminal/piExtension.ts --bundle --platform=node --target=node22 --format=esm --packages=external --outfile=dist/terminal-pi.js",
|
package/src/cli.ts
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
// xingular-harness wiki <command> Memory and Wiki, from inside a terminal session.
|
|
8
8
|
// xingular-harness mcp <command> Connected apps, from inside a terminal session.
|
|
9
9
|
// xingular-harness plugins <command> List, install, switch and trust plugins.
|
|
10
|
+
import { resolve } from 'node:path'
|
|
11
|
+
|
|
10
12
|
import { log } from './core/bus'
|
|
11
13
|
import { createHarness, type HarnessOptions } from './harness'
|
|
12
14
|
import { runPluginsCli, terminalAsk } from './plugins/cli'
|
|
@@ -50,15 +52,17 @@ function flags(argv: string[]): { values: Record<string, string[]>; switches: Se
|
|
|
50
52
|
|
|
51
53
|
function serveCommand(argv: string[]): Promise<number> {
|
|
52
54
|
const { values, switches } = flags(argv)
|
|
53
|
-
const
|
|
54
|
-
if (!
|
|
55
|
+
const stateArgument = values.state?.[0]
|
|
56
|
+
if (!stateArgument) {
|
|
55
57
|
process.stderr.write('serve needs --state <dir>.\n')
|
|
56
58
|
return Promise.resolve(2)
|
|
57
59
|
}
|
|
60
|
+
// The socket path is built from this folder, so a relative one would depend on where the host ran.
|
|
61
|
+
const stateDir = resolve(stateArgument)
|
|
58
62
|
const peer = new RpcPeer(stdioTransport())
|
|
59
63
|
const options: HarnessOptions = {
|
|
60
64
|
stateDir,
|
|
61
|
-
dataRoot: values.data?.[0]
|
|
65
|
+
dataRoot: values.data?.[0] ? resolve(values.data[0]) : null,
|
|
62
66
|
productName: values.name?.[0],
|
|
63
67
|
builtins: values.builtins,
|
|
64
68
|
...(switches.has('host-secrets') ? { secrets: remoteSecrets(peer) } : {}),
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
6
|
+
|
|
7
|
+
import * as attachments from '../plugins/attachments'
|
|
8
|
+
import { migrateConnectionScopes, rollbackConnectionScopeMigration } from './scopeMigration'
|
|
9
|
+
|
|
10
|
+
const roots: string[] = []
|
|
11
|
+
function fixture(): { root: string; stateDir: string; sessionPath: string } {
|
|
12
|
+
const base = mkdtempSync(join(tmpdir(), 'xingular-scope-migration-'))
|
|
13
|
+
roots.push(base)
|
|
14
|
+
const root = join(base, 'data')
|
|
15
|
+
const stateDir = join(base, 'state')
|
|
16
|
+
mkdirSync(join(root, 'config'), { recursive: true })
|
|
17
|
+
mkdirSync(join(root, 'sessions', 'agent'), { recursive: true })
|
|
18
|
+
mkdirSync(stateDir, { recursive: true })
|
|
19
|
+
const sessionPath = join(root, 'sessions', 'agent', 'thread.json')
|
|
20
|
+
writeFileSync(sessionPath, Buffer.from('{ "session": "keep these bytes\\nexactly" }\n'))
|
|
21
|
+
mkdirSync(join(root, 'agents', 'agent-a'), { recursive: true })
|
|
22
|
+
writeFileSync(join(root, 'agents', 'agent-a', 'agent.md'), Buffer.from('tools: [read, bash, mcp_call_tool]\n'))
|
|
23
|
+
return { root, stateDir, sessionPath }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function digest(path: string): string {
|
|
27
|
+
return createHash('sha256').update(readFileSync(path)).digest('hex')
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
describe('connector scope migration', () => {
|
|
35
|
+
it('moves scopes, verifies assignments, keeps session bytes, and restores exact backups', () => {
|
|
36
|
+
const { root, stateDir, sessionPath } = fixture()
|
|
37
|
+
const sessionHash = digest(sessionPath)
|
|
38
|
+
const agentToolsPath = join(root, 'agents', 'agent-a', 'agent.md')
|
|
39
|
+
const agentToolsHash = digest(agentToolsPath)
|
|
40
|
+
const connectionBytes = Buffer.from(`[{"id":"linear","name":"Linear","kind":"remote","enabled":true,"global":true,"agentIds":["agent-a"],"excludedAgentIds":["agent-b"],"projectIds":["project-a"],"excludedProjectIds":["project-b"],"url":"https://mcp.example"}]\n`)
|
|
41
|
+
const oldAttachmentsBytes = Buffer.from('{"version":1,"global":{"on":["existing"],"off":[]},"agents":{},"projects":{},"settings":{"keep":{"x":1}}}\n')
|
|
42
|
+
const connectionsPath = join(stateDir, 'connections.json')
|
|
43
|
+
const attachmentsPath = join(root, 'config', 'plugin-attachments.json')
|
|
44
|
+
writeFileSync(connectionsPath, connectionBytes)
|
|
45
|
+
writeFileSync(attachmentsPath, oldAttachmentsBytes)
|
|
46
|
+
|
|
47
|
+
const result = migrateConnectionScopes(root, stateDir)
|
|
48
|
+
expect(result).toMatchObject({ status: 'complete', connectionCount: 1, scopeAssignmentCount: 5, hadAttachments: true })
|
|
49
|
+
const saved = JSON.parse(readFileSync(connectionsPath, 'utf8'))[0]
|
|
50
|
+
expect(saved).toMatchObject({ id: 'linear', url: 'https://mcp.example' })
|
|
51
|
+
for (const key of ['global', 'agentIds', 'excludedAgentIds', 'projectIds', 'excludedProjectIds']) expect(saved).not.toHaveProperty(key)
|
|
52
|
+
|
|
53
|
+
const migrated = attachments.load(root)
|
|
54
|
+
expect(migrated.global.on).toContain('connected-apps/connection/linear')
|
|
55
|
+
expect(migrated.agents['agent-a'].on).toContain('connected-apps/connection/linear')
|
|
56
|
+
expect(migrated.agents['agent-b'].off).toContain('connected-apps/connection/linear')
|
|
57
|
+
expect(migrated.projects['project-a'].on).toContain('connected-apps/connection/linear')
|
|
58
|
+
expect(migrated.projects['project-b'].off).toContain('connected-apps/connection/linear')
|
|
59
|
+
expect(digest(sessionPath)).toBe(sessionHash)
|
|
60
|
+
expect(digest(agentToolsPath)).toBe(agentToolsHash)
|
|
61
|
+
expect(readFileSync(join(stateDir, 'connections.pre-capability-scope.json'))).toEqual(connectionBytes)
|
|
62
|
+
expect(readFileSync(join(root, 'config', 'plugin-attachments.pre-capability-scope.json'))).toEqual(oldAttachmentsBytes)
|
|
63
|
+
|
|
64
|
+
rollbackConnectionScopeMigration(root, stateDir)
|
|
65
|
+
expect(readFileSync(connectionsPath)).toEqual(connectionBytes)
|
|
66
|
+
expect(readFileSync(attachmentsPath)).toEqual(oldAttachmentsBytes)
|
|
67
|
+
expect(digest(sessionPath)).toBe(sessionHash)
|
|
68
|
+
expect(digest(agentToolsPath)).toBe(agentToolsHash)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('is idempotent after completion without rewriting session data', () => {
|
|
72
|
+
const { root, stateDir, sessionPath } = fixture()
|
|
73
|
+
writeFileSync(join(stateDir, 'connections.json'), '[{"id":"linear","global":false}]\n')
|
|
74
|
+
const before = digest(sessionPath)
|
|
75
|
+
const first = migrateConnectionScopes(root, stateDir)
|
|
76
|
+
expect(migrateConnectionScopes(root, stateDir)).toEqual(first)
|
|
77
|
+
expect(digest(sessionPath)).toBe(before)
|
|
78
|
+
})
|
|
79
|
+
})
|
package/src/mcp/service.test.ts
CHANGED
|
@@ -181,7 +181,7 @@ describe('connected apps for a session', () => {
|
|
|
181
181
|
const saved = await harness.apps.save({ ...local, command: '/missing/mcp-fixture' })
|
|
182
182
|
const result = await harness.apps.list(granted)
|
|
183
183
|
expect(result.tools).toEqual([])
|
|
184
|
-
expect(result.errors[0].message).toContain('
|
|
184
|
+
expect(result.errors[0].message).toContain('Capability → Connectors')
|
|
185
185
|
expect(harness.apps.connections.find(saved.id).error).toContain('executable and arguments')
|
|
186
186
|
})
|
|
187
187
|
|
package/src/mcp/service.ts
CHANGED
|
@@ -134,7 +134,7 @@ export class ConnectedApps implements SessionBroker {
|
|
|
134
134
|
private brokered: BrokeredTools | null = null
|
|
135
135
|
|
|
136
136
|
constructor(private readonly options: ConnectedAppsOptions) {
|
|
137
|
-
this.connections = new Connections(options.stateDir, options.secrets, options.bus, () => options.env.
|
|
137
|
+
this.connections = new Connections(options.stateDir, options.secrets, options.bus, () => options.env.root)
|
|
138
138
|
this.oauth = {
|
|
139
139
|
clientName: options.env.productName,
|
|
140
140
|
load: (id) => this.connections.oauthLoad(id),
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { chmodSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs'
|
|
2
|
+
import { createConnection } from 'node:net'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { describe, expect, it } from 'vitest'
|
|
6
|
+
|
|
7
|
+
import { SessionSocket, socketPath } from './socket'
|
|
8
|
+
|
|
9
|
+
const handlers = { memory: () => '', apps: async () => null }
|
|
10
|
+
|
|
11
|
+
/** Runs `body` as if on another operating system; `process.platform` is read at call time. */
|
|
12
|
+
function onPlatform<T>(platform: NodeJS.Platform, body: () => T): T {
|
|
13
|
+
const original = Object.getOwnPropertyDescriptor(process, 'platform')!
|
|
14
|
+
Object.defineProperty(process, 'platform', { value: platform })
|
|
15
|
+
try {
|
|
16
|
+
return body()
|
|
17
|
+
} finally {
|
|
18
|
+
Object.defineProperty(process, 'platform', original)
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe('session socket path', () => {
|
|
23
|
+
it('keeps the socket file in the state folder on Unix', () => {
|
|
24
|
+
expect(onPlatform('darwin', () => socketPath('/tmp/state'))).toBe(join('/tmp/state', 'run', 'harness.sock'))
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('uses a named pipe on Windows, with a random name each time', () => {
|
|
28
|
+
const first = onPlatform('win32', () => socketPath('C:\\Users\\someone\\.xingular-harness\\state'))
|
|
29
|
+
const second = onPlatform('win32', () => socketPath('C:\\Users\\someone\\.xingular-harness\\state'))
|
|
30
|
+
expect(first).toMatch(/^\\\\\.\\pipe\\xingular-harness-[0-9a-f]{24}$/)
|
|
31
|
+
expect(second).not.toBe(first)
|
|
32
|
+
})
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
describe('session socket', () => {
|
|
36
|
+
it.skipIf(process.platform !== 'win32')('listens on a named pipe and answers a request on Windows', async () => {
|
|
37
|
+
const socket = new SessionSocket(socketPath('unused'), handlers)
|
|
38
|
+
await socket.listen()
|
|
39
|
+
try {
|
|
40
|
+
const reply = await new Promise<string>((resolve, reject) => {
|
|
41
|
+
const client = createConnection(socket.path)
|
|
42
|
+
let buffer = ''
|
|
43
|
+
client.on('connect', () => client.write('{"token":"nope","call":"wiki.read"}\n'))
|
|
44
|
+
client.on('data', (chunk) => (buffer += chunk.toString('utf8')))
|
|
45
|
+
client.on('end', () => resolve(buffer))
|
|
46
|
+
client.on('error', reject)
|
|
47
|
+
})
|
|
48
|
+
expect(JSON.parse(reply)).toMatchObject({ error: { code: 'unauthorized' } })
|
|
49
|
+
} finally {
|
|
50
|
+
await socket.close()
|
|
51
|
+
}
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('names the folder and the user when the operating system refuses to create the socket', async () => {
|
|
55
|
+
// Root can write anywhere, so the refusal cannot be produced there.
|
|
56
|
+
if (process.platform === 'win32' || process.getuid?.() === 0) return
|
|
57
|
+
const base = mkdtempSync(join(tmpdir(), 'sock-'))
|
|
58
|
+
const run = join(base, 'run')
|
|
59
|
+
mkdirSync(run)
|
|
60
|
+
chmodSync(run, 0o500)
|
|
61
|
+
try {
|
|
62
|
+
await expect(new SessionSocket(join(run, 'harness.sock'), handlers).listen()).rejects.toMatchObject({
|
|
63
|
+
code: 'session_socket_denied',
|
|
64
|
+
message: expect.stringMatching(/could not be created \((?:EACCES|EPERM)\)\. .*mode 500; this process runs as uid \d+\./)
|
|
65
|
+
})
|
|
66
|
+
} finally {
|
|
67
|
+
chmodSync(run, 0o700)
|
|
68
|
+
rmSync(base, { recursive: true, force: true })
|
|
69
|
+
}
|
|
70
|
+
})
|
|
71
|
+
})
|
package/src/terminal/socket.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
// The session socket: how a process the harness did not start in-process (the `pi` command in
|
|
2
2
|
// a terminal, with its extension and the `agentx-wiki` / `agentx-mcp` commands) reaches the
|
|
3
|
-
// session it belongs to. Owner-only Unix socket, newline-delimited JSON,
|
|
4
|
-
// connection. Each terminal session holds a random token bound to its caller, its
|
|
5
|
-
// grant, and its controller; the token stops working when the terminal ends.
|
|
6
|
-
import { createHash } from 'node:crypto'
|
|
7
|
-
import { chmodSync, mkdirSync, rmSync } from 'node:fs'
|
|
3
|
+
// session it belongs to. Owner-only Unix socket (a named pipe on Windows), newline-delimited JSON,
|
|
4
|
+
// one request per connection. Each terminal session holds a random token bound to its caller, its
|
|
5
|
+
// connected-apps grant, and its controller; the token stops working when the terminal ends.
|
|
6
|
+
import { createHash, randomBytes } from 'node:crypto'
|
|
7
|
+
import { chmodSync, mkdirSync, rmSync, statSync } from 'node:fs'
|
|
8
8
|
import { createServer, type Server, type Socket } from 'node:net'
|
|
9
9
|
import { tmpdir } from 'node:os'
|
|
10
10
|
import { dirname, join } from 'node:path'
|
|
@@ -42,12 +42,47 @@ const MAX_REQUEST_BYTES = 1024 * 1024
|
|
|
42
42
|
/** macOS refuses a socket path longer than 103 bytes. */
|
|
43
43
|
const MAX_SOCKET_PATH = 100
|
|
44
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Windows has no socket files for Node's `net`: it listens on a named pipe, which lives in a
|
|
47
|
+
* machine-wide namespace. A random name keeps another local process from guessing it and standing
|
|
48
|
+
* in front of the session; the token still decides what a caller may do.
|
|
49
|
+
*/
|
|
45
50
|
export function socketPath(stateDir: string): string {
|
|
51
|
+
if (process.platform === 'win32') return `\\\\.\\pipe\\xingular-harness-${randomBytes(12).toString('hex')}`
|
|
46
52
|
const path = join(stateDir, 'run', 'harness.sock')
|
|
47
53
|
if (Buffer.byteLength(path) <= MAX_SOCKET_PATH) return path
|
|
48
54
|
return join(tmpdir(), `xh-${createHash('sha256').update(path).digest('hex').slice(0, 16)}.sock`)
|
|
49
55
|
}
|
|
50
56
|
|
|
57
|
+
/** A named pipe has no folder, file mode, or owner to check or clean up. */
|
|
58
|
+
const isPipePath = (path: string): boolean => path.startsWith('\\\\.\\pipe\\')
|
|
59
|
+
|
|
60
|
+
/** Names the folder and the user when the operating system refuses the socket, so the cause shows. */
|
|
61
|
+
function refusedBind(path: string, error: unknown): unknown {
|
|
62
|
+
const code = (error as { code?: unknown } | null)?.code
|
|
63
|
+
if (code !== 'EACCES' && code !== 'EPERM') return error
|
|
64
|
+
if (isPipePath(path)) {
|
|
65
|
+
return new HarnessError(
|
|
66
|
+
'session_socket_denied',
|
|
67
|
+
`The session pipe ${path} could not be created (${code}).`,
|
|
68
|
+
'Start the harness again from your own user session. If it keeps failing, report this code and the Windows version.'
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
let folder: string
|
|
72
|
+
try {
|
|
73
|
+
const stat = statSync(dirname(path))
|
|
74
|
+
folder = `${dirname(path)} is owned by uid ${stat.uid} with mode ${(stat.mode & 0o777).toString(8)}`
|
|
75
|
+
} catch {
|
|
76
|
+
folder = `${dirname(path)} could not be read`
|
|
77
|
+
}
|
|
78
|
+
const uid = process.getuid?.() ?? 'unknown'
|
|
79
|
+
return new HarnessError(
|
|
80
|
+
'session_socket_denied',
|
|
81
|
+
`The session socket ${path} could not be created (${code}). ${folder}; this process runs as uid ${uid}.`,
|
|
82
|
+
'Run the harness from your own terminal, as the user that owns its state folder, or choose a state folder you own with --state.'
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
|
|
51
86
|
export class SessionSocket {
|
|
52
87
|
private server: Server | null = null
|
|
53
88
|
private listening: Promise<void> | null = null
|
|
@@ -150,18 +185,22 @@ export class SessionSocket {
|
|
|
150
185
|
/** Binds the socket once; later calls wait for the same bind. */
|
|
151
186
|
listen(): Promise<void> {
|
|
152
187
|
this.listening ??= new Promise<void>((resolve, reject) => {
|
|
153
|
-
|
|
154
|
-
|
|
188
|
+
if (!isPipePath(this.path)) {
|
|
189
|
+
mkdirSync(dirname(this.path), { recursive: true, mode: 0o700 })
|
|
190
|
+
rmSync(this.path, { force: true })
|
|
191
|
+
}
|
|
155
192
|
const server = createServer((client) => this.serve(client))
|
|
156
193
|
server.once('error', (error) => {
|
|
157
194
|
this.listening = null
|
|
158
|
-
reject(error)
|
|
195
|
+
reject(refusedBind(this.path, error))
|
|
159
196
|
})
|
|
160
197
|
server.listen(this.path, () => {
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
198
|
+
if (!isPipePath(this.path)) {
|
|
199
|
+
try {
|
|
200
|
+
chmodSync(this.path, 0o600)
|
|
201
|
+
} catch (error) {
|
|
202
|
+
log('session socket permissions', error instanceof Error ? error.message : String(error))
|
|
203
|
+
}
|
|
165
204
|
}
|
|
166
205
|
this.server = server
|
|
167
206
|
resolve()
|
|
@@ -177,6 +216,6 @@ export class SessionSocket {
|
|
|
177
216
|
this.listening = null
|
|
178
217
|
for (const client of this.clients) client.destroy()
|
|
179
218
|
if (server) await new Promise<void>((resolve) => server.close(() => resolve()))
|
|
180
|
-
rmSync(this.path, { force: true })
|
|
219
|
+
if (!isPipePath(this.path)) rmSync(this.path, { force: true })
|
|
181
220
|
}
|
|
182
221
|
}
|
|
@@ -67,6 +67,11 @@ describe('standalone terminal startup', () => {
|
|
|
67
67
|
expect(() => parseStandaloneOptions(['--model', 'unknown'])).toThrow('Unknown standalone option: --model')
|
|
68
68
|
})
|
|
69
69
|
|
|
70
|
+
it('refuses default folders under a home that is not a folder path, and accepts explicit absolute folders', () => {
|
|
71
|
+
expect(() => parseStandaloneOptions([], 'HOME', '/work/project')).toThrow('is not an absolute path')
|
|
72
|
+
expect(parseStandaloneOptions(['--data', '/data', '--state', '/state'], 'HOME', '/work/project')).toEqual({ dataRoot: '/data', stateDir: '/state' })
|
|
73
|
+
})
|
|
74
|
+
|
|
70
75
|
it('starts Pi in the current directory, creates the first isolated account, and cleans up on exit', async () => {
|
|
71
76
|
const home = temporaryDirectory('harness-standalone-')
|
|
72
77
|
const cwd = temporaryDirectory('harness-cwd-')
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { spawn as nodeSpawn } from 'node:child_process'
|
|
4
4
|
import { createHash, randomUUID } from 'node:crypto'
|
|
5
5
|
import { homedir } from 'node:os'
|
|
6
|
-
import { join, resolve } from 'node:path'
|
|
6
|
+
import { isAbsolute, join, resolve } from 'node:path'
|
|
7
7
|
|
|
8
8
|
import { createHarness, type Harness, type HarnessOptions } from '../harness'
|
|
9
9
|
import type { TerminalLaunch } from './terminals'
|
|
@@ -36,15 +36,23 @@ export function parseStandaloneOptions(argv: string[], home = homedir(), cwd = p
|
|
|
36
36
|
stateDir: join(root, 'state')
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
const given = new Set<string>()
|
|
39
40
|
for (let index = 0; index < argv.length; index += 1) {
|
|
40
41
|
const argument = argv[index]
|
|
41
42
|
if (argument !== '--data' && argument !== '--state') throw new Error(`Unknown standalone option: ${argument}`)
|
|
42
43
|
const value = argv[index + 1]
|
|
43
44
|
if (!value || value.startsWith('--')) throw new Error(`${argument} needs a directory path.`)
|
|
44
45
|
paths[argument === '--data' ? 'dataRoot' : 'stateDir'] = resolve(cwd, value)
|
|
46
|
+
given.add(argument)
|
|
45
47
|
index += 1
|
|
46
48
|
}
|
|
47
49
|
|
|
50
|
+
// A home that is not a folder path (for example HOME set to the word "HOME") would put the
|
|
51
|
+
// defaults, and the session socket, in whatever folder the command was started from.
|
|
52
|
+
if (!isAbsolute(home) && !(given.has('--data') && given.has('--state'))) {
|
|
53
|
+
throw new Error(`The home folder "${home}" is not an absolute path, so the default data and state folders cannot be placed. Set HOME to your home folder, or pass --data and --state with absolute folders.`)
|
|
54
|
+
}
|
|
55
|
+
|
|
48
56
|
return paths
|
|
49
57
|
}
|
|
50
58
|
|