@xingular/harness 0.1.3 → 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
@@ -9921,7 +9921,7 @@ var TerminalController = class {
9921
9921
  };
9922
9922
 
9923
9923
  // src/terminal/socket.ts
9924
- import { createHash as createHash7 } from "node:crypto";
9924
+ import { createHash as createHash7, randomBytes } from "node:crypto";
9925
9925
  import { chmodSync as chmodSync10, mkdirSync as mkdirSync12, rmSync as rmSync8, statSync as statSync6 } from "node:fs";
9926
9926
  import { createServer as createServer2 } from "node:net";
9927
9927
  import { tmpdir } from "node:os";
@@ -9929,13 +9929,22 @@ import { dirname as dirname12, join as join37 } from "node:path";
9929
9929
  var MAX_REQUEST_BYTES = 1024 * 1024;
9930
9930
  var MAX_SOCKET_PATH = 100;
9931
9931
  function socketPath(stateDir) {
9932
+ if (process.platform === "win32") return `\\\\.\\pipe\\xingular-harness-${randomBytes(12).toString("hex")}`;
9932
9933
  const path3 = join37(stateDir, "run", "harness.sock");
9933
9934
  if (Buffer.byteLength(path3) <= MAX_SOCKET_PATH) return path3;
9934
9935
  return join37(tmpdir(), `xh-${createHash7("sha256").update(path3).digest("hex").slice(0, 16)}.sock`);
9935
9936
  }
9937
+ var isPipePath = (path3) => path3.startsWith("\\\\.\\pipe\\");
9936
9938
  function refusedBind(path3, error) {
9937
9939
  const code = error?.code;
9938
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
+ }
9939
9948
  let folder;
9940
9949
  try {
9941
9950
  const stat = statSync6(dirname12(path3));
@@ -10046,18 +10055,22 @@ var SessionSocket = class {
10046
10055
  /** Binds the socket once; later calls wait for the same bind. */
10047
10056
  listen() {
10048
10057
  this.listening ??= new Promise((resolve10, reject) => {
10049
- mkdirSync12(dirname12(this.path), { recursive: true, mode: 448 });
10050
- rmSync8(this.path, { force: true });
10058
+ if (!isPipePath(this.path)) {
10059
+ mkdirSync12(dirname12(this.path), { recursive: true, mode: 448 });
10060
+ rmSync8(this.path, { force: true });
10061
+ }
10051
10062
  const server = createServer2((client) => this.serve(client));
10052
10063
  server.once("error", (error) => {
10053
10064
  this.listening = null;
10054
10065
  reject(refusedBind(this.path, error));
10055
10066
  });
10056
10067
  server.listen(this.path, () => {
10057
- try {
10058
- chmodSync10(this.path, 384);
10059
- } catch (error) {
10060
- 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
+ }
10061
10074
  }
10062
10075
  this.server = server;
10063
10076
  resolve10();
@@ -10072,7 +10085,7 @@ var SessionSocket = class {
10072
10085
  this.listening = null;
10073
10086
  for (const client of this.clients) client.destroy();
10074
10087
  if (server) await new Promise((resolve10) => server.close(() => resolve10()));
10075
- rmSync8(this.path, { force: true });
10088
+ if (!isPipePath(this.path)) rmSync8(this.path, { force: true });
10076
10089
  }
10077
10090
  };
10078
10091
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xingular/harness",
3
- "version": "0.1.3",
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
6
  "repository": {
@@ -1,13 +1,56 @@
1
1
  import { chmodSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs'
2
+ import { createConnection } from 'node:net'
2
3
  import { tmpdir } from 'node:os'
3
4
  import { join } from 'node:path'
4
5
  import { describe, expect, it } from 'vitest'
5
6
 
6
- import { SessionSocket } from './socket'
7
+ import { SessionSocket, socketPath } from './socket'
7
8
 
8
9
  const handlers = { memory: () => '', apps: async () => null }
9
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
+
10
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
+
11
54
  it('names the folder and the user when the operating system refuses to create the socket', async () => {
12
55
  // Root can write anywhere, so the refusal cannot be produced there.
13
56
  if (process.platform === 'win32' || process.getuid?.() === 0) return
@@ -1,9 +1,9 @@
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'
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
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'
@@ -42,16 +42,32 @@ 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
+
51
60
  /** Names the folder and the user when the operating system refuses the socket, so the cause shows. */
52
61
  function refusedBind(path: string, error: unknown): unknown {
53
62
  const code = (error as { code?: unknown } | null)?.code
54
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
+ }
55
71
  let folder: string
56
72
  try {
57
73
  const stat = statSync(dirname(path))
@@ -169,18 +185,22 @@ export class SessionSocket {
169
185
  /** Binds the socket once; later calls wait for the same bind. */
170
186
  listen(): Promise<void> {
171
187
  this.listening ??= new Promise<void>((resolve, reject) => {
172
- mkdirSync(dirname(this.path), { recursive: true, mode: 0o700 })
173
- 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
+ }
174
192
  const server = createServer((client) => this.serve(client))
175
193
  server.once('error', (error) => {
176
194
  this.listening = null
177
195
  reject(refusedBind(this.path, error))
178
196
  })
179
197
  server.listen(this.path, () => {
180
- try {
181
- chmodSync(this.path, 0o600)
182
- } catch (error) {
183
- 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
+ }
184
204
  }
185
205
  this.server = server
186
206
  resolve()
@@ -196,6 +216,6 @@ export class SessionSocket {
196
216
  this.listening = null
197
217
  for (const client of this.clients) client.destroy()
198
218
  if (server) await new Promise<void>((resolve) => server.close(() => resolve()))
199
- rmSync(this.path, { force: true })
219
+ if (!isPipePath(this.path)) rmSync(this.path, { force: true })
200
220
  }
201
221
  }