car-runtime 0.8.2 → 0.9.0

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/assets.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "_doc": "Canonical per-platform asset name registry for car-runtime npm. Read by install.js, scripts/release.sh, and .github/workflows/build.yml so the three never drift. Add a platform here first, then verify each consumer picks it up. x86_64-apple-darwin was dropped — Apple Silicon only on macOS.",
3
+ "platforms": {
4
+ "darwin-arm64": {
5
+ "node": "car-runtime.darwin-arm64.node",
6
+ "server": "car-server-darwin-arm64"
7
+ },
8
+ "linux-x64": {
9
+ "node": "car-runtime.linux-x64-gnu.node",
10
+ "server": "car-server-linux-x64-gnu"
11
+ },
12
+ "linux-arm64": {
13
+ "node": "car-runtime.linux-arm64-gnu.node",
14
+ "server": "car-server-linux-arm64-gnu"
15
+ },
16
+ "win32-x64": {
17
+ "node": "car-runtime.win32-x64-msvc.node",
18
+ "server": "car-server-win32-x64-msvc.exe"
19
+ }
20
+ }
21
+ }
package/bin/car-server ADDED
@@ -0,0 +1,46 @@
1
+ #!/usr/bin/env node
2
+ // Cross-platform shim that execs the platform-specific car-server
3
+ // binary downloaded by install.js. Pointed at by package.json's
4
+ // `bin` field so `npx --package=car-runtime car-server` works.
5
+ //
6
+ // Why a JS shim, not a direct binary in `bin`: npm's bin entries
7
+ // must be JS files (or have a hashbang) on POSIX, and the binary
8
+ // suffix (`.exe`) varies on Windows. A 20-line dispatcher keeps
9
+ // the package layout portable. Closes Parslee-ai/car-releases#36.
10
+
11
+ const path = require('node:path');
12
+ const fs = require('node:fs');
13
+ const { spawnSync } = require('node:child_process');
14
+
15
+ const pkgRoot = path.join(__dirname, '..');
16
+ const ASSETS = JSON.parse(
17
+ fs.readFileSync(path.join(pkgRoot, 'assets.json'), 'utf8'),
18
+ ).platforms;
19
+
20
+ const key = `${process.platform}-${process.arch}`;
21
+ const entry = ASSETS[key];
22
+ if (!entry) {
23
+ console.error(
24
+ `car-server is not available for ${key}. Supported: ${Object.keys(ASSETS).join(', ')}.`
25
+ );
26
+ process.exit(1);
27
+ }
28
+
29
+ const binary = path.join(pkgRoot, entry.server);
30
+
31
+ if (!fs.existsSync(binary)) {
32
+ console.error(
33
+ `car-server binary not found at ${binary}.\n` +
34
+ `The car-runtime install script downloads it from the GitHub release; ` +
35
+ `re-run \`npm install car-runtime\`, or set CAR_RUNTIME_SKIP_DOWNLOAD=1 ` +
36
+ `and place the binary at that path manually.`
37
+ );
38
+ process.exit(1);
39
+ }
40
+
41
+ const result = spawnSync(binary, process.argv.slice(2), { stdio: 'inherit' });
42
+ if (result.error) {
43
+ console.error(`car-server failed to spawn: ${result.error.message}`);
44
+ process.exit(1);
45
+ }
46
+ process.exit(result.status ?? 1);
package/index.d.ts CHANGED
@@ -23,7 +23,11 @@
23
23
  * - `openSession`, `closeSession`, `registerPolicy(sessionId)` — use
24
24
  * `session.open` / `session.close` JSON-RPC methods
25
25
  * - `stateSnapshot`, `stateKeys` — daemon-side endpoints pending
26
- * - `removeModel`, `registerModel` — daemon owns models_dir / models.json
26
+ * - `removeModel` — daemon owns models_dir / models.json
27
+ *
28
+ * (`registerModel` was re-exposed in #39 — it now proxies to the
29
+ * daemon's `models.register` JSON-RPC. See its docstring for
30
+ * the visibility caveat.)
27
31
  *
28
32
  * Daemon URL override: `CAR_DAEMON_URL=ws://...` (default
29
33
  * `ws://127.0.0.1:9100`).
@@ -359,8 +363,23 @@ export class CarRuntime {
359
363
  */
360
364
  listModelsUnified(): string;
361
365
 
362
- /** Register a model schema (persists to `~/.car/models.json`). */
363
- registerModel(schemaJson: string): void;
366
+ /**
367
+ * Register a `ModelSchema` via the daemon's `models.register`
368
+ * JSON-RPC method (Parslee-ai/car-releases#39). The schema is
369
+ * persisted to `~/.car/models.json` (replacing any existing
370
+ * entry with the same `id`).
371
+ *
372
+ * **Visibility limitation**: the model becomes visible to
373
+ * `infer` / `models.list` / `models.list_unified` on the **next
374
+ * daemon boot**. Live hot-update inside a running daemon is
375
+ * tracked as a separate follow-up that requires interior
376
+ * mutability on the `UnifiedRegistry`. Register before
377
+ * starting the daemon's inference path, or restart the daemon
378
+ * after a batch of registrations.
379
+ *
380
+ * Returns JSON `{id, registered, path, note}`.
381
+ */
382
+ registerModel(schemaJson: string): Promise<string>;
364
383
 
365
384
  /** Route a prompt. Returns the routing decision as JSON. */
366
385
  routeModel(prompt: string): Promise<string>;
@@ -385,6 +404,26 @@ export class CarRuntime {
385
404
  /** Verify a proposal against this runtime's state + tools. Returns JSON. */
386
405
  verifyProposal(proposalJson: string): Promise<string>;
387
406
 
407
+ /**
408
+ * Submit a proposal for daemon-side execution using the
409
+ * persistent `tools.execute` handler set by
410
+ * `registerToolHandler` (Parslee-ai/car-releases#38).
411
+ *
412
+ * Symmetric to `executeProposal` but without the per-call
413
+ * handler argument — the handler is process-wide. Fails up
414
+ * front if no handler is registered.
415
+ *
416
+ * `sessionId`, when provided, scopes per-action policy
417
+ * validation to a session opened via the daemon's
418
+ * `session.policy.open` JSON-RPC method.
419
+ *
420
+ * Returns the JSON-encoded execution result.
421
+ */
422
+ submitProposal(
423
+ proposalJson: string,
424
+ sessionId?: string | null,
425
+ ): Promise<string>;
426
+
388
427
  // --- Browser automation ---
389
428
 
390
429
  /**
@@ -611,12 +650,21 @@ export class CarRuntime {
611
650
  * policies still apply, plus the session's. Without a session id the
612
651
  * behavior matches the no-scope path bit-for-bit. See
613
652
  * `docs/proposals/per-session-policy-scoping.md`.
653
+ *
654
+ * `scopeJson`, when provided, is a serialized `RuntimeScope` —
655
+ * `{ callerId?: string, tenantId?: string, claims?: Record<string, any> }`
656
+ * — attaching per-execution caller / tenant identity. When `tenantId`
657
+ * is set, the runtime routes per-action state R/W through the
658
+ * tenant-scoped view so distinct tenants can't observe each other's
659
+ * keys (Parslee-ai/car#187 phase 3). Single-tenant in-process callers
660
+ * pass `null` / omit and see no behaviour change.
614
661
  */
615
662
  export function executeProposal(
616
663
  rt: CarRuntime,
617
664
  proposalJson: string,
618
665
  toolFn: (callJson: string) => Promise<string>,
619
666
  sessionId?: string | null,
667
+ scopeJson?: string | null,
620
668
  ): Promise<string>;
621
669
 
622
670
  /**
@@ -790,6 +838,36 @@ export function registerVoiceEventHandler(
790
838
  onEvent: (sessionId: string, eventJson: string) => void,
791
839
  ): void;
792
840
 
841
+ /**
842
+ * Register the JS `tools.execute` handler for `submitProposal`
843
+ * (Parslee-ai/car-releases#38). When the daemon dispatches a
844
+ * proposal carrying host-owned tools, every tool routes through
845
+ * this handler.
846
+ *
847
+ * `handlerFn(callJson)` receives `{"tool":"name","params":{...}}`
848
+ * as a JSON string and MUST return a Promise resolving to the
849
+ * tool's JSON-encoded result. Throwing rejects the daemon-side
850
+ * action with a -32000 JSON-RPC error.
851
+ *
852
+ * Process-wide setter — re-calling overwrites the previous
853
+ * handler. Pair with `unregisterToolHandler` to clear. Symmetric
854
+ * to `registerInferenceRunner` / `registerAgentRunner`: only one
855
+ * handler can be active at a time.
856
+ *
857
+ * Required before `submitProposal`. `executeProposal` continues
858
+ * to accept a per-call handler and does not use this registration.
859
+ */
860
+ export function registerToolHandler(
861
+ handlerFn: (callJson: string) => Promise<string>,
862
+ ): void;
863
+
864
+ /**
865
+ * Clear the registered `tools.execute` handler. `submitProposal`
866
+ * calls after this will fail if the proposal carries any
867
+ * host-tool actions.
868
+ */
869
+ export function unregisterToolHandler(): void;
870
+
793
871
  export function transcribeStream(
794
872
  rt: CarRuntime,
795
873
  sessionId: string,
@@ -1049,13 +1127,25 @@ export function reapStaleAgents(
1049
1127
  * "agent_name": "...", // optional
1050
1128
  * "agent_description": "...", // optional
1051
1129
  * "organization": "...", // optional
1052
- * "organization_url": "..." // optional
1130
+ * "organization_url": "...", // optional
1131
+ * "share_session_runtime": false // optional, default false
1053
1132
  * }
1054
1133
  * ```
1055
1134
  *
1135
+ * `share_session_runtime` — when `true`, the A2A dispatcher uses
1136
+ * the calling `CarRuntime`'s session runtime instead of spawning a
1137
+ * fresh one. Tools registered on the session via
1138
+ * `registerToolSchema` then appear on the Agent Card's `skills`
1139
+ * list, and A2A peer `message/send` calls for those tools route
1140
+ * back to the handler installed via `registerToolHandler`. This is
1141
+ * the canonical path for host-language agents to project themselves
1142
+ * over A2A. Default `false` preserves the legacy fresh-Runtime
1143
+ * behaviour (only `register_agent_basics` tools, dispatch in Rust).
1144
+ *
1056
1145
  * Returns `'{"bound":"127.0.0.1:8731"}'` on success. Errors if a
1057
- * server is already running, the bind fails, or `paramsJson` is
1058
- * malformed.
1146
+ * server is already running, the bind fails, `share_session_runtime`
1147
+ * is set but no session runtime is available (e.g. invoked from a
1148
+ * non-WS path), or `paramsJson` is malformed.
1059
1149
  */
1060
1150
  export function startA2aServer(paramsJson: string): Promise<string>;
1061
1151
 
@@ -1363,6 +1453,30 @@ export function agentsHealth(): Promise<string>;
1363
1453
  */
1364
1454
  export function agentsUpsert(specJson: string): Promise<string>;
1365
1455
 
1456
+ /**
1457
+ * Install a contributed-agent `AgentManifest` (Parslee-ai/car#182
1458
+ * phase 3). Runs install-time validation against the daemon's
1459
+ * default host capability advertisement:
1460
+ *
1461
+ * - `runtime.car_min_version` must be satisfied by the runtime's
1462
+ * own semver.
1463
+ * - Every `capabilities.required[namespace][feature]` must be
1464
+ * advertised by the host. Fail-closed on any miss.
1465
+ * - `capabilities.optional` is reported back as `missingOptional`
1466
+ * when the host can't satisfy it — informational, not blocking.
1467
+ *
1468
+ * For `external_process` manifests with a `command`, the
1469
+ * supervisor adopts the agent and returns it. For `pure_data`
1470
+ * and `health_url`-only manifests, the manifest is written to
1471
+ * `~/.car/agents/<id>/manifest.toml` but no `AgentSpec` is
1472
+ * adopted (the supervisor only spawns command-shaped externals
1473
+ * in this phase).
1474
+ *
1475
+ * Returns JSON
1476
+ * `{report: {missingOptional: [{namespace, feature}]}, agent: ManagedAgent|null}`.
1477
+ */
1478
+ export function agentsInstall(manifestJson: string): Promise<string>;
1479
+
1366
1480
  /**
1367
1481
  * Remove an agent's spec. Stops the running child first if it's up.
1368
1482
  * Idempotent — `{removed: false}` when nothing matched.
package/index.js CHANGED
@@ -4,9 +4,10 @@
4
4
  const os = require('node:os');
5
5
  const path = require('node:path');
6
6
 
7
+ // darwin-x64 was dropped — Apple Silicon only on macOS. See the
8
+ // "macOS x86_64 support dropped" CHANGELOG entry.
7
9
  const platformMap = {
8
10
  'darwin-arm64': 'darwin-arm64',
9
- 'darwin-x64': 'darwin-x64',
10
11
  'linux-x64': 'linux-x64-gnu',
11
12
  'linux-arm64': 'linux-arm64-gnu',
12
13
  'win32-x64': 'win32-x64-msvc',
package/install.js CHANGED
@@ -1,8 +1,19 @@
1
1
  #!/usr/bin/env node
2
- // Downloads the native Node.js binary matching this package's version and
3
- // the host platform from the car-releases GitHub repo. Cached next to this
4
- // script so `require('./car-runtime.<platform>.node')` resolves without
2
+ // Downloads the native artifacts (Node.js `.node` module + the
3
+ // `car-server` daemon binary) matching this package's version and
4
+ // the host platform from the car-releases GitHub repo. Cached
5
+ // next to this script so `require('./car-runtime.<platform>.node')`
6
+ // and `npx --package=car-runtime car-server` both resolve without
5
7
  // another network round-trip.
8
+ //
9
+ // Closes Parslee-ai/car-releases#36 followup: prior versions
10
+ // shipped only the .node and broke `npx … car-server` despite the
11
+ // README promising both.
12
+ //
13
+ // Asset names live in `assets.json` — single source of truth
14
+ // across install.js, scripts/release.sh, and the CI build flow.
15
+ // Per neo + Linus review feedback (this PR): consolidating the
16
+ // list closes a 3-source drift hazard the prior commit left open.
6
17
 
7
18
  const fs = require('node:fs');
8
19
  const path = require('node:path');
@@ -10,23 +21,22 @@ const http = require('node:http');
10
21
  const https = require('node:https');
11
22
  const { pipeline } = require('node:stream/promises');
12
23
 
13
- const PLATFORMS = {
14
- 'darwin-arm64': 'car-runtime.darwin-arm64.node',
15
- 'darwin-x64': 'car-runtime.darwin-x64.node',
16
- 'linux-x64': 'car-runtime.linux-x64-gnu.node',
17
- 'linux-arm64': 'car-runtime.linux-arm64-gnu.node',
18
- 'win32-x64': 'car-runtime.win32-x64-msvc.node',
19
- };
24
+ // Platform asset names read from the canonical assets.json registry.
25
+ // darwin-x64 was removed there — Apple Silicon only on macOS. See the
26
+ // "macOS x86_64 support dropped" CHANGELOG entry.
27
+ const ASSETS = JSON.parse(
28
+ fs.readFileSync(path.join(__dirname, 'assets.json'), 'utf8'),
29
+ ).platforms;
20
30
 
21
31
  function targetForHost() {
22
32
  const key = `${process.platform}-${process.arch}`;
23
- const name = PLATFORMS[key];
24
- if (!name) {
33
+ const entry = ASSETS[key];
34
+ if (!entry) {
25
35
  throw new Error(
26
- `Unsupported platform ${key}. Supported: ${Object.keys(PLATFORMS).join(', ')}.`,
36
+ `Unsupported platform ${key}. Supported: ${Object.keys(ASSETS).join(', ')}.`,
27
37
  );
28
38
  }
29
- return name;
39
+ return { key, nodeName: entry.node, serverName: entry.server };
30
40
  }
31
41
 
32
42
  function pkgVersion() {
@@ -61,46 +71,87 @@ function get(url) {
61
71
  });
62
72
  }
63
73
 
74
+ async function downloadAsset(assetName, destPath, { executable = false } = {}) {
75
+ if (fs.existsSync(destPath)) {
76
+ return { skipped: true };
77
+ }
78
+ const tmp = `${destPath}.part`;
79
+ // Clean stale .part files from a prior crashed install — without
80
+ // this, fs.createWriteStream's default flag is 'w' which truncates
81
+ // (fine for content) but a half-written file from a *previous*
82
+ // process exit between writeStream end + rename leaves an orphan
83
+ // .part on disk that nothing cleans up. Best-effort unlink.
84
+ if (fs.existsSync(tmp)) {
85
+ try {
86
+ fs.unlinkSync(tmp);
87
+ } catch (_) {
88
+ // Non-fatal — pipeline will overwrite with 'w' mode regardless.
89
+ }
90
+ }
91
+ const version = pkgVersion();
92
+ const base = process.env.CAR_RUNTIME_DOWNLOAD_BASE;
93
+ const url = base
94
+ ? `${base}/${assetName}`
95
+ : `https://github.com/Parslee-ai/car-releases/releases/download/v${version}/${assetName}`;
96
+ console.log(`[car-runtime] downloading ${url}`);
97
+ const res = await get(url);
98
+ await pipeline(res, fs.createWriteStream(tmp));
99
+ fs.renameSync(tmp, destPath);
100
+ if (executable && process.platform !== 'win32') {
101
+ // npm strips the execute bit from non-bin files. car-server is
102
+ // exec'd by bin/car-server through child_process.spawn, which
103
+ // needs the bit. chmod here so the bin shim doesn't have to
104
+ // re-stat + chmod on first run.
105
+ try {
106
+ fs.chmodSync(destPath, 0o755);
107
+ } catch (e) {
108
+ console.warn(
109
+ `[car-runtime] chmod +x on ${destPath} failed: ${e.message}. ` +
110
+ `\`npx --package=car-runtime car-server\` may fail with EACCES.`
111
+ );
112
+ }
113
+ }
114
+ console.log(`[car-runtime] installed ${assetName}`);
115
+ return { skipped: false };
116
+ }
117
+
64
118
  async function main() {
65
- const name = targetForHost();
66
- const dest = path.join(__dirname, name);
119
+ const { nodeName, serverName } = targetForHost();
67
120
 
68
- // Opt-out for air-gapped installs — user is expected to drop the .node
69
- // file into this directory themselves.
121
+ // Opt-out for air-gapped installs — user is expected to drop both
122
+ // the .node file AND the car-server binary into this directory
123
+ // themselves before requiring the package.
70
124
  if (process.env.CAR_RUNTIME_SKIP_DOWNLOAD === '1') {
71
125
  console.log('[car-runtime] CAR_RUNTIME_SKIP_DOWNLOAD=1 — skipping download.');
72
126
  return;
73
127
  }
74
128
 
75
- // Skip if already present (e.g. preinstalled, reinstall without cache wipe).
76
- if (fs.existsSync(dest)) {
77
- return;
78
- }
79
-
80
- const version = pkgVersion();
81
- const url = `https://github.com/Parslee-ai/car-releases/releases/download/v${version}/${name}`;
129
+ const nodeDest = path.join(__dirname, nodeName);
130
+ const serverDest = path.join(__dirname, serverName);
82
131
 
83
- // Override for testing / mirrors.
84
- const base = process.env.CAR_RUNTIME_DOWNLOAD_BASE;
85
- const finalUrl = base ? `${base}/${name}` : url;
86
-
87
- console.log(`[car-runtime] downloading ${finalUrl}`);
88
- try {
89
- const res = await get(finalUrl);
90
- const tmp = `${dest}.part`;
91
- await pipeline(res, fs.createWriteStream(tmp));
92
- fs.renameSync(tmp, dest);
93
- console.log(`[car-runtime] installed ${name}`);
94
- } catch (err) {
95
- console.error(
96
- `[car-runtime] download failed: ${err.message}\n` +
97
- `Place ${name} manually in ${__dirname} or set CAR_RUNTIME_SKIP_DOWNLOAD=1 ` +
98
- `and ensure the binary is resolvable at require-time.`,
99
- );
100
- // Exit non-zero so `npm install` fails loudly instead of silently producing
101
- // a broken package. CAR_RUNTIME_SKIP_DOWNLOAD=1 remains the escape hatch
102
- // for air-gapped installs; see the opt-out branch above.
103
- process.exit(1);
132
+ // Both downloads are required. Failing one but not the other
133
+ // produces a half-working package (e.g. `require('car-runtime')`
134
+ // works but `npx --package=car-runtime car-server` fails at
135
+ // exec time) — strictly worse than `npm install` failing loudly
136
+ // here. The CI preflight gate ensures the release has both
137
+ // assets before publish, so under normal operation neither
138
+ // download fails for a missing asset; the only failure mode
139
+ // is network / mirror flakiness, which retries handle better
140
+ // than runtime ENOENT.
141
+ for (const { name, dest, executable, kind } of [
142
+ { name: nodeName, dest: nodeDest, executable: false, kind: '.node module' },
143
+ { name: serverName, dest: serverDest, executable: true, kind: 'car-server binary' },
144
+ ]) {
145
+ try {
146
+ await downloadAsset(name, dest, { executable });
147
+ } catch (err) {
148
+ console.error(
149
+ `[car-runtime] ${kind} download failed: ${err.message}\n` +
150
+ `Place ${name} manually in ${__dirname} or set CAR_RUNTIME_SKIP_DOWNLOAD=1 ` +
151
+ `and ensure both the .node module and car-server binary are present.`,
152
+ );
153
+ process.exit(1);
154
+ }
104
155
  }
105
156
  }
106
157
 
package/package.json CHANGED
@@ -1,9 +1,12 @@
1
1
  {
2
2
  "name": "car-runtime",
3
- "version": "0.8.2",
3
+ "version": "0.9.0",
4
4
  "description": "Common Agent Runtime — a deterministic execution layer for AI agents",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
7
+ "bin": {
8
+ "car-server": "bin/car-server"
9
+ },
7
10
  "keywords": [
8
11
  "ai",
9
12
  "agent",
@@ -34,6 +37,8 @@
34
37
  "index.js",
35
38
  "index.d.ts",
36
39
  "install.js",
40
+ "assets.json",
41
+ "bin/car-server",
37
42
  "README.md",
38
43
  "LICENSE"
39
44
  ],