@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 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((resolve9, reject) => {
893
+ prompt: (prompt2) => new Promise((resolve10, reject) => {
891
894
  const promptId = randomUUID2();
892
- login.prompts.set(promptId, { resolve: resolve9, reject });
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 resolve9 = (installing) => manager2().resolve(async (source2) => installing && shared.has(source2) ? "install" : "skip");
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(() => resolve9(true)) : await resolve9(false);
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 resolve9(false);
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((resolve9, reject) => {
6253
- settle = { resolve: resolve9, reject };
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((resolve9) => server.once("close", resolve9));
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.dataRoot);
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((resolve9) => {
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
- resolve9(decision);
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((resolve9) => {
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
- resolve9(answer);
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((resolve9) => setTimeout(resolve9, timeoutMs))]);
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((resolve9) => setTimeout(resolve9, 250));
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((resolve9) => {
8873
+ return new Promise((resolve10) => {
8871
8874
  let timer;
8872
8875
  const done = (value) => {
8873
8876
  if (timer) clearTimeout(timer);
8874
- resolve9(value);
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((resolve9, reject) => {
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(() => resolve9(reply.result));
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((resolve9) => setTimeout(resolve9, 50));
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((resolve9, reject) => {
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: resolve9, reject };
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((resolve9, reject) => {
10029
- mkdirSync12(dirname12(this.path), { recursive: true, mode: 448 });
10030
- rmSync8(this.path, { force: true });
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
- try {
10038
- chmodSync10(this.path, 384);
10039
- } catch (error) {
10040
- log("session socket permissions", error instanceof Error ? error.message : String(error));
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
- resolve9();
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((resolve9) => server.close(() => resolve9()));
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 statSync6 } from "node:fs";
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) && statSync6(source2).isDirectory())) throw invalid("That folder does not exist.");
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((resolve9, reject) => {
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
- resolve9(value);
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 statSync7 } from "node:fs";
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 (statSync7(path3).size > MAX_SOURCE_BYTES) throw invalid("Sources larger than 50 MB are not imported.");
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 stateDir = values2.state?.[0];
11455
- if (!stateDir) {
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] ?? null,
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((resolve9) => {
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(() => resolve9(0));
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.2",
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": "./dist/cli.js"
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 stateDir = values.state?.[0]
54
- if (!stateDir) {
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] ?? null,
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
+ })
@@ -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('Settings')
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
 
@@ -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.dataRoot)
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
+ })
@@ -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, one request per
4
- // connection. Each terminal session holds a random token bound to its caller, its connected-apps
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
- mkdirSync(dirname(this.path), { recursive: true, mode: 0o700 })
154
- rmSync(this.path, { force: true })
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
- try {
162
- chmodSync(this.path, 0o600)
163
- } catch (error) {
164
- log('session socket permissions', error instanceof Error ? error.message : String(error))
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