@nikelyh/offscript 0.0.0-stage → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +183 -2
- package/THIRD_PARTY_NOTICES.md +16 -0
- package/dist/packages/application/assign-investigation/execute.js +34 -0
- package/dist/packages/application/assign-investigation/ports.js +1 -0
- package/dist/packages/application/assign-investigation/resolve-context.js +41 -0
- package/dist/packages/application/check-target.js +4 -0
- package/dist/packages/application/complete-investigation/execute.js +54 -0
- package/dist/packages/application/complete-investigation/ports.js +1 -0
- package/dist/packages/application/complete-investigation/resolve-context.js +48 -0
- package/dist/packages/application/configure-simulation/execute.js +29 -0
- package/dist/packages/application/configure-simulation/ports.js +1 -0
- package/dist/packages/application/configure-simulation/profiles.js +16 -0
- package/dist/packages/application/delete-configured-simulation.js +11 -0
- package/dist/packages/application/errors.js +8 -0
- package/dist/packages/application/index.js +11 -0
- package/dist/packages/application/outbox/dispatch.js +48 -0
- package/dist/packages/application/outbox/ports.js +16 -0
- package/dist/packages/application/recover-simulation/execute.js +31 -0
- package/dist/packages/application/recover-simulation/ports.js +1 -0
- package/dist/packages/application/request-investigation/execute.js +38 -0
- package/dist/packages/application/request-investigation/ports.js +1 -0
- package/dist/packages/application/request-investigation/resolve-context.js +41 -0
- package/dist/packages/browser/browser-manager.js +91 -0
- package/dist/packages/browser/dom-snapshot.js +186 -0
- package/dist/packages/browser/index.js +5 -0
- package/dist/packages/browser/scope-guard.js +73 -0
- package/dist/packages/browser/session.js +209 -0
- package/dist/packages/browser/tools/browser-tools.js +192 -0
- package/dist/packages/browser/types.js +1 -0
- package/dist/packages/contracts/errors.js +8 -0
- package/dist/packages/contracts/event-envelope.js +28 -0
- package/dist/packages/contracts/event-validation.js +44 -0
- package/dist/packages/contracts/events.js +1 -0
- package/dist/packages/contracts/finding-assessment.js +128 -0
- package/dist/packages/contracts/index.js +15 -0
- package/dist/packages/contracts/investigation-events.js +43 -0
- package/dist/packages/contracts/investigation-summary.js +60 -0
- package/dist/packages/contracts/mappers.js +17 -0
- package/dist/packages/contracts/observation-summary.js +25 -0
- package/dist/packages/contracts/semantic-events.js +41 -0
- package/dist/packages/contracts/serialization.js +18 -0
- package/dist/packages/contracts/simulation-configuration.js +17 -0
- package/dist/packages/contracts/simulation-execution.js +62 -0
- package/dist/packages/contracts/simulation-results.js +52 -0
- package/dist/packages/contracts/simulation-setup.js +60 -0
- package/dist/packages/contracts/summaries.js +42 -0
- package/dist/packages/contracts/target-connection.js +36 -0
- package/dist/packages/contracts/validation.js +42 -0
- package/dist/packages/domain/agent.js +23 -0
- package/dist/packages/domain/environment.js +1 -0
- package/dist/packages/domain/errors.js +14 -0
- package/dist/packages/domain/identity.js +16 -0
- package/dist/packages/domain/index.js +10 -0
- package/dist/packages/domain/investigation-result.js +19 -0
- package/dist/packages/domain/investigation.js +70 -0
- package/dist/packages/domain/observation.js +19 -0
- package/dist/packages/domain/profile.js +19 -0
- package/dist/packages/domain/simulation.js +61 -0
- package/dist/packages/domain/timestamp.js +9 -0
- package/dist/packages/orchestration/browser-exploration-factory.js +135 -0
- package/dist/packages/orchestration/controlled-demo-factory.js +26 -0
- package/dist/packages/orchestration/deterministic-browser-runner.js +92 -0
- package/dist/packages/orchestration/deterministic-runner.js +69 -0
- package/dist/packages/orchestration/event-bridge.js +27 -0
- package/dist/packages/orchestration/guard-inference.js +44 -0
- package/dist/packages/orchestration/index.js +7 -0
- package/dist/packages/orchestration/investigation-reaction.js +53 -0
- package/dist/packages/orchestration/investigation-store.js +96 -0
- package/dist/packages/orchestration/memory-outbox.js +63 -0
- package/dist/packages/orchestration/multi-mode-factory.js +12 -0
- package/dist/packages/orchestration/outbox-publisher.js +13 -0
- package/dist/packages/orchestration/session-storage.js +52 -0
- package/dist/packages/orchestration/session.js +176 -0
- package/dist/packages/orchestration/situations.js +26 -0
- package/dist/packages/orchestration/state.js +32 -0
- package/dist/packages/orchestration/tools/arguments.js +13 -0
- package/dist/packages/orchestration/tools/browser-exploration-tools.js +154 -0
- package/dist/packages/orchestration/tools/browser-interaction-tools.js +74 -0
- package/dist/packages/orchestration/tools/exploration-tools.js +119 -0
- package/dist/packages/persistence/codecs/investigation.js +30 -0
- package/dist/packages/persistence/codecs/simulation.js +69 -0
- package/dist/packages/persistence/codecs/values.js +24 -0
- package/dist/packages/persistence/connection.js +29 -0
- package/dist/packages/persistence/errors.js +8 -0
- package/dist/packages/persistence/index.js +22 -0
- package/dist/packages/persistence/migrate.js +40 -0
- package/dist/packages/persistence/migrations/0000_initial_domain_storage.sql +92 -0
- package/dist/packages/persistence/migrations/0001_transactional_outbox.sql +26 -0
- package/dist/packages/persistence/migrations/0002_simulation_configuration.sql +7 -0
- package/dist/packages/persistence/migrations/meta/0000_snapshot.json +637 -0
- package/dist/packages/persistence/migrations/meta/0001_snapshot.json +812 -0
- package/dist/packages/persistence/migrations/meta/0002_snapshot.json +859 -0
- package/dist/packages/persistence/migrations/meta/_journal.json +27 -0
- package/dist/packages/persistence/outbox/committer.js +54 -0
- package/dist/packages/persistence/outbox/store.js +66 -0
- package/dist/packages/persistence/repositories/configurations.js +41 -0
- package/dist/packages/persistence/repositories/delete-configuration.js +20 -0
- package/dist/packages/persistence/repositories/investigation-writes.js +72 -0
- package/dist/packages/persistence/repositories/investigations.js +27 -0
- package/dist/packages/persistence/repositories/observations.js +36 -0
- package/dist/packages/persistence/repositories/simulation-writes.js +39 -0
- package/dist/packages/persistence/repositories/simulations.js +23 -0
- package/dist/packages/persistence/schema/configurations.js +10 -0
- package/dist/packages/persistence/schema/index.js +5 -0
- package/dist/packages/persistence/schema/investigations.js +53 -0
- package/dist/packages/persistence/schema/observations.js +17 -0
- package/dist/packages/persistence/schema/outbox.js +26 -0
- package/dist/packages/persistence/schema/simulations.js +29 -0
- package/dist/packages/projects/index.js +2 -0
- package/dist/packages/projects/inspect.js +45 -0
- package/dist/packages/projects/suggest-target.js +34 -0
- package/dist/packages/projects/target-probe.js +61 -0
- package/dist/packages/runtime/deadline.js +11 -0
- package/dist/packages/runtime/evidence-store.js +76 -0
- package/dist/packages/runtime/execution-coordinator.js +223 -0
- package/dist/packages/runtime/execution-settlement.js +50 -0
- package/dist/packages/runtime/execution-types.js +1 -0
- package/dist/packages/runtime/host-config.js +26 -0
- package/dist/packages/runtime/host-storage.js +46 -0
- package/dist/packages/runtime/host.js +2 -0
- package/dist/packages/runtime/index.js +1 -0
- package/dist/packages/runtime/local-host.js +280 -0
- package/dist/packages/runtime/outbox-dispatcher.js +61 -0
- package/dist/packages/runtime/session-registry.js +42 -0
- package/dist/packages/server/configuration-routes.js +85 -0
- package/dist/packages/server/contracts.js +13 -0
- package/dist/packages/server/index.js +2 -0
- package/dist/packages/server/security.js +38 -0
- package/dist/packages/server/server.js +44 -0
- package/dist/packages/server/studio-assets.js +44 -0
- package/dist/packages/server/studio-session.js +28 -0
- package/dist/scripts/server/access-file.js +21 -0
- package/dist/scripts/server/cli-results.js +126 -0
- package/dist/scripts/server/open-browser.js +13 -0
- package/dist/scripts/server/run.js +76 -0
- package/dist/scripts/server/startup-error.js +17 -0
- package/dist/scripts/server/studio-entry-file.js +22 -0
- package/dist/studio/assets/index-DMnHlGCr.js +9 -0
- package/dist/studio/assets/index-Dh5KHl3J.css +1 -0
- package/dist/studio/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
- package/dist/studio/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
- package/dist/studio/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
- package/dist/studio/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
- package/dist/studio/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
- package/dist/studio/assets/space-grotesk-latin-ext-wght-normal-D9tNdqV9.woff2 +0 -0
- package/dist/studio/assets/space-grotesk-latin-wght-normal-BhU9QXUp.woff2 +0 -0
- package/dist/studio/assets/space-grotesk-vietnamese-wght-normal-D0rl6rjA.woff2 +0 -0
- package/dist/studio/brand/logo-perspectives.svg +1 -0
- package/dist/studio/favicon.svg +1 -0
- package/dist/studio/index.html +14 -0
- package/dist/studio/licenses/jetbrains-mono.txt +93 -0
- package/dist/studio/licenses/space-grotesk.txt +93 -0
- package/dist/studio/participants/badger.png +0 -0
- package/dist/studio/participants/compass.png +0 -0
- package/dist/studio/participants/fox.png +0 -0
- package/dist/studio/participants/owl.png +0 -0
- package/dist/studio/participants/raven.png +0 -0
- package/dist/studio/participants/sparrow.png +0 -0
- package/offscript.mjs +178 -0
- package/package.json +100 -4
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { simulationId } from "../domain/index.js";
|
|
2
|
+
import { ApplicationError } from "./errors.js";
|
|
3
|
+
export async function executeDeleteConfiguredSimulation(id, store) {
|
|
4
|
+
simulationId(id);
|
|
5
|
+
try {
|
|
6
|
+
return await store.deleteConfiguredSimulation(id);
|
|
7
|
+
}
|
|
8
|
+
catch (cause) {
|
|
9
|
+
throw new ApplicationError("DELETE_FAILED", "Could not delete the saved simulation.", { cause });
|
|
10
|
+
}
|
|
11
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export { ApplicationError } from "./errors.js";
|
|
2
|
+
export { executeDeleteConfiguredSimulation } from "./delete-configured-simulation.js";
|
|
3
|
+
export { executeCheckTarget } from "./check-target.js";
|
|
4
|
+
export { executeRequestInvestigation } from "./request-investigation/execute.js";
|
|
5
|
+
export { executeAssignInvestigation } from "./assign-investigation/execute.js";
|
|
6
|
+
export { executeCompleteInvestigation } from "./complete-investigation/execute.js";
|
|
7
|
+
export { dispatchOutboxBatch } from "./outbox/dispatch.js";
|
|
8
|
+
export { validateClaim, validateDispatch } from "./outbox/ports.js";
|
|
9
|
+
export { executeConfigureSimulation } from "./configure-simulation/execute.js";
|
|
10
|
+
export { configuredSimulation } from "./configure-simulation/profiles.js";
|
|
11
|
+
export { executeRecoverSimulation } from "./recover-simulation/execute.js";
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { deserializeSemanticEvent } from "../../contracts/index.js";
|
|
2
|
+
import { validateDispatch } from "./ports.js";
|
|
3
|
+
export async function dispatchOutboxBatch(dependencies, options) {
|
|
4
|
+
validateDispatch(options);
|
|
5
|
+
const leases = await dependencies.store.claim({
|
|
6
|
+
now: dependencies.now(), leaseMs: options.leaseMs, limit: options.batchSize,
|
|
7
|
+
maxAttempts: options.maxAttempts, token: dependencies.token(),
|
|
8
|
+
});
|
|
9
|
+
const stats = { claimed: leases.length, accepted: 0, deferred: 0, rejected: 0, stale: 0 };
|
|
10
|
+
for (const lease of leases) {
|
|
11
|
+
if (dependencies.now() >= lease.leaseUntil) {
|
|
12
|
+
stats.stale++;
|
|
13
|
+
continue;
|
|
14
|
+
}
|
|
15
|
+
let event;
|
|
16
|
+
try {
|
|
17
|
+
const parsed = deserializeSemanticEvent(lease.eventJson);
|
|
18
|
+
if ((parsed.type !== "investigation.requested" && parsed.type !== "investigation.assigned" && parsed.type !== "investigation.completed") ||
|
|
19
|
+
parsed.eventId !== lease.eventId || parsed.simulationId !== lease.simulationId ||
|
|
20
|
+
parsed.payload.investigation.id !== lease.investigationId ||
|
|
21
|
+
(parsed.type === "investigation.requested" ? lease.revision !== 0 : lease.revision < 1))
|
|
22
|
+
throw new Error("Outbox event identity mismatch.");
|
|
23
|
+
event = parsed;
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
const updated = await dependencies.store.reject(lease.eventId, lease.leaseToken, dependencies.now(), "INVALID_EVENT", null);
|
|
27
|
+
stats[updated ? "rejected" : "stale"]++;
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
let outcome;
|
|
31
|
+
try {
|
|
32
|
+
outcome = await dependencies.publisher.publish(event);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
const now = dependencies.now();
|
|
36
|
+
const backoff = Math.min(options.retryMaxMs, options.retryBaseMs * 2 ** Math.min(lease.attempts - 1, 30));
|
|
37
|
+
const updated = await dependencies.store.reject(lease.eventId, lease.leaseToken, now, "PUBLISH_REJECTED", lease.attempts >= options.maxAttempts ? null : now + backoff);
|
|
38
|
+
stats[updated ? "rejected" : "stale"]++;
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
const now = dependencies.now();
|
|
42
|
+
const updated = outcome === "deferred"
|
|
43
|
+
? await dependencies.store.defer(lease.eventId, lease.leaseToken, now, now + options.deferMs)
|
|
44
|
+
: await dependencies.store.accept(lease.eventId, lease.leaseToken, now);
|
|
45
|
+
stats[updated ? outcome : "stale"]++;
|
|
46
|
+
}
|
|
47
|
+
return Object.freeze(stats);
|
|
48
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export function validateDispatch(options) {
|
|
2
|
+
for (const value of Object.values(options)) {
|
|
3
|
+
if (!Number.isSafeInteger(value) || value <= 0 || value > 2_147_483_647)
|
|
4
|
+
throw new Error("Outbox options must be bounded positive integers.");
|
|
5
|
+
}
|
|
6
|
+
if (options.batchSize > 100 || options.maxAttempts > 100 || options.retryBaseMs > options.retryMaxMs)
|
|
7
|
+
throw new Error("Invalid outbox limits.");
|
|
8
|
+
}
|
|
9
|
+
export function validateClaim(options) {
|
|
10
|
+
if (!Number.isSafeInteger(options.now) || options.now < 0 || !options.token.trim())
|
|
11
|
+
throw new Error("Invalid outbox claim identity/time.");
|
|
12
|
+
if (!Number.isSafeInteger(options.leaseMs) || options.leaseMs <= 0 || !Number.isSafeInteger(options.now + options.leaseMs))
|
|
13
|
+
throw new Error("Invalid lease duration.");
|
|
14
|
+
if (!Number.isInteger(options.limit) || options.limit < 1 || options.limit > 100 || !Number.isInteger(options.maxAttempts) || options.maxAttempts < 1 || options.maxAttempts > 100)
|
|
15
|
+
throw new Error("Invalid claim limits.");
|
|
16
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { simulationId, transitionSimulation } from "../../domain/index.js";
|
|
2
|
+
import { ApplicationError } from "../errors.js";
|
|
3
|
+
export async function executeRecoverSimulation(command, store) {
|
|
4
|
+
simulationId(command.simulationId);
|
|
5
|
+
const sim = await store.findSimulation(command.simulationId);
|
|
6
|
+
if (!sim) {
|
|
7
|
+
throw new ApplicationError("SIMULATION_NOT_FOUND", "Simulation not found.");
|
|
8
|
+
}
|
|
9
|
+
if (sim.lifecycle.status !== "running") {
|
|
10
|
+
throw new ApplicationError("INVALID_EXECUTION_STATE", `Only running simulations can be recovered; current status is ${sim.lifecycle.status}.`);
|
|
11
|
+
}
|
|
12
|
+
const action = command.action ?? "fail";
|
|
13
|
+
const reason = (command.reason && command.reason.trim())
|
|
14
|
+
? command.reason
|
|
15
|
+
: "INTERRUPTED_BY_HOST_RESTART";
|
|
16
|
+
const nextSim = action === "cancel"
|
|
17
|
+
? transitionSimulation(sim, { status: "cancelled" })
|
|
18
|
+
: transitionSimulation(sim, { status: "failed", reason });
|
|
19
|
+
const commit = await store.updateSimulationAggregate(nextSim, { status: "running" });
|
|
20
|
+
if (commit === "conflict") {
|
|
21
|
+
throw new ApplicationError("EXECUTION_ALREADY_ACTIVE", "Simulation state changed concurrently.");
|
|
22
|
+
}
|
|
23
|
+
if (commit === "not-found") {
|
|
24
|
+
throw new ApplicationError("SIMULATION_NOT_FOUND", "Simulation not found.");
|
|
25
|
+
}
|
|
26
|
+
return Object.freeze({
|
|
27
|
+
simulation: nextSim,
|
|
28
|
+
action,
|
|
29
|
+
recoveredAt: new Date().toISOString(),
|
|
30
|
+
});
|
|
31
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { agentId, investigationId, observationId, requestInvestigation, simulationId } from "../../domain/index.js";
|
|
2
|
+
import { parseInvestigationEvent } from "../../contracts/index.js";
|
|
3
|
+
import { ApplicationError } from "../errors.js";
|
|
4
|
+
import { resolveContext } from "./resolve-context.js";
|
|
5
|
+
export async function executeRequestInvestigation(input, dependencies) {
|
|
6
|
+
// Snapshot caller-owned data before awaiting adapters.
|
|
7
|
+
const command = Object.freeze({
|
|
8
|
+
...input, investigationId: investigationId(input.investigationId), simulationId: simulationId(input.simulationId),
|
|
9
|
+
requestedBy: agentId(input.requestedBy), observationIds: Object.freeze(input.observationIds.map(observationId)),
|
|
10
|
+
});
|
|
11
|
+
const observations = await resolveContext(command, dependencies);
|
|
12
|
+
const investigation = requestInvestigation({
|
|
13
|
+
id: command.investigationId, simulationId: command.simulationId, requestedBy: command.requestedBy,
|
|
14
|
+
hypothesis: command.hypothesis, requestedAt: command.requestedAt,
|
|
15
|
+
}, observations);
|
|
16
|
+
const event = parseInvestigationEvent({
|
|
17
|
+
eventId: command.eventId, type: "investigation.requested", schemaVersion: 1,
|
|
18
|
+
occurredAt: command.requestedAt, simulationId: command.simulationId,
|
|
19
|
+
origin: { kind: "agent", agentId: command.requestedBy }, correlationId: command.correlationId,
|
|
20
|
+
payload: { investigation },
|
|
21
|
+
});
|
|
22
|
+
if (event.type !== "investigation.requested") {
|
|
23
|
+
throw new ApplicationError("INVALID_REFERENCE", "Unexpected event type returned by the contract parser.");
|
|
24
|
+
}
|
|
25
|
+
let saved;
|
|
26
|
+
try {
|
|
27
|
+
saved = await dependencies.investigations.commitRequest(event);
|
|
28
|
+
}
|
|
29
|
+
catch (cause) {
|
|
30
|
+
throw new ApplicationError("SAVE_FAILED", "Investigation save failed; its commit outcome may be unknown.", { cause });
|
|
31
|
+
}
|
|
32
|
+
if (saved === "already-exists") {
|
|
33
|
+
throw new ApplicationError("INVESTIGATION_ALREADY_EXISTS", "An investigation with this identity already exists.");
|
|
34
|
+
}
|
|
35
|
+
if (saved === "event-conflict")
|
|
36
|
+
throw new ApplicationError("EVENT_ID_CONFLICT", "Event identity already has different content.");
|
|
37
|
+
return Object.freeze({ investigation, event, publication: Object.freeze({ status: saved === "duplicate" ? "already-recorded" : "queued" }) });
|
|
38
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { ApplicationError } from "../errors.js";
|
|
2
|
+
async function read(operation) {
|
|
3
|
+
try {
|
|
4
|
+
return await operation();
|
|
5
|
+
}
|
|
6
|
+
catch (cause) {
|
|
7
|
+
throw new ApplicationError("READ_FAILED", "Could not read investigation context.", { cause });
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
export async function resolveContext(command, dependencies) {
|
|
11
|
+
const simulation = await read(() => dependencies.simulations.findSimulation(command.simulationId));
|
|
12
|
+
if (!simulation)
|
|
13
|
+
throw new ApplicationError("SIMULATION_NOT_FOUND", "Simulation does not exist.");
|
|
14
|
+
validateSimulation(simulation, command);
|
|
15
|
+
const observations = [];
|
|
16
|
+
for (const id of command.observationIds) {
|
|
17
|
+
const observation = await read(() => dependencies.observations.findObservation(id));
|
|
18
|
+
if (!observation)
|
|
19
|
+
throw new ApplicationError("OBSERVATION_NOT_FOUND", `Observation does not exist: ${id}`);
|
|
20
|
+
if (observation.id !== id || observation.simulationId !== simulation.id ||
|
|
21
|
+
!simulation.agents.some((agent) => agent.id === observation.agentId)) {
|
|
22
|
+
throw new ApplicationError("INVALID_REFERENCE", "Observation identity, simulation, or author is inconsistent.");
|
|
23
|
+
}
|
|
24
|
+
observations.push(observation);
|
|
25
|
+
}
|
|
26
|
+
return observations;
|
|
27
|
+
}
|
|
28
|
+
function validateSimulation(simulation, command) {
|
|
29
|
+
if (simulation.id !== command.simulationId) {
|
|
30
|
+
throw new ApplicationError("INVALID_REFERENCE", "Simulation reader returned a different identity.");
|
|
31
|
+
}
|
|
32
|
+
if (simulation.lifecycle.status !== "running") {
|
|
33
|
+
throw new ApplicationError("SIMULATION_NOT_RUNNING", "Investigation requests require a running simulation.");
|
|
34
|
+
}
|
|
35
|
+
const requester = simulation.agents.find((agent) => agent.id === command.requestedBy);
|
|
36
|
+
if (!requester)
|
|
37
|
+
throw new ApplicationError("REQUESTER_NOT_FOUND", "Requesting agent is not a simulation member.");
|
|
38
|
+
if (requester.lifecycle.status !== "running") {
|
|
39
|
+
throw new ApplicationError("REQUESTER_NOT_RUNNING", "Requesting agent must have active participation.");
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { chromium } from "playwright";
|
|
2
|
+
import { BrowserUserSession } from "./session.js";
|
|
3
|
+
export class BrowserManager {
|
|
4
|
+
browser = null;
|
|
5
|
+
pendingLaunch = null;
|
|
6
|
+
headless;
|
|
7
|
+
sessions = new Set();
|
|
8
|
+
closed = false;
|
|
9
|
+
constructor(options = {}) {
|
|
10
|
+
this.headless = options.headless ?? true;
|
|
11
|
+
}
|
|
12
|
+
async ensureBrowser() {
|
|
13
|
+
if (this.closed)
|
|
14
|
+
throw new Error("BrowserManager is closed.");
|
|
15
|
+
if (this.browser && this.browser.isConnected()) {
|
|
16
|
+
return this.browser;
|
|
17
|
+
}
|
|
18
|
+
if (this.pendingLaunch) {
|
|
19
|
+
return this.pendingLaunch;
|
|
20
|
+
}
|
|
21
|
+
this.pendingLaunch = (async () => {
|
|
22
|
+
try {
|
|
23
|
+
const browser = await chromium.launch({
|
|
24
|
+
headless: this.headless,
|
|
25
|
+
});
|
|
26
|
+
if (this.closed) {
|
|
27
|
+
await browser.close().catch(() => { });
|
|
28
|
+
throw new Error("BrowserManager was closed during launch.");
|
|
29
|
+
}
|
|
30
|
+
this.browser = browser;
|
|
31
|
+
return browser;
|
|
32
|
+
}
|
|
33
|
+
catch (cause) {
|
|
34
|
+
if (cause instanceof Error && cause.message.includes("Executable doesn't exist")) {
|
|
35
|
+
throw new Error("Chromium executable is not installed. Run 'npx playwright install chromium' before launching browser sessions.", { cause });
|
|
36
|
+
}
|
|
37
|
+
throw cause;
|
|
38
|
+
}
|
|
39
|
+
finally {
|
|
40
|
+
this.pendingLaunch = null;
|
|
41
|
+
}
|
|
42
|
+
})();
|
|
43
|
+
return this.pendingLaunch;
|
|
44
|
+
}
|
|
45
|
+
async createSession(options) {
|
|
46
|
+
if (this.closed)
|
|
47
|
+
throw new Error("BrowserManager is closed.");
|
|
48
|
+
const browser = await this.ensureBrowser();
|
|
49
|
+
const contextOptions = {};
|
|
50
|
+
if (options.storageState) {
|
|
51
|
+
contextOptions.storageState = options.storageState;
|
|
52
|
+
}
|
|
53
|
+
const context = await browser.newContext(contextOptions);
|
|
54
|
+
const session = await BrowserUserSession.create(context, options);
|
|
55
|
+
this.sessions.add(session);
|
|
56
|
+
return session;
|
|
57
|
+
}
|
|
58
|
+
async closeSession(session) {
|
|
59
|
+
this.sessions.delete(session);
|
|
60
|
+
await session.close();
|
|
61
|
+
}
|
|
62
|
+
async close() {
|
|
63
|
+
if (this.closed)
|
|
64
|
+
return;
|
|
65
|
+
this.closed = true;
|
|
66
|
+
for (const session of this.sessions) {
|
|
67
|
+
await session.close().catch(() => { });
|
|
68
|
+
}
|
|
69
|
+
this.sessions.clear();
|
|
70
|
+
if (this.pendingLaunch) {
|
|
71
|
+
try {
|
|
72
|
+
const browser = await this.pendingLaunch;
|
|
73
|
+
await browser.close().catch(() => { });
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
// Ignore launch error during close
|
|
77
|
+
}
|
|
78
|
+
this.pendingLaunch = null;
|
|
79
|
+
}
|
|
80
|
+
if (this.browser) {
|
|
81
|
+
await this.browser.close().catch(() => { });
|
|
82
|
+
this.browser = null;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
isClosed() {
|
|
86
|
+
return this.closed;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
export function createBrowserManager(options = {}) {
|
|
90
|
+
return new BrowserManager(options);
|
|
91
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
const INTERACTIVE_SELECTOR = [
|
|
2
|
+
"button",
|
|
3
|
+
"a[href]",
|
|
4
|
+
"input:not([type='hidden'])",
|
|
5
|
+
"select",
|
|
6
|
+
"textarea",
|
|
7
|
+
"details summary",
|
|
8
|
+
"[role='button']",
|
|
9
|
+
"[role='link']",
|
|
10
|
+
"[role='checkbox']",
|
|
11
|
+
"[role='radio']",
|
|
12
|
+
"[role='textbox']",
|
|
13
|
+
"[role='combobox']",
|
|
14
|
+
"[role='tab']",
|
|
15
|
+
"[role='menuitem']",
|
|
16
|
+
].join(", ");
|
|
17
|
+
export async function capturePageSnapshot(page, epoch) {
|
|
18
|
+
const url = page.url();
|
|
19
|
+
const title = await page.title().catch(() => "");
|
|
20
|
+
// Temporarily mask password values so they never appear in the accessibility tree
|
|
21
|
+
const originalPasswords = await page
|
|
22
|
+
.evaluate(() => {
|
|
23
|
+
const saved = [];
|
|
24
|
+
const pwElements = document.querySelectorAll("input[type='password']");
|
|
25
|
+
for (const el of pwElements) {
|
|
26
|
+
saved.push(el.value);
|
|
27
|
+
el.value = "";
|
|
28
|
+
}
|
|
29
|
+
return saved;
|
|
30
|
+
})
|
|
31
|
+
.catch(() => []);
|
|
32
|
+
let ariaSnapshot;
|
|
33
|
+
try {
|
|
34
|
+
ariaSnapshot = await page.locator(":root").ariaSnapshot();
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
ariaSnapshot = undefined;
|
|
38
|
+
}
|
|
39
|
+
// Restore password values if necessary for form state
|
|
40
|
+
if (originalPasswords.length > 0) {
|
|
41
|
+
await page
|
|
42
|
+
.evaluate((vals) => {
|
|
43
|
+
const pwElements = document.querySelectorAll("input[type='password']");
|
|
44
|
+
for (let i = 0; i < pwElements.length && i < vals.length; i++) {
|
|
45
|
+
const val = vals[i];
|
|
46
|
+
if (val !== undefined && pwElements[i])
|
|
47
|
+
pwElements[i].value = val;
|
|
48
|
+
}
|
|
49
|
+
}, originalPasswords)
|
|
50
|
+
.catch(() => { });
|
|
51
|
+
}
|
|
52
|
+
const { elements, textSummary } = await page.evaluate(({ selector, epochNumber }) => {
|
|
53
|
+
// Clear any previous references
|
|
54
|
+
for (const el of document.querySelectorAll("[data-offscript-ref]")) {
|
|
55
|
+
el.removeAttribute("data-offscript-ref");
|
|
56
|
+
el.removeAttribute("data-offscript-epoch");
|
|
57
|
+
}
|
|
58
|
+
function isVisible(el) {
|
|
59
|
+
const htmlEl = el;
|
|
60
|
+
if (!htmlEl)
|
|
61
|
+
return false;
|
|
62
|
+
const rect = htmlEl.getBoundingClientRect();
|
|
63
|
+
if (rect.width === 0 && rect.height === 0)
|
|
64
|
+
return false;
|
|
65
|
+
const style = window.getComputedStyle(htmlEl);
|
|
66
|
+
if (style.visibility === "hidden" || style.display === "none")
|
|
67
|
+
return false;
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
function computeName(el) {
|
|
71
|
+
const ariaLabel = el.getAttribute("aria-label");
|
|
72
|
+
if (ariaLabel && ariaLabel.trim())
|
|
73
|
+
return ariaLabel.trim();
|
|
74
|
+
const labelledBy = el.getAttribute("aria-labelledby");
|
|
75
|
+
if (labelledBy) {
|
|
76
|
+
const labelEl = document.getElementById(labelledBy);
|
|
77
|
+
if (labelEl?.textContent)
|
|
78
|
+
return labelEl.textContent.trim();
|
|
79
|
+
}
|
|
80
|
+
const inputEl = el;
|
|
81
|
+
if (inputEl.labels && inputEl.labels.length > 0) {
|
|
82
|
+
const label = inputEl.labels[0]?.textContent?.trim();
|
|
83
|
+
if (label)
|
|
84
|
+
return label;
|
|
85
|
+
}
|
|
86
|
+
if (inputEl.placeholder)
|
|
87
|
+
return inputEl.placeholder.trim();
|
|
88
|
+
const textContent = el.textContent?.trim() || "";
|
|
89
|
+
return textContent.slice(0, 100);
|
|
90
|
+
}
|
|
91
|
+
function computeRole(el) {
|
|
92
|
+
const explicitRole = el.getAttribute("role");
|
|
93
|
+
if (explicitRole)
|
|
94
|
+
return explicitRole.toLowerCase();
|
|
95
|
+
const tag = el.tagName.toLowerCase();
|
|
96
|
+
if (tag === "a")
|
|
97
|
+
return "link";
|
|
98
|
+
if (tag === "button")
|
|
99
|
+
return "button";
|
|
100
|
+
if (tag === "select")
|
|
101
|
+
return "combobox";
|
|
102
|
+
if (tag === "textarea")
|
|
103
|
+
return "textbox";
|
|
104
|
+
if (tag === "input") {
|
|
105
|
+
const type = el.type.toLowerCase();
|
|
106
|
+
if (type === "button" || type === "submit" || type === "reset")
|
|
107
|
+
return "button";
|
|
108
|
+
if (type === "checkbox")
|
|
109
|
+
return "checkbox";
|
|
110
|
+
if (type === "radio")
|
|
111
|
+
return "radio";
|
|
112
|
+
return "textbox";
|
|
113
|
+
}
|
|
114
|
+
return tag;
|
|
115
|
+
}
|
|
116
|
+
const found = [];
|
|
117
|
+
const candidates = document.querySelectorAll(selector);
|
|
118
|
+
let counter = 1;
|
|
119
|
+
for (const candidate of candidates) {
|
|
120
|
+
if (!isVisible(candidate))
|
|
121
|
+
continue;
|
|
122
|
+
const ref = `elem_${counter}`;
|
|
123
|
+
candidate.setAttribute("data-offscript-ref", ref);
|
|
124
|
+
candidate.setAttribute("data-offscript-epoch", String(epochNumber));
|
|
125
|
+
const role = computeRole(candidate);
|
|
126
|
+
const name = computeName(candidate);
|
|
127
|
+
const inputEl = candidate;
|
|
128
|
+
const typeAttr = inputEl.type ? inputEl.type.toLowerCase() : undefined;
|
|
129
|
+
const isPassword = typeAttr === "password";
|
|
130
|
+
const text = isPassword ? "" : (candidate.textContent?.trim().slice(0, 80) || "");
|
|
131
|
+
const disabled = candidate.disabled === true ||
|
|
132
|
+
candidate.getAttribute("aria-disabled") === "true";
|
|
133
|
+
let checked;
|
|
134
|
+
if (inputEl.checked !== undefined && (inputEl.type === "checkbox" || inputEl.type === "radio")) {
|
|
135
|
+
checked = inputEl.checked;
|
|
136
|
+
}
|
|
137
|
+
else if (candidate.getAttribute("aria-checked") !== null) {
|
|
138
|
+
checked = candidate.getAttribute("aria-checked") === "true";
|
|
139
|
+
}
|
|
140
|
+
let expanded;
|
|
141
|
+
const ariaExpanded = candidate.getAttribute("aria-expanded");
|
|
142
|
+
if (ariaExpanded === "true")
|
|
143
|
+
expanded = true;
|
|
144
|
+
else if (ariaExpanded === "false")
|
|
145
|
+
expanded = false;
|
|
146
|
+
const placeholder = !isPassword && inputEl.placeholder ? inputEl.placeholder : undefined;
|
|
147
|
+
found.push({
|
|
148
|
+
ref,
|
|
149
|
+
role,
|
|
150
|
+
name,
|
|
151
|
+
tagName: candidate.tagName,
|
|
152
|
+
text,
|
|
153
|
+
disabled,
|
|
154
|
+
...(typeAttr ? { type: typeAttr } : {}),
|
|
155
|
+
...(checked !== undefined ? { checked } : {}),
|
|
156
|
+
...(expanded !== undefined ? { expanded } : {}),
|
|
157
|
+
...(placeholder ? { placeholder } : {}),
|
|
158
|
+
});
|
|
159
|
+
counter++;
|
|
160
|
+
}
|
|
161
|
+
// Collect bounded visible text summary
|
|
162
|
+
const textNodes = [];
|
|
163
|
+
const contentElements = document.querySelectorAll("h1, h2, h3, h4, p, label, pre, [role='status'], [role='alert'], div[id], span[id], li");
|
|
164
|
+
let totalLength = 0;
|
|
165
|
+
for (const h of contentElements) {
|
|
166
|
+
if (!isVisible(h))
|
|
167
|
+
continue;
|
|
168
|
+
const t = h.textContent?.trim();
|
|
169
|
+
if (t && t.length > 0) {
|
|
170
|
+
textNodes.push(`${h.tagName.toLowerCase()}: ${t}`);
|
|
171
|
+
totalLength += t.length;
|
|
172
|
+
if (totalLength > 3000)
|
|
173
|
+
break;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return { elements: found, textSummary: textNodes.join("\n") };
|
|
177
|
+
}, { selector: INTERACTIVE_SELECTOR, epochNumber: epoch });
|
|
178
|
+
return Object.freeze({
|
|
179
|
+
url,
|
|
180
|
+
title,
|
|
181
|
+
textSummary,
|
|
182
|
+
elements: Object.freeze(elements),
|
|
183
|
+
snapshotEpoch: epoch,
|
|
184
|
+
...(ariaSnapshot ? { ariaSnapshot } : {}),
|
|
185
|
+
});
|
|
186
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { BrowserManager, createBrowserManager } from "./browser-manager.js";
|
|
2
|
+
export { BrowserUserSession } from "./session.js";
|
|
3
|
+
export { ScopeViolationError, assertInScope, isAllowedSubresource, isUrlInScope, normalizeTargetUrl, } from "./scope-guard.js";
|
|
4
|
+
export { capturePageSnapshot } from "./dom-snapshot.js";
|
|
5
|
+
export { createBrowserTools } from "./tools/browser-tools.js";
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
export class ScopeViolationError extends Error {
|
|
2
|
+
attemptedUrl;
|
|
3
|
+
constructor(message, attemptedUrl) {
|
|
4
|
+
super(message);
|
|
5
|
+
this.name = "ScopeViolationError";
|
|
6
|
+
this.attemptedUrl = attemptedUrl;
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
export function normalizeTargetUrl(input) {
|
|
10
|
+
const trimmed = input.trim();
|
|
11
|
+
if (trimmed.length === 0 || trimmed.length > 2048) {
|
|
12
|
+
throw new ScopeViolationError("Target URL is missing or exceeds maximum length.", trimmed);
|
|
13
|
+
}
|
|
14
|
+
let parsed;
|
|
15
|
+
try {
|
|
16
|
+
parsed = new URL(trimmed);
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
throw new ScopeViolationError("Target URL is not a valid URL.", trimmed);
|
|
20
|
+
}
|
|
21
|
+
if (!/^https?:$/i.test(parsed.protocol)) {
|
|
22
|
+
throw new ScopeViolationError("Target URL must use http or https protocol.", trimmed);
|
|
23
|
+
}
|
|
24
|
+
if (parsed.username || parsed.password) {
|
|
25
|
+
throw new ScopeViolationError("Target URL must not contain credentials.", trimmed);
|
|
26
|
+
}
|
|
27
|
+
parsed.hash = "";
|
|
28
|
+
return parsed.href;
|
|
29
|
+
}
|
|
30
|
+
export function isUrlInScope(candidate, targetBaseUrl) {
|
|
31
|
+
try {
|
|
32
|
+
const base = new URL(normalizeTargetUrl(targetBaseUrl));
|
|
33
|
+
const target = new URL(candidate, base);
|
|
34
|
+
if (target.protocol !== base.protocol)
|
|
35
|
+
return false;
|
|
36
|
+
if (target.hostname !== base.hostname)
|
|
37
|
+
return false;
|
|
38
|
+
if (target.port !== base.port)
|
|
39
|
+
return false;
|
|
40
|
+
if (target.username || target.password)
|
|
41
|
+
return false;
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
export function assertInScope(candidate, targetBaseUrl) {
|
|
49
|
+
const base = new URL(normalizeTargetUrl(targetBaseUrl));
|
|
50
|
+
let resolved;
|
|
51
|
+
try {
|
|
52
|
+
resolved = new URL(candidate, base);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
throw new ScopeViolationError(`Cannot resolve URL "${candidate}" against target.`, candidate);
|
|
56
|
+
}
|
|
57
|
+
if (!/^https?:$/i.test(resolved.protocol)) {
|
|
58
|
+
throw new ScopeViolationError(`Disallowed protocol "${resolved.protocol}" for "${candidate}".`, candidate);
|
|
59
|
+
}
|
|
60
|
+
if (resolved.username || resolved.password) {
|
|
61
|
+
throw new ScopeViolationError("Target URL must not contain credentials.", candidate);
|
|
62
|
+
}
|
|
63
|
+
if (resolved.origin !== base.origin) {
|
|
64
|
+
throw new ScopeViolationError(`Navigation to origin "${resolved.origin}" is out of authorized target scope "${base.origin}".`, resolved.href);
|
|
65
|
+
}
|
|
66
|
+
resolved.hash = "";
|
|
67
|
+
return resolved.href;
|
|
68
|
+
}
|
|
69
|
+
export function isAllowedSubresource(url, targetBaseUrl) {
|
|
70
|
+
if (url.startsWith("data:") || url.startsWith("blob:"))
|
|
71
|
+
return true;
|
|
72
|
+
return isUrlInScope(url, targetBaseUrl);
|
|
73
|
+
}
|