Skip to content

Commit 67091ed

Browse files
authored
feat: Bots work as coworkers (#673)
* fix: enable Automatic Learning by default * fix: provision the default learning container during setup * feat: provision self-hosted learning through Intelligence SSO * feat: coworker parity for OpenBot Bots now work as always-on coworkers: they act unattended, take on standing responsibilities, reach people where they are, and stay under one set of approvals and admin controls. - Unattended work: headless turns drive the Bot's own computer, draw charts and cards into the conversation, and keep its browser profile, cookies and files across restarts. Computers move to a new image on wake or on request. - Responsibilities and triggers: standing goals fed by schedules, signed webhooks, GitHub, Linear, Sentry, PagerDuty, inbound email and Slack messages; Run now and run history for routines. - Reachability: Slack and Microsoft Teams through the OpenTag pairing, SMS, and native push. A Bot's questions and approval requests reach the person who owns the conversation and resume it when they answer. Charts reach Slack and Teams as native charts. - Approvals: one personal approval flow across browser, connected apps, shell, files, the host and remote Bots; custom rules, auto-review and host command modes. - Memory: memories imported from connected apps with provenance, Bot-formed memories, and background research that suggests next steps. - Coordination: Bot-to-Bot messages, group conversations with attributed speakers, and Team Bots shared with teammates. - Demonstrations: record a task in the browser and turn it into a skill. - Admin: capability toggles, SSO-required sign-in, SCIM, network egress policy, action recording, OpenTelemetry export, Passwords and private sign-in requests. - A native mobile app (Expo) and its CI typecheck. Migrations 0048 and 0049 are generated from the schema. * docs: describe the coworker parity release in the changelog * test: launch Chromium in the sign-in test only when asked The test imported Playwright at the top of the file, and CI's install does not carry it, so the whole file failed to load. It now follows follows-popup.test.ts: the browser half runs with OPENBOT_COMPUTER_BROWSER=1 and imports Playwright then, while the parsing test runs everywhere. * fix: close the holes found in review Every change below was reproduced with a failing test before it was fixed. - Admin gate: match the decoded path as well as the raw one, and recognise CopilotKit runs, connects and suggestions by their trailing segments, as the runtime does. Use Bots and the model allowlist now also apply in the shared headless turn runner, and Team Bots publication checks its own switch. - Egress: a computer refuses the network until its policy arrives, and the server pushes the policy as the computer wakes. Plain HTTP connects to the address it checked, metadata and link-local addresses are refused in every mode, WebRTC stays inside the proxy, and the filter no longer forwards plain HTTP back into itself. - Team Bots: the computer, its live screen and sign-ins stay with the owner and administrators; teammates' turns run without computer tools. - Groups: a reply waiting on its owner's share check is hidden from other people and from their Bots, a resumed turn keeps its Bot-to-Bot depth, and an always-allow is bound to the member set it was given for. - Approvals: a refused or changed re-check, an approved hand-off, a revoked rule and an interrupted action all continue the conversation, once. Same-action matching includes the arguments, previews show the content being approved, Team Bot calls keep their credential rules without spending a one-time consent early, and auto-review covers vendor settings tools. - Lifecycle, memory, passwords, telemetry: reset clears Bot-formed memories, research and allow rules; one failed pause read no longer stops every turn; a pause stops a running hand-off; memories stay visible during a resync and are filtered in SQL; a stuck password sign-in reopens after a lease; exported commands are scrubbed; Bot-formed memories cannot be stored twice. - Inbound and auth: signed webhooks dedupe on signed identifiers, Slack triggers match messages from anyone in the owner's channels, a push token cannot be moved to another account, saving SSO-required ends non-SSO sessions (and is refused with no identity provider), and the mobile sign-in scheme is trusted only where the native app runs. - A CI job runs the real-Chromium computer tests, and the new environment variables are documented. Migration 0050_review_fixes is generated from the schema. * test: give each ADK model-name case its own thread Both cases posted to thread "namespaced-model", run "first-run". The current ag-ui-adk keeps a thread's state for the life of the process, so the second case answered from the first one's thread and never asked the model endpoint. Each case now uses its own thread and run. * fix: close the gaps found in the second review Each was reproduced with a failing test before it was fixed. - An approved hand-off keeps its Bot-to-Bot depth however long the person takes to decide: the signed run is checked by its signature, not by the ten-minute expiry that guards live callbacks. - Hand-offs obey Use Bots and the model allowlist, and a refusal is final rather than retried. - An approval whose Bot may no longer run is refused before the approved action executes, instead of executing first and failing in the continuation. - A re-check that hits a removed connector, a bad saved argument, or a share check that is pending again tells the Bot, and an approval still failing on its last try is answered "could not be carried out", so the conversation continues instead of hanging. - The egress filter tries every checked address (Happy Eyeballs), so an IPv6-first answer on a host without IPv6 still connects, and never looks the name up again.
1 parent 1a15d8f commit 67091ed

337 files changed

Lines changed: 95596 additions & 493 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.env.example‎

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -425,3 +425,66 @@ WORKER_SHARED_SECRET=
425425
# One key for the whole deployment, not one per person. Composio keeps people apart by a user id sent
426426
# with every call, so one account's connections are never reachable from another's.
427427
COMPOSIO_API_KEY=
428+
429+
# ---------------------------------------------------------------------------
430+
# Reaching people outside the app: Slack, Teams, SMS, push
431+
# ---------------------------------------------------------------------------
432+
433+
# Slack and Microsoft Teams arrive through an OpenTag deployment, which calls this server as its
434+
# AG-UI agent at <public URL>/api/delivery/webhooks/opentag/agent. The secret is at least 32
435+
# characters and is the same value as OpenTag's AGENT_AUTH_HEADER bearer token. OPENTAG_URL is
436+
# where messages go when no Slack or Teams turn is open (replies after an approval, questions,
437+
# progress); without it those sends fail visibly. Setting OPENTAG_URL or the icon without the secret
438+
# stops the server at start-up. The former SLACK_* settings are refused at start-up.
439+
# OPENTAG_SHARED_SECRET=
440+
# OPENTAG_URL=https://opentag.example.com
441+
# OPENTAG_BOT_ICON_URL_TEMPLATE=https://avatars.example.com/{seed}.png
442+
443+
# The public origin providers call back on, when it is not OPENBOT_PUBLIC_URL or BETTER_AUTH_URL (a
444+
# tunnel, say). Twilio signs over the URL it was given, so this must match the Twilio number's setting.
445+
# DELIVERY_PUBLIC_URL=https://hooks.example.com
446+
447+
# SMS. All four or none: some but not all stops the server at start-up. Point the number's incoming
448+
# webhook at <DELIVERY_PUBLIC_URL>/api/delivery/webhooks/sms.
449+
# TWILIO_ACCOUNT_SID=
450+
# TWILIO_AUTH_TOKEN=
451+
# TWILIO_VERIFY_SERVICE_SID=
452+
# TWILIO_FROM_NUMBER=
453+
454+
# Push to the native app in mobile/. EXPO_PROJECT_ID is the app's EAS project UUID and switches push
455+
# and native-app sign-in on; a device registers only when the app reports the same id. The access token is optional and is
456+
# sent to Expo's push service as a bearer token.
457+
# EXPO_PROJECT_ID=00000000-0000-0000-0000-000000000000
458+
# EXPO_ACCESS_TOKEN=
459+
460+
# ---------------------------------------------------------------------------
461+
# Inbound email triggers
462+
# ---------------------------------------------------------------------------
463+
464+
# Amazon SES receiving for this domain publishes to an SNS topic subscribed to
465+
# <public URL>/api/events/email/sns. Each email trigger's address is trigger-<id>@<domain>. Both
466+
# are needed; without them email triggers have no address. Messages from any other topic are refused.
467+
# OPENBOT_INBOUND_EMAIL_DOMAIN=in.example.com
468+
# OPENBOT_INBOUND_EMAIL_SNS_TOPIC_ARNS=arn:aws:sns:us-east-1:123456789012:openbot-inbound
469+
470+
# ---------------------------------------------------------------------------
471+
# Directory provisioning and audit export
472+
# ---------------------------------------------------------------------------
473+
474+
# SCIM 2.0 at <BETTER_AUTH_URL>/api/auth/scim/v2, on only while the token is set. The _NEXT token is
475+
# accepted alongside it while a directory moves to a new one.
476+
# openssl rand -base64 32
477+
# SCIM_BEARER_TOKEN=
478+
# SCIM_BEARER_TOKEN_NEXT=
479+
# SCIM_CONNECTION_ID=directory
480+
481+
# Every audit row as an OTLP log record over HTTP, for a SIEM or an OpenTelemetry Collector. Either
482+
# endpoint switches it on: the logs endpoint is used as written, the generic one gets /v1/logs.
483+
# Headers are key=value pairs separated by commas. OPENBOT_OTEL_EXPORT=off turns it off with an
484+
# endpoint still set.
485+
# OTEL_EXPORTER_OTLP_ENDPOINT=https://collector.example.com:4318
486+
# OTEL_EXPORTER_OTLP_LOGS_ENDPOINT=
487+
# OTEL_EXPORTER_OTLP_HEADERS=x-api-key=change-me
488+
# OTEL_EXPORTER_OTLP_LOGS_HEADERS=
489+
# OTEL_SERVICE_NAME=openbot
490+
# OPENBOT_OTEL_EXPORT=off

‎.github/workflows/ci.yml‎

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,51 @@ jobs:
8787
- run: bun run typecheck
8888
working-directory: ${{ matrix.package }}
8989

90+
mobile:
91+
name: native types
92+
runs-on: ubuntu-latest
93+
steps:
94+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
95+
with:
96+
persist-credentials: false
97+
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
98+
with:
99+
node-version: '22'
100+
cache: npm
101+
cache-dependency-path: mobile/package-lock.json
102+
# The native app is an isolated npm package; adding it to Bun workspaces would change the
103+
# existing desktop/server installation contract. CI installs its own reviewed lockfile.
104+
- run: npm ci --prefix mobile --ignore-scripts
105+
- run: npm run --prefix mobile typecheck
106+
107+
# The agent-computer tests that launch a real Chromium. They are asked for by name because a
108+
# machine running `bun test` is not required to have a browser, and these are the only tests that
109+
# would catch a password reaching the snapshot a model reads, a session cookie lost on restart, or
110+
# WebRTC leaving around the egress filter. The browser is the one the computer image installs,
111+
# pinned by agent-computer's own lockfile.
112+
computer-browser:
113+
name: computer (real browser)
114+
runs-on: ubuntu-latest
115+
steps:
116+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
117+
with:
118+
persist-credentials: false
119+
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
120+
with:
121+
bun-version: 1.3.14
122+
- run: bun install --frozen-lockfile
123+
- run: bun install --frozen-lockfile
124+
working-directory: agent-computer
125+
- run: bunx playwright install --with-deps chromium
126+
working-directory: agent-computer
127+
- run: >-
128+
bun test tests/sign-in.test.ts tests/session-cookies.test.ts tests/follows-popup.test.ts
129+
tests/demonstration-live.test.ts tests/webrtc-proxy.test.ts
130+
working-directory: agent-computer
131+
env:
132+
OPENBOT_COMPUTER_BROWSER: '1'
133+
OPENBOT_DEMONSTRATION_LIVE: '1'
134+
90135
chart:
91136
name: chart (${{ matrix.target }})
92137
runs-on: ubuntu-latest
@@ -565,7 +610,7 @@ jobs:
565610
name: verify
566611
runs-on: ubuntu-latest
567612
if: always()
568-
needs: [static, deployables, chart, test, startup, python-harness, build, migrations, image, component-dockerfiles]
613+
needs: [static, deployables, mobile, computer-browser, chart, test, startup, python-harness, build, migrations, image, component-dockerfiles]
569614
steps:
570615
- name: Require every check
571616
env:

‎CHANGELOG.md‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,37 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged.
88

99
## Unreleased
1010

11+
### Bots work as coworkers
12+
13+
A Bot can now carry on without anyone watching it. It runs standing **Responsibilities** fed by
14+
schedules, signed webhooks, GitHub, Linear, Sentry, PagerDuty, inbound email and Slack messages,
15+
drives its own computer while the app is closed, and keeps its browser profile, cookies and files
16+
across restarts. Its questions and approval requests reach the person who owns the conversation in
17+
Slack or Microsoft Teams (through OpenTag), by text message or by push, and the conversation
18+
resumes when they answer; charts reach Slack and Teams as native charts. **Reachability** links
19+
each of those places to a conversation.
20+
21+
Approvals become one personal flow across the browser, connected apps, shell, files, the host and
22+
remote Bots, with custom rules, auto-review and host command modes (**Approvals**). Bots can
23+
message each other, share a group conversation with attributed speakers, and be published to
24+
teammates as **Team Bots**. **Memory** imports facts from connected apps with their source, and
25+
optional background research suggests next steps. A browser demonstration can be recorded and
26+
turned into a skill. Administrators get capability toggles, SSO-required sign-in, SCIM, network
27+
egress policy, action recording, OpenTelemetry export, and **Passwords** with private sign-in
28+
requests.
29+
30+
Upgrading runs migrations `0048_coworker_parity`, `0049_coworker_parity_lanes` and
31+
`0050_review_fixes`.
32+
33+
### A Bot's computer refuses the network until its policy arrives
34+
35+
A computer used to allow every connection until the server had pushed its Bot's network policy,
36+
which left up to 30 seconds of unfiltered access after every wake. It now refuses until the policy
37+
arrives, and the server pushes it as the computer wakes. Cloud metadata and link-local addresses are
38+
refused in every mode, including `allow_all`, and the browser's WebRTC traffic now goes through the
39+
filter instead of around it. A computer run without an API server can set
40+
`EGRESS_POLICY_REQUIRED=0` to keep the old behaviour.
41+
1142
### `start.sh` names the port to change on macOS
1243

1344
When the API server's or the app's port was held by another process, `start.sh` was meant to say

‎agent-computer/src/authorisation.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,8 @@ const ACTING_PATHS = new Set([
7373
"/scroll",
7474
"/exec",
7575
"/files/write",
76+
// Typing a person's login into the page. Refused while they hold the wheel, like any other typing.
77+
"/sign-in/fill",
7678
]);
7779

7880
export function actsOnTheComputer(pathname: string): boolean {

‎agent-computer/src/control.ts‎

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -165,14 +165,12 @@ export function createControl(
165165
}
166166
function interrupt(reason: string): ControlState {
167167
const request = current();
168+
// Recovery is for a page a person was driving: what they left it as is unknown until someone
169+
// looks. A request nobody took left the page exactly as the Bot had it, and holding the Bot
170+
// until a person "checks" a page no person touched stranded it asking for control it had.
171+
const personHadIt = data.holder === "human" || request?.status === "taken";
168172
if (request && active(request)) finish(request, "interrupted", reason);
169-
if (
170-
request &&
171-
(active(request) ||
172-
request.status === "interrupted" ||
173-
data.holder === "human")
174-
)
175-
data.recoveryRequired = true;
173+
if (personHadIt) data.recoveryRequired = true;
176174
data.holder = "bot";
177175
data.since = now();
178176
data.resumeSnapshotRequired = true;
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import type { Page } from "playwright";
2+
import type { InputMessage } from "./screencast";
3+
export type RecordedGesture = {
4+
kind: "click" | "type" | "key" | "scroll";
5+
key?: string;
6+
deltaY?: number;
7+
};
8+
export function gestureForRecording(
9+
input: InputMessage,
10+
): RecordedGesture | null {
11+
if (input.type === "mouse")
12+
return input.event === "released" && input.button !== "right"
13+
? { kind: "click" }
14+
: null;
15+
if (input.type === "wheel")
16+
return {
17+
kind: "scroll",
18+
deltaY: Math.max(-2000, Math.min(2000, input.deltaY)),
19+
};
20+
if (input.type === "text") return { kind: "type" };
21+
if (input.event !== "down") return null;
22+
if (input.key.length === 1 || (input.text && input.key !== "Enter"))
23+
return { kind: "type" };
24+
if (
25+
[
26+
"Enter",
27+
"Tab",
28+
"Escape",
29+
"Backspace",
30+
"Delete",
31+
"ArrowDown",
32+
"ArrowUp",
33+
"ArrowLeft",
34+
"ArrowRight",
35+
"Home",
36+
"End",
37+
"PageDown",
38+
"PageUp",
39+
].includes(input.key)
40+
)
41+
return { kind: "key", key: input.key };
42+
return null;
43+
}
44+
export function safeDemonstrationUrl(raw: string): string {
45+
try {
46+
const url = new URL(raw);
47+
if (!["http:", "https:"].includes(url.protocol)) return "about:blank";
48+
const path = url.pathname
49+
.split("/")
50+
.map((part) =>
51+
part.length > 40 || /token|secret|password|credential/i.test(part)
52+
? "[redacted]"
53+
: part,
54+
)
55+
.join("/");
56+
return `${url.origin}${path}`;
57+
} catch {
58+
return "about:blank";
59+
}
60+
}
61+
/** Never reads a field's value, page body, clipboard or image. All text entry is parameterized. */
62+
export async function describeHumanGesture(page: Page, input: InputMessage) {
63+
const gesture = gestureForRecording(input);
64+
if (!gesture) return null;
65+
const point =
66+
input.type === "mouse" || input.type === "wheel"
67+
? { x: input.x, y: input.y }
68+
: null;
69+
const target = await page.evaluate((point) => {
70+
const hit = point
71+
? document.elementFromPoint(point.x, point.y)
72+
: document.activeElement;
73+
const closest =
74+
hit?.closest(
75+
"button,a,input,textarea,select,label,[role],[contenteditable=true]",
76+
) ?? hit;
77+
// A click on a label's text is a click on the control it names: "Small", a radio, not a label.
78+
const element =
79+
closest instanceof HTMLLabelElement
80+
? (closest.control ?? closest)
81+
: closest;
82+
if (!element) return { role: "page", name: "Page", sensitive: false };
83+
const tag = element.tagName.toLowerCase();
84+
const inputType =
85+
element instanceof HTMLInputElement ? element.type.toLowerCase() : "";
86+
const role =
87+
element.getAttribute("role") ||
88+
(
89+
{
90+
checkbox: "checkbox",
91+
radio: "radio",
92+
button: "button",
93+
submit: "button",
94+
reset: "button",
95+
image: "button",
96+
range: "slider",
97+
file: "button",
98+
} as Record<string, string>
99+
)[inputType] ||
100+
(
101+
{
102+
button: "button",
103+
a: "link",
104+
input: "textbox",
105+
textarea: "textbox",
106+
select: "combobox",
107+
} as Record<string, string>
108+
)[tag] ||
109+
tag;
110+
const label =
111+
element.getAttribute("aria-label") ||
112+
(element instanceof HTMLInputElement ||
113+
element instanceof HTMLTextAreaElement ||
114+
element instanceof HTMLSelectElement
115+
? Array.from(element.labels ?? [])
116+
.map((entry) => entry.textContent ?? "")
117+
.join(" ")
118+
: "") ||
119+
(tag === "button" || tag === "a" ? element.textContent : "") ||
120+
element.getAttribute("placeholder") ||
121+
role;
122+
const sensitive =
123+
element.getAttribute("type") === "password" ||
124+
// Short words only as whole words: "pin" is in "topping" and "shipping", "otp" in "hotpot".
125+
/password|secret|token|one.time|verification|credit.card|security.code|passcode|(?:^|[^a-z])(?:cc-|(?:cvc|cvv|pin|otp)(?![a-z]))/i.test(
126+
[
127+
label,
128+
element.getAttribute("name"),
129+
element.getAttribute("autocomplete"),
130+
].join(" "),
131+
);
132+
const name = sensitive
133+
? "[sensitive field]"
134+
: label
135+
.replace(/[A-Za-z0-9_+/=-]{32,}/g, "[redacted]")
136+
.replace(/[^\s@]+@[^\s@]+/g, "[redacted]")
137+
.trim()
138+
.slice(0, 120);
139+
return { role: role.slice(0, 40), name: name || role, sensitive };
140+
}, point);
141+
return { ...gesture, url: safeDemonstrationUrl(page.url()), target };
142+
}

0 commit comments

Comments
 (0)