zigmini 1.0.0-beta.2 → 1.1.0-beta.1
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.ko.md +36 -3
- package/README.md +36 -3
- package/assets/agent/gateway-request-rules.md +1 -1
- package/assets/agent/gateway-rules.md +6 -1
- package/assets/android/zigmini.apk +0 -0
- package/assets/android/zigmini.apk.json +3 -3
- package/assets/build.json +1 -1
- package/assets/document-reader/document-reader +0 -0
- package/assets/document-reader/host-selector +0 -0
- package/assets/document-reader/manifest.json +27 -0
- package/docs/manual.ko.md +120 -11
- package/docs/manual.md +123 -12
- package/host/document-reader/DocumentRead.swift +370 -0
- package/host/document-reader/HostSelector.swift +192 -0
- package/host/document-reader/Protocol.swift +647 -0
- package/host/document-reader/main.swift +22 -0
- package/host/omo-bridge/agent-aside-tool.ts +119 -0
- package/host/omo-bridge/agent-aside.ts +522 -0
- package/host/omo-bridge/agent-collaboration-files.ts +282 -0
- package/host/omo-bridge/agent-collaboration.ts +385 -0
- package/host/omo-bridge/agent-control.ts +400 -28
- package/host/omo-bridge/agent-execution.sb +42 -0
- package/host/omo-bridge/agent-execution.ts +158 -0
- package/host/omo-bridge/agent-tool-adapters.ts +388 -0
- package/host/omo-bridge/agent-tool-permissions.ts +96 -0
- package/host/omo-bridge/aside-tabs.ts +41 -0
- package/host/omo-bridge/attachment-read.ts +100 -0
- package/host/omo-bridge/bridge.ts +10 -0
- package/host/omo-bridge/managed-call.ts +46 -0
- package/host/omo-bridge/task-control.ts +39 -5
- package/host/omo-check/patches/attachments-293/README.md +138 -0
- package/host/omo-check/patches/attachments-293/document-content.js +234 -0
- package/host/omo-check/patches/attachments-293/document-content.types.txt +91 -0
- package/host/omo-check/patches/attachments-293/omo-5.1.51-documents.patch +831 -0
- package/host/omo-check/patches/attachments-293/omo-5.1.51-gateway-port.manifest.json +471 -0
- package/host/omo-check/patches/omo-5.1.51-agent-tool-authority.patch +471 -0
- package/host/omo-check/patches/omo-5.1.51-chatgpt-terra.patch +21 -0
- package/host/omo-check/patches/omo-5.1.51-gateway-port.manifest.json +44 -6
- package/host/omo-check/patches/omo-5.1.51-memory-receipts.patch +5 -0
- package/package.json +5 -3
- package/src/agent/api.ts +345 -20
- package/src/agent/artifacts.ts +196 -0
- package/src/agent/aside-target-routes.ts +369 -0
- package/src/agent/aside-targets.ts +215 -0
- package/src/agent/collaboration-admission.ts +47 -0
- package/src/agent/collaboration-read.ts +117 -0
- package/src/agent/collaboration-session.ts +196 -0
- package/src/agent/collaboration-types.ts +258 -0
- package/src/agent/collaboration.ts +399 -0
- package/src/agent/definition.ts +65 -3
- package/src/agent/fields.ts +9 -3
- package/src/agent/file-core.ts +325 -0
- package/src/agent/files.ts +87 -8
- package/src/agent/list.ts +3 -3
- package/src/agent/managed-call.ts +144 -0
- package/src/agent/tool-catalog.ts +184 -0
- package/src/agent/tool-delivery.ts +359 -0
- package/src/agent/tool-permission-types.ts +81 -0
- package/src/agent/tool-permissions.ts +1366 -0
- package/src/attachments/api.ts +161 -0
- package/src/attachments/document-reader.ts +325 -0
- package/src/attachments/host-selector.ts +410 -0
- package/src/attachments/managed-binding.ts +83 -0
- package/src/attachments/prepare.ts +214 -0
- package/src/attachments/selector-port.ts +28 -0
- package/src/attachments/service.ts +729 -0
- package/src/attachments/store.ts +698 -0
- package/src/attachments/types.ts +123 -0
- package/src/cli/doctor.ts +30 -10
- package/src/decisions.ts +109 -10
- package/src/engine/apply-patch.ts +21 -0
- package/src/engine/compat.json +2 -1
- package/src/engine/install.ts +45 -4
- package/src/engine/sandbox-cell.ts +2 -0
- package/src/gc.ts +33 -225
- package/src/housekeeping/api.ts +163 -0
- package/src/housekeeping/audit.ts +61 -0
- package/src/housekeeping/collectors/agent-collaboration.ts +33 -0
- package/src/housekeeping/collectors/agent-drafts.ts +43 -0
- package/src/housekeeping/collectors/agent-file-trash.ts +36 -0
- package/src/housekeeping/collectors/agent-trash.ts +28 -0
- package/src/housekeeping/collectors/attachment-cleanup.ts +35 -0
- package/src/housekeeping/collectors/db-backup.ts +25 -0
- package/src/housekeeping/collectors/docker-build-cache.ts +29 -0
- package/src/housekeeping/collectors/docker-image.ts +135 -0
- package/src/housekeeping/collectors/omo-version.ts +32 -0
- package/src/housekeeping/collectors/orphan-test-process.ts +205 -0
- package/src/housekeeping/collectors/reconcile-work.ts +55 -0
- package/src/housekeeping/collectors/rpc-daemon.ts +39 -0
- package/src/housekeeping/collectors/shared.ts +149 -0
- package/src/housekeeping/collectors/temp-dir.ts +28 -0
- package/src/housekeeping/collectors/update-file.ts +25 -0
- package/src/housekeeping/collectors/worktree.ts +193 -0
- package/src/housekeeping/engine.ts +125 -0
- package/src/housekeeping/reconcile/work.ts +130 -0
- package/src/housekeeping/registry.ts +63 -0
- package/src/housekeeping/trash.ts +59 -0
- package/src/housekeeping/types.ts +31 -0
- package/src/hub.ts +1095 -41
- package/src/main.ts +230 -31
- package/src/message-references.ts +14 -5
- package/src/profile-prompt.ts +13 -1
- package/src/profile.ts +13 -5
- package/src/protocol.ts +41 -6
- package/src/security-allow.ts +79 -2
- package/src/security-check.ts +1 -1
- package/src/store.ts +1946 -23
- package/src/test-crash.ts +20 -0
- package/src/usage.ts +18 -7
- package/src/work-watch.ts +55 -7
- package/src/work.ts +192 -3
- package/web/DESIGN.md +35 -1
- package/web/agents.d.ts +189 -12
- package/web/agents.js +898 -39
- package/web/app.js +2634 -210
- package/web/attachments.d.ts +91 -0
- package/web/attachments.js +422 -0
- package/web/collaboration.d.ts +87 -0
- package/web/collaboration.js +290 -0
- package/web/decisions.d.ts +39 -2
- package/web/decisions.js +354 -2
- package/web/gc.d.ts +26 -5
- package/web/gc.js +113 -13
- package/web/i18n/agents.d.ts +4 -0
- package/web/i18n/agents.js +200 -8
- package/web/i18n/attachments.js +78 -0
- package/web/i18n/decisions.d.ts +4 -0
- package/web/i18n/decisions.js +68 -0
- package/web/i18n/gc.d.ts +4 -0
- package/web/i18n/gc.js +184 -2
- package/web/i18n/main.js +80 -8
- package/web/i18n/tour.d.ts +4 -0
- package/web/i18n/tour.js +37 -0
- package/web/index.html +20 -3
- package/web/profile.d.ts +2 -2
- package/web/profile.js +16 -10
- package/web/style.css +342 -21
- package/web/tooltip.d.ts +2 -0
- package/web/tooltip.js +12 -0
- package/web/tour.d.ts +57 -0
- package/web/tour.js +450 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import type { AsidePermission } from "../../src/agent/aside-targets.ts";
|
|
3
|
+
import type { SessionAsideTarget } from "../../src/agent/aside-target-routes.ts";
|
|
4
|
+
import { ASIDE_OPERATIONS } from "../../src/agent/tool-catalog.ts";
|
|
5
|
+
import { agentToolOperationId } from "../../src/agent/tool-delivery.ts";
|
|
6
|
+
import { canonicalJson } from "../../src/agent/tool-permissions.ts";
|
|
7
|
+
|
|
8
|
+
// 294 D1/D2: the session-side pieces agent-control wires for the zgw_aside tool and for calls the engine authority held.
|
|
9
|
+
|
|
10
|
+
export const ASIDE_TOOL = "zgw_aside";
|
|
11
|
+
|
|
12
|
+
/** C7 input: a closed union checked again by parseAsideRequest. No code, selector, account or host option. */
|
|
13
|
+
export const ASIDE_PARAMETERS = {
|
|
14
|
+
type: "object", additionalProperties: false, required: ["operation"],
|
|
15
|
+
properties: {
|
|
16
|
+
operation: { type: "string", enum: Object.keys(ASIDE_OPERATIONS) },
|
|
17
|
+
targetHandle: { type: "string", minLength: 1, maxLength: 2048 },
|
|
18
|
+
url: { type: "string", minLength: 1, maxLength: 2048 },
|
|
19
|
+
snapshotRevision: { type: "string", minLength: 1, maxLength: 2048 },
|
|
20
|
+
ref: { type: "string", minLength: 1, maxLength: 2048 },
|
|
21
|
+
text: { type: "string", minLength: 1, maxLength: 4000 },
|
|
22
|
+
},
|
|
23
|
+
} as const;
|
|
24
|
+
|
|
25
|
+
export const ASIDE_DESCRIPTION = "Use the Aside browser through fixed operations. open_owned {url} opens a new work tab and returns its targetHandle; " +
|
|
26
|
+
"read/screenshot/close_owned {targetHandle}; navigate_owned {targetHandle,url}; click {targetHandle,snapshotRevision,ref}; " +
|
|
27
|
+
"fill {targetHandle,snapshotRevision,ref,text}. Refs and snapshotRevision come from the latest read of that tab.";
|
|
28
|
+
|
|
29
|
+
const CONTROL = ["open_owned", "navigate", "click", "fill", "close"];
|
|
30
|
+
|
|
31
|
+
/** The adapter's permission class against the catalog operations the current grant names. */
|
|
32
|
+
export function asidePermits(operations: readonly string[], permission: AsidePermission): boolean {
|
|
33
|
+
if (permission === "browser.aside.read") return operations.includes("read") || operations.includes("view");
|
|
34
|
+
return CONTROL.some((operation) => operations.includes(operation));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The zgw_aside operations a policy read (/internal/agent-tools/policy) grants; anything malformed grants nothing. */
|
|
38
|
+
export function asideGrantedOperations(policy: unknown): string[] {
|
|
39
|
+
const operations = (policy as { operations?: unknown } | null)?.operations;
|
|
40
|
+
const listed = operations !== null && typeof operations === "object" ? (operations as Record<string, unknown>)[ASIDE_TOOL] : undefined;
|
|
41
|
+
return Array.isArray(listed) ? listed.filter((operation): operation is string => typeof operation === "string") : [];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* C1/C7: what the session does with one zgw_aside call. An operation the current grant names runs; one it does not name is
|
|
46
|
+
* held for a new owner card (the engine allows zgw_aside by name, so the session holds the rest itself); a blocked policy
|
|
47
|
+
* (missing: "deny") or an unknown operation is refused.
|
|
48
|
+
*/
|
|
49
|
+
export function asideCallDecision(policy: unknown, operation: unknown): "run" | "hold" | "refuse" {
|
|
50
|
+
const needed = typeof operation === "string" && Object.hasOwn(ASIDE_OPERATIONS, operation) ? ASIDE_OPERATIONS[operation]! : null;
|
|
51
|
+
if (needed === null) return "refuse";
|
|
52
|
+
if (asideGrantedOperations(policy).includes(needed)) return "run";
|
|
53
|
+
return (policy as { missing?: unknown } | null)?.missing === "hold" ? "hold" : "refuse";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The card scope of a held call on a tab this session knows: an owned work tab stays the session's own target (the gateway
|
|
58
|
+
* never carries it); a borrowed tab the owner designated keeps its gateway handle. Unknown or no handle: no scope.
|
|
59
|
+
*/
|
|
60
|
+
export function heldAsideScope(target: { readonly handle: string; readonly kind: "borrowed" | "owned"; readonly origin: string } | null): Record<string, string> | null {
|
|
61
|
+
if (!target) return null;
|
|
62
|
+
return { kind: target.kind === "owned" ? "owned_target" : "browser_target", targetHandle: target.handle, targetLabel: target.origin };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* `aside account status` marks the active account with "*". The host confirms an account only when exactly one line is
|
|
67
|
+
* marked and signed in; otherwise it cannot tell and the adapter refuses (target_identity_unclear).
|
|
68
|
+
*/
|
|
69
|
+
export function parseAsideAccount(stdout: string): string | null {
|
|
70
|
+
const marked = stdout.split("\n").map((line) => /^\*\s+(u\d+)\s.*\bsigned in\b/.exec(line)?.[1]).filter((id): id is string => id !== undefined);
|
|
71
|
+
return marked.length === 1 ? marked[0]! : null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function asideAccount(bin: string, exists: (path: string) => boolean = existsSync): Promise<string | null> {
|
|
75
|
+
if (!exists(bin)) return null;
|
|
76
|
+
const child = Bun.spawn([bin, "account", "status"], { stdin: "ignore", stdout: "pipe", stderr: "ignore", timeout: 10_000 });
|
|
77
|
+
const [stdout, code] = await Promise.all([new Response(child.stdout).text(), child.exited]);
|
|
78
|
+
return code === 0 ? parseAsideAccount(stdout) : null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const text = (value: unknown): value is string => typeof value === "string" && value !== "";
|
|
82
|
+
const seq = (value: unknown): value is number => typeof value === "number" && Number.isSafeInteger(value);
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* D4: the tab the gateway carries to this session for one granted call (`zgw.aside_target`), keyed by the call's stored
|
|
86
|
+
* args. `target: null` clears the key. Only a well-formed handle of this session's own agent is accepted.
|
|
87
|
+
*/
|
|
88
|
+
export function carriedAsideTarget(data: unknown, agentId: string): { key: string; target: SessionAsideTarget | null } {
|
|
89
|
+
const value = data as { args?: unknown; target?: unknown } | null;
|
|
90
|
+
if (!value || typeof value !== "object" || !("args" in value)) throw new Error("aside_target_invalid");
|
|
91
|
+
const key = canonicalJson(value.args);
|
|
92
|
+
if (value.target === null) return { key, target: null };
|
|
93
|
+
const t = (value.target ?? {}) as Record<string, unknown>;
|
|
94
|
+
if (!text(t.handle) || !t.handle.startsWith("ash_") || (t.kind !== "borrowed" && t.kind !== "owned") || !text(t.host) || !text(t.account) ||
|
|
95
|
+
!text(t.targetId) || !text(t.origin) || !text(t.agentId) || !seq(t.incarnation) || !(t.selectionSeq === null || seq(t.selectionSeq))) throw new Error("aside_target_invalid");
|
|
96
|
+
if (t.agentId !== agentId) throw new Error("aside_target_agent_mismatch");
|
|
97
|
+
return { key, target: { handle: t.handle, kind: t.kind, host: t.host, account: t.account, targetId: t.targetId, origin: t.origin,
|
|
98
|
+
agentId: t.agentId, incarnation: t.incarnation, selectionSeq: t.selectionSeq } };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The stored call with the owner-bound handle; the model's own handle, if any, never overrides the owner's choice. */
|
|
102
|
+
export function withCarriedTarget(params: Record<string, unknown>, target: SessionAsideTarget): Record<string, unknown> {
|
|
103
|
+
return { ...params, targetHandle: target.handle };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The engine authority ended a call with one pending result: `{"state":"permission_pending","requestId","operationId"}`.
|
|
108
|
+
* Only a result whose operation id is this call's own direct operation counts; anything else is not a held call.
|
|
109
|
+
*/
|
|
110
|
+
export function heldOperation(event: { readonly toolCallId: string; readonly isError: boolean; readonly result: unknown }): { operationId: string; requestId: string } | null {
|
|
111
|
+
if (!event.isError) return null;
|
|
112
|
+
const content = (event.result as { content?: unknown } | null)?.content;
|
|
113
|
+
const text = Array.isArray(content) && content.length === 1 && (content[0] as { type?: unknown })?.type === "text" ? (content[0] as { text?: unknown }).text : undefined;
|
|
114
|
+
let parsed: unknown;
|
|
115
|
+
try { parsed = JSON.parse(String(text)); } catch { return null; }
|
|
116
|
+
const value = parsed as { state?: unknown; requestId?: unknown; operationId?: unknown } | null;
|
|
117
|
+
if (value?.state !== "permission_pending" || typeof value.requestId !== "string" || value.operationId !== agentToolOperationId(event.toolCallId)) return null;
|
|
118
|
+
return { operationId: value.operationId, requestId: value.requestId };
|
|
119
|
+
}
|
|
@@ -0,0 +1,522 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import {
|
|
5
|
+
ActionTickets, type AsidePermission, type AsideRefusal, type AsideRequest, type AsideTarget, AsideTargetRegistry, actionDigest, checkObservation,
|
|
6
|
+
classifyRef, isProtectedUrl, originOf, parseAsideRequest, permissionFor, snapshotRevision,
|
|
7
|
+
} from "../../src/agent/aside-targets.ts";
|
|
8
|
+
|
|
9
|
+
// 294 C7: host adapter for the zgw_aside tool. It runs the official `aside repl` with argv (no shell) and the
|
|
10
|
+
// absolute ~/.local/bin/aside path, because the launchd PATH does not contain that folder. Each script is a fixed
|
|
11
|
+
// template plus one JSON literal of values; the model never supplies code, selectors, account or host options.
|
|
12
|
+
// The CLI closes the tabs a REPL process opened when that process exits, and closeTab() from another process is a
|
|
13
|
+
// silent no-op, so the adapter keeps one persistent stdin-driven `aside repl --account <id>` session (294 item 15 D5).
|
|
14
|
+
|
|
15
|
+
export const RESULT_PREFIX = "ZGW_ASIDE_RESULT ";
|
|
16
|
+
/** Printed by the fixed session wrapper after every script, with the script's sequence number. */
|
|
17
|
+
export const END_PREFIX = "ZGW_ASIDE_END ";
|
|
18
|
+
const TEXT_LIMIT = 20_000;
|
|
19
|
+
const IMAGE_LIMIT = 4_000_000;
|
|
20
|
+
/** Per operation; below the CLI's own 120 s REPL call limit. */
|
|
21
|
+
export const OP_TIMEOUT_MS = 90_000;
|
|
22
|
+
const SHUTDOWN_MS = 10_000;
|
|
23
|
+
|
|
24
|
+
export interface AsideRun {
|
|
25
|
+
readonly code: number;
|
|
26
|
+
readonly stdout: string;
|
|
27
|
+
}
|
|
28
|
+
export type AsideRunner = (bin: string, argv: readonly string[]) => Promise<AsideRun>;
|
|
29
|
+
|
|
30
|
+
export function asideBin(home: string = homedir()): string {
|
|
31
|
+
return join(home, ".local/bin/aside");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** One-shot runner (one process per script). It cannot own tabs; the adapter uses it only when injected as `run`. */
|
|
35
|
+
export const spawnAside: AsideRunner = async (bin, argv) => {
|
|
36
|
+
const child = Bun.spawn([bin, ...argv], { stdout: "pipe", stderr: "ignore", stdin: "ignore" });
|
|
37
|
+
const stdout = await new Response(child.stdout).text();
|
|
38
|
+
return { code: await child.exited, stdout };
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/** The parts of a long-lived `aside repl` process the session uses. */
|
|
42
|
+
export interface AsideProcess {
|
|
43
|
+
readonly stdout: ReadableStream<Uint8Array>;
|
|
44
|
+
readonly exited: Promise<number>;
|
|
45
|
+
write(text: string): void;
|
|
46
|
+
end(): void;
|
|
47
|
+
kill(): void;
|
|
48
|
+
}
|
|
49
|
+
export type AsideSpawner = (bin: string, argv: readonly string[]) => AsideProcess;
|
|
50
|
+
|
|
51
|
+
export const spawnAsideProcess: AsideSpawner = (bin, argv) => {
|
|
52
|
+
const child = Bun.spawn([bin, ...argv], { stdin: "pipe", stdout: "pipe", stderr: "ignore" });
|
|
53
|
+
return {
|
|
54
|
+
stdout: child.stdout, exited: child.exited,
|
|
55
|
+
write: (text) => { child.stdin.write(text); void child.stdin.flush(); },
|
|
56
|
+
end: () => { void child.stdin.end(); },
|
|
57
|
+
kill: () => { child.kill(); },
|
|
58
|
+
};
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The REPL evaluates one input line at a time and keeps top-level bindings, so each script travels as one fixed line
|
|
63
|
+
* that runs its base64 text as a fresh async function body and always prints the end marker with its sequence number.
|
|
64
|
+
*/
|
|
65
|
+
export function sessionLine(script: string, seq: number): string {
|
|
66
|
+
const code = `try {\n${script}\n} catch (error) { console.log("ZGW_ASIDE_ERROR " + String(error?.message ?? error).slice(0, 300)); } finally { console.log(${JSON.stringify(END_PREFIX)} + ${seq}); }`;
|
|
67
|
+
return `await (new (async function(){}.constructor)(Buffer.from(${JSON.stringify(Buffer.from(code, "utf8").toString("base64"))}, "base64").toString("utf8")))()\n`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const ANSI = /\u001b\[[0-9;]*m/g;
|
|
71
|
+
|
|
72
|
+
/** One persistent CLI process. Scripts run one at a time; each waits at most `ms` after it is written. */
|
|
73
|
+
class AsideSession {
|
|
74
|
+
/** Owned tabs this process opened, by handle: only this process can close them. */
|
|
75
|
+
readonly owned = new Map<string, AsideTarget>();
|
|
76
|
+
readonly exited: Promise<void>;
|
|
77
|
+
closed = false;
|
|
78
|
+
private buffer = "";
|
|
79
|
+
private seq = 0;
|
|
80
|
+
private queue: Promise<unknown> = Promise.resolve();
|
|
81
|
+
private wake: (() => void) | null = null;
|
|
82
|
+
|
|
83
|
+
constructor(readonly account: string, private readonly proc: AsideProcess) {
|
|
84
|
+
this.exited = proc.exited.then(() => this.markClosed(), () => this.markClosed());
|
|
85
|
+
void this.pump();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
private markClosed(): void {
|
|
89
|
+
this.closed = true;
|
|
90
|
+
this.wake?.();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
private async pump(): Promise<void> {
|
|
94
|
+
const reader = this.proc.stdout.getReader();
|
|
95
|
+
const decoder = new TextDecoder();
|
|
96
|
+
try {
|
|
97
|
+
for (;;) {
|
|
98
|
+
const { value, done } = await reader.read();
|
|
99
|
+
if (done) break;
|
|
100
|
+
this.buffer += decoder.decode(value, { stream: true });
|
|
101
|
+
this.wake?.();
|
|
102
|
+
}
|
|
103
|
+
} catch {
|
|
104
|
+
// A broken stdout pipe ends the session the same way as a closed one: the waiting script fails below.
|
|
105
|
+
}
|
|
106
|
+
// Without stdout the process is unusable; stop it so its exit marks the owned tabs lost.
|
|
107
|
+
this.proc.kill();
|
|
108
|
+
this.markClosed();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
run(script: string, ms: number): Promise<AsideRun> {
|
|
112
|
+
const next = this.queue.then(() => this.send(script, ms));
|
|
113
|
+
this.queue = next;
|
|
114
|
+
return next;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
private async send(script: string, ms: number): Promise<AsideRun> {
|
|
118
|
+
if (this.closed) return { code: -1, stdout: "" };
|
|
119
|
+
const marker = `${END_PREFIX}${++this.seq}`;
|
|
120
|
+
try {
|
|
121
|
+
this.proc.write(sessionLine(script, this.seq));
|
|
122
|
+
} catch {
|
|
123
|
+
this.proc.kill();
|
|
124
|
+
return { code: -1, stdout: "" };
|
|
125
|
+
}
|
|
126
|
+
let timedOut = false;
|
|
127
|
+
const timer = setTimeout(() => { timedOut = true; this.wake?.(); }, ms);
|
|
128
|
+
try {
|
|
129
|
+
const out: string[] = [];
|
|
130
|
+
for (;;) {
|
|
131
|
+
const newline = this.buffer.indexOf("\n");
|
|
132
|
+
if (newline >= 0) {
|
|
133
|
+
const line = this.buffer.slice(0, newline).replace(ANSI, "").trimEnd();
|
|
134
|
+
this.buffer = this.buffer.slice(newline + 1);
|
|
135
|
+
if (line.endsWith(marker)) return { code: 0, stdout: out.join("\n") };
|
|
136
|
+
const at = line.indexOf(RESULT_PREFIX);
|
|
137
|
+
out.push(at > 0 ? line.slice(at) : line);
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
if (timedOut || this.closed) {
|
|
141
|
+
// A script that outlives its limit leaves the REPL in an unknown state: end the process.
|
|
142
|
+
this.proc.kill();
|
|
143
|
+
return { code: -1, stdout: "" };
|
|
144
|
+
}
|
|
145
|
+
await new Promise<void>((resolve) => { this.wake = resolve; });
|
|
146
|
+
this.wake = null;
|
|
147
|
+
}
|
|
148
|
+
} finally {
|
|
149
|
+
clearTimeout(timer);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async shutdown(ms: number): Promise<void> {
|
|
154
|
+
if (!this.closed) {
|
|
155
|
+
try {
|
|
156
|
+
this.proc.end();
|
|
157
|
+
} catch {
|
|
158
|
+
this.proc.kill();
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
162
|
+
const late = await Promise.race([this.exited.then(() => false), new Promise<boolean>((resolve) => { timer = setTimeout(() => resolve(true), ms); })]);
|
|
163
|
+
clearTimeout(timer);
|
|
164
|
+
if (late) this.proc.kill();
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const PRELUDE = `
|
|
169
|
+
const SEL = 'a[href],button,input:not([type=hidden]):not([type=password]),textarea,select,[role=button],[role=link],[contenteditable=true]';
|
|
170
|
+
async function observe(id) {
|
|
171
|
+
const tab = (await listBrowserTabs()).find((t) => t.targetId === id);
|
|
172
|
+
return tab ? { targetId: tab.targetId, url: String(tab.url) } : { targetId: null, url: "" };
|
|
173
|
+
}
|
|
174
|
+
async function mainDocument(p, limit) {
|
|
175
|
+
return p.evaluate(({ sel, limit }) => {
|
|
176
|
+
const els = [...document.querySelectorAll(sel)].filter((e) => e.getClientRects().length > 0).slice(0, 200);
|
|
177
|
+
const items = els.map((e, i) => ({ ref: "e" + i, tag: e.tagName.toLowerCase(), role: e.getAttribute("role") || "",
|
|
178
|
+
name: (e.getAttribute("aria-label") || e.innerText || e.getAttribute("placeholder") || "").trim().slice(0, 120) }));
|
|
179
|
+
const frames = document.querySelectorAll("iframe,frame").length;
|
|
180
|
+
const text = document.body ? document.body.innerText : "";
|
|
181
|
+
return { title: document.title, text: text.slice(0, limit), textLength: text.length, items, frames };
|
|
182
|
+
}, { sel: SEL, limit });
|
|
183
|
+
}
|
|
184
|
+
async function elementAt(p, i) {
|
|
185
|
+
const at = await p.evaluate(({ sel, i }) => {
|
|
186
|
+
const all = [...document.querySelectorAll(sel)];
|
|
187
|
+
const shown = all.filter((e) => e.getClientRects().length > 0)[i];
|
|
188
|
+
return shown ? all.indexOf(shown) : -1;
|
|
189
|
+
}, { sel: SEL, i });
|
|
190
|
+
return at < 0 ? null : p.locator(SEL).nth(at);
|
|
191
|
+
}
|
|
192
|
+
function done(out) { console.log(${JSON.stringify(RESULT_PREFIX)} + JSON.stringify(out)); }
|
|
193
|
+
`;
|
|
194
|
+
|
|
195
|
+
const BODY = {
|
|
196
|
+
open_owned: `
|
|
197
|
+
const before = new Set((await listBrowserTabs()).map((t) => t.targetId));
|
|
198
|
+
await openTab(A.url);
|
|
199
|
+
const fresh = (await listBrowserTabs()).filter((t) => !before.has(t.targetId));
|
|
200
|
+
done(fresh.length === 1 ? { opened: { targetId: fresh[0].targetId, url: String(fresh[0].url) } } : { opened: null });`,
|
|
201
|
+
read: `
|
|
202
|
+
const pre = await observe(A.targetId);
|
|
203
|
+
if (pre.targetId === null) { done({ pre }); } else {
|
|
204
|
+
const p = await attachBrowserTab(A.targetId);
|
|
205
|
+
const doc = await mainDocument(p, A.limit);
|
|
206
|
+
done({ pre, doc, post: await observe(A.targetId) });
|
|
207
|
+
}`,
|
|
208
|
+
screenshot: `
|
|
209
|
+
const pre = await observe(A.targetId);
|
|
210
|
+
if (pre.targetId === null) { done({ pre }); } else {
|
|
211
|
+
const p = await attachBrowserTab(A.targetId);
|
|
212
|
+
const doc = await mainDocument(p, 0);
|
|
213
|
+
const image = doc.frames > 0 ? null : (await p.screenshot({ type: "png" })).toString("base64");
|
|
214
|
+
done({ pre, doc, image, post: await observe(A.targetId) });
|
|
215
|
+
}`,
|
|
216
|
+
navigate_owned: `
|
|
217
|
+
const pre = await observe(A.targetId);
|
|
218
|
+
if (pre.targetId === null) { done({ pre }); } else {
|
|
219
|
+
const p = await attachBrowserTab(A.targetId);
|
|
220
|
+
await p.goto(A.url);
|
|
221
|
+
done({ pre, post: await observe(A.targetId) });
|
|
222
|
+
}`,
|
|
223
|
+
act: `
|
|
224
|
+
const pre = await observe(A.targetId);
|
|
225
|
+
if (pre.targetId === null || !pre.url.startsWith(A.origin + "/")) { done({ pre }); } else {
|
|
226
|
+
const p = await attachBrowserTab(A.targetId);
|
|
227
|
+
const doc = await mainDocument(p, 0);
|
|
228
|
+
if (JSON.stringify(doc.items) !== JSON.stringify(A.expected)) { done({ pre, stale: true }); } else {
|
|
229
|
+
const el = await elementAt(p, A.index);
|
|
230
|
+
if (!el) { done({ pre, stale: true }); } else {
|
|
231
|
+
if (A.text === null) await el.click(); else await el.fill(A.text);
|
|
232
|
+
done({ pre, acted: true, post: await observe(A.targetId) });
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}`,
|
|
236
|
+
close_owned: `
|
|
237
|
+
const pre = await observe(A.targetId);
|
|
238
|
+
if (pre.targetId === null) { done({ pre, closed: true }); } else {
|
|
239
|
+
const p = await attachBrowserTab(A.targetId);
|
|
240
|
+
await closeTab(p);
|
|
241
|
+
done({ pre, closed: (await observe(A.targetId)).targetId === null });
|
|
242
|
+
}`,
|
|
243
|
+
} as const;
|
|
244
|
+
|
|
245
|
+
/** The whole script: a JSON literal of values followed by fixed host code. */
|
|
246
|
+
export function asideScript(body: keyof typeof BODY, values: Record<string, unknown>): string {
|
|
247
|
+
return `const A = ${JSON.stringify(values)};\n${PRELUDE}${BODY[body]}`;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export interface AsideContext {
|
|
251
|
+
readonly agentId: string;
|
|
252
|
+
readonly incarnation: number;
|
|
253
|
+
readonly actionTicket?: string;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export interface AsideDeps {
|
|
257
|
+
readonly registry: AsideTargetRegistry;
|
|
258
|
+
readonly tickets: ActionTickets;
|
|
259
|
+
readonly host: string;
|
|
260
|
+
readonly gatewayOrigins: readonly string[];
|
|
261
|
+
/** The account the host confirmed with the CLI right now; null when it cannot tell. */
|
|
262
|
+
readonly confirmedAccount: () => Promise<string | null>;
|
|
263
|
+
readonly authorize: (permission: AsidePermission, target: AsideTarget | null) => boolean;
|
|
264
|
+
/** One process per script (tests and fixtures). Without it the adapter keeps one persistent session. */
|
|
265
|
+
readonly run?: AsideRunner;
|
|
266
|
+
readonly spawn?: AsideSpawner;
|
|
267
|
+
readonly timeoutMs?: number;
|
|
268
|
+
readonly bin?: string;
|
|
269
|
+
readonly exists?: (path: string) => boolean;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export type AsideResult =
|
|
273
|
+
| { readonly ok: true; readonly targetHandle: string; readonly [key: string]: unknown }
|
|
274
|
+
| { readonly ok: false; readonly refused: AsideRefusal; readonly targetHandle?: string };
|
|
275
|
+
|
|
276
|
+
interface Item { readonly ref: string; readonly tag: string; readonly role: string; readonly name: string }
|
|
277
|
+
interface Seen { readonly targetId: string | null; readonly url: string }
|
|
278
|
+
|
|
279
|
+
const refuse = (refused: AsideRefusal, targetHandle?: string): AsideResult => (targetHandle ? { ok: false, refused, targetHandle } : { ok: false, refused });
|
|
280
|
+
|
|
281
|
+
/** A click/fill checked for its one-shot confirmation (C8): the target and the exact element, or why it cannot run. */
|
|
282
|
+
export type AsidePreparedAction =
|
|
283
|
+
| { readonly ok: true; readonly target: { readonly handle: string; readonly origin: string; readonly kind: AsideTarget["kind"] }; readonly element: { readonly tag: string; readonly role: string; readonly name: string } }
|
|
284
|
+
| { readonly ok: false; readonly refused: AsideRefusal; readonly targetHandle?: string };
|
|
285
|
+
|
|
286
|
+
export class AsideAdapter {
|
|
287
|
+
private readonly snapshots = new Map<string, { revision: string; items: readonly Item[] }>();
|
|
288
|
+
private session: AsideSession | null = null;
|
|
289
|
+
/** Owned handles whose session ended: their tab can no longer be closed by this adapter. */
|
|
290
|
+
private readonly lost = new Set<string>();
|
|
291
|
+
private stopped = false;
|
|
292
|
+
|
|
293
|
+
constructor(private readonly deps: AsideDeps) {}
|
|
294
|
+
|
|
295
|
+
private sessionFor(bin: string, account: string): AsideSession {
|
|
296
|
+
if (this.stopped) throw new Error("aside_adapter_stopped");
|
|
297
|
+
const current = this.session;
|
|
298
|
+
if (current && !current.closed && current.account === account) return current;
|
|
299
|
+
if (current) {
|
|
300
|
+
this.sessionEnded(current);
|
|
301
|
+
void current.shutdown(SHUTDOWN_MS);
|
|
302
|
+
}
|
|
303
|
+
const session = new AsideSession(account, (this.deps.spawn ?? spawnAsideProcess)(bin, ["repl", "--account", account]));
|
|
304
|
+
this.session = session;
|
|
305
|
+
void session.exited.then(() => this.sessionEnded(session));
|
|
306
|
+
return session;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** Idempotent: the tabs the ended session owned are marked lost; their next operation answers target_changed. */
|
|
310
|
+
private sessionEnded(session: AsideSession): void {
|
|
311
|
+
if (this.session === session) this.session = null;
|
|
312
|
+
for (const handle of session.owned.keys()) this.lost.add(handle);
|
|
313
|
+
session.owned.clear();
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
private async exec(target: AsideTarget | { readonly account: string }, body: keyof typeof BODY, values: Record<string, unknown>): Promise<Record<string, unknown> | AsideRefusal> {
|
|
317
|
+
return (await this.execWith(target, body, values)).out;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
private async execWith(target: AsideTarget | { readonly account: string }, body: keyof typeof BODY, values: Record<string, unknown>): Promise<{ out: Record<string, unknown> | AsideRefusal; session: AsideSession | null }> {
|
|
321
|
+
// verify() of a missing observation always suspends and answers target_changed.
|
|
322
|
+
if ("handle" in target && this.lost.delete(target.handle)) return { out: this.verify(target, undefined) as AsideRefusal, session: null };
|
|
323
|
+
const bin = this.deps.bin ?? asideBin();
|
|
324
|
+
if (!(this.deps.exists ?? existsSync)(bin)) return { out: "aside_cli_unavailable", session: null };
|
|
325
|
+
const script = asideScript(body, values);
|
|
326
|
+
let result: AsideRun;
|
|
327
|
+
let session: AsideSession | null = null;
|
|
328
|
+
try {
|
|
329
|
+
if (this.deps.run) {
|
|
330
|
+
result = await this.deps.run(bin, ["repl", "--account", target.account, script]);
|
|
331
|
+
} else {
|
|
332
|
+
session = this.sessionFor(bin, target.account);
|
|
333
|
+
result = await session.run(script, this.deps.timeoutMs ?? OP_TIMEOUT_MS);
|
|
334
|
+
if (session.closed) this.sessionEnded(session);
|
|
335
|
+
}
|
|
336
|
+
} catch {
|
|
337
|
+
return { out: "aside_failed", session };
|
|
338
|
+
}
|
|
339
|
+
if (result.code !== 0) return { out: "aside_failed", session };
|
|
340
|
+
const line = result.stdout.split("\n").reverse().find((entry) => entry.startsWith(RESULT_PREFIX));
|
|
341
|
+
if (!line) return { out: "aside_output_invalid", session };
|
|
342
|
+
try {
|
|
343
|
+
const parsed: unknown = JSON.parse(line.slice(RESULT_PREFIX.length));
|
|
344
|
+
return { out: parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed as Record<string, unknown> : "aside_output_invalid", session };
|
|
345
|
+
} catch {
|
|
346
|
+
return { out: "aside_output_invalid", session };
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
private async accountOk(target: AsideTarget): Promise<boolean> {
|
|
351
|
+
return (await this.deps.confirmedAccount()) === target.account;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** Applies the identity check to one observation; a moved or replaced tab is suspended and needs a new selection. */
|
|
355
|
+
private verify(target: AsideTarget, seen: unknown): "ok" | AsideRefusal {
|
|
356
|
+
const s = seen as Seen | undefined;
|
|
357
|
+
if (!s || typeof s.targetId !== "string") {
|
|
358
|
+
this.deps.registry.suspend(target);
|
|
359
|
+
this.snapshots.delete(target.handle);
|
|
360
|
+
return "target_changed";
|
|
361
|
+
}
|
|
362
|
+
const check = checkObservation(target, { targetId: s.targetId, url: s.url, account: target.account });
|
|
363
|
+
if (check !== "ok") {
|
|
364
|
+
this.deps.registry.suspend(target);
|
|
365
|
+
this.snapshots.delete(target.handle);
|
|
366
|
+
}
|
|
367
|
+
return check;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
async invoke(ctx: AsideContext, input: unknown): Promise<AsideResult> {
|
|
371
|
+
const request = parseAsideRequest(input);
|
|
372
|
+
if ("refused" in request) return refuse(request.refused);
|
|
373
|
+
if (request.operation === "open_owned") return this.openOwned(ctx, request.url);
|
|
374
|
+
const target = this.deps.registry.resolve(request.targetHandle, ctx.agentId, ctx.incarnation);
|
|
375
|
+
if ("refused" in target) return refuse(target.refused, request.targetHandle);
|
|
376
|
+
if (!this.deps.authorize(permissionFor(request.operation), target)) return refuse("permission_denied", target.handle);
|
|
377
|
+
if ((request.operation === "navigate_owned" || request.operation === "close_owned") && target.kind !== "owned") return refuse("borrowed_target", target.handle);
|
|
378
|
+
if (!await this.accountOk(target)) {
|
|
379
|
+
this.deps.registry.suspend(target);
|
|
380
|
+
return refuse("account_changed", target.handle);
|
|
381
|
+
}
|
|
382
|
+
switch (request.operation) {
|
|
383
|
+
case "read": return this.read(target);
|
|
384
|
+
case "screenshot": return this.screenshot(target);
|
|
385
|
+
case "navigate_owned": return this.navigate(target, request.url);
|
|
386
|
+
case "close_owned": return this.close(target, "agent_request");
|
|
387
|
+
case "click": case "fill": return this.act(ctx, target, request);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* C8: checks a click/fill without running it and describes what it would act on, for the owner's one-shot confirmation.
|
|
393
|
+
* The same identity, permission and snapshot checks as the action itself; the snapshot is kept for the confirmed run.
|
|
394
|
+
*/
|
|
395
|
+
prepareAction(ctx: AsideContext, input: unknown): AsidePreparedAction {
|
|
396
|
+
const request = parseAsideRequest(input);
|
|
397
|
+
if ("refused" in request) return { ok: false, refused: request.refused };
|
|
398
|
+
if (request.operation !== "click" && request.operation !== "fill") return { ok: false, refused: "invalid_input" };
|
|
399
|
+
const target = this.deps.registry.resolve(request.targetHandle, ctx.agentId, ctx.incarnation);
|
|
400
|
+
if ("refused" in target) return { ok: false, refused: target.refused, targetHandle: request.targetHandle };
|
|
401
|
+
if (!this.deps.authorize(permissionFor(request.operation), target)) return { ok: false, refused: "permission_denied", targetHandle: target.handle };
|
|
402
|
+
const kind = classifyRef(request.ref);
|
|
403
|
+
if (kind === "frame") return { ok: false, refused: "excluded_frame", targetHandle: target.handle };
|
|
404
|
+
const snapshot = this.snapshots.get(target.handle);
|
|
405
|
+
const item = kind === "main" && snapshot?.revision === request.snapshotRevision ? snapshot.items.find((entry) => entry.ref === request.ref) : undefined;
|
|
406
|
+
if (!item) return { ok: false, refused: "stale_ref", targetHandle: target.handle };
|
|
407
|
+
return { ok: true, target: { handle: target.handle, origin: target.origin, kind: target.kind }, element: { tag: item.tag, role: item.role, name: item.name } };
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
private async openOwned(ctx: AsideContext, url: string): Promise<AsideResult> {
|
|
411
|
+
if (isProtectedUrl(url, this.deps.gatewayOrigins)) return refuse("protected_target");
|
|
412
|
+
if (!this.deps.authorize("browser.aside.control", null)) return refuse("permission_denied");
|
|
413
|
+
const account = await this.deps.confirmedAccount();
|
|
414
|
+
if (!account) return refuse("target_identity_unclear");
|
|
415
|
+
const { out, session } = await this.execWith({ account }, "open_owned", { url });
|
|
416
|
+
if (typeof out === "string") return refuse(out);
|
|
417
|
+
const opened = out.opened as Seen | null | undefined;
|
|
418
|
+
if (!opened || typeof opened.targetId !== "string") return refuse("target_identity_unclear");
|
|
419
|
+
const target = this.deps.registry.registerOwned({ host: this.deps.host, account, targetId: opened.targetId, url, agentId: ctx.agentId, incarnation: ctx.incarnation });
|
|
420
|
+
if (session?.closed) this.lost.add(target.handle);
|
|
421
|
+
else session?.owned.set(target.handle, target);
|
|
422
|
+
if (originOf(opened.url) !== target.origin) {
|
|
423
|
+
await this.close(target, "redirected_on_open");
|
|
424
|
+
return refuse("origin_changed", target.handle);
|
|
425
|
+
}
|
|
426
|
+
return { ok: true, targetHandle: target.handle, origin: target.origin, background: "unverified_cli_default" };
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
private async read(target: AsideTarget): Promise<AsideResult> {
|
|
430
|
+
const out = await this.exec(target, "read", { targetId: target.targetId, limit: TEXT_LIMIT });
|
|
431
|
+
if (typeof out === "string") return refuse(out, target.handle);
|
|
432
|
+
for (const seen of [out.pre, out.post]) {
|
|
433
|
+
const check = this.verify(target, seen);
|
|
434
|
+
if (check !== "ok") return refuse(check, target.handle);
|
|
435
|
+
}
|
|
436
|
+
const doc = out.doc as { title: string; text: string; textLength: number; items: Item[]; frames: number };
|
|
437
|
+
const revision = snapshotRevision(target.targetId, (out.post as Seen).url, doc.items.map((item) => JSON.stringify(item)));
|
|
438
|
+
this.snapshots.set(target.handle, { revision, items: doc.items });
|
|
439
|
+
target.snapshotRevision = revision;
|
|
440
|
+
return { ok: true, targetHandle: target.handle, url: (out.post as Seen).url, title: doc.title, text: doc.text, complete: doc.text.length >= doc.textLength,
|
|
441
|
+
range: { start: 0, end: doc.text.length, total: doc.textLength }, excludedFrames: doc.frames, elements: doc.items, snapshotRevision: revision };
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
private async screenshot(target: AsideTarget): Promise<AsideResult> {
|
|
445
|
+
const out = await this.exec(target, "screenshot", { targetId: target.targetId });
|
|
446
|
+
if (typeof out === "string") return refuse(out, target.handle);
|
|
447
|
+
for (const seen of [out.pre, out.post]) {
|
|
448
|
+
const check = this.verify(target, seen);
|
|
449
|
+
if (check !== "ok") return refuse(check, target.handle);
|
|
450
|
+
}
|
|
451
|
+
const image = typeof out.image === "string" && out.image.length <= IMAGE_LIMIT ? out.image : null;
|
|
452
|
+
return image === null
|
|
453
|
+
? { ok: true, targetHandle: target.handle, view: "view_unavailable", excludedFrames: (out.doc as { frames: number }).frames }
|
|
454
|
+
: { ok: true, targetHandle: target.handle, view: "png_base64", image };
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
private async navigate(target: AsideTarget, url: string): Promise<AsideResult> {
|
|
458
|
+
if (isProtectedUrl(url, this.deps.gatewayOrigins)) return refuse("protected_target", target.handle);
|
|
459
|
+
const out = await this.exec(target, "navigate_owned", { targetId: target.targetId, url });
|
|
460
|
+
if (typeof out === "string") return refuse(out, target.handle);
|
|
461
|
+
const pre = this.verify(target, out.pre);
|
|
462
|
+
if (pre !== "ok") return refuse(pre, target.handle);
|
|
463
|
+
const post = out.post as Seen | undefined;
|
|
464
|
+
this.snapshots.delete(target.handle);
|
|
465
|
+
target.snapshotRevision = null;
|
|
466
|
+
if (!post || post.targetId !== target.targetId || originOf(post.url) !== originOf(url) || isProtectedUrl(post.url, this.deps.gatewayOrigins)) {
|
|
467
|
+
this.deps.registry.suspend(target);
|
|
468
|
+
return refuse("origin_changed", target.handle);
|
|
469
|
+
}
|
|
470
|
+
target.origin = originOf(url) as string;
|
|
471
|
+
return { ok: true, targetHandle: target.handle, url: post.url };
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
private async act(ctx: AsideContext, target: AsideTarget, request: Extract<AsideRequest, { operation: "click" | "fill" }>): Promise<AsideResult> {
|
|
475
|
+
const kind = classifyRef(request.ref);
|
|
476
|
+
if (kind === "frame") return refuse("excluded_frame", target.handle);
|
|
477
|
+
// The one-shot ticket is checked first, so a replayed ticket is refused as such even after its snapshot is gone.
|
|
478
|
+
const text = request.operation === "fill" ? request.text : null;
|
|
479
|
+
if (!this.deps.tickets.consume(ctx.actionTicket, actionDigest(target.handle, request.operation, request.ref, request.snapshotRevision, text))) {
|
|
480
|
+
return refuse("action_ticket_required", target.handle);
|
|
481
|
+
}
|
|
482
|
+
const snapshot = this.snapshots.get(target.handle);
|
|
483
|
+
if (kind !== "main" || !snapshot || snapshot.revision !== request.snapshotRevision) return refuse("stale_ref", target.handle);
|
|
484
|
+
const index = snapshot.items.findIndex((item) => item.ref === request.ref);
|
|
485
|
+
if (index < 0) return refuse("stale_ref", target.handle);
|
|
486
|
+
this.snapshots.delete(target.handle);
|
|
487
|
+
target.snapshotRevision = null;
|
|
488
|
+
const out = await this.exec(target, "act", { targetId: target.targetId, origin: target.origin, expected: snapshot.items, index, text });
|
|
489
|
+
if (typeof out === "string") return refuse(out, target.handle);
|
|
490
|
+
const pre = this.verify(target, out.pre);
|
|
491
|
+
if (pre !== "ok") return refuse(pre, target.handle);
|
|
492
|
+
if (out.acted !== true) return refuse("stale_ref", target.handle);
|
|
493
|
+
const post = this.verify(target, out.post);
|
|
494
|
+
if (post !== "ok") return refuse(post, target.handle);
|
|
495
|
+
return { ok: true, targetHandle: target.handle, acted: request.operation };
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
private async close(target: AsideTarget, reason: string): Promise<AsideResult> {
|
|
499
|
+
const out = await this.exec(target, "close_owned", { targetId: target.targetId });
|
|
500
|
+
const closed = typeof out !== "string" && out.closed === true;
|
|
501
|
+
this.deps.registry.recordClose(target, reason, closed ? "closed" : "close_failed");
|
|
502
|
+
if (closed) this.session?.owned.delete(target.handle);
|
|
503
|
+
this.snapshots.delete(target.handle);
|
|
504
|
+
return closed ? { ok: true, targetHandle: target.handle, closed: true } : refuse(typeof out === "string" ? out : "aside_failed", target.handle);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/** Work end, cancel, revoke, exception or restart: close every owned tab of the agent, never a borrowed one. */
|
|
508
|
+
async cleanup(agentId: string, reason: string): Promise<void> {
|
|
509
|
+
for (const target of this.deps.registry.ownedActive(agentId)) await this.close(target, reason);
|
|
510
|
+
for (const target of this.deps.registry.borrowed(agentId)) this.deps.registry.recordClose(target, reason, "kept_borrowed");
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/** Adapter end (gateway stop): closes every owned tab the live session opened, then ends the CLI process. */
|
|
514
|
+
async shutdown(reason = "adapter_shutdown"): Promise<void> {
|
|
515
|
+
const session = this.session;
|
|
516
|
+
if (session) {
|
|
517
|
+
for (const target of [...session.owned.values()]) await this.close(target, reason);
|
|
518
|
+
await session.shutdown(SHUTDOWN_MS);
|
|
519
|
+
}
|
|
520
|
+
this.stopped = true;
|
|
521
|
+
}
|
|
522
|
+
}
|