@opengeni/runtime 3.2.2 → 3.3.1-canary.38922642121001

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.
Files changed (57) hide show
  1. package/dist/agent-LJT5NPWT.js +9 -0
  2. package/dist/assets/codemode-client.json +1 -1
  3. package/dist/{chunk-HJSIPTFA.js → chunk-4XDJ4TJA.js} +2 -2
  4. package/dist/{chunk-XQ23VCZ4.js → chunk-6FQ6KEII.js} +3 -3
  5. package/dist/{chunk-7LJ7KDPI.js → chunk-ERD4ICVB.js} +4 -3
  6. package/dist/{chunk-YG26XE7B.js → chunk-JYDG2A7H.js} +2 -2
  7. package/dist/{chunk-RSHQSLEJ.js → chunk-KIL4FGX5.js} +3 -3
  8. package/dist/{chunk-NWS4HPB7.js → chunk-MIAL5I4O.js} +2 -2
  9. package/dist/{chunk-7OUG7WBV.js → chunk-N5EB52EH.js} +3 -3
  10. package/dist/{chunk-Z5U6WE22.js → chunk-PRZPGRIK.js} +2 -2
  11. package/dist/{chunk-KCPW4K7L.js → chunk-PZQUUBEB.js} +2 -2
  12. package/dist/{chunk-2AUDDDO2.js → chunk-XJARX6VV.js} +194 -23
  13. package/dist/chunk-XJARX6VV.js.map +1 -0
  14. package/dist/{chunk-5WJOPANS.js → chunk-Y5THZH5N.js} +7 -7
  15. package/dist/{chunk-462CH77G.js → chunk-ZUHGY36R.js} +181 -8
  16. package/dist/chunk-ZUHGY36R.js.map +1 -0
  17. package/dist/gmail-rest-mcp.js +8 -8
  18. package/dist/index.d.ts +3 -0
  19. package/dist/index.js +14 -12
  20. package/dist/interaction-tool-json-schema.d.ts +20 -0
  21. package/dist/{lazy-manifest-AJ6XPJ4F.js → lazy-manifest-BLZ3UZLX.js} +4 -4
  22. package/dist/{localSources-SSIEH5F5.js → localSources-I6SIQ6CX.js} +6 -6
  23. package/dist/model-call.js +3 -3
  24. package/dist/sandbox/browser-control-client.d.ts +10 -0
  25. package/dist/sandbox/browser-control-http.d.ts +33 -0
  26. package/dist/sandbox/index.js +7 -7
  27. package/dist/sandbox/routing/backend-resolver.d.ts +1 -0
  28. package/dist/sandbox/selfhosted/session.d.ts +19 -0
  29. package/dist/slack-rest-mcp.js +8 -8
  30. package/dist/workspace-tool-gateway.js +12 -12
  31. package/package.json +14 -14
  32. package/src/bundled_site_skills/opengeni-sites/SKILL.md +46 -12
  33. package/src/index.ts +39 -1
  34. package/src/interaction-tool-json-schema.ts +214 -0
  35. package/src/interaction-tools.ts +6 -6
  36. package/src/sandbox/browser-control-client.ts +89 -0
  37. package/src/sandbox/browser-control-http.ts +32 -0
  38. package/src/sandbox/routing/backend-resolver.ts +2 -0
  39. package/src/sandbox/selfhosted/session.ts +116 -0
  40. package/dist/agent-CTPLA3HP.js +0 -9
  41. package/dist/browser-action-json-schema.d.ts +0 -4
  42. package/dist/chunk-2AUDDDO2.js.map +0 -1
  43. package/dist/chunk-462CH77G.js.map +0 -1
  44. package/src/browser-action-json-schema.ts +0 -10
  45. /package/dist/{agent-CTPLA3HP.js.map → agent-LJT5NPWT.js.map} +0 -0
  46. /package/dist/{chunk-HJSIPTFA.js.map → chunk-4XDJ4TJA.js.map} +0 -0
  47. /package/dist/{chunk-XQ23VCZ4.js.map → chunk-6FQ6KEII.js.map} +0 -0
  48. /package/dist/{chunk-7LJ7KDPI.js.map → chunk-ERD4ICVB.js.map} +0 -0
  49. /package/dist/{chunk-YG26XE7B.js.map → chunk-JYDG2A7H.js.map} +0 -0
  50. /package/dist/{chunk-RSHQSLEJ.js.map → chunk-KIL4FGX5.js.map} +0 -0
  51. /package/dist/{chunk-NWS4HPB7.js.map → chunk-MIAL5I4O.js.map} +0 -0
  52. /package/dist/{chunk-7OUG7WBV.js.map → chunk-N5EB52EH.js.map} +0 -0
  53. /package/dist/{chunk-Z5U6WE22.js.map → chunk-PRZPGRIK.js.map} +0 -0
  54. /package/dist/{chunk-KCPW4K7L.js.map → chunk-PZQUUBEB.js.map} +0 -0
  55. /package/dist/{chunk-5WJOPANS.js.map → chunk-Y5THZH5N.js.map} +0 -0
  56. /package/dist/{lazy-manifest-AJ6XPJ4F.js.map → lazy-manifest-BLZ3UZLX.js.map} +0 -0
  57. /package/dist/{localSources-SSIEH5F5.js.map → localSources-I6SIQ6CX.js.map} +0 -0
package/src/index.ts CHANGED
@@ -117,6 +117,7 @@ import { createSecretArgumentResolver } from "./secret-argument-resolution";
117
117
  import {
118
118
  approvalIdentifier,
119
119
  INTERACTION_REQUEST_HUMAN_MODEL_TOOL_NAME,
120
+ RequestHumanInteractionToolInput,
120
121
  CAPABILITY_DESCRIPTORS,
121
122
  DEFAULT_FIRST_PARTY_MCP_PERMISSIONS,
122
123
  DEFAULT_FIRST_PARTY_MCP_TOOLS,
@@ -303,6 +304,7 @@ import {
303
304
  applyDiff,
304
305
  RunContext,
305
306
  ToolGuardrailFunctionOutputFactory,
307
+ defineToolInputGuardrail,
306
308
  type AgentInputItem,
307
309
  type CallModelInputFilter,
308
310
  type MCPServer,
@@ -3881,6 +3883,23 @@ function activeModelToolInvocation(modelName: string): ModelToolInvocation | nul
3881
3883
  return invocation?.modelName === modelName ? invocation : null;
3882
3884
  }
3883
3885
 
3886
+ /** Why interaction_request_human arguments cannot be recorded as an
3887
+ * intervention, in words the model can act on, or null when they can. */
3888
+ export function interactionRequestArgumentsProblem(serialized: string): string | null {
3889
+ let value: unknown;
3890
+ try {
3891
+ value = JSON.parse(serialized);
3892
+ } catch {
3893
+ return "Invalid interaction_request_human arguments: they are not valid JSON. Nothing was requested; call it again with a JSON object matching its schema.";
3894
+ }
3895
+ const parsed = RequestHumanInteractionToolInput.safeParse(value);
3896
+ if (parsed.success) return null;
3897
+ const issues = parsed.error.issues
3898
+ .slice(0, 5)
3899
+ .map((issue) => `${issue.path.join(".") || "arguments"}: ${issue.message}`);
3900
+ return `Invalid interaction_request_human arguments: ${issues.join("; ")}. Nothing was requested; call it again with corrected arguments.`;
3901
+ }
3902
+
3884
3903
  /**
3885
3904
  * Turn the canonical interaction-request tool into a typed SDK interruption.
3886
3905
  * Its MCP/Codemode catalog entry and execution stay unchanged; this projection
@@ -3903,6 +3922,22 @@ function installInteractionInterventionPolicy(
3903
3922
  const originalInvoke = tool.invoke.bind(tool);
3904
3923
  return {
3905
3924
  ...tool,
3925
+ // Runs before the approval pause, so invalid arguments come back to
3926
+ // the model as a correctable tool result. Without it the call would
3927
+ // pause first, the intervention could never be recorded, and the
3928
+ // turn would fail.
3929
+ inputGuardrails: [
3930
+ ...(tool.inputGuardrails ?? []),
3931
+ defineToolInputGuardrail({
3932
+ name: "validate_interaction_request_human",
3933
+ run: async ({ toolCall }) => {
3934
+ const problem = interactionRequestArgumentsProblem(toolCall.arguments);
3935
+ return problem
3936
+ ? ToolGuardrailFunctionOutputFactory.rejectContent(problem)
3937
+ : ToolGuardrailFunctionOutputFactory.allow();
3938
+ },
3939
+ }),
3940
+ ],
3906
3941
  needsApproval: async (
3907
3942
  _runContext: Parameters<typeof originalNeedsApproval>[0],
3908
3943
  _parsedInput: Parameters<typeof originalNeedsApproval>[1],
@@ -8469,7 +8504,10 @@ export class PrefixedMcpServer implements MCPServer {
8469
8504
  return {
8470
8505
  ...tool,
8471
8506
  name,
8472
- ...(this.accountLabel
8507
+ // Name the account only when the turn has several accounts of this
8508
+ // connector to tell apart. With one account the line adds nothing
8509
+ // the model can act on, and it would repeat on every tool.
8510
+ ...(this.accountLabel?.account
8473
8511
  ? {
8474
8512
  description:
8475
8513
  `Account: ${this.accountLabel.model}. Use only this account; if the intended account for a write is unclear, ask before calling.\n${tool.description ?? ""}`.trimEnd(),
@@ -0,0 +1,214 @@
1
+ import type { AttemptToolJsonSchema } from "@opengeni/contracts";
2
+ import { z } from "zod";
3
+
4
+ type JsonSchemaNode = Record<string, unknown>;
5
+
6
+ /** Keywords that only constrain validation. The tool's Zod schema still
7
+ * enforces them before execution, and rejects with the exact field. */
8
+ const VALIDATION_ONLY_KEYWORDS = new Set(["$schema", "$id", "minLength", "maxLength"]);
9
+ /** Larger numeric bounds are overflow guards, not guidance. */
10
+ const MEANINGFUL_NUMERIC_BOUND = 100_000;
11
+ const MEANINGFUL_MAX_ITEMS = 64;
12
+ /** Longer patterns (UUIDs, control-character guards) are validation noise. */
13
+ const MEANINGFUL_PATTERN_LENGTH = 32;
14
+
15
+ /**
16
+ * The JSON Schema an interaction tool shows the model.
17
+ *
18
+ * Model providers expand every `$ref` when they render a tool, so a tagged
19
+ * union such as a browser action, whose locator union repeats in each of its
20
+ * twenty variants, costs several thousand tokens. Each tagged union of objects
21
+ * is shown as one object instead: the tag lists every variant, each field
22
+ * appears once, and one compact line names the fields each variant takes.
23
+ * Validation-only limits are left out. The tool's own Zod schema stays the
24
+ * authority and rejects any invalid combination with the exact field.
25
+ */
26
+ export function interactionToolInputJsonSchema(schema: z.ZodType): AttemptToolJsonSchema {
27
+ const generated = z.toJSONSchema(schema, { target: "draft-2020-12" }) as JsonSchemaNode;
28
+ return modelFacingSchema(generated) as AttemptToolJsonSchema;
29
+ }
30
+
31
+ /** Per-variant field lists of a flattened union, for the enclosing summary. */
32
+ const flattenedVariants = new WeakMap<JsonSchemaNode, Array<[string, string[]]>>();
33
+
34
+ function modelFacingSchema(node: unknown): unknown {
35
+ if (Array.isArray(node)) return node.map(modelFacingSchema);
36
+ if (!isNode(node)) return node;
37
+ const out: JsonSchemaNode = {};
38
+ for (const [key, value] of Object.entries(node)) {
39
+ if (VALIDATION_ONLY_KEYWORDS.has(key)) continue;
40
+ if (key === "additionalProperties" && value === false) continue;
41
+ if (
42
+ (key === "minimum" || key === "maximum") &&
43
+ typeof value === "number" &&
44
+ Math.abs(value) >= MEANINGFUL_NUMERIC_BOUND
45
+ ) {
46
+ continue;
47
+ }
48
+ if (key === "minItems" && typeof value === "number" && value <= 1) continue;
49
+ if (key === "maxItems" && typeof value === "number" && value > MEANINGFUL_MAX_ITEMS) continue;
50
+ if (
51
+ key === "pattern" &&
52
+ typeof value === "string" &&
53
+ value.length > MEANINGFUL_PATTERN_LENGTH
54
+ ) {
55
+ continue;
56
+ }
57
+ out[key] = modelFacingSchema(value);
58
+ }
59
+ for (const unionKey of ["oneOf", "anyOf"] as const) {
60
+ const members = out[unionKey];
61
+ if (!Array.isArray(members)) continue;
62
+ const flattened = flattenTaggedUnion(members);
63
+ if (!flattened) continue;
64
+ delete out[unionKey];
65
+ const { description, ...rest } = flattened;
66
+ Object.assign(out, rest);
67
+ const own = typeof out.description === "string" ? out.description : "";
68
+ out.description = own ? `${own} ${String(description)}` : description;
69
+ flattenedVariants.set(out, flattenedVariants.get(flattened) ?? []);
70
+ }
71
+ return out;
72
+ }
73
+
74
+ /** One object for a union whose members are objects sharing a string tag.
75
+ * Null when the union is not such a tagged union. */
76
+ function flattenTaggedUnion(members: unknown[]): JsonSchemaNode | null {
77
+ if (members.length < 2 || !members.every(isObjectSchema)) return null;
78
+ const objects = members as Array<JsonSchemaNode & { properties: Record<string, unknown> }>;
79
+ const tag = ["type", "kind", "operation"].find((candidate) =>
80
+ objects.every((member) => tagValues(member.properties[candidate]) !== null),
81
+ );
82
+ if (!tag) return null;
83
+
84
+ const variants: Array<[string, string[]]> = [];
85
+ const tagValuesInOrder: string[] = [];
86
+ const properties = new Map<string, unknown[]>();
87
+ for (const member of objects) {
88
+ const nested = flattenedVariants.get(member);
89
+ const required = new Set(Array.isArray(member.required) ? (member.required as string[]) : []);
90
+ if (nested) {
91
+ variants.push(...nested);
92
+ } else {
93
+ const fields = Object.keys(member.properties)
94
+ .filter((field) => field !== tag)
95
+ .map((field) => (required.has(field) ? field : `${field}?`));
96
+ for (const value of tagValues(member.properties[tag])!) variants.push([value, fields]);
97
+ }
98
+ for (const value of tagValues(member.properties[tag])!) {
99
+ if (!tagValuesInOrder.includes(value)) tagValuesInOrder.push(value);
100
+ }
101
+ for (const [field, schema] of Object.entries(member.properties)) {
102
+ if (field === tag) continue;
103
+ const seen = properties.get(field) ?? [];
104
+ if (!seen.some((existing) => sameSchema(existing, schema))) seen.push(schema);
105
+ properties.set(field, seen);
106
+ }
107
+ }
108
+
109
+ const merged: Record<string, unknown> = {
110
+ [tag]: { type: "string", enum: tagValuesInOrder },
111
+ };
112
+ for (const [field, schemas] of properties) {
113
+ merged[field] = schemas.length === 1 ? schemas[0] : mergeAlternatives(schemas);
114
+ }
115
+ const summary = variants
116
+ .map(([value, fields]) => (fields.length > 0 ? `${value}: ${fields.join(", ")}` : value))
117
+ .join("; ");
118
+ const flattened: JsonSchemaNode = {
119
+ type: "object",
120
+ properties: merged,
121
+ required: [tag],
122
+ description: `Fields by ${tag} (? = optional): ${summary}.`,
123
+ };
124
+ flattenedVariants.set(flattened, variants);
125
+ return flattened;
126
+ }
127
+
128
+ function mergeAlternatives(schemas: unknown[]): unknown {
129
+ if (schemas.every((schema) => tagValues(schema) !== null)) {
130
+ return { type: "string", enum: [...new Set(schemas.flatMap((schema) => tagValues(schema)!))] };
131
+ }
132
+ return { anyOf: schemas };
133
+ }
134
+
135
+ function tagValues(schema: unknown): string[] | null {
136
+ if (!isNode(schema)) return null;
137
+ if (typeof schema.const === "string") return [schema.const];
138
+ if (
139
+ Array.isArray(schema.enum) &&
140
+ schema.enum.length > 0 &&
141
+ schema.enum.every((value) => typeof value === "string")
142
+ ) {
143
+ return schema.enum as string[];
144
+ }
145
+ return null;
146
+ }
147
+
148
+ function isObjectSchema(schema: unknown): boolean {
149
+ return isNode(schema) && schema.type === "object" && isNode(schema.properties);
150
+ }
151
+
152
+ function sameSchema(left: unknown, right: unknown): boolean {
153
+ return JSON.stringify(left) === JSON.stringify(right);
154
+ }
155
+
156
+ function isNode(value: unknown): value is JsonSchemaNode {
157
+ return typeof value === "object" && value !== null && !Array.isArray(value);
158
+ }
159
+
160
+ const MAX_REPORTED_ISSUES = 4;
161
+ const MAX_ISSUE_MESSAGE_CHARS = 240;
162
+
163
+ /**
164
+ * Name exactly what is wrong with invalid tool arguments. A union failure
165
+ * reports the variant the arguments chose (the one whose tag matched), so a
166
+ * click without a locator role says `action.locator.role`, not only "invalid".
167
+ */
168
+ export function describeInvalidToolArguments(error: z.ZodError): string {
169
+ const issues = leafIssues(error.issues, []).slice(0, MAX_REPORTED_ISSUES);
170
+ const described = issues.map(({ path, message }) => {
171
+ const text = message.replace(/^Invalid input: /u, "").slice(0, MAX_ISSUE_MESSAGE_CHARS);
172
+ return path.length > 0 ? `${path.join(".")}: ${text}` : text;
173
+ });
174
+ return `Interaction tool arguments are invalid: ${described.join("; ")}.`;
175
+ }
176
+
177
+ type LeafIssue = { path: PropertyKey[]; message: string };
178
+
179
+ function leafIssues(issues: readonly z.core.$ZodIssue[], base: PropertyKey[]): LeafIssue[] {
180
+ const leaves: LeafIssue[] = [];
181
+ for (const issue of issues) {
182
+ const path = [...base, ...issue.path];
183
+ if (issue.code === "invalid_union" && issue.errors.length > 0) {
184
+ leaves.push(...leafIssues(chosenUnionBranch(issue.errors), path));
185
+ continue;
186
+ }
187
+ if (issue.code === "unrecognized_keys") {
188
+ leaves.push({ path, message: `unexpected field ${issue.keys.join(", ")}` });
189
+ continue;
190
+ }
191
+ leaves.push({ path, message: issue.message });
192
+ }
193
+ return leaves;
194
+ }
195
+
196
+ /** The union branch the arguments meant: one whose tag matched, else the
197
+ * branch that reports the unknown tag, else the one with the fewest issues. */
198
+ function chosenUnionBranch(branches: z.core.$ZodIssue[][]): z.core.$ZodIssue[] {
199
+ const tagMismatch = (issue: z.core.$ZodIssue): boolean =>
200
+ (issue.code === "invalid_union" && issue.errors.length === 0) ||
201
+ (issue.code === "invalid_value" &&
202
+ ["type", "kind", "operation"].includes(String(issue.path.at(-1))));
203
+ const matching = branches.filter((branch) => !branch.some(tagMismatch));
204
+ if (matching.length > 0) {
205
+ return matching.reduce((best, branch) => (branch.length < best.length ? branch : best));
206
+ }
207
+ const unknownTag = branches.find((branch) =>
208
+ branch.some((issue) => issue.code === "invalid_union" && issue.errors.length === 0),
209
+ );
210
+ return (
211
+ unknownTag?.filter((issue) => issue.code === "invalid_union") ??
212
+ branches.reduce((best, branch) => (branch.length < best.length ? branch : best))
213
+ );
214
+ }
@@ -90,7 +90,10 @@ import {
90
90
  type InteractionTransport,
91
91
  } from "@opengeni/sdk";
92
92
  import { z } from "zod";
93
- import { browserActionInputJsonSchema } from "./browser-action-json-schema";
93
+ import {
94
+ describeInvalidToolArguments,
95
+ interactionToolInputJsonSchema,
96
+ } from "./interaction-tool-json-schema";
94
97
  import { guardedMcpFetch } from "./mcp-network";
95
98
 
96
99
  export const INTERACTION_ATTEMPT_TOOL_NAMES = FIRST_PARTY_IN_PROCESS_TOOL_NAMES;
@@ -786,10 +789,7 @@ export function createInteractionAttemptToolDefinitions(
786
789
  codemodePath: options.codemodePath,
787
790
  title: options.title,
788
791
  description: options.description,
789
- inputSchema:
790
- options.name === "browser_act"
791
- ? browserActionInputJsonSchema(options.input)
792
- : jsonSchema(options.input),
792
+ inputSchema: interactionToolInputJsonSchema(options.input),
793
793
  outputSchema: jsonSchema(options.output),
794
794
  annotations: {
795
795
  title: options.title,
@@ -2603,7 +2603,7 @@ async function safeInteractionExecution<TInput extends z.ZodType, TOutput extend
2603
2603
  return result;
2604
2604
  } catch (error) {
2605
2605
  if (error instanceof z.ZodError) {
2606
- return interactionErrorResult("invalid_arguments", "Interaction tool arguments are invalid.");
2606
+ return interactionErrorResult("invalid_arguments", describeInvalidToolArguments(error));
2607
2607
  }
2608
2608
  // A failed read is still a useful tool result. Preserve the API's public
2609
2609
  // explanation; mutations and uncertain outcomes retain their failure path.
@@ -87,6 +87,7 @@ import {
87
87
  } from "./browser-control-server";
88
88
  import { parseExecResponseBanner } from "./exec-banner";
89
89
  import { controllerStreamRequest, parseControllerStreamResponse } from "./browser-control-stream";
90
+ import type { BrowserControlHttpCall, BrowserControlHttpOutcome } from "./browser-control-http";
90
91
  import {
91
92
  buildStreamUrl,
92
93
  exposedPortAllowsHostFetch,
@@ -177,6 +178,8 @@ export type BrowserControlPlacementSession = {
177
178
  ensureBrowserControl?: (
178
179
  request: BrowserControlEnsureRequest,
179
180
  ) => Promise<BrowserControlEnsureResponse>;
181
+ /** Agent-native controller transport, addressed by authority scope. */
182
+ browserControlHttp?: (request: BrowserControlHttpCall) => Promise<BrowserControlHttpOutcome>;
180
183
  openBrowserFrames?: (
181
184
  request: BrowserFramesOpenRequest,
182
185
  ) => Promise<{ channel: StreamChannel; endpoint: ExposedPortEndpoint }>;
@@ -1176,6 +1179,19 @@ export class BrowserControlClient {
1176
1179
  );
1177
1180
  }
1178
1181
 
1182
+ const native = await this.requestNative(input, BROWSER_CONTROL_MAX_JSON_BYTES);
1183
+ if (native) {
1184
+ let text: string;
1185
+ try {
1186
+ text = new TextDecoder("utf-8", { fatal: true }).decode(native.body);
1187
+ } catch (error) {
1188
+ throw new BrowserControlProtocolError("browser controller response is not UTF-8", {
1189
+ cause: error,
1190
+ });
1191
+ }
1192
+ return parseEnvelope(text, native.status);
1193
+ }
1194
+
1179
1195
  if (
1180
1196
  this.session.execWithInput &&
1181
1197
  (input.body === undefined || Buffer.byteLength(JSON.stringify(input.body)) <= 32 * 1024)
@@ -1357,6 +1373,18 @@ export class BrowserControlClient {
1357
1373
  );
1358
1374
  }
1359
1375
 
1376
+ const nativeFrame = await this.requestNative(
1377
+ input,
1378
+ input.surface === "browser" ? 24 * 1024 * 1024 : COMPUTER_SCREENSHOT_MAX_BYTES,
1379
+ );
1380
+ if (nativeFrame) {
1381
+ if (nativeFrame.status < 200 || nativeFrame.status >= 300) {
1382
+ parseEnvelope(new TextDecoder().decode(nativeFrame.body), nativeFrame.status);
1383
+ throw new BrowserControlProtocolError("browser controller image response is invalid");
1384
+ }
1385
+ return controllerImageFrame(nativeFrame.body, nativeFrame.headers, input);
1386
+ }
1387
+
1360
1388
  if (this.session.execWithInput) {
1361
1389
  try {
1362
1390
  const response = await this.requestStream(
@@ -1512,8 +1540,69 @@ export class BrowserControlClient {
1512
1540
  }
1513
1541
 
1514
1542
  private async controllerPort(): Promise<number> {
1543
+ if (!this.nativeAuthority || !this.session.ensureBrowserControl) return this.port;
1544
+ return await this.ensureControllerPort(false);
1545
+ }
1546
+
1547
+ /** The agent-native transport: one in-process loopback request on the
1548
+ * Connected Machine, addressed by authority scope, with no process started.
1549
+ * Null when the placement does not serve it; the caller then keeps its
1550
+ * command transport. A controller that is not running receives nothing, so
1551
+ * it is ensured and the request sent once more whatever its method. */
1552
+ private async requestNative(
1553
+ input: { method: string; path: string; token: string; body?: unknown; timeoutMs?: number },
1554
+ maxResponseBytes: number,
1555
+ retry = true,
1556
+ ): Promise<{ status: number; headers: Map<string, string>; body: Uint8Array } | null> {
1557
+ const authority = this.nativeAuthority;
1558
+ if (!authority || !this.session.browserControlHttp || !this.session.ensureBrowserControl) {
1559
+ return null;
1560
+ }
1561
+ const method = input.method;
1562
+ if (method !== "GET" && method !== "POST" && method !== "PUT" && method !== "DELETE") {
1563
+ throw new BrowserControlProtocolError("browser controller request method is invalid");
1564
+ }
1565
+ // Validates the path exactly as the command transport does.
1566
+ localControllerUrl(this.port, input.path);
1567
+ const body =
1568
+ input.body === undefined ? undefined : new TextEncoder().encode(JSON.stringify(input.body));
1569
+ if (body !== undefined && body.byteLength > BROWSER_CONTROL_MAX_JSON_BYTES) {
1570
+ throw new RangeError("browser controller request body is too large");
1571
+ }
1572
+ let outcome: BrowserControlHttpOutcome;
1573
+ try {
1574
+ outcome = await this.session.browserControlHttp({
1575
+ scopeId: authority.scopeId,
1576
+ scopeGeneration: authority.scopeGeneration,
1577
+ method,
1578
+ path: input.path,
1579
+ bearerToken: requireToken(input.token, "browser controller token"),
1580
+ ...(body === undefined ? {} : { body }),
1581
+ timeoutMs: boundedTimeout(input.timeoutMs ?? this.timeoutMs),
1582
+ maxResponseBytes,
1583
+ });
1584
+ } catch (error) {
1585
+ if (retry && method === "GET") {
1586
+ await this.ensureControllerPort(true);
1587
+ return await this.requestNative(input, maxResponseBytes, false);
1588
+ }
1589
+ throw new BrowserControlTransportError("browser controller request transport failed", {
1590
+ cause: error,
1591
+ });
1592
+ }
1593
+ if (outcome.kind === "unsupported") return null;
1594
+ if (outcome.kind === "controller_absent") {
1595
+ if (!retry) throw new BrowserControlTransportError("browser controller is not running");
1596
+ await this.ensureControllerPort(true);
1597
+ return await this.requestNative(input, maxResponseBytes, false);
1598
+ }
1599
+ return outcome;
1600
+ }
1601
+
1602
+ private async ensureControllerPort(replace: boolean): Promise<number> {
1515
1603
  if (!this.nativeAuthority || !this.session.ensureBrowserControl) return this.port;
1516
1604
  const key = nativeControllerKey(this.nativeAuthority);
1605
+ if (replace) nativeControllerPorts.delete(key);
1517
1606
  const cached = nativeControllerPorts.get(key);
1518
1607
  if (cached !== undefined) return cached;
1519
1608
  const ensured = await this.session.ensureBrowserControl({
@@ -0,0 +1,32 @@
1
+ /** One request to an agent-owned browser/computer controller, addressed by its
2
+ * exact authority scope rather than a local port. */
3
+ export type BrowserControlHttpCall = {
4
+ scopeId: string;
5
+ scopeGeneration: string;
6
+ method: "GET" | "POST" | "PUT" | "DELETE";
7
+ /** Absolute controller path with optional query. */
8
+ path: string;
9
+ bearerToken: string;
10
+ /** JSON request body bytes, when the request has a body. */
11
+ body?: Uint8Array;
12
+ timeoutMs: number;
13
+ maxResponseBytes: number;
14
+ };
15
+
16
+ /** What a placement transport did with one controller request. */
17
+ export type BrowserControlHttpOutcome =
18
+ /** The placement cannot serve it natively; nothing was sent. */
19
+ | { kind: "unsupported" }
20
+ /** No controller for that scope and generation accepted it; nothing was
21
+ * delivered, so the caller may ensure the controller and send again. */
22
+ | { kind: "controller_absent" }
23
+ | {
24
+ kind: "response";
25
+ status: number;
26
+ /** Lowercase header names. */
27
+ headers: Map<string, string>;
28
+ body: Uint8Array;
29
+ };
30
+
31
+ /** Extra time beyond the request's own deadline for the reply to arrive. */
32
+ export const BROWSER_CONTROL_HTTP_REPLY_GRACE_MS = 5_000;
@@ -67,6 +67,7 @@ export interface SelfhostedConnectionBinding {
67
67
  operationResourcePolicySupported: boolean;
68
68
  operationCpuQuotaSupported: boolean;
69
69
  transactionalFsWriteSupported?: boolean;
70
+ browserControlHttpSupported?: boolean;
70
71
  }
71
72
 
72
73
  export interface ActiveBackendResolverDeps {
@@ -406,6 +407,7 @@ export function makeActiveBackendResolver(
406
407
  operationResourcePolicySupported: connection.operationResourcePolicySupported,
407
408
  operationCpuQuotaSupported: connection.operationCpuQuotaSupported,
408
409
  transactionalFsWriteSupported: connection.transactionalFsWriteSupported === true,
410
+ browserControlHttpSupported: connection.browserControlHttpSupported === true,
409
411
  // The routing proxy caches this session by sandbox+epoch, but command
410
412
  // policy and runner authority are mutable. Re-read one coherent DB
411
413
  // snapshot only when a new exec/Git is admitted.