@demicodes/host-local 0.1.0 → 0.2.1
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/index.d.mts +94 -3
- package/dist/index.mjs +349 -11
- package/package.json +6 -4
package/dist/index.d.mts
CHANGED
|
@@ -1,5 +1,50 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import { AgentHarness, AgentServer, AgentServerSessionOptions } from "@demicodes/agent";
|
|
2
|
+
import { resolveDemiHome } from "@demicodes/provider/credentials-pool";
|
|
3
|
+
import { BashEnvironmentOptions, Host, HostFileSystem, HostProcess, HostStore } from "@demicodes/shell";
|
|
4
|
+
import { Provider } from "@demicodes/provider";
|
|
5
|
+
//#region src/command-bridge.d.ts
|
|
6
|
+
/**
|
|
7
|
+
* Dispatch script shared by every generated command-name symlink.
|
|
8
|
+
* Reads the invoked name from the symlink path, applies the stdin grace
|
|
9
|
+
* contract from docs/command-bridge.md, and POSTs /run on the UDS socket.
|
|
10
|
+
*/
|
|
11
|
+
declare const COMMAND_BRIDGE_SHIM_SOURCE = "#!/usr/bin/env node\nconst { request } = require('node:http')\nconst { basename } = require('node:path')\n\n// Stdin policy (docs/command-bridge.md): if no byte arrives within the grace\n// window, proceed with empty stdin. Once any byte arrives, clear the timer\n// and read until EOF with no time cap. Late data after empty-stdin dispatch\n// is reported on stderr and not pretended to have been delivered.\nconst STDIN_GRACE_MS = 300\n\nfunction readStdin() {\n if (process.stdin.isTTY) return Promise.resolve('')\n return new Promise((resolve, reject) => {\n const chunks = []\n let resolved = false\n let timedOut = false\n let timer\n const cleanup = () => {\n clearTimeout(timer)\n process.stdin.removeListener('data', onData)\n process.stdin.removeListener('end', onEnd)\n process.stdin.removeListener('error', onError)\n }\n const onData = (chunk) => {\n if (timedOut) {\n process.stderr.write(\n 'command bridge: ' + chunk.length + ' byte(s) of stdin arrived after the ' + STDIN_GRACE_MS +\n 'ms grace period elapsed; the command already ran with empty stdin\\n',\n )\n cleanup()\n return\n }\n clearTimeout(timer)\n chunks.push(chunk)\n }\n const onEnd = () => {\n if (timedOut) {\n cleanup()\n return\n }\n resolved = true\n cleanup()\n resolve(Buffer.concat(chunks).toString('utf8'))\n }\n const onError = (error) => {\n if (resolved || timedOut) return\n resolved = true\n cleanup()\n reject(error)\n }\n timer = setTimeout(() => {\n timedOut = true\n resolved = true\n resolve('')\n }, STDIN_GRACE_MS)\n process.stdin.on('data', onData)\n process.stdin.on('end', onEnd)\n process.stdin.on('error', onError)\n })\n}\n\nfunction postRun(socketPath, body) {\n return new Promise((resolve, reject) => {\n const req = request(\n { socketPath, path: '/run', method: 'POST', headers: { 'content-type': 'application/json' } },\n (res) => {\n const chunks = []\n res.on('data', (chunk) => chunks.push(chunk))\n res.on('end', () => resolve({ statusCode: res.statusCode || 0, body: Buffer.concat(chunks).toString('utf8') }))\n },\n )\n req.on('error', reject)\n req.end(body)\n })\n}\n\nasync function main() {\n const socketPath = process.env.DEMI_COMMAND_BRIDGE_SOCK\n // Shell sessions export DEMI_SESSION_ID as the command scope id (the agent\n // session id for agent-owned shells).\n const commandScopeId = process.env.DEMI_SESSION_ID\n if (!socketPath || !commandScopeId) {\n process.stderr.write('command bridge: DEMI_COMMAND_BRIDGE_SOCK / session id not set in this shell\\n')\n process.exit(1)\n }\n const name = basename(process.argv[1] || '')\n const args = process.argv.slice(2)\n const stdin = await readStdin()\n const body = JSON.stringify({ commandScopeId, name, args, cwd: process.cwd(), stdin })\n\n const response = await postRun(socketPath, body)\n if (response.statusCode !== 200) {\n let message = response.body\n try {\n message = JSON.parse(response.body).error || message\n } catch {}\n process.stderr.write('command bridge: ' + message + '\\n')\n process.exit(1)\n }\n const result = JSON.parse(response.body)\n if (result.stdout) {\n // Binary final streams arrive base64-encoded; write raw bytes so external\n // pipes (demi read a.png | ffmpeg -i - ...) stay byte-clean.\n process.stdout.write(result.stdoutEncoding === 'base64' ? Buffer.from(result.stdout, 'base64') : result.stdout)\n }\n if (result.stderr) process.stderr.write(result.stderr)\n process.exit(result.exitCode)\n}\n\nmain().catch((error) => {\n process.stderr.write('command bridge: ' + (error && error.message ? error.message : String(error)) + '\\n')\n process.exit(1)\n})\n";
|
|
12
|
+
interface CommandBridgeOptions {
|
|
13
|
+
socketPath: string;
|
|
14
|
+
}
|
|
15
|
+
interface CommandBridgeHandle {
|
|
16
|
+
close(): Promise<void>;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Starts the process-wide UDS listener for command-bridge shims.
|
|
20
|
+
* Each POST /run is dispatched to `AgentServer.runCommandLine`.
|
|
21
|
+
*/
|
|
22
|
+
declare function startCommandBridge(server: AgentServer, options: CommandBridgeOptions): CommandBridgeHandle;
|
|
23
|
+
//#endregion
|
|
24
|
+
//#region src/command-bridge-shim.d.ts
|
|
25
|
+
interface MaterializeCommandBridgeShimsOptions {
|
|
26
|
+
host: Host;
|
|
27
|
+
agentSessionId: string;
|
|
28
|
+
commandNames: readonly string[];
|
|
29
|
+
shimSource: string;
|
|
30
|
+
/**
|
|
31
|
+
* Absolute Demi state root (`~/.demi` or `$DEMI_HOME` / `stateDir`).
|
|
32
|
+
* Shims always go to `<stateDir>/bridge-bin/<agentSessionId>/`.
|
|
33
|
+
*/
|
|
34
|
+
stateDir: string;
|
|
35
|
+
}
|
|
36
|
+
/** Fixed relative layout under stateDir. */
|
|
37
|
+
declare function bridgeBinDirFor(stateDir: string): string;
|
|
38
|
+
/**
|
|
39
|
+
* Writes the command bridge shim directory for one session and returns its
|
|
40
|
+
* resolved absolute path for PATH injection. Idempotent: safe on every open().
|
|
41
|
+
*/
|
|
42
|
+
declare function materializeCommandBridgeShims(options: MaterializeCommandBridgeShimsOptions): Promise<string>;
|
|
43
|
+
//#endregion
|
|
44
|
+
//#region src/demi-home.d.ts
|
|
45
|
+
/** Fixed layout: `<stateDir>/bridges/<id>.sock` (short id for macOS AF_UNIX limits). */
|
|
46
|
+
declare function defaultBridgeSocketPath(stateDir: string, serverId?: string): string;
|
|
47
|
+
//#endregion
|
|
3
48
|
//#region src/local-host.d.ts
|
|
4
49
|
interface LocalHostOptions {
|
|
5
50
|
storeRoot?: string;
|
|
@@ -12,4 +57,50 @@ declare class LocalHost implements Host {
|
|
|
12
57
|
constructor(defaultCwd: string, options?: LocalHostOptions);
|
|
13
58
|
}
|
|
14
59
|
//#endregion
|
|
15
|
-
|
|
60
|
+
//#region src/local-agent-server.d.ts
|
|
61
|
+
interface CreateLocalAgentServerOptions {
|
|
62
|
+
/** Same LocalHost instance the harness was built with. */
|
|
63
|
+
host: LocalHost;
|
|
64
|
+
agent: AgentHarness<unknown>;
|
|
65
|
+
providers: Provider[];
|
|
66
|
+
shell?: Omit<BashEnvironmentOptions, 'host' | 'commands'>;
|
|
67
|
+
session?: AgentServerSessionOptions;
|
|
68
|
+
/**
|
|
69
|
+
* Command bridge for real OS subprocesses. **Default true** (open box).
|
|
70
|
+
* Pass `false` only to disable.
|
|
71
|
+
*/
|
|
72
|
+
commandBridge?: boolean;
|
|
73
|
+
/**
|
|
74
|
+
* Demi state root (default `$DEMI_HOME` or `~/.demi`).
|
|
75
|
+
* Bridge socket and `bridge-bin/` always live under this tree — never under workspace cwd.
|
|
76
|
+
* Layout is fixed: `bridges/*.sock`, `bridge-bin/<sessionId>/`.
|
|
77
|
+
*/
|
|
78
|
+
stateDir?: string;
|
|
79
|
+
/** Override UDS path when bridge is enabled (default under `stateDir/bridges/`). */
|
|
80
|
+
commandBridgeSocketPath?: string;
|
|
81
|
+
}
|
|
82
|
+
interface LocalAgentServerHandle {
|
|
83
|
+
server: AgentServer;
|
|
84
|
+
host: LocalHost;
|
|
85
|
+
/** Resolved state root used for bridge artifacts when bridge is enabled. */
|
|
86
|
+
stateDir: string | null;
|
|
87
|
+
/** Stops the bridge listener (if any) then closes the server. */
|
|
88
|
+
close(): Promise<void>;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Open-box local agent assembly: `LocalHost` + `AgentServer` with command
|
|
92
|
+
* bridge **on by default**.
|
|
93
|
+
*
|
|
94
|
+
* Owns the Node-only bridge transport (UDS + PATH shims under `stateDir`).
|
|
95
|
+
* AgentServer only receives a generic `prepareSessionShell` hook and exposes
|
|
96
|
+
* `runCommandLine` — it never sees bin dirs or sockets.
|
|
97
|
+
*
|
|
98
|
+
* Default layout under `stateDir` (`~/.demi` or `$DEMI_HOME`):
|
|
99
|
+
* ```
|
|
100
|
+
* bridges/<id>.sock
|
|
101
|
+
* bridge-bin/<sessionId>/…
|
|
102
|
+
* ```
|
|
103
|
+
*/
|
|
104
|
+
declare function createLocalAgentServer(options: CreateLocalAgentServerOptions): LocalAgentServerHandle;
|
|
105
|
+
//#endregion
|
|
106
|
+
export { COMMAND_BRIDGE_SHIM_SOURCE, CommandBridgeHandle, CommandBridgeOptions, CreateLocalAgentServerOptions, LocalAgentServerHandle, LocalHost, LocalHostOptions, MaterializeCommandBridgeShimsOptions, bridgeBinDirFor, createLocalAgentServer, defaultBridgeSocketPath, materializeCommandBridgeShims, resolveDemiHome, startCommandBridge };
|
package/dist/index.mjs
CHANGED
|
@@ -1,9 +1,273 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, unlinkSync } from "node:fs";
|
|
2
|
+
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
3
|
+
import { createServer } from "node:http";
|
|
4
|
+
import { AgentServer, RunCommandLineCommandNotRegisteredError, RunCommandLineSessionNotFoundError, RunCommandLineTimeoutError } from "@demicodes/agent";
|
|
5
|
+
import { encodeUtf8, errorMessage, isFileNotFoundError, parsePortableJson, stringifyPortableJson } from "@demicodes/utils";
|
|
6
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
7
|
+
import { resolveDemiHome } from "@demicodes/provider/credentials-pool";
|
|
1
8
|
import { spawn } from "node:child_process";
|
|
2
|
-
import { createHash } from "node:crypto";
|
|
3
9
|
import { homedir } from "node:os";
|
|
4
|
-
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
5
10
|
import { appendFile, chmod, cp, link, lstat, mkdir, readFile, readdir, readlink, realpath, rename, rm, stat, symlink, utimes, writeFile } from "node:fs/promises";
|
|
6
|
-
|
|
11
|
+
//#region src/command-bridge.ts
|
|
12
|
+
/**
|
|
13
|
+
* Dispatch script shared by every generated command-name symlink.
|
|
14
|
+
* Reads the invoked name from the symlink path, applies the stdin grace
|
|
15
|
+
* contract from docs/command-bridge.md, and POSTs /run on the UDS socket.
|
|
16
|
+
*/
|
|
17
|
+
const COMMAND_BRIDGE_SHIM_SOURCE = `#!/usr/bin/env node
|
|
18
|
+
const { request } = require('node:http')
|
|
19
|
+
const { basename } = require('node:path')
|
|
20
|
+
|
|
21
|
+
// Stdin policy (docs/command-bridge.md): if no byte arrives within the grace
|
|
22
|
+
// window, proceed with empty stdin. Once any byte arrives, clear the timer
|
|
23
|
+
// and read until EOF with no time cap. Late data after empty-stdin dispatch
|
|
24
|
+
// is reported on stderr and not pretended to have been delivered.
|
|
25
|
+
const STDIN_GRACE_MS = 300
|
|
26
|
+
|
|
27
|
+
function readStdin() {
|
|
28
|
+
if (process.stdin.isTTY) return Promise.resolve('')
|
|
29
|
+
return new Promise((resolve, reject) => {
|
|
30
|
+
const chunks = []
|
|
31
|
+
let resolved = false
|
|
32
|
+
let timedOut = false
|
|
33
|
+
let timer
|
|
34
|
+
const cleanup = () => {
|
|
35
|
+
clearTimeout(timer)
|
|
36
|
+
process.stdin.removeListener('data', onData)
|
|
37
|
+
process.stdin.removeListener('end', onEnd)
|
|
38
|
+
process.stdin.removeListener('error', onError)
|
|
39
|
+
}
|
|
40
|
+
const onData = (chunk) => {
|
|
41
|
+
if (timedOut) {
|
|
42
|
+
process.stderr.write(
|
|
43
|
+
'command bridge: ' + chunk.length + ' byte(s) of stdin arrived after the ' + STDIN_GRACE_MS +
|
|
44
|
+
'ms grace period elapsed; the command already ran with empty stdin\\n',
|
|
45
|
+
)
|
|
46
|
+
cleanup()
|
|
47
|
+
return
|
|
48
|
+
}
|
|
49
|
+
clearTimeout(timer)
|
|
50
|
+
chunks.push(chunk)
|
|
51
|
+
}
|
|
52
|
+
const onEnd = () => {
|
|
53
|
+
if (timedOut) {
|
|
54
|
+
cleanup()
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
resolved = true
|
|
58
|
+
cleanup()
|
|
59
|
+
resolve(Buffer.concat(chunks).toString('utf8'))
|
|
60
|
+
}
|
|
61
|
+
const onError = (error) => {
|
|
62
|
+
if (resolved || timedOut) return
|
|
63
|
+
resolved = true
|
|
64
|
+
cleanup()
|
|
65
|
+
reject(error)
|
|
66
|
+
}
|
|
67
|
+
timer = setTimeout(() => {
|
|
68
|
+
timedOut = true
|
|
69
|
+
resolved = true
|
|
70
|
+
resolve('')
|
|
71
|
+
}, STDIN_GRACE_MS)
|
|
72
|
+
process.stdin.on('data', onData)
|
|
73
|
+
process.stdin.on('end', onEnd)
|
|
74
|
+
process.stdin.on('error', onError)
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function postRun(socketPath, body) {
|
|
79
|
+
return new Promise((resolve, reject) => {
|
|
80
|
+
const req = request(
|
|
81
|
+
{ socketPath, path: '/run', method: 'POST', headers: { 'content-type': 'application/json' } },
|
|
82
|
+
(res) => {
|
|
83
|
+
const chunks = []
|
|
84
|
+
res.on('data', (chunk) => chunks.push(chunk))
|
|
85
|
+
res.on('end', () => resolve({ statusCode: res.statusCode || 0, body: Buffer.concat(chunks).toString('utf8') }))
|
|
86
|
+
},
|
|
87
|
+
)
|
|
88
|
+
req.on('error', reject)
|
|
89
|
+
req.end(body)
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function main() {
|
|
94
|
+
const socketPath = process.env.DEMI_COMMAND_BRIDGE_SOCK
|
|
95
|
+
// Shell sessions export DEMI_SESSION_ID as the command scope id (the agent
|
|
96
|
+
// session id for agent-owned shells).
|
|
97
|
+
const commandScopeId = process.env.DEMI_SESSION_ID
|
|
98
|
+
if (!socketPath || !commandScopeId) {
|
|
99
|
+
process.stderr.write('command bridge: DEMI_COMMAND_BRIDGE_SOCK / session id not set in this shell\\n')
|
|
100
|
+
process.exit(1)
|
|
101
|
+
}
|
|
102
|
+
const name = basename(process.argv[1] || '')
|
|
103
|
+
const args = process.argv.slice(2)
|
|
104
|
+
const stdin = await readStdin()
|
|
105
|
+
const body = JSON.stringify({ commandScopeId, name, args, cwd: process.cwd(), stdin })
|
|
106
|
+
|
|
107
|
+
const response = await postRun(socketPath, body)
|
|
108
|
+
if (response.statusCode !== 200) {
|
|
109
|
+
let message = response.body
|
|
110
|
+
try {
|
|
111
|
+
message = JSON.parse(response.body).error || message
|
|
112
|
+
} catch {}
|
|
113
|
+
process.stderr.write('command bridge: ' + message + '\\n')
|
|
114
|
+
process.exit(1)
|
|
115
|
+
}
|
|
116
|
+
const result = JSON.parse(response.body)
|
|
117
|
+
if (result.stdout) {
|
|
118
|
+
// Binary final streams arrive base64-encoded; write raw bytes so external
|
|
119
|
+
// pipes (demi read a.png | ffmpeg -i - ...) stay byte-clean.
|
|
120
|
+
process.stdout.write(result.stdoutEncoding === 'base64' ? Buffer.from(result.stdout, 'base64') : result.stdout)
|
|
121
|
+
}
|
|
122
|
+
if (result.stderr) process.stderr.write(result.stderr)
|
|
123
|
+
process.exit(result.exitCode)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
main().catch((error) => {
|
|
127
|
+
process.stderr.write('command bridge: ' + (error && error.message ? error.message : String(error)) + '\\n')
|
|
128
|
+
process.exit(1)
|
|
129
|
+
})
|
|
130
|
+
`;
|
|
131
|
+
/**
|
|
132
|
+
* Starts the process-wide UDS listener for command-bridge shims.
|
|
133
|
+
* Each POST /run is dispatched to `AgentServer.runCommandLine`.
|
|
134
|
+
*/
|
|
135
|
+
function startCommandBridge(server, options) {
|
|
136
|
+
mkdirSync(dirname(options.socketPath), { recursive: true });
|
|
137
|
+
if (existsSync(options.socketPath)) unlinkSync(options.socketPath);
|
|
138
|
+
const httpServer = createServer((req, res) => {
|
|
139
|
+
handleRun(server, req, res);
|
|
140
|
+
});
|
|
141
|
+
httpServer.listen(options.socketPath);
|
|
142
|
+
return { close: () => closeServer(httpServer, options.socketPath) };
|
|
143
|
+
}
|
|
144
|
+
async function handleRun(server, req, res) {
|
|
145
|
+
if (req.method !== "POST" || req.url !== "/run") {
|
|
146
|
+
sendJson(res, 404, { error: "not found" });
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
let body;
|
|
150
|
+
try {
|
|
151
|
+
body = await readJsonBody(req);
|
|
152
|
+
} catch (error) {
|
|
153
|
+
sendJson(res, 400, { error: `bad request: ${errorMessage(error)}` });
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (!isRunRequestBody(body)) {
|
|
157
|
+
sendJson(res, 400, { error: "bad request: expected { commandScopeId, name, args, cwd, stdin }" });
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
const controller = new AbortController();
|
|
161
|
+
let responded = false;
|
|
162
|
+
const onClosedEarly = () => {
|
|
163
|
+
if (!responded) controller.abort();
|
|
164
|
+
};
|
|
165
|
+
req.on("close", onClosedEarly);
|
|
166
|
+
try {
|
|
167
|
+
const result = await server.runCommandLine(body.commandScopeId, body.name, body.args, {
|
|
168
|
+
cwd: body.cwd,
|
|
169
|
+
stdin: body.stdin,
|
|
170
|
+
signal: controller.signal
|
|
171
|
+
});
|
|
172
|
+
responded = true;
|
|
173
|
+
sendJson(res, 200, result);
|
|
174
|
+
} catch (error) {
|
|
175
|
+
responded = true;
|
|
176
|
+
sendJson(res, statusForError(error), { error: errorMessage(error) });
|
|
177
|
+
} finally {
|
|
178
|
+
req.off("close", onClosedEarly);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
function statusForError(error) {
|
|
182
|
+
if (error instanceof RunCommandLineSessionNotFoundError) return 404;
|
|
183
|
+
if (error instanceof RunCommandLineCommandNotRegisteredError) return 404;
|
|
184
|
+
if (error instanceof RunCommandLineTimeoutError) return 504;
|
|
185
|
+
return 500;
|
|
186
|
+
}
|
|
187
|
+
function isRunRequestBody(value) {
|
|
188
|
+
if (value === null || typeof value !== "object") return false;
|
|
189
|
+
const record = value;
|
|
190
|
+
return typeof record.commandScopeId === "string" && typeof record.name === "string" && Array.isArray(record.args) && record.args.every((arg) => typeof arg === "string") && typeof record.cwd === "string" && typeof record.stdin === "string";
|
|
191
|
+
}
|
|
192
|
+
function readJsonBody(req) {
|
|
193
|
+
return new Promise((resolve, reject) => {
|
|
194
|
+
const chunks = [];
|
|
195
|
+
req.on("data", (chunk) => chunks.push(chunk));
|
|
196
|
+
req.on("end", () => {
|
|
197
|
+
try {
|
|
198
|
+
resolve(JSON.parse(Buffer.concat(chunks).toString("utf8")));
|
|
199
|
+
} catch (error) {
|
|
200
|
+
reject(error);
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
req.on("error", reject);
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
function sendJson(res, status, body) {
|
|
207
|
+
const text = JSON.stringify(body);
|
|
208
|
+
res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
|
|
209
|
+
res.end(text);
|
|
210
|
+
}
|
|
211
|
+
function closeServer(httpServer, socketPath) {
|
|
212
|
+
return new Promise((resolve) => {
|
|
213
|
+
httpServer.close(() => {
|
|
214
|
+
try {
|
|
215
|
+
if (existsSync(socketPath)) unlinkSync(socketPath);
|
|
216
|
+
} catch {}
|
|
217
|
+
resolve();
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
//#endregion
|
|
222
|
+
//#region src/command-bridge-shim.ts
|
|
223
|
+
const BRIDGE_BIN = "bridge-bin";
|
|
224
|
+
const DISPATCH_FILE = ".dispatch";
|
|
225
|
+
const DISPATCH_PACKAGE_JSON = "{\"type\":\"commonjs\"}\n";
|
|
226
|
+
/**
|
|
227
|
+
* Rejects an `agentSessionId` that cannot be used as one filesystem path
|
|
228
|
+
* segment. `agentSessionId` is client-supplied and becomes a real path
|
|
229
|
+
* component under Host.fs, so it is validated here rather than trusted.
|
|
230
|
+
*/
|
|
231
|
+
function assertPathSafeSessionId(agentSessionId) {
|
|
232
|
+
if (agentSessionId.length === 0 || agentSessionId.includes("\0") || agentSessionId.includes("/") || agentSessionId.includes("\\") || agentSessionId === "." || agentSessionId === "..") throw new Error(`Command bridge: agentSessionId "${agentSessionId}" is not safe to use as a path segment`);
|
|
233
|
+
}
|
|
234
|
+
/** Fixed relative layout under stateDir. */
|
|
235
|
+
function bridgeBinDirFor(stateDir) {
|
|
236
|
+
return `${stateDir.replace(/\/+$/, "")}/${BRIDGE_BIN}`;
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Writes the command bridge shim directory for one session and returns its
|
|
240
|
+
* resolved absolute path for PATH injection. Idempotent: safe on every open().
|
|
241
|
+
*/
|
|
242
|
+
async function materializeCommandBridgeShims(options) {
|
|
243
|
+
const { host, agentSessionId, commandNames, shimSource, stateDir } = options;
|
|
244
|
+
assertPathSafeSessionId(agentSessionId);
|
|
245
|
+
if (!stateDir || stateDir.includes("\0")) throw new Error("Command bridge: stateDir must be a non-empty absolute path");
|
|
246
|
+
const dir = `${bridgeBinDirFor(stateDir)}/${agentSessionId}`;
|
|
247
|
+
await host.fs.mkdir(dir, { recursive: true });
|
|
248
|
+
await host.fs.writeFile(`${dir}/package.json`, encodeUtf8(DISPATCH_PACKAGE_JSON));
|
|
249
|
+
await host.fs.writeFile(`${dir}/${DISPATCH_FILE}`, encodeUtf8(shimSource));
|
|
250
|
+
await host.fs.chmod(`${dir}/${DISPATCH_FILE}`, 493);
|
|
251
|
+
const wanted = new Set(commandNames);
|
|
252
|
+
const existing = await host.fs.readdir(dir);
|
|
253
|
+
for (const entry of existing) {
|
|
254
|
+
if (entry === DISPATCH_FILE || entry === "package.json" || wanted.has(entry)) continue;
|
|
255
|
+
await host.fs.rm(`${dir}/${entry}`, { force: true });
|
|
256
|
+
}
|
|
257
|
+
for (const name of wanted) {
|
|
258
|
+
const linkPath = `${dir}/${name}`;
|
|
259
|
+
await host.fs.rm(linkPath, { force: true });
|
|
260
|
+
await host.fs.symlink(DISPATCH_FILE, linkPath);
|
|
261
|
+
}
|
|
262
|
+
return host.fs.realpath(dir);
|
|
263
|
+
}
|
|
264
|
+
//#endregion
|
|
265
|
+
//#region src/demi-home.ts
|
|
266
|
+
/** Fixed layout: `<stateDir>/bridges/<id>.sock` (short id for macOS AF_UNIX limits). */
|
|
267
|
+
function defaultBridgeSocketPath(stateDir, serverId = randomUUID().replace(/-/g, "").slice(0, 12)) {
|
|
268
|
+
return join(stateDir, "bridges", `${serverId}.sock`);
|
|
269
|
+
}
|
|
270
|
+
//#endregion
|
|
7
271
|
//#region src/local-store.ts
|
|
8
272
|
var LocalHostStore = class {
|
|
9
273
|
root;
|
|
@@ -12,8 +276,7 @@ var LocalHostStore = class {
|
|
|
12
276
|
}
|
|
13
277
|
async readJson(key) {
|
|
14
278
|
try {
|
|
15
|
-
|
|
16
|
-
return JSON.parse(content);
|
|
279
|
+
return parsePortableJson(await readFile(this.pathForKey(key), "utf8"));
|
|
17
280
|
} catch (error) {
|
|
18
281
|
if (isFileNotFoundError(error)) return null;
|
|
19
282
|
throw error;
|
|
@@ -22,7 +285,7 @@ var LocalHostStore = class {
|
|
|
22
285
|
async writeJson(key, value) {
|
|
23
286
|
const path = this.pathForKey(key);
|
|
24
287
|
await mkdir(dirname(path), { recursive: true });
|
|
25
|
-
await writeFile(path,
|
|
288
|
+
await writeFile(path, stringifyPortableJson(value, 2), "utf8");
|
|
26
289
|
}
|
|
27
290
|
async delete(key) {
|
|
28
291
|
await rm(this.pathForKey(key), {
|
|
@@ -235,16 +498,16 @@ var LocalHostFileSystem = class {
|
|
|
235
498
|
return readdir(target);
|
|
236
499
|
}
|
|
237
500
|
async mkdir(path, options) {
|
|
238
|
-
await mkdir(this.resolvePath(path, options?.cwd), { recursive: options?.recursive });
|
|
501
|
+
await mkdir(this.resolvePath(path, options?.cwd), { recursive: options?.recursive === true });
|
|
239
502
|
}
|
|
240
503
|
async rm(path, options) {
|
|
241
504
|
await rm(this.resolvePath(path, options?.cwd), {
|
|
242
|
-
recursive: options?.recursive,
|
|
243
|
-
force: options?.force
|
|
505
|
+
recursive: options?.recursive === true,
|
|
506
|
+
force: options?.force === true
|
|
244
507
|
});
|
|
245
508
|
}
|
|
246
509
|
async cp(path, destination, options) {
|
|
247
|
-
await cp(this.resolvePath(path, options?.cwd), this.resolvePath(destination, options?.cwd), { recursive: options?.recursive });
|
|
510
|
+
await cp(this.resolvePath(path, options?.cwd), this.resolvePath(destination, options?.cwd), { recursive: options?.recursive === true });
|
|
248
511
|
}
|
|
249
512
|
async mv(path, destination, options) {
|
|
250
513
|
await rename(this.resolvePath(path, options?.cwd), this.resolvePath(destination, options?.cwd));
|
|
@@ -310,4 +573,79 @@ async function* streamBytes(stream) {
|
|
|
310
573
|
else yield Buffer.from(String(chunk));
|
|
311
574
|
}
|
|
312
575
|
//#endregion
|
|
313
|
-
|
|
576
|
+
//#region src/local-agent-server.ts
|
|
577
|
+
/**
|
|
578
|
+
* Open-box local agent assembly: `LocalHost` + `AgentServer` with command
|
|
579
|
+
* bridge **on by default**.
|
|
580
|
+
*
|
|
581
|
+
* Owns the Node-only bridge transport (UDS + PATH shims under `stateDir`).
|
|
582
|
+
* AgentServer only receives a generic `prepareSessionShell` hook and exposes
|
|
583
|
+
* `runCommandLine` — it never sees bin dirs or sockets.
|
|
584
|
+
*
|
|
585
|
+
* Default layout under `stateDir` (`~/.demi` or `$DEMI_HOME`):
|
|
586
|
+
* ```
|
|
587
|
+
* bridges/<id>.sock
|
|
588
|
+
* bridge-bin/<sessionId>/…
|
|
589
|
+
* ```
|
|
590
|
+
*/
|
|
591
|
+
function createLocalAgentServer(options) {
|
|
592
|
+
const bridgeEnabled = options.commandBridge !== false;
|
|
593
|
+
const stateDir = bridgeEnabled ? resolveDemiHome(options.stateDir) : null;
|
|
594
|
+
let socketPath = null;
|
|
595
|
+
if (bridgeEnabled && stateDir) {
|
|
596
|
+
socketPath = options.commandBridgeSocketPath ?? defaultBridgeSocketPath(stateDir);
|
|
597
|
+
mkdirSync(dirname(socketPath), { recursive: true });
|
|
598
|
+
}
|
|
599
|
+
const initialEnv = {
|
|
600
|
+
PATH: process.env.PATH ?? "",
|
|
601
|
+
...options.shell?.initialEnv
|
|
602
|
+
};
|
|
603
|
+
const serverOptions = {
|
|
604
|
+
agent: options.agent,
|
|
605
|
+
providers: options.providers,
|
|
606
|
+
session: options.session,
|
|
607
|
+
shell: {
|
|
608
|
+
...options.shell,
|
|
609
|
+
initialEnv
|
|
610
|
+
}
|
|
611
|
+
};
|
|
612
|
+
if (bridgeEnabled && socketPath && stateDir) {
|
|
613
|
+
const bridgeStateDir = stateDir;
|
|
614
|
+
const bridgeSocketPath = socketPath;
|
|
615
|
+
serverOptions.prepareSessionShell = async ({ host, agentSessionId, commandNames, shell }) => {
|
|
616
|
+
const shimDir = await materializeCommandBridgeShims({
|
|
617
|
+
host,
|
|
618
|
+
agentSessionId,
|
|
619
|
+
commandNames,
|
|
620
|
+
shimSource: COMMAND_BRIDGE_SHIM_SOURCE,
|
|
621
|
+
stateDir: bridgeStateDir
|
|
622
|
+
});
|
|
623
|
+
const existingPath = shell.initialEnv?.PATH;
|
|
624
|
+
return {
|
|
625
|
+
...shell,
|
|
626
|
+
initialEnv: {
|
|
627
|
+
...shell.initialEnv,
|
|
628
|
+
DEMI_COMMAND_BRIDGE_SOCK: bridgeSocketPath,
|
|
629
|
+
PATH: existingPath ? `${shimDir}:${existingPath}` : shimDir
|
|
630
|
+
}
|
|
631
|
+
};
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
const server = new AgentServer(serverOptions);
|
|
635
|
+
let bridge = null;
|
|
636
|
+
if (bridgeEnabled && socketPath) bridge = startCommandBridge(server, { socketPath });
|
|
637
|
+
return {
|
|
638
|
+
server,
|
|
639
|
+
host: options.host,
|
|
640
|
+
stateDir,
|
|
641
|
+
async close() {
|
|
642
|
+
if (bridge) {
|
|
643
|
+
await bridge.close();
|
|
644
|
+
bridge = null;
|
|
645
|
+
}
|
|
646
|
+
await server.close();
|
|
647
|
+
}
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
//#endregion
|
|
651
|
+
export { COMMAND_BRIDGE_SHIM_SOURCE, LocalHost, bridgeBinDirFor, createLocalAgentServer, defaultBridgeSocketPath, materializeCommandBridgeShims, resolveDemiHome, startCommandBridge };
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@demicodes/host-local",
|
|
3
|
-
"description": "Node
|
|
4
|
-
"version": "0.1
|
|
3
|
+
"description": "Node LocalHost and open-box local AgentServer assembly (command bridge on by default).",
|
|
4
|
+
"version": "0.2.1",
|
|
5
5
|
"private": false,
|
|
6
6
|
"type": "module",
|
|
7
7
|
"exports": {
|
|
@@ -12,8 +12,10 @@
|
|
|
12
12
|
}
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
|
-
"@demicodes/
|
|
16
|
-
"@demicodes/
|
|
15
|
+
"@demicodes/agent": "^0.2.1",
|
|
16
|
+
"@demicodes/provider": "^0.2.1",
|
|
17
|
+
"@demicodes/shell": "^0.2.1",
|
|
18
|
+
"@demicodes/utils": "^0.2.1"
|
|
17
19
|
},
|
|
18
20
|
"license": "Apache-2.0",
|
|
19
21
|
"main": "./dist/index.mjs",
|