jev-layer 0.2.0 → 0.3.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.
@@ -1,15 +1,18 @@
1
1
  #!/usr/bin/env node
2
2
  import { createInterface } from "node:readline";
3
+ import { readFile } from "node:fs/promises";
3
4
  import { appendExecutionReceipt, appendRoutingCase, buildExecutionReceipt, replayCasePath } from "./receipts.mjs";
4
5
  import { configuredProvider, configuredReplayPath, loadConfig } from "./config.mjs";
5
6
  import { decideBrowserStep } from "./browser.mjs";
7
+ import { decideComputerStep } from "./computer.mjs";
6
8
  import { superviseWork } from "./supervision.mjs";
7
9
  import { buildShadowCompactionReport } from "./shadow-compaction.mjs";
8
10
  import { recommendModelRoute } from "./model-routing.mjs";
9
11
  import { routeRequest } from "./route.mjs";
10
12
 
11
13
  const { config } = await loadConfig();
12
- const serverInfo = { name: "jev-layer", version: "0.1.0" };
14
+ const { version } = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8"));
15
+ const serverInfo = { name: "jev-layer", version };
13
16
  const CASES_PATH = replayCasePath(configuredReplayPath(config));
14
17
  const pendingDecisions = new Map();
15
18
  const tools = [
@@ -23,6 +26,7 @@ const tools = [
23
26
  intent: { type: "string" },
24
27
  harness: { type: "string" },
25
28
  context: { type: "object" },
29
+ assets: { type: "object", description: "Inline PNG/JPEG assets keyed by reference; images are passed outside the text projection." },
26
30
  actor_permissions: { type: "array", items: { type: "string" } },
27
31
  capabilities: { type: "array", items: { type: "object" } },
28
32
  policy: { type: "object" },
@@ -41,7 +45,41 @@ const tools = [
41
45
  goal: { type: "string" },
42
46
  harness: { type: "string" },
43
47
  start_url: { type: ["string", "null"] },
44
- observation: { type: "object" },
48
+ observation: { type: "object", description: "Host snapshot: url, visible_text, targets, tabs, scroll, and optional screenshot {format,data_base64,width,height}." },
49
+ progress: { type: "object" },
50
+ actor_permissions: { type: "array", items: { type: "string" } },
51
+ policy: { type: "object" },
52
+ provider: { type: "string", enum: ["demo", "typesafe", "openrouter"] },
53
+ enabled: { type: "boolean" },
54
+ },
55
+ },
56
+ },
57
+ {
58
+ name: "jev_computer_step",
59
+ description: "Recommend one host-declared desktop action, optionally grounding a click in a screenshot. Advisory only: host validates freshness, permissions, approval and execution.",
60
+ inputSchema: {
61
+ type: "object",
62
+ required: ["goal", "observation", "actions", "actor_permissions"],
63
+ properties: {
64
+ goal: { type: "string" },
65
+ harness: { type: "string" },
66
+ observation: { type: "object", description: "Host screenshot {format,data_base64,width,height}, optional application and visible_text." },
67
+ actions: { type: "array", maxItems: 32, items: {
68
+ type: "object", required: ["id", "operation"],
69
+ properties: {
70
+ id: { type: "string" },
71
+ operation: { type: "string", enum: ["click", "type", "key", "scroll", "handoff"] },
72
+ args: { type: "object", description: "Exact host-declared arguments; Jev does not generate typing or key input." },
73
+ target: { type: "string", description: "Unique visible click target to locate when no x/y are supplied." },
74
+ description: { type: "string" },
75
+ risk: { type: "string", enum: ["low", "medium", "high", "critical"] },
76
+ available: { type: "boolean" },
77
+ availability: { type: "object" },
78
+ permissions: { type: "array", items: { type: "string" } },
79
+ policy: { type: "object" },
80
+ },
81
+ } },
82
+ actor_permissions: { type: "array", items: { type: "string" }, description: "Must explicitly include computer; never grants host permission." },
45
83
  policy: { type: "object" },
46
84
  provider: { type: "string", enum: ["demo", "typesafe", "openrouter"] },
47
85
  enabled: { type: "boolean" },
@@ -148,6 +186,7 @@ async function handle(message) {
148
186
  const args = params.arguments && typeof params.arguments === "object" ? params.arguments : {};
149
187
  if (params.name === "jev_route") return route(args, id);
150
188
  if (params.name === "jev_browser_step") return browserStep(args, id);
189
+ if (params.name === "jev_computer_step") return computerStep(args, id);
151
190
  if (params.name === "jev_supervise") return supervise(args, id);
152
191
  if (params.name === "jev_model_route") return modelRoute(args, id);
153
192
  if (params.name === "jev_shadow_compaction") return shadowCompaction(args, id);
@@ -175,17 +214,22 @@ async function browserStep(args, id) {
175
214
  provider: configuredProvider(config, requestedProvider),
176
215
  });
177
216
  routed.decision.browser_action = routed.action
178
- ? {
179
- id: routed.action.id,
180
- operation: routed.action.operation,
181
- target_id: routed.action.target_id ?? null,
182
- option_id: routed.action.option_id ?? null,
183
- consequential: routed.action.consequential,
184
- }
217
+ ? { ...routed.action, target_id: routed.action.target_id ?? null, option_id: routed.action.option_id ?? null }
185
218
  : null;
186
219
  return persistDecision(routed.request, routed.decision, id);
187
220
  }
188
221
 
222
+ async function computerStep(args, id) {
223
+ const { provider: requestedProvider, enabled, ...input } = args;
224
+ const routed = await decideComputerStep(input, {
225
+ config,
226
+ enabled: enabled ?? (process.env.JEV_COMPUTER_USE === "1" || config.features?.computer_use),
227
+ provider: configuredProvider(config, requestedProvider),
228
+ });
229
+ routed.decision.computer_action = routed.action;
230
+ return persistDecision(routed.request ?? { harness: input.harness ?? "computer", intent: input.goal }, routed.decision, id);
231
+ }
232
+
189
233
  async function supervise(args, id) {
190
234
  const { provider: requestedProvider, enabled, ...input } = args;
191
235
  const result = await superviseWork({
@@ -217,7 +261,9 @@ async function shadowCompaction(args, id) {
217
261
  }
218
262
 
219
263
  async function persistDecision(request, decision, id) {
220
- pendingDecisions.set(decision.correlation_id, { request, decision });
264
+ const { assets, ...retainedRequest } = request;
265
+ if (Object.keys(assets ?? {}).length) retainedRequest.assets_omitted = true;
266
+ pendingDecisions.set(decision.correlation_id, { request: retainedRequest, decision });
221
267
  try {
222
268
  await appendRoutingCase({ path: CASES_PATH, request, decision });
223
269
  decision.receipt = { ...decision.receipt, replay_case_id: decision.correlation_id, replay_persisted: true };
@@ -1,7 +1,57 @@
1
+ import { normalizeImageAssets } from "../images.mjs";
2
+
3
+ function containsReference(value, reference) {
4
+ if (typeof value === "string") return value.includes(reference);
5
+ return value !== null && typeof value === "object"
6
+ && Object.values(value).some((child) => containsReference(child, reference));
7
+ }
8
+
9
+ function openRouterImageInput(state, questions, images) {
10
+ const parts = Object.fromEntries(Object.entries(images).map(([reference, image]) => [reference, {
11
+ type: "image_url",
12
+ image_url: { url: `data:image/${image.format === "png" ? "png" : "jpeg"};base64,${image.data_base64}` },
13
+ }]));
14
+ const pattern = new RegExp(Object.keys(parts).sort((a, b) => b.length - a.length).map((key) => key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|"), "g");
15
+ const used = new Set();
16
+ function expand(value) {
17
+ if (typeof value === "string") {
18
+ if (Object.hasOwn(parts, value)) {
19
+ used.add(value);
20
+ return parts[value];
21
+ }
22
+ const content = [];
23
+ let offset = 0;
24
+ for (const match of value.matchAll(pattern)) {
25
+ if (match.index > offset) content.push(value.slice(offset, match.index));
26
+ content.push(parts[match[0]]);
27
+ used.add(match[0]);
28
+ offset = match.index + match[0].length;
29
+ }
30
+ if (!content.length) return value;
31
+ if (offset < value.length) content.push(value.slice(offset));
32
+ return content;
33
+ }
34
+ if (Array.isArray(value)) return value.map(expand);
35
+ if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([key, child]) => [key, expand(child)]));
36
+ return value;
37
+ }
38
+ const input = {
39
+ state: expand(state),
40
+ questions: Object.fromEntries(Object.entries(questions).map(([key, question]) => [key, {
41
+ ...question,
42
+ instructions: expand(question.instructions),
43
+ ...(question.criteria !== undefined ? { criteria: expand(question.criteria) } : {}),
44
+ }])),
45
+ };
46
+ if (used.size !== Object.keys(parts).length) throw new Error("every image asset must be referenced in state, instructions, or criteria");
47
+ return input;
48
+ }
49
+
1
50
  export class TypeSafeProvider {
2
51
  name = "typesafe";
52
+ supportsImages = true;
3
53
 
4
- constructor({ apiKey = process.env.TYPESAFE_API_KEY, endpoint = process.env.TYPESAFE_ENDPOINT ?? "https://api.typesafe.ai/v1/systemone", model = process.env.TYPESAFE_MODEL ?? "jev-latest", timeoutMs = 2_000, fetchImpl = globalThis.fetch } = {}) {
54
+ constructor({ apiKey = process.env.TYPESAFE_API_KEY, endpoint = process.env.TYPESAFE_ENDPOINT ?? "https://api.typesafe.ai/v1/systemone", model = process.env.TYPESAFE_MODEL ?? "jev-1.14.0", timeoutMs = 2_000, fetchImpl = globalThis.fetch } = {}) {
5
55
  this.apiKey = apiKey;
6
56
  this.endpoint = endpoint;
7
57
  this.model = model;
@@ -9,7 +59,7 @@ export class TypeSafeProvider {
9
59
  this.fetchImpl = fetchImpl;
10
60
  }
11
61
 
12
- async decide({ state, candidates }) {
62
+ async decide({ state, candidates, assets }) {
13
63
  const criteria = Object.fromEntries(candidates.map((candidate) => [candidate.id, {
14
64
  name: candidate.name,
15
65
  kind: candidate.kind,
@@ -18,6 +68,7 @@ export class TypeSafeProvider {
18
68
  }]));
19
69
  return this.evaluate({
20
70
  state,
71
+ assets,
21
72
  questions: {
22
73
  tool: {
23
74
  type: "choice",
@@ -28,27 +79,44 @@ export class TypeSafeProvider {
28
79
  });
29
80
  }
30
81
 
31
- async evaluate({ state, questions }) {
82
+ async evaluate({ state, questions, assets }) {
32
83
  if (!this.apiKey) throw new Error("TYPESAFE_API_KEY is not configured");
33
84
  if (typeof this.fetchImpl !== "function") throw new Error("fetch is unavailable");
85
+ const images = normalizeImageAssets(assets);
86
+ const hasImages = Object.keys(images).length > 0;
87
+ const hasBox = Object.values(questions).some((question) => question?.type === "box");
88
+ for (const reference of Object.keys(images)) {
89
+ if (!containsReference(state, reference)
90
+ && !Object.values(questions).some((question) => containsReference(question?.instructions, reference) || containsReference(question?.criteria, reference))) {
91
+ throw new Error("every image asset must be referenced in state, instructions, or criteria");
92
+ }
93
+ }
94
+ const body = JSON.stringify({ state, model: this.model, questions, ...(hasImages ? { assets: images } : {}) });
95
+ if (Buffer.byteLength(body) > 32 * 1024 * 1024) throw new Error("TypeSafe request exceeds the 32 MiB body limit");
34
96
  const response = await this.fetchImpl(this.endpoint, {
35
97
  method: "POST",
36
98
  headers: {
37
99
  Authorization: `Bearer ${this.apiKey}`,
38
100
  "Content-Type": "application/json",
101
+ ...(hasImages || hasBox ? { "TypeSafe-Danger-Zone-Images": "true" } : {}),
39
102
  },
40
- body: JSON.stringify({ state, model: this.model, questions }),
103
+ body,
41
104
  signal: AbortSignal.timeout(this.timeoutMs),
42
105
  }).catch((error) => {
43
106
  if (error?.name === "TimeoutError") throw new Error(`TypeSafe request timed out after ${this.timeoutMs}ms`);
107
+ if (hasImages) throw new Error("TypeSafe image request failed");
44
108
  throw error;
45
109
  });
46
110
 
47
111
  if (!response.ok) {
48
- const body = await response.text();
49
- throw new Error(`TypeSafe returned HTTP ${response.status}: ${body.slice(0, 240)}`);
112
+ // Image requests must not copy an upstream echo of image data into receipts.
113
+ const detail = hasImages ? "" : `: ${(await response.text()).slice(0, 240)}`;
114
+ throw new Error(`TypeSafe returned HTTP ${response.status}${detail}`);
50
115
  }
51
- const raw = await response.json();
116
+ const raw = await response.json().catch((error) => {
117
+ if (hasImages) throw new Error("TypeSafe image response is not valid JSON");
118
+ throw error;
119
+ });
52
120
  if (!raw || typeof raw !== "object" || !raw.answers || typeof raw.answers !== "object") {
53
121
  throw new Error("TypeSafe response has no answers object");
54
122
  }
@@ -57,10 +125,12 @@ export class TypeSafeProvider {
57
125
  }
58
126
 
59
127
  export class OpenRouterDecisionsProvider {
128
+ supportsImages = true;
129
+
60
130
  constructor({
61
131
  apiKey = process.env.OPENROUTER_API_KEY,
62
132
  endpoint = process.env.OPENROUTER_DECISIONS_ENDPOINT ?? "https://openrouter.ai/api/alpha/decisions",
63
- model = process.env.OPENROUTER_DECISIONS_MODEL ?? "typesafe/jev-1.13",
133
+ model = process.env.OPENROUTER_DECISIONS_MODEL ?? "typesafe/jev-1.14",
64
134
  timeoutMs = 5_000,
65
135
  fetchImpl = globalThis.fetch,
66
136
  httpReferer = process.env.OPENROUTER_HTTP_REFERER,
@@ -75,7 +145,7 @@ export class OpenRouterDecisionsProvider {
75
145
  this.httpReferer = httpReferer;
76
146
  this.appTitle = appTitle;
77
147
  }
78
- async decide({ state, candidates }) {
148
+ async decide({ state, candidates, assets }) {
79
149
  const criteria = Object.fromEntries(candidates.map((candidate) => [candidate.id, {
80
150
  name: candidate.name,
81
151
  kind: candidate.kind,
@@ -84,6 +154,7 @@ export class OpenRouterDecisionsProvider {
84
154
  }]));
85
155
  return this.evaluate({
86
156
  state,
157
+ assets,
87
158
  questions: {
88
159
  tool: {
89
160
  type: "choice",
@@ -94,9 +165,14 @@ export class OpenRouterDecisionsProvider {
94
165
  });
95
166
  }
96
167
 
97
- async evaluate({ state, questions }) {
168
+ async evaluate({ state, questions, assets }) {
98
169
  if (!this.apiKey) throw new Error("OPENROUTER_API_KEY is not configured");
99
170
  if (typeof this.fetchImpl !== "function") throw new Error("fetch is unavailable");
171
+ const images = normalizeImageAssets(assets);
172
+ const hasImages = Object.keys(images).length > 0;
173
+ const input = hasImages ? openRouterImageInput(state, questions, images) : { state, questions };
174
+ const body = JSON.stringify({ model: this.model, ...input });
175
+ if (Buffer.byteLength(body) > 32 * 1024 * 1024) throw new Error("OpenRouter Decisions request exceeds the 32 MiB body limit");
100
176
 
101
177
  const response = await this.fetchImpl(this.endpoint, {
102
178
  method: "POST",
@@ -106,18 +182,22 @@ export class OpenRouterDecisionsProvider {
106
182
  ...(this.httpReferer ? { "HTTP-Referer": this.httpReferer } : {}),
107
183
  ...(this.appTitle ? { "X-Title": this.appTitle } : {}),
108
184
  },
109
- body: JSON.stringify({ model: this.model, questions, state }),
185
+ body,
110
186
  signal: AbortSignal.timeout(this.timeoutMs),
111
187
  }).catch((error) => {
112
188
  if (error?.name === "TimeoutError") throw new Error(`OpenRouter Decisions request timed out after ${this.timeoutMs}ms`);
189
+ if (hasImages) throw new Error("OpenRouter Decisions image request failed");
113
190
  throw error;
114
191
  });
115
192
 
116
193
  if (!response.ok) {
117
- const body = await response.text();
118
- throw new Error(`OpenRouter Decisions returned HTTP ${response.status}: ${body.slice(0, 240)}`);
194
+ const detail = hasImages ? "" : `: ${(await response.text()).slice(0, 240)}`;
195
+ throw new Error(`OpenRouter Decisions returned HTTP ${response.status}${detail}`);
119
196
  }
120
- const raw = await response.json();
197
+ const raw = await response.json().catch((error) => {
198
+ if (hasImages) throw new Error("OpenRouter Decisions image response is not valid JSON");
199
+ throw error;
200
+ });
121
201
  if (!raw || typeof raw !== "object" || !raw.answers || typeof raw.answers !== "object") {
122
202
  throw new Error("OpenRouter Decisions response has no answers object");
123
203
  }
package/src/receipts.mjs CHANGED
@@ -167,6 +167,7 @@ function replayRequest(request) {
167
167
  actor_permissions: Array.isArray(request.actor_permissions) ? request.actor_permissions.filter((value) => typeof value === "string") : undefined,
168
168
  policy: request.policy && typeof request.policy === "object" ? request.policy : {},
169
169
  capabilities: Array.isArray(request.capabilities) ? request.capabilities.map(replayCapability) : [],
170
+ ...(request.assets_omitted || Object.keys(request.assets ?? {}).length ? { assets_omitted: true } : {}),
170
171
  };
171
172
  }
172
173
 
package/src/route.mjs CHANGED
@@ -3,11 +3,12 @@ import { projectState } from "./context-filter.mjs";
3
3
  import { discoverCapabilities } from "./discovery.mjs";
4
4
  import { decisionCandidates, DEFAULT_POLICY, deterministicCandidate, filterCapabilities, normalizeCapabilities } from "./registry.mjs";
5
5
  import { resolveProvider } from "./providers/index.mjs";
6
+ import { normalizeImageAssets } from "./images.mjs";
6
7
 
7
8
  export async function routeRequest(input, options = {}) {
8
9
  const discovered = options.discovery ? discoverCapabilities(options.discovery) : [];
9
10
  const request = normalizeRequest(discovered.length ? { ...input, capabilities: [...(Array.isArray(input?.capabilities) ? input.capabilities : []), ...discovered] } : input);
10
- if (options.engine === "jevrouter") return routeViaJevRouter(request, options);
11
+ if (options.engine === "jevrouter" && !input.assets && !input.assets_omitted) return routeViaJevRouter(request, options);
11
12
 
12
13
  const started = performance.now();
13
14
  const effectivePolicy = { ...DEFAULT_POLICY, ...request.policy, ...(options.policy ?? {}) };
@@ -28,6 +29,14 @@ export async function routeRequest(input, options = {}) {
28
29
  try {
29
30
  provider = resolveProvider(options.provider ?? "demo", options);
30
31
  base.provider = provider.name;
32
+ if (request.assets_omitted) throw new Error("visual replay requires the original images; stored screenshots are intentionally omitted");
33
+ const assets = normalizeImageAssets(request.assets);
34
+ if (Object.keys(assets).length) {
35
+ if (options.engine === "jevrouter" || provider.supportsImages !== true) throw new Error("provider does not support image decisions");
36
+ projection.state.visual_assets = Object.keys(assets);
37
+ projection.context_bytes = byteLength(projection.state);
38
+ }
39
+ request.assets = assets;
31
40
  } catch (error) {
32
41
  return fallback(base, assessments, "provider_error", error instanceof Error ? error.message : String(error), projection.state);
33
42
  }
@@ -66,7 +75,7 @@ export async function routeRequest(input, options = {}) {
66
75
 
67
76
  let raw;
68
77
  try {
69
- raw = await provider.decide({ state: projection.state, candidates: eligible });
78
+ raw = await provider.decide({ state: projection.state, candidates: eligible, assets: request.assets });
70
79
  } catch (error) {
71
80
  return fallback(base, assessments, "provider_error", error instanceof Error ? error.message : String(error), projection.state);
72
81
  }
@@ -6,6 +6,7 @@ import { test } from "node:test";
6
6
  import { buildBrowserRequest, browserActions, decideBrowserStep, runBrowserFastPath } from "../src/browser.mjs";
7
7
  import { readRoutingCases } from "../src/receipts.mjs";
8
8
  import { routeRequest } from "../src/route.mjs";
9
+ import { buildExecutionReceipt, buildRoutingCase } from "../src/receipts.mjs";
9
10
  function providerFor(choices) {
10
11
  let call = 0;
11
12
  return {
@@ -197,3 +198,25 @@ test("native select uses the optional host select executor", async () => {
197
198
  assert.equal(selectCalls, 1);
198
199
  assert.equal(executeCalls, 0);
199
200
  });
201
+
202
+ test("screenshots stay out of browser text context and durable replay", async () => {
203
+ const imageData = "private-screen-pixels";
204
+ const request = buildBrowserRequest({
205
+ goal: "inspect the screen",
206
+ observation: {
207
+ screenshot: { format: "png", data_base64: imageData, width: 280, height: 280 },
208
+ targets: [{ id: "inspect", role: "button", name: "Inspect" }],
209
+ },
210
+ });
211
+ assert.equal(JSON.stringify(request.context).includes(imageData), false);
212
+ const decision = await routeRequest(request, { provider: "demo" });
213
+ assert.equal(decision.status, "fallback");
214
+ const recorded = buildRoutingCase({ request, decision });
215
+ assert.equal(JSON.stringify(recorded).includes(imageData), false);
216
+ assert.equal(recorded.request.assets_omitted, true);
217
+ assert.equal(typeof recorded.request.context.browser.observation.screenshot.sha256, "string");
218
+ const execution = buildExecutionReceipt({ request, decision, host: {
219
+ status: "completed", result: { observation: { screenshot: { format: "png", data_base64: imageData } } },
220
+ } });
221
+ assert.equal(JSON.stringify(execution).includes(imageData), false);
222
+ });
@@ -0,0 +1,195 @@
1
+ import assert from "node:assert/strict";
2
+ import { createHash } from "node:crypto";
3
+ import { test } from "node:test";
4
+ import { decideComputerStep } from "../src/computer.mjs";
5
+ import { PNG } from "./image-fixture.mjs";
6
+
7
+ const SCREENSHOT = { format: "png", data_base64: PNG, width: 400, height: 300 };
8
+ const SCROLL = { id: "scroll-down", operation: "scroll", args: { direction: "down", amount: 120 } };
9
+ const CLICK = { id: "save", operation: "click", target: "the Save button", args: { button: "left" }, risk: "low" };
10
+ const BOX = { left: 0.2, right: 0.6, top: 0.1, bottom: 0.5 };
11
+
12
+ function input(overrides = {}) {
13
+ return { goal: "Inspect the application", observation: { screenshot: SCREENSHOT, visible_text: "Save", application: "Editor" }, actions: [SCROLL], actor_permissions: ["computer"], ...overrides };
14
+ }
15
+
16
+ function grounding(visibility = 0.99, confidence = 0.95, box = BOX) {
17
+ return { answers: { target_visible: { type: "noul", noul: visibility }, target_box: { type: "box", box, confidence } } };
18
+ }
19
+
20
+ function provider({ choice, confidence = 1, result = grounding(), error = false } = {}) {
21
+ return {
22
+ name: "computer-test", supportsImages: true, choices: [], groundings: [],
23
+ async decide(request) {
24
+ this.choices.push(request);
25
+ const selected = choice ?? request.candidates[0].id;
26
+ return { answers: { tool: { type: "choice", choice: selected, confidence, probabilities: { [selected]: confidence } } } };
27
+ },
28
+ async evaluate(request) {
29
+ this.groundings.push(request);
30
+ if (error) throw new Error("offline");
31
+ return result;
32
+ },
33
+ };
34
+ }
35
+
36
+ function assertFallback(result, type) {
37
+ assert.equal(result.action, null);
38
+ assert.equal(result.decision.status, "fallback");
39
+ if (type) assert.equal(result.decision.fallback.type, type);
40
+ assert.equal(result.decision.execution.enabled, false);
41
+ assert.equal(result.decision.correlation_id, result.decision.receipt.correlation_id);
42
+ }
43
+
44
+ test("computer advice is opt-in and an explicit disable overrides the environment", async () => {
45
+ const previous = process.env.JEV_COMPUTER_USE;
46
+ const engine = provider();
47
+ try {
48
+ delete process.env.JEV_COMPUTER_USE;
49
+ assertFallback(await decideComputerStep(input(), { provider: engine }), "computer_use_disabled");
50
+ process.env.JEV_COMPUTER_USE = "1";
51
+ assertFallback(await decideComputerStep(input(), { enabled: false, provider: engine }), "computer_use_disabled");
52
+ assert.equal(engine.choices.length, 0);
53
+ const enabled = await decideComputerStep(input(), { provider: engine });
54
+ assert.equal(enabled.decision.status, "selected");
55
+ assert.deepEqual(enabled.action.args, SCROLL.args);
56
+ assert.deepEqual(enabled.decision.execution, { enabled: false, status: "not_started" });
57
+ } finally {
58
+ if (previous === undefined) delete process.env.JEV_COMPUTER_USE;
59
+ else process.env.JEV_COMPUTER_USE = previous;
60
+ }
61
+ });
62
+
63
+ test("malformed screenshots and non-closed action arguments fail open before provider calls", async () => {
64
+ const engine = provider();
65
+ for (const candidate of [
66
+ input({ observation: {} }),
67
+ input({ observation: { screenshot: { ...SCREENSHOT, data_base64: "file:///screen.png" } } }),
68
+ input({ observation: { screenshot: { ...SCREENSHOT, data_base64: Buffer.from("not an image").toString("base64") } } }),
69
+ input({ observation: { screenshot: { ...SCREENSHOT, width: 800 } } }),
70
+ input({ actions: [{ ...SCROLL, operation: "shell", args: { command: "anything" } }] }),
71
+ input({ actions: [{ ...SCROLL, args: { direction: "down", command: "anything" } }] }),
72
+ input({ actions: [{ ...CLICK, target: undefined }] }),
73
+ input({ actions: [{ ...CLICK, args: { x: 400, y: 20 } }] }),
74
+ input({ actions: [{ ...CLICK, args: { x: 20 } }] }),
75
+ input({ actions: [SCROLL, SCROLL] }),
76
+ input({ actions: Array.from({ length: 33 }, (_, index) => ({ ...SCROLL, id: `scroll-${index}` })) }),
77
+ input({ policy: { min_target_visibility: Number.NaN } }),
78
+ input({ policy: { min_box_confidence: 0 } }),
79
+ ]) assertFallback(await decideComputerStep(candidate, { enabled: true, provider: engine }), "invalid_input");
80
+ assert.equal(engine.choices.length, 0);
81
+ assert.equal(engine.groundings.length, 0);
82
+ });
83
+
84
+ test("registry filters enforce explicit computer permission, availability, extra permissions, and risk", async () => {
85
+ const engine = provider();
86
+ for (const candidate of [
87
+ input({ actor_permissions: undefined }),
88
+ input({ actor_permissions: [] }),
89
+ input({ actions: [{ ...SCROLL, permissions: ["administrator"] }] }),
90
+ input({ actions: [{ ...SCROLL, available: false }] }),
91
+ input({ actions: [{ ...SCROLL, availability: { available: false, reason: "offline" } }] }),
92
+ input({ actions: [{ ...SCROLL, risk: "high" }], policy: { max_risk: "low" } }),
93
+ ]) {
94
+ const result = await decideComputerStep(candidate, { enabled: true, provider: engine });
95
+ assert.equal(result.decision.status, "no_decision");
96
+ assert.equal(result.action, null);
97
+ assert.equal(result.decision.execution.enabled, false);
98
+ assert.ok(result.decision.candidates.every((item) => item.filtered));
99
+ }
100
+ assert.equal(engine.choices.length, 0);
101
+ });
102
+
103
+ test("consequential actions require confirmation even at low risk", async () => {
104
+ for (const action of [
105
+ { id: "click", operation: "click", args: { x: 17, y: 29, button: "right" }, risk: "low" },
106
+ { id: "type", operation: "type", args: { text: "host supplied text" }, risk: "low" },
107
+ { id: "key", operation: "key", args: { keys: ["Control", "S"] }, risk: "low" },
108
+ { ...SCROLL, consequential: true },
109
+ { ...SCROLL, requires_confirmation: true },
110
+ { ...SCROLL, policy: { requires_confirmation: true } },
111
+ ]) {
112
+ const engine = provider();
113
+ const result = await decideComputerStep(input({ actions: [action], policy: { confirmation_risk_levels: [] } }), { enabled: true, provider: engine });
114
+ assert.equal(result.decision.status, "needs_confirmation");
115
+ assert.equal(result.decision.candidates[0].requires_confirmation, true);
116
+ assert.equal(result.decision.execution.enabled, false);
117
+ assert.equal(engine.groundings.length, 0);
118
+ }
119
+ });
120
+
121
+ test("only the chosen described click is grounded, once, and mapped to bound screenshot pixels", async () => {
122
+ const engine = provider({ choice: "save" });
123
+ const result = await decideComputerStep(input({ actions: [SCROLL, CLICK, { ...CLICK, id: "other", target: "Cancel button" }] }), { enabled: true, provider: engine });
124
+ assert.equal(result.decision.status, "needs_confirmation");
125
+ assert.deepEqual(result.action.args, { button: "left", x: 160, y: 90 });
126
+ assert.equal(result.action.target, CLICK.target);
127
+ assert.deepEqual(result.action.grounding, { box: BOX, visibility: 0.99, confidence: 0.95, coordinate_space: "screenshot_pixels" });
128
+ assert.equal(result.action.screenshot.sha256, `sha256:${createHash("sha256").update(Buffer.from(PNG, "base64")).digest("hex")}`);
129
+ assert.equal(result.action.screenshot.requires_current, true);
130
+ assert.equal(result.action.screenshot.width, 400);
131
+ assert.equal(result.action.screenshot.height, 300);
132
+ assert.equal(engine.groundings.length, 1);
133
+ assert.equal(JSON.stringify(result.request.context).includes(PNG), false);
134
+ assert.equal(JSON.stringify(result.decision).includes(PNG), false);
135
+ assert.deepEqual(CLICK.args, { button: "left" });
136
+ });
137
+
138
+ test("absent, malformed, and uncertain grounding yields no action while retaining routing correlation", async () => {
139
+ const badResults = [
140
+ [grounding(0), "target_not_visible"],
141
+ [grounding(0.899), "target_not_visible"],
142
+ [grounding(0.99, 0.799), "low_grounding_confidence"],
143
+ [grounding(Number.NaN), "malformed_grounding"],
144
+ [grounding(Infinity), "malformed_grounding"],
145
+ [grounding("0.99"), "malformed_grounding"],
146
+ [grounding(0.99, Infinity), "malformed_grounding"],
147
+ [{ answers: { target_box: grounding().answers.target_box } }, "malformed_grounding"],
148
+ [grounding(0.99, 0.95, { ...BOX, left: -0.1 }), "malformed_grounding"],
149
+ [grounding(0.99, 0.95, { ...BOX, right: 1.1 }), "malformed_grounding"],
150
+ [grounding(0.99, 0.95, { ...BOX, left: BOX.right }), "malformed_grounding"],
151
+ [grounding(0.99, 0.95, { ...BOX, top: BOX.bottom + 0.1 }), "malformed_grounding"],
152
+ [grounding(0.99, 0.95, { ...BOX, bottom: Number.NaN }), "malformed_grounding"],
153
+ ];
154
+ for (const [raw, type] of badResults) {
155
+ const result = await decideComputerStep(input({ actions: [CLICK] }), { enabled: true, provider: provider({ result: raw }) });
156
+ assertFallback(result, type);
157
+ assert.equal(result.decision.selected, CLICK.id);
158
+ assert.equal(result.decision.receipt.provider, "computer-test");
159
+ assert.equal(result.decision.receipt.candidate_count, 1);
160
+ }
161
+ });
162
+
163
+ test("host grounding thresholds are inclusive and edge pixels remain inside the screenshot", async () => {
164
+ const engine = provider({ result: grounding(0.95, 0.9, { left: 0.999, right: 1, top: 0.999, bottom: 1 }) });
165
+ const result = await decideComputerStep(input({ actions: [CLICK], policy: { min_target_visibility: 0.95, min_box_confidence: 0.9 } }), { enabled: true, provider: engine });
166
+ assert.deepEqual(result.action.args, { button: "left", x: 399, y: 299 });
167
+ const stricter = await decideComputerStep(input({ actions: [CLICK] }), { enabled: true, provider: engine, policy: { min_target_visibility: 0.96 } });
168
+ assertFallback(stricter, "target_not_visible");
169
+ });
170
+
171
+ test("route fallback, provider failures, unsupported images, and unknown selections never leak actions", async () => {
172
+ assertFallback(await decideComputerStep(input(), { enabled: true, provider: provider({ confidence: 0.2 }) }), "low_confidence");
173
+ assertFallback(await decideComputerStep(input(), { enabled: true, provider: provider({ choice: "not-host-supplied" }) }), "invalid_selection");
174
+ assertFallback(await decideComputerStep(input({ actions: [CLICK] }), { enabled: true, provider: provider({ error: true }) }), "provider_error");
175
+ const unavailable = provider();
176
+ unavailable.supportsImages = false;
177
+ assertFallback(await decideComputerStep(input(), { enabled: true, provider: unavailable }), "unsupported_images");
178
+ assert.equal(unavailable.choices.length, 0);
179
+ const noGrounding = provider();
180
+ delete noGrounding.evaluate;
181
+ assertFallback(await decideComputerStep(input({ actions: [CLICK] }), { enabled: true, provider: noGrounding }), "grounding_unavailable");
182
+ const broken = provider();
183
+ broken.decide = async () => { throw new Error("offline"); };
184
+ assertFallback(await decideComputerStep(input(), { enabled: true, provider: broken }), "provider_error");
185
+ });
186
+
187
+ test("handoff is advisory and nonselected described targets are never grounded", async () => {
188
+ const engine = provider({ choice: "handoff" });
189
+ const action = { id: "handoff", operation: "handoff", args: { reason: "Host planning needed" }, available: true, risk: "low" };
190
+ const result = await decideComputerStep(input({ actions: [CLICK, action] }), { enabled: true, provider: engine });
191
+ assert.equal(result.decision.status, "selected");
192
+ assert.equal(result.action.operation, "handoff");
193
+ assert.equal(engine.groundings.length, 0);
194
+ assert.equal(result.decision.execution.enabled, false);
195
+ });
@@ -3,9 +3,10 @@ import { mkdtemp, readFile } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { spawn } from "node:child_process";
6
+ import { fileURLToPath } from "node:url";
6
7
  import test from "node:test";
7
8
 
8
- const adapter = new URL("../src/hermes-adapter.mjs", import.meta.url).pathname;
9
+ const adapter = fileURLToPath(new URL("../src/hermes-adapter.mjs", import.meta.url));
9
10
 
10
11
  function run(lines, replayPath) {
11
12
  return new Promise((resolve, reject) => {
@@ -0,0 +1,2 @@
1
+ // Complete 400x300 still RGB PNG, generated with zlib and valid chunk CRCs.
2
+ export const PNG = "iVBORw0KGgoAAAANSUhEUgAAAZAAAAEsCAIAAABi1XKVAAABdElEQVR4nO3BMQEAAADCoPVPbQwfoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD+Bn+3AAEkkD9cAAAAAElFTkSuQmCC";