Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions apps/desktop/launcher/tron-node.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// Generic launcher for shipped Node bins that import @tronbrowser/* packages by
// bare specifier (e.g. analyze-bin -> @tronbrowser/browser-core). Registers the
// resolver hook that maps those specifiers to the sibling dist trees in the
// launcher payload, then runs the target entry.
//
// node tron-node.mjs <entry.js> [args...]
import { register } from 'node:module';
import { dirname, join } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';

const here = dirname(fileURLToPath(import.meta.url));
register(pathToFileURL(join(here, 'tron-run-hooks.mjs')), import.meta.url, {
data: {
'@tronbrowser/browser-core': pathToFileURL(join(here, 'automate', 'index.js')).href,
'@tronbrowser/agent-runtime': pathToFileURL(join(here, 'analyze', 'index.js')).href,
'@tronbrowser/sdk': pathToFileURL(join(here, 'sdk', 'index.js')).href,
},
});

const entry = process.argv[2];
if (!entry) {
process.stderr.write('tron-node: missing entry\n');
process.exit(2);
}
process.argv = [process.argv[0], entry, ...process.argv.slice(3)];
await import(pathToFileURL(entry).href);
2 changes: 2 additions & 0 deletions apps/desktop/launcher/tron-run.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
const here = dirname(fileURLToPath(import.meta.url));
const sdkEntry = join(here, 'sdk', 'index.js');
const coreEntry = join(here, 'automate', 'index.js');
const agentEntry = join(here, 'analyze', 'index.js');

const argv = process.argv.slice(2);
let script;
Expand Down Expand Up @@ -43,6 +44,7 @@ register(pathToFileURL(join(here, 'tron-run-hooks.mjs')), import.meta.url, {
data: {
'@tronbrowser/sdk': pathToFileURL(sdkEntry).href,
'@tronbrowser/browser-core': pathToFileURL(coreEntry).href,
'@tronbrowser/agent-runtime': pathToFileURL(agentEntry).href,
},
});

Expand Down
14 changes: 9 additions & 5 deletions apps/desktop/scripts/build-release.sh
Original file line number Diff line number Diff line change
Expand Up @@ -61,15 +61,17 @@ stage_automation() { # dest dir
local s="$1"
command -v node >/dev/null 2>&1 && command -v pnpm >/dev/null 2>&1 || {
echo " ! automation runtime skipped (needs node + pnpm)"; return; }
if ( cd "$REPO_ROOT" && pnpm --filter @tronbrowser/browser-core --filter @tronbrowser/sdk build >/dev/null 2>&1 ); then
rm -rf "$s/automate" "$s/sdk"
if ( cd "$REPO_ROOT" && pnpm --filter @tronbrowser/browser-core --filter @tronbrowser/agent-runtime --filter @tronbrowser/sdk build >/dev/null 2>&1 ); then
rm -rf "$s/automate" "$s/analyze" "$s/sdk"
cp -R "$REPO_ROOT/packages/browser-core/dist" "$s/automate"
printf '{\n "type": "module"\n}\n' > "$s/automate/package.json"
cp -R "$REPO_ROOT/packages/agent-runtime/dist" "$s/analyze"
printf '{\n "type": "module"\n}\n' > "$s/analyze/package.json"
cp -R "$REPO_ROOT/packages/sdk/dist" "$s/sdk"
printf '{\n "type": "module"\n}\n' > "$s/sdk/package.json"
echo " + bundled automation runtime + SDK (tron snapshot/extract/run)"
echo " + bundled automation + analyze + SDK (tron snapshot/extract/analyze/run)"
else
echo " ! automation runtime skipped (browser-core/sdk build failed)"
echo " ! automation runtime skipped (browser-core/agent-runtime/sdk build failed)"
fi
}

Expand All @@ -84,9 +86,11 @@ stage() { # dest dir
# Managed-session engine for `tron browser …` / `tron open` (PRD M3.1). Sits
# next to the shim; the `tron` dispatcher resolves it relative to $CURRENT.
install -m 0755 "$DESKTOP/launcher/tron-session" "$s/tron-session"
# `tron run` launcher + ESM resolver hook (PRD M3.4).
# `tron run` launcher + ESM resolver hook (PRD M3.4) and the generic bin
# launcher used by `tron analyze` (PRD M3.5).
install -m 0644 "$DESKTOP/launcher/tron-run.mjs" "$s/tron-run.mjs"
install -m 0644 "$DESKTOP/launcher/tron-run-hooks.mjs" "$s/tron-run-hooks.mjs"
install -m 0644 "$DESKTOP/launcher/tron-node.mjs" "$s/tron-node.mjs"
stage_automation "$s"
# -L dereferences the branding symlinks (icons/logo.svg -> repo-root logo.svg)
# so the package contains real files, not dangling links.
Expand Down
9 changes: 9 additions & 0 deletions apps/web/public/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ Usage:
tron screenshot <p> Save a PNG of the current page (--full-page)
tron headless <url> One-shot: --snapshot | --screenshot <p> | --extract <mode>
tron run <script> Run a JS/TS script using @tronbrowser/sdk (--headless/--trace)
tron analyze [goal] Analyze/fill a form or page (--data, --execute, --json)
tron upgrade Update to the latest release
tron remove Uninstall TronBrowser (keeps your profile data)
tron version Print the installed version
Expand Down Expand Up @@ -176,6 +177,14 @@ case "${1:-}" in
snapshot|click|fill|type|extract|screenshot|pdf)
# CDP automation on the managed session's current page (PRD M3.2/M3.3).
run_automation "$@" ;;
analyze)
# AI-assisted unknown-interface analysis / form fill (PRD M3.5). Runs via
# tron-node.mjs so agent-runtime's @tronbrowser/* imports resolve.
_ld="$(dirname "$(readlink -f "$CURRENT" 2>/dev/null || echo "$CURRENT")")"
ENTRY="$_ld/analyze/analyze-bin.js"
command -v node >/dev/null 2>&1 || { echo "tron analyze needs Node.js (>=22) on PATH." >&2; exit 1; }
[ -f "$ENTRY" ] || { echo "This TronBrowser build lacks the analyze runtime. Run: tron upgrade" >&2; exit 1; }
exec node "$_ld/tron-node.mjs" "$ENTRY" "$@" ;;
headless)
# One-shot: launch a headless ephemeral session, navigate, act, tear down.
run_automation "$@" ;;
Expand Down
66 changes: 66 additions & 0 deletions docs/analyze.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# AI analyze (M3.5)

`tron analyze` inspects the current page of a managed session and answers "what
is this asking for, and what should I do next?" — mapping forms to your data,
proposing a safe plan, and (with `--execute`) filling low-risk fields under a
strict safety policy. **Dry-run by default.**

```sh
tron analyze # describe the page's forms
tron analyze form --json # machine-readable form map
tron analyze "Fill this contact form" --data ./lead.json
tron analyze "Fill this contact form but do not submit" --data ./lead.json --execute --no-submit
tron analyze "Fill and submit" --data ./lead.json --execute --allow-submit
```

`--data` accepts a file, inline JSON, or `-` (stdin):

```json
{ "lead": { "name": "Jane Doe", "email": "jane@example.com", "message": "Please send pricing." } }
```

## What it does

- **Maps fields to data** deterministically: labels/placeholders/ARIA/`name` are
matched to your data paths (synonyms like *e-mail → email*, *organization →
company*) with a confidence score. Plans reference `lead.email`, never the
value — so values stay out of logs and traces.
- **Reports missing required data** and **ambiguous** mappings instead of
guessing.
- **Safety policy** (`--policy safe|auto|ask`, default `safe`):
- Credential/payment/PII fields (password, card, CVV, SSN, API key…) are
**never auto-filled**.
- A final submit needs `--allow-submit`; `--no-submit` fills but never submits.
- Payment/irreversible submits (*Pay*, *Delete account*, *Transfer*…) are
**blocked even with `--allow-submit`**.
- A CAPTCHA/challenge stops the loop.
- **`--execute`** runs a bounded (`--max-steps`, default 8), validated loop:
fill low-risk fields, then stop before the gated submit. Stale refs, missing
data, ambiguity, challenges, and max-steps all end the loop cleanly.

## Output

Text by default; `--json` for a machine-readable `AnalyzeResult` (status,
`detectedForms`, `plan`, `nextAction`, `missingData`, `ambiguous`, `reason`).
Statuses: `planned`, `acted`, `complete`, `needs_confirmation`, `blocked`,
`ambiguous`, `failed`.

## From the SDK

```ts
const result = await page.analyze('Fill contact form', { data: lead }); // dry-run
await page.analyze('Fill contact form', { data: lead, execute: true, noSubmit: true });
```

`page.step()` runs one bounded step; `page.runTask()` runs the loop to
completion.

## Scope

- The **form-fill path is deterministic** (no LLM) and fully covered by tests.
- **Open-ended navigation** goals (e.g. "click through onboarding until the
dashboard") need a configured AI provider; without one, analyze returns
`AI_PROVIDER_NOT_CONFIGURED` rather than guessing. Wiring the BYOK/local
provider planner is a follow-up.
- Requires Node ≥22 and a running managed session (`tron browser launch`).
- Logic lives in `packages/agent-runtime/src/analyze`.
4 changes: 4 additions & 0 deletions packages/agent-runtime/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,11 @@
"test": "vitest run --passWithNoTests",
"lint": "eslint src"
},
"dependencies": {
"@tronbrowser/browser-core": "workspace:*"
},
"devDependencies": {
"happy-dom": "^20.10.6",
"typescript": "^5.6.3",
"vitest": "^2.1.4"
}
Expand Down
13 changes: 13 additions & 0 deletions packages/agent-runtime/src/analyze-bin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/**
* Executable wrapper for `tron analyze`. Built into the launcher payload as
* `analyze/analyze-bin.js`; the shell dispatcher runs it via node.
*/
import { run } from './analyze-cli.js';

run(process.argv.slice(2)).then(
(code) => process.exit(code),
(err: unknown) => {
process.stderr.write(`tron: ${err instanceof Error ? err.message : String(err)}\n`);
process.exit(1);
},
);
88 changes: 88 additions & 0 deletions packages/agent-runtime/src/analyze-cli.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { describe, expect, it, vi } from 'vitest';
import { EXIT, run, type AnalyzeCliDeps } from './analyze-cli.js';
import type { AnalyzeBrowser } from './analyze/analyze.js';
import type { RawFormsResult } from './analyze/form-script.js';

const contactForm: RawFormsResult = {
challenge: false,
forms: [{ name: 'contact', submitRef: '@e4', submitLabel: 'Send', fields: [
{ ref: '@e1', label: 'Name', name: 'name', type: 'text', role: 'input', required: true },
{ ref: '@e2', label: 'Email', name: 'email', type: 'email', role: 'input', required: true },
] }],
};
const lead = { lead: { name: 'Jane', email: 'jane@example.com' } };

function browser(raw: RawFormsResult, hooks: Partial<AnalyzeBrowser> = {}): AnalyzeBrowser {
return {
snapshot: async () => ({ url: 'https://x/contact', title: 'Contact', timestamp: 't', elements: [] }),
readForms: async () => raw,
fill: hooks.fill ?? (async () => {}),
click: hooks.click ?? (async () => {}),
};
}

function harness(overrides: Partial<AnalyzeCliDeps> = {}, raw = contactForm) {
const out: string[] = [];
const err: string[] = [];
const close = vi.fn();
const deps: Partial<AnalyzeCliDeps> = {
env: {},
attach: async () => ({ browser: browser(raw), close }),
readData: async () => lead,
out: (t) => out.push(t),
err: (t) => err.push(t),
...overrides,
};
return { deps, out, err, close };
}

describe('analyze CLI', () => {
it('prints a JSON plan and closes the session', async () => {
const { deps, out, close } = harness();
const code = await run(['Fill contact form', '--data', './lead.json', '--json'], deps);
expect(code).toBe(EXIT.ok);
const result = JSON.parse(out.join('\n'));
expect(result.status).toBe('planned');
expect(result.detectedForms[0].fields[1].valueFrom).toBe('lead.email');
expect(close).toHaveBeenCalled();
});

it('treats a bare mode keyword as mode, not a goal', async () => {
const { deps, out } = harness();
await run(['form', '--json'], deps);
expect(JSON.parse(out.join('\n')).goal).toBeUndefined();
});

it('exits notOk (6) when required data is missing', async () => {
const { deps } = harness({ readData: async () => ({ lead: { name: 'Jane' } }) });
expect(await run(['Fill', '--data', 'x', '--json'], deps)).toBe(EXIT.notOk);
});

it('exits noSession (4) when no managed session', async () => {
const { deps, err } = harness({
attach: async () => {
const e = new Error('No managed session. Run: tron browser launch') as Error & { exit?: number };
e.exit = EXIT.noSession;
throw e;
},
});
const code = await run(['form'], deps);
expect(code).toBe(EXIT.noSession);
expect(err.join('\n')).toContain('tron browser launch');
});

it('executes fills but not submit with --execute --no-submit', async () => {
const fill = vi.fn(async () => {});
const click = vi.fn(async () => {});
const { deps } = harness({ attach: async () => ({ browser: browser(contactForm, { fill, click }), close: vi.fn() }) });
const code = await run(['Fill', '--data', 'x', '--execute', '--no-submit', '--json'], deps);
expect(code).toBe(EXIT.ok);
expect(fill).toHaveBeenCalledTimes(2);
expect(click).not.toHaveBeenCalled();
});

it('rejects an invalid --policy', async () => {
const { deps } = harness();
expect(await run(['form', '--policy', 'wild'], deps)).toBe(EXIT.usage);
});
});
Loading
Loading