@xingular/harness 0.1.1 → 0.1.3
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 +65 -39
- package/dist/terminal-pi.js +2 -2
- package/package.json +12 -5
- 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/piExtension.ts +4 -2
- package/src/terminal/socket.test.ts +28 -0
- package/src/terminal/socket.ts +21 -2
- 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
|
});
|
|
@@ -9919,7 +9922,7 @@ var TerminalController = class {
|
|
|
9919
9922
|
|
|
9920
9923
|
// src/terminal/socket.ts
|
|
9921
9924
|
import { createHash as createHash7 } from "node:crypto";
|
|
9922
|
-
import { chmodSync as chmodSync10, mkdirSync as mkdirSync12, rmSync as rmSync8 } from "node:fs";
|
|
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";
|
|
@@ -9930,6 +9933,23 @@ function socketPath(stateDir) {
|
|
|
9930
9933
|
if (Buffer.byteLength(path3) <= MAX_SOCKET_PATH) return path3;
|
|
9931
9934
|
return join37(tmpdir(), `xh-${createHash7("sha256").update(path3).digest("hex").slice(0, 16)}.sock`);
|
|
9932
9935
|
}
|
|
9936
|
+
function refusedBind(path3, error) {
|
|
9937
|
+
const code = error?.code;
|
|
9938
|
+
if (code !== "EACCES" && code !== "EPERM") return error;
|
|
9939
|
+
let folder;
|
|
9940
|
+
try {
|
|
9941
|
+
const stat = statSync6(dirname12(path3));
|
|
9942
|
+
folder = `${dirname12(path3)} is owned by uid ${stat.uid} with mode ${(stat.mode & 511).toString(8)}`;
|
|
9943
|
+
} catch {
|
|
9944
|
+
folder = `${dirname12(path3)} could not be read`;
|
|
9945
|
+
}
|
|
9946
|
+
const uid = process.getuid?.() ?? "unknown";
|
|
9947
|
+
return new HarnessError(
|
|
9948
|
+
"session_socket_denied",
|
|
9949
|
+
`The session socket ${path3} could not be created (${code}). ${folder}; this process runs as uid ${uid}.`,
|
|
9950
|
+
"Run the harness from your own terminal, as the user that owns its state folder, or choose a state folder you own with --state."
|
|
9951
|
+
);
|
|
9952
|
+
}
|
|
9933
9953
|
var SessionSocket = class {
|
|
9934
9954
|
constructor(path3, handlers) {
|
|
9935
9955
|
this.path = path3;
|
|
@@ -10025,13 +10045,13 @@ var SessionSocket = class {
|
|
|
10025
10045
|
}
|
|
10026
10046
|
/** Binds the socket once; later calls wait for the same bind. */
|
|
10027
10047
|
listen() {
|
|
10028
|
-
this.listening ??= new Promise((
|
|
10048
|
+
this.listening ??= new Promise((resolve10, reject) => {
|
|
10029
10049
|
mkdirSync12(dirname12(this.path), { recursive: true, mode: 448 });
|
|
10030
10050
|
rmSync8(this.path, { force: true });
|
|
10031
10051
|
const server = createServer2((client) => this.serve(client));
|
|
10032
10052
|
server.once("error", (error) => {
|
|
10033
10053
|
this.listening = null;
|
|
10034
|
-
reject(error);
|
|
10054
|
+
reject(refusedBind(this.path, error));
|
|
10035
10055
|
});
|
|
10036
10056
|
server.listen(this.path, () => {
|
|
10037
10057
|
try {
|
|
@@ -10040,7 +10060,7 @@ var SessionSocket = class {
|
|
|
10040
10060
|
log("session socket permissions", error instanceof Error ? error.message : String(error));
|
|
10041
10061
|
}
|
|
10042
10062
|
this.server = server;
|
|
10043
|
-
|
|
10063
|
+
resolve10();
|
|
10044
10064
|
});
|
|
10045
10065
|
});
|
|
10046
10066
|
return this.listening;
|
|
@@ -10051,7 +10071,7 @@ var SessionSocket = class {
|
|
|
10051
10071
|
this.server = null;
|
|
10052
10072
|
this.listening = null;
|
|
10053
10073
|
for (const client of this.clients) client.destroy();
|
|
10054
|
-
if (server) await new Promise((
|
|
10074
|
+
if (server) await new Promise((resolve10) => server.close(() => resolve10()));
|
|
10055
10075
|
rmSync8(this.path, { force: true });
|
|
10056
10076
|
}
|
|
10057
10077
|
};
|
|
@@ -10558,7 +10578,7 @@ import { join as join41, resolve as resolve7 } from "node:path";
|
|
|
10558
10578
|
import { createInterface } from "node:readline/promises";
|
|
10559
10579
|
|
|
10560
10580
|
// src/plugins/install.ts
|
|
10561
|
-
import { existsSync as existsSync15, mkdirSync as mkdirSync15, renameSync as renameSync8, statSync as
|
|
10581
|
+
import { existsSync as existsSync15, mkdirSync as mkdirSync15, renameSync as renameSync8, statSync as statSync7 } from "node:fs";
|
|
10562
10582
|
import { homedir as homedir4 } from "node:os";
|
|
10563
10583
|
import { basename as basename8, isAbsolute as isAbsolute5, join as join40, resolve as resolve6 } from "node:path";
|
|
10564
10584
|
function normalizeSource(input) {
|
|
@@ -10578,7 +10598,7 @@ async function manager(harness) {
|
|
|
10578
10598
|
var changed = (harness) => harness.bus.emit({ type: "plugins.changed" });
|
|
10579
10599
|
async function install2(harness, params) {
|
|
10580
10600
|
const source2 = normalizeSource(params.source);
|
|
10581
|
-
if (isAbsolute5(source2) && !(existsSync15(source2) &&
|
|
10601
|
+
if (isAbsolute5(source2) && !(existsSync15(source2) && statSync7(source2).isDirectory())) throw invalid("That folder does not exist.");
|
|
10582
10602
|
await ensureAdopted(harness);
|
|
10583
10603
|
const before = new Set((await list2(harness, params)).map((plugin) => plugin.id));
|
|
10584
10604
|
const { settings, packages } = await manager(harness);
|
|
@@ -10888,7 +10908,7 @@ var RpcPeer = class {
|
|
|
10888
10908
|
/** Asks the host to perform a named operation. */
|
|
10889
10909
|
callHost(call, params, timeoutMs = 3e4) {
|
|
10890
10910
|
const id = `s${++this.nextCallId}`;
|
|
10891
|
-
return new Promise((
|
|
10911
|
+
return new Promise((resolve10, reject) => {
|
|
10892
10912
|
const timer = setTimeout(() => {
|
|
10893
10913
|
this.pendingCalls.delete(id);
|
|
10894
10914
|
reject(new HarnessError("host_timeout", `The host did not answer ${call}.`));
|
|
@@ -10896,7 +10916,7 @@ var RpcPeer = class {
|
|
|
10896
10916
|
this.pendingCalls.set(id, {
|
|
10897
10917
|
resolve: (value) => {
|
|
10898
10918
|
clearTimeout(timer);
|
|
10899
|
-
|
|
10919
|
+
resolve10(value);
|
|
10900
10920
|
},
|
|
10901
10921
|
reject: (error) => {
|
|
10902
10922
|
clearTimeout(timer);
|
|
@@ -10947,7 +10967,7 @@ var RpcPeer = class {
|
|
|
10947
10967
|
};
|
|
10948
10968
|
|
|
10949
10969
|
// src/server/methods.ts
|
|
10950
|
-
import { readFileSync as readFileSync20, statSync as
|
|
10970
|
+
import { readFileSync as readFileSync20, statSync as statSync8 } from "node:fs";
|
|
10951
10971
|
import { basename as basename9 } from "node:path";
|
|
10952
10972
|
|
|
10953
10973
|
// src/memory/graph.ts
|
|
@@ -11161,7 +11181,7 @@ function methods(harness) {
|
|
|
11161
11181
|
"wiki.sources": () => listSources(wikiDir2()),
|
|
11162
11182
|
"wiki.ingest": (params) => {
|
|
11163
11183
|
const path3 = text5(params, "path");
|
|
11164
|
-
if (
|
|
11184
|
+
if (statSync8(path3).size > MAX_SOURCE_BYTES) throw invalid("Sources larger than 50 MB are not imported.");
|
|
11165
11185
|
const source2 = maintenance.ingest(optional2(params, "name") ?? basename9(path3), readFileSync20(path3), optional2(params, "namespace") ?? LIBRARY_NAMESPACE);
|
|
11166
11186
|
harness.bus.emit({ type: "wiki.changed" });
|
|
11167
11187
|
return source2;
|
|
@@ -11327,7 +11347,7 @@ function attach3(harness, peer) {
|
|
|
11327
11347
|
import { spawn as nodeSpawn } from "node:child_process";
|
|
11328
11348
|
import { createHash as createHash8, randomUUID as randomUUID8 } from "node:crypto";
|
|
11329
11349
|
import { homedir as homedir6 } from "node:os";
|
|
11330
|
-
import { join as join42, resolve as resolve8 } from "node:path";
|
|
11350
|
+
import { isAbsolute as isAbsolute6, join as join42, resolve as resolve8 } from "node:path";
|
|
11331
11351
|
function standaloneArguments(argv) {
|
|
11332
11352
|
const command = argv[0];
|
|
11333
11353
|
if (!command || command.startsWith("--") || command === "-h") return argv;
|
|
@@ -11339,14 +11359,19 @@ function parseStandaloneOptions(argv, home = homedir6(), cwd = process.cwd()) {
|
|
|
11339
11359
|
dataRoot: join42(root, "data"),
|
|
11340
11360
|
stateDir: join42(root, "state")
|
|
11341
11361
|
};
|
|
11362
|
+
const given = /* @__PURE__ */ new Set();
|
|
11342
11363
|
for (let index = 0; index < argv.length; index += 1) {
|
|
11343
11364
|
const argument = argv[index];
|
|
11344
11365
|
if (argument !== "--data" && argument !== "--state") throw new Error(`Unknown standalone option: ${argument}`);
|
|
11345
11366
|
const value = argv[index + 1];
|
|
11346
11367
|
if (!value || value.startsWith("--")) throw new Error(`${argument} needs a directory path.`);
|
|
11347
11368
|
paths[argument === "--data" ? "dataRoot" : "stateDir"] = resolve8(cwd, value);
|
|
11369
|
+
given.add(argument);
|
|
11348
11370
|
index += 1;
|
|
11349
11371
|
}
|
|
11372
|
+
if (!isAbsolute6(home) && !(given.has("--data") && given.has("--state"))) {
|
|
11373
|
+
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.`);
|
|
11374
|
+
}
|
|
11350
11375
|
return paths;
|
|
11351
11376
|
}
|
|
11352
11377
|
function processEnvironment(overrides) {
|
|
@@ -11451,15 +11476,16 @@ function flags(argv) {
|
|
|
11451
11476
|
}
|
|
11452
11477
|
function serveCommand(argv) {
|
|
11453
11478
|
const { values: values2, switches } = flags(argv);
|
|
11454
|
-
const
|
|
11455
|
-
if (!
|
|
11479
|
+
const stateArgument = values2.state?.[0];
|
|
11480
|
+
if (!stateArgument) {
|
|
11456
11481
|
process.stderr.write("serve needs --state <dir>.\n");
|
|
11457
11482
|
return Promise.resolve(2);
|
|
11458
11483
|
}
|
|
11484
|
+
const stateDir = resolve9(stateArgument);
|
|
11459
11485
|
const peer = new RpcPeer(stdioTransport());
|
|
11460
11486
|
const options2 = {
|
|
11461
11487
|
stateDir,
|
|
11462
|
-
dataRoot: values2.data?.[0]
|
|
11488
|
+
dataRoot: values2.data?.[0] ? resolve9(values2.data[0]) : null,
|
|
11463
11489
|
productName: values2.name?.[0],
|
|
11464
11490
|
builtins: values2.builtins,
|
|
11465
11491
|
...switches.has("host-secrets") ? { secrets: remoteSecrets(peer) } : {},
|
|
@@ -11469,12 +11495,12 @@ function serveCommand(argv) {
|
|
|
11469
11495
|
const harness = createHarness(options2);
|
|
11470
11496
|
if (switches.has("host-tools")) harness.sessions.provide(remoteTools(peer));
|
|
11471
11497
|
attach3(harness, peer);
|
|
11472
|
-
return new Promise((
|
|
11498
|
+
return new Promise((resolve10) => {
|
|
11473
11499
|
let closing = false;
|
|
11474
11500
|
const shutdown = () => {
|
|
11475
11501
|
if (closing) return;
|
|
11476
11502
|
closing = true;
|
|
11477
|
-
void Promise.race([harness.close(), new Promise((done) => setTimeout(done, 5e3))]).then(() =>
|
|
11503
|
+
void Promise.race([harness.close(), new Promise((done) => setTimeout(done, 5e3))]).then(() => resolve10(0));
|
|
11478
11504
|
};
|
|
11479
11505
|
peer.handle("harness.shutdown", () => void setImmediate(shutdown));
|
|
11480
11506
|
process.stdin.on("end", shutdown);
|
package/dist/terminal-pi.js
CHANGED
|
@@ -202,7 +202,7 @@ async function piExtension_default(pi) {
|
|
|
202
202
|
if (command?.type === "interrupt") context?.abort();
|
|
203
203
|
else if (command?.type === "send") {
|
|
204
204
|
if (context?.ui.getEditorText().trim()) throw new Error("The terminal contains a user draft.");
|
|
205
|
-
pi.sendUserMessage(command.text);
|
|
205
|
+
pi.sendUserMessage(command.text, context?.isIdle() === false ? { deliverAs: "followUp" } : void 0);
|
|
206
206
|
}
|
|
207
207
|
}).catch(() => {
|
|
208
208
|
context?.abort();
|
|
@@ -218,7 +218,7 @@ async function piExtension_default(pi) {
|
|
|
218
218
|
usage = emptyUsage();
|
|
219
219
|
reasoningTokens = void 0;
|
|
220
220
|
costReported = false;
|
|
221
|
-
await terminalCall("begin", { text: event.prompt });
|
|
221
|
+
await terminalCall("begin", { text: event.messageType === "user" ? event.prompt : "" });
|
|
222
222
|
});
|
|
223
223
|
pi.on("message_end", async (event) => {
|
|
224
224
|
if (event.message.role !== "assistant") return;
|
package/package.json
CHANGED
|
@@ -1,14 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xingular/harness",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "Xingular Harness
|
|
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",
|
|
@@ -18,7 +25,7 @@
|
|
|
18
25
|
"prove": "npm run build && node scripts/prove-standalone.mjs"
|
|
19
26
|
},
|
|
20
27
|
"dependencies": {
|
|
21
|
-
"@earendil-works/pi-coding-agent": "1.
|
|
28
|
+
"@earendil-works/pi-coding-agent": "1.2.0",
|
|
22
29
|
"@modelcontextprotocol/client": "^2.2.0",
|
|
23
30
|
"@modelcontextprotocol/server": "^2.2.0",
|
|
24
31
|
"typebox": "^1.3.7",
|
|
@@ -32,7 +39,7 @@
|
|
|
32
39
|
"vitest": "^3.2.4"
|
|
33
40
|
},
|
|
34
41
|
"engines": {
|
|
35
|
-
"node": ">=22.
|
|
42
|
+
"node": ">=22.19"
|
|
36
43
|
},
|
|
37
44
|
"files": [
|
|
38
45
|
"src",
|
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),
|
|
@@ -86,7 +86,8 @@ export default async function (pi: ExtensionAPI) {
|
|
|
86
86
|
if (command?.type === 'interrupt') context?.abort()
|
|
87
87
|
else if (command?.type === 'send') {
|
|
88
88
|
if (context?.ui.getEditorText().trim()) throw new Error('The terminal contains a user draft.')
|
|
89
|
-
|
|
89
|
+
// Pi 1.2 needs deliverAs while a run is busy; a message sent then waits for that run to finish.
|
|
90
|
+
pi.sendUserMessage(command.text!, context?.isIdle() === false ? { deliverAs: 'followUp' } : undefined)
|
|
90
91
|
}
|
|
91
92
|
})
|
|
92
93
|
.catch(() => {
|
|
@@ -104,7 +105,8 @@ export default async function (pi: ExtensionAPI) {
|
|
|
104
105
|
usage = emptyUsage()
|
|
105
106
|
reasoningTokens = undefined
|
|
106
107
|
costReported = false
|
|
107
|
-
|
|
108
|
+
// A run a plugin starts with a custom message has no user prompt to record; it still begins a turn.
|
|
109
|
+
await terminalCall('begin', { text: event.messageType === 'user' ? event.prompt : '' })
|
|
108
110
|
})
|
|
109
111
|
pi.on('message_end', async (event) => {
|
|
110
112
|
if (event.message.role !== 'assistant') return
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { chmodSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { describe, expect, it } from 'vitest'
|
|
5
|
+
|
|
6
|
+
import { SessionSocket } from './socket'
|
|
7
|
+
|
|
8
|
+
const handlers = { memory: () => '', apps: async () => null }
|
|
9
|
+
|
|
10
|
+
describe('session socket', () => {
|
|
11
|
+
it('names the folder and the user when the operating system refuses to create the socket', async () => {
|
|
12
|
+
// Root can write anywhere, so the refusal cannot be produced there.
|
|
13
|
+
if (process.platform === 'win32' || process.getuid?.() === 0) return
|
|
14
|
+
const base = mkdtempSync(join(tmpdir(), 'sock-'))
|
|
15
|
+
const run = join(base, 'run')
|
|
16
|
+
mkdirSync(run)
|
|
17
|
+
chmodSync(run, 0o500)
|
|
18
|
+
try {
|
|
19
|
+
await expect(new SessionSocket(join(run, 'harness.sock'), handlers).listen()).rejects.toMatchObject({
|
|
20
|
+
code: 'session_socket_denied',
|
|
21
|
+
message: expect.stringMatching(/could not be created \((?:EACCES|EPERM)\)\. .*mode 500; this process runs as uid \d+\./)
|
|
22
|
+
})
|
|
23
|
+
} finally {
|
|
24
|
+
chmodSync(run, 0o700)
|
|
25
|
+
rmSync(base, { recursive: true, force: true })
|
|
26
|
+
}
|
|
27
|
+
})
|
|
28
|
+
})
|
package/src/terminal/socket.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// connection. Each terminal session holds a random token bound to its caller, its connected-apps
|
|
5
5
|
// grant, and its controller; the token stops working when the terminal ends.
|
|
6
6
|
import { createHash } from 'node:crypto'
|
|
7
|
-
import { chmodSync, mkdirSync, rmSync } from 'node:fs'
|
|
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'
|
|
@@ -48,6 +48,25 @@ export function socketPath(stateDir: string): string {
|
|
|
48
48
|
return join(tmpdir(), `xh-${createHash('sha256').update(path).digest('hex').slice(0, 16)}.sock`)
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
/** Names the folder and the user when the operating system refuses the socket, so the cause shows. */
|
|
52
|
+
function refusedBind(path: string, error: unknown): unknown {
|
|
53
|
+
const code = (error as { code?: unknown } | null)?.code
|
|
54
|
+
if (code !== 'EACCES' && code !== 'EPERM') return error
|
|
55
|
+
let folder: string
|
|
56
|
+
try {
|
|
57
|
+
const stat = statSync(dirname(path))
|
|
58
|
+
folder = `${dirname(path)} is owned by uid ${stat.uid} with mode ${(stat.mode & 0o777).toString(8)}`
|
|
59
|
+
} catch {
|
|
60
|
+
folder = `${dirname(path)} could not be read`
|
|
61
|
+
}
|
|
62
|
+
const uid = process.getuid?.() ?? 'unknown'
|
|
63
|
+
return new HarnessError(
|
|
64
|
+
'session_socket_denied',
|
|
65
|
+
`The session socket ${path} could not be created (${code}). ${folder}; this process runs as uid ${uid}.`,
|
|
66
|
+
'Run the harness from your own terminal, as the user that owns its state folder, or choose a state folder you own with --state.'
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
|
|
51
70
|
export class SessionSocket {
|
|
52
71
|
private server: Server | null = null
|
|
53
72
|
private listening: Promise<void> | null = null
|
|
@@ -155,7 +174,7 @@ export class SessionSocket {
|
|
|
155
174
|
const server = createServer((client) => this.serve(client))
|
|
156
175
|
server.once('error', (error) => {
|
|
157
176
|
this.listening = null
|
|
158
|
-
reject(error)
|
|
177
|
+
reject(refusedBind(this.path, error))
|
|
159
178
|
})
|
|
160
179
|
server.listen(this.path, () => {
|
|
161
180
|
try {
|
|
@@ -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
|
|