@amistio/cli 0.1.36 → 0.1.37

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/README.md CHANGED
@@ -9,7 +9,19 @@ npm install -g @amistio/cli
9
9
  amistio --help
10
10
  ```
11
11
 
12
- The package install only installs the `amistio` command. Repository cloning, project pairing, credential storage, sync watching, and runner execution happen only when the user explicitly runs commands such as `amistio bootstrap`, `amistio import`, `amistio pair`, `amistio sync watch`, or `amistio run --watch`. When the app copies a personal project into an organization, the CLI command syntax stays the same; create the org-scoped code and run `amistio import <code>` from the intended local checkout. Import scans repo-local project-brain docs plus recognized repo-local IDE, harness, and local AI tool memory or instruction files such as `AGENTS.md`, `.github/copilot-instructions.md`, `.cursor/rules/*.mdc`, `.windsurfrules`, and `memories/*.md`; durable Amistio memory belongs in `docs/memory/`, and global plugin or harness memory outside the repository is not scanned by default.
12
+ The package install provides the `amistio` command and the optional `amistio-host-helper` executable. Repository cloning, project pairing, credential storage, sync watching, helper activation, and runner execution happen only when the user explicitly runs commands such as `amistio bootstrap`, `amistio import`, `amistio pair`, `amistio sync watch`, `amistio host-helper status`, or `amistio run --watch`. When the app copies a personal project into an organization, the CLI command syntax stays the same; create the org-scoped code and run `amistio import <code>` from the intended local checkout. Import scans repo-local project-brain docs plus recognized repo-local IDE, harness, and local AI tool memory or instruction files such as `AGENTS.md`, `.github/copilot-instructions.md`, `.cursor/rules/*.mdc`, `.windsurfrules`, and `memories/*.md`; durable Amistio memory belongs in `docs/memory/`, and global plugin or harness memory outside the repository is not scanned by default.
13
+
14
+ For enterprise setup, use the web Runner panel as the primary guide. It shows repository, pairing code, AI provider, local runner, and approved-work readiness with plain next actions; advanced CLI logs remain secondary diagnostics. The panel also shows trust/privacy boundaries, cost forecast and budget posture, runner health, blockers, verification health, and runner-version distribution from safe runner metadata.
15
+
16
+ Optional host helpers are configured outside the SaaS with `AMISTIO_HOST_HELPER_PATH`. The official npm package ships `amistio-host-helper` for Level 1 supervised process execution diagnostics; enable it with `AMISTIO_HOST_HELPER_PATH=$(command -v amistio-host-helper)` on reviewed runner machines and confirm `amistio host-helper status` plus `amistio host-helper conformance`. PTY and sandbox requests use a helper only when its versioned handshake advertises support; otherwise the CLI returns deterministic unsupported results instead of silently downgrading. The npm helper does not advertise PTY or sandbox support. Helper request envelopes include allowlisted environment values, explicit sandbox policy metadata, and bounded normalized output, and must never include provider tokens, OAuth material, runner API tokens, local auth files, or arbitrary SaaS-originated shell text.
17
+
18
+ Before publishing the CLI, run the package release gate:
19
+
20
+ ```sh
21
+ corepack pnpm --config.verify-deps-before-run=false --filter @amistio/cli release:check
22
+ ```
23
+
24
+ The check packs and installs the package, rejects source maps/tests/source files in the tarball, verifies both installed bins, and runs helper handshake, status, and conformance from the installed artifact.
13
25
 
14
26
  Runner lifecycle controls in the web app, such as update, restart, and remove, apply only to the runner paired by that user unless the active organization role is an admin role. The runner API binds command polling, command status, logs, activity, and tool sessions to the local runner credential that produced them.
15
27
 
@@ -27,6 +39,8 @@ After pairing, confirm that at least one local AI tool is available:
27
39
 
28
40
  ```sh
29
41
  amistio tools
42
+ amistio host-helper status
43
+ amistio host-helper conformance
30
44
  ```
31
45
 
32
46
  If no supported tool is available, install and authenticate one of the supported local tools, such as opencode, then start the runner:
@@ -37,12 +51,16 @@ amistio run --watch --tool opencode
37
51
  amistio run --watch --tool opencode --provider anthropic --model-id claude-opus-4.6 --reasoning-effort xhigh
38
52
  amistio run --watch --max-concurrent-work 2 --tool opencode
39
53
  amistio run --watch --background --tool opencode
54
+ AMISTIO_HOST_HELPER_PATH=$(command -v amistio-host-helper) amistio host-helper status
55
+ AMISTIO_HOST_HELPER_PATH=$(command -v amistio-host-helper) amistio host-helper conformance
40
56
  amistio runner status
41
57
  amistio runner smoke-session-lifecycle
42
58
  ```
43
59
 
44
60
  Provider-backed model preferences use sanitized catalog fields: `--provider`, `--model-id`, optional `--model-variant`, and `--reasoning-effort` (`auto`, `low`, `medium`, `high`, or `xhigh`). Opencode catalog metadata is synthesized by Amistio or derived from readable local OpenCode JSON config until opencode exposes a native catalog. Provider credentials, API keys, and local secret paths stay in the local tool configuration; they are not stored in Amistio preferences or runner heartbeats.
45
61
 
62
+ Opencode remains an optional compatibility route. When a provider/model is named for opencode, Amistio validates it against the safe provider catalog before launching the external tool instead of silently falling back. Command-mode local tool runs are bounded by `--tool-timeout-seconds`, wait for process cleanup after timeout, and return redacted/capped stdout and stderr so large external output, local execution-root paths, and secret-looking assignments are not persisted unbounded.
63
+
46
64
  When `--tool copilot` uses the GitHub Copilot SDK, Amistio approves read-only permission requests by default and denies mutating, network, MCP, hook, memory, and shell requests. Set `AMISTIO_COPILOT_APPROVE_ALL=1` only on a local machine where broad Copilot SDK approval is intentional.
47
65
 
48
66
  When `--tool codex` uses the Codex SDK, intermediate progress can be quiet until the final response. For live Codex CLI logs, run `amistio run --watch --tool codex --invocation-channel command`.
@@ -0,0 +1,315 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/host-helper.ts
4
+ import { realpathSync } from "node:fs";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ // src/host-execution.ts
8
+ import { spawn } from "node:child_process";
9
+ var hostExecutionProtocolVersion = "amistio.hostExecution.v1";
10
+ var nativeHostHelperSecretBoundary = {
11
+ credentialPolicy: "never-send-credentials",
12
+ environmentPolicy: "allowlist-only",
13
+ forbiddenInputs: [
14
+ "runner API tokens",
15
+ "provider API keys",
16
+ "OAuth tokens",
17
+ "refresh tokens",
18
+ "SaaS credentials",
19
+ "local auth files",
20
+ "unbounded environment variables",
21
+ "arbitrary SaaS-sourced shell text"
22
+ ]
23
+ };
24
+ var defaultNativeHostOutputBudgetBytes = 64 * 1024;
25
+ var nodeHostExecutionCapabilities = {
26
+ protocolVersion: hostExecutionProtocolVersion,
27
+ implementation: "node",
28
+ processGroups: process.platform === "win32" ? { supported: false, reason: "Windows process-group cleanup needs a native helper or platform-specific implementation." } : { supported: true },
29
+ signalEscalation: { supported: true },
30
+ streamCapture: { supported: true },
31
+ pty: { supported: false, reason: "PTY bridging is reserved for a future native helper." },
32
+ sandbox: { supported: false, reason: "Sandboxed command execution is reserved for a future native helper." },
33
+ outputEncoding: "utf8"
34
+ };
35
+ var defaultHostExecution = {
36
+ capabilities: nodeHostExecutionCapabilities,
37
+ executeCommand: executeNodeCommand,
38
+ commandExists
39
+ };
40
+ async function executeNodeCommand(request) {
41
+ if (request.requirePty) {
42
+ return unsupportedResult("pty_unsupported", "PTY command execution is not supported by the default Node host execution port.");
43
+ }
44
+ if (request.sandbox && request.sandbox !== "none") {
45
+ return unsupportedResult("sandbox_unsupported", `${request.sandbox} sandbox execution is not supported by the default Node host execution port.`);
46
+ }
47
+ return new Promise((resolve) => {
48
+ let child;
49
+ try {
50
+ child = spawn(request.command, [...request.args], {
51
+ cwd: request.cwd,
52
+ env: request.env ?? process.env,
53
+ detached: process.platform !== "win32",
54
+ shell: request.shell ?? false,
55
+ stdio: ["pipe", "pipe", "pipe"]
56
+ });
57
+ } catch (error) {
58
+ resolve(spawnFailedResult(error));
59
+ return;
60
+ }
61
+ let stdout = "";
62
+ let stderr = "";
63
+ let settled = false;
64
+ let forceKillTimer;
65
+ let timedOutError;
66
+ const timeout = request.timeoutMs && request.timeoutMs > 0 ? setTimeout(() => {
67
+ if (settled) return;
68
+ const message = request.timeoutMessage ?? `Command timed out after ${request.timeoutMs}ms: ${request.command}`;
69
+ stderr += `${message}
70
+ `;
71
+ timedOutError = { code: "timeout", message };
72
+ killProcessTree(child, "SIGTERM");
73
+ forceKillTimer = setTimeout(() => killProcessTree(child, "SIGKILL"), request.killGraceMs ?? 5e3);
74
+ forceKillTimer.unref?.();
75
+ }, request.timeoutMs) : void 0;
76
+ timeout?.unref?.();
77
+ const resolveOnce = (result) => {
78
+ if (settled) return;
79
+ settled = true;
80
+ if (timeout) clearTimeout(timeout);
81
+ if (forceKillTimer) clearTimeout(forceKillTimer);
82
+ resolve(result);
83
+ };
84
+ child.on("error", (error) => {
85
+ if (timedOutError) return;
86
+ resolveOnce(spawnFailedResult(error, stdout, stderr));
87
+ });
88
+ child.stdout.setEncoding("utf8");
89
+ child.stderr.setEncoding("utf8");
90
+ child.stdout.on("data", (chunk) => {
91
+ stdout += chunk;
92
+ if (request.streamOutput) process.stdout.write(chunk);
93
+ });
94
+ child.stderr.on("data", (chunk) => {
95
+ stderr += chunk;
96
+ if (request.streamOutput) process.stderr.write(chunk);
97
+ });
98
+ child.stdin.on("error", () => void 0);
99
+ if (request.stdin) {
100
+ child.stdin.write(request.stdin);
101
+ }
102
+ child.stdin.end();
103
+ child.on("close", (exitCode) => {
104
+ if (forceKillTimer) clearTimeout(forceKillTimer);
105
+ if (timedOutError) {
106
+ resolveOnce({ status: "timedOut", exitCode: 1, stdout, stderr, error: timedOutError });
107
+ return;
108
+ }
109
+ resolveOnce({ status: "completed", exitCode: exitCode ?? 1, stdout, stderr });
110
+ });
111
+ });
112
+ }
113
+ function unsupportedResult(code, message) {
114
+ return { status: "unsupported", exitCode: 1, stdout: "", stderr: "", error: { code, message } };
115
+ }
116
+ function spawnFailedResult(error, stdout = "", stderr = "") {
117
+ return { status: "spawnFailed", exitCode: 1, stdout, stderr, error: { code: "spawn_failed", message: errorMessage(error) } };
118
+ }
119
+ function killProcessTree(child, signal) {
120
+ if (child.pid && process.platform !== "win32") {
121
+ try {
122
+ process.kill(-child.pid, signal);
123
+ return;
124
+ } catch {
125
+ }
126
+ }
127
+ child.kill(signal);
128
+ }
129
+ async function commandExists(command) {
130
+ const lookupCommand = process.platform === "win32" ? "where" : "which";
131
+ return new Promise((resolve) => {
132
+ const lookup = spawn(lookupCommand, [command], { stdio: "ignore" });
133
+ lookup.on("error", () => resolve(false));
134
+ lookup.on("close", (exitCode) => resolve(exitCode === 0));
135
+ });
136
+ }
137
+ function errorMessage(error) {
138
+ return error instanceof Error ? error.message : String(error);
139
+ }
140
+
141
+ // src/host-helper.ts
142
+ var maxRequestEnvelopeBytes = 512 * 1024;
143
+ var defaultOutputBudgetBytes = 64 * 1024;
144
+ function createOfficialHostHelperHandshake() {
145
+ const capabilities = {
146
+ ...nodeHostExecutionCapabilities,
147
+ implementation: "nativeHelper",
148
+ pty: { supported: false, reason: "The official npm helper does not allocate PTYs yet. Use a future native helper build for PTY support." },
149
+ sandbox: { supported: false, reason: "The official npm helper does not enforce OS sandboxes yet. Use a future native helper build for sandbox support." }
150
+ };
151
+ return { protocolVersion: hostExecutionProtocolVersion, capabilities, secretBoundary: nativeHostHelperSecretBoundary };
152
+ }
153
+ async function executeOfficialHostHelperRequest(input) {
154
+ const parsed = parseJson(input);
155
+ if (!isNativeHostCommandRequestEnvelope(parsed)) {
156
+ const requestId = requestIdFromUnknown(parsed) ?? "invalid-request";
157
+ return resultEnvelope(requestId, protocolMismatch("Host helper request envelope did not match the Amistio host execution protocol."));
158
+ }
159
+ if (parsed.stdin === "provided") {
160
+ return resultEnvelope(parsed.requestId, unsupportedResult2("helper_failed", "The official helper does not accept stdin payload delegation."));
161
+ }
162
+ if (parsed.shell) {
163
+ return resultEnvelope(parsed.requestId, unsupportedResult2("helper_failed", "The official helper does not execute shell-wrapped requests."));
164
+ }
165
+ if (parsed.requirePty) {
166
+ return resultEnvelope(parsed.requestId, unsupportedResult2("pty_unsupported", "PTY execution is not supported by the official npm helper."));
167
+ }
168
+ if (parsed.sandbox !== "none") {
169
+ return resultEnvelope(parsed.requestId, unsupportedResult2("sandbox_unsupported", `${parsed.sandbox} sandbox execution is not supported by the official npm helper.`));
170
+ }
171
+ const commandRequest = {
172
+ command: parsed.command,
173
+ args: parsed.args,
174
+ cwd: parsed.cwd,
175
+ env: sanitizeRequestEnvironment(parsed),
176
+ shell: false,
177
+ streamOutput: false,
178
+ outputBudgetBytes: positiveInteger(parsed.outputBudgetBytes) ?? defaultOutputBudgetBytes
179
+ };
180
+ const timeoutMs = positiveInteger(parsed.timeoutMs);
181
+ if (timeoutMs !== void 0) {
182
+ commandRequest.timeoutMs = timeoutMs;
183
+ }
184
+ const result = await defaultHostExecution.executeCommand(commandRequest);
185
+ return resultEnvelope(parsed.requestId, result, positiveInteger(parsed.outputBudgetBytes) ?? defaultOutputBudgetBytes);
186
+ }
187
+ async function runOfficialHostHelperCli(argv = process.argv.slice(2), io = process) {
188
+ const mode = argv[0];
189
+ if (mode === "--amistio-host-helper-handshake") {
190
+ io.stdout.write(`${JSON.stringify(createOfficialHostHelperHandshake())}
191
+ `);
192
+ return 0;
193
+ }
194
+ if (mode === "--amistio-host-helper-execute") {
195
+ try {
196
+ const input = await readStdinLimited(io.stdin, maxRequestEnvelopeBytes);
197
+ const result = await executeOfficialHostHelperRequest(input);
198
+ io.stdout.write(`${JSON.stringify(result)}
199
+ `);
200
+ return 0;
201
+ } catch (error) {
202
+ io.stderr.write(`${errorMessage2(error)}
203
+ `);
204
+ return 1;
205
+ }
206
+ }
207
+ if (mode === "--help" || mode === "-h") {
208
+ io.stdout.write("Usage: amistio-host-helper --amistio-host-helper-handshake | --amistio-host-helper-execute\n");
209
+ return 0;
210
+ }
211
+ io.stderr.write("Unknown Amistio host helper mode. Use --help for usage.\n");
212
+ return 1;
213
+ }
214
+ function resultEnvelope(requestId, result, outputBudgetBytes = defaultOutputBudgetBytes) {
215
+ return {
216
+ protocolVersion: hostExecutionProtocolVersion,
217
+ requestId,
218
+ status: result.status,
219
+ exitCode: result.exitCode,
220
+ stdout: capUtf8Text(result.stdout, outputBudgetBytes),
221
+ stderr: capUtf8Text(result.stderr, outputBudgetBytes),
222
+ ...result.error ? { error: result.error } : {}
223
+ };
224
+ }
225
+ function unsupportedResult2(code, message) {
226
+ return { status: "unsupported", exitCode: 1, stdout: "", stderr: "", error: { code, message } };
227
+ }
228
+ function protocolMismatch(message) {
229
+ return { status: "protocolMismatch", exitCode: 1, stdout: "", stderr: "", error: { code: "protocol_mismatch", message } };
230
+ }
231
+ function sanitizeRequestEnvironment(request) {
232
+ const allowedNames = new Set(request.envAllowlist);
233
+ const env = {};
234
+ for (const [name, value] of Object.entries(request.env)) {
235
+ if (!allowedNames.has(name) || isSensitiveEnvironmentName(name)) {
236
+ continue;
237
+ }
238
+ env[name] = value;
239
+ }
240
+ return env;
241
+ }
242
+ function isNativeHostCommandRequestEnvelope(value) {
243
+ if (!isRecord(value) || value.protocolVersion !== hostExecutionProtocolVersion) return false;
244
+ return typeof value.requestId === "string" && value.requestId.trim().length > 0 && typeof value.command === "string" && value.command.trim().length > 0 && Array.isArray(value.args) && value.args.every((item) => typeof item === "string") && typeof value.cwd === "string" && value.cwd.trim().length > 0 && typeof value.shell === "boolean" && typeof value.timeoutMs === "number" && Number.isFinite(value.timeoutMs) && value.timeoutMs >= 0 && (value.stdin === "none" || value.stdin === "provided") && isStringRecord(value.env) && Array.isArray(value.envAllowlist) && value.envAllowlist.every((item) => typeof item === "string") && typeof value.streamOutput === "boolean" && typeof value.requirePty === "boolean" && isSandboxMode(value.sandbox) && isRecord(value.sandboxPolicy) && typeof value.outputBudgetBytes === "number" && Number.isFinite(value.outputBudgetBytes) && value.outputBudgetBytes >= 0;
245
+ }
246
+ function isSandboxMode(value) {
247
+ return value === "none" || value === "filesystemReadOnly" || value === "networkDisabled";
248
+ }
249
+ function isStringRecord(value) {
250
+ return isRecord(value) && Object.values(value).every((item) => typeof item === "string");
251
+ }
252
+ function requestIdFromUnknown(value) {
253
+ return isRecord(value) && typeof value.requestId === "string" && value.requestId.trim() ? value.requestId : void 0;
254
+ }
255
+ function readStdinLimited(stdin, limitBytes) {
256
+ return new Promise((resolve, reject) => {
257
+ let input = "";
258
+ let bytes = 0;
259
+ stdin.setEncoding("utf8");
260
+ stdin.on("data", (chunk) => {
261
+ bytes += Buffer.byteLength(chunk, "utf8");
262
+ if (bytes > limitBytes) {
263
+ reject(new Error(`Host helper request exceeded ${limitBytes} bytes.`));
264
+ stdin.pause();
265
+ return;
266
+ }
267
+ input += chunk;
268
+ });
269
+ stdin.on("error", reject);
270
+ stdin.on("end", () => resolve(input));
271
+ });
272
+ }
273
+ function capUtf8Text(value, budgetBytes) {
274
+ if (Buffer.byteLength(value, "utf8") <= budgetBytes) return value;
275
+ let capped = Buffer.from(value, "utf8").subarray(0, budgetBytes).toString("utf8");
276
+ while (Buffer.byteLength(capped, "utf8") > budgetBytes && capped.length > 0) {
277
+ capped = capped.slice(0, -1);
278
+ }
279
+ return capped;
280
+ }
281
+ function isSensitiveEnvironmentName(name) {
282
+ return /(?:TOKEN|SECRET|PASSWORD|PASS|KEY|CREDENTIAL|AUTH|OAUTH|COOKIE|SESSION|PRIVATE|CERT|SSH)/i.test(name);
283
+ }
284
+ function isRecord(value) {
285
+ return typeof value === "object" && value !== null && !Array.isArray(value);
286
+ }
287
+ function parseJson(value) {
288
+ try {
289
+ return JSON.parse(value);
290
+ } catch {
291
+ return void 0;
292
+ }
293
+ }
294
+ function positiveInteger(value) {
295
+ return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : void 0;
296
+ }
297
+ function errorMessage2(error) {
298
+ return error instanceof Error ? error.message : String(error);
299
+ }
300
+ function isDirectExecution(metaUrl) {
301
+ if (!process.argv[1]) return false;
302
+ try {
303
+ return realpathSync(fileURLToPath(metaUrl)) === realpathSync(process.argv[1]);
304
+ } catch {
305
+ return fileURLToPath(metaUrl) === fileURLToPath(new URL(`file://${process.argv[1]}`).href);
306
+ }
307
+ }
308
+ if (isDirectExecution(import.meta.url)) {
309
+ process.exitCode = await runOfficialHostHelperCli();
310
+ }
311
+ export {
312
+ createOfficialHostHelperHandshake,
313
+ executeOfficialHostHelperRequest,
314
+ runOfficialHostHelperCli
315
+ };