@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 +21 -8
- package/package.json +1 -1
- package/src/terminal/socket.test.ts +44 -1
- package/src/terminal/socket.ts +31 -11
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
|
-
|
|
10050
|
-
|
|
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
|
-
|
|
10058
|
-
|
|
10059
|
-
|
|
10060
|
-
|
|
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
|
+
"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
|
package/src/terminal/socket.ts
CHANGED
|
@@ -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,
|
|
4
|
-
// connection. Each terminal session holds a random token bound to its caller, its
|
|
5
|
-
// grant, and its controller; the token stops working when the terminal ends.
|
|
6
|
-
import { createHash } from 'node:crypto'
|
|
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
|
-
|
|
173
|
-
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
}
|