@demicodes/host-local 0.10.1 → 0.10.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.mts +8 -3
- package/dist/index.mjs +29 -24
- package/package.json +5 -5
package/dist/index.d.mts
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import { AgentHarness, AgentServer, AgentServerSessionOptions } from "@demicodes/agent";
|
|
2
|
-
import { resolveDemiHome } from "@demicodes/provider/credentials-pool";
|
|
3
2
|
import { BashEnvironmentOptions, Host, HostFileSystem, HostProcess, HostStore } from "@demicodes/shell";
|
|
4
3
|
import { Provider } from "@demicodes/provider";
|
|
4
|
+
|
|
5
5
|
//#region src/command-bridge.d.ts
|
|
6
6
|
/**
|
|
7
7
|
* Dispatch script shared by every generated command-name symlink.
|
|
8
8
|
* Reads the invoked name from the symlink path, applies the stdin grace
|
|
9
9
|
* contract from docs/command-bridge.md, and POSTs /run on the UDS socket.
|
|
10
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 const
|
|
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 agent session id (command scope).\n const commandScopeId = process.env.DEMI_AGENT_SESSION_ID || 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) process.stdout.write(result.stdout)\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
12
|
interface CommandBridgeOptions {
|
|
13
13
|
socketPath: string;
|
|
14
14
|
}
|
|
@@ -42,6 +42,11 @@ declare function bridgeBinDirFor(stateDir: string): string;
|
|
|
42
42
|
declare function materializeCommandBridgeShims(options: MaterializeCommandBridgeShimsOptions): Promise<string>;
|
|
43
43
|
//#endregion
|
|
44
44
|
//#region src/demi-home.d.ts
|
|
45
|
+
/**
|
|
46
|
+
* Demi local state root (not the workspace cwd).
|
|
47
|
+
* Override with `DEMI_HOME` or `createLocalAgentServer({ stateDir })`, else `~/.demi`.
|
|
48
|
+
*/
|
|
49
|
+
declare function resolveDemiHome(explicit?: string): string;
|
|
45
50
|
/** Fixed layout: `<stateDir>/bridges/<id>.sock` (short id for macOS AF_UNIX limits). */
|
|
46
51
|
declare function defaultBridgeSocketPath(stateDir: string, serverId?: string): string;
|
|
47
52
|
//#endregion
|
|
@@ -92,7 +97,7 @@ interface LocalAgentServerHandle {
|
|
|
92
97
|
* bridge **on by default**.
|
|
93
98
|
*
|
|
94
99
|
* Owns the Node-only bridge transport (UDS + PATH shims under `stateDir`).
|
|
95
|
-
* AgentServer only receives a generic `
|
|
100
|
+
* AgentServer only receives a generic `prepareSessionShell` hook and exposes
|
|
96
101
|
* `runCommandLine` — it never sees bin dirs or sockets.
|
|
97
102
|
*
|
|
98
103
|
* Default layout under `stateDir` (`~/.demi` or `$DEMI_HOME`):
|
package/dist/index.mjs
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, unlinkSync } from "node:fs";
|
|
2
2
|
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
3
3
|
import { createServer } from "node:http";
|
|
4
|
-
import { AgentServer,
|
|
4
|
+
import { AgentServer, RunCommandLineSessionNotFoundError, RunCommandLineTimeoutError } from "@demicodes/agent";
|
|
5
5
|
import { encodeUtf8, errorMessage, isFileNotFoundError, parsePortableJson, stringifyPortableJson } from "@demicodes/utils";
|
|
6
6
|
import { createHash, randomUUID } from "node:crypto";
|
|
7
|
-
import { resolveDemiHome } from "@demicodes/provider/credentials-pool";
|
|
8
|
-
import { spawn } from "node:child_process";
|
|
9
7
|
import { homedir } from "node:os";
|
|
8
|
+
import { spawn } from "node:child_process";
|
|
10
9
|
import { appendFile, chmod, cp, link, lstat, mkdir, readFile, readdir, readlink, realpath, rename, rm, stat, symlink, utimes, writeFile } from "node:fs/promises";
|
|
11
10
|
//#region src/command-bridge.ts
|
|
12
11
|
/**
|
|
@@ -92,15 +91,16 @@ function postRun(socketPath, body) {
|
|
|
92
91
|
|
|
93
92
|
async function main() {
|
|
94
93
|
const socketPath = process.env.DEMI_COMMAND_BRIDGE_SOCK
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
94
|
+
// Shell sessions export DEMI_SESSION_ID as the agent session id (command scope).
|
|
95
|
+
const commandScopeId = process.env.DEMI_AGENT_SESSION_ID || process.env.DEMI_SESSION_ID
|
|
96
|
+
if (!socketPath || !commandScopeId) {
|
|
97
|
+
process.stderr.write('command bridge: DEMI_COMMAND_BRIDGE_SOCK / session id not set in this shell\\n')
|
|
98
98
|
process.exit(1)
|
|
99
99
|
}
|
|
100
100
|
const name = basename(process.argv[1] || '')
|
|
101
101
|
const args = process.argv.slice(2)
|
|
102
102
|
const stdin = await readStdin()
|
|
103
|
-
const body = JSON.stringify({
|
|
103
|
+
const body = JSON.stringify({ commandScopeId, name, args, cwd: process.cwd(), stdin })
|
|
104
104
|
|
|
105
105
|
const response = await postRun(socketPath, body)
|
|
106
106
|
if (response.statusCode !== 200) {
|
|
@@ -112,11 +112,7 @@ async function main() {
|
|
|
112
112
|
process.exit(1)
|
|
113
113
|
}
|
|
114
114
|
const result = JSON.parse(response.body)
|
|
115
|
-
if (result.stdout)
|
|
116
|
-
// Binary final streams arrive base64-encoded; write raw bytes so external
|
|
117
|
-
// pipes (demi read a.png | ffmpeg -i - ...) stay byte-clean.
|
|
118
|
-
process.stdout.write(result.stdoutEncoding === 'base64' ? Buffer.from(result.stdout, 'base64') : result.stdout)
|
|
119
|
-
}
|
|
115
|
+
if (result.stdout) process.stdout.write(result.stdout)
|
|
120
116
|
if (result.stderr) process.stderr.write(result.stderr)
|
|
121
117
|
process.exit(result.exitCode)
|
|
122
118
|
}
|
|
@@ -152,7 +148,7 @@ async function handleRun(server, req, res) {
|
|
|
152
148
|
return;
|
|
153
149
|
}
|
|
154
150
|
if (!isRunRequestBody(body)) {
|
|
155
|
-
sendJson(res, 400, { error: "bad request: expected {
|
|
151
|
+
sendJson(res, 400, { error: "bad request: expected { commandScopeId, name, args, cwd, stdin }" });
|
|
156
152
|
return;
|
|
157
153
|
}
|
|
158
154
|
const controller = new AbortController();
|
|
@@ -162,7 +158,7 @@ async function handleRun(server, req, res) {
|
|
|
162
158
|
};
|
|
163
159
|
req.on("close", onClosedEarly);
|
|
164
160
|
try {
|
|
165
|
-
const result = await server.runCommandLine(body.
|
|
161
|
+
const result = await server.runCommandLine(body.commandScopeId, body.name, body.args, {
|
|
166
162
|
cwd: body.cwd,
|
|
167
163
|
stdin: body.stdin,
|
|
168
164
|
signal: controller.signal
|
|
@@ -177,15 +173,14 @@ async function handleRun(server, req, res) {
|
|
|
177
173
|
}
|
|
178
174
|
}
|
|
179
175
|
function statusForError(error) {
|
|
180
|
-
if (error instanceof
|
|
181
|
-
if (error instanceof RunCommandLineCommandNotRegisteredError) return 404;
|
|
176
|
+
if (error instanceof RunCommandLineSessionNotFoundError) return 404;
|
|
182
177
|
if (error instanceof RunCommandLineTimeoutError) return 504;
|
|
183
178
|
return 500;
|
|
184
179
|
}
|
|
185
180
|
function isRunRequestBody(value) {
|
|
186
181
|
if (value === null || typeof value !== "object") return false;
|
|
187
182
|
const record = value;
|
|
188
|
-
return typeof record.
|
|
183
|
+
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";
|
|
189
184
|
}
|
|
190
185
|
function readJsonBody(req) {
|
|
191
186
|
return new Promise((resolve, reject) => {
|
|
@@ -227,7 +222,7 @@ const DISPATCH_PACKAGE_JSON = "{\"type\":\"commonjs\"}\n";
|
|
|
227
222
|
* component under Host.fs, so it is validated here rather than trusted.
|
|
228
223
|
*/
|
|
229
224
|
function assertPathSafeSessionId(agentSessionId) {
|
|
230
|
-
if (agentSessionId.length === 0 || agentSessionId.includes("
|
|
225
|
+
if (agentSessionId.length === 0 || agentSessionId.includes("/") || agentSessionId.includes("\\") || agentSessionId === "..") throw new Error(`Command bridge: agentSessionId "${agentSessionId}" is not safe to use as a path segment`);
|
|
231
226
|
}
|
|
232
227
|
/** Fixed relative layout under stateDir. */
|
|
233
228
|
function bridgeBinDirFor(stateDir) {
|
|
@@ -261,6 +256,16 @@ async function materializeCommandBridgeShims(options) {
|
|
|
261
256
|
}
|
|
262
257
|
//#endregion
|
|
263
258
|
//#region src/demi-home.ts
|
|
259
|
+
/**
|
|
260
|
+
* Demi local state root (not the workspace cwd).
|
|
261
|
+
* Override with `DEMI_HOME` or `createLocalAgentServer({ stateDir })`, else `~/.demi`.
|
|
262
|
+
*/
|
|
263
|
+
function resolveDemiHome(explicit) {
|
|
264
|
+
if (explicit && explicit.trim()) return resolve(explicit.trim());
|
|
265
|
+
const fromEnv = process.env.DEMI_HOME;
|
|
266
|
+
if (fromEnv && fromEnv.trim()) return resolve(fromEnv.trim());
|
|
267
|
+
return join(homedir(), ".demi");
|
|
268
|
+
}
|
|
264
269
|
/** Fixed layout: `<stateDir>/bridges/<id>.sock` (short id for macOS AF_UNIX limits). */
|
|
265
270
|
function defaultBridgeSocketPath(stateDir, serverId = randomUUID().replace(/-/g, "").slice(0, 12)) {
|
|
266
271
|
return join(stateDir, "bridges", `${serverId}.sock`);
|
|
@@ -496,16 +501,16 @@ var LocalHostFileSystem = class {
|
|
|
496
501
|
return readdir(target);
|
|
497
502
|
}
|
|
498
503
|
async mkdir(path, options) {
|
|
499
|
-
await mkdir(this.resolvePath(path, options?.cwd), { recursive: options?.recursive
|
|
504
|
+
await mkdir(this.resolvePath(path, options?.cwd), { recursive: options?.recursive });
|
|
500
505
|
}
|
|
501
506
|
async rm(path, options) {
|
|
502
507
|
await rm(this.resolvePath(path, options?.cwd), {
|
|
503
|
-
recursive: options?.recursive
|
|
504
|
-
force: options?.force
|
|
508
|
+
recursive: options?.recursive,
|
|
509
|
+
force: options?.force
|
|
505
510
|
});
|
|
506
511
|
}
|
|
507
512
|
async cp(path, destination, options) {
|
|
508
|
-
await cp(this.resolvePath(path, options?.cwd), this.resolvePath(destination, options?.cwd), { recursive: options?.recursive
|
|
513
|
+
await cp(this.resolvePath(path, options?.cwd), this.resolvePath(destination, options?.cwd), { recursive: options?.recursive });
|
|
509
514
|
}
|
|
510
515
|
async mv(path, destination, options) {
|
|
511
516
|
await rename(this.resolvePath(path, options?.cwd), this.resolvePath(destination, options?.cwd));
|
|
@@ -577,7 +582,7 @@ async function* streamBytes(stream) {
|
|
|
577
582
|
* bridge **on by default**.
|
|
578
583
|
*
|
|
579
584
|
* Owns the Node-only bridge transport (UDS + PATH shims under `stateDir`).
|
|
580
|
-
* AgentServer only receives a generic `
|
|
585
|
+
* AgentServer only receives a generic `prepareSessionShell` hook and exposes
|
|
581
586
|
* `runCommandLine` — it never sees bin dirs or sockets.
|
|
582
587
|
*
|
|
583
588
|
* Default layout under `stateDir` (`~/.demi` or `$DEMI_HOME`):
|
|
@@ -610,7 +615,7 @@ function createLocalAgentServer(options) {
|
|
|
610
615
|
if (bridgeEnabled && socketPath && stateDir) {
|
|
611
616
|
const bridgeStateDir = stateDir;
|
|
612
617
|
const bridgeSocketPath = socketPath;
|
|
613
|
-
serverOptions.
|
|
618
|
+
serverOptions.prepareSessionShell = async ({ host, agentSessionId, commandNames, shell }) => {
|
|
614
619
|
const shimDir = await materializeCommandBridgeShims({
|
|
615
620
|
host,
|
|
616
621
|
agentSessionId,
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@demicodes/host-local",
|
|
3
3
|
"description": "Node LocalHost and open-box local AgentServer assembly (command bridge on by default).",
|
|
4
|
-
"version": "0.10.
|
|
4
|
+
"version": "0.10.3",
|
|
5
5
|
"private": false,
|
|
6
6
|
"type": "module",
|
|
7
7
|
"exports": {
|
|
@@ -11,10 +11,10 @@
|
|
|
11
11
|
}
|
|
12
12
|
},
|
|
13
13
|
"dependencies": {
|
|
14
|
-
"@demicodes/agent": "^0.10.
|
|
15
|
-
"@demicodes/provider": "^0.10.
|
|
16
|
-
"@demicodes/shell": "^0.10.
|
|
17
|
-
"@demicodes/utils": "^0.10.
|
|
14
|
+
"@demicodes/agent": "^0.10.3",
|
|
15
|
+
"@demicodes/provider": "^0.10.3",
|
|
16
|
+
"@demicodes/shell": "^0.10.3",
|
|
17
|
+
"@demicodes/utils": "^0.10.3"
|
|
18
18
|
},
|
|
19
19
|
"license": "Apache-2.0",
|
|
20
20
|
"main": "./dist/index.mjs",
|