@inetafrica/open-claudia 3.5.20 → 3.5.24
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/.env.example +8 -3
- package/CHANGELOG.md +37 -0
- package/README.md +5 -4
- package/core/actions.js +4 -0
- package/core/agent-browser-cli.js +98 -0
- package/core/auth-flow.js +7 -2
- package/core/browser-address.js +171 -0
- package/core/browser-control.js +113 -24
- package/core/browser-teach.js +59 -44
- package/core/browser-view.js +65 -4
- package/core/dream.js +132 -10
- package/core/handlers.js +16 -2
- package/core/limit-pickup.js +24 -20
- package/core/providers/claude.js +15 -8
- package/core/providers/env.js +17 -0
- package/core/providers/events.js +25 -1
- package/core/providers/utility-policy.js +31 -6
- package/core/runner.js +203 -10
- package/core/scheduler.js +57 -0
- package/core/state.js +14 -0
- package/core/system-prompt.js +15 -2
- package/core/turn-journal.js +55 -2
- package/core/usage-limits.js +110 -0
- package/core/utility-agent.js +109 -15
- package/package.json +2 -2
- package/test-fixtures/fake-agent-cli.js +46 -2
- package/test-provider-boot-matrix.js +6 -2
- package/test-provider-capabilities.js +1 -1
- package/test-provider-claude.js +7 -5
- package/test-provider-env-isolation.js +24 -0
- package/test-provider-events.js +43 -1
- package/test-provider-language.js +16 -1
- package/web.js +7 -2
package/.env.example
CHANGED
|
@@ -74,12 +74,17 @@ USAGE_ALERT_COOLDOWN_MS=1800000
|
|
|
74
74
|
# when a window's use passes these percentages, once per window (off = never).
|
|
75
75
|
LIMIT_WARN_PERCENTS=80,95
|
|
76
76
|
# A message a usage limit stopped is picked up when the limit resets, unless
|
|
77
|
-
# the person writes again first: on (default) | off.
|
|
77
|
+
# the person writes again first: on (default) | off. Scheduled runs and the
|
|
78
|
+
# dream's AI step always wait for the reset.
|
|
78
79
|
LIMIT_PICKUP=on
|
|
79
|
-
# Seconds after the reset before
|
|
80
|
-
# than LIMIT_PICKUP_MAX_WAIT_H hours (default 192) is not waited
|
|
80
|
+
# Seconds after the reset before waiting work runs (default 120); a reset
|
|
81
|
+
# further out than LIMIT_PICKUP_MAX_WAIT_H hours (default 192) is not waited
|
|
82
|
+
# for. One rule for chat messages, scheduled runs and the dream.
|
|
81
83
|
LIMIT_PICKUP_DELAY_S=120
|
|
82
84
|
LIMIT_PICKUP_MAX_WAIT_H=192
|
|
85
|
+
# Messages sent while a conversation is busy run together as one turn when it
|
|
86
|
+
# ends (on, default); off runs them one at a time.
|
|
87
|
+
QUEUE_BATCH=on
|
|
83
88
|
MEMORY_RECALL_MAX_CHARS=9000
|
|
84
89
|
# Default recall engine when a chat hasn't picked one with /engine: classic | discoverer
|
|
85
90
|
RECALL_ENGINE=classic
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,42 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 3.5.24
|
|
4
|
+
|
|
5
|
+
The Browser app's address bar, and Chrome in its own container (Sumeet, 9 Oct 21:27 EAT on his phone: "I can't change the url on the mobile app"). A canary build for the Browser canary bot first; the fleet waits for his OK.
|
|
6
|
+
|
|
7
|
+
- **The driver can change the page.** `POST /api/browser/navigate` (open, back, forward, reload) for the person who has taken over, from Agent Space's address bar. Public internet addresses only (`core/browser-address.js`): http or https, no user name or password; no localhost, single-label, .local, .internal, .svc or .home.arpa name; no private, loopback, link-local, shared, benchmarking, multicast or reserved IP, typed in any form the URL parser reads or found in any of the name's DNS answers (3 s at most). It needs the driver's live lock and extends it, runs in order with the driver's input, gives the page 20 s, and logs origin and path only. Teach me records it as a step ("Go to …"). `GET /api/browser/control` says `navigate: true`.
|
|
8
|
+
- **One way to run agent-browser on the bot's own sessions** (`core/agent-browser-cli.js`): Teach me's element lookup and the address bar run it with the agent's environment and only `--session`, `--json` and a command, so no launch setting can ride along. On a bot pod a session reached with other launch settings is relaunched and loses its page.
|
|
9
|
+
- **The agent gets agent-browser's settings.** Every `AGENT_BROWSER_*` variable now reaches the agent process (provider credentials and control-plane tokens are still stripped after), so its own browser commands and the Browser app share one set of launch settings. On a pod the agent used to run agent-browser without the image's `AGENT_BROWSER_EXECUTABLE_PATH`.
|
|
10
|
+
- **Container mode.** With Agent Space's browser container (`AGENT_BROWSER_CDP`, `AGENT_BROWSER_PIN_TAB`), Chrome runs in its own container without the bot's files or keys and every agent-browser call attaches to it, each session pinned to its own tab. One Chrome window paints only its front tab, so opening a viewer brings that session's tab forward (Chrome's `/json/activate`, loopback only). The prompt now says how this bot's Chrome really runs, and no longer claims agent-browser adds `--no-sandbox` by itself.
|
|
11
|
+
- **Tests:** `test-browser-address.js` (new), `test-browser-control.js`, `test-browser-teach.js`, `test-web-browser-app.js`, `test-browser-view.js`, `test-kazee-bot-apps.js`, `test-provider-env-isolation.js`.
|
|
12
|
+
|
|
13
|
+
## 3.5.23
|
|
14
|
+
|
|
15
|
+
Usage limits as one rule, /stop as a real cancel, the queue's ✕ and run-together, and background checks on Haiku 5.5 and Sonnet 5.5 (Sumeet, 9 Oct 15:56 EAT: "yeh release and upgrade all the bots, include rahil for the updates going forward"). 3.5.20 below, the Browser app, only ever went out as a canary build, so this is the first release that brings it to everyone. 3.5.21 and 3.5.22 were tagged with the same changes but never published: 3.5.21's build stopped on the new /stop and queue test, which needed the fake agent its neighbours run on (CI has no Claude or Codex installed), and 3.5.22's builds stopped on two timing checks that busy build machines pushed past their limits (the boot matrix's 30 s per boot, the /stop test's 3 s and 2.5 s windows); those checks now allow more time.
|
|
16
|
+
|
|
17
|
+
Usage limits as one runtime rule (Sumeet, 9 Oct 2026 after the 07:00 dream failed on his bots: "it should use whatever the active model is … if the Claude model's out of usage … we can't assume that the other will be available … do the standard wake up, the delay until the bot's session has been replenished … the handling of session limits working as a foundational concept rather than just being hacks"). On 9 Oct the 07:00 dream's AI step failed on 18 of 24 fleet bots: Codex bots dreamed on a signed-out Claude, used-up limits failed instead of waiting, and Claude Code's refresh-lock race was final. No chats failed.
|
|
18
|
+
|
|
19
|
+
- **Work a usage limit stops waits for the reset, the same way everywhere.** One rule (`core/usage-limits.js` `limitRetryAt`: 2 minutes after the reset, `LIMIT_PICKUP_DELAY_S`; never a reset more than `LIMIT_PICKUP_MAX_WAIT_H`, 192 h, away) now serves a chat message (the 3.5.18 pick-up), a scheduled run and the dream. Never on the other provider: it may not be signed in, or be used up too.
|
|
20
|
+
- **A scheduled run a limit stops waits for the reset.** A wake-up, watch or cron that hit a limit used to retry every 30 s and give up after its attempts ("gave up and will not fire"). It now waits in the jobs store until the reset ("⏳ Claude's weekly limit is used up, so this scheduled run waits. I'll run it at 11:02, when it resets."), without using up an attempt; a cron's ticks meanwhile start nothing; the run after the reset is told it waited, and a reset that is used up again waits again, at most 5 times.
|
|
21
|
+
- **The dream's AI step waits for the reset.** The automatic filing and graph upkeep run on time; the AI-led merges and self-review run after the reset ("⏳ Codex's weekly limit is used up, so the AI-led merges wait for its reset: I'll run them tomorrow at 11:02."), on a timer kept in the dream state, so a restart keeps it. A sign-in that died says so plainly ("Claude's sign-in on this bot has expired (send /login to sign in again)").
|
|
22
|
+
- **Background work with nobody behind it runs on the bot's own provider.** The dream, introspection and other checks outside any chat now use the provider the owner chats on (`/backend`, `/login`), else the one a new chat starts on. They used to take the first provider that did not look signed out, which on a Codex bot was a signed-out Claude.
|
|
23
|
+
- **A signed-out Claude reads as signed out.** `claude auth status` prints `{"loggedIn": false}` (Claude Code 2.1), which no check matched, so the bot read it as "unknown" and still chose Claude.
|
|
24
|
+
- **One reading of a provider's failure for chats and background calls** (`providerFailureCode`). Background calls now see a plan limit as `PROVIDER_USAGE_LIMIT` with its reset time (it was a plain `UTILITY_PROVIDER_ERROR`). A dead sign-in ("Failed to authenticate: OAuth session expired and could not be refreshed", "OAuth access token is invalid", Codex's "Your authentication token has expired") is a sign-in error in chats and background calls alike, so a chat gets the /login help instead of a raw error. Claude Code's refresh-lock race ("another Claude Code process is refreshing it") gets one more try, 20 s later, in work nobody waits on (the dream, memory review, sub-agents); a chat's own checks go without rather than hold the reply up.
|
|
25
|
+
- **A limit is recorded for every waiter.** Any run a limit stops (a chat turn, a scheduled run, a background call) records the provider as used up until its reset (`usage-limits.json` `blocks`); background calls do not start it until then, and any run on it that works clears the record. Chat turns never read the record: they always try, so the provider stays the judge. A weekly Opus limit stops only Opus calls.
|
|
26
|
+
- **Tests:** `test-limit-waits.js` (new: background calls through the fake Claude CLI, scheduled runs, the dream and its timer, provider choice with nobody behind a call, the signed-out status), `test-usage-limits.js` (the rule and the record), `test-provider-events.js` (Claude Code 2.1.280's own sign-in and refresh-race wording); the fake agent CLI gains a `provider-error` scenario (`FAKE_AGENT_ERROR_TEXT`, `FAKE_AGENT_ERROR_ONCE_FILE`).
|
|
27
|
+
|
|
28
|
+
/stop and the queue (Sumeet, 9 Oct 14:18 EAT, with a screenshot of home pi: "it doesn't seem to understand i cancelled something … one for a queued message a small card with delete queued … i can delete message 2 and 4 arbitrarily from the queue … when the turn finishes … get all the messages that are queued at once? no point of them coming in series is there?").
|
|
29
|
+
|
|
30
|
+
- **A /stop is a cancel, not a cut.** /stop kills the provider mid-turn. On the next message Claude Code resumes the session and writes the stopped turn into the history the way a restart's cut reads ("[Request interrupted by user for tool use]", then "Continue from where you left off."), and the system prompt (since 3.5.11) says to finish work a restart cut off. So the stopped request came back: home pi answered the message after "/stop" on "ok play big bang" with "Big Bang didn't start … Just say if you still want it." /stop now marks the conversation in its turn-journal file (kept across a restart), and the conversation's next turn opens with a note that the person stopped that request and it is cancelled; the system prompt says so too. A stop before the provider started, or of a compaction, leaves no note; `/new`, `/end` and a project change drop it.
|
|
31
|
+
- **Each "Queued." notice has a ✕ Remove from queue button.** It takes that one message out of the queue, so of four queued messages the second and fourth can go while the others stay ("Removed from the queue: "…""). Only the person who sent it, or the owner, can remove it; a message that already started says so.
|
|
32
|
+
- **Queued messages run together.** When a turn ends, the messages the person sent meanwhile run as one turn ("3 messages arrived while you were busy … where a later one changes or cancels an earlier one, the later one wins"), in order, with every image and file, the reply answering the newest. They used to run one turn each, in series, each without the others. Only the same person's plain chat messages in the same session, project, provider and model join (a scheduled run, a side chat's follow-up, a limit pick-up and a live call run on their own), at most 10 per turn. `QUEUE_BATCH=off` runs them one at a time again.
|
|
33
|
+
- **Tests:** `test-stop-queue.js` (new: the note after a /stop, once, kept on disk, not after `/new` or a stop before the provider started; three queued messages as one turn with no journal leftovers; ✕ on two of four; who may press; what joins a batch), `test-turn-journal.js` (the stop survives a restart's boot pass); the fake agent CLI logs every `[[tag:…]]` in a prompt and `FAKE_AGENT_PROMPT_PROBES` matches.
|
|
34
|
+
|
|
35
|
+
Background checks on Claude Haiku 5.5 and Sonnet 5.5 (Sumeet, 9 Oct 14:51 EAT: "check if anything using haiku and have it shifted to the new haiku 5.5", then 15:36: "sonnet should also be just 5.5").
|
|
36
|
+
|
|
37
|
+
- **The low tier is Claude Haiku 5.5** (`claude-haiku-5-5`, was `claude-haiku-4-5-20251001`): the recall router and its warm walker, the recall filter, passive group replies, thread titles, the memory duplicate check and the agency deliberation. List price $0.10 / $0.50 per million tokens for prompts up to 100,000 tokens ($0.50 / $2.50 above), against $1 / $5 for Haiku 4.5. Checked live through the utility agent and Claude Code 2.1.280: a structured duplicate-check reply and a plain thread title, about 5 s each. The /model picker offers Haiku 5.5 instead of 4.5; a typed `/model claude-haiku-4-5-20251001` still works.
|
|
38
|
+
- **The medium tier is Claude Sonnet 5.5** (`claude-sonnet-5-5`, was `claude-sonnet-4-6`): the post-turn memory reviewer, the tool-rule guard and `open-claudia agent` sub-agents. List price $2 / $10 per million tokens, cache reads $0.10, against $3 / $15 and $0.30 for Sonnet 4.6; Sonnet 5.5 uses the newer tokenizer (about 30% more tokens for the same text), so a call costs roughly 15% less on fresh input and output and over half less on cache reads. The /model picker offers Sonnet 5.5 instead of 4.6; a typed `/model claude-sonnet-4-6` still works. A weekly Sonnet limit still stops only Sonnet work.
|
|
39
|
+
|
|
3
40
|
## 3.5.20
|
|
4
41
|
|
|
5
42
|
The Browser app in Kazee Chat, bot apps v1 (Sumeet, 9 Oct 12:02 EAT: "fine go ahead and push but also ensure you clear the images after they all done"). Kazee Chat opens it through Agent Space, which calls these pod routes with the control token.
|
package/README.md
CHANGED
|
@@ -346,7 +346,7 @@ Unsupported optional controls return a provider-specific explanation and do not
|
|
|
346
346
|
|
|
347
347
|
## Utility provider policy
|
|
348
348
|
|
|
349
|
-
Background intelligence—sub-agents, recall, memory review, dream/introspection, and the external-person enforcer—uses one provider-selection policy. A per-purpose override is checked first, then `UTILITY_PROVIDER` (default `active`)
|
|
349
|
+
Background intelligence—sub-agents, recall, memory review, dream/introspection, and the external-person enforcer—uses one provider-selection policy. A per-purpose override is checked first, then `UTILITY_PROVIDER` (default `active`): the speaker's provider when a chat exists, and for work with nobody behind it (the dream, introspection) the provider the owner chats on, else `DEFAULT_PROVIDER`, else the one a new chat starts on. A provider a usage limit has used up is not started for background work until its reset; such work waits for the reset (the dream) or goes without (a recall check), and never moves to the other provider. Providers translate the neutral `low`, `medium`, and `high` tiers to their own models; exact per-provider model overrides remain available.
|
|
350
350
|
|
|
351
351
|
Foreground turns never silently fall back to a different provider. Utility work uses `PROVIDER_FALLBACKS` only when the operator explicitly supplies an ordered list, starts a fresh provider session, and records the provider/model used. The relationship enforcer may try that configured list, but total failure always fails closed and asks the owner to decide.
|
|
352
352
|
|
|
@@ -455,7 +455,8 @@ All stored in `~/.open-claudia/`:
|
|
|
455
455
|
| `USAGE_ALERT_BASELINE_TURNS` / `USAGE_ALERT_MIN_BASELINE_TURNS` / `USAGE_ALERT_COOLDOWN_MS` | No | Tune token-rate baseline size, minimum sample size, and alert cooldown |
|
|
456
456
|
| `LIMIT_WARN_PERCENTS` | No | Plan-limit warnings in the chat: comma-separated percentages of a 5-hour or weekly window (default `80,95`, `off` disables). Each is said once per window, after the reply that crossed it; never to an outside contact |
|
|
457
457
|
| `LIMIT_PICKUP` | No | `on` (default) or `off`. A message a Claude or Codex usage limit stopped is picked up when the limit resets; the person writing again in that conversation, `/stop`, `/new` or `/end` drops it, and a new limit hit arms it again |
|
|
458
|
-
| `LIMIT_PICKUP_DELAY_S` / `LIMIT_PICKUP_MAX_WAIT_H` | No | Seconds after the reset before
|
|
458
|
+
| `LIMIT_PICKUP_DELAY_S` / `LIMIT_PICKUP_MAX_WAIT_H` | No | Seconds after the reset before waiting work runs (default `120`); resets further out than this many hours (default `192`) are not waited for. One rule for a picked-up message, a scheduled run a limit stopped (it waits instead of failing) and the dream's AI step |
|
|
459
|
+
| `QUEUE_BATCH` | No | `on` (default) or `off`. Messages a person sends while their conversation is busy run together as one turn when it ends (the same person's plain chat messages, at most 10); `off` runs them one at a time. Each "Queued." notice has a ✕ Remove from queue button either way |
|
|
459
460
|
| `MEMORY_RECALL_MAX_CHARS` | No | Hard cap for auto-injected pack/entity memory per turn (default `9000`, `off` disables auto recall injection) |
|
|
460
461
|
| `RECALL_ENGINE` | No | Default recall engine when a chat hasn't set one via `/engine` (`classic` or `discoverer`, default `discoverer`) |
|
|
461
462
|
| `RECALL_GRAPH_DB` / `RECALL_METRICS` | No | Override the discoverer graph DB path; `off` on metrics disables per-turn recall logging |
|
|
@@ -468,11 +469,11 @@ All stored in `~/.open-claudia/`:
|
|
|
468
469
|
| `TRANSCRIPT_MAX_ENTRY_CHARS` | No | Max chars per transcript entry (default `12000`) |
|
|
469
470
|
| `TRANSCRIPTS_DIR` / `PACKS_DIR` / `ENTITIES_DIR` | No | Override storage directories |
|
|
470
471
|
| `PACK_REVIEW` | No | `off` disables the post-turn pack/entity reviewer (default on) |
|
|
471
|
-
| `PACK_REVIEW_MODEL` | No | Model for the reviewer (default `
|
|
472
|
+
| `PACK_REVIEW_MODEL` | No | Model for the reviewer (default: the provider's medium tier, `claude-sonnet-5-5` on Claude). Background checks without a model of their own use the low tier: `claude-haiku-5-5` on Claude |
|
|
472
473
|
| `PACK_MATCH_THRESHOLD` / `ENTITY_MATCH_THRESHOLD` | No | Router match score thresholds (default `2`) |
|
|
473
474
|
| `DREAM` | No | `off` disables the nightly memory consolidation pass (default on) |
|
|
474
475
|
| `DREAM_CRON` | No | Schedule for the dream pass (default `0 4 * * *`) |
|
|
475
|
-
| `DREAM_PROVIDER` | No | Provider for dream and introspection (`claude` or `codex`); otherwise
|
|
476
|
+
| `DREAM_PROVIDER` | No | Provider for dream and introspection (`claude` or `codex`); otherwise the provider the owner chats on, then `DEFAULT_PROVIDER` |
|
|
476
477
|
| `DREAM_MODEL_CLAUDE` / `DREAM_MODEL_CODEX` | No | Exact per-provider dream model override; legacy `DREAM_MODEL` remains a Claude-only compatibility alias |
|
|
477
478
|
| `DREAM_TIER` | No | Provider-owned model tier for the dream pass: `low`, `medium`, or `high` (default) |
|
|
478
479
|
| `DREAM_FULL_SWEEP_DAYS` | No | Days between full-corpus dream sweeps; other nights are cheap deltas over recently-touched packs (default `30`) |
|
package/core/actions.js
CHANGED
|
@@ -918,6 +918,10 @@ async function handleAction(envelope) {
|
|
|
918
918
|
if (d.startsWith("rq:")) {
|
|
919
919
|
return require("./turn-journal").handleRunDropped(d, envelope, { isOwner: isChatOwner(envelope.channelId) });
|
|
920
920
|
}
|
|
921
|
+
// The ✕ on a "Queued." notice: take that message out of the queue.
|
|
922
|
+
if (d.startsWith("qx:")) {
|
|
923
|
+
return require("./runner").handleQueueRemove(d, envelope, { isOwner: isChatOwner(envelope.channelId) });
|
|
924
|
+
}
|
|
921
925
|
if (d.startsWith("grd:")) {
|
|
922
926
|
// Guardrail capture buttons (Track C). Owner-only: standing rules layer
|
|
923
927
|
// above lessons and are never editable by an external speaker.
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// The bot's own agent-browser CLI calls on a session its agent already has open: Teach me's element
|
|
2
|
+
// lookup (core/browser-teach.js) and the Browser app's address bar (core/browser-control.js).
|
|
3
|
+
//
|
|
4
|
+
// agent-browser keeps one browser per session and remembers the settings it was launched with
|
|
5
|
+
// (the executable, its launch args, the idle timeout). A command that arrives with other launch
|
|
6
|
+
// settings restarts that browser, and the person's page is lost. So every call from here runs
|
|
7
|
+
// with the environment the bot's own agent-browser commands get, and never with a launch flag
|
|
8
|
+
// (--args, --idle-timeout, --executable-path, --headed, --cdp, ...): only what the CLI needs goes
|
|
9
|
+
// along (PATH, HOME, TMPDIR, LANG, LC_ALL, USER and every AGENT_BROWSER_* variable), none of the
|
|
10
|
+
// bot's tokens, and no XDG_RUNTIME_DIR (the agent's own commands run without it). The session is
|
|
11
|
+
// found by its socket folder, the one it was listed from: AGENT_BROWSER_SOCKET_DIR. Without that
|
|
12
|
+
// folder nothing runs, since agent-browser starts a new browser for a session it cannot find.
|
|
13
|
+
|
|
14
|
+
const childProcess = require("child_process");
|
|
15
|
+
const { SESSION_NAME } = require("./browser-view");
|
|
16
|
+
|
|
17
|
+
const ENV_KEYS = new Set(["PATH", "HOME", "TMPDIR", "LANG", "LC_ALL", "USER"]);
|
|
18
|
+
const DEFAULT_TIMEOUT_MS = 20 * 1000;
|
|
19
|
+
// After a kill the CLI's output can stay open (a child of its own holding it); the call settles
|
|
20
|
+
// this long after its time is up all the same, so a session's queue never waits on it for good.
|
|
21
|
+
const SETTLE_GRACE_MS = 2000;
|
|
22
|
+
const MAX_BUFFER = 512 * 1024;
|
|
23
|
+
// The only dash argument a command may carry: eval's -b (a base64 script follows).
|
|
24
|
+
const COMMAND_FLAGS = new Set(["-b"]);
|
|
25
|
+
|
|
26
|
+
function agentBrowserBin() {
|
|
27
|
+
return process.env.OC_AGENT_BROWSER_BIN || "agent-browser";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function agentBrowserEnv(session) {
|
|
31
|
+
const env = {};
|
|
32
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
33
|
+
if (ENV_KEYS.has(key) || key.startsWith("AGENT_BROWSER_")) env[key] = value;
|
|
34
|
+
}
|
|
35
|
+
if (session && session.dir) env.AGENT_BROWSER_SOCKET_DIR = session.dir;
|
|
36
|
+
return env;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ["--session", <name>, ("--json"), <command>, <its arguments>...]. The command's own arguments
|
|
40
|
+
// (an address, a script) are never flags, so no launch setting can ride along; null when the
|
|
41
|
+
// session name or the command is not one of these.
|
|
42
|
+
function commandArgs(session, command, { json = false } = {}) {
|
|
43
|
+
const name = String((session && session.name) || "");
|
|
44
|
+
if (!SESSION_NAME.test(name) || name.startsWith("-")) return null;
|
|
45
|
+
if (!Array.isArray(command) || !command.length || !/^[a-z]+$/.test(String(command[0]))) return null;
|
|
46
|
+
for (const arg of command.slice(1)) {
|
|
47
|
+
if (typeof arg !== "string" || (arg.startsWith("-") && !COMMAND_FLAGS.has(arg))) return null;
|
|
48
|
+
}
|
|
49
|
+
return ["--session", name, ...(json ? ["--json"] : []), ...command];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Run `agent-browser --session <name> [--json] <command...>` on that session:
|
|
53
|
+
// { ok, stdout, timedOut }. Never rejects. A call past timeoutMs is killed (SIGKILL).
|
|
54
|
+
function runAgentBrowser(session, command, { json = false, timeoutMs = DEFAULT_TIMEOUT_MS, maxBuffer = MAX_BUFFER, execFile = childProcess.execFile } = {}) {
|
|
55
|
+
return new Promise((resolve) => {
|
|
56
|
+
let settled = false;
|
|
57
|
+
let guard = null;
|
|
58
|
+
const done = (ok, stdout, timedOut) => {
|
|
59
|
+
if (settled) return;
|
|
60
|
+
settled = true;
|
|
61
|
+
if (guard) clearTimeout(guard);
|
|
62
|
+
resolve({ ok, stdout: ok ? String(stdout || "") : "", timedOut });
|
|
63
|
+
};
|
|
64
|
+
const args = commandArgs(session, command, { json });
|
|
65
|
+
if (!args || !session.dir) return done(false, "", false);
|
|
66
|
+
let child = null;
|
|
67
|
+
try {
|
|
68
|
+
child = execFile(agentBrowserBin(), args, {
|
|
69
|
+
env: agentBrowserEnv(session),
|
|
70
|
+
timeout: timeoutMs,
|
|
71
|
+
killSignal: "SIGKILL",
|
|
72
|
+
maxBuffer,
|
|
73
|
+
windowsHide: true,
|
|
74
|
+
}, (error, stdout) => {
|
|
75
|
+
if (!error) return done(true, stdout, false);
|
|
76
|
+
done(false, "", error.killed === true && error.code !== "ERR_CHILD_PROCESS_STDIO_MAXBUFFER");
|
|
77
|
+
});
|
|
78
|
+
} catch (_) {
|
|
79
|
+
return done(false, "", false);
|
|
80
|
+
}
|
|
81
|
+
if (child && typeof child.on === "function") child.on("error", () => { /* reported through the callback */ });
|
|
82
|
+
if (child && child.stdin) child.stdin.end();
|
|
83
|
+
if (settled) return;
|
|
84
|
+
guard = setTimeout(() => {
|
|
85
|
+
try { if (child && typeof child.kill === "function") child.kill("SIGKILL"); } catch (_) { /* already gone */ }
|
|
86
|
+
done(false, "", true);
|
|
87
|
+
}, timeoutMs + SETTLE_GRACE_MS);
|
|
88
|
+
if (guard.unref) guard.unref();
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
module.exports = {
|
|
93
|
+
DEFAULT_TIMEOUT_MS,
|
|
94
|
+
agentBrowserBin,
|
|
95
|
+
agentBrowserEnv,
|
|
96
|
+
commandArgs,
|
|
97
|
+
runAgentBrowser,
|
|
98
|
+
};
|
package/core/auth-flow.js
CHANGED
|
@@ -109,7 +109,12 @@ function looksLikeOpenAIKey(value) {
|
|
|
109
109
|
|
|
110
110
|
function isClaudeAuthErrorText(text) {
|
|
111
111
|
const lower = String(text || "").toLowerCase();
|
|
112
|
-
|
|
112
|
+
// `claude auth status` prints JSON in Claude Code 2.1: signed out it is
|
|
113
|
+
// {"loggedIn": false, "authMethod": "none"} (exit 1), which no phrase below
|
|
114
|
+
// matched, so a signed-out Claude read as "unknown" and was still chosen
|
|
115
|
+
// (9 Oct 2026: the dream of every Codex-only bot ran on Claude).
|
|
116
|
+
return /"loggedin"\s*:\s*false/.test(lower) ||
|
|
117
|
+
lower.includes("unauthorized") ||
|
|
113
118
|
lower.includes("not logged in") ||
|
|
114
119
|
lower.includes("login required") ||
|
|
115
120
|
lower.includes("reauthenticate") ||
|
|
@@ -145,7 +150,7 @@ function summarizeAuthOutput(output, ok) {
|
|
|
145
150
|
function summarizeClaudeAuthStatus(output, exitCode, tokenInfo) {
|
|
146
151
|
const text = String(output || "");
|
|
147
152
|
const lower = text.toLowerCase();
|
|
148
|
-
const loggedOut = /not (logged in|authenticated)|unauthenticated|no auth|login required/.test(lower);
|
|
153
|
+
const loggedOut = /not (logged in|authenticated)|unauthenticated|no auth|login required|"loggedin"\s*:\s*false/.test(lower);
|
|
149
154
|
const loggedIn = !loggedOut && (exitCode === 0 || /logged in|authenticated|claude\.ai|anthropic/.test(lower));
|
|
150
155
|
const email = (text.match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i) || [null])[0];
|
|
151
156
|
const provider = /claude\.ai|claudeai/.test(lower) ? "Claude.ai" : (/anthropic/.test(lower) ? "Anthropic" : "unknown");
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// Where the Browser app's address bar may go (core/browser-control.js navigate): public internet
|
|
2
|
+
// addresses only. The bot's browser runs inside the pod, next to the cluster's services and the
|
|
3
|
+
// cloud's metadata endpoint, so a typed address must not reach what a person outside could not:
|
|
4
|
+
// - http or https, at most 2048 characters, no user name or password in it;
|
|
5
|
+
// - no localhost, no single-label name, nothing under .localhost, .local, .internal, .svc,
|
|
6
|
+
// .cluster.local or .home.arpa;
|
|
7
|
+
// - no private, loopback, link-local, shared, benchmarking, multicast or reserved IP: typed as
|
|
8
|
+
// one in any form the URL parser reads as an IP (decimal, octal or hex IPv4, IPv6, IPv4 inside
|
|
9
|
+
// IPv6), or found in ANY of the name's DNS answers (A and AAAA, 3 s at most).
|
|
10
|
+
//
|
|
11
|
+
// parseAddress(raw) { url, lookupName } | { error: "bad-url" | "blocked-address" }
|
|
12
|
+
// No network: lookupName is the name still to resolve, null for an IP.
|
|
13
|
+
// resolveAddress(name) { addresses } | { error: "blocked-address" | "unknown-host" }
|
|
14
|
+
// checkAddress(raw) both: { ok: true, url, addresses } | { ok: false, error }
|
|
15
|
+
//
|
|
16
|
+
// The browser resolves the name again when it loads the page; this check keeps a typed address
|
|
17
|
+
// from reaching the inside, it does not follow redirects.
|
|
18
|
+
|
|
19
|
+
const dns = require("dns");
|
|
20
|
+
const net = require("net");
|
|
21
|
+
|
|
22
|
+
const MAX_URL_LENGTH = 2048;
|
|
23
|
+
const DNS_TIMEOUT_MS = 3000;
|
|
24
|
+
|
|
25
|
+
function v4Bytes(text) {
|
|
26
|
+
const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(String(text));
|
|
27
|
+
if (!m) return null;
|
|
28
|
+
const bytes = m.slice(1).map(Number);
|
|
29
|
+
return bytes.every((b) => b <= 255) ? bytes : null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function v4Number(bytes) {
|
|
33
|
+
return ((bytes[0] << 24) | (bytes[1] << 16) | (bytes[2] << 8) | bytes[3]) >>> 0;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const BLOCKED_V4 = [
|
|
37
|
+
["0.0.0.0", 8], // "this network"
|
|
38
|
+
["10.0.0.0", 8], // private
|
|
39
|
+
["100.64.0.0", 10], // shared address space (carrier-grade NAT)
|
|
40
|
+
["127.0.0.0", 8], // loopback
|
|
41
|
+
["169.254.0.0", 16], // link-local, the cloud metadata endpoint
|
|
42
|
+
["172.16.0.0", 12], // private
|
|
43
|
+
["192.0.0.0", 24], // IETF protocol assignments
|
|
44
|
+
["192.168.0.0", 16], // private
|
|
45
|
+
["198.18.0.0", 15], // benchmarking
|
|
46
|
+
["224.0.0.0", 4], // multicast
|
|
47
|
+
["240.0.0.0", 4], // reserved
|
|
48
|
+
["255.255.255.255", 32], // broadcast
|
|
49
|
+
].map(([base, bits]) => ({ base: v4Number(v4Bytes(base)), mask: (0xffffffff << (32 - bits)) >>> 0 }));
|
|
50
|
+
|
|
51
|
+
function v4Blocked(bytes) {
|
|
52
|
+
const n = v4Number(bytes);
|
|
53
|
+
return BLOCKED_V4.some(({ base, mask }) => ((n & mask) >>> 0) === base);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// The eight 16-bit words of an IPv6 address (brackets and a zone allowed), or null.
|
|
57
|
+
function v6Words(text) {
|
|
58
|
+
let s = String(text).replace(/^\[(.*)\]$/, "$1").replace(/%.*$/, "");
|
|
59
|
+
if (!net.isIPv6(s)) return null;
|
|
60
|
+
const tail = /^(.*:)(\d{1,3}(?:\.\d{1,3}){3})$/.exec(s);
|
|
61
|
+
if (tail) {
|
|
62
|
+
const b = v4Bytes(tail[2]);
|
|
63
|
+
if (!b) return null;
|
|
64
|
+
s = `${tail[1]}${((b[0] << 8) | b[1]).toString(16)}:${((b[2] << 8) | b[3]).toString(16)}`;
|
|
65
|
+
}
|
|
66
|
+
const halves = s.split("::");
|
|
67
|
+
if (halves.length > 2) return null;
|
|
68
|
+
const head = halves[0] ? halves[0].split(":") : [];
|
|
69
|
+
const rest = halves.length === 2 && halves[1] ? halves[1].split(":") : [];
|
|
70
|
+
const fill = halves.length === 2 ? 8 - head.length - rest.length : 0;
|
|
71
|
+
if (fill < 0) return null;
|
|
72
|
+
const words = [...head, ...Array(fill).fill("0"), ...rest].map((h) => (/^[0-9a-f]{1,4}$/i.test(h) ? parseInt(h, 16) : NaN));
|
|
73
|
+
return words.length === 8 && words.every(Number.isInteger) ? words : null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function v6Blocked(words) {
|
|
77
|
+
const zero = (from, to) => words.slice(from, to).every((w) => w === 0);
|
|
78
|
+
if (zero(0, 8)) return true; // ::
|
|
79
|
+
if (zero(0, 7) && words[7] === 1) return true; // ::1
|
|
80
|
+
if ((words[0] & 0xfe00) === 0xfc00) return true; // fc00::/7, unique local
|
|
81
|
+
if ((words[0] & 0xffc0) === 0xfe80) return true; // fe80::/10, link-local
|
|
82
|
+
if ((words[0] & 0xff00) === 0xff00) return true; // ff00::/8, multicast
|
|
83
|
+
const v4 = [words[6] >> 8, words[6] & 0xff, words[7] >> 8, words[7] & 0xff];
|
|
84
|
+
if (zero(0, 5) && words[5] === 0xffff) return v4Blocked(v4); // ::ffff:a.b.c.d, IPv4-mapped
|
|
85
|
+
if (zero(0, 6)) return v4Blocked(v4); // ::a.b.c.d, IPv4-compatible
|
|
86
|
+
if (words[0] === 0x64 && words[1] === 0xff9b && zero(2, 6)) return v4Blocked(v4); // 64:ff9b::/96, NAT64
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Is this IP (as text) off limits? Anything that is not an IP is.
|
|
91
|
+
function ipBlocked(ip) {
|
|
92
|
+
const text = String(ip == null ? "" : ip);
|
|
93
|
+
if (net.isIPv4(text)) return v4Blocked(v4Bytes(text));
|
|
94
|
+
const words = v6Words(text);
|
|
95
|
+
return words ? v6Blocked(words) : true;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// A name that never means the public internet (an IP is judged by ipBlocked instead).
|
|
99
|
+
function blockedName(hostname) {
|
|
100
|
+
const host = String(hostname || "").toLowerCase().replace(/\.+$/, "");
|
|
101
|
+
if (!host.includes(".")) return true; // localhost, a bare name: a machine or a Service nearby
|
|
102
|
+
return /(^|\.)(localhost|local|internal|svc|home\.arpa)$/.test(host); // .local covers .cluster.local
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function parseAddress(raw) {
|
|
106
|
+
if (typeof raw !== "string" || !raw.trim() || raw.length > MAX_URL_LENGTH) return { error: "bad-url" };
|
|
107
|
+
let url;
|
|
108
|
+
try {
|
|
109
|
+
url = new URL(raw);
|
|
110
|
+
} catch (_) {
|
|
111
|
+
return { error: "bad-url" };
|
|
112
|
+
}
|
|
113
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return { error: "bad-url" };
|
|
114
|
+
if (url.username || url.password || !url.hostname) return { error: "bad-url" };
|
|
115
|
+
const host = url.hostname; // the URL parser's own reading: IPv4 in dotted decimal, IPv6 in brackets
|
|
116
|
+
if (host.startsWith("[")) {
|
|
117
|
+
const words = v6Words(host);
|
|
118
|
+
if (!words) return { error: "bad-url" };
|
|
119
|
+
return v6Blocked(words) ? { error: "blocked-address" } : { url, lookupName: null };
|
|
120
|
+
}
|
|
121
|
+
if (net.isIPv4(host)) return v4Blocked(v4Bytes(host)) ? { error: "blocked-address" } : { url, lookupName: null };
|
|
122
|
+
if (blockedName(host)) return { error: "blocked-address" };
|
|
123
|
+
return { url, lookupName: host };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function defaultLookup(hostname, options) {
|
|
127
|
+
return dns.promises.lookup(hostname, options);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function withTimeout(promise, ms) {
|
|
131
|
+
let timer = null;
|
|
132
|
+
const late = new Promise((_, reject) => {
|
|
133
|
+
timer = setTimeout(() => reject(new Error("timed out")), ms);
|
|
134
|
+
});
|
|
135
|
+
return Promise.race([promise, late]).finally(() => clearTimeout(timer));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Every A and AAAA answer must be public; none at all (or no answer in time) is unknown.
|
|
139
|
+
async function resolveAddress(hostname, { lookup = defaultLookup, timeoutMs = DNS_TIMEOUT_MS } = {}) {
|
|
140
|
+
let answers;
|
|
141
|
+
try {
|
|
142
|
+
answers = await withTimeout(Promise.resolve().then(() => lookup(hostname, { all: true })), timeoutMs);
|
|
143
|
+
} catch (_) {
|
|
144
|
+
return { error: "unknown-host" };
|
|
145
|
+
}
|
|
146
|
+
const addresses = (Array.isArray(answers) ? answers : [answers])
|
|
147
|
+
.filter((a) => a != null)
|
|
148
|
+
.map((a) => (typeof a === "object" ? a.address : a));
|
|
149
|
+
if (!addresses.length) return { error: "unknown-host" };
|
|
150
|
+
if (addresses.some((a) => typeof a !== "string" || ipBlocked(a))) return { error: "blocked-address" };
|
|
151
|
+
return { addresses };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function checkAddress(raw, options = {}) {
|
|
155
|
+
const parsed = parseAddress(raw);
|
|
156
|
+
if (parsed.error) return { ok: false, error: parsed.error };
|
|
157
|
+
if (!parsed.lookupName) return { ok: true, url: parsed.url, addresses: [parsed.url.hostname.replace(/^\[(.*)\]$/, "$1")] };
|
|
158
|
+
const found = await resolveAddress(parsed.lookupName, options);
|
|
159
|
+
if (found.error) return { ok: false, error: found.error };
|
|
160
|
+
return { ok: true, url: parsed.url, addresses: found.addresses };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
module.exports = {
|
|
164
|
+
DNS_TIMEOUT_MS,
|
|
165
|
+
MAX_URL_LENGTH,
|
|
166
|
+
blockedName,
|
|
167
|
+
checkAddress,
|
|
168
|
+
ipBlocked,
|
|
169
|
+
parseAddress,
|
|
170
|
+
resolveAddress,
|
|
171
|
+
};
|
package/core/browser-control.js
CHANGED
|
@@ -7,7 +7,12 @@
|
|
|
7
7
|
// the browser. It lasts 120 s; the page's heartbeat and every accepted input extend it.
|
|
8
8
|
// - one input WebSocket per agent-browser session (ws://127.0.0.1:<port>/?maxFps=2): events
|
|
9
9
|
// are checked strictly, field by field, and forwarded in order. The same socket keeps the
|
|
10
|
-
// latest frame, which Teach me saves as a click's picture.
|
|
10
|
+
// latest frame, which Teach me saves as a click's (or a navigation's) picture.
|
|
11
|
+
// - the address bar: go to an address, back, forward, reload. The stream socket only takes
|
|
12
|
+
// input, so these are agent-browser commands on the same session, run in the session's queue
|
|
13
|
+
// after the input before them, with the bot's own agent-browser environment and never a
|
|
14
|
+
// launch flag (core/agent-browser-cli.js: other launch settings would restart the browser).
|
|
15
|
+
// Only public internet addresses (core/browser-address.js).
|
|
11
16
|
// - hand back (release, a closed panel, or a lock found expired by the 30 s sweeper): the lock
|
|
12
17
|
// goes, the socket closes, any recording stops, and the bot is woken in the person's own chat
|
|
13
18
|
// to carry on (C6). A recording stopped at the same time folds into one wake-up.
|
|
@@ -19,6 +24,8 @@ const WebSocket = require("ws");
|
|
|
19
24
|
const lockFile = require("./browser-lock");
|
|
20
25
|
const view = require("./browser-view");
|
|
21
26
|
const teach = require("./browser-teach");
|
|
27
|
+
const address = require("./browser-address");
|
|
28
|
+
const { runAgentBrowser } = require("./agent-browser-cli");
|
|
22
29
|
|
|
23
30
|
const LOCK_TTL_MS = lockFile.LOCK_TTL_MS;
|
|
24
31
|
// Inputs extend the lock; the file (fsynced) is rewritten at most this often, so the lock ends
|
|
@@ -31,19 +38,29 @@ const MAX_EVENTS = 50;
|
|
|
31
38
|
const MAX_PENDING_EVENTS = 1000;
|
|
32
39
|
const COORD_MAX = 10000;
|
|
33
40
|
const ID_RE = /^[A-Za-z0-9._:-]{1,100}$/;
|
|
41
|
+
// An address-bar command still running after this is killed, and the person told it failed.
|
|
42
|
+
const NAVIGATE_TIMEOUT_MS = 20 * 1000;
|
|
43
|
+
const NAVIGATIONS = new Set(["open", "back", "forward", "reload"]);
|
|
34
44
|
|
|
35
45
|
const HAND_BACK_REASONS = {
|
|
36
46
|
closed: " (they closed the Browser)",
|
|
37
47
|
expired: " (their Browser went quiet for 2 minutes, so control came back on its own)",
|
|
38
48
|
};
|
|
39
49
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
50
|
+
function defaultDeps() {
|
|
51
|
+
return {
|
|
52
|
+
now: () => Date.now(),
|
|
53
|
+
listSessions: () => view.listSessions(),
|
|
54
|
+
connect: (url) => new WebSocket(url),
|
|
55
|
+
scheduler: null, // core/scheduler by default (through core/cards.js wakeOrigin)
|
|
56
|
+
runFor: null, // the chat's provider, settings and live session; activeRun() by default
|
|
57
|
+
execFile: null, // child_process.execFile by default (core/agent-browser-cli.js)
|
|
58
|
+
lookupHost: null, // dns.promises.lookup by default (core/browser-address.js)
|
|
59
|
+
navigateTimeoutMs: NAVIGATE_TIMEOUT_MS,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
let deps = defaultDeps();
|
|
47
64
|
|
|
48
65
|
function reply(status, body) {
|
|
49
66
|
return { status, body };
|
|
@@ -70,6 +87,19 @@ function sameUser(lock, who) {
|
|
|
70
87
|
return !!lock && !!who && String(lock.userId) === String(who.userId);
|
|
71
88
|
}
|
|
72
89
|
|
|
90
|
+
// The live lock this person holds on this session, extended (every accepted input or navigation
|
|
91
|
+
// extends it; the file is rewritten at most every LOCK_REWRITE_MS), or null.
|
|
92
|
+
function extendDriverLock(session, who) {
|
|
93
|
+
const now = deps.now();
|
|
94
|
+
const lock = lockFile.liveLock(now);
|
|
95
|
+
if (!lock || lock.session !== session || !sameUser(lock, who)) return null;
|
|
96
|
+
if (lockFile.lockExpiresAt(lock) - now < LOCK_TTL_MS - LOCK_REWRITE_MS) {
|
|
97
|
+
lock.expiresAt = iso(now + LOCK_TTL_MS);
|
|
98
|
+
lockFile.writeLock(lock);
|
|
99
|
+
}
|
|
100
|
+
return lock;
|
|
101
|
+
}
|
|
102
|
+
|
|
73
103
|
// ---------------------------------------------------------------------------
|
|
74
104
|
// C3: input events, checked field by field. Only the listed fields are forwarded.
|
|
75
105
|
|
|
@@ -382,12 +412,14 @@ async function control(body = {}) {
|
|
|
382
412
|
return reply(400, { error: "bad-action" });
|
|
383
413
|
}
|
|
384
414
|
|
|
415
|
+
// navigate: true tells the front door this bot has the address bar (POST /api/browser/navigate).
|
|
385
416
|
function status() {
|
|
386
417
|
const lock = lockFile.liveLock(deps.now());
|
|
387
418
|
return reply(200, {
|
|
388
419
|
driving: !!lock,
|
|
389
420
|
driver: lock ? { name: lock.name, userId: lock.userId } : null,
|
|
390
421
|
recording: teach.activeSummary(),
|
|
422
|
+
navigate: true,
|
|
391
423
|
});
|
|
392
424
|
}
|
|
393
425
|
|
|
@@ -403,14 +435,8 @@ async function input({ session, events, who } = {}) {
|
|
|
403
435
|
if (!msg) return reply(400, { error: "bad-event", index: i });
|
|
404
436
|
messages.push(msg);
|
|
405
437
|
}
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
if (!lock || lock.session !== session || !sameUser(lock, who)) return reply(409, { error: "not-driving" });
|
|
409
|
-
// Every accepted input extends the lock; the file is rewritten only every few seconds.
|
|
410
|
-
if (lockFile.lockExpiresAt(lock) - now < LOCK_TTL_MS - LOCK_REWRITE_MS) {
|
|
411
|
-
lock.expiresAt = iso(now + LOCK_TTL_MS);
|
|
412
|
-
lockFile.writeLock(lock);
|
|
413
|
-
}
|
|
438
|
+
// Every accepted input extends the lock.
|
|
439
|
+
if (!extendDriverLock(session, who)) return reply(409, { error: "not-driving" });
|
|
414
440
|
if (!messages.length) return reply(200, { ok: true, accepted: 0 });
|
|
415
441
|
const entry = openInputSocket(session);
|
|
416
442
|
if (!entry) return reply(404, { error: "not-running" });
|
|
@@ -436,6 +462,72 @@ async function input({ session, events, who } = {}) {
|
|
|
436
462
|
return job;
|
|
437
463
|
}
|
|
438
464
|
|
|
465
|
+
// ---------------------------------------------------------------------------
|
|
466
|
+
// The address bar: open an address, back, forward, reload
|
|
467
|
+
|
|
468
|
+
// Log words. An address is cut to origin + path: query strings and fragments can carry tokens.
|
|
469
|
+
function navigationWords(action, href) {
|
|
470
|
+
if (action === "open") {
|
|
471
|
+
const where = view.safePageAddress(href) || "an address";
|
|
472
|
+
return { done: `went to ${where}`, failed: `go to ${where}` };
|
|
473
|
+
}
|
|
474
|
+
if (action === "reload") return { done: "reloaded the page", failed: "reload the page" };
|
|
475
|
+
return { done: `went ${action}`, failed: `go ${action}` };
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// { session, action: "open" | "back" | "forward" | "reload", url (open), who: { userId } }
|
|
479
|
+
async function navigate({ session, action, url, who } = {}) {
|
|
480
|
+
if (!validSession(session) || !validWho(who)) return reply(400, { error: "bad-request" });
|
|
481
|
+
if (!NAVIGATIONS.has(action)) return reply(400, { error: "bad-action" });
|
|
482
|
+
let target = null;
|
|
483
|
+
if (action === "open") {
|
|
484
|
+
target = address.parseAddress(url);
|
|
485
|
+
if (target.error) return reply(target.error === "bad-url" ? 400 : 403, { error: target.error });
|
|
486
|
+
}
|
|
487
|
+
// Like an input, a navigation needs the live lock and extends it.
|
|
488
|
+
if (!extendDriverLock(session, who)) return reply(409, { error: "not-driving" });
|
|
489
|
+
if (!openInputSocket(session)) return reply(404, { error: "not-running" });
|
|
490
|
+
// The name is resolved from now on, while the input queued before this still runs.
|
|
491
|
+
const resolving = target && target.lookupName
|
|
492
|
+
? address.resolveAddress(target.lookupName, { lookup: deps.lookupHost || undefined })
|
|
493
|
+
: null;
|
|
494
|
+
const href = target ? target.url.href : null;
|
|
495
|
+
const job = runQueued(session, 1, async () => {
|
|
496
|
+
if (resolving) {
|
|
497
|
+
const found = await resolving;
|
|
498
|
+
if (found.error) return reply(found.error === "unknown-host" ? 400 : 403, { error: found.error });
|
|
499
|
+
}
|
|
500
|
+
// agent-browser starts a new browser for a session it does not know: one that ended while
|
|
501
|
+
// this waited is not asked.
|
|
502
|
+
const info = sessionInfo(session);
|
|
503
|
+
if (!info || !info.dir) return reply(404, { error: "not-running" });
|
|
504
|
+
const recorder = teach.recorderFor(session, who.userId);
|
|
505
|
+
if (recorder) {
|
|
506
|
+
const live = sockets.get(session);
|
|
507
|
+
try {
|
|
508
|
+
await recorder.onNavigate({ how: action, to: href }, { session: { name: info.name, dir: info.dir }, frame: live && !live.closed ? live.frame : null });
|
|
509
|
+
} catch (e) {
|
|
510
|
+
console.error(`[browser] recording a step failed: ${e.message}`);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
const words = navigationWords(action, href);
|
|
514
|
+
const run = await runAgentBrowser(info, action === "open" ? ["open", href] : [action], {
|
|
515
|
+
timeoutMs: deps.navigateTimeoutMs,
|
|
516
|
+
execFile: deps.execFile || undefined,
|
|
517
|
+
});
|
|
518
|
+
if (!run.ok) {
|
|
519
|
+
// Never the command's own output or error: both can repeat the whole address.
|
|
520
|
+
const why = run.timedOut ? `agent-browser took longer than ${Math.ceil(deps.navigateTimeoutMs / 1000)} s and was stopped` : "agent-browser failed";
|
|
521
|
+
console.error(`[browser] ${who.userId} could not ${words.failed}: ${why}`);
|
|
522
|
+
return reply(503, { error: "browser-unavailable" });
|
|
523
|
+
}
|
|
524
|
+
console.log(`[browser] ${who.userId} ${words.done}`);
|
|
525
|
+
return reply(200, { ok: true });
|
|
526
|
+
});
|
|
527
|
+
if (!job) return reply(429, { error: "busy" });
|
|
528
|
+
return job;
|
|
529
|
+
}
|
|
530
|
+
|
|
439
531
|
// ---------------------------------------------------------------------------
|
|
440
532
|
// C4: Teach me, start / stop / cancel
|
|
441
533
|
|
|
@@ -501,7 +593,8 @@ function startSweeper({ intervalMs = SWEEP_INTERVAL_MS } = {}) {
|
|
|
501
593
|
return sweepTimer;
|
|
502
594
|
}
|
|
503
595
|
|
|
504
|
-
// Tests swap the clock, the session list, the socket factory, the scheduler
|
|
596
|
+
// Tests swap the clock, the session list, the socket factory, the scheduler, the run lookup, the
|
|
597
|
+
// agent-browser call (execFile), the DNS lookup and the address-bar time limit.
|
|
505
598
|
function _setDeps(overrides = {}) {
|
|
506
599
|
deps = { ...deps, ...overrides };
|
|
507
600
|
}
|
|
@@ -509,24 +602,20 @@ function _reset() {
|
|
|
509
602
|
for (const name of [...sockets.keys()]) closeInputSocket(name);
|
|
510
603
|
queues.clear();
|
|
511
604
|
if (sweepTimer) { clearInterval(sweepTimer); sweepTimer = null; }
|
|
512
|
-
deps =
|
|
513
|
-
now: () => Date.now(),
|
|
514
|
-
listSessions: () => view.listSessions(),
|
|
515
|
-
connect: (url) => new WebSocket(url),
|
|
516
|
-
scheduler: null,
|
|
517
|
-
runFor: null,
|
|
518
|
-
};
|
|
605
|
+
deps = defaultDeps();
|
|
519
606
|
}
|
|
520
607
|
|
|
521
608
|
module.exports = {
|
|
522
609
|
LOCK_TTL_MS,
|
|
523
610
|
MAX_EVENTS,
|
|
611
|
+
NAVIGATE_TIMEOUT_MS,
|
|
524
612
|
SWEEP_INTERVAL_MS,
|
|
525
613
|
checkEvent,
|
|
526
614
|
chatOrigin,
|
|
527
615
|
control,
|
|
528
616
|
handBackLine,
|
|
529
617
|
input,
|
|
618
|
+
navigate,
|
|
530
619
|
release,
|
|
531
620
|
renew,
|
|
532
621
|
startSweeper,
|