diff --git a/PRIMITIVES.md b/PRIMITIVES.md index f61caad9..8678dbb3 100644 --- a/PRIMITIVES.md +++ b/PRIMITIVES.md @@ -85,6 +85,15 @@ MCP servers, validated against a toolspec. - **Execution & MCP plumbing:** `pkg/tools/exec/` (sandboxed CLI execution, with `remote/` and `fake/`), `pkg/tools/mcp/` (`spec/`, `oauth/`, `probe/`, `render/`, `trust/`, `validator/`) +- **Browser toolbox:** `pkg/apis/v1alpha1/browsertoolbox_types.go` (a + per-session browser: site allowlist/blocklist, act/readonly interaction, + ephemeral state); `pkg/agent/tool/browser/` synthesizes the observation tools + (navigate/read/find/follow/scroll/back/wait_for, +screenshot) over one + chromedp session; `pkg/tools/browsergate/` is the per-session gate process + (authenticated DevTools + policy-enforced egress) the sandbox pod runs; + `pkg/tools/browserpolicy/` is the shared site-matching decision both the gate + and the tool's own pre-check consult. No dedicated controller yet (Phase A) — + identity-scoped sites and acting tools (click/type) arrive in later phases. - **Binaries:** `internal/cmd/streamclient/` (claims a streaming ToolCall via the gateway); tool execution is otherwise driven from `internal/cmd/runner/` diff --git a/cmd/oap/internal/installcmd/clean.go b/cmd/oap/internal/installcmd/clean.go index b8f704ac..dc56983d 100644 --- a/cmd/oap/internal/installcmd/clean.go +++ b/cmd/oap/internal/installcmd/clean.go @@ -43,6 +43,7 @@ var crGroupVersionResources = []schema.GroupVersionResource{ {Group: "agentprimitives.authzed.com", Version: "v1alpha1", Resource: "useridentities"}, {Group: "agentprimitives.authzed.com", Version: "v1alpha1", Resource: "sessionuseridentities"}, {Group: "agentprimitives.authzed.com", Version: "v1alpha1", Resource: "sidecartoolboxes"}, + {Group: "agentprimitives.authzed.com", Version: "v1alpha1", Resource: "browsertoolboxes"}, {Group: "agentprimitives.authzed.com", Version: "v1alpha1", Resource: "clusteragentsettings"}, {Group: "agentprimitives.authzed.com", Version: "v1alpha1", Resource: "agentsettings"}, {Group: "agentprimitives.authzed.com", Version: "v1alpha1", Resource: "skills"}, diff --git a/cmd/oap/internal/installcmd/dockerfile_user_test.go b/cmd/oap/internal/installcmd/dockerfile_user_test.go index a5f7be3e..c3996724 100644 --- a/cmd/oap/internal/installcmd/dockerfile_user_test.go +++ b/cmd/oap/internal/installcmd/dockerfile_user_test.go @@ -28,7 +28,13 @@ import ( // runner and sandbox are intentionally exempt: the operator synthesizes their // Pods with an explicit numeric runAsUser (1000) that overrides the image USER, // and their workspace UID story is deliberately 1000. The detector image is -// exempt too (no fixed runAsNonRoot Deployment in the install bundle). +// exempt too (no fixed runAsNonRoot Deployment in the install bundle). The +// browser toolbox image's exemption is purely STRUCTURAL: this test requires +// an explicit --target stage and the browser Dockerfile has exactly one +// runtime stage with no Stage selector, so there is nothing for the stage +// scanner to look up. Its USER is nonetheless numeric (`USER 65532`, +// images/browser/Dockerfile) for the same kubelet reason this test exists — +// the exemption skips the structural requirement, not the invariant. // // This is an EXEMPTION set, not an inclusion set: TestServiceImagesUseNumericUser // iterates apimage.All (the long-lived platform Deployments) and requires a @@ -43,6 +49,7 @@ var imagesExemptFromNumericUser = map[string]bool{ apimage.Runner.Target: true, apimage.Sandbox.Target: true, apimage.Detector.Target: true, + apimage.Browser.Target: true, } var numericUserRe = regexp.MustCompile(`^[0-9]+(:[0-9]+)?$`) diff --git a/cmd/oap/internal/toolscmd/root.go b/cmd/oap/internal/toolscmd/root.go index 3b8c0d8d..b5d2f6ff 100644 --- a/cmd/oap/internal/toolscmd/root.go +++ b/cmd/oap/internal/toolscmd/root.go @@ -10,6 +10,7 @@ import ( // Side-effect imports: each kind's init() registers itself with // pkg/tools/kinds/registry so the verbs below can dispatch. "github.com/authzed/openagentprimitives/cmd/oap/internal/apcmd" + _ "github.com/authzed/openagentprimitives/pkg/tools/kinds/browsertoolbox" _ "github.com/authzed/openagentprimitives/pkg/tools/kinds/mcp" _ "github.com/authzed/openagentprimitives/pkg/tools/kinds/sandbox" _ "github.com/authzed/openagentprimitives/pkg/tools/kinds/sidecartoolbox" diff --git a/config/crds/agentprimitives.authzed.com_agentclasses.yaml b/config/crds/agentprimitives.authzed.com_agentclasses.yaml index b2d99f3c..1be76791 100644 --- a/config/crds/agentprimitives.authzed.com_agentclasses.yaml +++ b/config/crds/agentprimitives.authzed.com_agentclasses.yaml @@ -1144,6 +1144,27 @@ spec: type: object type: array x-kubernetes-list-type: atomic + browserToolboxes: + description: |- + BrowserToolboxes references BrowserToolbox CRs in the same namespace + whose per-session browser each session gets. Each ref's Name is the + LLM-facing prefix; pattern [a-z0-9_-]{1,32}. + items: + description: AgentClassBrowserToolboxRef references a BrowserToolbox + CR in the same namespace. + properties: + name: + description: Name is the LLM-visible tool prefix; pattern [a-z0-9_-]{1,32}. + pattern: ^[a-z0-9_-]{1,32}$ + type: string + ref: + description: Ref names the BrowserToolbox CR in the same namespace. + type: string + required: + - name + - ref + type: object + type: array budget: description: |- Budget is optional: when omitted, the class inherits the resolved tier diff --git a/config/crds/agentprimitives.authzed.com_agentsessions.yaml b/config/crds/agentprimitives.authzed.com_agentsessions.yaml index 00984ca9..15127b7e 100644 --- a/config/crds/agentprimitives.authzed.com_agentsessions.yaml +++ b/config/crds/agentprimitives.authzed.com_agentsessions.yaml @@ -2650,6 +2650,189 @@ spec: format: int32 type: integer type: object + resolvedBrowserToolboxes: + description: |- + ResolvedBrowserToolboxes snapshots each referenced BrowserToolbox at + session start, frozen for the session lifetime exactly like + ResolvedSidecarToolboxes. Empty when AgentClass.spec.browserToolboxes + is empty. + items: + description: |- + ResolvedBrowserToolbox snapshots one BrowserToolbox CR for a running + AgentSession, mirroring ResolvedSidecarToolbox's shape: the LLM-facing + prefix, the frozen spec, and the operator-allocated pod/gate the runner + uses to reach the per-session browser. + properties: + browserPodIP: + description: |- + BrowserPodIP is the per-session browser pod's IP, reflected once Ready; + the runner reaches its gate at http://:. + type: string + browserPodName: + description: BrowserPodName is the per-session browser pod's + name. + type: string + effectiveSites: + description: |- + EffectiveSites is the resolved site list the running browser's egress + is actually evaluated against -- in Phase A, a straight carry-through + of Spec.Access.Sites with Source "explicit". + items: + description: |- + BrowserEffectiveSite is one resolved site entry in a ResolvedBrowserToolbox's + EffectiveSites. Source records where the entry came from: Phase A only ever + writes "explicit" (a BrowserAccess.Sites entry carried through verbatim); + "identity" is a Phase B addition once a bound identity can contribute its + own sites to the effective set. + properties: + exact: + description: Exact mirrors BrowserSite.Exact for this + entry. + type: boolean + host: + description: Host is the resolved hostname, matched exactly + as BrowserSite.Host. + type: string + source: + description: |- + Source is where this entry came from. "explicit" only in Phase A; + "identity" arrives in Phase B. + type: string + required: + - host + - source + type: object + type: array + gatePort: + description: |- + GatePort is the operator-allocated port the runner uses to reach the + browser pod's gate process. + format: int32 + type: integer + name: + description: |- + Name is the LLM-facing prefix (the `name` field from + AgentClassBrowserToolboxRef). + type: string + podFailure: + description: |- + PodFailure records a TERMINAL container state on the browser pod + (CrashLoopBackOff, ImagePullBackOff) -- reuses SidecarPodFailure's shape + exactly; see that type's doc comment for why it carries no timestamp. + properties: + exitCode: + description: |- + ExitCode is the container's last terminated exit code. Nil when the + container never ran (e.g. the image could not be pulled). + format: int32 + type: integer + message: + description: |- + Message is the most actionable text available: the container's last + terminated message when present — the crashing container's log tail, + thanks to the pod's FallbackToLogsOnError policy — otherwise the + kubelet's waiting message. Preferring the log tail is what makes the + field worth surfacing; the waiting message alone is content-free for a + crash loop ("back-off Ns restarting failed container=…"). + type: string + reason: + description: |- + Reason is the kubelet's terminal container Waiting reason, e.g. + "CrashLoopBackOff" or "ImagePullBackOff". + type: string + required: + - reason + type: object + ref: + description: |- + Ref is the BrowserToolbox CR name (the `ref` field from + AgentClassBrowserToolboxRef). + type: string + spec: + description: Spec is the BrowserToolbox spec snapshot at session-start + time. + properties: + access: + description: |- + Access is the site allowlist/blocklist the browser's egress is + evaluated against. + properties: + mode: + description: |- + Mode is "allowlist" (only Sites are reachable) or "blocklist" (every + site except Sites is reachable). + enum: + - allowlist + - blocklist + type: string + sites: + description: |- + Sites is the set of hosts Mode interprets. Matching is suffix-by-label + unless Exact is set: a Site with Host "example.com" also matches + "www.example.com" and "a.b.example.com", but never a lookalike like + "notexample.com" -- pkg/tools/browserpolicy.Match does label-boundary + comparison, not a raw string suffix check. Exact pins the entry to the + literal host only. + items: + description: BrowserSite is one host entry in a BrowserAccess + list. + properties: + exact: + description: |- + Exact pins this entry to the literal Host only, disabling the default + suffix-by-label subdomain match. + type: boolean + host: + description: |- + Host is the DNS hostname this entry covers, matched suffix-by-label + (see BrowserAccess.Sites) unless Exact is set. + maxLength: 253 + pattern: ^[a-z0-9]([a-z0-9-]{0,62}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,62}[a-z0-9])?)*$ + type: string + required: + - host + type: object + type: array + x-kubernetes-list-map-keys: + - host + x-kubernetes-list-type: map + required: + - mode + type: object + interaction: + default: act + description: |- + Interaction gates whether the LLM may act on the page (click, type, + navigate) or only read its rendered content. + act -- full interaction. + readonly -- observation only; no mutating actions are dispatched. + enum: + - act + - readonly + type: string + state: + default: ephemeral + description: |- + State selects how the browser's session state (cookies, storage) is + retained across turns. + Phase A: ephemeral is the only state; persistent arrives with identities (Phase C). + enum: + - ephemeral + type: string + required: + - access + type: object + tokenHash: + description: |- + TokenHash is a hash of the resolved per-session gate token; a change + triggers pod replacement. + type: string + required: + - name + - ref + - spec + type: object + type: array resolvedContentGuardDetectors: description: |- ResolvedContentGuardDetectors snapshots the detector sidecars injected for diff --git a/config/crds/agentprimitives.authzed.com_browsertoolboxes.yaml b/config/crds/agentprimitives.authzed.com_browsertoolboxes.yaml new file mode 100644 index 00000000..e6216e4e --- /dev/null +++ b/config/crds/agentprimitives.authzed.com_browsertoolboxes.yaml @@ -0,0 +1,224 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.20.1 + name: browsertoolboxes.agentprimitives.authzed.com +spec: + group: agentprimitives.authzed.com + names: + categories: + - authzed + - spicebox + kind: BrowserToolbox + listKind: BrowserToolboxList + plural: browsertoolboxes + shortNames: + - btb + singular: browsertoolbox + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.access.mode + name: Mode + type: string + - jsonPath: .spec.interaction + name: Interaction + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: |- + BrowserToolbox declares a per-session browser the agent can drive: which + sites it may reach and whether the LLM may act on the page or only read it. + + Namespaced. An AgentClass references it via AgentClassBrowserToolboxRef, + and the AgentSession reconciler snapshots the referenced spec into + status.resolvedBrowserToolboxes at session start (see + ResolvedBrowserToolbox), frozen for the session's lifetime exactly like a + SidecarToolbox. + + Phase A deliberately ships a narrow field subset: Access covers only an + explicit allowlist/blocklist of sites (Task 1's pkg/tools/browserpolicy + decides admission from it), Interaction is act/readonly, and State is + pinned to "ephemeral" -- there is no persistent browser profile/identity + yet. Those arrive in later phases (identity-sourced sites in Phase B, + persistent state in Phase C) as additive fields, not a breaking change to + this spec. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: |- + BrowserToolboxSpec is deliberately not annotated with a CEL XValidation + rule: this type is embedded by value into AgentSession's + status.resolvedBrowserToolboxes snapshot (see ResolvedBrowserToolbox.Spec), + and a CEL rule reachable from a status path both trips the status-CEL guard + and risks the apiserver's CRD rule-cost budget across that unbounded status + array -- the same reasoning SidecarToolboxSpec documents. Field-level + markers (Enum, Pattern, default) are the validation surface here. + properties: + access: + description: |- + Access is the site allowlist/blocklist the browser's egress is + evaluated against. + properties: + mode: + description: |- + Mode is "allowlist" (only Sites are reachable) or "blocklist" (every + site except Sites is reachable). + enum: + - allowlist + - blocklist + type: string + sites: + description: |- + Sites is the set of hosts Mode interprets. Matching is suffix-by-label + unless Exact is set: a Site with Host "example.com" also matches + "www.example.com" and "a.b.example.com", but never a lookalike like + "notexample.com" -- pkg/tools/browserpolicy.Match does label-boundary + comparison, not a raw string suffix check. Exact pins the entry to the + literal host only. + items: + description: BrowserSite is one host entry in a BrowserAccess + list. + properties: + exact: + description: |- + Exact pins this entry to the literal Host only, disabling the default + suffix-by-label subdomain match. + type: boolean + host: + description: |- + Host is the DNS hostname this entry covers, matched suffix-by-label + (see BrowserAccess.Sites) unless Exact is set. + maxLength: 253 + pattern: ^[a-z0-9]([a-z0-9-]{0,62}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,62}[a-z0-9])?)*$ + type: string + required: + - host + type: object + type: array + x-kubernetes-list-map-keys: + - host + x-kubernetes-list-type: map + required: + - mode + type: object + interaction: + default: act + description: |- + Interaction gates whether the LLM may act on the page (click, type, + navigate) or only read its rendered content. + act -- full interaction. + readonly -- observation only; no mutating actions are dispatched. + enum: + - act + - readonly + type: string + state: + default: ephemeral + description: |- + State selects how the browser's session state (cookies, storage) is + retained across turns. + Phase A: ephemeral is the only state; persistent arrives with identities (Phase C). + enum: + - ephemeral + type: string + required: + - access + type: object + status: + description: |- + BrowserToolboxStatus is intentionally minimal in Phase A: a condition set + and the generation it reflects. There is no admission-time probe the way + SidecarToolbox has one -- a BrowserToolbox has no image to reach; its own + validity is purely a function of its spec. + properties: + conditions: + description: Conditions carries this BrowserToolbox's Valid condition. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + observedGeneration: + description: ObservedGeneration is the spec generation this status + reflects. + format: int64 + type: integer + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crds/kustomization.yaml b/config/crds/kustomization.yaml index efa3f1e4..59c7aa22 100644 --- a/config/crds/kustomization.yaml +++ b/config/crds/kustomization.yaml @@ -8,6 +8,7 @@ resources: - agentprimitives.authzed.com_agentsettings.yaml - agentprimitives.authzed.com_agentuis.yaml - agentprimitives.authzed.com_artifactrenders.yaml + - agentprimitives.authzed.com_browsertoolboxes.yaml - agentprimitives.authzed.com_channels.yaml - agentprimitives.authzed.com_clusteragentsettings.yaml - agentprimitives.authzed.com_clusteridentityproviders.yaml diff --git a/config/manager/deployment.yaml b/config/manager/deployment.yaml index b3a1ecac..609f720b 100644 --- a/config/manager/deployment.yaml +++ b/config/manager/deployment.yaml @@ -33,6 +33,7 @@ spec: args: - --runner-image=agentprimitives-runner:dev - --sandbox-image=spicebox-sandbox:dev + - --browser-image=ap-browser:dev - --leader-elect=true - --metrics-bind-address=127.0.0.1:8080 - --health-probe-bind-address=:8081 diff --git a/config/manager/role.yaml b/config/manager/role.yaml index 33f5773f..2384b298 100644 --- a/config/manager/role.yaml +++ b/config/manager/role.yaml @@ -178,6 +178,7 @@ rules: - agentprimitives.authzed.com resources: - agentsettings + - browsertoolboxes - clusteragentsettings - clusteridentityproviders - clusterskillsources diff --git a/go.mod b/go.mod index 040a14bb..c825dea6 100644 --- a/go.mod +++ b/go.mod @@ -22,6 +22,8 @@ require ( github.com/charmbracelet/bubbletea v1.3.4 github.com/charmbracelet/huh v0.7.0 github.com/charmbracelet/lipgloss v1.1.0 + github.com/chromedp/cdproto v0.0.0-20260714215040-dc233986426f + github.com/chromedp/chromedp v0.16.0 github.com/coreos/go-oidc/v3 v3.18.0 github.com/dustin/go-humanize v1.0.1 github.com/go-git/go-billy/v5 v5.9.0 @@ -92,8 +94,13 @@ require ( github.com/authzed/ctxkey v0.0.0-20250226155515-d49f99185584 // indirect github.com/aws/aws-sdk-go-v2/feature/rds/auth v1.6.37 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect + github.com/chromedp/sysutil v1.1.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/creasty/defaults v1.8.0 // indirect + github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68 // indirect + github.com/gobwas/httphead v0.1.0 // indirect + github.com/gobwas/pool v0.2.1 // indirect + github.com/gobwas/ws v1.4.0 // indirect github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 // indirect github.com/segmentio/asm v1.2.1 // indirect github.com/segmentio/encoding v0.5.4 // indirect diff --git a/go.sum b/go.sum index 4cd4ac3e..5b2409ad 100644 --- a/go.sum +++ b/go.sum @@ -288,6 +288,12 @@ github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8 github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI= github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4= +github.com/chromedp/cdproto v0.0.0-20260714215040-dc233986426f h1:0Z1zcSLEmnj2c2CmJYBqewtS6pxhB39bNWUSEUAWjgk= +github.com/chromedp/cdproto v0.0.0-20260714215040-dc233986426f/go.mod h1:RwFsSODCtFExll+GhHM6R92SARHR3Z3oipaxLHj46C0= +github.com/chromedp/chromedp v0.16.0 h1:rOO4deOm4CbZgBCa8mD9g2rDyIoNs0BkgvNrlbp5ouk= +github.com/chromedp/chromedp v0.16.0/go.mod h1:rbuGKFT1vMcFcFqKfPIO1GpX/N+2s8onm2qMxZLbU5U= +github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM= +github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8= github.com/clbanning/mxj/v2 v2.7.0 h1:WA/La7UGCanFe5NpHF0Q3DNtnCsVoxbPKuyBNHWRyME= github.com/clbanning/mxj/v2 v2.7.0/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= @@ -418,6 +424,8 @@ github.com/go-git/go-git/v5 v5.19.2 h1:wkfn7vOlUBu8ivAWKBWisTiwJK4jYHzTF8Ndv1LyG github.com/go-git/go-git/v5 v5.19.2/go.mod h1:QqCBE1EFN5ddFmrliLQ3/ntRCUjZU3EJuwuB/jWEHjk= github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68 h1:KZaTBSyshWX3MP5jukJcNSuXDQTO+rNpt0J564dX/eg= +github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg= github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= @@ -470,6 +478,12 @@ github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPE github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/gobuffalo/flect v1.0.3 h1:xeWBM2nui+qnVvNM4S3foBhCAL2XgPU+a7FdpelbTq4= github.com/gobuffalo/flect v1.0.3/go.mod h1:A5msMlrHtLqh9umBSnvabjsMrCcCpAyzglnDvkbYKHs= +github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU= +github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= +github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= +github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= +github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs= +github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc= github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 h1:ZpnhV/YsD2/4cESfV5+Hoeu/iUR3ruzNvZ+yQfO03a0= @@ -648,6 +662,8 @@ github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o= github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhRWSsG5rVo6hYhAB/ADZrk= github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw= +github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo= +github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs= github.com/leodido/ragel-machinery v0.0.0-20181214104525-299bdde78165/go.mod h1:WZxr2/6a/Ar9bMDc2rN/LJrE/hF6bXE4LPyDSIxwAfg= github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ= github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= @@ -760,6 +776,8 @@ github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgr github.com/opencontainers/runc v1.2.8 h1:RnEICeDReapbZ5lZEgHvj7E9Q3Eex9toYmaGBsbvU5Q= github.com/opencontainers/runc v1.2.8/go.mod h1:cC0YkmZcuvr+rtBZ6T7NBoVbMGNAdLa/21vIElJDOzI= github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o= +github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw= +github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0= github.com/ory/dockertest/v3 v3.12.0 h1:3oV9d0sDzlSQfHtIaB5k6ghUCVMVLpAY8hwrqoCyRCw= github.com/ory/dockertest/v3 v3.12.0/go.mod h1:aKNDTva3cp8dwOWwb9cWuX84aH5akkxXRvO7KCwWVjE= github.com/otiai10/gosseract/v2 v2.4.1 h1:G8AyBpXEeSlcq8TI85LH/pM5SXk8Djy2GEXisgyblRw= diff --git a/images/browser/Dockerfile b/images/browser/Dockerfile new file mode 100644 index 00000000..0c7436f0 --- /dev/null +++ b/images/browser/Dockerfile @@ -0,0 +1,47 @@ +# syntax=docker/dockerfile:1.6 +# +# images/browser/Dockerfile — the first-party browser toolbox image: chromium +# plus apbrowsergate (internal/cmd/apbrowsergate), the gate that fronts it. +# +# A later task's pod builder runs TWO containers from this ONE image — Chrome +# and the gate — setting each container's Command explicitly. So this image +# needs no ENTRYPOINT contract beyond both binaries existing on PATH as a +# non-root user: /usr/bin/chromium (the debian package binary) and +# /usr/local/bin/apbrowsergate. +# +# The builder stage mirrors Dockerfile.services' own preamble (same +# golang:1.26 tag the CI pin-gate covers, same BUILDPLATFORM/TARGETARCH +# cross-compile shape, same cache mounts) even though this image builds only +# one binary — so a golang-tag bump stays a single edit across every +# first-party image. + +FROM --platform=$BUILDPLATFORM golang:1.26 AS builder +ARG TARGETARCH +WORKDIR /src +ENV GOFLAGS=-trimpath +COPY go.mod go.sum ./ +RUN --mount=type=cache,target=/go/pkg/mod go mod download +COPY . . +RUN --mount=type=cache,target=/go/pkg/mod \ + --mount=type=cache,target=/root/.cache/go-build \ + CGO_ENABLED=0 GOOS=linux GOARCH=$TARGETARCH \ + go build -o /out/apbrowsergate ./internal/cmd/apbrowsergate + +# --- runtime ------------------------------------------------------------------- +# debian:trixie-slim, not distroless: chromium's debian package (multi-arch, +# unlike google-chrome) pulls a real dependency graph (libnss3, fontconfig, +# etc.) that a distroless/static base cannot host. fonts-liberation and +# fonts-noto-color-emoji keep common web content (Latin substitutes, emoji) +# from rendering as tofu boxes. +FROM debian:trixie-slim +RUN apt-get update && apt-get install -y --no-install-recommends \ + chromium fonts-liberation fonts-noto-color-emoji ca-certificates \ + && rm -rf /var/lib/apt/lists/* \ + && useradd --create-home --uid 65532 chrome +COPY --from=builder /out/apbrowsergate /usr/local/bin/apbrowsergate +# Numeric uid, not the name: Kubernetes' RunAsNonRoot:true cannot verify a +# symbolic USER from the image config and fails the container with +# CreateContainerConfigError. The useradd above still creates the named +# account so uid 65532 has a real home directory and passwd entry (Chrome +# wants both); only the recorded USER must be numeric. +USER 65532 diff --git a/internal/cmd/apbrowsergate/main.go b/internal/cmd/apbrowsergate/main.go new file mode 100644 index 00000000..03ea4c0c --- /dev/null +++ b/internal/cmd/apbrowsergate/main.go @@ -0,0 +1,90 @@ +// Command apbrowsergate is the browser toolbox's gate: the second container in +// a per-session Chrome pod. It runs two listeners — a policy-enforcing forward +// proxy that is Chrome's ONLY egress, and an authenticated reverse proxy to +// Chrome's DevTools that the session runner is the sole client of. It is a +// security boundary: hostname-policy enforcement and per-session token auth both +// live here, so Chrome can reach only policy-approved hosts and nothing can +// drive Chrome's DevTools without the token. +// +// The per-session token is read ONLY from the APBROWSERGATE_TOKEN environment +// variable, never a flag: a flag value is visible in the pod's process list +// (`ps`), and this token is a credential. +package main + +import ( + "context" + "os" + "os/signal" + "syscall" + + "github.com/spf13/cobra" + + "github.com/authzed/openagentprimitives/pkg/cli/clikit" + "github.com/authzed/openagentprimitives/pkg/platform/deplogs" + "github.com/authzed/openagentprimitives/pkg/tools/browsergate" +) + +// tokenEnv names the environment variable carrying the per-session browser +// token. It is deliberately not a flag (see the package comment). +const tokenEnv = "APBROWSERGATE_TOKEN" + +// config holds apbrowsergate's non-secret configuration, populated from flags +// and (via clikit) the environment. +type config struct { + proxyAddr string + gateAddr string + devtoolsURL string + policyPath string +} + +func main() { + deplogs.Silence() + + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + if err := newCommand().ExecuteContext(ctx); err != nil { + os.Exit(1) + } +} + +func newCommand() *cobra.Command { + cfg := &config{} + cmd := &cobra.Command{ + Use: "apbrowsergate", + Short: "Browser toolbox gate: a policy-enforcing CONNECT proxy and an authenticated DevTools proxy.", + SilenceUsage: true, + RunE: func(cmd *cobra.Command, _ []string) error { + return run(cmd.Context(), cfg) + }, + } + fs := cmd.Flags() + fs.StringVar(&cfg.proxyAddr, "proxy-addr", browsergate.DefaultProxyAddr, "Chrome-facing forward-proxy listen address (loopback only)") + fs.StringVar(&cfg.gateAddr, "gate-addr", browsergate.DefaultGateAddr, "runner-facing DevTools-proxy + /healthz listen address") + fs.StringVar(&cfg.devtoolsURL, "devtools-url", browsergate.DefaultDevtoolsURL, "Chrome remote-debugging endpoint to proxy to") + fs.StringVar(&cfg.policyPath, "policy-path", "/etc/browserpolicy/policy.json", "path to the JSON browser policy document (reloaded on mtime change)") + cmd.PreRunE = clikit.EnvOverridePreRunE(map[string]string{ + "proxy-addr": "APBROWSERGATE_PROXY_ADDR", + "gate-addr": "APBROWSERGATE_GATE_ADDR", + "devtools-url": "APBROWSERGATE_DEVTOOLS_URL", + "policy-path": "APBROWSERGATE_POLICY_PATH", + }) + return cmd +} + +// run builds the gate from cfg and the token env var and serves until the +// context is cancelled. browsergate.New fails closed on a missing token or an +// unreadable/invalid policy, so a bad configuration crash-loops the pod rather +// than serving an open gate. +func run(ctx context.Context, cfg *config) error { + g, err := browsergate.New(browsergate.Config{ + ProxyAddr: cfg.proxyAddr, + GateAddr: cfg.gateAddr, + DevtoolsURL: cfg.devtoolsURL, + Token: os.Getenv(tokenEnv), + PolicyPath: cfg.policyPath, + }) + if err != nil { + return err + } + return g.Run(ctx) +} diff --git a/internal/cmd/operator/main.go b/internal/cmd/operator/main.go index c37be848..7dd52907 100644 --- a/internal/cmd/operator/main.go +++ b/internal/cmd/operator/main.go @@ -725,6 +725,7 @@ type config struct { gatewayAddr string runnerImage string sandboxImage string + browserImage string imagePullSecret string channelsdTokenFile string webdTokenFile string @@ -849,6 +850,7 @@ func newCommand() *cobra.Command { fs.StringVar(&cfg.gatewayAddr, "gateway-bind-address", ":8443", "Address for the streaming gRPC gateway. Empty to disable.") fs.StringVar(&cfg.runnerImage, "runner-image", apimage.Runner.LocalRef(), "Container image for the agent runner pod.") fs.StringVar(&cfg.sandboxImage, "sandbox-image", apimage.Sandbox.LocalRef(), "Default container image for sandbox/bundle pods of a SpiceboxClass that does not pin spec.image.") + fs.StringVar(&cfg.browserImage, "browser-image", apimage.Browser.LocalRef(), "Container image (chromium + apbrowsergate) for per-session browser-toolbox pods.") fs.StringVar(&cfg.imagePullSecret, "image-pull-secret", "", "Optional imagePullSecret name added to operator-synthesized runner + sidecar pods (private registries).") fs.StringVar(&cfg.natsURL, "nats-url", "nats://spicebox-nats.agentprimitives-system.svc:4222", "NATS connection URL passed to runner pods.") fs.DurationVar(&cfg.defaultChannelArchiveAfter, "default-channel-archive-after", 4*time.Hour, @@ -2483,6 +2485,16 @@ func run(cfg *config) { // identical question. trustedImageRegistry := trustedImageRegistryFrom(apimage.Sandbox, cfg.sandboxImage) + // Browser-toolbox posture: the resolved cloud kind decides whether the browser + // pod may rely on Chrome's own sandbox. Phase A: every kind returns + // Sandboxed=false with a non-empty Reason, logged once here so an operator can + // always find WHY Chrome is running unsandboxed (the browser-pod builder reads + // this value, never a kind switch). + browserSandbox := clusterStrategy.BrowserChromeSandbox() + log.Info("browser-toolbox Chrome sandbox posture resolved", + "sandboxed", browserSandbox.Sandboxed, "reason", browserSandbox.Reason, + "clusterKind", clusterStrategy.Key(), "browserImage", cfg.browserImage) + podFactory := &agentsessionctrl.PodRunnerFactory{ Client: mgr.GetClient(), RunnerImage: cfg.runnerImage, @@ -2577,6 +2589,12 @@ func run(cfg *config) { // pod can carry WORKSHOP_TRUSTED_IMAGE_REGISTRY too (BuildSidecarPod's // identity branch, threaded through reconcileSidecarPod). TrustedImageRegistry: trustedImageRegistry, + // BrowserImageRef resolves like RunnerImage/SandboxImage — a flag defaulting + // to apimage.Browser.LocalRef() that `oap install` registry-qualifies via + // manifest substitution. BrowserSandbox is the resolved cloud kind's answer, + // logged once above. Both feed the per-session browser pod builder. + BrowserImageRef: cfg.browserImage, + BrowserSandbox: browserSandbox, DefaultChannelArchiveAfter: cfg.defaultChannelArchiveAfter, DefaultSessionSleepAfter: cfg.defaultSessionSleepAfter, FailedSandboxReapGrace: cfg.failedSandboxReapGrace, diff --git a/internal/cmd/runner/main.go b/internal/cmd/runner/main.go index 0d1968e8..d9548ad1 100644 --- a/internal/cmd/runner/main.go +++ b/internal/cmd/runner/main.go @@ -71,6 +71,7 @@ import ( "github.com/authzed/openagentprimitives/pkg/agent/session/state/plans" "github.com/authzed/openagentprimitives/pkg/agent/tool" "github.com/authzed/openagentprimitives/pkg/agent/tool/authfail" + browsertoolboxsynth "github.com/authzed/openagentprimitives/pkg/agent/tool/browser" mcpdispatch "github.com/authzed/openagentprimitives/pkg/agent/tool/mcp" "github.com/authzed/openagentprimitives/pkg/agent/tool/meta" "github.com/authzed/openagentprimitives/pkg/agent/tool/meta/capability" @@ -130,6 +131,7 @@ import ( _ "github.com/authzed/openagentprimitives/pkg/channels/channelkinds/slack" // register slack kind "github.com/authzed/openagentprimitives/pkg/channels/notice" "github.com/authzed/openagentprimitives/pkg/cli/clikit" + "github.com/authzed/openagentprimitives/pkg/controllers/agentsession" "github.com/authzed/openagentprimitives/pkg/memory" "github.com/authzed/openagentprimitives/pkg/memory/httpclient" _ "github.com/authzed/openagentprimitives/pkg/memory/kinds/all" @@ -1473,6 +1475,100 @@ func run(cfg *config) error { bootSynthedSidecars[rt.Name] = rt.SidecarPodIP } + // Build browser-toolbox tools. + // + // Mirrors the sidecar block above: the operator creates the runner pod and + // any per-session browser pods in parallel, so at runner boot a browser + // pod may not yet be Ready — its BrowserPodIP is empty in the session + // status snapshot (`sess`) read earlier in run(). Poll the AgentSession + // until the IP is reflected, bounded by browserPodIPTimeout (same wait + // shape/constants as sidecarPodIPTimeout/sidecarPodIPPollInterval). + const browserPodIPTimeout = 120 * time.Second + const browserPodIPPollInterval = 2 * time.Second + + browserTools := []tool.Tool{} + for _, rt := range sess.Status.ResolvedBrowserToolboxes { + if rt.BrowserPodIP == "" { + slog.Info("browser toolbox IP not yet reflected; polling", + "browser", rt.Name, "timeout", browserPodIPTimeout) + deadline := time.Now().Add(browserPodIPTimeout) + for time.Now().Before(deadline) && rt.BrowserPodIP == "" { + select { + case <-rootCtx.Done(): + return rootCtx.Err() + case <-time.After(browserPodIPPollInterval): + } + var refreshed spiceboxv1alpha1.AgentSession + if rerr := c.Get(rootCtx, sessKey, &refreshed); rerr != nil { + return fmt.Errorf("poll AgentSession for browser toolbox %q IP: %w", rt.Name, rerr) + } + for _, rrt := range refreshed.Status.ResolvedBrowserToolboxes { + if rrt.Name == rt.Name { + rt = rrt + break + } + } + } + if rt.BrowserPodIP == "" { + // Browser pod created but never became Ready within the + // timeout — treat as boot failure, same as the sidecar case. + if werr := failSession(spiceboxv1alpha1.ReasonAgentSessionBrowserBootFailed, + fmt.Sprintf("browser toolbox %q: BrowserPodIP not reflected after %s", rt.Name, browserPodIPTimeout)); werr != nil { + return werr + } + return nil + } + } + + // Read the per-session gate token Secret the operator minted + // (mintBrowserToken) — the runner's per-session Role is pinned to + // exactly this Secret's deterministic name. The Secret's one data key + // is "token" (pkg/controllers/agentsession's browserTokenSecretKey is + // unexported, so the literal is duplicated here deliberately). + var tokenSecret corev1.Secret + if gerr := c.Get(rootCtx, client.ObjectKey{Namespace: ns, Name: agentsession.BrowserTokenSecretName(&sess, rt.Ref)}, &tokenSecret); gerr != nil { + if werr := failSession(spiceboxv1alpha1.ReasonAgentSessionBrowserBootFailed, + fmt.Sprintf("browser toolbox %q: get token secret: %v", rt.Name, gerr)); werr != nil { + return werr + } + return nil + } + token := string(tokenSecret.Data["token"]) + if token == "" { + // The operator always writes the "token" key (mintBrowserToken), + // so an empty value means the Secret was tampered with or the + // contract drifted. Fail the boot loudly rather than letting + // every browser call die later on an opaque gate 401. + if werr := failSession(spiceboxv1alpha1.ReasonAgentSessionBrowserBootFailed, + fmt.Sprintf("browser toolbox %q: token secret %q has an empty %q key", rt.Name, tokenSecret.Name, "token")); werr != nil { + return werr + } + return nil + } + + // acceptsImages gates the screenshot observation tool (spec §3.2). The + // browser gate's screenshot op returns a PNG (synthesize.go's + // screenshotOp), so the predicate is exactly "does this session's + // resolved model accept a native image/png content block" — + // llm.Provider.NativeInputMIMEs(model), the same cheap/no-network + // provider method that resolves modelCaps a bit further down in this + // function, just asked about MIME support instead of Capability + // flags. Fail-closed: an unknown model yields an empty MIMESet, so + // Has returns false and the screenshot tool is withheld rather than + // offered to a model that cannot consume it. + acceptsImages := provider.NativeInputMIMEs(sess.Status.EffectiveSettings.Model.Name).Has("image/png") + gateURL := fmt.Sprintf("http://%s:%d", rt.BrowserPodIP, rt.GatePort) + built, serr := browsertoolboxsynth.Synthesize(rt, gateURL, token, acceptsImages) + if serr != nil { + if werr := failSession(spiceboxv1alpha1.ReasonAgentSessionBrowserBootFailed, + fmt.Sprintf("browser toolbox %q: %v", rt.Name, serr)); werr != nil { + return werr + } + return nil + } + browserTools = append(browserTools, built...) + } + // Resolve opted-in skills for this agent class. The ceiling enforcement // (AllowedSkills/DeniedSkills) is already done upstream by the webhook and // controller; the runner trusts class.Spec.Skills as validated. Fail-closed: @@ -1944,7 +2040,7 @@ func run(cfg *config) error { Session: &sess, Binding: bindingOrNil, Env: env, - NonMetaTools: append(append(append([]tool.Tool{}, sandboxTools...), mcpTools...), sidecarTools...), + NonMetaTools: append(append(append(append([]tool.Tool{}, sandboxTools...), mcpTools...), sidecarTools...), browserTools...), Logger: logr.FromSlogHandler(slog.Default().Handler()), }) mergedTools := assembled.Tools diff --git a/pkg/agent/tool/browser/chrome_test.go b/pkg/agent/tool/browser/chrome_test.go new file mode 100644 index 00000000..9fa70b94 --- /dev/null +++ b/pkg/agent/tool/browser/chrome_test.go @@ -0,0 +1,348 @@ +package browser + +import ( + "context" + "encoding/json" + "fmt" + "net" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "strings" + "testing" + "time" + + "github.com/chromedp/chromedp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + agenttool "github.com/authzed/openagentprimitives/pkg/agent/tool" + spiceboxv1alpha1 "github.com/authzed/openagentprimitives/pkg/apis/v1alpha1" + "github.com/authzed/openagentprimitives/pkg/tools/browsergate" +) + +const chromeToken = "chrome-test-token" + +// requireLocalChrome skips the test unless a Chrome/Chromium binary can be +// found, mirroring chromedp's own findExecPath search (PATH names on every OS +// plus the macOS .app bundle paths). Spec §7 permits this skip, with a reason. +func requireLocalChrome(t *testing.T) { + t.Helper() + var names []string + switch runtime.GOOS { + case "darwin": + for _, p := range []string{ + "/Applications/Chromium.app/Contents/MacOS/Chromium", + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + } { + if _, err := os.Stat(p); err == nil { + return + } + } + names = []string{"chromium", "google-chrome", "chrome"} + case "windows": + names = []string{"chrome", "chrome.exe"} + default: + names = []string{"headless-shell", "chromium", "chromium-browser", "google-chrome", "google-chrome-stable", "chrome"} + } + for _, n := range names { + if _, err := exec.LookPath(n); err == nil { + return + } + } + t.Skipf("no chrome binary found: looked for %v (and macOS .app bundles)", names) +} + +// freePort grabs a free localhost TCP port and releases it. There is a small +// race before it is reclaimed; acceptable for a test harness. +func freePort(t *testing.T) int { + t.Helper() + l, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + port := l.Addr().(*net.TCPAddr).Port + require.NoError(t, l.Close()) + return port +} + +// writePolicyFile writes a browserpolicy PolicyDoc JSON file for the gate. +func writePolicyFile(t *testing.T, mode string, hosts ...string) string { + t.Helper() + sites := make([]map[string]any, 0, len(hosts)) + for _, h := range hosts { + sites = append(sites, map[string]any{"host": h}) + } + doc, err := json.Marshal(map[string]any{"mode": mode, "sites": sites}) + require.NoError(t, err) + dir := t.TempDir() + path := filepath.Join(dir, "policy.json") + require.NoError(t, os.WriteFile(path, doc, 0o600)) + return path +} + +// fixture is a running full-stack browser test environment: a local Chrome +// whose ONLY egress is the in-process gate's proxy, reached by session code +// THROUGH the in-process gate's authenticated DevTools endpoint. +type fixture struct { + gateURL string // gate DevTools listener; session connects here + siteBase string // "http://site.test:" — the allowlisted test site + rt spiceboxv1alpha1.ResolvedBrowserToolbox +} + +// startFixture stands up: the httptest site, the in-process gate (proxy + +// devtools), and a local headless Chrome launched via chromedp.ExecAllocator +// with --proxy-server pointed at the gate proxy. The allowlisted site is +// reached by Chrome directly (host-resolver-rules + proxy-bypass), because the +// gate's guarded dialer refuses loopback by design and its dialer hook is +// package-private; a redirect to an off-policy host still traverses the gate +// proxy and is denied there, which is what the redirect test exercises. +func startFixture(t *testing.T, handler http.Handler) *fixture { + t.Helper() + requireLocalChrome(t) + + site := httptest.NewServer(handler) + t.Cleanup(site.Close) + sitePort := site.Listener.Addr().(*net.TCPAddr).Port + siteBase := fmt.Sprintf("http://site.test:%d", sitePort) + + debugPort := freePort(t) + policyPath := writePolicyFile(t, "allowlist", "site.test") + + g, err := browsergate.New(browsergate.Config{ + ProxyAddr: "127.0.0.1:0", + GateAddr: "127.0.0.1:0", + DevtoolsURL: fmt.Sprintf("http://127.0.0.1:%d", debugPort), + Token: chromeToken, + PolicyPath: policyPath, + ExtraPorts: []int{sitePort}, + }) + require.NoError(t, err) + + proxy := httptest.NewServer(g.ProxyHandler()) + t.Cleanup(proxy.Close) + proxyAddr := strings.TrimPrefix(proxy.URL, "http://") + + gate := httptest.NewServer(g.GateHandler()) + t.Cleanup(gate.Close) + + // Launch Chrome: egress only through the gate proxy; the allowlisted test + // site is mapped to loopback and bypassed so Chrome reaches it directly. + opts := append([]chromedp.ExecAllocatorOption{}, chromedp.DefaultExecAllocatorOptions[:]...) + opts = append(opts, + chromedp.ProxyServer(proxyAddr), + chromedp.Flag("remote-debugging-port", strconv.Itoa(debugPort)), + // Chrome 111+ rejects remote-debugging connections whose Origin is not + // allow-listed; the gate reaches Chrome over a rewritten host, so allow all. + chromedp.Flag("remote-allow-origins", "*"), + chromedp.Flag("host-resolver-rules", "MAP site.test 127.0.0.1"), + chromedp.Flag("proxy-bypass-list", "site.test"), + chromedp.NoSandbox, + ) + allocCtx, allocCancel := chromedp.NewExecAllocator(context.Background(), opts...) + t.Cleanup(allocCancel) + browserCtx, browserCancel := chromedp.NewContext(allocCtx) + t.Cleanup(browserCancel) + // Boot on the browser context itself (not a timeout-scoped child): cancelling + // a derived boot context can tear down the freshly created target. + require.NoError(t, chromedp.Run(browserCtx), "boot local Chrome through the gate proxy") + + // Wait until Chrome's DevTools endpoint answers (the gate proxies it). + waitForDevtools(t, debugPort) + + return &fixture{ + gateURL: gate.URL, + siteBase: siteBase, + rt: spiceboxv1alpha1.ResolvedBrowserToolbox{ + Name: "demo", + Ref: "demo-browser", + Spec: spiceboxv1alpha1.BrowserToolboxSpec{ + Access: spiceboxv1alpha1.BrowserAccess{Mode: "allowlist"}, + Interaction: "readonly", + State: "ephemeral", + }, + EffectiveSites: []spiceboxv1alpha1.BrowserEffectiveSite{{Host: "site.test", Source: "explicit"}}, + }, + } +} + +func waitForDevtools(t *testing.T, debugPort int) { + t.Helper() + deadline := time.Now().Add(20 * time.Second) + url := fmt.Sprintf("http://127.0.0.1:%d/json/version", debugPort) + for time.Now().Before(deadline) { + resp, err := http.Get(url) + if err == nil { + resp.Body.Close() + if resp.StatusCode == 200 { + return + } + } + time.Sleep(100 * time.Millisecond) + } + t.Fatalf("Chrome DevTools on :%d never became ready", debugPort) +} + +// synthTools builds the toolbox's tools against the fixture and registers the +// shared session for teardown. +func (fx *fixture) synthTools(t *testing.T, acceptsImages bool) []agenttool.Tool { + t.Helper() + tools, err := Synthesize(fx.rt, fx.gateURL, chromeToken, acceptsImages) + require.NoError(t, err) + require.NotEmpty(t, tools) + if bt, ok := tools[0].(*browserTool); ok { + t.Cleanup(func() { bt.sess.Close() }) + } + return tools +} + +func toolBy(t *testing.T, tools []agenttool.Tool, suffix string) agenttool.Tool { + t.Helper() + return toolBySuffix(t, tools, suffix) +} + +// run executes a tool with flat args, requiring no Go-level error. +func run(t *testing.T, tl agenttool.Tool, args map[string]any) agenttool.Result { + t.Helper() + raw, err := json.Marshal(args) + require.NoError(t, err) + ctx, cancel := context.WithTimeout(context.Background(), 40*time.Second) + defer cancel() + res, err := tl.Execute(ctx, raw, &agenttool.SessionContext{}) + require.NoError(t, err) + return res +} + +func TestReadRendersOutlineWithRefs(t *testing.T) { + fx := startFixture(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`

Fares

Book a flight`)) + })) + tools := fx.synthTools(t, false) + + nav := run(t, toolBy(t, tools, "navigate"), map[string]any{"url": fx.siteBase + "/"}) + require.False(t, nav.IsError, "navigate: %s", nav.Content) + + res := run(t, toolBy(t, tools, "read"), map[string]any{}) + require.False(t, res.IsError, "read: %s", res.Content) + assert.Contains(t, res.Content, `heading "Fares"`) + assert.Contains(t, res.Content, `link "Book a flight"`) + assert.Contains(t, res.Content, "[e", "links must carry an [eN] ref") +} + +func TestFollowOnlyLinks(t *testing.T) { + fx := startFixture(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/x" { + _, _ = w.Write([]byte(`

Booked

`)) + return + } + _, _ = w.Write([]byte(`

Fares

Book a flight`)) + })) + tools := fx.synthTools(t, false) + require.False(t, run(t, toolBy(t, tools, "navigate"), map[string]any{"url": fx.siteBase + "/"}).IsError) + + read := run(t, toolBy(t, tools, "read"), map[string]any{}) + require.False(t, read.IsError, "read: %s", read.Content) + headingRef := refForRole(t, read.Content, "heading") + linkRef := refForRole(t, read.Content, "link") + + follow := toolBy(t, tools, "follow") + notLink := run(t, follow, map[string]any{"ref": headingRef}) + assert.True(t, notLink.IsError) + assert.Contains(t, notLink.Content, "is not a link") + + ok := run(t, follow, map[string]any{"ref": linkRef}) + assert.False(t, ok.IsError, "follow link: %s", ok.Content) + assert.Contains(t, ok.Content, "loaded") + assert.Contains(t, ok.Content, "/x") +} + +func TestStaleRefRefused(t *testing.T) { + fx := startFixture(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/other" { + _, _ = w.Write([]byte(`

Other

`)) + return + } + _, _ = w.Write([]byte(`

Fares

Book a flight`)) + })) + tools := fx.synthTools(t, false) + require.False(t, run(t, toolBy(t, tools, "navigate"), map[string]any{"url": fx.siteBase + "/"}).IsError) + + read := run(t, toolBy(t, tools, "read"), map[string]any{}) + require.False(t, read.IsError) + linkRef := refForRole(t, read.Content, "link") + + // Navigate elsewhere: the old snapshot's refs are now stale. + require.False(t, run(t, toolBy(t, tools, "navigate"), map[string]any{"url": fx.siteBase + "/other"}).IsError) + + stale := run(t, toolBy(t, tools, "follow"), map[string]any{"ref": linkRef}) + assert.True(t, stale.IsError) + assert.Contains(t, stale.Content, "is stale") +} + +func TestRedirectToBlockedHostSurfacesDenial(t *testing.T) { + fx := startFixture(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/r" { + http.Redirect(w, r, "https://blocked.test/", http.StatusFound) + return + } + _, _ = w.Write([]byte(`

Home

`)) + })) + tools := fx.synthTools(t, false) + + res := run(t, toolBy(t, tools, "navigate"), map[string]any{"url": fx.siteBase + "/r"}) + assert.True(t, res.IsError, "a redirect to an off-policy host must surface as an error, not a blank success") + assert.Contains(t, res.Content, "is not allowed by browser toolbox") + assert.Contains(t, res.Content, "blocked.test", "the real blocked redirect target should be named") + assert.NotContains(t, res.Content, "loaded https://blocked.test") +} + +func TestReadPaginates(t *testing.T) { + fx := startFixture(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + var b strings.Builder + b.WriteString(`
    `) + for i := 0; i < 900; i++ { + fmt.Fprintf(&b, `
  • list entry number %d of the very long enumeration
  • `, i) + } + b.WriteString(`
`) + _, _ = w.Write([]byte(b.String())) + })) + tools := fx.synthTools(t, false) + require.False(t, run(t, toolBy(t, tools, "navigate"), map[string]any{"url": fx.siteBase + "/"}).IsError) + + readTool := toolBy(t, tools, "read") + p1 := run(t, readTool, map[string]any{"page": 1}) + require.False(t, p1.IsError, "read p1: %s", p1.Content) + assert.Contains(t, p1.Content, "page 1 of ") + + p2 := run(t, readTool, map[string]any{"page": 2}) + require.False(t, p2.IsError, "read p2: %s", p2.Content) + assert.NotEqual(t, p1.Content, p2.Content, "page 2 must differ from page 1") + + oob := run(t, readTool, map[string]any{"page": 99}) + assert.Contains(t, oob.Content, "page 99 is out of range") +} + +// refForRole extracts the first ref id (e.g. "e3") from an outline line whose +// rendered role matches role. +func refForRole(t *testing.T, outline, role string) string { + t.Helper() + for _, line := range strings.Split(outline, "\n") { + trimmed := strings.TrimSpace(line) + if !strings.HasPrefix(trimmed, "[e") { + continue + } + close := strings.Index(trimmed, "]") + if close < 0 { + continue + } + ref := trimmed[1:close] // "eN" + rest := strings.TrimSpace(trimmed[close+1:]) + if strings.HasPrefix(rest, role+" ") { + return ref + } + } + t.Fatalf("no [eN] %s line found in outline:\n%s", role, outline) + return "" +} diff --git a/pkg/agent/tool/browser/policy_precheck_test.go b/pkg/agent/tool/browser/policy_precheck_test.go new file mode 100644 index 00000000..19efb316 --- /dev/null +++ b/pkg/agent/tool/browser/policy_precheck_test.go @@ -0,0 +1,98 @@ +package browser + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + agenttool "github.com/authzed/openagentprimitives/pkg/agent/tool" + spiceboxv1alpha1 "github.com/authzed/openagentprimitives/pkg/apis/v1alpha1" +) + +// buildToolbox returns a ResolvedBrowserToolbox with the given access mode and +// effective sites, named "demo" (the LLM-facing prefix the error text embeds). +func buildToolbox(mode string, sites ...spiceboxv1alpha1.BrowserEffectiveSite) spiceboxv1alpha1.ResolvedBrowserToolbox { + return spiceboxv1alpha1.ResolvedBrowserToolbox{ + Name: "demo", + Ref: "demo-browser", + Spec: spiceboxv1alpha1.BrowserToolboxSpec{ + Access: spiceboxv1alpha1.BrowserAccess{Mode: mode}, + Interaction: "readonly", + State: "ephemeral", + }, + EffectiveSites: sites, + } +} + +// toolBySuffix returns the synthesized tool whose LLM name ends in "_". +func toolBySuffix(t *testing.T, tools []agenttool.Tool, suffix string) agenttool.Tool { + t.Helper() + for _, tl := range tools { + if strings.HasSuffix(tl.Name(), "_"+suffix) { + return tl + } + } + t.Fatalf("no tool with suffix %q in %v", suffix, toolNames(tools)) + return nil +} + +func toolNames(tools []agenttool.Tool) []string { + out := make([]string, len(tools)) + for i, t := range tools { + out[i] = t.Name() + } + return out +} + +// execNavigate synthesizes a toolbox and runs its navigate tool with the given +// URL, returning the Result. gateURL is unreachable on purpose: every case here +// must be refused BEFORE any Chrome connection is attempted. +func execNavigate(t *testing.T, rt spiceboxv1alpha1.ResolvedBrowserToolbox, url string) agenttool.Result { + t.Helper() + tools, err := Synthesize(rt, "http://127.0.0.1:1", "tok", false) + require.NoError(t, err) + nav := toolBySuffix(t, tools, "navigate") + args, err := json.Marshal(map[string]any{"url": url}) + require.NoError(t, err) + res, err := nav.Execute(context.Background(), args, &agenttool.SessionContext{}) + require.NoError(t, err, "Execute must not return a Go error; refusals are tool errors") + return res +} + +func TestNavigateRefusesSchemes(t *testing.T) { + rt := buildToolbox("blocklist") // blocklist with no sites admits every host by name + cases := []string{ + "file:///etc/passwd", + "chrome://settings", + "about:blank", + "data:text/html,x", + } + for _, url := range cases { + t.Run(url, func(t *testing.T) { + res := execNavigate(t, rt, url) + assert.True(t, res.IsError, "non-http(s) scheme must be a tool error") + assert.Equal(t, "only http and https URLs can be opened", res.Content) + }) + } +} + +func TestNavigatePolicyPrecheck(t *testing.T) { + t.Run("allowlist denies unlisted host: rule 'not in allowlist'", func(t *testing.T) { + rt := buildToolbox("allowlist", spiceboxv1alpha1.BrowserEffectiveSite{Host: "good.test", Source: "explicit"}) + res := execNavigate(t, rt, "https://evil.com/x") + assert.True(t, res.IsError) + assert.Contains(t, res.Content, "(rule: not in allowlist)") + assert.Contains(t, res.Content, "host evil.com is not allowed by browser toolbox demo") + }) + t.Run("blocklist denies listed host: rule 'explicit denylist'", func(t *testing.T) { + rt := buildToolbox("blocklist", spiceboxv1alpha1.BrowserEffectiveSite{Host: "blocked.test", Source: "explicit"}) + res := execNavigate(t, rt, "https://blocked.test/path") + assert.True(t, res.IsError) + assert.Contains(t, res.Content, "(rule: explicit denylist)") + assert.Contains(t, res.Content, "host blocked.test is not allowed by browser toolbox demo") + }) +} diff --git a/pkg/agent/tool/browser/session.go b/pkg/agent/tool/browser/session.go new file mode 100644 index 00000000..2d74256d --- /dev/null +++ b/pkg/agent/tool/browser/session.go @@ -0,0 +1,238 @@ +// Package browser synthesizes the runner-side observation tools for one +// resolved BrowserToolbox. The tools drive a per-session Chrome over chromedp, +// reaching it ONLY through the browser gate (pkg/tools/browsergate): the +// DevTools control plane is reached over the gate's authenticated reverse +// proxy, and Chrome's own egress is policy-enforced by the gate's forward +// proxy. The tools additionally pre-check every navigation host against the +// toolbox's browserpolicy BEFORE handing a URL to Chrome, so an out-of-policy +// host is refused without a round-trip. +// +// All tools of one toolbox share a single Session (one Chrome connection) and a +// single Origin, so toolguard's origin-level circuit breaker treats them as one +// upstream — mirroring pkg/agent/tool/sidecartoolbox's origin wrapper. +package browser + +import ( + "context" + "fmt" + "sync" + "time" + + "github.com/chromedp/cdproto/network" + "github.com/chromedp/chromedp" +) + +// connectRetryDelay is the pause before the single reconnect attempt. The pod +// is Ready before the first tool call, but a lazy connect with one retry makes +// a wake race harmless. +const connectRetryDelay = 500 * time.Millisecond + +// Session owns the lazily-established chromedp connection to one per-session +// browser, reached through the gate. It is shared by every tool of a toolbox +// and is safe for concurrent use: all browser operations serialize under mu, so +// a single Chrome tab is never driven by two calls at once and the per-snapshot +// ref map is always rebuilt atomically. +type Session struct { + gateURL string + token string + + // newAllocator creates the chromedp allocator for parent. Overridable only + // for tests; production always dials the gate's /json/version with the token. + newAllocator func(parent context.Context) (context.Context, context.CancelFunc) + + mu sync.Mutex + rootCtx context.Context + rootStop context.CancelFunc + allocStop context.CancelFunc + browseCtx context.Context + browseCl context.CancelFunc + connected bool + + // refs is the latest snapshot's ref map, rebuilt atomically by read/find. + // A ref not present here is stale (the page changed since it was minted). + refs map[string]refEntry + + // netMu guards the network-event capture below. It is SEPARATE from mu + // because the chromedp event listener fires on its own goroutine while mu is + // held for the in-flight do(); sharing mu would deadlock. + netMu sync.Mutex + reqURLs map[network.RequestID]string // per-navigation request URLs + blockedURL string // the last URL a gate/proxy denial failed on +} + +// refEntry is what a per-snapshot "e" ref resolves to: the DOM backend node +// and the rendered role, so follow can both locate the element and refuse a +// non-link without a second round-trip. +type refEntry struct { + backendNodeID int64 + role string +} + +// newSession builds a Session for a gate URL + per-session token. gateURL is +// "http://:"; the connection is not made until the +// first tool call. +func newSession(gateURL, token string) *Session { + s := &Session{gateURL: gateURL, token: token, refs: map[string]refEntry{}} + s.newAllocator = func(parent context.Context) (context.Context, context.CancelFunc) { + // The gate rewrites /json/version's webSocketDebuggerUrl back to itself + // WITH the token, so chromedp stays on the authenticated gate rather than + // dialing Chrome directly. chromedp's default modifyURL preserves the + // query, so the token reaches the gate's auth check. + return chromedp.NewRemoteAllocator(parent, s.gateURL+"/json/version?t="+s.token) + } + return s +} + +// ensureConnected establishes the chromedp connection on first use, with one +// retry. It must be called with s.mu held. Every failure is returned with +// context; nothing is swallowed. +func (s *Session) ensureConnected() error { + if s.connected { + return nil + } + if s.rootCtx == nil { + // Parent on a session-scoped context, NOT a single call's context: the + // browser connection outlives any one Execute. Close tears it down. + s.rootCtx, s.rootStop = context.WithCancel(context.Background()) + } + + var lastErr error + for attempt := 0; attempt < 2; attempt++ { + allocCtx, allocStop := s.newAllocator(s.rootCtx) + browseCtx, browseCl := chromedp.NewContext(allocCtx) + // Boot on browseCtx itself: a derived, immediately-cancelled context + // would tear down the target chromedp just created. chromedp's own dial + // and modifyURL timeouts bound this; the pod is Ready before first use. + // Enabling Network lets the listener name the host a redirect was denied + // at, rather than the chrome-error pseudo-host Location reports. + if err := chromedp.Run(browseCtx, network.Enable()); err == nil { + s.allocStop = allocStop + s.browseCtx = browseCtx + s.browseCl = browseCl + s.connected = true + s.reqURLs = map[network.RequestID]string{} + chromedp.ListenTarget(browseCtx, s.onNetworkEvent) + return nil + } else { + browseCl() + allocStop() + lastErr = err + } + if attempt == 0 { + time.Sleep(connectRetryDelay) + } + } + return fmt.Errorf("connecting to browser gate %s: %w", s.gateURL, lastErr) +} + +// do runs fn against the browser tab, honoring the caller's ctx for +// cancellation and applying a per-call timeout. It serializes all browser +// operations and connects lazily. fn receives a context already bound to the +// chromedp browser context, so it can invoke chromedp.Run / cdproto actions. +func (s *Session) do(ctx context.Context, timeout time.Duration, fn func(ctx context.Context) error) error { + s.mu.Lock() + defer s.mu.Unlock() + if err := s.ensureConnected(); err != nil { + return err + } + runCtx, cancel := context.WithTimeout(s.browseCtx, timeout) + defer cancel() + // Propagate the caller's cancellation (an interrupt or the LLM call ending) + // into the chromedp run, which is parented on the long-lived browser ctx. + stop := context.AfterFunc(ctx, cancel) + defer stop() + return fn(runCtx) +} + +// onNetworkEvent records request URLs and, on a gate/proxy-denied load failure, +// the URL it failed at — so navigate can name the real blocked host (e.g. a +// redirect target) instead of Chrome's chrome-error pseudo-host. Runs on +// chromedp's event goroutine; guarded by netMu, never mu. +func (s *Session) onNetworkEvent(ev any) { + switch e := ev.(type) { + case *network.EventRequestWillBeSent: + if e.Request == nil { + return + } + s.netMu.Lock() + if len(s.reqURLs) < 1024 { // bounded; reset per navigation keeps it small + s.reqURLs[e.RequestID] = e.Request.URL + } + s.netMu.Unlock() + case *network.EventLoadingFailed: + if !isProxyDenial(e.ErrorText) && e.BlockedReason == "" { + return + } + s.netMu.Lock() + if u, ok := s.reqURLs[e.RequestID]; ok { + s.blockedURL = u + } + s.netMu.Unlock() + } +} + +// resetBlocked clears the per-navigation capture before a navigation starts. +func (s *Session) resetBlocked() { + s.netMu.Lock() + s.blockedURL = "" + s.reqURLs = map[network.RequestID]string{} + s.netMu.Unlock() +} + +// blockedHost returns the host of the last gate/proxy-denied URL, waiting +// briefly for the async LoadingFailed event to arrive, or "" if none. +func (s *Session) blockedHost() string { + deadline := time.Now().Add(300 * time.Millisecond) + for { + s.netMu.Lock() + u := s.blockedURL + s.netMu.Unlock() + if u != "" { + return hostOf(u) + } + if time.Now().After(deadline) { + return "" + } + time.Sleep(20 * time.Millisecond) + } +} + +// setRefs atomically replaces the latest snapshot's ref map. +func (s *Session) setRefs(refs map[string]refEntry) { + s.refs = refs +} + +// clearRefs drops the ref map; any ref presented afterward is stale. Called on +// every navigation (navigate / successful follow / back), because the page the +// refs described is gone. +func (s *Session) clearRefs() { + s.refs = map[string]refEntry{} +} + +// lookupRef returns the entry for ref, and whether it is present (i.e. not +// stale). Must be called with s.mu held (callers already hold it inside do). +func (s *Session) lookupRef(ref string) (refEntry, bool) { + e, ok := s.refs[ref] + return e, ok +} + +// Close tears down the browser connection and its goroutines. Safe to call more +// than once. After Close a subsequent tool call reconnects lazily. +func (s *Session) Close() { + s.mu.Lock() + defer s.mu.Unlock() + if s.browseCl != nil { + s.browseCl() + s.browseCl = nil + } + if s.allocStop != nil { + s.allocStop() + s.allocStop = nil + } + if s.rootStop != nil { + s.rootStop() + s.rootStop = nil + } + s.rootCtx = nil + s.connected = false +} diff --git a/pkg/agent/tool/browser/snapshot.go b/pkg/agent/tool/browser/snapshot.go new file mode 100644 index 00000000..c995547a --- /dev/null +++ b/pkg/agent/tool/browser/snapshot.go @@ -0,0 +1,183 @@ +package browser + +import ( + "context" + stdjson "encoding/json" + "fmt" + "strconv" + "strings" + + "github.com/chromedp/cdproto/accessibility" +) + +// snapNode is one rendered line of an accessibility outline: a per-snapshot ref, +// the display role, the accessible name, the indentation depth (by rendered +// ancestors), and the DOM backend node the ref resolves to. +type snapNode struct { + ref string + role string + name string + depth int + backendNodeID int64 +} + +// snapshot fetches a fresh full accessibility tree and builds the ordered +// interesting-node list plus the per-snapshot ref map. startBackendID scopes the +// walk to that node's subtree (0 = whole document). ctx must be bound to the +// chromedp browser context (i.e. called from within Session.do). +func snapshot(ctx context.Context, startBackendID int64) ([]snapNode, map[string]refEntry, error) { + var nodes []*accessibility.Node + err := runAction(ctx, func(c context.Context) error { + var e error + nodes, e = accessibility.GetFullAXTree().Do(c) + return e + }) + if err != nil { + return nil, nil, fmt.Errorf("reading accessibility tree: %w", err) + } + lines, refs := buildSnapshot(nodes, startBackendID) + return lines, refs, nil +} + +// buildSnapshot is the pure half of snapshot: it turns a flat AX node list into +// the rendered-node list and ref map. Refs are assigned in document pre-order +// over interesting, non-ignored nodes. It never panics on a nil node, a missing +// child id, or a detached subtree. +func buildSnapshot(nodes []*accessibility.Node, startBackendID int64) ([]snapNode, map[string]refEntry) { + byID := make(map[accessibility.NodeID]*accessibility.Node, len(nodes)) + for _, n := range nodes { + if n != nil { + byID[n.NodeID] = n + } + } + + var roots []*accessibility.Node + if startBackendID != 0 { + for _, n := range nodes { + if n != nil && int64(n.BackendDOMNodeID) == startBackendID { + roots = []*accessibility.Node{n} + break + } + } + } + if len(roots) == 0 { + for _, n := range nodes { + if n != nil && n.ParentID == "" { + roots = append(roots, n) + } + } + } + + refs := make(map[string]refEntry) + lines := make([]snapNode, 0, len(nodes)) + seen := make(map[accessibility.NodeID]bool, len(nodes)) + counter := 0 + + var walk func(n *accessibility.Node, depth int) + walk = func(n *accessibility.Node, depth int) { + if n == nil || seen[n.NodeID] { + return // nil child id or a cycle: never panic, never loop forever + } + seen[n.NodeID] = true + + childDepth := depth + if !n.Ignored { + if disp, ok := interestingRole(axRole(n)); ok { + counter++ + ref := "e" + strconv.Itoa(counter) + name := axName(n) + lines = append(lines, snapNode{ + ref: ref, + role: disp, + name: name, + depth: depth, + backendNodeID: int64(n.BackendDOMNodeID), + }) + refs[ref] = refEntry{backendNodeID: int64(n.BackendDOMNodeID), role: disp} + childDepth = depth + 1 + } + } + for _, cid := range n.ChildIDs { + walk(byID[cid], childDepth) + } + } + for _, r := range roots { + walk(r, 0) + } + return lines, refs +} + +// renderOutline renders the snapshot as an indented outline, one node per line: +// +// [e3] link "Book a flight" +func renderOutline(lines []snapNode) string { + var b strings.Builder + for _, ln := range lines { + b.WriteString(strings.Repeat(" ", ln.depth)) + fmt.Fprintf(&b, "[%s] %s %q\n", ln.ref, ln.role, ln.name) + } + return strings.TrimRight(b.String(), "\n") +} + +// interestingRole maps an AX role to its display form, reporting whether the +// role is one the outline renders. Only "interesting" roles surface; everything +// else is traversed for its children but not rendered. +func interestingRole(role string) (string, bool) { + switch strings.ToLower(role) { + case "rootwebarea", "webarea", "document": + return "document", true + case "heading": + return "heading", true + case "link": + return "link", true + case "button": + return "button", true + case "textbox", "textfield", "searchbox", "combobox": + return "textbox", true + case "list": + return "list", true + case "listitem": + return "listitem", true + case "image", "img": + return "image", true + case "row": + return "row", true + case "cell", "gridcell", "columnheader", "rowheader": + return "cell", true + case "statictext", "text", "paragraph": + return "text", true + default: + return "", false + } +} + +// axRole returns a node's computed role as a plain string ("" if absent). +func axRole(n *accessibility.Node) string { + if n == nil { + return "" + } + return axValueString(n.Role) +} + +// axName returns a node's accessible name as a plain string ("" if absent). +func axName(n *accessibility.Node) string { + if n == nil { + return "" + } + return axValueString(n.Name) +} + +// axValueString decodes an AXValue's JSON-encoded value into a display string. +// The computed value is raw JSON (usually a quoted string); non-string values +// fall back to their trimmed raw form. Returns "" for a nil value. +func axValueString(v *accessibility.Value) string { + if v == nil || len(v.Value) == 0 { + return "" + } + raw := []byte(v.Value) + var s string + if err := stdjson.Unmarshal(raw, &s); err == nil { + return s + } + return strings.TrimSpace(string(raw)) +} diff --git a/pkg/agent/tool/browser/synthesize.go b/pkg/agent/tool/browser/synthesize.go new file mode 100644 index 00000000..ca4c157f --- /dev/null +++ b/pkg/agent/tool/browser/synthesize.go @@ -0,0 +1,110 @@ +package browser + +import ( + "encoding/json" + + agenttool "github.com/authzed/openagentprimitives/pkg/agent/tool" + "github.com/authzed/openagentprimitives/pkg/agent/tool/originfmt" + "github.com/authzed/openagentprimitives/pkg/agent/tool/synthesize" + spiceboxv1alpha1 "github.com/authzed/openagentprimitives/pkg/apis/v1alpha1" + "github.com/authzed/openagentprimitives/pkg/tools/browserpolicy" +) + +// opSpec describes one observation tool's LLM-facing surface. +type opSpec struct { + op string + description string + schema string +} + +// observationOps is the Phase A observation tool set. screenshot is appended +// conditionally (acceptsImages); every other op is always offered. +var observationOps = []opSpec{ + { + op: "navigate", + description: "Open an http(s) URL in the browser and return the final URL and page title. The URL's host must be permitted by the browser toolbox's egress policy.", + schema: `{"type":"object","properties":{"url":{"type":"string","description":"The absolute http(s) URL to open."}},"required":["url"]}`, + }, + { + op: "read", + description: "Render the current page as an indented accessibility outline with per-snapshot refs ([eN]). Paginated at 16 KiB; pass page to move through pages, or ref to scope to a subtree.", + schema: `{"type":"object","properties":{"page":{"type":"integer","description":"1-based page number (default 1)."},"ref":{"type":"string","description":"A ref from a prior read to scope the outline to that element's subtree."}}}`, + }, + { + op: "find", + description: "Find elements whose accessible name or text contains the given string (case-insensitive). Returns up to 50 matching [eN] lines.", + schema: `{"type":"object","properties":{"text":{"type":"string","description":"Substring to search for."}},"required":["text"]}`, + }, + { + op: "follow", + description: "Follow a link referenced by a ref from a prior read, navigating to its URL through the same egress policy as navigate. Fails if the ref is not a link or is stale.", + schema: `{"type":"object","properties":{"ref":{"type":"string","description":"A link ref (e.g. e3) from a prior read."}},"required":["ref"]}`, + }, + { + op: "scroll", + description: "Scroll the viewport up or down, or scroll a referenced element into view.", + schema: `{"type":"object","properties":{"direction":{"type":"string","enum":["up","down"],"description":"Scroll direction for the viewport."},"ref":{"type":"string","description":"A ref to scroll into view instead of the viewport."}}}`, + }, + { + op: "back", + description: "Go back one entry in the browser history; re-checks the landed host against the egress policy.", + schema: `{"type":"object","properties":{}}`, + }, + { + op: "wait_for", + description: "Poll the page until text appears (case-insensitive) or a referenced element is present, or until the timeout. The timeout is clamped to [1,30] seconds.", + schema: `{"type":"object","properties":{"text":{"type":"string","description":"Text to wait for."},"ref":{"type":"string","description":"A ref to wait for."},"timeout_seconds":{"type":"integer","description":"Seconds to wait; clamped to [1,30]."}}}`, + }, +} + +// screenshotOp is offered only when the model accepts images. +var screenshotOp = opSpec{ + op: "screenshot", + description: "Capture a PNG screenshot of the viewport, or of a referenced element's bounding box.", + schema: `{"type":"object","properties":{"ref":{"type":"string","description":"A ref to clip the screenshot to."}}}`, +} + +// Synthesize returns the observation tools for one resolved toolbox. +// gateURL is "http://:"; token is the per-session +// browser token; acceptsImages gates the screenshot tool (spec §3.2). All +// returned tools share one chromedp session (lazily connected on first use) and +// one origin. +func Synthesize(rt spiceboxv1alpha1.ResolvedBrowserToolbox, gateURL, token string, acceptsImages bool) ([]agenttool.Tool, error) { + sites := make([]browserpolicy.Site, 0, len(rt.EffectiveSites)) + for _, s := range rt.EffectiveSites { + sites = append(sites, browserpolicy.Site{Host: s.Host, Exact: s.Exact}) + } + + sess := newSession(gateURL, token) + origin := originfmt.ForBrowser(rt) + + ops := make([]opSpec, len(observationOps)) + copy(ops, observationOps) + if acceptsImages { + ops = append(ops, screenshotOp) + } + + // Phase A offers the identical observation set regardless of Interaction. + // Acting tools (click/type/etc.) arrive in Phase C; the act branch exists + // so the shape is explicit, but today it returns the same set. + if rt.Spec.Interaction == "act" { + // Phase C: append acting tools here. For now, same observation set. + _ = rt.Spec.Interaction + } + + tools := make([]agenttool.Tool, 0, len(ops)) + for _, o := range ops { + tools = append(tools, &browserTool{ + name: synthesize.FullName(rt.Name, o.op), + op: o.op, + description: o.description, + schema: json.RawMessage(o.schema), + origin: origin, + sess: sess, + rtName: rt.Name, + mode: rt.Spec.Access.Mode, + sites: sites, + }) + } + return tools, nil +} diff --git a/pkg/agent/tool/browser/tools.go b/pkg/agent/tool/browser/tools.go new file mode 100644 index 00000000..8a3e2451 --- /dev/null +++ b/pkg/agent/tool/browser/tools.go @@ -0,0 +1,626 @@ +package browser + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "strings" + "time" + + "github.com/chromedp/cdproto/cdp" + "github.com/chromedp/cdproto/dom" + "github.com/chromedp/cdproto/page" + "github.com/chromedp/chromedp" + + agenttool "github.com/authzed/openagentprimitives/pkg/agent/tool" + "github.com/authzed/openagentprimitives/pkg/authz" + "github.com/authzed/openagentprimitives/pkg/tools/browserpolicy" +) + +const ( + // maxPageBytes is the read pagination budget per page. + maxPageBytes = 16 * 1024 + // navigateTimeout bounds a navigation (navigate / follow / back). + navigateTimeout = 30 * time.Second + // snapshotTimeout bounds an accessibility snapshot (read / find / screenshot). + snapshotTimeout = 20 * time.Second + // shortTimeout bounds a quick action (scroll, a ref resolve). + shortTimeout = 10 * time.Second + // pollInterval is how often wait_for re-snapshots. + pollInterval = 250 * time.Millisecond + + waitMinTimeout = 1 * time.Second + waitMaxTimeout = 30 * time.Second + waitDefaultTimeout = 30 * time.Second +) + +// browserTool is one observation tool for a resolved BrowserToolbox. Every tool +// of a toolbox shares one *Session and one origin; op selects the behavior. +type browserTool struct { + name string + op string + description string + schema json.RawMessage + origin string + + sess *Session + rtName string // the LLM-facing toolbox Name, embedded in the policy error text + mode string + sites []browserpolicy.Site +} + +var ( + _ agenttool.Tool = (*browserTool)(nil) + _ agenttool.OriginTool = (*browserTool)(nil) + _ agenttool.Cancellable = (*browserTool)(nil) +) + +func (t *browserTool) Name() string { return t.name } +func (t *browserTool) Kind() agenttool.Kind { return agenttool.KindMCP } +func (t *browserTool) Description() string { return t.description } +func (t *browserTool) InputSchema() json.RawMessage { return t.schema } + +// Permission is Passthrough: a browser observation reaches external state but +// maps to no SpiceDB resource, so no per-tool Check is required — the gate's +// egress policy, not SpiceDB, authorizes which hosts the agent may reach. +func (t *browserTool) Permission() authz.Permission { + return authz.Permission{StateImpact: authz.Passthrough} +} +func (t *browserTool) PermissionVariants() []authz.PermissionVariant { return nil } + +// Origin is shared across one toolbox's tools so toolguard's origin-level +// circuit breaker treats them as a single upstream. +func (t *browserTool) Origin() string { return t.origin } + +// Cancel tears down the shared browser connection, interrupting any in-flight +// call and releasing its goroutines; the next tool call reconnects lazily. +func (t *browserTool) Cancel(context.Context) error { + t.sess.Close() + return nil +} + +// Execute parses the tool's flat arguments and dispatches by op. Tool-level +// failures (bad scheme, policy refusal, stale ref) are returned as IsError +// results, never as a Go error — a Go error is reserved for a runner-side +// dispatch fault. +func (t *browserTool) Execute(ctx context.Context, raw json.RawMessage, _ *agenttool.SessionContext) (agenttool.Result, error) { + var a struct { + URL string `json:"url"` + Page int `json:"page"` + Ref string `json:"ref"` + Text string `json:"text"` + Direction string `json:"direction"` + TimeoutSeconds int `json:"timeout_seconds"` + } + if len(strings.TrimSpace(string(raw))) > 0 { + if err := json.Unmarshal(raw, &a); err != nil { + return errResult("invalid arguments: %v", err), nil + } + } + switch t.op { + case "navigate": + if a.URL == "" { + return errResult("navigate requires a url"), nil + } + return t.navigateTo(ctx, a.URL), nil + case "read": + return t.read(ctx, a.Page, a.Ref), nil + case "find": + return t.find(ctx, a.Text), nil + case "screenshot": + return t.screenshot(ctx, a.Ref), nil + case "follow": + return t.follow(ctx, a.Ref), nil + case "scroll": + return t.scroll(ctx, a.Direction, a.Ref), nil + case "back": + return t.back(ctx), nil + case "wait_for": + return t.waitFor(ctx, a.Text, a.Ref, a.TimeoutSeconds), nil + default: + return errResult("unknown browser tool %q", t.op), nil + } +} + +// decide evaluates host against the toolbox's egress policy. +func (t *browserTool) decide(host string) browserpolicy.Decision { + return browserpolicy.Decide(t.mode, t.sites, host) +} + +// policyError renders the verbatim policy-refusal contract text. +func (t *browserTool) policyError(host, rule string) agenttool.Result { + return errResult("host %s is not allowed by browser toolbox %s (rule: %s)", host, t.rtName, rule) +} + +// navigateTo pre-checks scheme + host policy BEFORE touching Chrome, then +// navigates. A proxy-denied load (a redirect to an off-policy host) is reported +// as the policy error rather than the Chrome error page. +func (t *browserTool) navigateTo(ctx context.Context, rawURL string) agenttool.Result { + u, err := url.Parse(rawURL) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") { + return errResult("only http and https URLs can be opened") + } + host := u.Hostname() + if dec := t.decide(host); !dec.Allowed { + return t.policyError(host, dec.Rule) + } + + var out agenttool.Result + runErr := t.sess.do(ctx, navigateTimeout, func(c context.Context) error { + t.sess.resetBlocked() + var navErrText string + if e := runAction(c, func(ac context.Context) error { + _, _, et, _, e := page.Navigate(rawURL).Do(ac) + navErrText = et + return e + }); e != nil { + return e + } + if navErrText != "" { + if isProxyDenial(navErrText) { + // A host in the (possibly redirected) chain was denied by the + // gate's egress proxy. Name the real blocked host when the + // network listener captured it; else fall back to the landed URL. + bh := t.sess.blockedHost() + if bh == "" { + bh = t.landedHost(c, host) + } + rule := t.decide(bh).Rule + if rule == "" { + rule = "blocked by egress policy" + } + out = t.policyError(bh, rule) + return nil + } + out = errResult("navigation failed: %s", navErrText) + return nil + } + finalURL, title := t.locationTitle(c) + t.sess.clearRefs() + out = okResult(fmt.Sprintf("loaded %s — title: %s", finalURL, title)) + return nil + }) + if runErr != nil { + return errResult("navigation failed: %v", runErr) + } + return out +} + +// landedHost best-effort reads the host Chrome ended on after a failed +// navigation, for naming the policy error; falls back to the requested host. +func (t *browserTool) landedHost(c context.Context, fallback string) string { + var loc string + _ = runAction(c, func(ac context.Context) error { return chromedp.Location(&loc).Do(ac) }) + if lu, e := url.Parse(loc); e == nil && lu.Hostname() != "" { + return lu.Hostname() + } + return fallback +} + +// locationTitle reads the current URL and title, ignoring individual errors +// (either may be empty on an odd page without failing the whole navigation). +func (t *browserTool) locationTitle(c context.Context) (string, string) { + var loc, title string + _ = runAction(c, func(ac context.Context) error { + _ = chromedp.Location(&loc).Do(ac) + _ = chromedp.Title(&title).Do(ac) + return nil + }) + return loc, title +} + +func (t *browserTool) read(ctx context.Context, pageNum int, ref string) agenttool.Result { + var out agenttool.Result + runErr := t.sess.do(ctx, snapshotTimeout, func(c context.Context) error { + var startBackend int64 + if ref != "" { + e, ok := t.sess.lookupRef(ref) + if !ok { + out = errResult("ref %s is stale; call read again", ref) + return nil + } + startBackend = e.backendNodeID + } + lines, refs, e := snapshot(c, startBackend) + if e != nil { + return e + } + t.sess.setRefs(refs) + pages := paginate(renderOutline(lines), maxPageBytes) + p := pageNum + if p <= 0 { + p = 1 + } + if p > len(pages) { + out = errResult("page %d is out of range", p) + return nil + } + out = okResult(fmt.Sprintf("%s\n\npage %d of %d", pages[p-1], p, len(pages))) + return nil + }) + if runErr != nil { + return errResult("read failed: %v", runErr) + } + return out +} + +func (t *browserTool) find(ctx context.Context, text string) agenttool.Result { + if strings.TrimSpace(text) == "" { + return errResult("find requires non-empty text") + } + var out agenttool.Result + runErr := t.sess.do(ctx, snapshotTimeout, func(c context.Context) error { + lines, refs, e := snapshot(c, 0) + if e != nil { + return e + } + t.sess.setRefs(refs) + needle := strings.ToLower(text) + matched := make([]string, 0, 50) + for _, ln := range lines { + if strings.Contains(strings.ToLower(ln.name), needle) { + matched = append(matched, fmt.Sprintf("[%s] %s %q", ln.ref, ln.role, ln.name)) + if len(matched) >= 50 { + break + } + } + } + if len(matched) == 0 { + out = okResult(fmt.Sprintf("no matches for %q", text)) + return nil + } + out = okResult(strings.Join(matched, "\n")) + return nil + }) + if runErr != nil { + return errResult("find failed: %v", runErr) + } + return out +} + +func (t *browserTool) follow(ctx context.Context, ref string) agenttool.Result { + if ref == "" { + return errResult("follow requires a ref") + } + var handled *agenttool.Result + var href string + runErr := t.sess.do(ctx, snapshotTimeout, func(c context.Context) error { + e, ok := t.sess.lookupRef(ref) + if !ok { + r := errResult("ref %s is stale; call read again", ref) + handled = &r + return nil + } + if e.role != "link" { + r := errResult("ref %s is not a link", ref) + handled = &r + return nil + } + var node *cdp.Node + if ae := runAction(c, func(ac context.Context) error { + var e2 error + node, e2 = dom.DescribeNode().WithBackendNodeID(cdp.BackendNodeID(e.backendNodeID)).Do(ac) + return e2 + }); ae != nil { + return ae + } + base, _ := t.locationTitle(c) + href = resolveHref(base, attrValue(node, "href")) + return nil + }) + if runErr != nil { + return errResult("follow failed: %v", runErr) + } + if handled != nil { + return *handled + } + if href == "" { + return errResult("ref %s has no link target", ref) + } + return t.navigateTo(ctx, href) +} + +func (t *browserTool) back(ctx context.Context) agenttool.Result { + var out agenttool.Result + runErr := t.sess.do(ctx, navigateTimeout, func(c context.Context) error { + if e := runAction(c, func(ac context.Context) error { return chromedp.NavigateBack().Do(ac) }); e != nil { + return e + } + finalURL, title := t.locationTitle(c) + t.sess.clearRefs() + if lu, e := url.Parse(finalURL); e == nil && lu.Hostname() != "" { + if dec := t.decide(lu.Hostname()); !dec.Allowed { + out = t.policyError(lu.Hostname(), dec.Rule) + return nil + } + } + out = okResult(fmt.Sprintf("loaded %s — title: %s", finalURL, title)) + return nil + }) + if runErr != nil { + return errResult("back failed: %v", runErr) + } + return out +} + +func (t *browserTool) scroll(ctx context.Context, direction, ref string) agenttool.Result { + if ref == "" && direction != "up" && direction != "down" { + return errResult(`scroll requires direction "up" or "down" (or a ref to scroll into view)`) + } + var out agenttool.Result + runErr := t.sess.do(ctx, shortTimeout, func(c context.Context) error { + if ref != "" { + e, ok := t.sess.lookupRef(ref) + if !ok { + out = errResult("ref %s is stale; call read again", ref) + return nil + } + if ae := runAction(c, func(ac context.Context) error { + return dom.ScrollIntoViewIfNeeded().WithBackendNodeID(cdp.BackendNodeID(e.backendNodeID)).Do(ac) + }); ae != nil { + return ae + } + out = okResult(fmt.Sprintf("scrolled ref %s into view", ref)) + return nil + } + delta := "window.innerHeight" + if direction == "up" { + delta = "-window.innerHeight" + } + var ignore int + if ae := runAction(c, func(ac context.Context) error { + return chromedp.Evaluate("window.scrollBy(0, "+delta+"); 0", &ignore).Do(ac) + }); ae != nil { + return ae + } + out = okResult("scrolled " + direction) + return nil + }) + if runErr != nil { + return errResult("scroll failed: %v", runErr) + } + return out +} + +// screenshot captures a PNG (validating ref/page state along the way — the +// box-model lookup above fails loudly on a stale ref) but does NOT return the +// image bytes to the model: agenttool.Result carries only text today, so a +// base64 data-URI would just be an unrenderable, budget-burning text blob. The +// capture is kept — it is still useful as a liveness/validity check — and the +// result is a short text stub instead. acceptsImages (synthesize.go) still +// gates whether this tool is offered at all; that plumbing is correct as-is +// and is exactly what should flip this stub over to real image delivery once +// agenttool.Result grows an image field. +func (t *browserTool) screenshot(ctx context.Context, ref string) agenttool.Result { + var out agenttool.Result + runErr := t.sess.do(ctx, snapshotTimeout, func(c context.Context) error { + var data []byte + if ref != "" { + e, ok := t.sess.lookupRef(ref) + if !ok { + out = errResult("ref %s is stale; call read again", ref) + return nil + } + var box *dom.BoxModel + if ae := runAction(c, func(ac context.Context) error { + var e2 error + box, e2 = dom.GetBoxModel().WithBackendNodeID(cdp.BackendNodeID(e.backendNodeID)).Do(ac) + return e2 + }); ae != nil { + return ae + } + if ae := runAction(c, func(ac context.Context) error { + var e2 error + data, e2 = page.CaptureScreenshot().WithClip(clipFromBox(box)).Do(ac) + return e2 + }); ae != nil { + return ae + } + } else { + if ae := runAction(c, func(ac context.Context) error { + var e2 error + data, e2 = page.CaptureScreenshot().Do(ac) + return e2 + }); ae != nil { + return ae + } + } + out = okResult(fmt.Sprintf("screenshot captured (%d bytes PNG); image delivery to the model is not yet supported", len(data))) + return nil + }) + if runErr != nil { + return errResult("screenshot failed: %v", runErr) + } + return out +} + +func (t *browserTool) waitFor(ctx context.Context, text, ref string, seconds int) agenttool.Result { + if strings.TrimSpace(text) == "" && ref == "" { + return errResult("wait_for requires one of text or ref") + } + d, clamped := clampWaitTimeout(seconds) + suffix := "" + if clamped { + suffix = " (timeout clamped to 30s)" + } + + // Resolve a ref target once up front; a stale ref is refused immediately. + var targetBackend int64 + if ref != "" { + var staleRes *agenttool.Result + if e := t.sess.do(ctx, shortTimeout, func(context.Context) error { + entry, ok := t.sess.lookupRef(ref) + if !ok { + r := errResult("ref %s is stale; call read again", ref) + staleRes = &r + return nil + } + targetBackend = entry.backendNodeID + return nil + }); e != nil { + return errResult("wait_for failed: %v", e) + } + if staleRes != nil { + return *staleRes + } + } + + deadline := time.Now().Add(d) + needle := strings.ToLower(text) + for { + var found bool + if e := t.sess.do(ctx, snapshotTimeout, func(c context.Context) error { + lines, refs, se := snapshot(c, 0) + if se != nil { + return se + } + t.sess.setRefs(refs) + for _, ln := range lines { + if text != "" && strings.Contains(strings.ToLower(ln.name), needle) { + found = true + break + } + if ref != "" && ln.backendNodeID == targetBackend { + found = true + break + } + } + return nil + }); e != nil { + return errResult("wait_for failed: %v", e) + } + if found { + return okResult(fmt.Sprintf("found%s", suffix)) + } + if time.Now().After(deadline) { + return okResult(fmt.Sprintf("not found within %ds%s", int(d.Seconds()), suffix)) + } + select { + case <-ctx.Done(): + return errResult("wait_for cancelled: %v", ctx.Err()) + case <-time.After(pollInterval): + } + } +} + +// clampWaitTimeout clamps a requested wait to [1,30]s. An unset (0) value +// defaults to the 30s maximum and is NOT reported as clamped; anything outside +// the range is clamped and reported so the result can note it. +func clampWaitTimeout(seconds int) (time.Duration, bool) { + if seconds == 0 { + return waitDefaultTimeout, false + } + d := time.Duration(seconds) * time.Second + switch { + case d > waitMaxTimeout: + return waitMaxTimeout, true + case d < waitMinTimeout: + return waitMinTimeout, true + default: + return d, false + } +} + +// paginate splits s into pages no larger than limit bytes, never splitting a +// line. A single over-long line becomes its own page. Always returns at least +// one page. +func paginate(s string, limit int) []string { + if s == "" { + return []string{""} + } + var pages []string + var cur strings.Builder + for _, ln := range strings.Split(s, "\n") { + if cur.Len() > 0 && cur.Len()+len(ln)+1 > limit { + pages = append(pages, cur.String()) + cur.Reset() + } + if cur.Len() > 0 { + cur.WriteByte('\n') + } + cur.WriteString(ln) + } + if cur.Len() > 0 { + pages = append(pages, cur.String()) + } + if len(pages) == 0 { + return []string{""} + } + return pages +} + +// isProxyDenial reports whether a Chrome navigation errorText is a gate/proxy +// refusal (a CONNECT the gate denied, or a proxy error), as opposed to an +// ordinary load failure. +func isProxyDenial(errText string) bool { + return strings.Contains(errText, "ERR_TUNNEL_CONNECTION_FAILED") || strings.Contains(errText, "ERR_PROXY") +} + +// resolveHref resolves a possibly-relative href against the current page URL. +func resolveHref(base, href string) string { + href = strings.TrimSpace(href) + if href == "" { + return "" + } + b, err := url.Parse(base) + if err != nil { + return href + } + r, err := url.Parse(href) + if err != nil { + return href + } + return b.ResolveReference(r).String() +} + +// hostOf returns the hostname of a URL, or "" if it cannot be parsed. +func hostOf(raw string) string { + u, err := url.Parse(raw) + if err != nil { + return "" + } + return u.Hostname() +} + +// attrValue reads an element attribute from a DOM node's flat [name,value,…] +// attribute array; "" if the node is nil or the attribute is absent. +func attrValue(node *cdp.Node, name string) string { + if node == nil { + return "" + } + for i := 0; i+1 < len(node.Attributes); i += 2 { + if node.Attributes[i] == name { + return node.Attributes[i+1] + } + } + return "" +} + +// clipFromBox converts a DOM content box quad into a screenshot clip viewport. +func clipFromBox(box *dom.BoxModel) *page.Viewport { + if box == nil || len(box.Content) < 8 { + return &page.Viewport{Scale: 1} + } + q := box.Content + minX, minY := q[0], q[1] + maxX, maxY := q[0], q[1] + for i := 0; i+1 < len(q); i += 2 { + minX, maxX = min(minX, q[i]), max(maxX, q[i]) + minY, maxY = min(minY, q[i+1]), max(maxY, q[i+1]) + } + return &page.Viewport{X: minX, Y: minY, Width: maxX - minX, Height: maxY - minY, Scale: 1} +} + +func errResult(format string, a ...any) agenttool.Result { + return agenttool.Result{Content: fmt.Sprintf(format, a...), IsError: true} +} + +func okResult(content string) agenttool.Result { + return agenttool.Result{Content: content} +} + +// runAction runs a single cdproto action against the chromedp-bound context. +func runAction(ctx context.Context, fn func(context.Context) error) error { + return chromedp.Run(ctx, chromedp.ActionFunc(fn)) +} diff --git a/pkg/agent/tool/browser/tools_test.go b/pkg/agent/tool/browser/tools_test.go new file mode 100644 index 00000000..d1972916 --- /dev/null +++ b/pkg/agent/tool/browser/tools_test.go @@ -0,0 +1,87 @@ +package browser + +import ( + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + spiceboxv1alpha1 "github.com/authzed/openagentprimitives/pkg/apis/v1alpha1" +) + +func TestWaitForClampsTimeout(t *testing.T) { + cases := []struct { + name string + seconds int + wantDur time.Duration + wantClamped bool + }{ + {name: "huge value clamps to 30s", seconds: 3600, wantDur: 30 * time.Second, wantClamped: true}, + {name: "above-max clamps to 30s", seconds: 31, wantDur: 30 * time.Second, wantClamped: true}, + {name: "in-range passes through unclamped", seconds: 5, wantDur: 5 * time.Second, wantClamped: false}, + {name: "exactly max is not clamped", seconds: 30, wantDur: 30 * time.Second, wantClamped: false}, + {name: "exactly min is not clamped", seconds: 1, wantDur: 1 * time.Second, wantClamped: false}, + {name: "negative clamps up to 1s", seconds: -5, wantDur: 1 * time.Second, wantClamped: true}, + {name: "unset (0) defaults to 30s, not reported as clamped", seconds: 0, wantDur: 30 * time.Second, wantClamped: false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + d, clamped := clampWaitTimeout(tc.seconds) + assert.Equal(t, tc.wantDur, d) + assert.Equal(t, tc.wantClamped, clamped) + assert.LessOrEqual(t, d, 30*time.Second, "effective deadline must never exceed 30s") + }) + } +} + +func TestScreenshotOfferedOnlyWithImages(t *testing.T) { + rt := spiceboxv1alpha1.ResolvedBrowserToolbox{ + Name: "demo", + Ref: "demo-browser", + Spec: spiceboxv1alpha1.BrowserToolboxSpec{ + Access: spiceboxv1alpha1.BrowserAccess{Mode: "blocklist"}, + Interaction: "readonly", + State: "ephemeral", + }, + } + + hasScreenshot := func(acceptsImages bool) bool { + tools, err := Synthesize(rt, "http://127.0.0.1:1", "tok", acceptsImages) + if err != nil { + t.Fatalf("Synthesize: %v", err) + } + for _, tl := range tools { + if strings.HasSuffix(tl.Name(), "_screenshot") { + return true + } + } + return false + } + + assert.False(t, hasScreenshot(false), "screenshot must be absent when the model does not accept images") + assert.True(t, hasScreenshot(true), "screenshot must be offered when the model accepts images") +} + +// TestInteractionActOffersSameSetInPhaseA proves the act/readonly branch offers +// the identical observation set in Phase A (acting tools arrive in Phase C). +func TestInteractionActOffersSameSetInPhaseA(t *testing.T) { + base := spiceboxv1alpha1.ResolvedBrowserToolbox{ + Name: "demo", + Ref: "demo-browser", + Spec: spiceboxv1alpha1.BrowserToolboxSpec{ + Access: spiceboxv1alpha1.BrowserAccess{Mode: "blocklist"}, + State: "ephemeral", + }, + } + readonly := base + readonly.Spec.Interaction = "readonly" + act := base + act.Spec.Interaction = "act" + + ro, err := Synthesize(readonly, "http://127.0.0.1:1", "tok", true) + assert.NoError(t, err) + ac, err := Synthesize(act, "http://127.0.0.1:1", "tok", true) + assert.NoError(t, err) + assert.Equal(t, toolNames(ro), toolNames(ac), "act and readonly offer the same set in Phase A") +} diff --git a/pkg/agent/tool/originfmt/originfmt.go b/pkg/agent/tool/originfmt/originfmt.go index e408c38e..ecb08fe7 100644 --- a/pkg/agent/tool/originfmt/originfmt.go +++ b/pkg/agent/tool/originfmt/originfmt.go @@ -39,6 +39,9 @@ const ( // KindSidecarToolbox is the origin kind of a tool served by a // SidecarToolbox CR's sidecar MCP server. KindSidecarToolbox = "sidecartoolbox" + // KindBrowserToolbox is the origin kind of a tool served by a + // BrowserToolbox CR's per-session browser (reached through the gate). + KindBrowserToolbox = "browsertoolbox" ) // The three composers below are the ONLY places a caller should build an origin @@ -90,3 +93,15 @@ func ForSidecar(rt spiceboxv1alpha1.ResolvedSidecarToolbox) string { } return KindSidecarToolbox + "/" + rt.Ref } + +// ForBrowser mirrors browser.browserTool's Origin(). Like ForSidecar it takes +// the whole resolved entry so the call site cannot pick Name (the LLM-facing +// prefix) where Ref (the BrowserToolbox CR name, which Origin() uses) is meant. +// All of one toolbox's tools share this origin so toolguard's circuit breaker +// treats them as one origin. +func ForBrowser(rt spiceboxv1alpha1.ResolvedBrowserToolbox) string { + if rt.Ref == "" { + return "" + } + return KindBrowserToolbox + "/" + rt.Ref +} diff --git a/pkg/agent/tool/originfmt/originfmt_test.go b/pkg/agent/tool/originfmt/originfmt_test.go new file mode 100644 index 00000000..dcc9b3c7 --- /dev/null +++ b/pkg/agent/tool/originfmt/originfmt_test.go @@ -0,0 +1,23 @@ +package originfmt_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/authzed/openagentprimitives/pkg/agent/tool/originfmt" + spiceboxv1alpha1 "github.com/authzed/openagentprimitives/pkg/apis/v1alpha1" +) + +func TestForBrowser(t *testing.T) { + t.Run("uses Ref (the CR name), not Name (the LLM prefix)", func(t *testing.T) { + rt := spiceboxv1alpha1.ResolvedBrowserToolbox{Name: "demo", Ref: "demo-browser"} + assert.Equal(t, "browsertoolbox/demo-browser", originfmt.ForBrowser(rt)) + }) + t.Run("empty Ref yields empty string, mirroring Origin()'s empty case", func(t *testing.T) { + assert.Equal(t, "", originfmt.ForBrowser(spiceboxv1alpha1.ResolvedBrowserToolbox{Name: "demo"})) + }) + t.Run("kind constant is browsertoolbox", func(t *testing.T) { + assert.Equal(t, "browsertoolbox", originfmt.KindBrowserToolbox) + }) +} diff --git a/pkg/apis/v1alpha1/agentclass_types.go b/pkg/apis/v1alpha1/agentclass_types.go index 763a8956..f3534241 100644 --- a/pkg/apis/v1alpha1/agentclass_types.go +++ b/pkg/apis/v1alpha1/agentclass_types.go @@ -150,6 +150,12 @@ type AgentClassSpec struct { // +optional SidecarToolboxes []AgentClassSidecarToolboxRef `json:"sidecarToolboxes,omitempty"` + // BrowserToolboxes references BrowserToolbox CRs in the same namespace + // whose per-session browser each session gets. Each ref's Name is the + // LLM-facing prefix; pattern [a-z0-9_-]{1,32}. + // +optional + BrowserToolboxes []AgentClassBrowserToolboxRef `json:"browserToolboxes,omitempty"` + // WorkspaceSource optionally binds a single WorkspaceSource whose // materialized base seeds each session's /workspace as a per-session // copy-on-write overlay. The referenced CR must be Valid. A single source @@ -1002,6 +1008,15 @@ type AgentClassSidecarToolboxRef struct { Ref string `json:"ref"` } +// AgentClassBrowserToolboxRef references a BrowserToolbox CR in the same namespace. +type AgentClassBrowserToolboxRef struct { + // Name is the LLM-visible tool prefix; pattern [a-z0-9_-]{1,32}. + // +kubebuilder:validation:Pattern=`^[a-z0-9_-]{1,32}$` + Name string `json:"name"` + // Ref names the BrowserToolbox CR in the same namespace. + Ref string `json:"ref"` +} + // AgentClassWorkspaceSourceRef references a WorkspaceSource CR in the same namespace. type AgentClassWorkspaceSourceRef struct { // Ref names the WorkspaceSource CR in the AgentClass's namespace. diff --git a/pkg/apis/v1alpha1/agentsession_ownership.go b/pkg/apis/v1alpha1/agentsession_ownership.go index cc4dc489..30045854 100644 --- a/pkg/apis/v1alpha1/agentsession_ownership.go +++ b/pkg/apis/v1alpha1/agentsession_ownership.go @@ -84,12 +84,15 @@ var operatorOwnedStatusFields = map[string]bool{ // own name, get/watch/patch). The runner reads the answer off its own // object. Same shape as every other derived fact here — the party that can // see it writes it, the party that needs it reads it. - "closureDenied": true, - "runnerPodName": true, - "runnerRestarts": true, - "retryAttempts": true, - "bundleSessions": true, - "resolvedSidecarToolboxes": true, + "closureDenied": true, + "runnerPodName": true, + "runnerRestarts": true, + "retryAttempts": true, + "bundleSessions": true, + "resolvedSidecarToolboxes": true, + // resolvedBrowserToolboxes is the operator's per-session BrowserToolbox + // snapshot, written at session start exactly like its sidecar sibling above. + "resolvedBrowserToolboxes": true, "resolvedWorkspaceSource": true, "resolvedContentGuardDetectors": true, "resolvedSkillBundles": true, diff --git a/pkg/apis/v1alpha1/agentsession_types.go b/pkg/apis/v1alpha1/agentsession_types.go index 0191a853..4fda1132 100644 --- a/pkg/apis/v1alpha1/agentsession_types.go +++ b/pkg/apis/v1alpha1/agentsession_types.go @@ -185,6 +185,13 @@ type AgentSessionStatus struct { // +optional ResolvedSidecarToolboxes []ResolvedSidecarToolbox `json:"resolvedSidecarToolboxes,omitempty"` + // ResolvedBrowserToolboxes snapshots each referenced BrowserToolbox at + // session start, frozen for the session lifetime exactly like + // ResolvedSidecarToolboxes. Empty when AgentClass.spec.browserToolboxes + // is empty. + // +optional + ResolvedBrowserToolboxes []ResolvedBrowserToolbox `json:"resolvedBrowserToolboxes,omitempty"` + // ResolvedWorkspaceSource snapshots the bound WorkspaceSource for a running // session: the ref, the base PVC the overlay was cut from, and whether the // cut completed. Frozen at session start (like ResolvedSidecarToolboxes). @@ -774,6 +781,67 @@ type ResolvedSidecarToolbox struct { PodFailure *SidecarPodFailure `json:"podFailure,omitempty"` } +// BrowserEffectiveSite is one resolved site entry in a ResolvedBrowserToolbox's +// EffectiveSites. Source records where the entry came from: Phase A only ever +// writes "explicit" (a BrowserAccess.Sites entry carried through verbatim); +// "identity" is a Phase B addition once a bound identity can contribute its +// own sites to the effective set. +type BrowserEffectiveSite struct { + // Host is the resolved hostname, matched exactly as BrowserSite.Host. + Host string `json:"host"` + // Exact mirrors BrowserSite.Exact for this entry. + // +optional + Exact bool `json:"exact,omitempty"` + // Source is where this entry came from. "explicit" only in Phase A; + // "identity" arrives in Phase B. + Source string `json:"source"` +} + +// ResolvedBrowserToolbox snapshots one BrowserToolbox CR for a running +// AgentSession, mirroring ResolvedSidecarToolbox's shape: the LLM-facing +// prefix, the frozen spec, and the operator-allocated pod/gate the runner +// uses to reach the per-session browser. +type ResolvedBrowserToolbox struct { + // Name is the LLM-facing prefix (the `name` field from + // AgentClassBrowserToolboxRef). + Name string `json:"name"` + + // Ref is the BrowserToolbox CR name (the `ref` field from + // AgentClassBrowserToolboxRef). + Ref string `json:"ref"` + + // Spec is the BrowserToolbox spec snapshot at session-start time. + Spec BrowserToolboxSpec `json:"spec"` + + // EffectiveSites is the resolved site list the running browser's egress + // is actually evaluated against -- in Phase A, a straight carry-through + // of Spec.Access.Sites with Source "explicit". + // +optional + EffectiveSites []BrowserEffectiveSite `json:"effectiveSites,omitempty"` + + // BrowserPodName is the per-session browser pod's name. + // +optional + BrowserPodName string `json:"browserPodName,omitempty"` + // BrowserPodIP is the per-session browser pod's IP, reflected once Ready; + // the runner reaches its gate at http://:. + // +optional + BrowserPodIP string `json:"browserPodIP,omitempty"` + // GatePort is the operator-allocated port the runner uses to reach the + // browser pod's gate process. + // +optional + GatePort int32 `json:"gatePort,omitempty"` + // TokenHash is a hash of the resolved per-session gate token; a change + // triggers pod replacement. + // +optional + TokenHash string `json:"tokenHash,omitempty"` + + // PodFailure records a TERMINAL container state on the browser pod + // (CrashLoopBackOff, ImagePullBackOff) -- reuses SidecarPodFailure's shape + // exactly; see that type's doc comment for why it carries no timestamp. + // +optional + PodFailure *SidecarPodFailure `json:"podFailure,omitempty"` +} + // SidecarPodFailure is the operator's record of why a separate-pod sidecar // will not become Ready. The runner reads it to tell the agent and the user // that a promised toolset is unavailable, and why, instead of proceeding diff --git a/pkg/apis/v1alpha1/browsertoolbox_types.go b/pkg/apis/v1alpha1/browsertoolbox_types.go new file mode 100644 index 00000000..36abfb51 --- /dev/null +++ b/pkg/apis/v1alpha1/browsertoolbox_types.go @@ -0,0 +1,128 @@ +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// +kubebuilder:object:root=true +// +kubebuilder:resource:scope=Namespaced,categories={authzed,spicebox},shortName=btb +// +kubebuilder:subresource:status +// +kubebuilder:printcolumn:name="Mode",type=string,JSONPath=`.spec.access.mode` +// +kubebuilder:printcolumn:name="Interaction",type=string,JSONPath=`.spec.interaction` +// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` +// +genclient +// +// BrowserToolbox declares a per-session browser the agent can drive: which +// sites it may reach and whether the LLM may act on the page or only read it. +// +// Namespaced. An AgentClass references it via AgentClassBrowserToolboxRef, +// and the AgentSession reconciler snapshots the referenced spec into +// status.resolvedBrowserToolboxes at session start (see +// ResolvedBrowserToolbox), frozen for the session's lifetime exactly like a +// SidecarToolbox. +// +// Phase A deliberately ships a narrow field subset: Access covers only an +// explicit allowlist/blocklist of sites (Task 1's pkg/tools/browserpolicy +// decides admission from it), Interaction is act/readonly, and State is +// pinned to "ephemeral" -- there is no persistent browser profile/identity +// yet. Those arrive in later phases (identity-sourced sites in Phase B, +// persistent state in Phase C) as additive fields, not a breaking change to +// this spec. +type BrowserToolbox struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec BrowserToolboxSpec `json:"spec,omitempty"` + Status BrowserToolboxStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true +// +// BrowserToolboxList is a list of BrowserToolbox. +type BrowserToolboxList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []BrowserToolbox `json:"items"` +} + +// BrowserToolboxSpec is deliberately not annotated with a CEL XValidation +// rule: this type is embedded by value into AgentSession's +// status.resolvedBrowserToolboxes snapshot (see ResolvedBrowserToolbox.Spec), +// and a CEL rule reachable from a status path both trips the status-CEL guard +// and risks the apiserver's CRD rule-cost budget across that unbounded status +// array -- the same reasoning SidecarToolboxSpec documents. Field-level +// markers (Enum, Pattern, default) are the validation surface here. +type BrowserToolboxSpec struct { + // Access is the site allowlist/blocklist the browser's egress is + // evaluated against. + Access BrowserAccess `json:"access"` + + // Interaction gates whether the LLM may act on the page (click, type, + // navigate) or only read its rendered content. + // act -- full interaction. + // readonly -- observation only; no mutating actions are dispatched. + // +kubebuilder:validation:Enum=act;readonly + // +kubebuilder:default=act + Interaction string `json:"interaction,omitempty"` + + // State selects how the browser's session state (cookies, storage) is + // retained across turns. + // Phase A: ephemeral is the only state; persistent arrives with identities (Phase C). + // +kubebuilder:validation:Enum=ephemeral + // +kubebuilder:default=ephemeral + State string `json:"state,omitempty"` +} + +// BrowserAccess is the egress policy a BrowserToolbox's browser is evaluated +// against. Mode selects allowlist or blocklist semantics (literal values +// agree with pkg/tools/browserpolicy.ModeAllowlist/ModeBlocklist); Sites is +// the list Mode interprets. +type BrowserAccess struct { + // Mode is "allowlist" (only Sites are reachable) or "blocklist" (every + // site except Sites is reachable). + // +kubebuilder:validation:Enum=allowlist;blocklist + Mode string `json:"mode"` + + // Sites is the set of hosts Mode interprets. Matching is suffix-by-label + // unless Exact is set: a Site with Host "example.com" also matches + // "www.example.com" and "a.b.example.com", but never a lookalike like + // "notexample.com" -- pkg/tools/browserpolicy.Match does label-boundary + // comparison, not a raw string suffix check. Exact pins the entry to the + // literal host only. + // +optional + // +listType=map + // +listMapKey=host + Sites []BrowserSite `json:"sites,omitempty"` +} + +// BrowserSite is one host entry in a BrowserAccess list. +type BrowserSite struct { + // Host is the DNS hostname this entry covers, matched suffix-by-label + // (see BrowserAccess.Sites) unless Exact is set. + // +kubebuilder:validation:Pattern=`^[a-z0-9]([a-z0-9-]{0,62}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,62}[a-z0-9])?)*$` + // +kubebuilder:validation:MaxLength=253 + Host string `json:"host"` + + // Exact pins this entry to the literal Host only, disabling the default + // suffix-by-label subdomain match. + // +optional + Exact bool `json:"exact,omitempty"` +} + +// BrowserToolboxStatus is intentionally minimal in Phase A: a condition set +// and the generation it reflects. There is no admission-time probe the way +// SidecarToolbox has one -- a BrowserToolbox has no image to reach; its own +// validity is purely a function of its spec. +type BrowserToolboxStatus struct { + // ObservedGeneration is the spec generation this status reflects. + // +optional + ObservedGeneration int64 `json:"observedGeneration,omitempty"` + + // Conditions carries this BrowserToolbox's Valid condition. + // +optional + Conditions []metav1.Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type"` +} + +func init() { + SchemeBuilder.Register(&BrowserToolbox{}, &BrowserToolboxList{}) +} diff --git a/pkg/apis/v1alpha1/conditions.go b/pkg/apis/v1alpha1/conditions.go index 8f91ee88..44258f5b 100644 --- a/pkg/apis/v1alpha1/conditions.go +++ b/pkg/apis/v1alpha1/conditions.go @@ -1563,6 +1563,12 @@ const ( ReasonAgentClassSidecarToolboxInvalid = "AgentClassSidecarToolboxInvalid" ) +// AgentClass condition reasons specific to BrowserToolbox refs. +const ( + ReasonAgentClassBrowserToolboxMissing = "AgentClassBrowserToolboxMissing" + ReasonAgentClassBrowserToolboxInvalid = "AgentClassBrowserToolboxInvalid" +) + // SidecarToolbox reasons (Valid / Reachable conditions). const ( ReasonSidecarToolboxSpecOK = "SpecOK" @@ -1594,6 +1600,7 @@ const ( ReasonAgentSessionSidecarToolboxMissing = "SidecarToolboxMissing" ReasonAgentSessionSidecarToolboxInvalid = "SidecarToolboxInvalid" ReasonAgentSessionSidecarBootFailed = "SidecarBootFailed" + ReasonAgentSessionBrowserBootFailed = "BrowserToolboxBootFailed" // ReasonAgentSessionImagePinDrifted is set on AgentSessionConditionSettingsAccepted // (False) when a SidecarToolbox's image has drifted from its baseline digest // and the cluster/namespace pinning policy mode is "block". The session is diff --git a/pkg/apis/v1alpha1/zz_generated.deepcopy.go b/pkg/apis/v1alpha1/zz_generated.deepcopy.go index dcb66ad0..0f97055a 100644 --- a/pkg/apis/v1alpha1/zz_generated.deepcopy.go +++ b/pkg/apis/v1alpha1/zz_generated.deepcopy.go @@ -40,6 +40,21 @@ func (in *AgentClass) DeepCopyObject() runtime.Object { return nil } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AgentClassBrowserToolboxRef) DeepCopyInto(out *AgentClassBrowserToolboxRef) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AgentClassBrowserToolboxRef. +func (in *AgentClassBrowserToolboxRef) DeepCopy() *AgentClassBrowserToolboxRef { + if in == nil { + return nil + } + out := new(AgentClassBrowserToolboxRef) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *AgentClassList) DeepCopyInto(out *AgentClassList) { *out = *in @@ -157,6 +172,11 @@ func (in *AgentClassSpec) DeepCopyInto(out *AgentClassSpec) { *out = make([]AgentClassSidecarToolboxRef, len(*in)) copy(*out, *in) } + if in.BrowserToolboxes != nil { + in, out := &in.BrowserToolboxes, &out.BrowserToolboxes + *out = make([]AgentClassBrowserToolboxRef, len(*in)) + copy(*out, *in) + } if in.WorkspaceSource != nil { in, out := &in.WorkspaceSource, &out.WorkspaceSource *out = new(AgentClassWorkspaceSourceRef) @@ -858,6 +878,13 @@ func (in *AgentSessionStatus) DeepCopyInto(out *AgentSessionStatus) { (*in)[i].DeepCopyInto(&(*out)[i]) } } + if in.ResolvedBrowserToolboxes != nil { + in, out := &in.ResolvedBrowserToolboxes, &out.ResolvedBrowserToolboxes + *out = make([]ResolvedBrowserToolbox, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } if in.ResolvedWorkspaceSource != nil { in, out := &in.ResolvedWorkspaceSource, &out.ResolvedWorkspaceSource *out = new(ResolvedWorkspaceSource) @@ -1694,6 +1721,153 @@ func (in *BreakerSpec) DeepCopy() *BreakerSpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BrowserAccess) DeepCopyInto(out *BrowserAccess) { + *out = *in + if in.Sites != nil { + in, out := &in.Sites, &out.Sites + *out = make([]BrowserSite, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BrowserAccess. +func (in *BrowserAccess) DeepCopy() *BrowserAccess { + if in == nil { + return nil + } + out := new(BrowserAccess) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BrowserEffectiveSite) DeepCopyInto(out *BrowserEffectiveSite) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BrowserEffectiveSite. +func (in *BrowserEffectiveSite) DeepCopy() *BrowserEffectiveSite { + if in == nil { + return nil + } + out := new(BrowserEffectiveSite) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BrowserSite) DeepCopyInto(out *BrowserSite) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BrowserSite. +func (in *BrowserSite) DeepCopy() *BrowserSite { + if in == nil { + return nil + } + out := new(BrowserSite) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BrowserToolbox) DeepCopyInto(out *BrowserToolbox) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BrowserToolbox. +func (in *BrowserToolbox) DeepCopy() *BrowserToolbox { + if in == nil { + return nil + } + out := new(BrowserToolbox) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *BrowserToolbox) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BrowserToolboxList) DeepCopyInto(out *BrowserToolboxList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]BrowserToolbox, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BrowserToolboxList. +func (in *BrowserToolboxList) DeepCopy() *BrowserToolboxList { + if in == nil { + return nil + } + out := new(BrowserToolboxList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *BrowserToolboxList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BrowserToolboxSpec) DeepCopyInto(out *BrowserToolboxSpec) { + *out = *in + in.Access.DeepCopyInto(&out.Access) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BrowserToolboxSpec. +func (in *BrowserToolboxSpec) DeepCopy() *BrowserToolboxSpec { + if in == nil { + return nil + } + out := new(BrowserToolboxSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BrowserToolboxStatus) DeepCopyInto(out *BrowserToolboxStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BrowserToolboxStatus. +func (in *BrowserToolboxStatus) DeepCopy() *BrowserToolboxStatus { + if in == nil { + return nil + } + out := new(BrowserToolboxStatus) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *BudgetConfig) DeepCopyInto(out *BudgetConfig) { *out = *in @@ -4946,6 +5120,32 @@ func (in *RelationshipSourceSyncStatus) DeepCopy() *RelationshipSourceSyncStatus return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ResolvedBrowserToolbox) DeepCopyInto(out *ResolvedBrowserToolbox) { + *out = *in + in.Spec.DeepCopyInto(&out.Spec) + if in.EffectiveSites != nil { + in, out := &in.EffectiveSites, &out.EffectiveSites + *out = make([]BrowserEffectiveSite, len(*in)) + copy(*out, *in) + } + if in.PodFailure != nil { + in, out := &in.PodFailure, &out.PodFailure + *out = new(SidecarPodFailure) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ResolvedBrowserToolbox. +func (in *ResolvedBrowserToolbox) DeepCopy() *ResolvedBrowserToolbox { + if in == nil { + return nil + } + out := new(ResolvedBrowserToolbox) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ResolvedBundle) DeepCopyInto(out *ResolvedBundle) { *out = *in diff --git a/pkg/controllers/agentclass/browsertoolbox_validate_test.go b/pkg/controllers/agentclass/browsertoolbox_validate_test.go new file mode 100644 index 00000000..7db0c119 --- /dev/null +++ b/pkg/controllers/agentclass/browsertoolbox_validate_test.go @@ -0,0 +1,83 @@ +//go:build integration + +// pkg/controllers/agentclass/browsertoolbox_validate_test.go +// +// Phase A validation of AgentClass.spec.browserToolboxes: existence-only. +// Unlike validateSidecarToolboxes, this does NOT read the BrowserToolbox's +// own Valid condition -- Phase A has no BrowserToolbox reconciler to stamp +// one (it arrives in Phase B), and the CRD's field-level markers mean the +// API server already rejects a structurally-invalid BrowserToolbox. +package agentclass_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + + spiceboxv1alpha1 "github.com/authzed/openagentprimitives/pkg/apis/v1alpha1" + "github.com/authzed/openagentprimitives/pkg/controllers/agentclass" + "github.com/authzed/openagentprimitives/pkg/controllers/testenv" +) + +// TestAgentClassBrowserToolbox covers the two outcomes of Phase A browser +// toolbox validation: a class referencing a BrowserToolbox that does not +// exist lands Valid=False/AgentClassBrowserToolboxMissing, and creating the +// referenced BrowserToolbox then re-reconciling flips the class to +// Valid=True. +func TestAgentClassBrowserToolbox(t *testing.T) { + env := testenv.Shared(t) + ctx := context.Background() + r := &agentclass.Reconciler{Client: env.Client, APIReader: env.Client} + + require.NoError(t, env.Client.Create(ctx, &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "llm-creds", Namespace: "default"}, + Data: map[string][]byte{"api-key": []byte("sk")}, + }), "create llm-creds") + + ac := newClass("ac-btb-missing") + ac.Spec.BrowserToolboxes = []spiceboxv1alpha1.AgentClassBrowserToolboxRef{ + {Name: "web", Ref: "missing-btb"}, + } + require.NoError(t, env.Client.Create(ctx, ac), "create AgentClass") + + _, err := r.Reconcile(ctx, ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "default", Name: "ac-btb-missing"}}) + require.NoError(t, err, "Reconcile") + + var got spiceboxv1alpha1.AgentClass + require.NoError(t, env.Client.Get(ctx, types.NamespacedName{Namespace: "default", Name: "ac-btb-missing"}, &got), + "Get AgentClass") + cond := findCondition(got.Status.Conditions, spiceboxv1alpha1.AgentClassConditionValid) + require.NotNil(t, cond, "Valid condition") + assert.Equal(t, metav1.ConditionFalse, cond.Status, "Valid status") + assert.Equal(t, spiceboxv1alpha1.ReasonAgentClassBrowserToolboxMissing, cond.Reason, "Valid reason") + assert.Contains(t, cond.Message, "missing-btb", "message should name the missing ref") + + // Now create the referenced BrowserToolbox and reconcile again -- the + // class must flip to Valid=True without needing a Valid condition on + // the BrowserToolbox itself (Phase A is existence-only). + btb := &spiceboxv1alpha1.BrowserToolbox{ + ObjectMeta: metav1.ObjectMeta{Name: "missing-btb", Namespace: "default"}, + Spec: spiceboxv1alpha1.BrowserToolboxSpec{ + Access: spiceboxv1alpha1.BrowserAccess{ + Mode: "allowlist", + Sites: []spiceboxv1alpha1.BrowserSite{{Host: "example.com"}}, + }, + }, + } + require.NoError(t, env.Client.Create(ctx, btb), "create BrowserToolbox") + + _, err = r.Reconcile(ctx, ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "default", Name: "ac-btb-missing"}}) + require.NoError(t, err, "Reconcile after BrowserToolbox created") + + require.NoError(t, env.Client.Get(ctx, types.NamespacedName{Namespace: "default", Name: "ac-btb-missing"}, &got), + "Get AgentClass after BrowserToolbox created") + assert.True(t, + hasCondition(got.Status.Conditions, spiceboxv1alpha1.AgentClassConditionValid, metav1.ConditionTrue), + "want Valid=True; got conditions=%+v", got.Status.Conditions) +} diff --git a/pkg/controllers/agentclass/controller.go b/pkg/controllers/agentclass/controller.go index 468a27d6..317d9ad9 100644 --- a/pkg/controllers/agentclass/controller.go +++ b/pkg/controllers/agentclass/controller.go @@ -71,6 +71,7 @@ const schemaMismatchRetry = 5 * time.Second // +kubebuilder:rbac:groups=agentprimitives.authzed.com,resources=spiceboxclasses,verbs=get;list;watch // +kubebuilder:rbac:groups=agentprimitives.authzed.com,resources=spiceboxtoolkits,verbs=get;list;watch // +kubebuilder:rbac:groups=agentprimitives.authzed.com,resources=sidecartoolboxes,verbs=get;list;watch +// +kubebuilder:rbac:groups=agentprimitives.authzed.com,resources=browsertoolboxes,verbs=get;list;watch // +kubebuilder:rbac:groups=agentprimitives.authzed.com,resources=workspacesources,verbs=get;list;watch // +kubebuilder:rbac:groups=agentprimitives.authzed.com,resources=agentuis,verbs=get;list;watch // +kubebuilder:rbac:groups=agentprimitives.authzed.com,resources=skills,verbs=get;list;watch @@ -385,6 +386,32 @@ func (r *Reconciler) SetupWithManager(mgr ctrl.Manager) error { } return out } + // Re-enqueue any AgentClass whose browserToolboxes[].ref names the + // changed BrowserToolbox. Phase A's validateBrowserToolboxes is + // existence-only (no Valid-condition read), so the race this closes is + // narrower than mapSidecarToolboxToClasses' -- a class parked at + // Valid=False reason=AgentClassBrowserToolboxMissing must re-reconcile + // once the toolbox is created, same install-ordering race as every other + // referenced CR. Namespaced, so list in its namespace only. + mapBrowserToolboxToClasses := func(ctx context.Context, o client.Object) []reconcile.Request { + var list spiceboxv1alpha1.AgentClassList + if err := r.Client.List(ctx, &list, client.InNamespace(o.GetNamespace())); err != nil { + log.FromContext(ctx).Info("list AgentClasses for BrowserToolbox watch failed; dropping re-enqueue (self-heals on next resync)", + "browsertoolbox", o.GetName(), "namespace", o.GetNamespace(), "err", err.Error()) + return nil + } + var out []reconcile.Request + for i := range list.Items { + ac := &list.Items[i] + for _, ref := range ac.Spec.BrowserToolboxes { + if ref.Ref == o.GetName() { + out = append(out, reconcile.Request{NamespacedName: client.ObjectKeyFromObject(ac)}) + break + } + } + } + return out + } // Re-enqueue any AgentClass whose spec.workspaceSource.ref names the // changed WorkspaceSource — validateWorkspaceSource reads the source's // Valid condition, so a class parked at Valid=False @@ -553,6 +580,7 @@ func (r *Reconciler) SetupWithManager(mgr ctrl.Manager) error { Watches(&spiceboxv1alpha1.SpiceboxClass{}, handler.EnqueueRequestsFromMapFunc(mapSpiceboxClassToClasses)). Watches(&spiceboxv1alpha1.SpiceboxToolkit{}, handler.EnqueueRequestsFromMapFunc(mapToolkitToClasses)). Watches(&spiceboxv1alpha1.SidecarToolbox{}, handler.EnqueueRequestsFromMapFunc(mapSidecarToolboxToClasses)). + Watches(&spiceboxv1alpha1.BrowserToolbox{}, handler.EnqueueRequestsFromMapFunc(mapBrowserToolboxToClasses)). Watches(&spiceboxv1alpha1.WorkspaceSource{}, handler.EnqueueRequestsFromMapFunc(mapWorkspaceSourceToClasses)). Watches(&spiceboxv1alpha1.AgentUI{}, handler.EnqueueRequestsFromMapFunc(mapAgentUIToClasses)). Watches(&spiceboxv1alpha1.Skill{}, handler.EnqueueRequestsFromMapFunc(mapSkillToClasses)). @@ -803,6 +831,15 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu return ctrl.Result{}, fmt.Errorf("gather sidecarToolboxes: %w", stbErr) } + // Validate browserToolboxes (Phase A of the browser toolbox primitive): + // existence-only, same shape as sidecarToolboxes above but with no + // Valid-condition read -- see validateBrowserToolboxes' doc comment. + if len(ac.Spec.BrowserToolboxes) > 0 { + if reason, msg := validateBrowserToolboxes(ctx, reader, ac.Namespace, ac.Spec.BrowserToolboxes); reason != "" { + return r.setInvalid(ctx, &ac, reason, msg) + } + } + // Slice-2 toolkit-subcommand walk: only fires in enforcing mode. // Resolved regardless of whether MCPServers are present so toolBundle- // only AgentClasses get the same strictness. @@ -1771,6 +1808,35 @@ func validateSidecarToolboxes( return "", "" } +// validateBrowserToolboxes checks that each BrowserToolbox a class +// references by name exists in the class's namespace. Phase A deliberately +// stops at existence: there is no BrowserToolbox reconciler yet to stamp a +// Valid condition (that gate, and the identity cross-references it needs, +// arrive in Phase B), and the CRD's field-level markers mean the API server +// already refuses a structurally-invalid BrowserToolbox -- so unlike +// validateSidecarToolboxes, this never reads the referenced object's status. +func validateBrowserToolboxes( + ctx context.Context, + reader client.Reader, + namespace string, + refs []spiceboxv1alpha1.AgentClassBrowserToolboxRef, +) (string, string) { + for _, ref := range refs { + var btb spiceboxv1alpha1.BrowserToolbox + err := reader.Get(ctx, types.NamespacedName{Namespace: namespace, Name: ref.Ref}, &btb) + if errors.IsNotFound(err) { + return spiceboxv1alpha1.ReasonAgentClassBrowserToolboxMissing, + fmt.Sprintf("browserToolboxes[%q]: BrowserToolbox/%s not found in namespace %s", + ref.Name, ref.Ref, namespace) + } + if err != nil { + return spiceboxv1alpha1.ReasonAgentClassBrowserToolboxInvalid, + fmt.Sprintf("browserToolboxes[%q]: %v", ref.Name, err) + } + } + return "", "" +} + // gatherSidecarToolboxes fetches the SidecarToolbox CRs a class references so // their spicedbSchema fragments join standing + slot resolution the same way // MCPServer and SpiceboxToolkit fragments do (standingSources), and so their diff --git a/pkg/controllers/agentclass/watches_integration_test.go b/pkg/controllers/agentclass/watches_integration_test.go index 451e9416..83634d70 100644 --- a/pkg/controllers/agentclass/watches_integration_test.go +++ b/pkg/controllers/agentclass/watches_integration_test.go @@ -524,6 +524,47 @@ func TestAgentClass_ReReconcilesOnSpiceboxToolkitChange(t *testing.T) { eventuallyValid(t, env.Client, "ac-tk-watch", metav1.ConditionTrue, "", 10*time.Second) } +// TestAgentClass_ReReconcilesOnBrowserToolboxCreate exercises the +// BrowserToolbox watch. The referenced BrowserToolbox does not exist yet, so +// the AgentClass lands Valid=False reason=AgentClassBrowserToolboxMissing; +// creating the BrowserToolbox must re-reconcile the AgentClass through a +// watch. Unlike the SidecarToolbox/AgentUI watch tests, Phase A validation +// never reads a Valid condition on the BrowserToolbox (there is no +// BrowserToolbox reconciler yet), so existence alone must flip the class to +// Valid=True. Fails (times out) when SetupWithManager does not register a +// watch on BrowserToolbox. +func TestAgentClass_ReReconcilesOnBrowserToolboxCreate(t *testing.T) { + env := testenv.Shared(t) + startManager(t, env) + ctx := context.Background() + + require.NoError(t, env.Client.Create(ctx, &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "llm-creds", Namespace: "default"}, + Data: map[string][]byte{"api-key": []byte("sk")}, + }), "create llm-creds") + + ac := newClass("ac-btb-watch") + ac.Spec.BrowserToolboxes = []spiceboxv1alpha1.AgentClassBrowserToolboxRef{ + {Name: "web", Ref: "btb-watch"}, + } + require.NoError(t, env.Client.Create(ctx, ac), "create AgentClass before its BrowserToolbox") + eventuallyValid(t, env.Client, "ac-btb-watch", metav1.ConditionFalse, + spiceboxv1alpha1.ReasonAgentClassBrowserToolboxMissing, 5*time.Second) + + btb := &spiceboxv1alpha1.BrowserToolbox{ + ObjectMeta: metav1.ObjectMeta{Name: "btb-watch", Namespace: "default"}, + Spec: spiceboxv1alpha1.BrowserToolboxSpec{ + Access: spiceboxv1alpha1.BrowserAccess{ + Mode: "allowlist", + Sites: []spiceboxv1alpha1.BrowserSite{{Host: "example.com"}}, + }, + }, + } + require.NoError(t, env.Client.Create(ctx, btb), "create BrowserToolbox") + + eventuallyValid(t, env.Client, "ac-btb-watch", metav1.ConditionTrue, "", 5*time.Second) +} + // TestAgentClass_ReReconcilesOnAgentUIStatusChange exercises the AgentUI // watch. The referenced AgentUI exists but has no Valid condition yet (the // same install-ordering race as the toolspec and sidecar-toolbox cases), so diff --git a/pkg/controllers/agentsession/browserpod.go b/pkg/controllers/agentsession/browserpod.go new file mode 100644 index 00000000..8c41b260 --- /dev/null +++ b/pkg/controllers/agentsession/browserpod.go @@ -0,0 +1,546 @@ +// pkg/controllers/agentsession/browserpod.go +// +// Per-session browser pod: two containers (chrome, gate) from the first-party +// browser image (images/browser/Dockerfile), the policy ConfigMap the gate +// reloads, the per-session gate-token Secret it is injected from, and the +// NetworkPolicy that isolates the pod (netpol.go). Pure builders only -- +// Task 8b wires the reconcile loop (create, readiness, status, reap/wake) +// around these. +package agentsession + +import ( + "context" + "encoding/json" + "fmt" + + corev1 "k8s.io/api/core/v1" + networkingv1 "k8s.io/api/networking/v1" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/intstr" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/log" + + spiceboxv1alpha1 "github.com/authzed/openagentprimitives/pkg/apis/v1alpha1" + "github.com/authzed/openagentprimitives/pkg/authz/adoptguard" + "github.com/authzed/openagentprimitives/pkg/platform/cloud" + "github.com/authzed/openagentprimitives/pkg/platform/podspec" + "github.com/authzed/openagentprimitives/pkg/tools/browsergate" + "github.com/authzed/openagentprimitives/pkg/tools/browserpolicy" +) + +const browserGatePort int32 = browsergate.DefaultGatePort // 9223 +const browserDebugPort = "9222" +const browserProxyAddr = "127.0.0.1:3128" + +// Pinned stable real-Chrome UA (spec §2.1); bump deliberately, never per-build: +const browserUserAgent = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36" + +const ( + // labelBrowserToolbox identifies the separate browser pod's toolbox ref -- + // the sibling of labelSidecarToolbox (sidecarpod.go) for this toolbox's + // own per-session pod. + labelBrowserToolbox = "agentprimitives.authzed.com/browsertoolbox" + + // browserImageUID is the ONE account images/browser/Dockerfile declares + // (useradd --uid 65532 chrome). See BuildBrowserPod's chrome-container + // comment for why this must be pinned per-container rather than inherited + // from podspec.HardenedPodSecurityContext's RunAsUser:1000. + browserImageUID = 65532 + + // browserTokenSecretKey is the Secret data key the per-session gate token + // is written under (Task 8b's mintBrowserToken) and the key the gate + // container's APBROWSERGATE_TOKEN env reads back via secretKeyRef. + browserTokenSecretKey = "token" + // browserPolicyConfigMapKey is the ConfigMap data key the JSON PolicyDoc + // is written under (Task 8b's ensureBrowserPolicyConfigMap), and the file + // name it appears as under the gate's policy mount -- so + // APBROWSERGATE_POLICY_PATH resolves to exactly this file. + browserPolicyConfigMapKey = "policy.json" + // browserPolicyMountPath is where the policy ConfigMap is mounted, + // read-only, in the gate container only. + browserPolicyMountPath = "/etc/browserpolicy" + // browserProfileMountPath is Chrome's --user-data-dir: an emptyDir, so a + // fresh (ephemeral) profile every pod boot -- matches + // BrowserToolboxSpec.State's only Phase A value. + browserProfileMountPath = "/home/chrome/profile" + + browserProfileVolume = "profile" + browserTmpVolume = "tmp" + browserPolicyVolume = "policy" +) + +// BrowserPodName returns the deterministic name for a session's per-session +// browser pod for the given toolbox ref: "-browser-". Mirrors +// SidecarPodName's plain concatenation (sidecarpod.go:66) -- neither builder +// truncates for a name that would exceed Kubernetes' 253-char object-name +// limit; there is no separate name-truncation helper in this package today. +func BrowserPodName(sess *spiceboxv1alpha1.AgentSession, ref string) string { + return sess.Name + "-browser-" + ref +} + +// BrowserTokenSecretName returns the per-session gate-token Secret's name: +// "-browser--token". +func BrowserTokenSecretName(sess *spiceboxv1alpha1.AgentSession, ref string) string { + return BrowserPodName(sess, ref) + "-token" +} + +// BrowserPolicyConfigMapName returns the per-session browser policy +// ConfigMap's name: "-browser--policy". +func BrowserPolicyConfigMapName(sess *spiceboxv1alpha1.AgentSession, ref string) string { + return BrowserPodName(sess, ref) + "-policy" +} + +// BrowserNetworkPolicyName returns the per-session browser NetworkPolicy's +// name: "-browser--netpol" -- mirrors SidecarNetworkPolicyName +// (netpol.go:328). +func BrowserNetworkPolicyName(sess *spiceboxv1alpha1.AgentSession, ref string) string { + return BrowserPodName(sess, ref) + "-netpol" +} + +// DeriveBrowserEffectiveSites maps spec.Access.Sites 1:1 into the resolved +// EffectiveSites list, Source "explicit", in order. Phase A has no other +// site source (identity-derived sites arrive in Phase B), so this is a +// straight carry-through -- except for host canonicalization, which is NOT +// optional: browserpolicy.Match (and Decide) assume every Site.Host they +// compare against is ALREADY canonical (lowercased, no trailing dot), and +// this is the one place that guarantee is established before the value +// reaches the gate's PolicyDoc (BuildBrowserPolicyDoc) or any runner-side +// pre-check. +// +// Pure and deterministic: same spec in, same slice out, on every call -- no +// clock, no randomness, no I/O. That purity is what makes the status write +// (ResolvedBrowserToolbox.EffectiveSites) idempotent across re-reconciles. +// +// A Host that fails CanonicalHost is DROPPED rather than carried through +// un-normalized or erroring the whole derivation. The CRD's Pattern marker on +// BrowserSite.Host (browsertoolbox_types.go) already forbids anything +// CanonicalHost would reject, so this path is believed unreachable in +// practice -- but failing closed costs nothing here: in allowlist mode a +// dropped entry only NARROWS the effective allowlist (strictly safer than +// including a malformed entry verbatim); in blocklist mode dropping an entry +// would instead WIDEN reachability for that one host, which is exactly why +// the CRD pattern exists to keep this branch unreachable in the first place, +// rather than relying on this fail-closed default alone. +func DeriveBrowserEffectiveSites(spec spiceboxv1alpha1.BrowserToolboxSpec) []spiceboxv1alpha1.BrowserEffectiveSite { + sites := make([]spiceboxv1alpha1.BrowserEffectiveSite, 0, len(spec.Access.Sites)) + for _, s := range spec.Access.Sites { + host, err := browserpolicy.CanonicalHost(s.Host) + if err != nil { + continue + } + sites = append(sites, spiceboxv1alpha1.BrowserEffectiveSite{ + Host: host, + Exact: s.Exact, + Source: "explicit", + }) + } + return sites +} + +// BuildBrowserPolicyDoc converts a ResolvedBrowserToolbox's effective sites +// (plus its Access mode) into the browserpolicy.PolicyDoc JSON document the +// gate reloads from the per-session ConfigMap. +func BuildBrowserPolicyDoc(rt spiceboxv1alpha1.ResolvedBrowserToolbox) browserpolicy.PolicyDoc { + sites := make([]browserpolicy.Site, 0, len(rt.EffectiveSites)) + for _, s := range rt.EffectiveSites { + sites = append(sites, browserpolicy.Site{Host: s.Host, Exact: s.Exact}) + } + return browserpolicy.PolicyDoc{Mode: rt.Spec.Access.Mode, Sites: sites} +} + +// BuildBrowserPod builds the per-session browser pod: two containers from +// imageRef (images/browser/Dockerfile) sharing one pod network namespace -- +// "chrome" (headless Chromium, egress forced through the gate's loopback +// forward proxy) and "gate" (browsergate.Gate: the policy-enforcing proxy +// Chrome's --proxy-server points at, plus the authenticated DevTools +// reverse-proxy the runner calls). +// +// sandbox decides whether chrome's command appends --no-sandbox (Sandboxed +// false -- every Phase A cloud.Strategy, see cloud.BrowserChromeSandbox's own +// doc comment) or instead relies on Chrome's native seccomp/userns sandbox +// (Sandboxed true; not yet reachable in Phase A). +// +// Security posture: the pod-level context is +// podspec.HardenedPodSecurityContext() UNMODIFIED (RunAsNonRoot, UID/GID/ +// FSGroup 1000, seccomp RuntimeDefault). The GATE container inherits it +// unmodified -- fully hardened, no per-container relaxation, exactly like +// every other hardened container in this package. The CHROME container +// overrides RunAsUser/RunAsGroup to 65532: images/browser/Dockerfile's only +// account (useradd --uid 65532 chrome) owns /home/chrome, mode 0700 -- a +// container running as the pod-level default UID 1000 could not even +// traverse into that directory to reach the --user-data-dir mount point +// below it. ReadOnlyRootFilesystem, AllowPrivilegeEscalation=false and +// Capabilities Drop:[ALL] are NOT relaxed for chrome; two emptyDir volumes +// supply the writable paths it actually needs instead: +// +// - "profile" at browserProfileMountPath (--user-data-dir): ephemeral by +// construction -- a fresh profile every pod boot, matching +// BrowserToolboxSpec.State's only Phase A value. +// - "tmp" at /tmp: chromium's --disable-dev-shm-usage avoids the default +// 64MB /dev/shm tmpfs by writing shared-memory-backed temp files under +// the OS temp dir instead, which defaults to /tmp -- the same +// ReadOnlyRootFilesystem-needs-a-writable-/tmp pattern +// cosidecar.BuildContainer and the runner/sandbox pods +// (pkg/controllers/agentsession/podspec.go, pkg/platform/podspec) already +// use, rather than redirecting TMPDIR or relaxing the flag. +// +// Both emptyDirs are writable by chrome's overridden UID via the pod-level +// FSGroup:1000 supplementary group kubelet adds to EVERY container +// regardless of that container's own RunAsUser/RunAsGroup -- so FSGroup +// itself needs no override. +func BuildBrowserPod(sess *spiceboxv1alpha1.AgentSession, rt spiceboxv1alpha1.ResolvedBrowserToolbox, imageRef string, sandbox cloud.BrowserChromeSandbox) (*corev1.Pod, error) { + if imageRef == "" { + return nil, fmt.Errorf("build browser pod %q: empty image ref", rt.Ref) + } + + chromeSecCtx := podspec.HardenedContainerSecurityContext() + chromeSecCtx.RunAsUser = ptr.To(int64(browserImageUID)) + chromeSecCtx.RunAsGroup = ptr.To(int64(browserImageUID)) + + chromeCmd := []string{ + "chromium", + "--headless=new", + "--remote-debugging-port=" + browserDebugPort, + "--remote-debugging-address=127.0.0.1", + "--proxy-server=" + browserProxyAddr, + "--proxy-bypass-list=<-loopback>", + "--no-first-run", + "--no-default-browser-check", + "--disable-sync", + "--disable-extensions", + "--disable-background-networking", + "--disable-dev-shm-usage", + // Crashpad needs a writable database directory before it parses any + // disable flag: without these three, the handler exits with + // "--database is required" and chromium dies with SIGTRAP (exit 133) + // under ReadOnlyRootFilesystem, because the image's /home/chrome is + // read-only at runtime (found on a real kubelet; no suite runs the + // real image under the real securityContext). + "--disable-crash-reporter", + "--disable-breakpad", + "--crash-dumps-dir=/tmp", + "--user-data-dir=" + browserProfileMountPath, + "--user-agent=" + browserUserAgent, + "about:blank", + } + if !sandbox.Sandboxed { + chromeCmd = append(chromeCmd, "--no-sandbox") + } + + chrome := corev1.Container{ + Name: "chrome", + Image: imageRef, + ImagePullPolicy: corev1.PullIfNotPresent, + Command: chromeCmd, + // HOME points at the profile emptyDir: the image's passwd entry says + // /home/chrome, which is read-only at runtime, and chromium writes + // $HOME/.config and $HOME/.pki on boot. + Env: []corev1.EnvVar{{Name: "HOME", Value: browserProfileMountPath}}, + SecurityContext: chromeSecCtx, + VolumeMounts: []corev1.VolumeMount{ + {Name: browserProfileVolume, MountPath: browserProfileMountPath}, + {Name: browserTmpVolume, MountPath: "/tmp"}, + }, + TerminationMessagePolicy: corev1.TerminationMessageFallbackToLogsOnError, + } + + gate := corev1.Container{ + Name: "gate", + Image: imageRef, + ImagePullPolicy: corev1.PullIfNotPresent, + Command: []string{"apbrowsergate"}, + Env: []corev1.EnvVar{ + { + Name: "APBROWSERGATE_TOKEN", + ValueFrom: &corev1.EnvVarSource{ + SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{Name: BrowserTokenSecretName(sess, rt.Ref)}, + Key: browserTokenSecretKey, + }, + }, + }, + {Name: "APBROWSERGATE_POLICY_PATH", Value: browserPolicyMountPath + "/" + browserPolicyConfigMapKey}, + }, + VolumeMounts: []corev1.VolumeMount{ + {Name: browserPolicyVolume, MountPath: browserPolicyMountPath, ReadOnly: true}, + }, + SecurityContext: podspec.HardenedContainerSecurityContext(), + StartupProbe: &corev1.Probe{ + ProbeHandler: corev1.ProbeHandler{ + HTTPGet: &corev1.HTTPGetAction{ + Path: "/healthz", + Port: intstr.FromInt32(browserGatePort), + }, + }, + PeriodSeconds: 1, + FailureThreshold: 30, // mirrors cosidecar.buildStartupProbe's default give-up window + }, + TerminationMessagePolicy: corev1.TerminationMessageFallbackToLogsOnError, + } + + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: BrowserPodName(sess, rt.Ref), + Namespace: sess.Namespace, + Labels: map[string]string{ + "agentprimitives.authzed.com/agentsession": sess.Name, + labelBrowserToolbox: rt.Ref, + }, + OwnerReferences: sessionOwnerRef(sess), + }, + Spec: corev1.PodSpec{ + // A long-running per-session server: restart on crash, mirroring + // BuildSidecarPod's separate-pod rationale. + RestartPolicy: corev1.RestartPolicyOnFailure, + // The browser pod has no business holding an apiserver credential. + AutomountServiceAccountToken: ptr.To(false), + SecurityContext: podspec.HardenedPodSecurityContext(), + Containers: []corev1.Container{chrome, gate}, + Volumes: []corev1.Volume{ + { + Name: browserProfileVolume, + VolumeSource: corev1.VolumeSource{EmptyDir: &corev1.EmptyDirVolumeSource{}}, + }, + { + Name: browserTmpVolume, + VolumeSource: corev1.VolumeSource{EmptyDir: &corev1.EmptyDirVolumeSource{}}, + }, + { + Name: browserPolicyVolume, + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: BrowserPolicyConfigMapName(sess, rt.Ref)}, + Items: []corev1.KeyToPath{{ + Key: browserPolicyConfigMapKey, + Path: browserPolicyConfigMapKey, + }}, + }, + }, + }, + }, + }, + } + return pod, nil +} + +// browserPodState is what one reconcile pass observed about a per-session +// browser pod. Mirrors sidecarPodState, minus the token-rotation Replacing case: +// the gate token is read-before-mint (mintBrowserToken), so it never rotates and +// the pod is never torn down for a token change. PodIP is set once the pod is +// Ready; Failure is non-nil only for a terminal (crashloop / unpullable) wedge. +type browserPodState struct { + PodIP string + Failure *spiceboxv1alpha1.SidecarPodFailure +} + +// mintBrowserToken resolves the per-session gate-token Secret and stamps +// rt.TokenHash from the value actually in hand. READ-BEFORE-MINT is the whole +// point: an existing Secret is reused verbatim, so a re-reconcile — and a +// reap/wake, where the pod is deleted but the Secret survives — recomputes the +// SAME hash and never rewrites the token. Only when the Secret is absent is a +// fresh 32-byte hex token minted and created (with the adoption label so the +// operator's later guarded reads find it in the label-filtered cache). A Create +// race is resolved by re-reading the winner's value. +func (r *Reconciler) mintBrowserToken(ctx context.Context, sess *spiceboxv1alpha1.AgentSession, rt *spiceboxv1alpha1.ResolvedBrowserToolbox) error { + name := BrowserTokenSecretName(sess, rt.Ref) + key := types.NamespacedName{Namespace: sess.Namespace, Name: name} + sec, err := r.getSecret(ctx, key) + switch { + case err == nil: + // Existing token — reuse it unchanged. + case errors.IsNotFound(err): + tok, genErr := generateToken() + if genErr != nil { + return fmt.Errorf("generate browser gate token for %q: %w", rt.Ref, genErr) + } + sec = &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: sess.Namespace, + Labels: map[string]string{ + "agentprimitives.authzed.com/agentsession": sess.Name, + }, + OwnerReferences: sessionOwnerRef(sess), + }, + Type: corev1.SecretTypeOpaque, + Data: map[string][]byte{browserTokenSecretKey: []byte(tok)}, + } + // Enter the label-filtered cache + pass the SecretReader guard on later + // reads, exactly like the memory-token Secret. + adoptguard.WithAdoptedLabel(sec) + if createErr := r.Client.Create(ctx, sec); createErr != nil { + if !errors.IsAlreadyExists(createErr) { + return fmt.Errorf("create browser gate-token Secret %q: %w", name, createErr) + } + // Raced with another writer: re-read so TokenHash reflects the token + // that actually landed, not the one this pass generated and discarded. + reread, getErr := r.getSecret(ctx, key) + if getErr != nil { + return fmt.Errorf("get browser gate-token Secret %q after create race: %w", name, getErr) + } + sec = reread + } + default: + return fmt.Errorf("get browser gate-token Secret %q: %w", name, err) + } + rt.TokenHash = SecretTokenHash(sec.Data[browserTokenSecretKey]) + return nil +} + +// ensureBrowserNetworkPolicy stamps (SSA) one per-session browser pod's +// NetworkPolicy. Called BEFORE the pod is created so an enforcing CNI never sees +// an unconstrained browser pod. BuildBrowserNetworkPolicy returns nil when +// per-session network policies are disabled, in which case this is a no-op +// (mirrors every other ensure*NetworkPolicy's disabled behavior). +func (r *Reconciler) ensureBrowserNetworkPolicy(ctx context.Context, sess *spiceboxv1alpha1.AgentSession, rt spiceboxv1alpha1.ResolvedBrowserToolbox) error { + np := BuildBrowserNetworkPolicy(sess, rt, r.Netpol) + if np == nil { + return nil + } + if err := r.Client.Patch(ctx, np, + client.Apply, client.ForceOwnership, client.FieldOwner(netpolFieldOwner), + ); err != nil { + return fmt.Errorf("apply browser NetworkPolicy %q: %w", np.Name, err) + } + return nil +} + +// ensureBrowserPolicyConfigMap writes the gate's policy document (the JSON the +// gate reloads) into the per-session ConfigMap. Idempotent and WRITE-IF-DIFFERENT: +// a byte-identical re-reconcile is a no-op, so the ConfigMap's resourceVersion — +// and the mounted policy the gate watches — never churns. +func (r *Reconciler) ensureBrowserPolicyConfigMap(ctx context.Context, sess *spiceboxv1alpha1.AgentSession, rt spiceboxv1alpha1.ResolvedBrowserToolbox) error { + raw, err := json.Marshal(BuildBrowserPolicyDoc(rt)) + if err != nil { + return fmt.Errorf("marshal browser policy for %q: %w", rt.Ref, err) + } + name := BrowserPolicyConfigMapName(sess, rt.Ref) + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: sess.Namespace, + Labels: map[string]string{ + "agentprimitives.authzed.com/agentsession": sess.Name, + labelBrowserToolbox: rt.Ref, + }, + OwnerReferences: sessionOwnerRef(sess), + }, + Data: map[string]string{browserPolicyConfigMapKey: string(raw)}, + } + adoptguard.WithAdoptedLabel(cm) + if err := r.Client.Create(ctx, cm); err != nil { + if !errors.IsAlreadyExists(err) { + return fmt.Errorf("create browser policy ConfigMap %q: %w", name, err) + } + existing, getErr := r.getConfigMap(ctx, types.NamespacedName{Namespace: sess.Namespace, Name: name}) + if getErr != nil { + return fmt.Errorf("get browser policy ConfigMap %q: %w", name, getErr) + } + // Write-if-different: an unchanged document is left exactly as is. + if existing.Data[browserPolicyConfigMapKey] == string(raw) { + return nil + } + existing.Data = map[string]string{browserPolicyConfigMapKey: string(raw)} + if updErr := r.Client.Update(ctx, existing); updErr != nil { + return fmt.Errorf("update browser policy ConfigMap %q: %w", name, updErr) + } + } + return nil +} + +// reconcileBrowserPod idempotently creates the per-session browser pod and +// reflects its readiness. Returns the PodIP once the pod is Ready (the runner +// then reaches the gate at http://:), or a Failure when the pod +// is wedged in a terminal container state. All-zero means "created, still +// starting, check again next pass". Mirrors reconcileSidecarPod minus token +// rotation — see browserPodState. +func (r *Reconciler) reconcileBrowserPod(ctx context.Context, sess *spiceboxv1alpha1.AgentSession, rt spiceboxv1alpha1.ResolvedBrowserToolbox) (browserPodState, error) { + desired, berr := BuildBrowserPod(sess, rt, r.BrowserImageRef, r.BrowserSandbox) + if berr != nil { + return browserPodState{}, berr + } + podKey := types.NamespacedName{Namespace: sess.Namespace, Name: desired.Name} + if err := r.Client.Create(ctx, desired); err != nil && !errors.IsAlreadyExists(err) { + return browserPodState{}, fmt.Errorf("create browser pod %q: %w", desired.Name, err) + } + var live corev1.Pod + if err := r.Client.Get(ctx, podKey, &live); err != nil { + if errors.IsNotFound(err) { + // Created but not yet observable in the cache; reflect next pass. + return browserPodState{}, nil + } + return browserPodState{}, fmt.Errorf("get browser pod %q: %w", desired.Name, err) + } + if podReady(&live) && live.Status.PodIP != "" { + return browserPodState{PodIP: live.Status.PodIP}, nil + } + // Not Ready. Distinguish "still starting" from "will never start" so the + // caller can tell the agent and the user WHY the browser is missing instead + // of waiting out a readiness deadline. sidecarPodFailure returns nil for a + // pod that is merely still starting. + return browserPodState{Failure: sidecarPodFailure(&live)}, nil +} + +// cleanupOrphanedBrowserPods lists this session's pods and deletes any browser +// pod whose toolbox ref is NOT in currentRefs, together with that pod's +// NetworkPolicy and policy ConfigMap. The gate-token Secret is deliberately +// KEPT: a wake recreates the pod and reuses the same token (read-before-mint). +// +// Passing a nil/empty currentRefs (reapSessionPods) orphans every browser pod; +// passing the current resolved set (the main loop) reaps only a toolbox removed +// from the AgentClass mid-session. Mirrors cleanupOrphanedSidecarPods. +func (r *Reconciler) cleanupOrphanedBrowserPods(ctx context.Context, sess *spiceboxv1alpha1.AgentSession, currentRefs map[string]bool) error { + var podList corev1.PodList + if err := r.Client.List(ctx, &podList, + client.InNamespace(sess.Namespace), + client.MatchingLabels{"agentprimitives.authzed.com/agentsession": sess.Name}, + ); err != nil { + return fmt.Errorf("list browser pods for session %q: %w", sess.Name, err) + } + logger := log.FromContext(ctx) + var firstErr error + for i := range podList.Items { + pod := &podList.Items[i] + ref, isBrowser := pod.Labels[labelBrowserToolbox] + if !isBrowser { + // Not a browser pod (runner, separate-pod sidecar, detector). + continue + } + if currentRefs[ref] { + continue // still in the resolved set — keep it. + } + if delErr := r.Client.Delete(ctx, pod); delErr != nil && !errors.IsNotFound(delErr) { + logger.Info("cleanupOrphanedBrowserPods: delete pod failed", + "session", sess.Namespace+"/"+sess.Name, "pod", pod.Name, "browsertoolbox", ref, "err", delErr.Error()) + if firstErr == nil { + firstErr = fmt.Errorf("delete orphaned browser pod %q (ref %q): %w", pod.Name, ref, delErr) + } + } else { + logger.Info("cleanupOrphanedBrowserPods: deleted orphaned browser pod", + "session", sess.Namespace+"/"+sess.Name, "pod", pod.Name, "browsertoolbox", ref) + } + // Reap the pod's NetworkPolicy + policy ConfigMap alongside it (by their + // deterministic names). NotFound is tolerated; the token Secret is NOT + // deleted — a wake reuses it. + np := &networkingv1.NetworkPolicy{ObjectMeta: metav1.ObjectMeta{Namespace: sess.Namespace, Name: BrowserNetworkPolicyName(sess, ref)}} + if delErr := r.Client.Delete(ctx, np); delErr != nil && !errors.IsNotFound(delErr) { + logger.Info("cleanupOrphanedBrowserPods: NetworkPolicy delete failed", + "session", sess.Namespace+"/"+sess.Name, "networkpolicy", np.Name, "browsertoolbox", ref, "err", delErr.Error()) + if firstErr == nil { + firstErr = fmt.Errorf("delete orphaned browser NetworkPolicy %q (ref %q): %w", np.Name, ref, delErr) + } + } + cm := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Namespace: sess.Namespace, Name: BrowserPolicyConfigMapName(sess, ref)}} + if delErr := r.Client.Delete(ctx, cm); delErr != nil && !errors.IsNotFound(delErr) { + logger.Info("cleanupOrphanedBrowserPods: ConfigMap delete failed", + "session", sess.Namespace+"/"+sess.Name, "configmap", cm.Name, "browsertoolbox", ref, "err", delErr.Error()) + if firstErr == nil { + firstErr = fmt.Errorf("delete orphaned browser ConfigMap %q (ref %q): %w", cm.Name, ref, delErr) + } + } + } + return firstErr +} diff --git a/pkg/controllers/agentsession/browserpod_envtest_test.go b/pkg/controllers/agentsession/browserpod_envtest_test.go new file mode 100644 index 00000000..fc2060f3 --- /dev/null +++ b/pkg/controllers/agentsession/browserpod_envtest_test.go @@ -0,0 +1,386 @@ +//go:build integration + +// pkg/controllers/agentsession/browserpod_envtest_test.go +// +// End-to-end integration tests for the AgentSession reconciler's browser-toolbox +// wiring (Task 8b): per-session browser pod creation, policy ConfigMap + gate +// token Secret + NetworkPolicy stamping, readiness gating of the runner pod, +// reap/wake with a byte-stable gate token, and the runner Role's pinned +// secrets/get grant for the gate token. +// +// Pod readiness is hand-stamped — envtest has no kubelet — mirroring +// setPodReady in identitychoice_envtest_test.go (adapted to the browser pod's +// "gate"/"chrome" containers). +package agentsession_test + +import ( + "context" + "encoding/json" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + networkingv1 "k8s.io/api/networking/v1" + rbacv1 "k8s.io/api/rbac/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + + spiceboxv1alpha1 "github.com/authzed/openagentprimitives/pkg/apis/v1alpha1" + "github.com/authzed/openagentprimitives/pkg/controllers/agentsession" + "github.com/authzed/openagentprimitives/pkg/controllers/testenv" + "github.com/authzed/openagentprimitives/pkg/tools/browserpolicy" +) + +// newBrowserReconciler is newReconciler plus the two fields the browser wiring +// needs: the first-party browser image ref (empty ref fails the pod build) and +// an enabled NetpolConfig so BuildBrowserNetworkPolicy emits a policy to assert. +func newBrowserReconciler(t *testing.T, env *testenv.Env) *agentsession.Reconciler { + t.Helper() + r := newReconciler(t, env) + r.BrowserImageRef = "ap-browser:dev" + r.Netpol = agentsession.NetpolConfig{ + Enabled: true, + OperatorNamespace: "agentprimitives-system", + NATSPort: 4222, + SpiceDBPort: 50051, + SpiceDBInCluster: true, + } + return r +} + +// browserToolbox returns a minimal valid BrowserToolbox CR in allowlist mode +// over the given hosts. +func browserToolbox(name string, hosts ...string) *spiceboxv1alpha1.BrowserToolbox { + sites := make([]spiceboxv1alpha1.BrowserSite, 0, len(hosts)) + for _, h := range hosts { + sites = append(sites, spiceboxv1alpha1.BrowserSite{Host: h}) + } + return &spiceboxv1alpha1.BrowserToolbox{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "default"}, + Spec: spiceboxv1alpha1.BrowserToolboxSpec{ + Access: spiceboxv1alpha1.BrowserAccess{Mode: "allowlist", Sites: sites}, + Interaction: "act", + State: "ephemeral", + }, + } +} + +// classWithBrowsers clones validClass and attaches browser refs. +func classWithBrowsers(name string, refs ...spiceboxv1alpha1.AgentClassBrowserToolboxRef) *spiceboxv1alpha1.AgentClass { + ac := validClass(name) + ac.Spec.BrowserToolboxes = refs + return ac +} + +// setBrowserPodReady flips a browser pod to Running+Ready with a PodIP — the +// edge reconcileBrowserPod watches to reflect BrowserPodIP and release the +// runner. envtest has no kubelet, so the test stamps the status by hand +// (mirrors setPodReady for the runner pod). +func setBrowserPodReady(t *testing.T, ctx context.Context, env *testenv.Env, podName string) { + t.Helper() + var pod corev1.Pod + require.NoError(t, env.Client.Get(ctx, types.NamespacedName{Namespace: "default", Name: podName}, &pod), + "get browser pod to mark Ready") + pod.Status.Phase = corev1.PodRunning + pod.Status.PodIP = "10.44.0.7" + pod.Status.Conditions = []corev1.PodCondition{{Type: corev1.PodReady, Status: corev1.ConditionTrue}} + pod.Status.ContainerStatuses = []corev1.ContainerStatus{ + {Name: "chrome", Ready: true}, + {Name: "gate", Ready: true}, + } + require.NoError(t, env.Client.Status().Update(ctx, &pod), "mark browser pod Ready") +} + +// TestReconcileCreatesBrowserPodAndPolicy proves the reconciler materializes the +// full browser-toolbox object set: the two-container pod, its NetworkPolicy +// (private-range except-list + gate-port ingress), the policy ConfigMap (whose +// JSON equals the derived PolicyDoc), the gate-token Secret, and a status entry +// carrying the effective sites, gate port, and the token hash of the secret. +func TestReconcileCreatesBrowserPodAndPolicy(t *testing.T) { + env := testenv.Shared(t) + ctx := context.Background() + r := newBrowserReconciler(t, env) + + tb := browserToolbox("btb-create", "example.com") + require.NoError(t, env.Client.Create(ctx, tb), "create BrowserToolbox") + + ac := classWithBrowsers("ac-browser-create", + spiceboxv1alpha1.AgentClassBrowserToolboxRef{Name: "web", Ref: "btb-create"}) + require.NoError(t, env.Client.Create(ctx, ac), "create AgentClass") + markValid(t, env, ac) + + sess := validSession("s-browser-create", "ac-browser-create") + require.NoError(t, env.Client.Create(ctx, sess), "create AgentSession") + reconcileToWork(t, ctx, r, "s-browser-create") + + podName := agentsession.BrowserPodName(sess, "btb-create") + + // Pod: two containers, owned by the session. + var pod corev1.Pod + require.NoError(t, env.Client.Get(ctx, types.NamespacedName{Namespace: "default", Name: podName}, &pod), + "browser pod must be created") + require.Len(t, pod.Spec.Containers, 2, "browser pod has chrome + gate containers") + require.Len(t, pod.OwnerReferences, 1, "browser pod owner ref") + assert.Equal(t, "AgentSession", pod.OwnerReferences[0].Kind) + assert.Equal(t, "s-browser-create", pod.OwnerReferences[0].Name) + + // NetworkPolicy: ingress on the gate port, egress with the private-range + // except-list. + var np networkingv1.NetworkPolicy + require.NoError(t, env.Client.Get(ctx, types.NamespacedName{Namespace: "default", Name: agentsession.BrowserNetworkPolicyName(sess, "btb-create")}, &np), + "browser NetworkPolicy must be created") + require.Len(t, np.Spec.Ingress, 1, "one ingress rule") + require.Len(t, np.Spec.Ingress[0].Ports, 1, "ingress on exactly the gate port") + assert.Equal(t, int32(9223), np.Spec.Ingress[0].Ports[0].Port.IntVal, "ingress on the gate port 9223") + var sawExcept bool + for _, e := range np.Spec.Egress { + for _, to := range e.To { + if to.IPBlock != nil { + assert.Contains(t, to.IPBlock.Except, "169.254.0.0/16", + "browser egress must except the link-local/metadata range") + sawExcept = true + } + } + } + assert.True(t, sawExcept, "browser NetworkPolicy must carry an IPBlock egress rule with an except-list") + + // ConfigMap: the policy JSON unmarshals to the derived PolicyDoc. + var cm corev1.ConfigMap + require.NoError(t, env.Client.Get(ctx, types.NamespacedName{Namespace: "default", Name: agentsession.BrowserPolicyConfigMapName(sess, "btb-create")}, &cm), + "browser policy ConfigMap must be created") + var doc browserpolicy.PolicyDoc + require.NoError(t, json.Unmarshal([]byte(cm.Data["policy.json"]), &doc), "policy ConfigMap holds valid PolicyDoc JSON") + assert.Equal(t, "allowlist", doc.Mode, "policy mode carried through") + require.Len(t, doc.Sites, 1, "one site in the policy doc") + assert.Equal(t, "example.com", doc.Sites[0].Host, "policy site host carried through") + + // Secret: the per-session gate token. + var sec corev1.Secret + require.NoError(t, env.Client.Get(ctx, types.NamespacedName{Namespace: "default", Name: agentsession.BrowserTokenSecretName(sess, "btb-create")}, &sec), + "browser gate-token Secret must be created") + require.NotEmpty(t, sec.Data["token"], "gate-token Secret carries a token") + + // Status entry: effective sites, gate port, token hash of the secret value. + var got spiceboxv1alpha1.AgentSession + require.NoError(t, env.Client.Get(ctx, types.NamespacedName{Namespace: "default", Name: "s-browser-create"}, &got), "get session") + require.Len(t, got.Status.ResolvedBrowserToolboxes, 1, "one resolved browser toolbox in status") + rt := got.Status.ResolvedBrowserToolboxes[0] + assert.Equal(t, "web", rt.Name, "resolved browser Name (LLM prefix)") + assert.Equal(t, "btb-create", rt.Ref, "resolved browser Ref (CR name)") + assert.Equal(t, int32(9223), rt.GatePort, "resolved browser GatePort == 9223") + require.Len(t, rt.EffectiveSites, 1, "one effective site") + assert.Equal(t, "example.com", rt.EffectiveSites[0].Host, "effective site host") + assert.Equal(t, "explicit", rt.EffectiveSites[0].Source, "effective site source is explicit in Phase A") + assert.Equal(t, agentsession.SecretTokenHash(sec.Data["token"]), rt.TokenHash, + "status TokenHash is the hash of the secret value actually in hand") +} + +// TestReconcileBrowserIsIdempotent proves a byte-identical re-reconcile rewrites +// nothing: the pod, ConfigMap, Secret, and session resourceVersions are stable +// across a second full pass (read-before-mint + write-if-different). +func TestReconcileBrowserIsIdempotent(t *testing.T) { + env := testenv.Shared(t) + ctx := context.Background() + r := newBrowserReconciler(t, env) + + tb := browserToolbox("btb-idem", "example.com", "docs.example.org") + require.NoError(t, env.Client.Create(ctx, tb), "create BrowserToolbox") + ac := classWithBrowsers("ac-browser-idem", + spiceboxv1alpha1.AgentClassBrowserToolboxRef{Name: "web", Ref: "btb-idem"}) + require.NoError(t, env.Client.Create(ctx, ac), "create AgentClass") + markValid(t, env, ac) + sess := validSession("s-browser-idem", "ac-browser-idem") + require.NoError(t, env.Client.Create(ctx, sess), "create AgentSession") + + reconcileToWork(t, ctx, r, "s-browser-idem") + + podName := agentsession.BrowserPodName(sess, "btb-idem") + cmName := agentsession.BrowserPolicyConfigMapName(sess, "btb-idem") + secName := agentsession.BrowserTokenSecretName(sess, "btb-idem") + + rv := func(obj interface{ GetResourceVersion() string }) string { return obj.GetResourceVersion() } + + var pod1 corev1.Pod + require.NoError(t, env.Client.Get(ctx, types.NamespacedName{Namespace: "default", Name: podName}, &pod1)) + var cm1 corev1.ConfigMap + require.NoError(t, env.Client.Get(ctx, types.NamespacedName{Namespace: "default", Name: cmName}, &cm1)) + var sec1 corev1.Secret + require.NoError(t, env.Client.Get(ctx, types.NamespacedName{Namespace: "default", Name: secName}, &sec1)) + var sess1 spiceboxv1alpha1.AgentSession + require.NoError(t, env.Client.Get(ctx, types.NamespacedName{Namespace: "default", Name: "s-browser-idem"}, &sess1)) + + // A second full set of reconciles must not rewrite any of them. + reconcileToWork(t, ctx, r, "s-browser-idem") + + var pod2 corev1.Pod + require.NoError(t, env.Client.Get(ctx, types.NamespacedName{Namespace: "default", Name: podName}, &pod2)) + var cm2 corev1.ConfigMap + require.NoError(t, env.Client.Get(ctx, types.NamespacedName{Namespace: "default", Name: cmName}, &cm2)) + var sec2 corev1.Secret + require.NoError(t, env.Client.Get(ctx, types.NamespacedName{Namespace: "default", Name: secName}, &sec2)) + var sess2 spiceboxv1alpha1.AgentSession + require.NoError(t, env.Client.Get(ctx, types.NamespacedName{Namespace: "default", Name: "s-browser-idem"}, &sess2)) + + assert.Equal(t, rv(&pod1), rv(&pod2), "browser pod must not be rewritten on re-reconcile") + assert.Equal(t, rv(&cm1), rv(&cm2), "browser policy ConfigMap must not be rewritten on re-reconcile") + assert.Equal(t, rv(&sec1), rv(&sec2), "browser gate-token Secret must not be rewritten on re-reconcile") + assert.Equal(t, rv(&sess1), rv(&sess2), "AgentSession must not be rewritten on re-reconcile") +} + +// TestRunnerWaitsForBrowserPod proves runner-pod creation is held until every +// browser pod is Ready: no runner after the first pass, then a runner once the +// browser pod reports Running+Ready+PodIP. +func TestRunnerWaitsForBrowserPod(t *testing.T) { + env := testenv.Shared(t) + ctx := context.Background() + r := newBrowserReconciler(t, env) + + tb := browserToolbox("btb-wait", "example.com") + require.NoError(t, env.Client.Create(ctx, tb), "create BrowserToolbox") + ac := classWithBrowsers("ac-browser-wait", + spiceboxv1alpha1.AgentClassBrowserToolboxRef{Name: "web", Ref: "btb-wait"}) + require.NoError(t, env.Client.Create(ctx, ac), "create AgentClass") + markValid(t, env, ac) + sess := validSession("s-browser-wait", "ac-browser-wait") + require.NoError(t, env.Client.Create(ctx, sess), "create AgentSession") + + reconcileToWork(t, ctx, r, "s-browser-wait") + + // Browser pod exists but is not Ready (no kubelet) → runner must be absent. + podName := agentsession.BrowserPodName(sess, "btb-wait") + require.NoError(t, env.Client.Get(ctx, types.NamespacedName{Namespace: "default", Name: podName}, &corev1.Pod{}), + "browser pod exists after first reconcile") + var runner corev1.Pod + err := env.Client.Get(ctx, types.NamespacedName{Namespace: "default", Name: agentsession.RunnerPodName(sess)}, &runner) + assert.True(t, apierrors.IsNotFound(err), "runner pod must NOT exist while the browser pod is not Ready, got err=%v", err) + + // Mark the browser pod Ready; the runner must now come up. + setBrowserPodReady(t, ctx, env, podName) + reconcileToWork(t, ctx, r, "s-browser-wait") + + require.NoError(t, env.Client.Get(ctx, types.NamespacedName{Namespace: "default", Name: agentsession.RunnerPodName(sess)}, &runner), + "runner pod must exist once the browser pod is Ready") +} + +// TestReapDeletesBrowserPodWakeRecreates proves reap deletes the browser pod but +// keeps its gate-token Secret, and a wake recreates the pod reusing the same +// token (read-before-mint keeps TokenHash byte-stable across reap/wake). +func TestReapDeletesBrowserPodWakeRecreates(t *testing.T) { + env := testenv.Shared(t) + ctx := context.Background() + r := newBrowserReconciler(t, env) + + tb := browserToolbox("btb-reap", "example.com") + require.NoError(t, env.Client.Create(ctx, tb), "create BrowserToolbox") + ac := classWithBrowsers("ac-browser-reap", + spiceboxv1alpha1.AgentClassBrowserToolboxRef{Name: "web", Ref: "btb-reap"}) + require.NoError(t, env.Client.Create(ctx, ac), "create AgentClass") + markValid(t, env, ac) + sess := validSession("s-browser-reap", "ac-browser-reap") + require.NoError(t, env.Client.Create(ctx, sess), "create AgentSession") + + reconcileToWork(t, ctx, r, "s-browser-reap") + + podName := agentsession.BrowserPodName(sess, "btb-reap") + secName := agentsession.BrowserTokenSecretName(sess, "btb-reap") + key := types.NamespacedName{Namespace: "default", Name: "s-browser-reap"} + + // Capture the original token hash. + var before spiceboxv1alpha1.AgentSession + require.NoError(t, env.Client.Get(ctx, key, &before), "get session before reap") + require.Len(t, before.Status.ResolvedBrowserToolboxes, 1) + tokenHash := before.Status.ResolvedBrowserToolboxes[0].TokenHash + require.NotEmpty(t, tokenHash, "token hash is recorded before reap") + + // Drive to the reap path: an archive-swept Succeeded session reaps its pods + // immediately (terminalReapAction), and no wake annotation is present yet, so + // shouldWake is false and the reap block runs. + var drive spiceboxv1alpha1.AgentSession + require.NoError(t, env.Client.Get(ctx, key, &drive), "get session to drive to Succeeded") + drive.Status.Phase = spiceboxv1alpha1.AgentSessionPhaseSucceeded + now := metav1.Now() + drive.Status.FinishedAt = &now + meta.SetStatusCondition(&drive.Status.Conditions, metav1.Condition{ + Type: spiceboxv1alpha1.AgentSessionConditionIdle, + Status: metav1.ConditionFalse, + Reason: spiceboxv1alpha1.ReasonAgentSessionArchived, + }) + require.NoError(t, env.Client.Status().Update(ctx, &drive), "mark session archive-swept Succeeded") + + reconcileToWork(t, ctx, r, "s-browser-reap") + + // Browser pod reaped; gate-token Secret retained. + err := env.Client.Get(ctx, types.NamespacedName{Namespace: "default", Name: podName}, &corev1.Pod{}) + assert.True(t, apierrors.IsNotFound(err), "browser pod must be reaped, got err=%v", err) + require.NoError(t, env.Client.Get(ctx, types.NamespacedName{Namespace: "default", Name: secName}, &corev1.Secret{}), + "gate-token Secret must survive reap") + + // Wake: stamp the wake annotation (newer than lastWakeAt), so shouldWake is + // true, the reap block is skipped, and the browser loop recreates the pod. + var waking spiceboxv1alpha1.AgentSession + require.NoError(t, env.Client.Get(ctx, key, &waking), "get session to wake") + if waking.Annotations == nil { + waking.Annotations = map[string]string{} + } + waking.Annotations[spiceboxv1alpha1.AnnotationWakeRequestedAt] = time.Now().Add(time.Hour).Format(time.RFC3339Nano) + require.NoError(t, env.Client.Update(ctx, &waking), "stamp wake annotation") + + reconcileToWork(t, ctx, r, "s-browser-reap") + + // Pod recreated; token (and so TokenHash) unchanged. + require.NoError(t, env.Client.Get(ctx, types.NamespacedName{Namespace: "default", Name: podName}, &corev1.Pod{}), + "browser pod must be recreated on wake") + var after spiceboxv1alpha1.AgentSession + require.NoError(t, env.Client.Get(ctx, key, &after), "get session after wake") + require.Len(t, after.Status.ResolvedBrowserToolboxes, 1) + assert.Equal(t, tokenHash, after.Status.ResolvedBrowserToolboxes[0].TokenHash, + "gate token (TokenHash) must be unchanged across reap/wake") +} + +// TestRunnerRoleGrantsBrowserTokenRead proves the runner Role pins secrets/get +// to each browser gate-token Secret derived from the class's browser refs. +func TestRunnerRoleGrantsBrowserTokenRead(t *testing.T) { + sess := validSession("s-browser-rbac", "ac-browser-rbac") + ac := classWithBrowsers("ac-browser-rbac", + spiceboxv1alpha1.AgentClassBrowserToolboxRef{Name: "web", Ref: "btb-rbac"}) + + _, role, _, _, _ := agentsession.BuildRunnerRBAC(sess, ac, "tok", nil, nil, "", "", "", "", "", "", nil) + + want := agentsession.BrowserTokenSecretName(sess, "btb-rbac") + var found bool + for _, rule := range role.Rules { + if !ruleCoversSecretsGet(rule) { + continue + } + for _, n := range rule.ResourceNames { + if n == want { + found = true + } + } + } + assert.True(t, found, "runner Role must grant secrets/get on the browser gate-token Secret %q", want) +} + +func ruleCoversSecretsGet(rule rbacv1.PolicyRule) bool { + hasCore, hasSecrets, hasGet := false, false, false + for _, g := range rule.APIGroups { + if g == "" { + hasCore = true + } + } + for _, res := range rule.Resources { + if res == "secrets" { + hasSecrets = true + } + } + for _, v := range rule.Verbs { + if v == "get" { + hasGet = true + } + } + return hasCore && hasSecrets && hasGet +} diff --git a/pkg/controllers/agentsession/browserpod_test.go b/pkg/controllers/agentsession/browserpod_test.go new file mode 100644 index 00000000..ea1f0a84 --- /dev/null +++ b/pkg/controllers/agentsession/browserpod_test.go @@ -0,0 +1,316 @@ +// Pure-function tests for the per-session browser pod builder: the name +// helpers, DeriveBrowserEffectiveSites, BuildBrowserPolicyDoc, and +// BuildBrowserPod (chrome command flags, token handling, pod shape). +package agentsession_test + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/types" + + spiceboxv1alpha1 "github.com/authzed/openagentprimitives/pkg/apis/v1alpha1" + "github.com/authzed/openagentprimitives/pkg/controllers/agentsession" + "github.com/authzed/openagentprimitives/pkg/platform/cloud" +) + +// browserToolboxSpec builds a minimal BrowserToolboxSpec for tests. +func browserToolboxSpec(mode string, sites ...spiceboxv1alpha1.BrowserSite) spiceboxv1alpha1.BrowserToolboxSpec { + return spiceboxv1alpha1.BrowserToolboxSpec{ + Access: spiceboxv1alpha1.BrowserAccess{Mode: mode, Sites: sites}, + Interaction: "act", + State: "ephemeral", + } +} + +// resolvedBrowserToolbox builds a ResolvedBrowserToolbox the way the +// reconciler would (Task 8b): EffectiveSites derived from Spec, fixed +// GatePort. +func resolvedBrowserToolbox(ref string, spec spiceboxv1alpha1.BrowserToolboxSpec) spiceboxv1alpha1.ResolvedBrowserToolbox { + return spiceboxv1alpha1.ResolvedBrowserToolbox{ + Name: ref, + Ref: ref, + Spec: spec, + EffectiveSites: agentsession.DeriveBrowserEffectiveSites(spec), + GatePort: 9223, + } +} + +func chromeContainer(t *testing.T, pod *corev1.Pod) corev1.Container { + t.Helper() + for _, c := range pod.Spec.Containers { + if c.Name == "chrome" { + return c + } + } + t.Fatalf("no chrome container in pod %q", pod.Name) + return corev1.Container{} +} + +func gateContainer(t *testing.T, pod *corev1.Pod) corev1.Container { + t.Helper() + for _, c := range pod.Spec.Containers { + if c.Name == "gate" { + return c + } + } + t.Fatalf("no gate container in pod %q", pod.Name) + return corev1.Container{} +} + +func hasVolumeMount(mounts []corev1.VolumeMount, name string) bool { + for _, m := range mounts { + if m.Name == name { + return true + } + } + return false +} + +func hasPrefixArg(args []string, prefix string) bool { + for _, a := range args { + if strings.HasPrefix(a, prefix) { + return true + } + } + return false +} + +func TestBrowserPodName(t *testing.T) { + sess := sessForPod("s1") + assert.Equal(t, "s1-browser-web", agentsession.BrowserPodName(sess, "web")) +} + +func TestBrowserTokenSecretName(t *testing.T) { + sess := sessForPod("s1") + assert.Equal(t, "s1-browser-web-token", agentsession.BrowserTokenSecretName(sess, "web")) +} + +func TestBrowserPolicyConfigMapName(t *testing.T) { + sess := sessForPod("s1") + assert.Equal(t, "s1-browser-web-policy", agentsession.BrowserPolicyConfigMapName(sess, "web")) +} + +func TestBrowserNetworkPolicyName(t *testing.T) { + sess := sessForPod("s1") + assert.Equal(t, "s1-browser-web-netpol", agentsession.BrowserNetworkPolicyName(sess, "web")) +} + +func TestDeriveBrowserEffectiveSites(t *testing.T) { + spec := browserToolboxSpec("allowlist", + spiceboxv1alpha1.BrowserSite{Host: "AA.com"}, + spiceboxv1alpha1.BrowserSite{Host: "exact.example.com", Exact: true}, + ) + + got := agentsession.DeriveBrowserEffectiveSites(spec) + require.Len(t, got, 2, "1:1 with spec.Access.Sites") + + assert.Equal(t, "aa.com", got[0].Host, "host canonicalized (lowercased)") + assert.False(t, got[0].Exact) + assert.Equal(t, "explicit", got[0].Source) + + assert.Equal(t, "exact.example.com", got[1].Host) + assert.True(t, got[1].Exact, "Exact preserved") + assert.Equal(t, "explicit", got[1].Source) + + // Deterministic: repeated calls on the same input produce an identical + // result -- this is what makes the status write idempotent. + again := agentsession.DeriveBrowserEffectiveSites(spec) + assert.Equal(t, got, again) +} + +func TestDeriveBrowserEffectiveSites_Empty(t *testing.T) { + spec := browserToolboxSpec("allowlist") + assert.Empty(t, agentsession.DeriveBrowserEffectiveSites(spec)) +} + +// TestDeriveBrowserEffectiveSites_DropsUncanonicalizableHost proves the +// fail-closed drop branch: a host CanonicalHost rejects (embedded space here; +// the CRD Pattern forbids it, but the derivation must not rely on that) is +// DROPPED while derivation continues -- the valid neighbors still come +// through, canonicalized, in order, Source "explicit". This branch is what +// resolves the Task 1 review finding (browserpolicy.Match assumes canonical +// hosts), so it is pinned by test, not inspection. +func TestDeriveBrowserEffectiveSites_DropsUncanonicalizableHost(t *testing.T) { + spec := browserToolboxSpec("allowlist", + spiceboxv1alpha1.BrowserSite{Host: "aa.com"}, + spiceboxv1alpha1.BrowserSite{Host: "bad host"}, // fails CanonicalHost (whitespace) + spiceboxv1alpha1.BrowserSite{Host: "EXAMPLE.org."}, // valid: lowercased + trailing dot stripped + ) + + got := agentsession.DeriveBrowserEffectiveSites(spec) + assert.Equal(t, []spiceboxv1alpha1.BrowserEffectiveSite{ + {Host: "aa.com", Source: "explicit"}, + {Host: "example.org", Source: "explicit"}, + }, got, "exactly the two valid entries, canonicalized, order preserved; the invalid host is dropped") +} + +func TestBuildBrowserPolicyDoc(t *testing.T) { + spec := browserToolboxSpec("blocklist", spiceboxv1alpha1.BrowserSite{Host: "evil.example"}) + rt := resolvedBrowserToolbox("web", spec) + + doc := agentsession.BuildBrowserPolicyDoc(rt) + assert.Equal(t, "blocklist", doc.Mode) + require.Len(t, doc.Sites, 1) + assert.Equal(t, "evil.example", doc.Sites[0].Host) + assert.False(t, doc.Sites[0].Exact) +} + +func TestBuildBrowserPodFlags(t *testing.T) { + sess := sessForPod("s1") + rt := resolvedBrowserToolbox("web", browserToolboxSpec("allowlist")) + + t.Run("unsandboxed cloud kind appends --no-sandbox", func(t *testing.T) { + pod, err := agentsession.BuildBrowserPod(sess, rt, "ghcr.io/x/browser:v1", + cloud.BrowserChromeSandbox{Sandboxed: false, Reason: "no verified unprivileged userns"}) + require.NoError(t, err) + c := chromeContainer(t, pod) + assert.Contains(t, c.Command, "--headless=new") + assert.Contains(t, c.Command, "--proxy-server=127.0.0.1:3128") + assert.Contains(t, c.Command, "--proxy-bypass-list=<-loopback>") + assert.Contains(t, c.Command, "--no-sandbox") + assert.Contains(t, c.Command, "about:blank") + assert.True(t, hasPrefixArg(c.Command, "--user-agent=Mozilla/5.0"), "UA flag present") + assert.True(t, hasPrefixArg(c.Command, "--user-data-dir=/home/chrome/profile"), "user-data-dir flag present") + // Crashpad under ReadOnlyRootFilesystem: without these, chromium exits + // 133 at boot on a real kubelet ("--database is required"). + assert.Contains(t, c.Command, "--disable-crash-reporter") + assert.Contains(t, c.Command, "--disable-breakpad") + assert.Contains(t, c.Command, "--crash-dumps-dir=/tmp") + assert.Contains(t, c.Env, corev1.EnvVar{Name: "HOME", Value: "/home/chrome/profile"}, + "HOME points at the writable profile emptyDir") + }) + + t.Run("sandboxed cloud kind omits --no-sandbox", func(t *testing.T) { + pod, err := agentsession.BuildBrowserPod(sess, rt, "ghcr.io/x/browser:v1", + cloud.BrowserChromeSandbox{Sandboxed: true, Reason: "verified unprivileged userns"}) + require.NoError(t, err) + c := chromeContainer(t, pod) + assert.NotContains(t, c.Command, "--no-sandbox") + }) +} + +func TestBuildBrowserPodTokenNeverLiteral(t *testing.T) { + sess := sessForPod("s1") + // Sentinel woven only into the ref (and so the derived Secret NAME) -- + // BuildBrowserPod never accepts a raw token VALUE as an argument at all, + // so this also proves structurally that no literal secret value can + // leak into the pod it builds. + const sentinelRef = "super-secret-sentinel-token-value" + rt := resolvedBrowserToolbox(sentinelRef, browserToolboxSpec("allowlist")) + + pod, err := agentsession.BuildBrowserPod(sess, rt, "ghcr.io/x/browser:v1", cloud.BrowserChromeSandbox{}) + require.NoError(t, err) + + gate := gateContainer(t, pod) + var tokenEnv *corev1.EnvVar + for i := range gate.Env { + if gate.Env[i].Name == "APBROWSERGATE_TOKEN" { + tokenEnv = &gate.Env[i] + } + } + require.NotNil(t, tokenEnv, "gate must set APBROWSERGATE_TOKEN") + assert.Empty(t, tokenEnv.Value, "no literal token value") + require.NotNil(t, tokenEnv.ValueFrom, "token must be sourced via ValueFrom, never a literal Value") + require.NotNil(t, tokenEnv.ValueFrom.SecretKeyRef, "token must come from a SecretKeyRef") + assert.Equal(t, agentsession.BrowserTokenSecretName(sess, sentinelRef), tokenEnv.ValueFrom.SecretKeyRef.Name) + assert.Equal(t, "token", tokenEnv.ValueFrom.SecretKeyRef.Key) + + raw, err := json.Marshal(pod) + require.NoError(t, err) + assert.NotContains(t, string(raw), `"value":"`+sentinelRef+`"`, + "the sentinel must never appear as a literal env Value (it legitimately appears in names)") +} + +func TestBuildBrowserPodShape(t *testing.T) { + sess := sessForPod("s1") + rt := resolvedBrowserToolbox("web", browserToolboxSpec("allowlist")) + + pod, err := agentsession.BuildBrowserPod(sess, rt, "ghcr.io/x/browser:v1", cloud.BrowserChromeSandbox{}) + require.NoError(t, err) + + assert.Equal(t, "s1-browser-web", pod.Name) + assert.Equal(t, "default", pod.Namespace) + + require.Len(t, pod.Spec.Containers, 2, "chrome + gate") + var names []string + for _, c := range pod.Spec.Containers { + names = append(names, c.Name) + } + assert.ElementsMatch(t, []string{"chrome", "gate"}, names) + + require.Len(t, pod.OwnerReferences, 1) + owner := pod.OwnerReferences[0] + assert.Equal(t, "AgentSession", owner.Kind) + assert.Equal(t, "s1", owner.Name) + assert.Equal(t, types.UID("sess-uid-123"), owner.UID) + require.NotNil(t, owner.Controller) + assert.True(t, *owner.Controller) + require.NotNil(t, owner.BlockOwnerDeletion) + assert.True(t, *owner.BlockOwnerDeletion) + + require.NotNil(t, pod.Spec.AutomountServiceAccountToken) + assert.False(t, *pod.Spec.AutomountServiceAccountToken, "browser pod must not mount an SA token") + + gate := gateContainer(t, pod) + chrome := chromeContainer(t, pod) + + // Policy ConfigMap volume mounted in gate only. + assert.True(t, hasVolumeMount(gate.VolumeMounts, "policy"), "gate must mount the policy volume") + assert.False(t, hasVolumeMount(chrome.VolumeMounts, "policy"), "chrome must NOT mount the policy volume") + + // Profile emptyDir mounted in chrome only. + assert.True(t, hasVolumeMount(chrome.VolumeMounts, "profile"), "chrome must mount the profile volume") + assert.False(t, hasVolumeMount(gate.VolumeMounts, "profile"), "gate must NOT mount the profile volume") + + // Labels identify session + browsertoolbox ref. + assert.Equal(t, "s1", pod.Labels["agentprimitives.authzed.com/agentsession"]) + assert.Equal(t, "web", pod.Labels["agentprimitives.authzed.com/browsertoolbox"]) + + // Gate stays fully hardened: no per-container UID override, standard + // hardened container security context. + require.NotNil(t, gate.SecurityContext) + assert.Nil(t, gate.SecurityContext.RunAsUser, "gate inherits the pod-level UID; no override") + require.NotNil(t, gate.SecurityContext.ReadOnlyRootFilesystem) + assert.True(t, *gate.SecurityContext.ReadOnlyRootFilesystem) + require.NotNil(t, gate.SecurityContext.AllowPrivilegeEscalation) + assert.False(t, *gate.SecurityContext.AllowPrivilegeEscalation) + + // Gate's startup probe hits /healthz on the fixed gate port. + require.NotNil(t, gate.StartupProbe) + require.NotNil(t, gate.StartupProbe.HTTPGet) + assert.Equal(t, "/healthz", gate.StartupProbe.HTTPGet.Path) + assert.Equal(t, int32(9223), gate.StartupProbe.HTTPGet.Port.IntVal) + + // Chrome's RunAsUser/RunAsGroup are overridden to match the browser + // image's one declared account (images/browser/Dockerfile: useradd + // --uid 65532 chrome); ReadOnlyRootFilesystem/Capabilities/ + // AllowPrivilegeEscalation stay hardened (not relaxed). + require.NotNil(t, chrome.SecurityContext) + require.NotNil(t, chrome.SecurityContext.RunAsUser) + assert.Equal(t, int64(65532), *chrome.SecurityContext.RunAsUser) + require.NotNil(t, chrome.SecurityContext.RunAsGroup) + assert.Equal(t, int64(65532), *chrome.SecurityContext.RunAsGroup) + require.NotNil(t, chrome.SecurityContext.ReadOnlyRootFilesystem) + assert.True(t, *chrome.SecurityContext.ReadOnlyRootFilesystem, "chrome does not relax ReadOnlyRootFilesystem") + require.NotNil(t, chrome.SecurityContext.AllowPrivilegeEscalation) + assert.False(t, *chrome.SecurityContext.AllowPrivilegeEscalation) + + // Pod-level hardened context (RunAsNonRoot, FSGroup) is present. + require.NotNil(t, pod.Spec.SecurityContext) + require.NotNil(t, pod.Spec.SecurityContext.RunAsNonRoot) + assert.True(t, *pod.Spec.SecurityContext.RunAsNonRoot) + require.NotNil(t, pod.Spec.SecurityContext.FSGroup) + assert.Equal(t, int64(1000), *pod.Spec.SecurityContext.FSGroup) +} + +func TestBuildBrowserPod_EmptyImageRef_Errors(t *testing.T) { + sess := sessForPod("s1") + rt := resolvedBrowserToolbox("web", browserToolboxSpec("allowlist")) + _, err := agentsession.BuildBrowserPod(sess, rt, "", cloud.BrowserChromeSandbox{}) + require.Error(t, err) +} diff --git a/pkg/controllers/agentsession/controller.go b/pkg/controllers/agentsession/controller.go index f7d83082..772d9186 100644 --- a/pkg/controllers/agentsession/controller.go +++ b/pkg/controllers/agentsession/controller.go @@ -49,6 +49,7 @@ import ( "github.com/authzed/openagentprimitives/pkg/memory/kinds/auditkey" "github.com/authzed/openagentprimitives/pkg/memory/provenance" "github.com/authzed/openagentprimitives/pkg/memory/tokens" + "github.com/authzed/openagentprimitives/pkg/platform/cloud" "github.com/authzed/openagentprimitives/pkg/platform/identity/credkind" credkindregistry "github.com/authzed/openagentprimitives/pkg/platform/identity/credkind/registry" "github.com/authzed/openagentprimitives/pkg/platform/identity/credresolve" @@ -98,6 +99,13 @@ import ( // +kubebuilder:rbac:groups=agentprimitives.authzed.com,resources=agentsessions/status,verbs=get;update;patch // +kubebuilder:rbac:groups=agentprimitives.authzed.com,resources=agentsessions/finalizers,verbs=update // +kubebuilder:rbac:groups=agentprimitives.authzed.com,resources=agentclasses,verbs=get;list;watch +// BrowserToolbox: `get` resolves each referenced CR at session start to snapshot +// its spec into status.resolvedBrowserToolboxes and derive the per-session +// browser pod/policy/token; `list;watch` back the Owns/Watches cache. Read-only — +// the AgentSession reconciler never writes a BrowserToolbox. (The per-session +// pod/secret/configmap/networkpolicy verbs are already granted above for the +// sidecar path.) +// +kubebuilder:rbac:groups=agentprimitives.authzed.com,resources=browsertoolboxes,verbs=get;list;watch // +kubebuilder:rbac:groups=agentprimitives.authzed.com,resources=channels,verbs=get;list;watch // +kubebuilder:rbac:groups=agentprimitives.authzed.com,resources=channels/status,verbs=update;patch // Skill bundles: opted-in Skills are read for their bundle digests, and @@ -807,6 +815,20 @@ type Reconciler struct { // omitted entirely, never sent as "". TrustedImageRegistry string + // BrowserImageRef is the first-party browser-toolbox image (chromium + + // apbrowsergate, images/browser/Dockerfile) the per-session browser pod runs. + // Resolved by the operator the same way RunnerImage/SandboxImage are — a flag + // defaulting to apimage.Browser.LocalRef() that `oap install` registry- + // qualifies — and passed to BuildBrowserPod. Empty fails the pod build with a + // clear error rather than creating a pod with no image. + BrowserImageRef string + + // BrowserSandbox is the resolved cloud kind's answer to whether the browser + // pod may rely on Chrome's own seccomp/userns sandbox (Sandboxed) instead of + // --no-sandbox. Phase A: every kind returns Sandboxed=false with a non-empty + // Reason, logged once at operator startup. Threaded into BuildBrowserPod. + BrowserSandbox cloud.BrowserChromeSandbox + // MonitoringPublish publishes framework MonitoringEvents onto the fixed // monitoring subject, which channelsd fans out to role=monitoring Channels. // Nil when NATS is unconfigured; capacity/scheduling events are then skipped, @@ -2594,6 +2616,122 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu } } + // 4d'. Browser toolboxes: resolve each referenced BrowserToolbox CR, mint its + // per-session gate token (read-before-mint keeps it byte-stable across + // re-reconciles and wakes), stamp its NetworkPolicy and policy ConfigMap + // BEFORE the pod exists, create the per-session browser pod, and reflect + // readiness into status. Mirrors the separate-pod sidecar branch above, minus + // the secret-output gate (a browser depends on nothing the runner produces) + // and minus token rotation (the token is read, not re-minted). A not-Ready + // browser pod sets browserPodNotReady, which joins the sidecar requeue + // decision below — so runner-pod creation is held until every browser pod is + // Ready, exactly as a satisfied-but-not-ready sidecar holds it. + resolvedBrowsers := make([]spiceboxv1alpha1.ResolvedBrowserToolbox, 0, len(ac.Spec.BrowserToolboxes)) + // browserPodNotReady marks a browser pod that exists but has not reported a + // Ready PodIP yet, so the reconcile requeues to reflect the IP on a later pass + // — and, until then, holds runner-pod creation. NOT a boot failure. + browserPodNotReady := false + // browserTerminalFailure marks a browser pod wedged unrecoverably while the + // runner is already live, so the requeue slows (a pod that will not recover + // does not deserve a 2s poll) while the runner surfaces the failure. + browserTerminalFailure := false + if len(ac.Spec.BrowserToolboxes) > 0 { + for _, ref := range ac.Spec.BrowserToolboxes { + var tb spiceboxv1alpha1.BrowserToolbox + if err := r.Client.Get(ctx, types.NamespacedName{Namespace: sess.Namespace, Name: ref.Ref}, &tb); err != nil { + if errors.IsNotFound(err) { + // A dangling ref is a terminal session-setup failure. The + // AgentClass validation (Task 4) makes this unreachable in steady + // state, but fail closed anyway — this Get is the only gate at + // session time. + return r.markBootFailed(ctx, &sess, + spiceboxv1alpha1.ReasonAgentSessionBrowserBootFailed, + fmt.Sprintf("BrowserToolbox %q missing in namespace %q", ref.Ref, sess.Namespace)) + } + return ctrl.Result{}, fmt.Errorf("get BrowserToolbox %q: %w", ref.Ref, err) + } + // DeepCopy before freezing: tb.Spec's inner slices alias the informer + // cache, and the resolved snapshot is mutated/read downstream. + tbSpec := *tb.Spec.DeepCopy() + rt := spiceboxv1alpha1.ResolvedBrowserToolbox{ + Name: ref.Name, + Ref: ref.Ref, + Spec: tbSpec, + EffectiveSites: DeriveBrowserEffectiveSites(tbSpec), + GatePort: browserGatePort, + } + // Read-before-mint: reuse an existing gate token so TokenHash — and the + // token the gate pod is injected from — stays byte-stable across + // re-reconciles and reap/wake. + if err := r.mintBrowserToken(ctx, &sess, &rt); err != nil { + return r.markBootFailed(ctx, &sess, + spiceboxv1alpha1.ReasonAgentSessionBrowserBootFailed, + fmt.Sprintf("mint gate token for browser %q: %v", ref.Ref, err)) + } + rt.BrowserPodName = BrowserPodName(&sess, rt.Ref) + // Stamp the NetworkPolicy BEFORE the pod exists (an enforcing CNI must + // never see an unconstrained browser pod), then the policy ConfigMap the + // gate mounts, then create the pod — same ordering the sidecar path uses. + if err := r.ensureBrowserNetworkPolicy(ctx, &sess, rt); err != nil { + return ctrl.Result{}, err + } + if err := r.ensureBrowserPolicyConfigMap(ctx, &sess, rt); err != nil { + return ctrl.Result{}, err + } + podState, podErr := r.reconcileBrowserPod(ctx, &sess, rt) + if podErr != nil { + return r.markBootFailed(ctx, &sess, + spiceboxv1alpha1.ReasonAgentSessionBrowserBootFailed, + fmt.Sprintf("reconcile browser pod for %q: %v", ref.Ref, podErr)) + } + rt.PodFailure = podState.Failure + if podState.Failure != nil { + detail := podState.Failure.Reason + if podState.Failure.Message != "" { + detail += " — " + podState.Failure.Message + } + // Before the runner exists nothing can relay the failure, so the + // terminal phase (which channelsd relays) is the only way a human + // learns why the session is stuck; once the runner is live the agent + // surfaces it off this status record and the session keeps serving. + if !podAlreadyStarted(&sess) { + return r.markBootFailed(ctx, &sess, + spiceboxv1alpha1.ReasonAgentSessionBrowserBootFailed, + fmt.Sprintf("browser %q pod %q failed to start: %s", rt.Ref, rt.BrowserPodName, detail)) + } + browserTerminalFailure = true + browserPodNotReady = true + log.FromContext(ctx).Info("browser pod terminally failed; recorded for the runner to surface", + "session", sess.Namespace+"/"+sess.Name, "browser", rt.Ref, "pod", rt.BrowserPodName, "detail", detail) + } else if podState.PodIP == "" { + // Created but not Ready yet; requeue and reflect the PodIP on a later + // pass. Holds the runner until then. + browserPodNotReady = true + log.FromContext(ctx).Info("browser pod not Ready yet; awaiting PodIP", + "session", sess.Namespace+"/"+sess.Name, "browser", rt.Ref, "pod", rt.BrowserPodName) + } else { + rt.BrowserPodIP = podState.PodIP + } + resolvedBrowsers = append(resolvedBrowsers, rt) + } + sess.Status.ResolvedBrowserToolboxes = resolvedBrowsers + } + + // Reap browser pods whose toolbox ref has left the resolved set — a browser + // toolbox removed from the AgentClass mid-session. Runs unconditionally (even + // at zero browsers); passing the current set keeps live ones. reapSessionPods + // passes nil to orphan them all. + { + currentBrowserRefs := make(map[string]bool, len(resolvedBrowsers)) + for _, rt := range resolvedBrowsers { + currentBrowserRefs[rt.Ref] = true + } + if cleanupErr := r.cleanupOrphanedBrowserPods(ctx, &sess, currentBrowserRefs); cleanupErr != nil { + // A failed delete is worth retrying; the helper has already logged. + return ctrl.Result{}, cleanupErr + } + } + // A separate-pod sidecar awaiting its secret-output must NOT hold the runner: // the gating secret is produced BY the runner, so returning here would // DEADLOCK the session in Pending — the secret cannot appear without a @@ -2617,9 +2755,13 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu // and the persisted hash keeps the rotation from being re-detected. The runner // pod may still be created meanwhile — it tolerates an unresolved // SidecarPodIP. - if sidecarPodNotReady { + // + // A per-session browser pod that exists but is not Ready joins this same + // decision (browserPodNotReady): the runner must not start until every browser + // pod it will drive is Ready, exactly like a satisfied-but-not-ready sidecar. + if sidecarPodNotReady || browserPodNotReady { requeue := 2 * time.Second - if sidecarTerminalFailure { + if sidecarTerminalFailure || browserTerminalFailure { requeue = sidecarTerminalRequeue } return ctrl.Result{RequeueAfter: requeue}, r.applyStatus(ctx, &sess) @@ -3240,6 +3382,14 @@ func (r *Reconciler) reapSessionPods(ctx context.Context, sess *spiceboxv1alpha1 "session", sess.Namespace+"/"+sess.Name, "err", err.Error()) return ctrl.Result{}, fmt.Errorf("reap detector/cosidecar pods: %w", err) } + // 4. Browser pods. Same shape, own label: nil orphans every browser pod and + // reaps its NetworkPolicy + policy ConfigMap, while deliberately KEEPING the + // gate-token Secret so a wake recreates the pod with the same token. + if err := r.cleanupOrphanedBrowserPods(ctx, sess, nil); err != nil { + logger.Info("reap: cleanup browser pods failed; will retry", + "session", sess.Namespace+"/"+sess.Name, "err", err.Error()) + return ctrl.Result{}, fmt.Errorf("reap browser pods: %w", err) + } logger.Info("reaped session pods; AgentSession + PVCs + Secrets kept", "session", sess.Namespace+"/"+sess.Name, "grace", r.FailedSandboxReapGrace.String(), diff --git a/pkg/controllers/agentsession/cosidecar/cosidecar.go b/pkg/controllers/agentsession/cosidecar/cosidecar.go index 8dc156a9..b9ea0c01 100644 --- a/pkg/controllers/agentsession/cosidecar/cosidecar.go +++ b/pkg/controllers/agentsession/cosidecar/cosidecar.go @@ -39,6 +39,15 @@ func CredentialSecretName(sessionName, ref string) string { // selectors agree. const labelSidecar = "agentprimitives.authzed.com/sidecartoolbox" +// labelBrowser is the label key that identifies a per-session browser pod by its +// ref — the sibling of labelSidecar. It is mirrored here (same literal as the +// parent package's labelBrowserToolbox) so this package's ingress peer selector +// can exclude browser pods WITHOUT importing the parent (an import cycle). A +// browser pod carries the session label but is NOT the runner, so admitting it +// to a sidecar's runner-only ingress would let a hostile page reach that +// sidecar's credentialed MCP port. +const labelBrowser = "agentprimitives.authzed.com/browsertoolbox" + // labelSession is the label key that carries the owning AgentSession name on // every per-session pod and NetworkPolicy. const labelSession = "agentprimitives.authzed.com/agentsession" @@ -258,9 +267,13 @@ func BuildNetworkPolicy(sess *spiceboxv1alpha1.AgentSession, s Spec) *networking From: []networkingv1.NetworkPolicyPeer{{ PodSelector: &metav1.LabelSelector{ MatchLabels: map[string]string{labelSession: sess.Name}, - MatchExpressions: []metav1.LabelSelectorRequirement{{ - Key: labelSidecar, Operator: metav1.LabelSelectorOpDoesNotExist, - }}, + // The runner is the one session pod carrying NEITHER a sidecartoolbox + // NOR a browsertoolbox label; excluding BOTH keeps a browser pod (a + // hostile-page risk) out of this sidecar's runner-only ingress. + MatchExpressions: []metav1.LabelSelectorRequirement{ + {Key: labelSidecar, Operator: metav1.LabelSelectorOpDoesNotExist}, + {Key: labelBrowser, Operator: metav1.LabelSelectorOpDoesNotExist}, + }, }, }}, Ports: []networkingv1.NetworkPolicyPort{{ diff --git a/pkg/controllers/agentsession/cosidecar/cosidecar_test.go b/pkg/controllers/agentsession/cosidecar/cosidecar_test.go index 30e73bce..b6cd983b 100644 --- a/pkg/controllers/agentsession/cosidecar/cosidecar_test.go +++ b/pkg/controllers/agentsession/cosidecar/cosidecar_test.go @@ -360,12 +360,19 @@ func TestBuildNetworkPolicy_ingressRunnerOnly(t *testing.T) { sess := &spiceboxv1alpha1.AgentSession{ObjectMeta: metav1.ObjectMeta{Name: "s6", Namespace: "ns"}} np := BuildNetworkPolicy(sess, Spec{Ref: "det", Port: 7777, Egress: EgressPolicy{Denied: true}}) require.Len(t, np.Spec.Ingress, 1) - // The ingress rule has a peer that includes the session label + DoesNotExist on sidecar label + // The ingress peer includes the session label + DoesNotExist on BOTH the + // sidecar and browser labels — the runner is the one session pod carrying + // neither, and a browser pod must not be admitted to a sidecar's ingress. require.Len(t, np.Spec.Ingress[0].From, 1) peer := np.Spec.Ingress[0].From[0] require.NotNil(t, peer.PodSelector) assert.Equal(t, "s6", peer.PodSelector.MatchLabels[labelSession]) - require.Len(t, peer.PodSelector.MatchExpressions, 1) - assert.Equal(t, metav1.LabelSelectorOpDoesNotExist, peer.PodSelector.MatchExpressions[0].Operator) - assert.Equal(t, labelSidecar, peer.PodSelector.MatchExpressions[0].Key) + require.Len(t, peer.PodSelector.MatchExpressions, 2, "excludes BOTH sidecar and browser pods") + var excluded []string + for _, me := range peer.PodSelector.MatchExpressions { + assert.Equal(t, metav1.LabelSelectorOpDoesNotExist, me.Operator) + excluded = append(excluded, me.Key) + } + assert.ElementsMatch(t, []string{labelSidecar, labelBrowser}, excluded, + "ingress peer must exclude both sidecar and browser pods") } diff --git a/pkg/controllers/agentsession/netpol.go b/pkg/controllers/agentsession/netpol.go index 05422e67..2c206466 100644 --- a/pkg/controllers/agentsession/netpol.go +++ b/pkg/controllers/agentsession/netpol.go @@ -164,21 +164,31 @@ func modeEgressRules(mode spiceboxv1alpha1.NetworkMode) []networkingv1.NetworkPo // BuildRunnerNetworkPolicy isolates the session's runner pod: all // ingress denied (probes and pods/exec are kubelet/apiserver-mediated // and unaffected), egress restricted to DNS, the control-plane -// services, separate-pod sidecar MCP servers, and external TCP -// 443/6443 (LLM / remote MCP / apiserver — no portable apiserver -// selector exists; see config/networkpolicy/egress-external.yaml for -// the same rationale). In-pod sidecar containers share the runner -// pod's network namespace, so their upstream HTTPS calls ride the -// external rule. +// services, separate-pod sidecar MCP servers, this session's browser +// pod(s), and external TCP 443/6443 (LLM / remote MCP / apiserver — no +// portable apiserver selector exists; see +// config/networkpolicy/egress-external.yaml for the same rationale). +// In-pod sidecar containers share the runner pod's network namespace, +// so their upstream HTTPS calls ride the external rule. // -// Separate-pod sidecar toolbox pods carry the same -// agentprimitives.authzed.com/agentsession label as the runner pod -// (plus agentprimitives.authzed.com/sidecartoolbox). The policy's -// PodSelector excludes them via a DoesNotExist expression on the -// sidecartoolbox label so that this policy does not deny their ingress. -// A dedicated egress rule allows runner→sidecar traffic on all ports -// (port numbers are allocated dynamically by AllocatePorts and are not -// statically known at policy-build time). +// Separate-pod sidecar toolbox pods AND per-session browser pods carry the same +// agentprimitives.authzed.com/agentsession label as the runner pod (plus their +// own agentprimitives.authzed.com/sidecartoolbox or +// agentprimitives.authzed.com/browsertoolbox label). The policy's PodSelector +// excludes BOTH via DoesNotExist expressions so that this runner policy — with +// its full egress to the operator, SpiceDB, NATS and external 443/6443 — never +// unions onto a per-toolbox pod. Omitting the browser exclusion would let a +// hostile page driving Chrome inherit the runner's entire egress; see the +// matching exclusion in cosidecar.BuildNetworkPolicy's ingress peer. A dedicated +// egress rule allows runner→sidecar traffic on all ports (port numbers are +// allocated dynamically by AllocatePorts and are not statically known at +// policy-build time). A second dedicated egress rule allows runner→browser +// traffic on exactly browserGatePort (9223): unlike a sidecar's dynamically +// allocated MCP port, the browser gate's port is fixed, so (unlike the sidecar +// rule) it can and does name the port. Without this rule the runner's own dial +// to http://:9223 (pkg/agent/tool/browser) is denied by its own +// egress policy on an enforcing CNI — NetworkPolicy is the AND of both ends, +// and BuildBrowserNetworkPolicy's ingress rule alone is not sufficient. func BuildRunnerNetworkPolicy(sess *spiceboxv1alpha1.AgentSession, cfg NetpolConfig) *networkingv1.NetworkPolicy { egress := []networkingv1.NetworkPolicyEgressRule{ netpolrule.DNSEgress(), @@ -214,6 +224,24 @@ func BuildRunnerNetworkPolicy(sess *spiceboxv1alpha1.AgentSession, cfg NetpolCon }, }}, }) + // This session's per-session browser pod(s), same namespace (no + // namespaceSelector), on exactly the browser gate's fixed port + // (browserGatePort — never dynamically allocated like a sidecar's MCP + // port, so unlike the sidecar rule above this one CAN and does name the + // port). Mirrors BuildBrowserNetworkPolicy's ingress rule, which allows + // exactly this port from the runner peer. Selects nothing when the + // session has no browser toolbox. + egress = append(egress, networkingv1.NetworkPolicyEgressRule{ + To: []networkingv1.NetworkPolicyPeer{{ + PodSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"agentprimitives.authzed.com/agentsession": sess.Name}, + MatchExpressions: []metav1.LabelSelectorRequirement{{ + Key: labelBrowserToolbox, Operator: metav1.LabelSelectorOpExists, + }}, + }, + }}, + Ports: []networkingv1.NetworkPolicyPort{netpolrule.Port(corev1.ProtocolTCP, browserGatePort)}, + }) egress = append(egress, networkingv1.NetworkPolicyEgressRule{ To: []networkingv1.NetworkPolicyPeer{{IPBlock: &networkingv1.IPBlock{CIDR: "0.0.0.0/0"}}}, Ports: []networkingv1.NetworkPolicyPort{ @@ -235,9 +263,10 @@ func BuildRunnerNetworkPolicy(sess *spiceboxv1alpha1.AgentSession, cfg NetpolCon Spec: networkingv1.NetworkPolicySpec{ PodSelector: metav1.LabelSelector{ MatchLabels: map[string]string{"agentprimitives.authzed.com/agentsession": sess.Name}, - MatchExpressions: []metav1.LabelSelectorRequirement{{ - Key: labelSidecarToolbox, Operator: metav1.LabelSelectorOpDoesNotExist, - }}, + MatchExpressions: []metav1.LabelSelectorRequirement{ + {Key: labelSidecarToolbox, Operator: metav1.LabelSelectorOpDoesNotExist}, + {Key: labelBrowserToolbox, Operator: metav1.LabelSelectorOpDoesNotExist}, + }, }, PolicyTypes: []networkingv1.PolicyType{networkingv1.PolicyTypeIngress, networkingv1.PolicyTypeEgress}, Egress: egress, @@ -393,6 +422,108 @@ func BuildSidecarNetworkPolicy(sess *spiceboxv1alpha1.AgentSession, rt spiceboxv return np } +// browserEgressExceptCIDRs excludes RFC1918 private ranges, link-local +// (169.254.0.0/16 -- which also covers the cloud-metadata address +// 169.254.169.254), and the CGNAT shared-address space (100.64.0.0/10) from +// the browser pod's "anywhere" TCP 80/443 egress rule. +// +// This is deliberately not the same literal set any Go-side check already +// covers: browsergate's own dialer (pkg/tools/browsergate) refuses a +// resolved private/metadata/loopback address but has no CGNAT carve-out +// (RFC 6598 space is not "private" in net/netip's sense) -- this +// NetworkPolicy except-list is the layer that closes that specific gap, at +// the CNI level, for every destination the gate's own guard does not +// already deny. +var browserEgressExceptCIDRs = []string{ + "10.0.0.0/8", + "172.16.0.0/12", + "192.168.0.0/16", + "169.254.0.0/16", + "100.64.0.0/10", +} + +// BuildBrowserNetworkPolicy isolates one per-session browser pod (spec +// §2.2). +// +// Egress: DNS to the cluster resolver (same shape as BuildRunnerNetworkPolicy's +// own DNS rule), then TCP 80+443 to 0.0.0.0/0 EXCEPT the private/link-local/ +// CGNAT ranges in browserEgressExceptCIDRs. Hostnames are not expressible in +// vanilla NetworkPolicy, so rt.EffectiveSites remains the source of truth for +// a DNS-aware CNI to narrow further, same as every other allowlist-mode +// egress rule in this package. +// +// Ingress: only from this session's runner pod -- agentsession label +// present, with NEITHER sidecartoolbox NOR browsertoolbox present. Excluding +// BOTH labels (not just browsertoolbox) is what distinguishes the runner +// from every per-toolbox pod sharing its namespace, including this +// toolbox's own browser-pod siblings -- on exactly the gate's fixed port +// (browserGatePort, never dynamically allocated like a sidecar's MCP port). +// +// Returns nil when cfg.Enabled is false, matching every other builder's +// disabled behavior in this package. Unlike BuildRunnerNetworkPolicy/ +// BuildSidecarNetworkPolicy (whose Enabled check lives only in their +// ensure*NetworkPolicy wrapper), that check lives IN this builder so it +// stays callable, and testable, as a pure function with no fake client. +func BuildBrowserNetworkPolicy(sess *spiceboxv1alpha1.AgentSession, rt spiceboxv1alpha1.ResolvedBrowserToolbox, cfg NetpolConfig) *networkingv1.NetworkPolicy { + if !cfg.Enabled { + return nil + } + + except := make([]string, len(browserEgressExceptCIDRs)) + copy(except, browserEgressExceptCIDRs) + + egress := []networkingv1.NetworkPolicyEgressRule{ + netpolrule.DNSEgress(), + { + To: []networkingv1.NetworkPolicyPeer{{IPBlock: &networkingv1.IPBlock{ + CIDR: "0.0.0.0/0", + Except: except, + }}}, + Ports: []networkingv1.NetworkPolicyPort{ + netpolrule.Port(corev1.ProtocolTCP, 80), + netpolrule.Port(corev1.ProtocolTCP, 443), + }, + }, + } + + ingress := []networkingv1.NetworkPolicyIngressRule{{ + From: []networkingv1.NetworkPolicyPeer{{ + PodSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"agentprimitives.authzed.com/agentsession": sess.Name}, + MatchExpressions: []metav1.LabelSelectorRequirement{ + {Key: labelSidecarToolbox, Operator: metav1.LabelSelectorOpDoesNotExist}, + {Key: labelBrowserToolbox, Operator: metav1.LabelSelectorOpDoesNotExist}, + }, + }, + }}, + Ports: []networkingv1.NetworkPolicyPort{netpolrule.Port(corev1.ProtocolTCP, browserGatePort)}, + }} + + return &networkingv1.NetworkPolicy{ + TypeMeta: metav1.TypeMeta{APIVersion: "networking.k8s.io/v1", Kind: "NetworkPolicy"}, + ObjectMeta: metav1.ObjectMeta{ + Name: BrowserNetworkPolicyName(sess, rt.Ref), + Namespace: sess.Namespace, + Labels: map[string]string{ + "agentprimitives.authzed.com/agentsession": sess.Name, + labelBrowserToolbox: rt.Ref, + }, + OwnerReferences: sessionOwnerRef(sess), + }, + Spec: networkingv1.NetworkPolicySpec{ + PodSelector: metav1.LabelSelector{ + MatchLabels: map[string]string{ + "agentprimitives.authzed.com/agentsession": sess.Name, + labelBrowserToolbox: rt.Ref, + }, + }, + PolicyTypes: []networkingv1.PolicyType{networkingv1.PolicyTypeIngress, networkingv1.PolicyTypeEgress}, + Ingress: ingress, + Egress: egress, + }, + } +} + // NamespaceDefaultDenyName is the fixed name of the per-namespace floor. It // carries no session in it on purpose: every session in a namespace converges // on ONE object, so N sessions do not stamp N identical policies and the floor diff --git a/pkg/controllers/agentsession/netpol_envtest_test.go b/pkg/controllers/agentsession/netpol_envtest_test.go index 69d99bbe..994cb24b 100644 --- a/pkg/controllers/agentsession/netpol_envtest_test.go +++ b/pkg/controllers/agentsession/netpol_envtest_test.go @@ -63,7 +63,7 @@ func TestReconcileStampsRunnerNetworkPolicy(t *testing.T) { np.Spec.PodSelector.MatchLabels) require.Len(t, np.OwnerReferences, 1) assert.Equal(t, "AgentSession", np.OwnerReferences[0].Kind) - assert.Len(t, np.Spec.Egress, 6, "DNS, NATS, operator, SpiceDB, sidecars, external") + assert.Len(t, np.Spec.Egress, 7, "DNS, NATS, operator, SpiceDB, sidecars, browser, external") assert.Empty(t, np.Spec.Ingress, "all ingress denied") } diff --git a/pkg/controllers/agentsession/netpol_test.go b/pkg/controllers/agentsession/netpol_test.go index 1af41f8d..f4aacc4b 100644 --- a/pkg/controllers/agentsession/netpol_test.go +++ b/pkg/controllers/agentsession/netpol_test.go @@ -117,21 +117,29 @@ func TestBuildRunnerNetworkPolicy(t *testing.T) { assert.Equal(t, "AgentSession", np.OwnerReferences[0].Kind) assert.Equal(t, "s1", np.OwnerReferences[0].Name) - // Selects exactly this session's runner pod (not sidecar pods, which - // carry the same agentsession label plus sidecartoolbox). + // Selects exactly this session's runner pod: NOT sidecar pods and NOT browser + // pods, both of which carry the same agentsession label plus their own + // per-toolbox label. assert.Equal(t, map[string]string{"agentprimitives.authzed.com/agentsession": "s1"}, np.Spec.PodSelector.MatchLabels) - require.Len(t, np.Spec.PodSelector.MatchExpressions, 1) - assert.Equal(t, "agentprimitives.authzed.com/sidecartoolbox", np.Spec.PodSelector.MatchExpressions[0].Key) - assert.Equal(t, metav1.LabelSelectorOpDoesNotExist, np.Spec.PodSelector.MatchExpressions[0].Operator) + require.Len(t, np.Spec.PodSelector.MatchExpressions, 2, "excludes BOTH sidecartoolbox and browsertoolbox pods") + var runnerExcluded []string + for _, me := range np.Spec.PodSelector.MatchExpressions { + assert.Equal(t, metav1.LabelSelectorOpDoesNotExist, me.Operator) + runnerExcluded = append(runnerExcluded, me.Key) + } + assert.ElementsMatch(t, []string{ + "agentprimitives.authzed.com/sidecartoolbox", + "agentprimitives.authzed.com/browsertoolbox", + }, runnerExcluded, "runner netpol must exclude both sidecar and browser pods") // Both directions isolated; no ingress rules → all ingress denied. assert.ElementsMatch(t, []networkingv1.PolicyType{networkingv1.PolicyTypeIngress, networkingv1.PolicyTypeEgress}, np.Spec.PolicyTypes) assert.Empty(t, np.Spec.Ingress) - // DNS, NATS, operator, SpiceDB (in-cluster), sidecars, external. - require.Len(t, np.Spec.Egress, 6) + // DNS, NATS, operator, SpiceDB (in-cluster), sidecars, browser, external. + require.Len(t, np.Spec.Egress, 7) assert.ElementsMatch(t, []string{"UDP/53", "TCP/53"}, egressPorts(np.Spec.Egress[0]), "DNS rule") assert.ElementsMatch(t, []string{"TCP/4222"}, egressPorts(np.Spec.Egress[1]), "NATS rule") assert.Equal(t, map[string]string{"app.kubernetes.io/name": "spicebox-nats"}, @@ -150,17 +158,41 @@ func TestBuildRunnerNetworkPolicy(t *testing.T) { assert.Equal(t, metav1.LabelSelectorOpExists, sidecarPeer.PodSelector.MatchExpressions[0].Operator) assert.Empty(t, np.Spec.Egress[4].Ports, "sidecar ports are dynamic — all ports allowed to those pods") - assert.ElementsMatch(t, []string{"TCP/443", "TCP/6443"}, egressPorts(np.Spec.Egress[5]), "external rule") - require.NotNil(t, np.Spec.Egress[5].To[0].IPBlock) - assert.Equal(t, "0.0.0.0/0", np.Spec.Egress[5].To[0].IPBlock.CIDR) + // Browser rule: this session's per-session browser pod(s), exactly + // browserGatePort (9223) -- mirrors the sidecar rule's shape but, unlike + // it, the port IS statically known, so the rule names it. + browserPeer := np.Spec.Egress[5].To[0] + assert.Nil(t, browserPeer.NamespaceSelector, "browser peer is same-namespace") + assert.Equal(t, map[string]string{"agentprimitives.authzed.com/agentsession": "s1"}, + browserPeer.PodSelector.MatchLabels) + require.Len(t, browserPeer.PodSelector.MatchExpressions, 1) + assert.Equal(t, "agentprimitives.authzed.com/browsertoolbox", browserPeer.PodSelector.MatchExpressions[0].Key) + assert.Equal(t, metav1.LabelSelectorOpExists, browserPeer.PodSelector.MatchExpressions[0].Operator) + assert.Equal(t, []string{"TCP/9223"}, egressPorts(np.Spec.Egress[5]), "runner egress to browser pods is restricted to exactly port 9223") + + assert.ElementsMatch(t, []string{"TCP/443", "TCP/6443"}, egressPorts(np.Spec.Egress[6]), "external rule") + require.NotNil(t, np.Spec.Egress[6].To[0].IPBlock) + assert.Equal(t, "0.0.0.0/0", np.Spec.Egress[6].To[0].IPBlock.CIDR) + + // Pairing: the runner's egress port to browser pods and the browser pod's + // own ingress port (asserted from the runner peer in + // TestBuildBrowserNetworkPolicy) must be the same constant, so the two + // halves of this policy pair cannot silently re-diverge. + rt := spiceboxv1alpha1.ResolvedBrowserToolbox{Ref: "web", GatePort: 9223} + browserNP := agentsession.BuildBrowserNetworkPolicy(sess, rt, testNetpolConfig()) + require.NotNil(t, browserNP) + require.Len(t, browserNP.Spec.Ingress, 1) + require.Len(t, browserNP.Spec.Ingress[0].Ports, 1) + assert.Equal(t, egressPorts(np.Spec.Egress[5]), policyPorts(browserNP.Spec.Ingress[0].Ports), + "runner egress port to browser pods must match the browser pod's own ingress port") } func TestBuildRunnerNetworkPolicy_ExternalSpiceDB_NoPeerRule(t *testing.T) { cfg := testNetpolConfig() cfg.SpiceDBInCluster = false np := agentsession.BuildRunnerNetworkPolicy(netpolSession("s1"), cfg) - // DNS, NATS, operator, sidecars, external — no SpiceDB peer rule. - require.Len(t, np.Spec.Egress, 5) + // DNS, NATS, operator, sidecars, browser, external — no SpiceDB peer rule. + require.Len(t, np.Spec.Egress, 6) for i, rule := range np.Spec.Egress { assert.NotContains(t, egressPorts(rule), "TCP/50051", "egress rule %d", i) } @@ -257,7 +289,9 @@ func TestBuildSidecarNetworkPolicy(t *testing.T) { // Ingress: ONLY this session's runner pod, on exactly the // sidecar's MCP port. The runner is the same-namespace pod with - // the agentsession label and NO sidecartoolbox label. + // the agentsession label and NEITHER a sidecartoolbox NOR a + // browsertoolbox label — excluding both keeps a browser pod out of + // this sidecar's ingress. require.Len(t, np.Spec.Ingress, 1) require.Len(t, np.Spec.Ingress[0].From, 1) runnerPeer := np.Spec.Ingress[0].From[0] @@ -265,11 +299,16 @@ func TestBuildSidecarNetworkPolicy(t *testing.T) { require.NotNil(t, runnerPeer.PodSelector) assert.Equal(t, map[string]string{"agentprimitives.authzed.com/agentsession": "s1"}, runnerPeer.PodSelector.MatchLabels) - require.Len(t, runnerPeer.PodSelector.MatchExpressions, 1) - assert.Equal(t, "agentprimitives.authzed.com/sidecartoolbox", - runnerPeer.PodSelector.MatchExpressions[0].Key) - assert.Equal(t, metav1.LabelSelectorOpDoesNotExist, - runnerPeer.PodSelector.MatchExpressions[0].Operator) + require.Len(t, runnerPeer.PodSelector.MatchExpressions, 2, "excludes BOTH sidecartoolbox and browsertoolbox pods") + var peerExcluded []string + for _, me := range runnerPeer.PodSelector.MatchExpressions { + assert.Equal(t, metav1.LabelSelectorOpDoesNotExist, me.Operator) + peerExcluded = append(peerExcluded, me.Key) + } + assert.ElementsMatch(t, []string{ + "agentprimitives.authzed.com/sidecartoolbox", + "agentprimitives.authzed.com/browsertoolbox", + }, peerExcluded, "sidecar ingress peer must exclude both sidecar and browser pods") require.Len(t, np.Spec.Ingress[0].Ports, 1) assert.ElementsMatch(t, []string{"TCP/18080"}, policyPorts(np.Spec.Ingress[0].Ports)) @@ -345,6 +384,71 @@ func TestBuildSidecarNetworkPolicy_WorkshopGatesOperatorAndAPIServerEgress(t *te }) } +func TestBuildBrowserNetworkPolicy(t *testing.T) { + sess := netpolSession("s1") + rt := spiceboxv1alpha1.ResolvedBrowserToolbox{Ref: "web", GatePort: 9223} + + np := agentsession.BuildBrowserNetworkPolicy(sess, rt, testNetpolConfig()) + require.NotNil(t, np) + + assert.Equal(t, "s1-browser-web-netpol", np.Name) + assert.Equal(t, "default", np.Namespace) + require.Len(t, np.OwnerReferences, 1) + assert.Equal(t, "AgentSession", np.OwnerReferences[0].Kind) + assert.Equal(t, "s1", np.OwnerReferences[0].Name) + + assert.Equal(t, map[string]string{ + "agentprimitives.authzed.com/agentsession": "s1", + "agentprimitives.authzed.com/browsertoolbox": "web", + }, np.Spec.PodSelector.MatchLabels) + assert.ElementsMatch(t, + []networkingv1.PolicyType{networkingv1.PolicyTypeIngress, networkingv1.PolicyTypeEgress}, + np.Spec.PolicyTypes) + + // Ingress: ONLY this session's runner pod (agentsession present, neither + // sidecartoolbox nor browsertoolbox present -- excluding BOTH labels is + // what distinguishes the runner from every sibling toolbox pod, this + // browser pod's own siblings included), on exactly the gate's fixed port. + require.Len(t, np.Spec.Ingress, 1) + require.Len(t, np.Spec.Ingress[0].From, 1) + runnerPeer := np.Spec.Ingress[0].From[0] + assert.Nil(t, runnerPeer.NamespaceSelector, "runner peer is same-namespace") + require.NotNil(t, runnerPeer.PodSelector) + assert.Equal(t, map[string]string{"agentprimitives.authzed.com/agentsession": "s1"}, + runnerPeer.PodSelector.MatchLabels) + require.Len(t, runnerPeer.PodSelector.MatchExpressions, 2, "excludes BOTH sidecartoolbox and browsertoolbox pods") + var excludedKeys []string + for _, me := range runnerPeer.PodSelector.MatchExpressions { + assert.Equal(t, metav1.LabelSelectorOpDoesNotExist, me.Operator) + excludedKeys = append(excludedKeys, me.Key) + } + assert.ElementsMatch(t, []string{ + "agentprimitives.authzed.com/sidecartoolbox", + "agentprimitives.authzed.com/browsertoolbox", + }, excludedKeys) + require.Len(t, np.Spec.Ingress[0].Ports, 1) + assert.ElementsMatch(t, []string{"TCP/9223"}, policyPorts(np.Spec.Ingress[0].Ports)) + + // Egress: DNS, then TCP 80+443 to 0.0.0.0/0 except the five CIDRs. + require.Len(t, np.Spec.Egress, 2) + assert.ElementsMatch(t, []string{"UDP/53", "TCP/53"}, egressPorts(np.Spec.Egress[0]), "DNS rule") + assert.ElementsMatch(t, []string{"TCP/80", "TCP/443"}, egressPorts(np.Spec.Egress[1]), "external rule") + require.Len(t, np.Spec.Egress[1].To, 1) + require.NotNil(t, np.Spec.Egress[1].To[0].IPBlock) + assert.Equal(t, "0.0.0.0/0", np.Spec.Egress[1].To[0].IPBlock.CIDR) + assert.ElementsMatch(t, []string{ + "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "169.254.0.0/16", "100.64.0.0/10", + }, np.Spec.Egress[1].To[0].IPBlock.Except) +} + +func TestBuildBrowserNetworkPolicy_DisabledReturnsNil(t *testing.T) { + cfg := testNetpolConfig() + cfg.Enabled = false + np := agentsession.BuildBrowserNetworkPolicy(netpolSession("s1"), + spiceboxv1alpha1.ResolvedBrowserToolbox{Ref: "web"}, cfg) + assert.Nil(t, np, "disabled must match the existing builders' disabled behavior") +} + // TestEnsureDetectorPod_PolicyIsStampedBeforeThePod pins the ordering rule the // runner, sandbox and sidecar paths all follow: the NetworkPolicy lands before // the pod it constrains, so an enforcing CNI never sees an unconstrained pod. diff --git a/pkg/controllers/agentsession/rbac.go b/pkg/controllers/agentsession/rbac.go index 3f14b51c..4f0ec68b 100644 --- a/pkg/controllers/agentsession/rbac.go +++ b/pkg/controllers/agentsession/rbac.go @@ -300,6 +300,30 @@ func BuildRunnerRBAC( ) } } + // The browser gate-token secrets `get` rule, pinned by name. The runner reads + // each per-session gate token to authenticate to its browser pod's gate + // (browsergate) over the pod network. The names are DETERMINISTIC from the + // session + ref (BrowserTokenSecretName), so — unlike the MCP secrets, which + // need the AgentIdentity's credentials resolved — they are derived here + // directly from the class's browser refs rather than threaded as a parameter. + // Same rule shape as the mcpSecretNames grant above. Empty ref entries are + // skipped; an empty name slice emits no rule. + if ac != nil && len(ac.Spec.BrowserToolboxes) > 0 { + browserSecretNames := make([]string, 0, len(ac.Spec.BrowserToolboxes)) + for _, ref := range ac.Spec.BrowserToolboxes { + if ref.Ref != "" { + browserSecretNames = append(browserSecretNames, BrowserTokenSecretName(s, ref.Ref)) + } + } + if len(browserSecretNames) > 0 { + role.Rules = append(role.Rules, rbacv1.PolicyRule{ + APIGroups: []string{""}, + Resources: []string{"secrets"}, + ResourceNames: browserSecretNames, + Verbs: []string{"get"}, + }) + } + } // The agentuis `get` rule is emitted whenever the AgentClass carries a UI // grant (spec.agentUI != nil): internal/cmd/runner fetches the referenced AgentUI // CR at session start to read spec.tools — condition (1) of the diff --git a/pkg/controllers/webhooks/agentsession/webhook.go b/pkg/controllers/webhooks/agentsession/webhook.go index 3f84682d..99b8a581 100644 --- a/pkg/controllers/webhooks/agentsession/webhook.go +++ b/pkg/controllers/webhooks/agentsession/webhook.go @@ -586,6 +586,15 @@ var runnerWritableStatusFields = map[string]string{ "the detector step, and builds the pod from that fresh local slice, never from what is persisted. The pod's name, " + "namespace and ownerRef are derived from the session (cosidecar.BuildPod), so a forged ref cannot name someone " + "else's. This waiver depends on effectiveSettings being pinned above", + "resolvedBrowserToolboxes": "the operator rebuilds the whole slice every reconcile from the AgentClass's " + + "browserToolboxes refs + each BrowserToolbox CR, and builds the pod/NetworkPolicy/policy-ConfigMap from that " + + "fresh local entry, never from what is persisted — unlike resolvedSidecarToolboxes, nothing reads the PRIOR " + + "tokenHash back (there is no browser token rotation: mintBrowserToken reads the per-session gate-token Secret " + + "itself, so tokenHash is recomputed from the Secret in hand, not trusted from status). Every object it names " + + "(pod, token Secret, ConfigMap, NetworkPolicy) is derived deterministically from the session + ref, so a forged " + + "ref cannot name another session's; the runner reading its OWN entry to reach its browser pod would only mislead " + + "the forging session. effectiveSites, like effectiveAllowedHosts, is a DNS-aware-CNI advisory recomputed from the " + + "CR each reconcile — a forged value is overwritten and, at worst, self-scoped", "interactorTuplesWritten": "the controller's dedup cache of subjects for whom the idempotent " + "agentclass#interactor SpiceDB touch has already landed (writeInteractorTuples); the runner never writes it. " + "can_personalize is derived from the LIVE relationship, not from this cache, so a forged entry grants nothing — " + diff --git a/pkg/platform/apimage/apimage.go b/pkg/platform/apimage/apimage.go index 34f64e8d..d8568c22 100644 --- a/pkg/platform/apimage/apimage.go +++ b/pkg/platform/apimage/apimage.go @@ -52,9 +52,10 @@ func (i Image) DigestRef(reg, digest string) string { return i.RegistryRef(reg) + "@" + digest } -// The eleven first-party platform images. The nine Go services share one +// The twelve first-party platform images. The nine Go services share one // builder stage in Dockerfile.services and select their own final stage with -// --target; sandbox and the detector have Dockerfiles of their own. +// --target; sandbox, the detector, and the browser toolbox have Dockerfiles of +// their own. var ( Operator = Image{Target: "operator", Name: "spicebox-operator", Dockerfile: "Dockerfile.services", Context: ".", Stage: "operator"} Sandbox = Image{Target: "sandbox", Name: "spicebox-sandbox", Dockerfile: "Dockerfile.sandbox", Context: "."} @@ -96,6 +97,17 @@ var ( // only for a session whose SidecarToolbox names it, never a standing // Deployment. Websearchd = Image{Target: "websearchd", Name: "ap-websearchd", Dockerfile: "Dockerfile.services", Context: ".", Stage: "websearchd"} + // Browser is the browser toolbox image: chromium plus apbrowsergate + // (internal/cmd/apbrowsergate), the policy-enforcing/token-authenticating + // gate that fronts it. A later pod builder runs TWO containers from this + // ONE image — Chrome and the gate — setting each container's Command + // explicitly, so the image needs no ENTRYPOINT contract beyond both + // binaries existing on PATH as a non-root user. Own Dockerfile (own apt + // dependency graph, not a thin stage over the shared Go builder), like + // Sandbox and Detector. Opt-in like Workshop, APIAdapter and Websearchd — + // pulled only for a session that requests a browser, never a standing + // Deployment. + Browser = Image{Target: "browser", Name: "ap-browser", Dockerfile: "images/browser/Dockerfile", Context: "."} ) // GoServices are the first-party images whose binaries are Go commands in this @@ -108,7 +120,7 @@ var ( var GoServices = []Image{Operator, Runner, Channelsd, Webd, Authzd, Extractord, Workshop, APIAdapter, Websearchd} // All is the canonical ordered list of every first-party image. -var All = []Image{Operator, Sandbox, Runner, Channelsd, Webd, Authzd, Extractord, Detector, Workshop, APIAdapter, Websearchd} +var All = []Image{Operator, Sandbox, Runner, Channelsd, Webd, Authzd, Extractord, Detector, Workshop, APIAdapter, Websearchd, Browser} // WorkshopSidecarImages are the first-party images a WORKSHOP-authored // SidecarToolbox may name in spec.source.image. It is the single source of @@ -122,13 +134,14 @@ var WorkshopSidecarImages = []Image{Workshop, APIAdapter} // regardless of which optional features are enabled — All minus the images // that are pulled only when a user opts into a feature. // -// Four opt-in first-party images exist: the prompt-injection Detector (off by +// Five opt-in first-party images exist: the prompt-injection Detector (off by // default; started only when enabled via the settings wizard), the // agent-builder Workshop sidecar (started only for a session whose class is // sanctioned via ClusterAgentSettings — see pkg/controllers/agentsession/workshop_hook.go), -// the declarative HTTP→MCP APIAdapter sidecar, and the search+fetch -// Websearchd sidecar (both started only for a session whose SidecarToolbox -// names them). Every other image in All backs an always-present part of a +// the declarative HTTP→MCP APIAdapter sidecar, the search+fetch Websearchd +// sidecar, and the Browser toolbox image (all four of these sidecars are +// started only for a session whose SidecarToolbox, or equivalent, names +// them). Every other image in All backs an always-present part of a // session: the control plane (Operator/Runner/Channelsd/Webd/Authzd/Extractord) // and Sandbox, the operator's --sandbox-image default used by any // SpiceboxClass that does not pin spec.image. @@ -139,7 +152,7 @@ var WorkshopSidecarImages = []Image{Workshop, APIAdapter} // ErrImagePull the instant a session starts: drop Sandbox and every agent chat // hangs on a sandbox pod pulling a tag that exists on no registry. func AlwaysOnImages() []Image { - optIn := map[string]bool{Detector.Name: true, Workshop.Name: true, APIAdapter.Name: true, Websearchd.Name: true} // see doc above + optIn := map[string]bool{Detector.Name: true, Workshop.Name: true, APIAdapter.Name: true, Websearchd.Name: true, Browser.Name: true} // see doc above out := make([]Image, 0, len(All)) for _, im := range All { if optIn[im.Name] { diff --git a/pkg/platform/apimage/apimage_test.go b/pkg/platform/apimage/apimage_test.go index a6b8b7a5..a8d0b1e1 100644 --- a/pkg/platform/apimage/apimage_test.go +++ b/pkg/platform/apimage/apimage_test.go @@ -55,8 +55,8 @@ func TestRefs(t *testing.T) { assert.Equal(t, "myreg.io/ap/pi-detector:dev", Detector.RegistryRef("myreg.io/ap/")) } -func TestAllEleven(t *testing.T) { - assert.Len(t, All, 11) +func TestAllTwelve(t *testing.T) { + assert.Len(t, All, 12) names := map[string]bool{} for _, im := range All { assert.NotEmpty(t, im.Name) @@ -68,7 +68,7 @@ func TestAllEleven(t *testing.T) { "spicebox-operator", "agentprimitives-runner", "agentprimitives-webd", "agentprimitives-authzd", "agentprimitives-channelsd", "spicebox-sandbox", "agentprimitives-extractord", "pi-detector", "ap-workshop", "ap-api-adapter", - "ap-websearchd", + "ap-websearchd", "ap-browser", } { assert.Truef(t, names[want], "All must include %q", want) } @@ -88,10 +88,10 @@ func TestAlwaysOnImages(t *testing.T) { assert.Truef(t, names[want.Name], "AlwaysOnImages must include %q (needed by every session)", want.Name) } // The prompt-injection detector, the agent-builder workshop sidecar, the - // declarative HTTP→MCP api-adapter sidecar, and the websearch search+fetch - // sidecar are all opt-in — pulled only when enabled/sanctioned/named by a - // SidecarToolbox. - optIn := map[string]bool{Detector.Name: true, Workshop.Name: true, APIAdapter.Name: true, Websearchd.Name: true} + // declarative HTTP→MCP api-adapter sidecar, the websearch search+fetch + // sidecar, and the browser toolbox image are all opt-in — pulled only when + // enabled/sanctioned/named by a SidecarToolbox (or equivalent). + optIn := map[string]bool{Detector.Name: true, Workshop.Name: true, APIAdapter.Name: true, Websearchd.Name: true, Browser.Name: true} for name := range optIn { assert.Falsef(t, names[name], "AlwaysOnImages must exclude the opt-in %q", name) } @@ -172,7 +172,7 @@ func TestVersion_DrivesRefs(t *testing.T) { } func TestCatalogIncludesToolchains(t *testing.T) { - assert.Len(t, All, 11, "All stays the platform images only") + assert.Len(t, All, 12, "All stays the platform images only") assert.Len(t, Catalog(), len(All)+len(Toolchains)) names := map[string]bool{} diff --git a/pkg/platform/cloud/aks/aks.go b/pkg/platform/cloud/aks/aks.go index 2753f53c..bb196b7a 100644 --- a/pkg/platform/cloud/aks/aks.go +++ b/pkg/platform/cloud/aks/aks.go @@ -102,6 +102,16 @@ func (s Strategy) Validate(ctx context.Context, p cloud.ValidateParams) error { return cloud.ValidateManagedPrefix(ctx, s, p) } +// BrowserChromeSandbox returns the Phase A posture: unsandboxed, pod boundary +// + gate only. AKS has not verified unprivileged user namespaces on its +// nodes. +func (Strategy) BrowserChromeSandbox() cloud.BrowserChromeSandbox { + return cloud.BrowserChromeSandbox{ + Sandboxed: false, + Reason: "unprivileged user namespaces not verified on AKS; Chrome runs --no-sandbox behind the pod boundary and the gate", + } +} + // aksWorkspaceStorage is the AKS cloud.WorkspaceStorage implementation. type aksWorkspaceStorage struct{} diff --git a/pkg/platform/cloud/cloud.go b/pkg/platform/cloud/cloud.go index d4833439..9d41ab68 100644 --- a/pkg/platform/cloud/cloud.go +++ b/pkg/platform/cloud/cloud.go @@ -112,6 +112,35 @@ type Strategy interface { // rather than halfway through a bring-up that leaves a crash-looping // cluster behind. Validate(ctx context.Context, p ValidateParams) error + + // BrowserChromeSandbox decides whether the browser-pod builder (a later + // task) may drop Chrome's own --no-sandbox flag in favor of its native + // seccomp/userns sandbox on this cluster kind. + // + // Phase A posture: every registered kind returns Sandboxed=false — no kind + // has yet verified unprivileged user namespaces are available on its nodes, + // which Chrome's sandbox requires. Reason is always non-empty; the browser + // pod builder logs it at startup regardless of Sandboxed, and the operator + // relies on that log line to say why Chrome is running unsandboxed. The + // per-kind flip to a verified seccomp-userns profile is spec §9's open + // item — it lands here, one kind at a time, without touching any consumer + // (the browser-pod builder reads this method, never a kind switch). + BrowserChromeSandbox() BrowserChromeSandbox +} + +// BrowserChromeSandbox is a cloud.Strategy's answer to whether Chrome's own +// sandbox (seccomp + unprivileged user namespaces) is safe to rely on inside +// the browser pod, on this cluster kind. +type BrowserChromeSandbox struct { + // Sandboxed is true only once this kind has verified unprivileged user + // namespaces are available on its nodes. False means the browser pod + // builder runs Chrome with --no-sandbox, relying on the pod boundary and + // the authorization gate instead. + Sandboxed bool + // Reason is always non-empty — it is recorded and logged by the browser-pod + // builder/operator at startup regardless of Sandboxed, so there is always a + // line in the log explaining the posture. + Reason string } var registry = struct { diff --git a/pkg/platform/cloud/cloud_test.go b/pkg/platform/cloud/cloud_test.go index 78b04ba3..fe531780 100644 --- a/pkg/platform/cloud/cloud_test.go +++ b/pkg/platform/cloud/cloud_test.go @@ -51,6 +51,9 @@ func (fakeStrategy) ResolveClusterIdentity(context.Context, ClusterIdentityParam } func (fakeStrategy) InstallProfile() InstallProfile { return ProductionProfile } func (fakeStrategy) Validate(context.Context, ValidateParams) error { return nil } +func (f fakeStrategy) BrowserChromeSandbox() BrowserChromeSandbox { + return BrowserChromeSandbox{Sandboxed: false, Reason: "fakeStrategy " + f.key + ": test fixture, not sandboxed"} +} // resetRegistry clears the shared registry before a test runs so the test can // register its own fakeStrategy fixtures against a blank slate, then restores diff --git a/pkg/platform/cloud/eks/eks.go b/pkg/platform/cloud/eks/eks.go index 0a2d0a31..ebe57fb1 100644 --- a/pkg/platform/cloud/eks/eks.go +++ b/pkg/platform/cloud/eks/eks.go @@ -104,6 +104,16 @@ func (s Strategy) Validate(ctx context.Context, p cloud.ValidateParams) error { return cloud.ValidateManagedPrefix(ctx, s, p) } +// BrowserChromeSandbox returns the Phase A posture: unsandboxed, pod boundary +// + gate only. EKS has not verified unprivileged user namespaces on its +// nodes. +func (Strategy) BrowserChromeSandbox() cloud.BrowserChromeSandbox { + return cloud.BrowserChromeSandbox{ + Sandboxed: false, + Reason: "unprivileged user namespaces not verified on EKS; Chrome runs --no-sandbox behind the pod boundary and the gate", + } +} + // eksWorkspaceStorage is the EKS cloud.WorkspaceStorage implementation. type eksWorkspaceStorage struct{} diff --git a/pkg/platform/cloud/gke/gke.go b/pkg/platform/cloud/gke/gke.go index b9996aa9..899fdcbc 100644 --- a/pkg/platform/cloud/gke/gke.go +++ b/pkg/platform/cloud/gke/gke.go @@ -124,6 +124,16 @@ func (s Strategy) Validate(ctx context.Context, p cloud.ValidateParams) error { return cloud.ValidateManagedPrefix(ctx, s, p) } +// BrowserChromeSandbox returns the Phase A posture: unsandboxed, pod boundary +// + gate only. GKE has not verified unprivileged user namespaces on its +// nodes. +func (Strategy) BrowserChromeSandbox() cloud.BrowserChromeSandbox { + return cloud.BrowserChromeSandbox{ + Sandboxed: false, + Reason: "unprivileged user namespaces not verified on GKE; Chrome runs --no-sandbox behind the pod boundary and the gate", + } +} + // workspaceStorage is the GKE cloud.WorkspaceStorage implementation. type workspaceStorage struct{} diff --git a/pkg/platform/cloud/invariants_test.go b/pkg/platform/cloud/invariants_test.go index 34e3cd1b..b0641b78 100644 --- a/pkg/platform/cloud/invariants_test.go +++ b/pkg/platform/cloud/invariants_test.go @@ -133,6 +133,22 @@ func TestEveryKindImplementsTheFullStrategy(t *testing.T) { } } +// TestBrowserChromeSandboxReasonIsAlwaysSet guards the Phase A posture: every +// registered kind must report a non-empty Reason, regardless of Sandboxed — +// the browser-pod builder (a later task) logs Reason at startup, and a silent +// empty string there would mean the operator logged nothing about why Chrome +// is (or is not) sandboxed on this cluster. +func TestBrowserChromeSandboxReasonIsAlwaysSet(t *testing.T) { + keys := cloud.RegisteredKeys() + require.NotEmpty(t, keys, "the registry must not be empty — a missing blank import would make this loop vacuous") + for _, key := range keys { + t.Run(key, func(t *testing.T) { + s := cloud.MustFor(key) + assert.NotEmpty(t, s.BrowserChromeSandbox().Reason) + }) + } +} + func TestNeedsBundledPostgresMatchesTheProfile(t *testing.T) { keys := cloud.RegisteredKeys() require.NotEmpty(t, keys, "the registry must not be empty — a missing blank import would make this loop vacuous") diff --git a/pkg/platform/cloud/local/local.go b/pkg/platform/cloud/local/local.go index 0edc2e1e..bf6054c2 100644 --- a/pkg/platform/cloud/local/local.go +++ b/pkg/platform/cloud/local/local.go @@ -36,6 +36,16 @@ func (Strategy) InstallProfile() cloud.InstallProfile { return cloud.DevProfile // Validate is defined in validate.go — the whole method (managed-providerID // refusal + kubeconfig host-pattern heuristic) lives there in one place. +// BrowserChromeSandbox returns the Phase A posture: unsandboxed, pod boundary +// + gate only. local has not verified unprivileged user namespaces on its +// nodes. (desktop embeds this Strategy and inherits the same answer.) +func (Strategy) BrowserChromeSandbox() cloud.BrowserChromeSandbox { + return cloud.BrowserChromeSandbox{ + Sandboxed: false, + Reason: "unprivileged user namespaces not verified on local; Chrome runs --no-sandbox behind the pod boundary and the gate", + } +} + // IsManaged reports false — this strategy covers non-managed clusters. func (Strategy) IsManaged() bool { return false } diff --git a/pkg/platform/cloud/unmanaged/unmanaged.go b/pkg/platform/cloud/unmanaged/unmanaged.go index d99498b0..15697009 100644 --- a/pkg/platform/cloud/unmanaged/unmanaged.go +++ b/pkg/platform/cloud/unmanaged/unmanaged.go @@ -146,6 +146,16 @@ func (Strategy) Validate(ctx context.Context, p cloud.ValidateParams) error { return fmt.Errorf("this cluster's nodes report %s, but the %s cluster kind was selected: pass --cluster-kind=%s to get its storage, artifact, and Gateway handling", got, cloud.KeyDefault, got) } +// BrowserChromeSandbox returns the Phase A posture: unsandboxed, pod boundary +// + gate only. unmanaged clusters vary too widely (kind, bare-metal, on-prem) +// to assume unprivileged user namespaces are available on every node. +func (Strategy) BrowserChromeSandbox() cloud.BrowserChromeSandbox { + return cloud.BrowserChromeSandbox{ + Sandboxed: false, + Reason: "unprivileged user namespaces not verified on unmanaged; Chrome runs --no-sandbox behind the pod boundary and the gate", + } +} + // unmanagedWorkspaceStorage is the unmanaged cloud.WorkspaceStorage implementation. type unmanagedWorkspaceStorage struct{} diff --git a/pkg/platform/manifests/install.yaml b/pkg/platform/manifests/install.yaml index c0e362f8..ec9af74a 100644 --- a/pkg/platform/manifests/install.yaml +++ b/pkg/platform/manifests/install.yaml @@ -1182,6 +1182,27 @@ spec: type: object type: array x-kubernetes-list-type: atomic + browserToolboxes: + description: |- + BrowserToolboxes references BrowserToolbox CRs in the same namespace + whose per-session browser each session gets. Each ref's Name is the + LLM-facing prefix; pattern [a-z0-9_-]{1,32}. + items: + description: AgentClassBrowserToolboxRef references a BrowserToolbox + CR in the same namespace. + properties: + name: + description: Name is the LLM-visible tool prefix; pattern [a-z0-9_-]{1,32}. + pattern: ^[a-z0-9_-]{1,32}$ + type: string + ref: + description: Ref names the BrowserToolbox CR in the same namespace. + type: string + required: + - name + - ref + type: object + type: array budget: description: |- Budget is optional: when omitted, the class inherits the resolved tier @@ -7210,6 +7231,189 @@ spec: format: int32 type: integer type: object + resolvedBrowserToolboxes: + description: |- + ResolvedBrowserToolboxes snapshots each referenced BrowserToolbox at + session start, frozen for the session lifetime exactly like + ResolvedSidecarToolboxes. Empty when AgentClass.spec.browserToolboxes + is empty. + items: + description: |- + ResolvedBrowserToolbox snapshots one BrowserToolbox CR for a running + AgentSession, mirroring ResolvedSidecarToolbox's shape: the LLM-facing + prefix, the frozen spec, and the operator-allocated pod/gate the runner + uses to reach the per-session browser. + properties: + browserPodIP: + description: |- + BrowserPodIP is the per-session browser pod's IP, reflected once Ready; + the runner reaches its gate at http://:. + type: string + browserPodName: + description: BrowserPodName is the per-session browser pod's + name. + type: string + effectiveSites: + description: |- + EffectiveSites is the resolved site list the running browser's egress + is actually evaluated against -- in Phase A, a straight carry-through + of Spec.Access.Sites with Source "explicit". + items: + description: |- + BrowserEffectiveSite is one resolved site entry in a ResolvedBrowserToolbox's + EffectiveSites. Source records where the entry came from: Phase A only ever + writes "explicit" (a BrowserAccess.Sites entry carried through verbatim); + "identity" is a Phase B addition once a bound identity can contribute its + own sites to the effective set. + properties: + exact: + description: Exact mirrors BrowserSite.Exact for this + entry. + type: boolean + host: + description: Host is the resolved hostname, matched exactly + as BrowserSite.Host. + type: string + source: + description: |- + Source is where this entry came from. "explicit" only in Phase A; + "identity" arrives in Phase B. + type: string + required: + - host + - source + type: object + type: array + gatePort: + description: |- + GatePort is the operator-allocated port the runner uses to reach the + browser pod's gate process. + format: int32 + type: integer + name: + description: |- + Name is the LLM-facing prefix (the `name` field from + AgentClassBrowserToolboxRef). + type: string + podFailure: + description: |- + PodFailure records a TERMINAL container state on the browser pod + (CrashLoopBackOff, ImagePullBackOff) -- reuses SidecarPodFailure's shape + exactly; see that type's doc comment for why it carries no timestamp. + properties: + exitCode: + description: |- + ExitCode is the container's last terminated exit code. Nil when the + container never ran (e.g. the image could not be pulled). + format: int32 + type: integer + message: + description: |- + Message is the most actionable text available: the container's last + terminated message when present — the crashing container's log tail, + thanks to the pod's FallbackToLogsOnError policy — otherwise the + kubelet's waiting message. Preferring the log tail is what makes the + field worth surfacing; the waiting message alone is content-free for a + crash loop ("back-off Ns restarting failed container=…"). + type: string + reason: + description: |- + Reason is the kubelet's terminal container Waiting reason, e.g. + "CrashLoopBackOff" or "ImagePullBackOff". + type: string + required: + - reason + type: object + ref: + description: |- + Ref is the BrowserToolbox CR name (the `ref` field from + AgentClassBrowserToolboxRef). + type: string + spec: + description: Spec is the BrowserToolbox spec snapshot at session-start + time. + properties: + access: + description: |- + Access is the site allowlist/blocklist the browser's egress is + evaluated against. + properties: + mode: + description: |- + Mode is "allowlist" (only Sites are reachable) or "blocklist" (every + site except Sites is reachable). + enum: + - allowlist + - blocklist + type: string + sites: + description: |- + Sites is the set of hosts Mode interprets. Matching is suffix-by-label + unless Exact is set: a Site with Host "example.com" also matches + "www.example.com" and "a.b.example.com", but never a lookalike like + "notexample.com" -- pkg/tools/browserpolicy.Match does label-boundary + comparison, not a raw string suffix check. Exact pins the entry to the + literal host only. + items: + description: BrowserSite is one host entry in a BrowserAccess + list. + properties: + exact: + description: |- + Exact pins this entry to the literal Host only, disabling the default + suffix-by-label subdomain match. + type: boolean + host: + description: |- + Host is the DNS hostname this entry covers, matched suffix-by-label + (see BrowserAccess.Sites) unless Exact is set. + maxLength: 253 + pattern: ^[a-z0-9]([a-z0-9-]{0,62}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,62}[a-z0-9])?)*$ + type: string + required: + - host + type: object + type: array + x-kubernetes-list-map-keys: + - host + x-kubernetes-list-type: map + required: + - mode + type: object + interaction: + default: act + description: |- + Interaction gates whether the LLM may act on the page (click, type, + navigate) or only read its rendered content. + act -- full interaction. + readonly -- observation only; no mutating actions are dispatched. + enum: + - act + - readonly + type: string + state: + default: ephemeral + description: |- + State selects how the browser's session state (cookies, storage) is + retained across turns. + Phase A: ephemeral is the only state; persistent arrives with identities (Phase C). + enum: + - ephemeral + type: string + required: + - access + type: object + tokenHash: + description: |- + TokenHash is a hash of the resolved per-session gate token; a change + triggers pod replacement. + type: string + required: + - name + - ref + - spec + type: object + type: array resolvedContentGuardDetectors: description: |- ResolvedContentGuardDetectors snapshots the detector sidecars injected for @@ -10963,6 +11167,230 @@ spec: --- apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.20.1 + name: browsertoolboxes.agentprimitives.authzed.com +spec: + group: agentprimitives.authzed.com + names: + categories: + - authzed + - spicebox + kind: BrowserToolbox + listKind: BrowserToolboxList + plural: browsertoolboxes + shortNames: + - btb + singular: browsertoolbox + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.access.mode + name: Mode + type: string + - jsonPath: .spec.interaction + name: Interaction + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: |- + BrowserToolbox declares a per-session browser the agent can drive: which + sites it may reach and whether the LLM may act on the page or only read it. + + Namespaced. An AgentClass references it via AgentClassBrowserToolboxRef, + and the AgentSession reconciler snapshots the referenced spec into + status.resolvedBrowserToolboxes at session start (see + ResolvedBrowserToolbox), frozen for the session's lifetime exactly like a + SidecarToolbox. + + Phase A deliberately ships a narrow field subset: Access covers only an + explicit allowlist/blocklist of sites (Task 1's pkg/tools/browserpolicy + decides admission from it), Interaction is act/readonly, and State is + pinned to "ephemeral" -- there is no persistent browser profile/identity + yet. Those arrive in later phases (identity-sourced sites in Phase B, + persistent state in Phase C) as additive fields, not a breaking change to + this spec. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: |- + BrowserToolboxSpec is deliberately not annotated with a CEL XValidation + rule: this type is embedded by value into AgentSession's + status.resolvedBrowserToolboxes snapshot (see ResolvedBrowserToolbox.Spec), + and a CEL rule reachable from a status path both trips the status-CEL guard + and risks the apiserver's CRD rule-cost budget across that unbounded status + array -- the same reasoning SidecarToolboxSpec documents. Field-level + markers (Enum, Pattern, default) are the validation surface here. + properties: + access: + description: |- + Access is the site allowlist/blocklist the browser's egress is + evaluated against. + properties: + mode: + description: |- + Mode is "allowlist" (only Sites are reachable) or "blocklist" (every + site except Sites is reachable). + enum: + - allowlist + - blocklist + type: string + sites: + description: |- + Sites is the set of hosts Mode interprets. Matching is suffix-by-label + unless Exact is set: a Site with Host "example.com" also matches + "www.example.com" and "a.b.example.com", but never a lookalike like + "notexample.com" -- pkg/tools/browserpolicy.Match does label-boundary + comparison, not a raw string suffix check. Exact pins the entry to the + literal host only. + items: + description: BrowserSite is one host entry in a BrowserAccess + list. + properties: + exact: + description: |- + Exact pins this entry to the literal Host only, disabling the default + suffix-by-label subdomain match. + type: boolean + host: + description: |- + Host is the DNS hostname this entry covers, matched suffix-by-label + (see BrowserAccess.Sites) unless Exact is set. + maxLength: 253 + pattern: ^[a-z0-9]([a-z0-9-]{0,62}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,62}[a-z0-9])?)*$ + type: string + required: + - host + type: object + type: array + x-kubernetes-list-map-keys: + - host + x-kubernetes-list-type: map + required: + - mode + type: object + interaction: + default: act + description: |- + Interaction gates whether the LLM may act on the page (click, type, + navigate) or only read its rendered content. + act -- full interaction. + readonly -- observation only; no mutating actions are dispatched. + enum: + - act + - readonly + type: string + state: + default: ephemeral + description: |- + State selects how the browser's session state (cookies, storage) is + retained across turns. + Phase A: ephemeral is the only state; persistent arrives with identities (Phase C). + enum: + - ephemeral + type: string + required: + - access + type: object + status: + description: |- + BrowserToolboxStatus is intentionally minimal in Phase A: a condition set + and the generation it reflects. There is no admission-time probe the way + SidecarToolbox has one -- a BrowserToolbox has no image to reach; its own + validity is purely a function of its spec. + properties: + conditions: + description: Conditions carries this BrowserToolbox's Valid condition. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + observedGeneration: + description: ObservedGeneration is the spec generation this status + reflects. + format: int64 + type: integer + type: object + type: object + served: true + storage: true + subresources: + status: {} +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition metadata: annotations: controller-gen.kubebuilder.io/version: v0.20.1 @@ -25578,6 +26006,7 @@ rules: - agentprimitives.authzed.com resources: - agentsettings + - browsertoolboxes - clusteragentsettings - clusteridentityproviders - clusterskillsources @@ -30184,6 +30613,7 @@ spec: - args: - --runner-image=agentprimitives-runner:dev - --sandbox-image=spicebox-sandbox:dev + - --browser-image=ap-browser:dev - --leader-elect=true - --metrics-bind-address=127.0.0.1:8080 - --health-probe-bind-address=:8081 diff --git a/pkg/platform/manifests/rbac_sufficiency_test.go b/pkg/platform/manifests/rbac_sufficiency_test.go index a3f11938..62ac4835 100644 --- a/pkg/platform/manifests/rbac_sufficiency_test.go +++ b/pkg/platform/manifests/rbac_sufficiency_test.go @@ -370,6 +370,14 @@ func operatorComponent() component { "channels": {"get", "list", "watch", "update", "patch", "create", "delete"}, "mcpservers": {"get", "list", "watch", "update", "patch", "create"}, "sidecartoolboxes": {"get", "list", "watch", "update", "patch", "create"}, + // BrowserToolbox is read-only for the operator: the AgentSession + // reconciler Gets each class-referenced CR at session start to snapshot + // its spec into status.resolvedBrowserToolboxes, and the AgentClass + // reconciler Gets them to validate refs and Watches the kind to + // re-validate (markers in pkg/controllers/agentsession/controller.go and + // pkg/controllers/agentclass/controller.go). Nothing writes one — there + // is no dedicated BrowserToolbox reconciler in Phase A. + "browsertoolboxes": {"get", "list", "watch"}, // The WorkspaceSource reconciler materializes/refreshes a shared base // checkout: get;list;watch to reconcile, update;patch for spec/status. // No create/delete — WorkspaceSources are author-owned (users, a future diff --git a/pkg/tools/browsergate/devtools.go b/pkg/tools/browsergate/devtools.go new file mode 100644 index 00000000..f9ccc24b --- /dev/null +++ b/pkg/tools/browsergate/devtools.go @@ -0,0 +1,151 @@ +package browsergate + +import ( + "crypto/subtle" + "encoding/json" + "io" + "net/http" + "net/url" + "strings" +) + +// GateHandler is the runner-facing listener: /healthz (unauthenticated) for the +// pod's startup probe, and an authenticated reverse proxy to Chrome's DevTools +// for everything else. The token must be presented before ANY request but +// /healthz reaches Chrome. +func (g *Gate) GateHandler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/healthz" { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok\n")) + return + } + + if !g.authOK(r) { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + // The token must never reach Chrome; strip it from the forwarded URL. + // (The /json/* rewrite below reads the token from config, not the query, + // so stripping first is safe.) + stripTokenParam(r) + + switch r.URL.Path { + case "/json/version", "/json/list": + g.serveJSONRewrite(w, r) + default: + g.devtoolsProxy.ServeHTTP(w, r) + } + }) +} + +// authOK reports whether r carries the per-session token, via an +// "Authorization: Bearer " header OR a "?t=" query param +// (websocket clients cannot set headers). The comparison is constant-time. +func (g *Gate) authOK(r *http.Request) bool { + var presented string + if h := r.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer ") { + presented = strings.TrimPrefix(h, "Bearer ") + } else { + presented = r.URL.Query().Get("t") + } + if presented == "" { + return false + } + return subtle.ConstantTimeCompare([]byte(presented), []byte(g.cfg.Token)) == 1 +} + +// stripTokenParam removes the "t" token param from r.URL so it is not forwarded +// upstream to Chrome. +func stripTokenParam(r *http.Request) { + q := r.URL.Query() + if !q.Has("t") { + return + } + q.Del("t") + r.URL.RawQuery = q.Encode() +} + +// serveJSONRewrite proxies /json/version and /json/list, rewriting every +// webSocketDebuggerUrl/devtoolsFrontendUrl so its upstream host is replaced with +// the gate's own Host header and the per-session token is appended. A chromedp +// NewRemoteAllocator pointed at the gate reads these URLs to connect, so this is +// what keeps it on the gate (authenticated) rather than dialing Chrome directly. +func (g *Gate) serveJSONRewrite(w http.ResponseWriter, r *http.Request) { + target := *g.devtools + target.Path = r.URL.Path + + resp, err := g.devtoolsCli.Get(target.String()) + if err != nil { + g.logger.Info("browsergate: devtools /json fetch failed", "path", r.URL.Path, "err", err.Error()) + http.Error(w, "devtools upstream error", http.StatusBadGateway) + return + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + g.logger.Info("browsergate: devtools /json read failed", "path", r.URL.Path, "err", err.Error()) + http.Error(w, "devtools upstream error", http.StatusBadGateway) + return + } + + rewritten := g.rewriteJSON(body, r.Host) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(resp.StatusCode) + _, _ = w.Write(rewritten) +} + +// rewriteJSON parses body as a DevTools /json document and rewrites the +// debugger-URL fields in place. If the body does not parse as JSON it falls back +// to a raw host replacement (and logs): the resulting URLs then point at the +// gate but carry no token, so a client using them is refused (401) rather than +// handed a direct, unauthenticated path to Chrome — fail-closed. +func (g *Gate) rewriteJSON(body []byte, gateHost string) []byte { + var doc any + if err := json.Unmarshal(body, &doc); err != nil { + g.logger.Info("browsergate: devtools /json not valid JSON; host-replacing only", "err", err.Error()) + return []byte(strings.ReplaceAll(string(body), g.devtools.Host, gateHost)) + } + rewriteNode(doc, g.devtools.Host, gateHost, g.cfg.Token) + out, err := json.Marshal(doc) + if err != nil { + g.logger.Info("browsergate: devtools /json re-marshal failed; host-replacing only", "err", err.Error()) + return []byte(strings.ReplaceAll(string(body), g.devtools.Host, gateHost)) + } + return out +} + +// rewriteNode walks a decoded JSON value and rewrites every string value under a +// "webSocketDebuggerUrl" or "devtoolsFrontendUrl" key. +func rewriteNode(n any, upstreamHost, gateHost, token string) { + switch v := n.(type) { + case map[string]any: + for k, val := range v { + if k == "webSocketDebuggerUrl" || k == "devtoolsFrontendUrl" { + if s, ok := val.(string); ok { + v[k] = rewriteDebuggerURL(s, upstreamHost, gateHost, token) + continue + } + } + rewriteNode(val, upstreamHost, gateHost, token) + } + case []any: + for _, item := range v { + rewriteNode(item, upstreamHost, gateHost, token) + } + } +} + +// rewriteDebuggerURL replaces the upstream host with the gate host and appends +// the token as a "t" query param (with the correct ? or & separator). +func rewriteDebuggerURL(s, upstreamHost, gateHost, token string) string { + s = strings.ReplaceAll(s, upstreamHost, gateHost) + sep := "?" + if strings.Contains(s, "?") { + sep = "&" + } + return s + sep + "t=" + url.QueryEscape(token) +} diff --git a/pkg/tools/browsergate/devtools_test.go b/pkg/tools/browsergate/devtools_test.go new file mode 100644 index 00000000..bf5edeb2 --- /dev/null +++ b/pkg/tools/browsergate/devtools_test.go @@ -0,0 +1,171 @@ +package browsergate + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/authzed/openagentprimitives/pkg/tools/browserpolicy" +) + +// fakeDevtools stands in for Chrome's DevTools endpoint. Its /json/* handlers +// embed its own host in the debugger URLs (as Chrome does), so the gate's +// rewrite has a concrete upstream host to replace. recordedQuery captures the +// query string of the last non-/json request, to prove the token is stripped +// before forwarding. +type fakeDevtools struct { + server *httptest.Server + host string + recordedQuery string + recordedPath string +} + +func newFakeDevtools(t *testing.T) *fakeDevtools { + t.Helper() + fd := &fakeDevtools{} + fd.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/json/version": + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"Browser":"Chrome","webSocketDebuggerUrl":"ws://`+fd.host+`/devtools/browser/abcd"}`) + case "/json/list": + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `[{"webSocketDebuggerUrl":"ws://`+fd.host+`/devtools/page/EFGH","devtoolsFrontendUrl":"/devtools/inspector.html?ws=`+fd.host+`/devtools/page/EFGH"}]`) + default: + fd.recordedPath = r.URL.Path + fd.recordedQuery = r.URL.RawQuery + _, _ = io.WriteString(w, "upstream-ok") + } + })) + fd.host = strings.TrimPrefix(fd.server.URL, "http://") + t.Cleanup(fd.server.Close) + return fd +} + +// newDevtoolsTestGate builds a gate whose DevTools proxy targets fd, with a +// trivial allowlist (unused by the gate listener) and a quiet logger. +func newDevtoolsTestGate(t *testing.T, fd *fakeDevtools) *Gate { + t.Helper() + g, err := New(Config{ + ProxyAddr: "127.0.0.1:0", + GateAddr: "127.0.0.1:0", + DevtoolsURL: fd.server.URL, + Token: testToken, + PolicyPath: writePolicy(t, browserpolicy.ModeAllowlist, browserpolicy.Site{Host: "a.test"}), + }) + require.NoError(t, err) + g.logger = quietLogger() + g.policy.logger = quietLogger() + return g +} + +func TestGateAuth(t *testing.T) { + fd := newFakeDevtools(t) + g := newDevtoolsTestGate(t, fd) + gate := httptest.NewServer(g.GateHandler()) + t.Cleanup(gate.Close) + client := &http.Client{Timeout: 3 * time.Second} + + cases := []struct { + name string + path string + header string // Authorization header value, "" for none + query string // raw query, "" for none + wantStatus int + }{ + {name: "healthz is unauthenticated: 200", path: "/healthz", wantStatus: 200}, + {name: "no token: 401", path: "/json/version", wantStatus: 401}, + {name: "wrong bearer token: 401", path: "/json/version", header: "Bearer wrong", wantStatus: 401}, + {name: "wrong query token: 401", path: "/json/version", query: "t=wrong", wantStatus: 401}, + {name: "correct bearer token: proxied 200", path: "/json/version", header: "Bearer " + testToken, wantStatus: 200}, + {name: "correct query token: proxied 200", path: "/json/version", query: "t=" + testToken, wantStatus: 200}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + u := gate.URL + tc.path + if tc.query != "" { + u += "?" + tc.query + } + req, err := http.NewRequest(http.MethodGet, u, nil) + require.NoError(t, err) + if tc.header != "" { + req.Header.Set("Authorization", tc.header) + } + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, tc.wantStatus, resp.StatusCode) + }) + } +} + +func TestGateJSONVersionRewrite(t *testing.T) { + fd := newFakeDevtools(t) + g := newDevtoolsTestGate(t, fd) + gate := httptest.NewServer(g.GateHandler()) + t.Cleanup(gate.Close) + gateHost := strings.TrimPrefix(gate.URL, "http://") + + req, err := http.NewRequest(http.MethodGet, gate.URL+"/json/version", nil) + require.NoError(t, err) + req.Header.Set("Authorization", "Bearer "+testToken) + resp, err := (&http.Client{Timeout: 3 * time.Second}).Do(req) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, 200, resp.StatusCode) + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + s := string(body) + + assert.Contains(t, s, gateHost, "debugger URL host must be rewritten to the gate's own host") + assert.Contains(t, s, "t="+testToken, "token must be appended so chromedp stays authenticated on the gate") + assert.NotContains(t, s, "ws://"+fd.host, "the upstream devtools host must not survive the rewrite") +} + +func TestGateJSONListRewrite(t *testing.T) { + fd := newFakeDevtools(t) + g := newDevtoolsTestGate(t, fd) + gate := httptest.NewServer(g.GateHandler()) + t.Cleanup(gate.Close) + gateHost := strings.TrimPrefix(gate.URL, "http://") + + req, err := http.NewRequest(http.MethodGet, gate.URL+"/json/list", nil) + require.NoError(t, err) + req.Header.Set("Authorization", "Bearer "+testToken) + resp, err := (&http.Client{Timeout: 3 * time.Second}).Do(req) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, 200, resp.StatusCode) + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + s := string(body) + + assert.Contains(t, s, gateHost) + assert.Contains(t, s, "t="+testToken) + assert.NotContains(t, s, fd.host, "no upstream devtools host should survive in webSocketDebuggerUrl or devtoolsFrontendUrl") +} + +func TestGateStripsTokenQueryBeforeForwarding(t *testing.T) { + fd := newFakeDevtools(t) + g := newDevtoolsTestGate(t, fd) + gate := httptest.NewServer(g.GateHandler()) + t.Cleanup(gate.Close) + + resp, err := (&http.Client{Timeout: 3 * time.Second}).Get(gate.URL + "/devtools/page/EFGH?t=" + testToken + "&foo=bar") + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, 200, resp.StatusCode) + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + assert.Equal(t, "upstream-ok", string(body)) + + assert.Equal(t, "/devtools/page/EFGH", fd.recordedPath) + assert.NotContains(t, fd.recordedQuery, "t=", "the token query param must be stripped before forwarding to Chrome") + assert.Contains(t, fd.recordedQuery, "foo=bar", "non-token query params must survive") +} diff --git a/pkg/tools/browsergate/gate.go b/pkg/tools/browsergate/gate.go new file mode 100644 index 00000000..0b5f8154 --- /dev/null +++ b/pkg/tools/browsergate/gate.go @@ -0,0 +1,427 @@ +// Package browsergate is the security boundary for the browser toolbox's +// per-session Chrome pod. It runs as the pod's second container and enforces +// two seams: +// +// - A policy-enforcing forward proxy (CONNECT + plain HTTP) bound to loopback, +// which is Chrome's ONLY egress (--proxy-server). Every destination host is +// checked against the per-session browserpolicy before a byte leaves the +// pod, and the dialer additionally refuses any host that RESOLVES to a +// private/metadata/loopback address — closing the DNS-rebinding and +// "localhost" SSRF gaps the hostname check alone cannot. +// - An authenticated reverse proxy for Chrome's DevTools endpoint, bound to +// the pod's gate port, which the session runner is the sole client of. +// Every request but /healthz must present the per-session token before +// anything reaches Chrome. +// +// The package is stdlib-only (plus the pure browserpolicy and safehttp helpers) +// so the gate stays a small, auditable boundary. +package browsergate + +import ( + "context" + "errors" + "fmt" + "io" + "log" + "log/slog" + "net" + "net/http" + "net/http/httputil" + "net/url" + "strconv" + "syscall" + "testing" + "time" + + "github.com/authzed/openagentprimitives/pkg/tools/browserpolicy" + "github.com/authzed/openagentprimitives/pkg/x/safehttp" +) + +const ( + // DefaultProxyAddr is Chrome's --proxy-server target. It is loopback-only, + // never the pod IP: nothing but Chrome in the same pod may reach it. + DefaultProxyAddr = "127.0.0.1:3128" + // DefaultGateAddr is the runner-facing listener (DevTools proxy + /healthz). + DefaultGateAddr = ":9223" + // DefaultGatePort is the port half of DefaultGateAddr. + DefaultGatePort = 9223 + // DefaultDevtoolsURL is Chrome's remote-debugging endpoint inside the pod. + DefaultDevtoolsURL = "http://127.0.0.1:9222" +) + +const ( + // dialTimeout bounds a single upstream dial (CONNECT and plain-HTTP alike). + dialTimeout = 10 * time.Second + // shutdownTimeout bounds each listener's graceful-shutdown window. + shutdownTimeout = 5 * time.Second + // devtoolsTimeout bounds the gate's own fetch of /json/* from Chrome. + devtoolsTimeout = 15 * time.Second +) + +// Config configures a Gate. Zero values for the addresses and DevtoolsURL fall +// back to the Default* constants; Token and a readable PolicyPath are required +// (New errors otherwise). +type Config struct { + ProxyAddr string + GateAddr string + DevtoolsURL string // e.g. "http://127.0.0.1:9222" + Token string // per-session browser token; required + PolicyPath string // JSON PolicyDoc; reloaded when its mtime changes + ExtraPorts []int // TEST-ONLY additional CONNECT/HTTP ports; empty in production +} + +// Gate is the running browser gate: a forward proxy (ProxyHandler) and an +// authenticated DevTools reverse proxy (GateHandler). +type Gate struct { + cfg Config + policy *policyStore + devtools *url.URL + ports map[int]bool + logger *slog.Logger + + // dialContext dials an upstream for both the CONNECT path and the plain-HTTP + // transport. It defaults to a guarded net.Dialer whose Control hook refuses + // any resolved private/metadata/loopback address. It is a field (not a + // method) so in-process tests can substitute a dialer that reaches an + // httptest backend on loopback without tripping that guard. + dialContext func(ctx context.Context, network, addr string) (net.Conn, error) + + transport *http.Transport // plain-HTTP forward transport (uses dialContext) + forwardProxy *httputil.ReverseProxy // plain-HTTP forward proxy + devtoolsProxy *httputil.ReverseProxy // DevTools reverse proxy (loopback; not guarded) + devtoolsCli *http.Client // fetches /json/* for rewriting +} + +// New validates cfg and builds a Gate. It fails closed: an empty Token, an +// unparseable DevtoolsURL, or an unreadable/invalid initial policy are errors, +// so a gate never starts in a state that could admit traffic it should refuse. +func New(cfg Config) (*Gate, error) { + if cfg.Token == "" { + return nil, errors.New("browsergate: Token is required") + } + if cfg.ProxyAddr == "" { + cfg.ProxyAddr = DefaultProxyAddr + } + if cfg.GateAddr == "" { + cfg.GateAddr = DefaultGateAddr + } + if cfg.DevtoolsURL == "" { + cfg.DevtoolsURL = DefaultDevtoolsURL + } + du, err := url.Parse(cfg.DevtoolsURL) + if err != nil || du.Scheme == "" || du.Host == "" { + return nil, fmt.Errorf("browsergate: invalid DevtoolsURL %q", cfg.DevtoolsURL) + } + + logger := slog.Default() + ps, err := newPolicyStore(cfg.PolicyPath, logger) + if err != nil { + return nil, err + } + + g := &Gate{ + cfg: cfg, + policy: ps, + devtools: du, + ports: portSet(cfg.ExtraPorts), + logger: logger, + } + + // The guarded dialer: resolved private/metadata/loopback addresses are + // refused at dial time, before connect. Shared by CONNECT and the transport. + baseDialer := &net.Dialer{Timeout: dialTimeout, Control: g.controlRejectPrivate} + g.dialContext = baseDialer.DialContext + + // The transport indirects through g.dialContext so a test override of that + // field takes effect for plain-HTTP forwarding too. + g.transport = &http.Transport{ + DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + return g.dialContext(ctx, network, addr) + }, + ForceAttemptHTTP2: false, // a forward proxy speaks HTTP/1.1 to arbitrary origins + MaxIdleConns: 100, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + ExpectContinueTimeout: 1 * time.Second, + } + + // forwardProxy forwards an already-absolute request URL (the Director is a + // no-op) through the guarded transport. Its ErrorHandler turns a dial-time + // private-address refusal into the policy 403, and every other failure into + // a logged 502. + g.forwardProxy = &httputil.ReverseProxy{ + Director: func(*http.Request) {}, + Transport: g.transport, + ErrorLog: log.New(io.Discard, "", 0), // real logging happens in ErrorHandler + ErrorHandler: func(w http.ResponseWriter, req *http.Request, err error) { + var bae *blockedAddrError + if errors.As(err, &bae) { + writeDeny(w, req.URL.Hostname(), rulePrivateOrMetadata) + return + } + g.logger.Info("browsergate: plain-HTTP forward error", "host", req.URL.Hostname(), "err", err.Error()) + http.Error(w, "upstream error", http.StatusBadGateway) + }, + } + + // devtoolsProxy targets Chrome's loopback DevTools endpoint. It deliberately + // does NOT use the guarded transport: its target is loopback, which that + // guard exists to refuse. Chrome's DevTools rejects a non-IP/localhost Host + // header (a DNS-rebinding defense), so the Director rewrites Host too. + g.devtoolsProxy = &httputil.ReverseProxy{ + Director: func(req *http.Request) { + req.URL.Scheme = g.devtools.Scheme + req.URL.Host = g.devtools.Host + req.Host = g.devtools.Host + }, + ErrorLog: log.New(io.Discard, "", 0), + ErrorHandler: func(w http.ResponseWriter, _ *http.Request, err error) { + g.logger.Info("browsergate: devtools proxy error", "err", err.Error()) + http.Error(w, "devtools upstream error", http.StatusBadGateway) + }, + } + + g.devtoolsCli = &http.Client{Timeout: devtoolsTimeout} + + return g, nil +} + +// SetDialContextForTest replaces the gate's upstream dialer and returns the +// one it displaced. TEST-ONLY seam, in the same spirit as Config.ExtraPorts: +// an in-process test substitutes a dialer that can reach a loopback/httptest +// backend the production guard (controlRejectPrivate) refuses by design — see +// the dialContext field's own comment, which anticipates exactly this. +// +// Returning the previous dialer lets an override DELEGATE every host but the +// one(s) it redirects, so the private/metadata-address guard still fires for +// off-site hosts and the security semantics a test is not deliberately +// bypassing stay intact. Production never calls this. +// +// It panics outside a test binary, mirroring pkg/x/browser.SetOpenerForTest: +// a panic is checkable where a doc comment is not, and that is what keeps +// this seam from becoming a way for production code to bypass the SSRF +// guard. Call it before Run; it is not safe for concurrent use with a +// running gate (g.dialContext is read without a lock by in-flight CONNECT/ +// proxy handling, so swapping it while the gate is serving traffic races). +func (g *Gate) SetDialContextForTest(d func(ctx context.Context, network, addr string) (net.Conn, error)) (prev func(ctx context.Context, network, addr string) (net.Conn, error)) { + if !testing.Testing() { + panic("browsergate.SetDialContextForTest called outside a test binary") + } + prev = g.dialContext + g.dialContext = d + return prev +} + +// portSet builds the set of CONNECT/HTTP ports the gate admits: 80 and 443, +// plus any TEST-ONLY extras. +func portSet(extra []int) map[int]bool { + ports := map[int]bool{80: true, 443: true} + for _, p := range extra { + ports[p] = true + } + return ports +} + +// Run serves both listeners until ctx is cancelled, then shuts them down +// gracefully. A listener that fails to bind returns its error (and the other +// listener is shut down). +func (g *Gate) Run(ctx context.Context) error { + proxySrv := &http.Server{Addr: g.cfg.ProxyAddr, Handler: g.ProxyHandler()} + gateSrv := &http.Server{Addr: g.cfg.GateAddr, Handler: g.GateHandler()} + safehttp.HardenServer(proxySrv) + safehttp.HardenServer(gateSrv) + + errCh := make(chan error, 2) + serve := func(name string, s *http.Server) { + err := s.ListenAndServe() + if err != nil && !errors.Is(err, http.ErrServerClosed) { + errCh <- fmt.Errorf("browsergate: %s listener: %w", name, err) + return + } + errCh <- nil + } + go serve("proxy", proxySrv) + go serve("gate", gateSrv) + + var runErr error + select { + case <-ctx.Done(): + case runErr = <-errCh: + } + + shCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout) + defer cancel() + if err := proxySrv.Shutdown(shCtx); err != nil { + g.logger.Info("browsergate: proxy listener shutdown", "err", err.Error()) + } + if err := gateSrv.Shutdown(shCtx); err != nil { + g.logger.Info("browsergate: gate listener shutdown", "err", err.Error()) + } + return runErr +} + +// ProxyHandler is Chrome's egress proxy: CONNECT tunnels and plain-HTTP +// forwarding, both policy-enforced. +func (g *Gate) ProxyHandler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodConnect { + g.handleConnect(w, r) + return + } + g.handlePlainHTTP(w, r) + }) +} + +// handleConnect enforces the port and host policy, then (on success) hijacks +// the client connection and bidirectionally copies to the upstream. Every deny +// is written before the hijack, as a plain 403. +func (g *Gate) handleConnect(w http.ResponseWriter, r *http.Request) { + host, port, err := net.SplitHostPort(r.Host) + if err != nil { + http.Error(w, "bad CONNECT target", http.StatusBadRequest) + return + } + if !g.portAllowed(port) { + writePortDeny(w, port) + return + } + if dec := g.decide(host); !dec.Allowed { + writeDeny(w, host, dec.Rule) + return + } + + ctx, cancel := context.WithTimeout(r.Context(), dialTimeout) + defer cancel() + upstream, err := g.dialContext(ctx, "tcp", net.JoinHostPort(host, port)) + if err != nil { + var bae *blockedAddrError + if errors.As(err, &bae) { + // The resolved address was private/metadata/loopback (e.g. a host + // that is policy-allowed by name but resolves to loopback). + writeDeny(w, host, rulePrivateOrMetadata) + return + } + g.logger.Info("browsergate: CONNECT dial failed", "host", host, "port", port, "err", err.Error()) + http.Error(w, "upstream dial failed", http.StatusBadGateway) + return + } + + hj, ok := w.(http.Hijacker) + if !ok { + _ = upstream.Close() + http.Error(w, "hijacking unsupported", http.StatusInternalServerError) + return + } + clientConn, _, err := hj.Hijack() + if err != nil { + _ = upstream.Close() + g.logger.Info("browsergate: CONNECT hijack failed", "host", host, "err", err.Error()) + return + } + if _, err := clientConn.Write([]byte("HTTP/1.1 200 Connection established\r\n\r\n")); err != nil { + _ = upstream.Close() + _ = clientConn.Close() + return + } + tunnel(clientConn, upstream) +} + +// handlePlainHTTP enforces the same port and host policy for a non-CONNECT +// forward-proxy request, then forwards it through the guarded transport. +func (g *Gate) handlePlainHTTP(w http.ResponseWriter, r *http.Request) { + host := r.URL.Hostname() + port := r.URL.Port() + if port == "" { + port = "80" + } + if !g.portAllowed(port) { + writePortDeny(w, port) + return + } + if dec := g.decide(host); !dec.Allowed { + writeDeny(w, host, dec.Rule) + return + } + g.forwardProxy.ServeHTTP(w, r) +} + +// decide evaluates host against the current (possibly just-reloaded) policy. +func (g *Gate) decide(host string) browserpolicy.Decision { + doc := g.policy.current() + return browserpolicy.Decide(doc.Mode, doc.Sites, host) +} + +// portAllowed reports whether port (a decimal string) is in the admitted set. +func (g *Gate) portAllowed(port string) bool { + n, err := strconv.Atoi(port) + if err != nil { + return false + } + return g.ports[n] +} + +// controlRejectPrivate is the net.Dialer.Control hook: address is the RESOLVED +// remote address, so refusing private/metadata/loopback/unspecified here closes +// the gap where a policy-allowed hostname resolves to an internal address. +func (g *Gate) controlRejectPrivate(_, address string, _ syscall.RawConn) error { + host, _, err := net.SplitHostPort(address) + if err != nil { + host = address + } + if browserpolicy.IsPrivateOrMetadataHost(host) { + return &blockedAddrError{addr: host} + } + return nil +} + +// blockedAddrError is returned by the dial Control hook when a resolved address +// is private/metadata/loopback. The handlers detect it with errors.As (it is +// wrapped in a *net.OpError by the dialer) to render the policy 403 rather than +// a generic dial failure. +type blockedAddrError struct{ addr string } + +func (e *blockedAddrError) Error() string { + return "browsergate: refusing to dial private/metadata address " + e.addr +} + +// rulePrivateOrMetadata is the deny-rule string browserpolicy uses for a +// private/metadata/loopback address; reused so the dial-time refusal renders the +// same body as the pre-dial decision. +const rulePrivateOrMetadata = "private or metadata address" + +// writeDeny writes the verbatim policy-deny body as a 403. The body matches the +// contract exactly (no trailing newline) so downstream exact-match assertions +// hold. +func writeDeny(w http.ResponseWriter, host, rule string) { + msg := fmt.Sprintf("host %s is not allowed by browser toolbox policy (rule: %s)", host, rule) + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(msg)) +} + +// writePortDeny writes a 403 for a CONNECT/HTTP port outside the admitted set. +func writePortDeny(w http.ResponseWriter, port string) { + msg := fmt.Sprintf("port %s is not allowed by browser toolbox policy", port) + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(msg)) +} + +// tunnel copies bytes in both directions between the hijacked client connection +// and the upstream, closing both when either side ends so neither copy goroutine +// can leak. +func tunnel(a, b net.Conn) { + done := make(chan struct{}, 2) + cp := func(dst, src net.Conn) { + _, _ = io.Copy(dst, src) + _ = dst.Close() + _ = src.Close() + done <- struct{}{} + } + go cp(a, b) + go cp(b, a) + <-done + <-done +} diff --git a/pkg/tools/browsergate/gate_test.go b/pkg/tools/browsergate/gate_test.go new file mode 100644 index 00000000..6fe58a12 --- /dev/null +++ b/pkg/tools/browsergate/gate_test.go @@ -0,0 +1,338 @@ +package browsergate + +import ( + "bufio" + "context" + "crypto/tls" + "io" + "log/slog" + "net" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/authzed/openagentprimitives/pkg/tools/browserpolicy" +) + +const testToken = "test-secret-token" + +// quietLogger returns a slog logger that discards everything, so a handler's +// best-effort error logging cannot pollute test output. +func quietLogger() *slog.Logger { + return slog.New(slog.NewTextHandler(io.Discard, nil)) +} + +// writePolicy writes doc as JSON to a fresh file under t.TempDir and returns +// the path. +func writePolicy(t *testing.T, mode string, sites ...browserpolicy.Site) string { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "policy.json") + rewritePolicy(t, path, mode, sites...) + return path +} + +// rewritePolicy overwrites path with a new PolicyDoc and bumps its mtime a +// second into the future so an mtime-based reloader sees the change even on a +// coarse-resolution filesystem. +func rewritePolicy(t *testing.T, path, mode string, sites ...browserpolicy.Site) { + t.Helper() + doc := `{"mode":"` + mode + `","sites":[` + parts := make([]string, 0, len(sites)) + for _, s := range sites { + p := `{"host":"` + s.Host + `"` + if s.Exact { + p += `,"exact":true` + } + p += `}` + parts = append(parts, p) + } + doc += strings.Join(parts, ",") + `]}` + require.NoError(t, os.WriteFile(path, []byte(doc), 0o600)) + future := time.Now().Add(time.Second) + require.NoError(t, os.Chtimes(path, future, future)) +} + +// newTestGate builds a Gate for the allowlist/blocklist under test, pointed at +// devtoolsURL, with a quiet logger. The dialContext is NOT overridden here; +// individual tests inject one when they need to reach a backend. +func newTestGate(t *testing.T, devtoolsURL, policyPath string) *Gate { + t.Helper() + g, err := New(Config{ + ProxyAddr: "127.0.0.1:0", + GateAddr: "127.0.0.1:0", + DevtoolsURL: devtoolsURL, + Token: testToken, + PolicyPath: policyPath, + }) + require.NoError(t, err) + g.logger = quietLogger() + g.policy.logger = quietLogger() + return g +} + +// bufConn adapts a net.Conn whose leading bytes have already been buffered into +// a bufio.Reader, so a tls.Client built on top reads through the buffer first. +type bufConn struct { + net.Conn + r *bufio.Reader +} + +func (c *bufConn) Read(p []byte) (int, error) { return c.r.Read(p) } + +// sendCONNECT dials proxyAddr, issues a CONNECT for target, and returns the +// raw conn, the parsed response, and the buffered reader positioned just past +// the response headers (for tunnelling after a 200). +func sendCONNECT(t *testing.T, proxyAddr, target string) (net.Conn, *http.Response, *bufio.Reader) { + t.Helper() + conn, err := net.DialTimeout("tcp", proxyAddr, 2*time.Second) + require.NoError(t, err) + _, err = conn.Write([]byte("CONNECT " + target + " HTTP/1.1\r\nHost: " + target + "\r\n\r\n")) + require.NoError(t, err) + require.NoError(t, conn.SetReadDeadline(time.Now().Add(2*time.Second))) + br := bufio.NewReader(conn) + resp, err := http.ReadResponse(br, &http.Request{Method: http.MethodConnect}) + require.NoError(t, err) + return conn, resp, br +} + +func TestCONNECT(t *testing.T) { + // A TLS backend the allowed tunnel reaches; it answers any path with "pong". + backend := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("pong")) + })) + t.Cleanup(backend.Close) + backendHostPort := strings.TrimPrefix(backend.URL, "https://") + + cases := []struct { + name string + mode string + sites []browserpolicy.Site + target string + inject bool // map every dial to the backend + wantStatus int + wantBody string // substring, checked only for deny cases + tunnel bool // complete a TLS exchange over the established tunnel + }{ + { + name: "allowlist admits listed host: tunnel established", + mode: browserpolicy.ModeAllowlist, sites: []browserpolicy.Site{{Host: "allowed.test"}}, + target: "allowed.test:443", inject: true, wantStatus: 200, tunnel: true, + }, + { + name: "allowlist denies unlisted host: 403 not in allowlist", + mode: browserpolicy.ModeAllowlist, sites: []browserpolicy.Site{{Host: "allowed.test"}}, + target: "other.test:443", wantStatus: 403, wantBody: "(rule: not in allowlist)", + }, + { + name: "port not in {80,443} denied: 403", + mode: browserpolicy.ModeAllowlist, sites: []browserpolicy.Site{{Host: "allowed.test"}}, + target: "allowed.test:8443", wantStatus: 403, + }, + { + name: "blocklist denies listed host: 403 explicit denylist", + mode: browserpolicy.ModeBlocklist, sites: []browserpolicy.Site{{Host: "blocked.test"}}, + target: "blocked.test:443", wantStatus: 403, wantBody: "(rule: explicit denylist)", + }, + { + name: "blocklist admits unlisted host: tunnel established", + mode: browserpolicy.ModeBlocklist, sites: []browserpolicy.Site{{Host: "blocked.test"}}, + target: "fine.test:443", inject: true, wantStatus: 200, + }, + { + name: "private IP literal denied: 403 private or metadata address", + mode: browserpolicy.ModeAllowlist, sites: []browserpolicy.Site{{Host: "allowed.test"}}, + target: "10.0.0.5:443", wantStatus: 403, wantBody: "(rule: private or metadata address)", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + g := newTestGate(t, backend.URL, writePolicy(t, tc.mode, tc.sites...)) + if tc.inject { + g.dialContext = func(ctx context.Context, network, _ string) (net.Conn, error) { + return (&net.Dialer{}).DialContext(ctx, network, backendHostPort) + } + } + proxy := httptest.NewServer(g.ProxyHandler()) + t.Cleanup(proxy.Close) + proxyAddr := strings.TrimPrefix(proxy.URL, "http://") + + conn, resp, br := sendCONNECT(t, proxyAddr, tc.target) + t.Cleanup(func() { _ = conn.Close() }) + assert.Equal(t, tc.wantStatus, resp.StatusCode) + + if tc.wantStatus != 200 { + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + if tc.wantBody != "" { + assert.Contains(t, string(body), tc.wantBody) + } + return + } + + if tc.tunnel { + tconn := tls.Client(&bufConn{Conn: conn, r: br}, &tls.Config{InsecureSkipVerify: true}) + require.NoError(t, tconn.SetDeadline(time.Now().Add(2*time.Second))) + _, err := tconn.Write([]byte("GET /ping HTTP/1.1\r\nHost: allowed.test\r\nConnection: close\r\n\r\n")) + require.NoError(t, err) + exResp, err := http.ReadResponse(bufio.NewReader(tconn), &http.Request{Method: http.MethodGet}) + require.NoError(t, err) + body, err := io.ReadAll(exResp.Body) + require.NoError(t, err) + assert.Equal(t, "pong", string(body)) + } + }) + } +} + +// TestCONNECTDialTimeResolvedLoopback proves the dial-time control hook rejects +// a hostname that is policy-allowed but resolves to a loopback address — the +// resolved-IP guard, not the name guard. "localhost" is admitted by the +// allowlist (it is not an IP literal, so Decide lets it through) but resolves +// to 127.0.0.1/::1, which the Dialer.Control hook refuses. +func TestCONNECTDialTimeResolvedLoopback(t *testing.T) { + g := newTestGate(t, DefaultDevtoolsURL, writePolicy(t, browserpolicy.ModeAllowlist, browserpolicy.Site{Host: "localhost"})) + // No dialContext injection: the real guarded dialer must fire. + proxy := httptest.NewServer(g.ProxyHandler()) + t.Cleanup(proxy.Close) + proxyAddr := strings.TrimPrefix(proxy.URL, "http://") + + conn, resp, _ := sendCONNECT(t, proxyAddr, "localhost:443") + t.Cleanup(func() { _ = conn.Close() }) + assert.Equal(t, 403, resp.StatusCode) + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + assert.Contains(t, string(body), "(rule: private or metadata address)") +} + +// TestProxyRefusesLoopbackAndInternalPorts proves Chrome cannot use the proxy +// to reach the gate's own DevTools surface (or any loopback service) and thus +// bypass the token. Even in blocklist mode — which admits every host by NAME — +// a loopback IP target is refused by the private-address rule, and an internal +// port is refused by the port rule. Both deny before any dial. +func TestProxyRefusesLoopbackAndInternalPorts(t *testing.T) { + // Blocklist with no entries: every host is admitted by name, so only the + // IP-literal and port guards can produce these denials. + g := newTestGate(t, DefaultDevtoolsURL, writePolicy(t, browserpolicy.ModeBlocklist)) + proxy := httptest.NewServer(g.ProxyHandler()) + t.Cleanup(proxy.Close) + proxyAddr := strings.TrimPrefix(proxy.URL, "http://") + + cases := []struct { + name, target, wantBody string + }{ + {name: "loopback on an open port is refused", target: "127.0.0.1:443", wantBody: "(rule: private or metadata address)"}, + {name: "devtools port is refused by the port rule", target: "127.0.0.1:9222", wantBody: "port 9222 is not allowed"}, + {name: "gate port is refused by the port rule", target: "127.0.0.1:9223", wantBody: "port 9223 is not allowed"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + conn, resp, _ := sendCONNECT(t, proxyAddr, tc.target) + t.Cleanup(func() { _ = conn.Close() }) + require.Equal(t, 403, resp.StatusCode) + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + assert.Contains(t, string(body), tc.wantBody) + }) + } +} + +func TestPlainHTTP(t *testing.T) { + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("ok")) + })) + t.Cleanup(backend.Close) + backendHostPort := strings.TrimPrefix(backend.URL, "http://") + + g := newTestGate(t, DefaultDevtoolsURL, writePolicy(t, browserpolicy.ModeAllowlist, browserpolicy.Site{Host: "allowed.test"})) + g.dialContext = func(ctx context.Context, network, _ string) (net.Conn, error) { + return (&net.Dialer{}).DialContext(ctx, network, backendHostPort) + } + proxy := httptest.NewServer(g.ProxyHandler()) + t.Cleanup(proxy.Close) + proxyURL, err := url.Parse(proxy.URL) + require.NoError(t, err) + + client := &http.Client{Transport: &http.Transport{Proxy: http.ProxyURL(proxyURL)}, Timeout: 3 * time.Second} + + t.Run("allowed host is forwarded", func(t *testing.T) { + resp, err := client.Get("http://allowed.test/x") + require.NoError(t, err) + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + assert.Equal(t, 200, resp.StatusCode) + assert.Equal(t, "ok", string(body)) + }) + + t.Run("denied host returns 403", func(t *testing.T) { + resp, err := client.Get("http://other.test/x") + require.NoError(t, err) + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + assert.Equal(t, 403, resp.StatusCode) + assert.Contains(t, string(body), "(rule: not in allowlist)") + }) +} + +// TestPolicyReload proves an mtime change re-reads the file, flipping a denied +// host to allowed. Exercised through the decision path (which runs the +// stat-and-reload) rather than a listener, to isolate the reload logic. +func TestPolicyReload(t *testing.T) { + path := writePolicy(t, browserpolicy.ModeAllowlist) // empty allowlist: denies everything + g := newTestGate(t, DefaultDevtoolsURL, path) + + dec := g.decide("allowed.test") + assert.False(t, dec.Allowed, "v1 empty allowlist must deny") + + rewritePolicy(t, path, browserpolicy.ModeAllowlist, browserpolicy.Site{Host: "allowed.test"}) + + dec = g.decide("allowed.test") + assert.True(t, dec.Allowed, "v2 allowlist must admit after mtime-triggered reload") +} + +// TestPolicyReloadKeepsOldOnParseFailure proves a corrupt rewrite is ignored: +// the last good policy stays in force, never failing open. +func TestPolicyReloadKeepsOldOnParseFailure(t *testing.T) { + path := writePolicy(t, browserpolicy.ModeAllowlist, browserpolicy.Site{Host: "allowed.test"}) + g := newTestGate(t, DefaultDevtoolsURL, path) + require.True(t, g.decide("allowed.test").Allowed) + + require.NoError(t, os.WriteFile(path, []byte("{not json"), 0o600)) + future := time.Now().Add(time.Second) + require.NoError(t, os.Chtimes(path, future, future)) + + assert.True(t, g.decide("allowed.test").Allowed, "corrupt reload must keep the old policy") +} + +func TestNewFailsClosed(t *testing.T) { + goodPolicy := writePolicy(t, browserpolicy.ModeAllowlist, browserpolicy.Site{Host: "a.test"}) + + t.Run("empty token errors", func(t *testing.T) { + _, err := New(Config{DevtoolsURL: DefaultDevtoolsURL, PolicyPath: goodPolicy}) + require.Error(t, err) + }) + t.Run("missing policy file errors", func(t *testing.T) { + _, err := New(Config{Token: testToken, DevtoolsURL: DefaultDevtoolsURL, PolicyPath: filepath.Join(t.TempDir(), "nope.json")}) + require.Error(t, err) + }) + t.Run("unknown mode errors", func(t *testing.T) { + p := writePolicy(t, "sideways") + _, err := New(Config{Token: testToken, DevtoolsURL: DefaultDevtoolsURL, PolicyPath: p}) + require.Error(t, err) + }) + t.Run("good config succeeds", func(t *testing.T) { + _, err := New(Config{Token: testToken, DevtoolsURL: DefaultDevtoolsURL, PolicyPath: goodPolicy}) + require.NoError(t, err) + }) +} diff --git a/pkg/tools/browsergate/reload.go b/pkg/tools/browsergate/reload.go new file mode 100644 index 00000000..9ccf8207 --- /dev/null +++ b/pkg/tools/browsergate/reload.go @@ -0,0 +1,112 @@ +package browsergate + +import ( + "encoding/json" + "fmt" + "log/slog" + "os" + "sync" + "time" + + "github.com/authzed/openagentprimitives/pkg/tools/browserpolicy" +) + +// policyStore holds the current PolicyDoc and hot-reloads it from disk when the +// file's mtime changes. A parse (or validation) failure on reload keeps the +// last good policy in force and logs — the gate never fails open to "no +// policy". All access to the cached doc and mtime is guarded by mu so the +// reload racing with concurrent decisions is race-detector clean. +type policyStore struct { + path string + logger *slog.Logger + + mu sync.RWMutex + doc browserpolicy.PolicyDoc + modTime time.Time +} + +// newPolicyStore reads and validates the initial policy. It fails closed: an +// unreadable file, malformed JSON, or an unrecognized mode is an error, so a +// gate never starts serving against a policy it could not load. +func newPolicyStore(path string, logger *slog.Logger) (*policyStore, error) { + info, err := os.Stat(path) + if err != nil { + return nil, fmt.Errorf("browsergate: stat policy %q: %w", path, err) + } + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("browsergate: read policy %q: %w", path, err) + } + doc, err := parsePolicy(data) + if err != nil { + return nil, err + } + return &policyStore{ + path: path, + logger: logger, + doc: doc, + modTime: info.ModTime(), + }, nil +} + +// parsePolicy unmarshals a PolicyDoc and rejects any mode the gate cannot +// enforce. An unknown mode is treated as a parse failure so it fails closed at +// startup (New errors) and is ignored on reload (the old policy stays). +func parsePolicy(data []byte) (browserpolicy.PolicyDoc, error) { + var doc browserpolicy.PolicyDoc + if err := json.Unmarshal(data, &doc); err != nil { + return doc, fmt.Errorf("browsergate: parse policy: %w", err) + } + if doc.Mode != browserpolicy.ModeAllowlist && doc.Mode != browserpolicy.ModeBlocklist { + return doc, fmt.Errorf("browsergate: unknown policy mode %q", doc.Mode) + } + return doc, nil +} + +// current returns the active policy, re-reading the file first if its mtime +// changed since the last load. +func (s *policyStore) current() browserpolicy.PolicyDoc { + s.maybeReload() + s.mu.RLock() + defer s.mu.RUnlock() + return s.doc +} + +// maybeReload stats the policy file and, when the mtime differs from the cached +// one, re-reads and re-parses it. Any failure along the way is logged and the +// previous policy is retained — reload never widens access to "allow all" or +// narrows to "deny all" on a transient read/parse error. +func (s *policyStore) maybeReload() { + info, err := os.Stat(s.path) + if err != nil { + s.logger.Warn("browsergate: stat policy for reload failed; keeping current policy", + "path", s.path, "err", err.Error()) + return + } + + s.mu.RLock() + unchanged := info.ModTime().Equal(s.modTime) + s.mu.RUnlock() + if unchanged { + return + } + + data, err := os.ReadFile(s.path) + if err != nil { + s.logger.Warn("browsergate: re-read policy failed; keeping current policy", + "path", s.path, "err", err.Error()) + return + } + doc, err := parsePolicy(data) + if err != nil { + s.logger.Warn("browsergate: re-parse policy failed; keeping current policy", + "path", s.path, "err", err.Error()) + return + } + + s.mu.Lock() + s.doc = doc + s.modTime = info.ModTime() + s.mu.Unlock() + s.logger.Info("browsergate: policy reloaded", "path", s.path, "mode", doc.Mode, "sites", len(doc.Sites)) +} diff --git a/pkg/tools/browserpolicy/policy.go b/pkg/tools/browserpolicy/policy.go new file mode 100644 index 00000000..c131bf58 --- /dev/null +++ b/pkg/tools/browserpolicy/policy.go @@ -0,0 +1,197 @@ +// Package browserpolicy defines the host-matching and allow/deny-decision +// rules shared by the browser toolbox's per-session egress policy: the +// reconciler that writes the policy ConfigMap, the gate that reloads and +// enforces it, and the runner-side tools that need to pre-check a host +// before issuing a request. It is pure and stdlib-only so every one of +// those can import it without creating a dependency cycle. +package browserpolicy + +import ( + "fmt" + "net/netip" + "strings" +) + +const ( + // ModeAllowlist means only hosts covered by a Site in PolicyDoc.Sites + // are permitted; everything else is denied. + ModeAllowlist = "allowlist" + // ModeBlocklist means every host is permitted except those covered by + // a Site in PolicyDoc.Sites. + ModeBlocklist = "blocklist" +) + +// Site is a single host entry in a PolicyDoc. Host is matched against a +// candidate host by Match: by equality always, and additionally by +// suffix-by-label (subdomains of Host) unless Exact is set. +type Site struct { + Host string `json:"host"` + Exact bool `json:"exact,omitempty"` +} + +// PolicyDoc is the JSON document the reconciler writes into the per-session +// ConfigMap and the gate reloads on change. +type PolicyDoc struct { + Mode string `json:"mode"` + Sites []Site `json:"sites"` +} + +// Decision is the result of evaluating a host against a PolicyDoc. +type Decision struct { + Allowed bool + // Rule names why the decision came out the way it did. One of + // "not in allowlist", "explicit denylist", "private or metadata + // address", or "" when Allowed is true. + Rule string +} + +// CanonicalHost lowercases, strips exactly one trailing dot, and rejects +// anything with a scheme, port, path, space, or empty labels. +func CanonicalHost(raw string) (string, error) { + if raw == "" { + return "", fmt.Errorf("browserpolicy: empty host") + } + if strings.ContainsAny(raw, " \t\r\n") { + return "", fmt.Errorf("browserpolicy: host %q contains whitespace", raw) + } + if strings.Contains(raw, "://") { + return "", fmt.Errorf("browserpolicy: host %q contains a scheme", raw) + } + if strings.ContainsAny(raw, "/\\?#") { + return "", fmt.Errorf("browserpolicy: host %q contains a path", raw) + } + + host := strings.ToLower(raw) + + // Strip exactly one trailing dot. A second trailing dot (or any other + // empty label) is caught by the label-emptiness check below. + host = strings.TrimSuffix(host, ".") + + // A bracketed IPv6 literal (e.g. "[::1]" or "[::1]:443") is rejected: + // we don't accept bracket syntax at all here. + if strings.Contains(host, "[") || strings.Contains(host, "]") { + return "", fmt.Errorf("browserpolicy: host %q contains brackets", raw) + } + + if host == "" { + return "", fmt.Errorf("browserpolicy: host %q is empty after normalization", raw) + } + + // An IP literal (v4 or v6) is a valid canonical host as-is; short-circuit + // it BEFORE the colon check so plain IPv6 literals ("::1") survive, and + // skip the label-emptiness checks that only make sense for DNS names. + if _, err := netip.ParseAddr(host); err == nil { + return host, nil + } + + // Not an IP literal, so any remaining colon is a port (or colon + // garbage), never IPv6 syntax. Rejecting ANY colon — not exactly one — + // closes the "evil.com:80:81" bypass, which would otherwise pass through + // unchanged and then silently fail to match a blocklisted "evil.com". + if strings.Contains(host, ":") { + return "", fmt.Errorf("browserpolicy: host %q contains a port or stray colon", raw) + } + + for _, label := range strings.Split(host, ".") { + if label == "" { + return "", fmt.Errorf("browserpolicy: host %q has an empty label", raw) + } + } + + return host, nil +} + +// Match reports whether host (already canonical) is covered by site: +// equality always; suffix-by-label (".{site.Host}" suffix) unless +// site.Exact. +func Match(site Site, host string) bool { + if host == site.Host { + return true + } + if site.Exact { + return false + } + return strings.HasSuffix(host, "."+site.Host) +} + +// cgnat is the CGNAT shared-address space (RFC 6598, 100.64.0.0/10). Carrier- +// grade NAT space is not "private" in net/netip's sense -- addr.IsPrivate() +// only covers RFC1918 -- so it needs its own explicit check here. +var cgnat = netip.MustParsePrefix("100.64.0.0/10") + +// IsPrivateOrMetadataHost reports whether host is an IP literal in +// RFC1918, loopback, link-local (incl. 169.254.169.254), +// unique-local/v6-equivalent space, the CGNAT shared-address space +// (100.64.0.0/10), or the unspecified address (0.0.0.0 / ::). +// Non-IP-literal hosts (DNS names) always report false here; the caller +// is responsible for resolving a name before it reaches this check, if +// that matters for the caller's threat model. +func IsPrivateOrMetadataHost(host string) bool { + addr, err := netip.ParseAddr(host) + if err != nil { + return false + } + // Unmap a 4-in-6 address (e.g. ::ffff:0.0.0.0 or ::ffff:127.0.0.1) to its + // IPv4 form up front. net/netip's IsPrivate/IsLoopback/IsLinkLocalUnicast + // unmap internally, but IsUnspecified and cgnat.Contains do NOT, so a mapped + // ::ffff:0.0.0.0 would otherwise slip past the unspecified check (it is + // neither == 0.0.0.0 nor == ::) and reach loopback — on Linux dialing + // 0.0.0.0 connects to localhost, re-opening SSRF to the pod itself, + // including Chrome's own unauthenticated DevTools port. Unmapping once makes + // every predicate below see the canonical form. + addr = addr.Unmap() + // IsUnspecified: on Linux, dialing 0.0.0.0 (or ::) connects to loopback, + // so the unspecified address is a localhost-SSRF vector, not a no-op. + return addr.IsPrivate() || + addr.IsLoopback() || + addr.IsLinkLocalUnicast() || + addr.IsUnspecified() || + cgnat.Contains(addr) +} + +// Decide canonicalizes host and applies mode over sites. Private/metadata +// literals are denied in BOTH modes, before mode logic. A host that fails +// canonicalization is denied using the mode's own deny rule rather than +// surfacing the canonicalization error to the caller. +func Decide(mode string, sites []Site, rawHost string) Decision { + host, err := CanonicalHost(rawHost) + if err != nil { + // A malformed host can't be evaluated against sites at all; fail + // closed using the mode's own deny rule rather than surfacing the + // canonicalization error to the caller. + return Decision{Allowed: false, Rule: denyRuleFor(mode)} + } + + if IsPrivateOrMetadataHost(host) { + return Decision{Allowed: false, Rule: "private or metadata address"} + } + + switch mode { + case ModeAllowlist: + for _, site := range sites { + if Match(site, host) { + return Decision{Allowed: true} + } + } + return Decision{Allowed: false, Rule: "not in allowlist"} + case ModeBlocklist: + for _, site := range sites { + if Match(site, host) { + return Decision{Allowed: false, Rule: "explicit denylist"} + } + } + return Decision{Allowed: true} + default: + return Decision{Allowed: false, Rule: denyRuleFor(mode)} + } +} + +// denyRuleFor returns the Rule a Decision carries when mode itself denies a +// host outright (a malformed host, or an unrecognized mode) rather than +// denying by site evaluation. +func denyRuleFor(mode string) string { + if mode == ModeBlocklist { + return "explicit denylist" + } + return "not in allowlist" +} diff --git a/pkg/tools/browserpolicy/policy_test.go b/pkg/tools/browserpolicy/policy_test.go new file mode 100644 index 00000000..d20aa7d0 --- /dev/null +++ b/pkg/tools/browserpolicy/policy_test.go @@ -0,0 +1,124 @@ +package browserpolicy_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/authzed/openagentprimitives/pkg/tools/browserpolicy" +) + +func TestCanonicalHost(t *testing.T) { + cases := []struct { + name, in, want string + wantErr bool + }{ + {name: "lowercases: AA.com -> aa.com", in: "AA.com", want: "aa.com"}, + {name: "strips one trailing dot: aa.com. -> aa.com", in: "aa.com.", want: "aa.com"}, + {name: "port rejected: aa.com:8443 errors", in: "aa.com:8443", wantErr: true}, + {name: "scheme rejected: https://aa.com errors", in: "https://aa.com", wantErr: true}, + {name: "path rejected: aa.com/x errors", in: "aa.com/x", wantErr: true}, + {name: "space rejected", in: "aa .com", wantErr: true}, + {name: "empty rejected", in: "", wantErr: true}, + {name: "double trailing dot rejected: empty label", in: "aa.com..", wantErr: true}, + {name: "ip literal passes canonicalization", in: "8.8.8.8", want: "8.8.8.8"}, + {name: "ipv6 literal passes canonicalization: ::1", in: "::1", want: "::1"}, + {name: "double colon-garbage rejected: evil.com:80:81 errors", in: "evil.com:80:81", wantErr: true}, + {name: "colon-colon garbage rejected: evil.com::81 errors", in: "evil.com::81", wantErr: true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := browserpolicy.CanonicalHost(tc.in) + if tc.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tc.want, got) + }) + } +} + +func TestMatch(t *testing.T) { + cases := []struct { + name string + site browserpolicy.Site + host string + want bool + }{ + {name: "exact host matches itself", site: browserpolicy.Site{Host: "aa.com"}, host: "aa.com", want: true}, + {name: "suffix-by-label: www.aa.com under aa.com", site: browserpolicy.Site{Host: "aa.com"}, host: "www.aa.com", want: true}, + {name: "lookalike notaa.com does NOT match aa.com", site: browserpolicy.Site{Host: "aa.com"}, host: "notaa.com", want: false}, + {name: "exact pin: www.aa.com does NOT match exact aa.com", site: browserpolicy.Site{Host: "aa.com", Exact: true}, host: "www.aa.com", want: false}, + {name: "exact pin still matches itself", site: browserpolicy.Site{Host: "aa.com", Exact: true}, host: "aa.com", want: true}, + {name: "deep subdomain matches", site: browserpolicy.Site{Host: "aa.com"}, host: "a.b.aa.com", want: true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, browserpolicy.Match(tc.site, tc.host)) + }) + } +} + +func TestDecide(t *testing.T) { + sites := []browserpolicy.Site{{Host: "aa.com"}, {Host: "united.com", Exact: true}} + cases := []struct { + name, mode, host string + wantAllowed bool + wantRule string + }{ + {name: "allowlist admits listed suffix", mode: "allowlist", host: "www.aa.com", wantAllowed: true}, + {name: "allowlist denies unlisted: rule 'not in allowlist'", mode: "allowlist", host: "evil.com", wantAllowed: false, wantRule: "not in allowlist"}, + {name: "blocklist denies listed: rule 'explicit denylist'", mode: "blocklist", host: "aa.com", wantAllowed: false, wantRule: "explicit denylist"}, + {name: "blocklist admits everything else", mode: "blocklist", host: "example.org", wantAllowed: true}, + {name: "metadata IP denied even in blocklist mode", mode: "blocklist", host: "169.254.169.254", wantAllowed: false, wantRule: "private or metadata address"}, + {name: "rfc1918 denied even when allowlisted", mode: "allowlist", host: "10.0.0.5", wantAllowed: false, wantRule: "private or metadata address"}, + {name: "uncanonical input is canonicalized: AA.com. in allowlist", mode: "allowlist", host: "AA.com.", wantAllowed: true}, + {name: "malformed host is denied, not errored through", mode: "allowlist", host: "aa .com", wantAllowed: false, wantRule: "not in allowlist"}, + {name: "colon-garbage on a blocklisted host fails closed: aa.com:80:81 denied", mode: "blocklist", host: "aa.com:80:81", wantAllowed: false, wantRule: "explicit denylist"}, + {name: "unspecified address denied: 0.0.0.0 is a loopback-dial vector", mode: "blocklist", host: "0.0.0.0", wantAllowed: false, wantRule: "private or metadata address"}, + {name: "v4-mapped-v6 unspecified denied: ::ffff:0.0.0.0 must not slip past as a loopback-dial vector", mode: "blocklist", host: "::ffff:0.0.0.0", wantAllowed: false, wantRule: "private or metadata address"}, + {name: "cgnat denied even in blocklist mode", mode: "blocklist", host: "100.64.1.1", wantAllowed: false, wantRule: "private or metadata address"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := browserpolicy.Decide(tc.mode, sites, tc.host) + assert.Equal(t, tc.wantAllowed, got.Allowed) + assert.Equal(t, tc.wantRule, got.Rule) + }) + } +} + +func TestIsPrivateOrMetadataHost(t *testing.T) { + cases := []struct { + name, host string + want bool + }{ + {name: "rfc1918 10.x is private", host: "10.1.2.3", want: true}, + {name: "rfc1918 192.168.x is private", host: "192.168.0.1", want: true}, + {name: "rfc1918 172.16.x is private", host: "172.16.0.1", want: true}, + {name: "link-local metadata address", host: "169.254.169.254", want: true}, + {name: "loopback v4", host: "127.0.0.1", want: true}, + {name: "loopback v6", host: "::1", want: true}, + {name: "unique-local v6", host: "fd00::1", want: true}, + {name: "unspecified v4: 0.0.0.0 dials loopback on Linux", host: "0.0.0.0", want: true}, + {name: "unspecified v6: :: dials loopback on Linux", host: "::", want: true}, + {name: "public v4 is not private", host: "8.8.8.8", want: false}, + {name: "dns name is not private", host: "aa.com", want: false}, + {name: "public v6 is not private", host: "2606:4700::1111", want: false}, + {name: "cgnat shared-address space (RFC 6598) is private", host: "100.64.1.1", want: true}, + {name: "v4-mapped-v6 cgnat is private", host: "::ffff:100.64.1.1", want: true}, + // A 4-in-6 mapped unspecified address must be caught: IsUnspecified does + // NOT unmap, so without the up-front Unmap() this slipped through and + // dialed loopback (SSRF to the pod, incl. Chrome's DevTools port). + {name: "v4-mapped-v6 unspecified is private (SSRF guard)", host: "::ffff:0.0.0.0", want: true}, + {name: "v4-mapped-v6 loopback is private", host: "::ffff:127.0.0.1", want: true}, + {name: "v4-mapped-v6 metadata address is private", host: "::ffff:169.254.169.254", want: true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, browserpolicy.IsPrivateOrMetadataHost(tc.host)) + }) + } +} diff --git a/pkg/tools/kinds/browsertoolbox/init.go b/pkg/tools/kinds/browsertoolbox/init.go new file mode 100644 index 00000000..f11d4521 --- /dev/null +++ b/pkg/tools/kinds/browsertoolbox/init.go @@ -0,0 +1,8 @@ +// Package browsertoolbox registers the BrowserToolbox Kind with the global +// tools-kind registry. Importing this package side-effects the +// registration. +package browsertoolbox + +import "github.com/authzed/openagentprimitives/pkg/tools/kinds/registry" + +func init() { registry.Register(New()) } diff --git a/pkg/tools/kinds/browsertoolbox/kind.go b/pkg/tools/kinds/browsertoolbox/kind.go new file mode 100644 index 00000000..ea6507d9 --- /dev/null +++ b/pkg/tools/kinds/browsertoolbox/kind.go @@ -0,0 +1,201 @@ +// Package browsertoolbox implements the contract.Kind plug-in for the +// BrowserToolbox CR: CLI presentation only (Row, Detail, ValidateFile), +// blank-imported by cmd/oap. Host validation defers to +// pkg/tools/browserpolicy.CanonicalHost so the authoring-time check never +// drifts from the egress gate's own notion of a well-formed host. +package browsertoolbox + +import ( + "fmt" + "os" + "strings" + + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/yaml" + + spiceboxv1alpha1 "github.com/authzed/openagentprimitives/pkg/apis/v1alpha1" + "github.com/authzed/openagentprimitives/pkg/tools/browserpolicy" + "github.com/authzed/openagentprimitives/pkg/tools/contract" +) + +// Valid Interaction values. There is no shared const for these in +// spiceboxv1alpha1 (the CRD states them only as a kubebuilder Enum marker on +// BrowserToolboxSpec.Interaction), so they are mirrored here as string +// literals that must stay in sync with that marker. +const ( + interactionAct = "act" + interactionReadonly = "readonly" +) + +type Kind struct{} + +func New() *Kind { return &Kind{} } + +func (Kind) Name() string { return "BrowserToolbox" } + +func (Kind) GVR() schema.GroupVersionResource { + return schema.GroupVersionResource{ + Group: spiceboxv1alpha1.GroupName, + Version: spiceboxv1alpha1.SchemeGroupVersion.Version, + Resource: "browsertoolboxes", + } +} + +func (Kind) NewObject() client.Object { return &spiceboxv1alpha1.BrowserToolbox{} } +func (Kind) NewList() client.ObjectList { return &spiceboxv1alpha1.BrowserToolboxList{} } + +func (Kind) DecodeYAML(doc []byte) (client.Object, error) { + var probe struct { + APIVersion string `yaml:"apiVersion"` + Kind string `yaml:"kind"` + } + if err := yaml.Unmarshal(doc, &probe); err != nil { + return nil, fmt.Errorf("decode kind: %w", err) + } + if probe.Kind != "BrowserToolbox" { + return nil, nil + } + var out spiceboxv1alpha1.BrowserToolbox + if err := yaml.Unmarshal(doc, &out); err != nil { + return nil, fmt.Errorf("decode BrowserToolbox: %w", err) + } + return &out, nil +} + +func (Kind) Row(obj client.Object) contract.Row { + cr := obj.(*spiceboxv1alpha1.BrowserToolbox) + return contract.Row{ + Kind: "BrowserToolbox", + Name: cr.Name, + Namespace: cr.Namespace, + Status: fmt.Sprintf("Mode=%s Interaction=%s", cr.Spec.Access.Mode, cr.Spec.Interaction), + Summary: fmt.Sprintf("%d site(s)", len(cr.Spec.Access.Sites)), + } +} + +func (Kind) Detail(obj client.Object) contract.Detail { + cr := obj.(*spiceboxv1alpha1.BrowserToolbox) + return contract.Detail{ + Kind: "BrowserToolbox", + Name: cr.Name, + Namespace: cr.Namespace, + Sections: []contract.Section{ + {Title: "Access", Body: renderAccess(cr.Spec.Access)}, + {Title: "Interaction", Body: cr.Spec.Interaction}, + {Title: "State", Body: cr.Spec.State}, + }, + } +} + +// MatchFlat: top-level access.mode present, and no MCPServer-shaped +// server.url or SidecarToolbox-shaped source. +func (Kind) MatchFlat(data []byte) bool { + var probe struct { + Access *struct { + Mode string `yaml:"mode"` + } `yaml:"access"` + Server *struct { + URL string `yaml:"url"` + } `yaml:"server"` + Source map[string]any `yaml:"source"` + } + if err := yaml.Unmarshal(data, &probe); err != nil { + return false + } + if probe.Server != nil && probe.Server.URL != "" { + return false + } + if probe.Source != nil { + return false + } + return probe.Access != nil && probe.Access.Mode != "" +} + +func (k Kind) ValidateFile(path string) ([]contract.Diagnostic, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var sp spiceboxv1alpha1.BrowserToolboxSpec + if err := yaml.Unmarshal(data, &sp); err != nil { + return []contract.Diagnostic{{Severity: "error", Path: path, Message: err.Error()}}, nil + } + + var diags []contract.Diagnostic + + if sp.Access.Mode != browserpolicy.ModeAllowlist && sp.Access.Mode != browserpolicy.ModeBlocklist { + diags = append(diags, contract.Diagnostic{ + Severity: "error", + Path: "access.mode", + Message: fmt.Sprintf("access.mode %q is not valid (want %q or %q)", sp.Access.Mode, browserpolicy.ModeAllowlist, browserpolicy.ModeBlocklist), + }) + } + + // Canonicalize every site's host via the SAME browserpolicy.CanonicalHost + // the runtime egress gate uses, so an authoring-time-valid host can never + // turn out to be rejected (or silently mismatched) at enforcement time. + // The canonical form also dedupes entries that differ only in case or a + // trailing dot. + seen := make(map[string]int, len(sp.Access.Sites)) + for i, site := range sp.Access.Sites { + sitePath := fmt.Sprintf("access.sites[%d].host", i) + canon, err := browserpolicy.CanonicalHost(site.Host) + if err != nil { + diags = append(diags, contract.Diagnostic{Severity: "error", Path: sitePath, Message: err.Error()}) + continue + } + if firstIdx, dup := seen[canon]; dup { + diags = append(diags, contract.Diagnostic{ + Severity: "error", + Path: sitePath, + Message: fmt.Sprintf("duplicate host %q (already listed at access.sites[%d].host)", canon, firstIdx), + }) + continue + } + seen[canon] = i + } + + // An allowlist with no sites denies every host — not a structural error, + // but almost certainly not what the author intended, so it's a warning + // rather than an error. + if sp.Access.Mode == browserpolicy.ModeAllowlist && len(sp.Access.Sites) == 0 { + diags = append(diags, contract.Diagnostic{ + Severity: "warning", + Path: "access.sites", + Message: "allowlist with no sites denies all browsing", + }) + } + + if sp.Interaction != "" && sp.Interaction != interactionAct && sp.Interaction != interactionReadonly { + diags = append(diags, contract.Diagnostic{ + Severity: "error", + Path: "interaction", + Message: fmt.Sprintf("interaction %q is not valid (want %q or %q)", sp.Interaction, interactionAct, interactionReadonly), + }) + } + + return diags, nil +} + +func (k Kind) LintFile(path string) ([]contract.Diagnostic, error) { return k.ValidateFile(path) } + +// renderAccess renders Mode and the site list, marking Exact entries so +// `oap tools get` surfaces the subdomain-matching behavior Sites implies +// (see BrowserAccess's doc comment). +func renderAccess(a spiceboxv1alpha1.BrowserAccess) string { + var b strings.Builder + fmt.Fprintf(&b, "mode: %s\n", a.Mode) + if len(a.Sites) == 0 { + b.WriteString("sites: (none)") + return b.String() + } + b.WriteString("sites:") + for _, s := range a.Sites { + b.WriteString("\n - " + s.Host) + if s.Exact { + b.WriteString(" (exact)") + } + } + return b.String() +} diff --git a/pkg/tools/kinds/browsertoolbox/kind_test.go b/pkg/tools/kinds/browsertoolbox/kind_test.go new file mode 100644 index 00000000..d1cd4431 --- /dev/null +++ b/pkg/tools/kinds/browsertoolbox/kind_test.go @@ -0,0 +1,305 @@ +package browsertoolbox + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + + spiceboxv1alpha1 "github.com/authzed/openagentprimitives/pkg/apis/v1alpha1" + "github.com/authzed/openagentprimitives/pkg/tools/contract" +) + +func writeTemp(t *testing.T, body string) string { + t.Helper() + dir := t.TempDir() + p := filepath.Join(dir, "spec.yaml") + require.NoError(t, os.WriteFile(p, []byte(body), 0o600)) + return p +} + +func sampleCR() *spiceboxv1alpha1.BrowserToolbox { + return &spiceboxv1alpha1.BrowserToolbox{ + ObjectMeta: metav1.ObjectMeta{Name: "btb1", Namespace: "ns1"}, + Spec: spiceboxv1alpha1.BrowserToolboxSpec{ + Access: spiceboxv1alpha1.BrowserAccess{ + Mode: "allowlist", + Sites: []spiceboxv1alpha1.BrowserSite{ + {Host: "example.com"}, + {Host: "exact.example.com", Exact: true}, + }, + }, + Interaction: "readonly", + State: "ephemeral", + }, + } +} + +// findDiag returns the first diagnostic at path, so tests can assert on a +// specific finding without coupling to the full diagnostic list (and its +// order) produced for the rest of the fixture. +func findDiag(diags []contract.Diagnostic, path string) (contract.Diagnostic, bool) { + for _, d := range diags { + if d.Path == path { + return d, true + } + } + return contract.Diagnostic{}, false +} + +func TestKind_BasicMetadata(t *testing.T) { + k := New() + assert.Equal(t, "BrowserToolbox", k.Name()) + gvr := k.GVR() + assert.Equal(t, "agentprimitives.authzed.com", gvr.Group) + assert.Equal(t, "browsertoolboxes", gvr.Resource) + _, ok := k.NewObject().(*spiceboxv1alpha1.BrowserToolbox) + assert.True(t, ok, "NewObject wrong type") + _, ok = k.NewList().(*spiceboxv1alpha1.BrowserToolboxList) + assert.True(t, ok, "NewList wrong type") +} + +func TestKind_DecodeYAML(t *testing.T) { + cases := []struct { + name string + doc string + wantNil bool + check func(t *testing.T, obj client.Object) + }{ + { + name: "SidecarToolbox doc is not claimed", + doc: `apiVersion: agentprimitives.authzed.com/v1alpha1 +kind: SidecarToolbox +metadata: { name: x } +spec: {} +`, + wantNil: true, + }, + { + name: "BrowserToolbox doc decodes to the typed object", + doc: `apiVersion: agentprimitives.authzed.com/v1alpha1 +kind: BrowserToolbox +metadata: { name: x } +spec: + access: + mode: allowlist + sites: + - host: example.com + interaction: readonly +`, + wantNil: false, + check: func(t *testing.T, obj client.Object) { + cr, ok := obj.(*spiceboxv1alpha1.BrowserToolbox) + require.True(t, ok, "expected *BrowserToolbox, got %T", obj) + assert.Equal(t, "x", cr.Name) + assert.Equal(t, "allowlist", cr.Spec.Access.Mode) + assert.Equal(t, "readonly", cr.Spec.Interaction) + }, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + obj, err := New().DecodeYAML([]byte(tc.doc)) + require.NoError(t, err) + if tc.wantNil { + assert.Nil(t, obj) + return + } + require.NotNil(t, obj) + tc.check(t, obj) + }) + } +} + +func TestKind_MatchFlat(t *testing.T) { + cases := []struct { + name string + data string + want bool + }{ + { + name: "access.mode present, no source/server: matches", + data: `access: + mode: allowlist + sites: + - host: example.com +interaction: readonly +`, + want: true, + }, + { + name: "sidecar-shaped doc: does not match", + data: `name: redditro +version: "1" +source: { image: ghcr.io/x:v1 } +sandbox: { class: sidecar-sandbox-default } +transport: { port: 8080 } +upstreamAuth: { provider: reddit-app } +tools: [] +`, + want: false, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, New().MatchFlat([]byte(tc.data))) + }) + } +} + +func TestKind_ValidateFile(t *testing.T) { + cases := []struct { + name string + body string + check func(t *testing.T, diags []contract.Diagnostic) + }{ + { + name: "valid allowlist with two sites: no diagnostics", + body: ` +access: + mode: allowlist + sites: + - host: example.com + - host: other.example.org +interaction: act +state: ephemeral +`, + check: func(t *testing.T, diags []contract.Diagnostic) { + assert.Empty(t, diags) + }, + }, + { + name: "bad mode: sideways errors naming access.mode", + body: ` +access: + mode: sideways + sites: + - host: example.com +`, + check: func(t *testing.T, diags []contract.Diagnostic) { + d, ok := findDiag(diags, "access.mode") + require.True(t, ok, "expected a diagnostic at path access.mode, got %+v", diags) + assert.Equal(t, "error", d.Severity) + assert.Contains(t, d.Message, "sideways") + }, + }, + { + name: "bad host: https://aa.com errors via CanonicalHost", + body: ` +access: + mode: allowlist + sites: + - host: https://aa.com +`, + check: func(t *testing.T, diags []contract.Diagnostic) { + d, ok := findDiag(diags, "access.sites[0].host") + require.True(t, ok, "expected a diagnostic at path access.sites[0].host, got %+v", diags) + assert.Equal(t, "error", d.Severity) + assert.Contains(t, d.Message, "scheme") + }, + }, + { + name: "duplicate host entries error", + body: ` +access: + mode: allowlist + sites: + - host: example.com + - host: example.com +`, + check: func(t *testing.T, diags []contract.Diagnostic) { + d, ok := findDiag(diags, "access.sites[1].host") + require.True(t, ok, "expected a diagnostic at path access.sites[1].host, got %+v", diags) + assert.Equal(t, "error", d.Severity) + assert.Contains(t, d.Message, "duplicate") + }, + }, + { + name: "allowlist with zero sites: warning", + body: ` +access: + mode: allowlist +`, + check: func(t *testing.T, diags []contract.Diagnostic) { + d, ok := findDiag(diags, "access.sites") + require.True(t, ok, "expected a diagnostic at path access.sites, got %+v", diags) + assert.Equal(t, "warning", d.Severity) + assert.Equal(t, "allowlist with no sites denies all browsing", d.Message) + }, + }, + { + name: "bad interaction: write errors", + body: ` +access: + mode: allowlist + sites: + - host: example.com +interaction: write +`, + check: func(t *testing.T, diags []contract.Diagnostic) { + d, ok := findDiag(diags, "interaction") + require.True(t, ok, "expected a diagnostic at path interaction, got %+v", diags) + assert.Equal(t, "error", d.Severity) + assert.Contains(t, d.Message, "write") + }, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + path := writeTemp(t, tc.body) + diags, err := New().ValidateFile(path) + require.NoError(t, err) + tc.check(t, diags) + }) + } +} + +func TestKind_LintFile_DelegatesToValidateFile(t *testing.T) { + path := writeTemp(t, ` +access: + mode: sideways +`) + validateDiags, err := New().ValidateFile(path) + require.NoError(t, err) + lintDiags, err := New().LintFile(path) + require.NoError(t, err) + assert.Equal(t, validateDiags, lintDiags) +} + +func TestKind_Row(t *testing.T) { + row := New().Row(sampleCR()) + assert.Equal(t, "BrowserToolbox", row.Kind) + assert.Equal(t, "btb1", row.Name) + assert.Equal(t, "ns1", row.Namespace) + assert.Equal(t, "Mode=allowlist Interaction=readonly", row.Status) + assert.Contains(t, row.Summary, "2") +} + +func TestKind_Detail_HasAllSections(t *testing.T) { + d := New().Detail(sampleCR()) + assert.Equal(t, "BrowserToolbox", d.Kind) + assert.Equal(t, "btb1", d.Name) + titles := make([]string, 0, len(d.Sections)) + body := "" + for _, s := range d.Sections { + titles = append(titles, s.Title) + body += s.Title + "\n" + s.Body + "\n" + } + assert.Contains(t, titles, "Access") + assert.Contains(t, titles, "Interaction") + assert.Contains(t, titles, "State") + for _, want := range []string{ + "allowlist", + "example.com", + "exact.example.com", + "(exact)", + "readonly", + "ephemeral", + } { + assert.Contains(t, body, want, "detail missing %q", want) + } +} diff --git a/pkg/web/workshopprojectsrv/whitelist_test.go b/pkg/web/workshopprojectsrv/whitelist_test.go index bda91b82..0bae763b 100644 --- a/pkg/web/workshopprojectsrv/whitelist_test.go +++ b/pkg/web/workshopprojectsrv/whitelist_test.go @@ -51,6 +51,7 @@ var notProjectedFields = map[string]bool{ "MCPServers": true, "CredentialExplanations": true, "SidecarToolboxes": true, + "BrowserToolboxes": true, "WorkspaceSource": true, "ToolSessionLog": true, "Budget": true, diff --git a/pkg/web/workshopprojectsrv/workshopprojectsrv.go b/pkg/web/workshopprojectsrv/workshopprojectsrv.go index ac878ce8..029fe787 100644 --- a/pkg/web/workshopprojectsrv/workshopprojectsrv.go +++ b/pkg/web/workshopprojectsrv/workshopprojectsrv.go @@ -71,7 +71,7 @@ // all. // // and copying NONE of: AgentIdentity, MCPServers, ToolBundles, -// CredentialExplanations, SidecarToolboxes, WorkspaceSource, Budget, +// CredentialExplanations, SidecarToolboxes, BrowserToolboxes, WorkspaceSource, Budget, // BoundEntities, Channels, Authz, Capabilities, CompletionRequirements, // AgentUI, Model, Harness, IdentityMode and its two timeouts, // IdentityRecommendation, ToolSessionLog, ToolGuard. See projectStandin's @@ -534,8 +534,8 @@ func reachable(agents []workshopthreadsrv.AgentInThread, ns, class string) bool // NEVER PROJECTED (left at zero value; the security boundary): // - AgentIdentity, IdentityMode, CredentialLinkTimeout, // IdentityRecommendation, IdentityChoiceTimeout — no credential, ever. -// - ToolBundles, MCPServers, SidecarToolboxes, WorkspaceSource, -// CredentialExplanations, ToolGuard — no tool access, ever. +// - ToolBundles, MCPServers, SidecarToolboxes, BrowserToolboxes, +// WorkspaceSource, CredentialExplanations, ToolGuard — no tool access, ever. // - Capabilities — no meta-tool capability grant. // - Authz — no slots, no start gate, no interact grant, no plan gate: a // stand-in carries no authorization policy of its own. diff --git a/site/content/docs/crd-agentclass.mdx b/site/content/docs/crd-agentclass.mdx index 6d1ff47f..022694ba 100644 --- a/site/content/docs/crd-agentclass.mdx +++ b/site/content/docs/crd-agentclass.mdx @@ -117,6 +117,9 @@ session closed rather than starting it degraded. | `spec.boundEntities[].requires[].undeterminedHint` \* | `string` | UndeterminedHint is shown to the AGENT while some referenced fact has not been recorded. It is the only party who can fix that, by making the call that establishes it — so this should name that call. Required, and the reason is recorded on PermissionCheck.ResourceIDHint: without one the agent sees a raw denial that reads as a system fault and tells it not to bother retrying. | | `spec.boundEntities[].resourceType` \* | `string` | | | `spec.boundEntities[].triggerInstance` | `string` | TriggerInstance is a CEL expression over the verified webhook delivery ({event: string, payload: dyn}) yielding the resource ID this slot binds at trigger time. The resource TYPE is always this slot's resourceType — the expression yields only the id half. When set it is authoritative for this slot; when unset, a channel kind that derives instances from the delivery (TriggerSlotProvider) supplies them. Compiled and validated at class admission; at delivery time an eval error, non-string, or empty result binds nothing. Meaningful only when fillFrom includes "trigger". | +| `spec.browserToolboxes` | `[]object` | BrowserToolboxes references BrowserToolbox CRs in the same namespace whose per-session browser each session gets. Each ref's Name is the LLM-facing prefix; pattern [a-z0-9_-]{1,32}. | +| `spec.browserToolboxes[].name` \* | `string` | Name is the LLM-visible tool prefix; pattern [a-z0-9_-]{1,32}. | +| `spec.browserToolboxes[].ref` \* | `string` | Ref names the BrowserToolbox CR in the same namespace. | | `spec.budget` | `object` | Budget is optional: when omitted, the class inherits the resolved tier default budget (see status.effectiveSettings.budget). | | `spec.budget.maxDelegatedAgents` | `integer (int32)` | MaxDelegatedAgents bounds the TOTAL number of sessions in one delegation tree, counting the root. It is a property of the tree rather than of any one session: every other dimension here caps a single session's spend, and a tree of N sessions each individually within budget still spends N times it. Resolved from the ROOT session's effective settings and enforced by the SubagentRequest controller before a child is created. Zero means UNSET, not unlimited. A consumer must apply its own built-in bound rather than reading zero as "no cap" — that reading is right for a DURATION (as MaxDuration uses it) and wrong for a COUNT, where it would license exactly the runaway fan-out this field exists to prevent. _(min 0)_ | | `spec.budget.maxDuration` \* | `string` | MaxDuration is the cumulative ACTIVE RUN-TIME budget (e.g. "30m") — the wall time the agent actually spent working, excluding time parked waiting on a human (the next user message or a tool-call approval). It persists across sleep/resume via status.runDuration; it is NOT wall-clock since session start. For a wall-clock lifetime cap, use SessionExpiration. Zero = no run-time cap. | diff --git a/site/content/docs/crd-agentsession.mdx b/site/content/docs/crd-agentsession.mdx index a15a5b51..d65e0c4c 100644 --- a/site/content/docs/crd-agentsession.mdx +++ b/site/content/docs/crd-agentsession.mdx @@ -356,6 +356,29 @@ Status is controller-owned (observed state). | `status.progress.outputTokens` | `integer (int64)` | OutputTokens is cumulative completion tokens returned by the provider. | | `status.progress.toolCallCount` | `integer (int32)` | ToolCallCount is how many tool calls the agent has issued. | | `status.progress.turnCount` | `integer (int32)` | TurnCount is how many agent turns have completed. | +| `status.resolvedBrowserToolboxes` | `[]object` | ResolvedBrowserToolboxes snapshots each referenced BrowserToolbox at session start, frozen for the session lifetime exactly like ResolvedSidecarToolboxes. Empty when AgentClass.spec.browserToolboxes is empty. | +| `status.resolvedBrowserToolboxes[].browserPodIP` | `string` | BrowserPodIP is the per-session browser pod's IP, reflected once Ready; the runner reaches its gate at http://<BrowserPodIP>:<GatePort>. | +| `status.resolvedBrowserToolboxes[].browserPodName` | `string` | BrowserPodName is the per-session browser pod's name. | +| `status.resolvedBrowserToolboxes[].effectiveSites` | `[]object` | EffectiveSites is the resolved site list the running browser's egress is actually evaluated against -- in Phase A, a straight carry-through of Spec.Access.Sites with Source "explicit". | +| `status.resolvedBrowserToolboxes[].effectiveSites[].exact` | `boolean` | Exact mirrors BrowserSite.Exact for this entry. | +| `status.resolvedBrowserToolboxes[].effectiveSites[].host` \* | `string` | Host is the resolved hostname, matched exactly as BrowserSite.Host. | +| `status.resolvedBrowserToolboxes[].effectiveSites[].source` \* | `string` | Source is where this entry came from. "explicit" only in Phase A; "identity" arrives in Phase B. | +| `status.resolvedBrowserToolboxes[].gatePort` | `integer (int32)` | GatePort is the operator-allocated port the runner uses to reach the browser pod's gate process. | +| `status.resolvedBrowserToolboxes[].name` \* | `string` | Name is the LLM-facing prefix (the `name` field from AgentClassBrowserToolboxRef). | +| `status.resolvedBrowserToolboxes[].podFailure` | `object` | PodFailure records a TERMINAL container state on the browser pod (CrashLoopBackOff, ImagePullBackOff) -- reuses SidecarPodFailure's shape exactly; see that type's doc comment for why it carries no timestamp. | +| `status.resolvedBrowserToolboxes[].podFailure.exitCode` | `integer (int32)` | ExitCode is the container's last terminated exit code. Nil when the container never ran (e.g. the image could not be pulled). | +| `status.resolvedBrowserToolboxes[].podFailure.message` | `string` | Message is the most actionable text available: the container's last terminated message when present — the crashing container's log tail, thanks to the pod's FallbackToLogsOnError policy — otherwise the kubelet's waiting message. Preferring the log tail is what makes the field worth surfacing; the waiting message alone is content-free for a crash loop ("back-off Ns restarting failed container=…"). | +| `status.resolvedBrowserToolboxes[].podFailure.reason` \* | `string` | Reason is the kubelet's terminal container Waiting reason, e.g. "CrashLoopBackOff" or "ImagePullBackOff". | +| `status.resolvedBrowserToolboxes[].ref` \* | `string` | Ref is the BrowserToolbox CR name (the `ref` field from AgentClassBrowserToolboxRef). | +| `status.resolvedBrowserToolboxes[].spec` \* | `object` | Spec is the BrowserToolbox spec snapshot at session-start time. | +| `status.resolvedBrowserToolboxes[].spec.access` \* | `object` | Access is the site allowlist/blocklist the browser's egress is evaluated against. | +| `status.resolvedBrowserToolboxes[].spec.access.mode` \* | `string` | Mode is "allowlist" (only Sites are reachable) or "blocklist" (every site except Sites is reachable). _(enum: allowlist \| blocklist)_ | +| `status.resolvedBrowserToolboxes[].spec.access.sites` | `[]object` | Sites is the set of hosts Mode interprets. Matching is suffix-by-label unless Exact is set: a Site with Host "example.com" also matches "www.example.com" and "a.b.example.com", but never a lookalike like "notexample.com" -- pkg/tools/browserpolicy.Match does label-boundary comparison, not a raw string suffix check. Exact pins the entry to the literal host only. | +| `status.resolvedBrowserToolboxes[].spec.access.sites[].exact` | `boolean` | Exact pins this entry to the literal Host only, disabling the default suffix-by-label subdomain match. | +| `status.resolvedBrowserToolboxes[].spec.access.sites[].host` \* | `string` | Host is the DNS hostname this entry covers, matched suffix-by-label (see BrowserAccess.Sites) unless Exact is set. | +| `status.resolvedBrowserToolboxes[].spec.interaction` | `string` | Interaction gates whether the LLM may act on the page (click, type, navigate) or only read its rendered content. act -- full interaction. readonly -- observation only; no mutating actions are dispatched. _(enum: act \| readonly; default: act)_ | +| `status.resolvedBrowserToolboxes[].spec.state` | `string` | State selects how the browser's session state (cookies, storage) is retained across turns. Phase A: ephemeral is the only state; persistent arrives with identities (Phase C). _(enum: ephemeral; default: ephemeral)_ | +| `status.resolvedBrowserToolboxes[].tokenHash` | `string` | TokenHash is a hash of the resolved per-session gate token; a change triggers pod replacement. | | `status.resolvedContentGuardDetectors` | `[]object` | ResolvedContentGuardDetectors snapshots the detector sidecars injected for content-guard inspectors (e.g. prompt-injection). Empty ⇒ none. | | `status.resolvedContentGuardDetectors[].healthPath` | `string` | HealthPath is the readiness path; empty means the pod has no probe path. | | `status.resolvedContentGuardDetectors[].image` \* | `string` | Image is the detector container image. | diff --git a/site/content/docs/crd-browsertoolbox.mdx b/site/content/docs/crd-browsertoolbox.mdx new file mode 100644 index 00000000..3e37e901 --- /dev/null +++ b/site/content/docs/crd-browsertoolbox.mdx @@ -0,0 +1,60 @@ +export const meta = { + title: 'BrowserToolbox', + section: 'Reference', + group: 'CRD reference', + order: 3080, + description: 'BrowserToolbox declares a per-session browser the agent can drive: which', +} + +# BrowserToolbox + +**Group** `agentprimitives.authzed.com` · **Scope** Namespaced · **Short names** `btb` + +BrowserToolbox declares a per-session browser the agent can drive: which +sites it may reach and whether the LLM may act on the page or only read it. + +Namespaced. An AgentClass references it via AgentClassBrowserToolboxRef, +and the AgentSession reconciler snapshots the referenced spec into +status.resolvedBrowserToolboxes at session start (see +ResolvedBrowserToolbox), frozen for the session's lifetime exactly like a +SidecarToolbox. + +Phase A deliberately ships a narrow field subset: Access covers only an +explicit allowlist/blocklist of sites (Task 1's pkg/tools/browserpolicy +decides admission from it), Interaction is act/readonly, and State is +pinned to "ephemeral" -- there is no persistent browser profile/identity +yet. Those arrive in later phases (identity-sourced sites in Phase B, +persistent state in Phase C) as additive fields, not a breaking change to +this spec. + +## Spec + +| Field | Type | Description | +| --- | --- | --- | +| `spec.access` \* | `object` | Access is the site allowlist/blocklist the browser's egress is evaluated against. | +| `spec.access.mode` \* | `string` | Mode is "allowlist" (only Sites are reachable) or "blocklist" (every site except Sites is reachable). _(enum: allowlist \| blocklist)_ | +| `spec.access.sites` | `[]object` | Sites is the set of hosts Mode interprets. Matching is suffix-by-label unless Exact is set: a Site with Host "example.com" also matches "www.example.com" and "a.b.example.com", but never a lookalike like "notexample.com" -- pkg/tools/browserpolicy.Match does label-boundary comparison, not a raw string suffix check. Exact pins the entry to the literal host only. | +| `spec.access.sites[].exact` | `boolean` | Exact pins this entry to the literal Host only, disabling the default suffix-by-label subdomain match. | +| `spec.access.sites[].host` \* | `string` | Host is the DNS hostname this entry covers, matched suffix-by-label (see BrowserAccess.Sites) unless Exact is set. | +| `spec.interaction` | `string` | Interaction gates whether the LLM may act on the page (click, type, navigate) or only read its rendered content. act -- full interaction. readonly -- observation only; no mutating actions are dispatched. _(enum: act \| readonly; default: act)_ | +| `spec.state` | `string` | State selects how the browser's session state (cookies, storage) is retained across turns. Phase A: ephemeral is the only state; persistent arrives with identities (Phase C). _(enum: ephemeral; default: ephemeral)_ | + +\* required + +## Status + +Status is controller-owned (observed state). + +| Field | Type | Description | +| --- | --- | --- | +| `status.conditions` | `[]object` | Conditions carries this BrowserToolbox's Valid condition. | +| `status.conditions[].lastTransitionTime` \* | `string (date-time)` | lastTransitionTime is the last time the condition transitioned from one status to another. This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. | +| `status.conditions[].message` \* | `string` | message is a human readable message indicating details about the transition. This may be an empty string. | +| `status.conditions[].observedGeneration` | `integer (int64)` | observedGeneration represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date with respect to the current state of the instance. _(min 0)_ | +| `status.conditions[].reason` \* | `string` | reason contains a programmatic identifier indicating the reason for the condition's last transition. Producers of specific condition types may define expected values and meanings for this field, and whether the values are considered a guaranteed API. The value should be a CamelCase string. This field may not be empty. | +| `status.conditions[].status` \* | `string` | status of the condition, one of True, False, Unknown. _(enum: True \| False \| Unknown)_ | +| `status.conditions[].type` \* | `string` | type of condition in CamelCase or in foo.example.com/CamelCase. | +| `status.observedGeneration` | `integer (int64)` | ObservedGeneration is the spec generation this status reflects. | + +\* required + diff --git a/site/content/docs/crd-channel.mdx b/site/content/docs/crd-channel.mdx index 4f420401..0817fe49 100644 --- a/site/content/docs/crd-channel.mdx +++ b/site/content/docs/crd-channel.mdx @@ -2,7 +2,7 @@ export const meta = { title: 'Channel', section: 'Reference', group: 'CRD reference', - order: 3080, + order: 3090, description: 'Channel binds one transport conversation -- a Slack channel, a scheduled', } diff --git a/site/content/docs/crd-clusteragentsettings.mdx b/site/content/docs/crd-clusteragentsettings.mdx index 022a414d..7288fe92 100644 --- a/site/content/docs/crd-clusteragentsettings.mdx +++ b/site/content/docs/crd-clusteragentsettings.mdx @@ -2,7 +2,7 @@ export const meta = { title: 'ClusterAgentSettings', section: 'Reference', group: 'CRD reference', - order: 3090, + order: 3100, description: 'ClusterAgentSettings is the cluster-wide top tier of agent governance', } diff --git a/site/content/docs/crd-clusteridentityprovider.mdx b/site/content/docs/crd-clusteridentityprovider.mdx index 4dc9e458..3346a35c 100644 --- a/site/content/docs/crd-clusteridentityprovider.mdx +++ b/site/content/docs/crd-clusteridentityprovider.mdx @@ -2,7 +2,7 @@ export const meta = { title: 'ClusterIdentityProvider', section: 'Reference', group: 'CRD reference', - order: 3100, + order: 3110, description: 'ClusterIdentityProvider configures cluster-wide human login: which', } diff --git a/site/content/docs/crd-clusterskill.mdx b/site/content/docs/crd-clusterskill.mdx index 6eb8b263..967d0332 100644 --- a/site/content/docs/crd-clusterskill.mdx +++ b/site/content/docs/crd-clusterskill.mdx @@ -2,7 +2,7 @@ export const meta = { title: 'ClusterSkill', section: 'Reference', group: 'CRD reference', - order: 3110, + order: 3120, description: 'ClusterSkill is a cluster-scoped Skill, visible to every namespace. A', } diff --git a/site/content/docs/crd-clusterskillsource.mdx b/site/content/docs/crd-clusterskillsource.mdx index 296681df..ba45ffab 100644 --- a/site/content/docs/crd-clusterskillsource.mdx +++ b/site/content/docs/crd-clusterskillsource.mdx @@ -2,7 +2,7 @@ export const meta = { title: 'ClusterSkillSource', section: 'Reference', group: 'CRD reference', - order: 3120, + order: 3130, description: 'ClusterSkillSource is a git repo that materializes cluster-scoped', } diff --git a/site/content/docs/crd-credentialupdaterequest.mdx b/site/content/docs/crd-credentialupdaterequest.mdx index 874e6ff0..607e4c92 100644 --- a/site/content/docs/crd-credentialupdaterequest.mdx +++ b/site/content/docs/crd-credentialupdaterequest.mdx @@ -2,7 +2,7 @@ export const meta = { title: 'CredentialUpdateRequest', section: 'Reference', group: 'CRD reference', - order: 3130, + order: 3140, description: 'CredentialUpdateRequest is an agent\'s REQUEST that a human replace a', } diff --git a/site/content/docs/crd-mcpserver.mdx b/site/content/docs/crd-mcpserver.mdx index 5c547a18..333ff81a 100644 --- a/site/content/docs/crd-mcpserver.mdx +++ b/site/content/docs/crd-mcpserver.mdx @@ -2,7 +2,7 @@ export const meta = { title: 'MCPServer', section: 'Reference', group: 'CRD reference', - order: 3140, + order: 3150, description: 'MCPServer declares one MCP endpoint an agent may call: where it lives', } diff --git a/site/content/docs/crd-publicendpoint.mdx b/site/content/docs/crd-publicendpoint.mdx index b1ffa3b2..92131303 100644 --- a/site/content/docs/crd-publicendpoint.mdx +++ b/site/content/docs/crd-publicendpoint.mdx @@ -2,7 +2,7 @@ export const meta = { title: 'PublicEndpoint', section: 'Reference', group: 'CRD reference', - order: 3150, + order: 3160, description: 'PublicEndpoint is how this cluster is reached from the public Internet.', } diff --git a/site/content/docs/crd-reference.mdx b/site/content/docs/crd-reference.mdx index ca79f241..75a1c9d5 100644 --- a/site/content/docs/crd-reference.mdx +++ b/site/content/docs/crd-reference.mdx @@ -21,6 +21,7 @@ full spec. | [AgentSettings](/docs/crd-agentsettings) | Namespaced | AgentSettings is the namespace tier of agent governance settings: limits | | [AgentUI](/docs/crd-agentui) | Namespaced | AgentUI is a bundle-authored DECLARATION of an agent-defined view: a page | | [ArtifactRender](/docs/crd-artifactrender) | Namespaced | ArtifactRender is one request to turn agent-supplied bytes into a | +| [BrowserToolbox](/docs/crd-browsertoolbox) | Namespaced | BrowserToolbox declares a per-session browser the agent can drive: which | | [Channel](/docs/crd-channel) | Namespaced | Channel binds one transport conversation -- a Slack channel, a scheduled | | [ClusterAgentSettings](/docs/crd-clusteragentsettings) | Cluster | ClusterAgentSettings is the cluster-wide top tier of agent governance | | [ClusterIdentityProvider](/docs/crd-clusteridentityprovider) | Cluster | ClusterIdentityProvider configures cluster-wide human login: which | diff --git a/site/content/docs/crd-relationshipsource.mdx b/site/content/docs/crd-relationshipsource.mdx index e8bdb05e..ac1915e3 100644 --- a/site/content/docs/crd-relationshipsource.mdx +++ b/site/content/docs/crd-relationshipsource.mdx @@ -2,7 +2,7 @@ export const meta = { title: 'RelationshipSource', section: 'Reference', group: 'CRD reference', - order: 3160, + order: 3170, description: 'RelationshipSource declares one upstream directory (Slack first) to poll', } diff --git a/site/content/docs/crd-sessionhold.mdx b/site/content/docs/crd-sessionhold.mdx index f8c7b9c9..2c0a7ff8 100644 --- a/site/content/docs/crd-sessionhold.mdx +++ b/site/content/docs/crd-sessionhold.mdx @@ -2,7 +2,7 @@ export const meta = { title: 'SessionHold', section: 'Reference', group: 'CRD reference', - order: 3170, + order: 3180, description: 'SessionHold parks an AgentSession for forensic review and gates its return to', } diff --git a/site/content/docs/crd-sessionuseridentity.mdx b/site/content/docs/crd-sessionuseridentity.mdx index 40cc63b3..4b245337 100644 --- a/site/content/docs/crd-sessionuseridentity.mdx +++ b/site/content/docs/crd-sessionuseridentity.mdx @@ -2,7 +2,7 @@ export const meta = { title: 'SessionUserIdentity', section: 'Reference', group: 'CRD reference', - order: 3180, + order: 3190, description: 'SessionUserIdentity is one session\'s narrowing of a user\'s UserIdentity', } diff --git a/site/content/docs/crd-sidecartoolbox.mdx b/site/content/docs/crd-sidecartoolbox.mdx index e0c6189c..1c2f6af6 100644 --- a/site/content/docs/crd-sidecartoolbox.mdx +++ b/site/content/docs/crd-sidecartoolbox.mdx @@ -2,7 +2,7 @@ export const meta = { title: 'SidecarToolbox', section: 'Reference', group: 'CRD reference', - order: 3190, + order: 3200, description: 'SidecarToolbox declares a user-supplied MCP server that runs as a sidecar', } diff --git a/site/content/docs/crd-skill.mdx b/site/content/docs/crd-skill.mdx index 896e1e4a..7a909ece 100644 --- a/site/content/docs/crd-skill.mdx +++ b/site/content/docs/crd-skill.mdx @@ -2,7 +2,7 @@ export const meta = { title: 'Skill', section: 'Reference', group: 'CRD reference', - order: 3200, + order: 3210, description: 'Skill is one agentskills.io skill: a SKILL.md (frontmatter + body) plus', } diff --git a/site/content/docs/crd-skillsource.mdx b/site/content/docs/crd-skillsource.mdx index 9facfa39..090dcfef 100644 --- a/site/content/docs/crd-skillsource.mdx +++ b/site/content/docs/crd-skillsource.mdx @@ -2,7 +2,7 @@ export const meta = { title: 'SkillSource', section: 'Reference', group: 'CRD reference', - order: 3210, + order: 3220, description: 'SkillSource is a git repo that yields one or more Skills.', } diff --git a/site/content/docs/crd-spiceboxclass.mdx b/site/content/docs/crd-spiceboxclass.mdx index 299fdf7e..b3452c7b 100644 --- a/site/content/docs/crd-spiceboxclass.mdx +++ b/site/content/docs/crd-spiceboxclass.mdx @@ -2,7 +2,7 @@ export const meta = { title: 'SpiceboxClass', section: 'Reference', group: 'CRD reference', - order: 3230, + order: 3240, description: 'SpiceboxClass is the template a tool sandbox is cut from: image, resources,', } diff --git a/site/content/docs/crd-spiceboxsession.mdx b/site/content/docs/crd-spiceboxsession.mdx index aa7da5bc..7bbb5eab 100644 --- a/site/content/docs/crd-spiceboxsession.mdx +++ b/site/content/docs/crd-spiceboxsession.mdx @@ -2,7 +2,7 @@ export const meta = { title: 'SpiceboxSession', section: 'Reference', group: 'CRD reference', - order: 3240, + order: 3250, description: 'SpiceboxSession is one live sandbox instantiated from a SpiceboxClass: the', } diff --git a/site/content/docs/crd-spiceboxtoolchain.mdx b/site/content/docs/crd-spiceboxtoolchain.mdx index a9a329c7..005ffbb1 100644 --- a/site/content/docs/crd-spiceboxtoolchain.mdx +++ b/site/content/docs/crd-spiceboxtoolchain.mdx @@ -2,7 +2,7 @@ export const meta = { title: 'SpiceboxToolchain', section: 'Reference', group: 'CRD reference', - order: 3250, + order: 3260, description: 'SpiceboxToolchain is a language or tooling overlay a sandbox can mount -- a', } diff --git a/site/content/docs/crd-spiceboxtoolkit.mdx b/site/content/docs/crd-spiceboxtoolkit.mdx index 74b7d15a..5dd44509 100644 --- a/site/content/docs/crd-spiceboxtoolkit.mdx +++ b/site/content/docs/crd-spiceboxtoolkit.mdx @@ -2,7 +2,7 @@ export const meta = { title: 'SpiceboxToolkit', section: 'Reference', group: 'CRD reference', - order: 3260, + order: 3270, description: 'SpiceboxToolkit is the machine-readable description of one CLI a sandbox', } diff --git a/site/content/docs/crd-spiceboxtoolspec.mdx b/site/content/docs/crd-spiceboxtoolspec.mdx index 81325042..5b60e8e5 100644 --- a/site/content/docs/crd-spiceboxtoolspec.mdx +++ b/site/content/docs/crd-spiceboxtoolspec.mdx @@ -2,7 +2,7 @@ export const meta = { title: 'SpiceboxToolspec', section: 'Reference', group: 'CRD reference', - order: 3270, + order: 3280, description: 'SpiceboxToolspec is one sandbox tool as the agent sees it: a SpiceboxToolkit', } diff --git a/site/content/docs/crd-spicedbbootstrap.mdx b/site/content/docs/crd-spicedbbootstrap.mdx index 19760ee4..bf8a0c5e 100644 --- a/site/content/docs/crd-spicedbbootstrap.mdx +++ b/site/content/docs/crd-spicedbbootstrap.mdx @@ -2,7 +2,7 @@ export const meta = { title: 'SpiceDBBootstrap', section: 'Reference', group: 'CRD reference', - order: 3220, + order: 3230, description: 'SpiceDBBootstrap declaratively seeds SpiceDB state: an optional schema', } diff --git a/site/content/docs/crd-subagentrequest.mdx b/site/content/docs/crd-subagentrequest.mdx index f970f19a..182b902b 100644 --- a/site/content/docs/crd-subagentrequest.mdx +++ b/site/content/docs/crd-subagentrequest.mdx @@ -2,7 +2,7 @@ export const meta = { title: 'SubagentRequest', section: 'Reference', group: 'CRD reference', - order: 3280, + order: 3290, description: 'SubagentRequest is a runner\'s request to delegate a task to a child session.', } diff --git a/site/content/docs/crd-toolcall.mdx b/site/content/docs/crd-toolcall.mdx index 8e410e82..46fe611d 100644 --- a/site/content/docs/crd-toolcall.mdx +++ b/site/content/docs/crd-toolcall.mdx @@ -2,7 +2,7 @@ export const meta = { title: 'ToolCall', section: 'Reference', group: 'CRD reference', - order: 3290, + order: 3300, description: 'ToolCall is one execution of one sandbox tool inside a SpiceboxSession: the', } diff --git a/site/content/docs/crd-useridentity.mdx b/site/content/docs/crd-useridentity.mdx index dac65b2d..65620037 100644 --- a/site/content/docs/crd-useridentity.mdx +++ b/site/content/docs/crd-useridentity.mdx @@ -2,7 +2,7 @@ export const meta = { title: 'UserIdentity', section: 'Reference', group: 'CRD reference', - order: 3300, + order: 3310, description: 'UserIdentity is a human\'s credential catalog: the credentials one person has', } diff --git a/site/content/docs/crd-workshop.mdx b/site/content/docs/crd-workshop.mdx index 1ac0806a..e004ded6 100644 --- a/site/content/docs/crd-workshop.mdx +++ b/site/content/docs/crd-workshop.mdx @@ -2,7 +2,7 @@ export const meta = { title: 'Workshop', section: 'Reference', group: 'CRD reference', - order: 3310, + order: 3320, description: 'Workshop is one builder session\'s isolated build space: the durable record', } diff --git a/site/content/docs/crd-workshopprobe.mdx b/site/content/docs/crd-workshopprobe.mdx index 16958ade..f5177f4f 100644 --- a/site/content/docs/crd-workshopprobe.mdx +++ b/site/content/docs/crd-workshopprobe.mdx @@ -2,7 +2,7 @@ export const meta = { title: 'WorkshopProbe', section: 'Reference', group: 'CRD reference', - order: 3320, + order: 3330, description: 'WorkshopProbe is the tool-discovery probe object for an agent-builder', } diff --git a/site/content/docs/crd-workspacesource.mdx b/site/content/docs/crd-workspacesource.mdx index 98fd9261..000eef61 100644 --- a/site/content/docs/crd-workspacesource.mdx +++ b/site/content/docs/crd-workspacesource.mdx @@ -2,7 +2,7 @@ export const meta = { title: 'WorkspaceSource', section: 'Reference', group: 'CRD reference', - order: 3330, + order: 3340, description: 'WorkspaceSource declares a named, pluggable origin that per-session', } diff --git a/test/e2e/bronzethread/main_test.go b/test/e2e/bronzethread/main_test.go new file mode 100644 index 00000000..62733962 --- /dev/null +++ b/test/e2e/bronzethread/main_test.go @@ -0,0 +1,27 @@ +//go:build e2e + +package bronzethread_test + +import ( + "os" + "testing" + + "github.com/authzed/openagentprimitives/test/testbrowser" +) + +// TestMain purges this binary's shared headless-Chrome container immediately +// after every bundle has run, mirroring test/e2e's own TestMain + +// testspicedb.StopShared shape (test/e2e/sharedspicedb_test.go). +// +// Without it the browser bundles' shared container relies entirely on +// testbrowser's idleGrace timer (SharedChrome's doc), which bridges the +// sub-millisecond gaps between serial tests within one binary but is not a +// guaranteed cleanup at process exit: a test binary that returns from m.Run() +// and calls os.Exit immediately may do so before a 5s AfterFunc ever fires, +// leaving the container to wait out dockertest's Expire(1200) backstop (or a +// later run's reap pass) instead of being purged now. +func TestMain(m *testing.M) { + code := m.Run() + testbrowser.StopShared() + os.Exit(code) +} diff --git a/test/e2e/bronzethread/testdata/browser-blocked-host/bundle.json b/test/e2e/bronzethread/testdata/browser-blocked-host/bundle.json new file mode 100644 index 00000000..a37ed593 --- /dev/null +++ b/test/e2e/bronzethread/testdata/browser-blocked-host/bundle.json @@ -0,0 +1,50 @@ +{ + "name": "browser-blocked-host", + "description": "A navigate call to a host outside the browser toolbox's allowlist is refused by the tool's pre-check (pkg/agent/tool/browser), before Chrome is ever touched, and the model is handed the stable policy-refusal error text verbatim. (The gate's own egress enforcement is a separate layer and is not exercised by this in-process harness.)\n\nTwo user turns, a plain warm-up then the real ask: markBrowserFixtureReady (the driver's secret-gated-sidecar-style status stamp) fires right after the FIRST SendUserMessage returns, and SendUserMessage is fire-and-forget. Measured directly (three consecutive standalone runs, both browser bundles): the status patch consistently lands before the runner's very first mid-session tool-refresh check, so `tools=web_navigate,...` is already present on turn 1's own first LLM request — NOT deferred to turn 2 the way the secret-gated sidecar's slower (Secret-propagation-gated) arrival is. This bundle therefore does NOT assert toolNotOffered on turn 1 (an earlier draft did and failed consistently against the real driver); the two-turn shape is kept anyway as a clean, readable split between greeting and the policy-refusal exchange, not as a timing proof.\n\nlastToolResultIsError + lastToolResultContains is the load-bearing pair: a scripted agentReplyContains alone cannot distinguish a refused call from an allowed one that merely happened to produce similar words, which is this suite's standing lesson about asserting on tool results rather than replies.\n\nThe BrowserToolbox CR's access.sites names an unrelated placeholder host (example.com), not the real test site: setupBrowserFixture appends the real site's host to an allowlist-mode toolbox's effective sites automatically, so this bundle never needs the site reachable at all and never navigates to it. See manifests/01-browser-toolbox.yaml for why that file is named in extraManifests as well as being applied via agentDir.", + + "agentDir": "testdata/browser-blocked-host/manifests", + "agentClass": "browser-blocked-host", + "extraManifests": ["01-browser-toolbox"], + + "userTurns": [ + "hi there", + "please open https://definitely-blocked.test/" + ], + + "llm": [ + { + "expect": { + "userTextContains": "hi there" + }, + "reply": [{ "text": "Hello! How can I help you today?" }] + }, + { + "expect": { "lastToolResult": "respond_to_user" }, + "reply": [{ "toolUse": { "name": "agent_work_complete", "args": { "summary": "greeted the user" } } }] + }, + { + "expect": { + "userTextContains": "definitely-blocked", + "toolOffered": "web_navigate" + }, + "reply": [{ "toolUse": { "name": "web_navigate", "args": { "url": "https://definitely-blocked.test/" } } }] + }, + { + "expect": { + "lastToolResult": "web_navigate", + "lastToolResultIsError": true, + "lastToolResultContains": "host definitely-blocked.test is not allowed by browser toolbox web (rule: not in allowlist)" + }, + "reply": [{ "text": "That site is outside my allowed list." }] + }, + { + "expect": { "lastToolResult": "respond_to_user" }, + "reply": [{ "toolUse": { "name": "agent_work_complete", "args": { "summary": "reported the site is blocked" } } }] + } + ], + + "assert": { + "agentReplyContains": ["Hello", "outside my allowed list"], + "toolsCalled": ["web_navigate"] + } +} diff --git a/test/e2e/bronzethread/testdata/browser-blocked-host/manifests/00-agent.yaml b/test/e2e/bronzethread/testdata/browser-blocked-host/manifests/00-agent.yaml new file mode 100644 index 00000000..bcaa500b --- /dev/null +++ b/test/e2e/bronzethread/testdata/browser-blocked-host/manifests/00-agent.yaml @@ -0,0 +1,54 @@ +# Fixture agent for the browser-blocked-host bronze bundle. +# +# No sidecar, no MCPServer, no agentIdentity — the only thing this fixture +# needs is a test-provider AgentClass and a fake Channel to drive it. The +# BrowserToolbox CR lives in its own file (01-browser-toolbox.yaml) rather +# than here: that file is ALSO named in the bundle's extraManifests, which is +# how the threadrun driver's browserToolboxSpecs finds and parses it (see that +# file's header comment for why). +# +# Deliberately NOT referenced from spec.browserToolboxes: per Task 11's +# ruling, a referenced browser toolbox makes the AgentSession reconciler try +# to create a pod envtest can never make Ready, re-deriving (and clobbering) +# status every reconcile. The driver stamps status.resolvedBrowserToolboxes +# directly (MarkBrowserReady) instead, exactly like the secret-gated sidecar +# pattern. +apiVersion: v1 +kind: Secret +metadata: {name: browser-blocked-host-placeholder, namespace: default} +type: Opaque +stringData: {api-key: "unused-by-the-test-provider"} +--- +apiVersion: v1 +kind: Secret +metadata: {name: browser-blocked-host-fake-creds, namespace: default} +type: Opaque +stringData: {placeholder: "unused-by-the-fake-channel"} +--- +apiVersion: agentprimitives.authzed.com/v1alpha1 +kind: AgentClass +metadata: {name: browser-blocked-host, namespace: default} +spec: + displayName: BrowserBlockedHostBot + description: "E2E fixture agent. Do not deploy to a real cluster." + model: + provider: test # production operator REFUSES this + name: scripted # cosmetic; ignored at runtime + apiKey: {name: browser-blocked-host-placeholder, key: api-key} + systemPrompt: + inline: | + You are a fixture agent for the agentprimitives e2e harness. + DO NOT deploy to a real cluster — your model provider is `test`. + You have a browser toolbox named `web` for browsing the open web within + its allowed sites. + budget: {maxTurns: 20, maxTokens: 50000, maxDuration: 5m} +--- +apiVersion: agentprimitives.authzed.com/v1alpha1 +kind: Channel +metadata: {name: browser-blocked-host-fake, namespace: default} +spec: + kind: fake + role: both + agentClass: browser-blocked-host + credentialsRef: {secretName: browser-blocked-host-fake-creds} + fake: {echo: false} diff --git a/test/e2e/bronzethread/testdata/browser-blocked-host/manifests/01-browser-toolbox.yaml b/test/e2e/bronzethread/testdata/browser-blocked-host/manifests/01-browser-toolbox.yaml new file mode 100644 index 00000000..dae20a55 --- /dev/null +++ b/test/e2e/bronzethread/testdata/browser-blocked-host/manifests/01-browser-toolbox.yaml @@ -0,0 +1,32 @@ +# The BrowserToolbox CR this bundle exercises. +# +# This file is named in bundle.json's `extraManifests` (as "01-browser-toolbox") +# as well as being applied through `agentDir` like every other file in this +# directory. The double listing is deliberate, not an accident of copy-paste: +# the threadrun driver's setupBrowserFixture/browserToolboxSpecs reads the +# RAW TEXT of every file named in extraManifests to find and parse a +# `kind: BrowserToolbox` document — it never looks the object up live through +# the K8s API, because the whole point of the secret-gated-sidecar-style +# pattern this bundle follows is that the AgentClass never references the +# toolbox, so nothing else ever resolves it from a CR. extraManifests is the +# ONLY route that hands the driver this spec. The AgentDir apply is what +# actually lands the object on envtest (for parity/realism); the extraManifests +# listing is what makes it legible to the fixture code. Both applies are +# idempotent Create-or-Update, so naming the same file twice is harmless. +# +# access.sites names an UNRELATED placeholder host ("example.com") on +# purpose: the bundle's whole point is that "definitely-blocked.test" is NOT +# in this allowlist. The real test site's host (host.docker.internal) is +# appended automatically by setupBrowserFixture for every allowlist-mode +# toolbox — see that function's own comment — so this bundle does not need to +# (and never does) navigate to the real site at all. +apiVersion: agentprimitives.authzed.com/v1alpha1 +kind: BrowserToolbox +metadata: {name: web, namespace: default} +spec: + access: + mode: allowlist + sites: + - host: example.com + interaction: act + state: ephemeral diff --git a/test/e2e/bronzethread/testdata/browser-blocked-host/trace.golden b/test/e2e/bronzethread/testdata/browser-blocked-host/trace.golden new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/test/e2e/bronzethread/testdata/browser-blocked-host/trace.golden @@ -0,0 +1 @@ + diff --git a/test/e2e/bronzethread/testdata/browser-readonly-catalog/bundle.json b/test/e2e/bronzethread/testdata/browser-readonly-catalog/bundle.json new file mode 100644 index 00000000..2baf671c --- /dev/null +++ b/test/e2e/bronzethread/testdata/browser-readonly-catalog/bundle.json @@ -0,0 +1,60 @@ +{ + "name": "browser-readonly-catalog", + "description": "A readonly browser toolbox's recorded tool catalog is the claim: web_navigate, web_read and web_find are offered, web_click and web_type are not, and a navigate+read round trip actually reaches the fixture's own test site and returns its content.\n\nTwo user turns, a plain warm-up then the real ask. The original sketch assumed the browser refresher only synthesizes a toolbox's tools onto the live set on the turn AFTER MarkBrowserReady stamps status.resolvedBrowserToolboxes (mirroring the secret-gated sidecar's slower, Secret-propagation-gated arrival). Measured directly instead (three consecutive standalone runs): markBrowserFixtureReady's status patch — fired right after the first, fire-and-forget SendUserMessage returns — consistently lands before the runner's first mid-session tool-refresh check, so the tools are already present on turn 1's own first LLM request. This bundle does not assert toolNotOffered on turn 1 for that reason (an earlier draft did and failed consistently against the real driver); the warm-up/real-ask split is kept for readability, not as a timing proof.\n\ntoolOffered/toolNotOffered are spread across turn 2's three requests (one offered- and one not-offered-check per step) rather than all piled on the first, because Expect carries exactly one of each per step — the three offered names (web_navigate, web_read, web_find) and the spec's \"a tool not offered cannot be called\" are still each checked once. web_click/web_type do not exist as ops at all in Phase A (pkg/agent/tool/browser/synthesize.go offers the identical observation set regardless of interaction), so those two assertions pass today independent of readonly — they are the standing guard for Phase C's acting tools, exactly as the brief says.\n\nlastToolResultContains on both the navigate result (\"loaded\") and the read result (the site's BRONZE-FARES-TABLE sentinel) is what proves the round trip actually reached the fixture site and read its real content back, rather than a scripted reply standing in for it.", + + "agentDir": "testdata/browser-readonly-catalog/manifests", + "agentClass": "browser-readonly-catalog", + "extraManifests": ["01-browser-toolbox"], + + "userTurns": [ + "hi there", + "please check the fares page at http://{{browserSite}}/ and tell me what's listed" + ], + + "llm": [ + { + "expect": { + "userTextContains": "hi there" + }, + "reply": [{ "text": "Hello! How can I help you today?" }] + }, + { + "expect": { "lastToolResult": "respond_to_user" }, + "reply": [{ "toolUse": { "name": "agent_work_complete", "args": { "summary": "greeted the user" } } }] + }, + { + "expect": { + "userTextContains": "fares page", + "toolOffered": "web_navigate", + "toolNotOffered": "web_click" + }, + "reply": [{ "toolUse": { "name": "web_navigate", "args": { "url": "http://{{browserSite}}/" } } }] + }, + { + "expect": { + "lastToolResult": "web_navigate", + "lastToolResultContains": "loaded", + "toolOffered": "web_read", + "toolNotOffered": "web_type" + }, + "reply": [{ "toolUse": { "name": "web_read", "args": {} } }] + }, + { + "expect": { + "lastToolResult": "web_read", + "lastToolResultContains": "BRONZE-FARES-TABLE", + "toolOffered": "web_find" + }, + "reply": [{ "text": "The fares page shows BRONZE-FARES-TABLE: NYC-LAX $199." }] + }, + { + "expect": { "lastToolResult": "respond_to_user" }, + "reply": [{ "toolUse": { "name": "agent_work_complete", "args": { "summary": "reported the fares table" } } }] + } + ], + + "assert": { + "agentReplyContains": ["Hello", "BRONZE-FARES-TABLE"], + "toolsCalled": ["web_navigate", "web_read"] + } +} diff --git a/test/e2e/bronzethread/testdata/browser-readonly-catalog/manifests/00-agent.yaml b/test/e2e/bronzethread/testdata/browser-readonly-catalog/manifests/00-agent.yaml new file mode 100644 index 00000000..465f8341 --- /dev/null +++ b/test/e2e/bronzethread/testdata/browser-readonly-catalog/manifests/00-agent.yaml @@ -0,0 +1,46 @@ +# Fixture agent for the browser-readonly-catalog bronze bundle. +# +# Same shape as browser-blocked-host's fixture: a bare test-provider +# AgentClass + fake Channel, no sidecar, no MCPServer, no agentIdentity. The +# BrowserToolbox CR lives in its own file (01-browser-toolbox.yaml); see that +# file's header for why it is named in extraManifests as well as applied via +# agentDir. +apiVersion: v1 +kind: Secret +metadata: {name: browser-readonly-catalog-placeholder, namespace: default} +type: Opaque +stringData: {api-key: "unused-by-the-test-provider"} +--- +apiVersion: v1 +kind: Secret +metadata: {name: browser-readonly-catalog-fake-creds, namespace: default} +type: Opaque +stringData: {placeholder: "unused-by-the-fake-channel"} +--- +apiVersion: agentprimitives.authzed.com/v1alpha1 +kind: AgentClass +metadata: {name: browser-readonly-catalog, namespace: default} +spec: + displayName: BrowserReadonlyCatalogBot + description: "E2E fixture agent. Do not deploy to a real cluster." + model: + provider: test # production operator REFUSES this + name: scripted # cosmetic; ignored at runtime + apiKey: {name: browser-readonly-catalog-placeholder, key: api-key} + systemPrompt: + inline: | + You are a fixture agent for the agentprimitives e2e harness. + DO NOT deploy to a real cluster — your model provider is `test`. + You have a browser toolbox named `web`, read-only, for looking things + up on the open web within its allowed sites. + budget: {maxTurns: 20, maxTokens: 50000, maxDuration: 5m} +--- +apiVersion: agentprimitives.authzed.com/v1alpha1 +kind: Channel +metadata: {name: browser-readonly-catalog-fake, namespace: default} +spec: + kind: fake + role: both + agentClass: browser-readonly-catalog + credentialsRef: {secretName: browser-readonly-catalog-fake-creds} + fake: {echo: false} diff --git a/test/e2e/bronzethread/testdata/browser-readonly-catalog/manifests/01-browser-toolbox.yaml b/test/e2e/bronzethread/testdata/browser-readonly-catalog/manifests/01-browser-toolbox.yaml new file mode 100644 index 00000000..3c2ba73b --- /dev/null +++ b/test/e2e/bronzethread/testdata/browser-readonly-catalog/manifests/01-browser-toolbox.yaml @@ -0,0 +1,30 @@ +# The BrowserToolbox CR this bundle exercises — interaction: readonly. +# +# Named in bundle.json's extraManifests (as "01-browser-toolbox") as well as +# being applied through agentDir like every other file in this directory; see +# browser-blocked-host/manifests/01-browser-toolbox.yaml's header for the full +# rationale (same driver, same reason) — short version: the threadrun driver's +# browserToolboxSpecs reads the RAW TEXT of every extraManifests-named file to +# find a `kind: BrowserToolbox` document, because this AgentClass never +# references the toolbox and so nothing ever resolves it live from the API. +# +# Phase A offers the identical observation tool set regardless of +# interaction (pkg/agent/tool/browser/synthesize.go's Synthesize says so +# explicitly) — click/type do not exist as ops at all yet, for any toolbox. +# So this bundle's toolNotOffered: web_click / web_type assertions pass today +# independent of this readonly setting; they exist as the standing guard for +# Phase C, when acting tools arrive and readonly must keep excluding them. +# +# access.sites names an unrelated placeholder host, exactly as in +# browser-blocked-host: the real test site's host is appended automatically +# by setupBrowserFixture for an allowlist-mode toolbox. +apiVersion: agentprimitives.authzed.com/v1alpha1 +kind: BrowserToolbox +metadata: {name: web, namespace: default} +spec: + access: + mode: allowlist + sites: + - host: example.com + interaction: readonly + state: ephemeral diff --git a/test/e2e/bronzethread/testdata/browser-readonly-catalog/site/index.html b/test/e2e/bronzethread/testdata/browser-readonly-catalog/site/index.html new file mode 100644 index 00000000..9d35a03b --- /dev/null +++ b/test/e2e/bronzethread/testdata/browser-readonly-catalog/site/index.html @@ -0,0 +1,8 @@ + + +Fares + +

BRONZE-FARES-TABLE

+

NYC-LAX: $199

+ + diff --git a/test/e2e/bronzethread/testdata/browser-readonly-catalog/trace.golden b/test/e2e/bronzethread/testdata/browser-readonly-catalog/trace.golden new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/test/e2e/bronzethread/testdata/browser-readonly-catalog/trace.golden @@ -0,0 +1 @@ + diff --git a/test/e2e/harness.go b/test/e2e/harness.go index adbbe7c6..2d54b8ba 100644 --- a/test/e2e/harness.go +++ b/test/e2e/harness.go @@ -569,6 +569,18 @@ func (h *Harness) SetSidecarProbeURL(fn func(rt spiceboxv1alpha1.ResolvedSidecar h.runnerFactory.SidecarProbeURL = fn } +// SetBrowserConnect installs the in-process runner factory's browser-toolbox +// connect seam. The browser gate runs in-process (test/testbrowser.StartGate) +// on an ephemeral 127.0.0.1 port rather than the controller-reflected +// BrowserPodIP:GatePort, and the token is the one StartGate was given rather +// than a per-session Secret — so a browser scenario redirects the runner's +// per-toolbox (gateURL, token) at its StartGate result, keyed by rt.Ref. The +// analogue of SetSidecarProbeURL. Call after Start and before the session that +// uses the browser toolbox runs its first turn. +func (h *Harness) SetBrowserConnect(fn func(rt spiceboxv1alpha1.ResolvedBrowserToolbox) (gateURL, token string)) { + h.runnerFactory.BrowserConnect = fn +} + // SetMinter installs a federation.Minter on the in-process runner // factory's token broker seam. Must be called BEFORE the session that // needs federated credentials is spawned (the broker is constructed @@ -745,6 +757,71 @@ func (h *Harness) MarkSidecarReady(ns, name, llmName, ref, dispatchURL string, s "MarkSidecarReady: patch status.resolvedSidecarToolboxes for %s/%s", ns, name) } +// MarkBrowserReady simulates the operator bringing up a per-session +// BrowserToolbox pod in-process: it stamps a full entry into +// AgentSession.status.resolvedBrowserToolboxes for the given (LLM-facing +// `name`, CR `ref`) with BrowserPodIP:GatePort parsed from `gateURL` +// (http://127.0.0.1:, the URL test/testbrowser.StartGate returns), +// EffectiveSites derived with the SAME agentsession.DeriveBrowserEffectiveSites +// production uses, TokenHash computed over `token` with the SAME +// agentsession.SecretTokenHash, and the BrowserToolbox Spec frozen onto the +// entry. +// +// Production does this via real pod-create (the reconciler reflects the pod's +// PodIP into status once Ready); the in-process harness has no real pod, so the +// test stamps the equivalent status directly — the mirror of MarkSidecarReady. +// +// The per-session gate TOKEN is NOT delivered through a Secret here: the driver +// hands buildBrowserTools the live (gateURL, token) through the +// InProcessRunnerFactory.BrowserConnect seam (the precedent is SidecarProbeURL), +// which is simpler than materializing a Secret and reconstructing the gate URL +// from status. The TokenHash is still stamped for production parity. (The +// factory's status-derived fallback path — seam nil, read the token Secret — +// stays exercised by production and by its own unit test, not by this stamp.) +// +// Call AFTER Start and AFTER the session exists (the AgentSession must be +// Get-able to patch its status). The factory's browser refresher re-reads this +// status at the top of each turn and synthesizes the browser tools into the +// live set — so, like the secret-gated sidecar, the tools appear on the turn +// after the stamp rather than in the boot set. +func (h *Harness) MarkBrowserReady(ns, name, llmName, ref, gateURL, token string, spec spiceboxv1alpha1.BrowserToolboxSpec) { + h.t.Helper() + ctx := context.Background() + + host, port := splitHostPort(h.t, gateURL) + + var sess spiceboxv1alpha1.AgentSession + require.NoError(h.t, h.K8s.Get(ctx, client.ObjectKey{Namespace: ns, Name: name}, &sess), + "MarkBrowserReady: get AgentSession %s/%s", ns, name) + base := sess.DeepCopy() + + entry := spiceboxv1alpha1.ResolvedBrowserToolbox{ + Name: llmName, + Ref: ref, + Spec: spec, + EffectiveSites: agentsessionctrl.DeriveBrowserEffectiveSites(spec), + BrowserPodName: agentsessionctrl.BrowserPodName(&sess, ref), + BrowserPodIP: host, + GatePort: port, + TokenHash: agentsessionctrl.SecretTokenHash([]byte(token)), + } + // Replace any prior entry for this ref (idempotent re-stamp / pod replace), + // else append. + replaced := false + for i := range sess.Status.ResolvedBrowserToolboxes { + if sess.Status.ResolvedBrowserToolboxes[i].Ref == ref { + sess.Status.ResolvedBrowserToolboxes[i] = entry + replaced = true + break + } + } + if !replaced { + sess.Status.ResolvedBrowserToolboxes = append(sess.Status.ResolvedBrowserToolboxes, entry) + } + require.NoError(h.t, h.K8s.Status().Patch(ctx, &sess, client.MergeFrom(base)), + "MarkBrowserReady: patch status.resolvedBrowserToolboxes for %s/%s", ns, name) +} + // splitHostPort parses a http://host:port URL into its host + numeric port. // Fatals the test on a malformed URL — the caller passes h.MCP.URL(), which // is always well-formed, so a failure here is a harness bug, not a test input. diff --git a/test/e2e/inprocess_runner_factory.go b/test/e2e/inprocess_runner_factory.go index 1025bec8..a88d31f5 100644 --- a/test/e2e/inprocess_runner_factory.go +++ b/test/e2e/inprocess_runner_factory.go @@ -55,6 +55,7 @@ import ( _ "github.com/authzed/openagentprimitives/pkg/agent/session/state/plans" // registers the "plans" state kind so update_plan works in-process "github.com/authzed/openagentprimitives/pkg/agent/tool" "github.com/authzed/openagentprimitives/pkg/agent/tool/authfail" + browsertoolboxsynth "github.com/authzed/openagentprimitives/pkg/agent/tool/browser" mcpdispatch "github.com/authzed/openagentprimitives/pkg/agent/tool/mcp" "github.com/authzed/openagentprimitives/pkg/agent/tool/meta" "github.com/authzed/openagentprimitives/pkg/agent/tool/meta/capability" @@ -274,6 +275,17 @@ type InProcessRunnerFactory struct { // this nil and probes http://127.0.0.1:. SidecarProbeURL func(rt spiceboxv1alpha1.ResolvedSidecarToolbox) string + // BrowserConnect, when non-nil, supplies the live (gateURL, token) for a + // resolved browser toolbox. Test seam, mirroring SidecarProbeURL: the real + // gate runs in-process (test/testbrowser.StartGate) on an ephemeral + // 127.0.0.1 port, not the controller-reflected rt.BrowserPodIP:rt.GatePort, + // and the token is the one StartGate was given rather than a per-session + // Secret. The driver sets this to its StartGate result keyed by rt.Ref. + // When nil, buildBrowserTools falls back to the production-shaped path: + // gateURL from status (http://:) + the token read + // from the per-session Secret agentsession.BrowserTokenSecretName names. + BrowserConnect func(rt spiceboxv1alpha1.ResolvedBrowserToolbox) (gateURL, token string) + // ExtraTools, when non-empty, are appended to every spawned runner.Loop's // tool set. Test seam for scenarios that need a tool the CRD-driven // synthesis paths can't produce in-process (e.g. a producer tool that @@ -1147,15 +1159,39 @@ func (f *InProcessRunnerFactory) buildLoop(sess *spiceboxv1alpha1.AgentSession, return nil, err } - // nonMetaTools threads sandbox+mcp+sidecar+ExtraTools into Assemble in the - // same order internal/cmd/runner/main.go uses (sandbox, mcp, sidecar). ExtraTools are - // a harness-only seam (see the field doc); folding them into the non-meta - // set keeps them in the LLM-facing catalog AND lets introspection see them, - // exactly as production non-meta tools are treated. - nonMetaTools := make([]tool.Tool, 0, len(sandboxTools)+len(mcpTools)+len(sidecarTools)+len(f.ExtraTools)) + // Browser-toolbox tools: boot pass mirroring internal/cmd/runner/main.go's + // browser block. acceptsImages gates the screenshot op exactly as production + // does — the fake/scripted provider reports no native image/png support, so + // this is false and the screenshot tool is withheld (fail-closed). In this + // harness the browser toolbox is stamped into status mid-session + // (h.MarkBrowserReady), so the boot list is normally empty and the browser + // refresher below adds the tools on a later turn; see buildBrowserTools. + // EffectiveSettings is nil-guarded here where production (runner main.go) + // reads it bare: the runner binary only ever starts AFTER the reconciler + // stamped the snapshot, but this factory's buildLoop is also called + // directly by harness-level tests (BuildLoopToolNamesForTest) with a + // hand-built, never-reconciled session. Missing settings degrade to + // acceptsImages=false — the screenshot tool is withheld, the fail-closed + // direction this gate already takes for a provider with no image support. + browserAcceptsImages := false + if f.LLM != nil && sess.Status.EffectiveSettings != nil { + browserAcceptsImages = f.LLM.NativeInputMIMEs(sess.Status.EffectiveSettings.Model.Name).Has("image/png") + } + browserTools, err := f.buildBrowserTools(sess, sess.Status.ResolvedBrowserToolboxes, browserAcceptsImages) + if err != nil { + return nil, err + } + + // nonMetaTools threads sandbox+mcp+sidecar+browser+ExtraTools into Assemble + // in the same order internal/cmd/runner/main.go uses (sandbox, mcp, sidecar, + // browser). ExtraTools are a harness-only seam (see the field doc); folding + // them into the non-meta set keeps them in the LLM-facing catalog AND lets + // introspection see them, exactly as production non-meta tools are treated. + nonMetaTools := make([]tool.Tool, 0, len(sandboxTools)+len(mcpTools)+len(sidecarTools)+len(browserTools)+len(f.ExtraTools)) nonMetaTools = append(nonMetaTools, sandboxTools...) nonMetaTools = append(nonMetaTools, mcpTools...) nonMetaTools = append(nonMetaTools, sidecarTools...) + nonMetaTools = append(nonMetaTools, browserTools...) nonMetaTools = append(nonMetaTools, f.ExtraTools...) // A whole-session replay holds back the tools its capture ungated at the @@ -2151,10 +2187,28 @@ func (f *InProcessRunnerFactory) buildLoop(sess *spiceboxv1alpha1.AgentSession, // the sidecar into status mid-session, so there's nothing to gate on at boot. // We arm it whenever K8s is wired; the refresher itself is cheap (re-Gets the // session, returns nil unless a newly-ready separate-pod sidecar appears). + // + // Browser toolboxes are stamped mid-session the same way (h.MarkBrowserReady), + // so the single ToolRefresher slot runs BOTH the sidecar and the browser + // refresher each turn and merges what each adds — a browser bundle's tools + // appear on the turn after MarkBrowserReady, exactly like a secret-gated + // sidecar's. The sidecar refresher runs first and unchanged, so a bundle with + // no browser toolbox is byte-for-byte on the pre-browser path. if f.K8s != nil { - loop.ToolRefresher = f.newSidecarToolRefresher(types.NamespacedName{ - Namespace: sess.Namespace, Name: sess.Name, - }, sessionCache) + sessKey := types.NamespacedName{Namespace: sess.Namespace, Name: sess.Name} + sidecarRefresh := f.newSidecarToolRefresher(sessKey, sessionCache) + browserRefresh := f.newBrowserToolRefresher(sessKey, browserAcceptsImages) + loop.ToolRefresher = func(ctx context.Context) (runner.ToolRefreshResult, error) { + sr, serr := sidecarRefresh(ctx) + br, berr := browserRefresh(ctx) + added := make([]tool.Tool, 0, len(sr.Added)+len(br.Added)) + added = append(added, sr.Added...) + added = append(added, br.Added...) + if serr != nil { + return runner.ToolRefreshResult{Added: added}, serr + } + return runner.ToolRefreshResult{Added: added}, berr + } } // Authz wiring: enables per-tool SpiceDB Checks, the approval flow @@ -3151,6 +3205,102 @@ func portFromURL(raw string) (int32, bool) { return int32(p), true } +// buildBrowserTools synthesizes the observation tools for each resolved browser +// toolbox, mirroring internal/cmd/runner/main.go's browser block: for each +// entry it resolves a gate URL + per-session token and calls +// browsertoolboxsynth.Synthesize. Like the sidecar and MCP paths, a synthesis +// failure surfaces as a buildLoop error rather than a runner silently missing +// its browser tools — the same fail-loud shape production uses (failSession +// there, error here), which is the whole point of "wire it in BOTH places": +// the control cannot be silently off. +// +// In the in-process harness the browser toolbox is stamped into status +// MID-session by h.MarkBrowserReady (there is no real pod, so nothing is in +// status at boot), so `resolved` is normally empty here and this is a no-op; +// newBrowserToolRefresher picks up the stamped entry on the next turn. The boot +// pass exists for production parity and for any entry already present at boot. +func (f *InProcessRunnerFactory) buildBrowserTools(sess *spiceboxv1alpha1.AgentSession, resolved []spiceboxv1alpha1.ResolvedBrowserToolbox, acceptsImages bool) ([]tool.Tool, error) { + if len(resolved) == 0 { + return nil, nil + } + var out []tool.Tool + for _, rt := range resolved { + built, err := f.synthBrowserToolbox(sess, rt, acceptsImages) + if err != nil { + return nil, err + } + out = append(out, built...) + } + return out, nil +} + +// synthBrowserToolbox resolves one toolbox's (gateURL, token) and synthesizes +// its tools. The BrowserConnect seam, when set, supplies the live in-process +// gate URL + token (the driver's StartGate result); otherwise it takes the +// production-shaped path — gate URL from status, token from the per-session +// Secret — exactly as internal/cmd/runner/main.go does. Shared by the boot pass +// and the refresher so both run the identical code path. +func (f *InProcessRunnerFactory) synthBrowserToolbox(sess *spiceboxv1alpha1.AgentSession, rt spiceboxv1alpha1.ResolvedBrowserToolbox, acceptsImages bool) ([]tool.Tool, error) { + var gateURL, token string + if f.BrowserConnect != nil { + gateURL, token = f.BrowserConnect(rt) + } else { + // Production parity (internal/cmd/runner/main.go): the runner builds the + // gate URL from status and reads the per-session gate-token Secret the + // operator minted. The Secret's one data key is "token" (the + // agentsession package's browserTokenSecretKey is unexported, so the + // literal is duplicated here deliberately, as the production runner does). + gateURL = fmt.Sprintf("http://%s:%d", rt.BrowserPodIP, rt.GatePort) + var tokenSecret corev1.Secret + if err := f.K8s.Get(context.Background(), client.ObjectKey{ + Namespace: sess.Namespace, + Name: agentsession.BrowserTokenSecretName(sess, rt.Ref), + }, &tokenSecret); err != nil { + return nil, fmt.Errorf("buildBrowserTools: browser %q: get token secret: %w", rt.Ref, err) + } + token = string(tokenSecret.Data["token"]) + } + built, err := browsertoolboxsynth.Synthesize(rt, gateURL, token, acceptsImages) + if err != nil { + return nil, fmt.Errorf("buildBrowserTools: browser %q: %w", rt.Ref, err) + } + return built, nil +} + +// newBrowserToolRefresher is the browser analogue of newSidecarToolRefresher: +// a Loop.ToolRefresher consulted at the top of each turn that re-Gets the +// AgentSession and synthesizes the tools of any browser toolbox newly stamped +// into status.resolvedBrowserToolboxes (by h.MarkBrowserReady) with a +// BrowserPodIP. It owns its dedup via the captured `synthed` set (rt.Name → +// BrowserPodIP), so a toolbox is synthesized once per pod IP and never +// duplicated across turns — mirroring the sidecar refresher. Browser tools are +// not secret-gated, so there is no secret-output gate here. +func (f *InProcessRunnerFactory) newBrowserToolRefresher(sessKey types.NamespacedName, acceptsImages bool) func(ctx context.Context) (runner.ToolRefreshResult, error) { + synthed := map[string]string{} + return func(ctx context.Context) (runner.ToolRefreshResult, error) { + var sess spiceboxv1alpha1.AgentSession + if err := f.K8s.Get(ctx, sessKey, &sess); err != nil { + return runner.ToolRefreshResult{}, fmt.Errorf("inprocess browser refresh: get AgentSession: %w", err) + } + var added []tool.Tool + for _, rt := range sess.Status.ResolvedBrowserToolboxes { + if rt.BrowserPodIP == "" { + continue // not Ready yet (operator/MarkBrowserReady hasn't stamped the IP). + } + if prevIP, ok := synthed[rt.Name]; ok && prevIP == rt.BrowserPodIP { + continue // already synthesized against this pod IP. + } + built, err := f.synthBrowserToolbox(&sess, rt, acceptsImages) + if err != nil { + return runner.ToolRefreshResult{Added: added}, fmt.Errorf("inprocess browser refresh: %w", err) + } + synthed[rt.Name] = rt.BrowserPodIP + added = append(added, built...) + } + return runner.ToolRefreshResult{Added: added}, nil + } +} + // newSidecarToolRefresher builds the in-process equivalent of // internal/cmd/runner/sidecar_refresh.go's newSidecarToolRefresher: a // runner.Loop.ToolRefresher consulted at the top of each turn that diff --git a/test/e2e/threadrun/driver.go b/test/e2e/threadrun/driver.go index e4896d0f..d86dab38 100644 --- a/test/e2e/threadrun/driver.go +++ b/test/e2e/threadrun/driver.go @@ -13,6 +13,8 @@ import ( "archive/zip" "bytes" "context" + "crypto/rand" + "encoding/hex" "encoding/json" "fmt" "io" @@ -31,6 +33,7 @@ import ( "k8s.io/apimachinery/pkg/types" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/yaml" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -54,8 +57,10 @@ import ( "github.com/authzed/openagentprimitives/pkg/platform/artifactstore" blobstore "github.com/authzed/openagentprimitives/pkg/platform/artifactstore/blob" "github.com/authzed/openagentprimitives/pkg/platform/identity" + "github.com/authzed/openagentprimitives/pkg/tools/browserpolicy" "github.com/authzed/openagentprimitives/pkg/tools/sandboxkinds/pod" "github.com/authzed/openagentprimitives/test/e2e" + "github.com/authzed/openagentprimitives/test/testbrowser" ) // workshopSidecarToolboxRef is the SidecarToolbox CR name every @@ -368,6 +373,15 @@ func Run(t *testing.T, dir string, b bt.Bundle) *e2e.Harness { startAutoApprover(t, h, b) + // Browser toolbox: boot the fixture (SharedChrome + ServeSite + gate per + // toolbox) and resolve {{browserSite}} in the bundle BEFORE the transcript is + // registered, so the replay and its divergence checks both see the real + // site host:port. nil (and a no-op) for every bundle with no BrowserToolbox. + browserFix := setupBrowserFixture(t, dir, h, b) + if browserFix != nil { + substituteBrowserSite(&b, browserFix.siteValue) + } + tr := registerTranscript(t, h, b) // Each bundle gets its OWN thread, and therefore its own session. @@ -428,6 +442,14 @@ func Run(t *testing.T, dir string, b bt.Bundle) *e2e.Harness { workshopSessionBound := false for i, turn := range b.UserTurns { h.SendUserMessage(turn.Text, e2e.InThread("bt-"+b.Name), e2e.WithAttachments(bundleAttachments(t, dir, i, turn)...)) + // Once the first inbound has created the session, stamp each browser + // toolbox into its status (the operator's pod-ready reflection, here with + // no real pod). The browser refresher synthesizes the tools on a later + // turn — so, like a secret-gated sidecar, a browser bundle uses the tool + // from turn 2 on. No-op for every bundle with no BrowserToolbox. + if i == 0 && browserFix != nil { + markBrowserFixtureReady(t, h, browserFix) + } // Contains and NotContains are positional and run on the SAME reply, so a // turn asserting both awaits its reply once and checks presence + absence // together. A turn with neither is not awaited (some turns produce no @@ -1667,6 +1689,247 @@ func loadManifests(t *testing.T, dir string, b bt.Bundle) []string { return out } +// --- Browser toolbox fixture wiring ------------------------------------------ +// +// When a bundle's manifests declare a BrowserToolbox CR, the driver boots the +// test/testbrowser fixture (one shared headless-Chrome container, one httptest +// site, one in-process gate per toolbox), installs the BrowserConnect seam so +// buildBrowserTools reaches each toolbox's gate, stamps readiness mid-session +// via h.MarkBrowserReady, and resolves the {{browserSite}} stand-in to the +// site's host:port in the bundle's toolUse args AND expect strings before +// replay. Every non-browser bundle takes none of this path (setupBrowserFixture +// returns nil), so the regression suite is byte-for-byte unaffected. +// +// The stand-in uses a driver-local string replacement over the bundle (the +// brief's sanctioned alternative to the {{name}} capture/vars mechanism): +// capture/vars only interpolates toolUse args at render time, never expect +// strings, and it needs a value "captured" from a prior tool result — whereas +// the site host:port is known at fixture-boot time and must reach both halves. + +// browserFixture is the running browser test environment for one bundle. +type browserFixture struct { + siteValue string // "host.docker.internal:" — the {{browserSite}} value + toolboxes []browserFixtureToolbox +} + +type browserFixtureToolbox struct { + ref string + llmName string + spec spiceboxv1alpha1.BrowserToolboxSpec // effective (site host appended in allowlist mode) + gateURL string + token string +} + +// setupBrowserFixture detects BrowserToolbox CRs among the bundle's manifests +// and boots the fixture for them. Returns nil when the bundle declares none. +// +// Mirrors the secret_gated_sidecar pattern: the bundle's AgentClass does NOT +// reference the toolbox (a referenced browser toolbox makes the AgentSession +// reconciler try to create a pod the envtest has no kubelet to make Ready, and +// re-derive status every reconcile, clobbering the stamp). The CR is applied +// only so its Spec can be read here and snapshotted onto status by +// MarkBrowserReady; the tools then appear mid-session via the browser refresher. +func setupBrowserFixture(t *testing.T, dir string, h *e2e.Harness, b bt.Bundle) *browserFixture { + t.Helper() + specs := browserToolboxSpecs(t, dir, b) + if len(specs) == 0 { + return nil + } + // One shared Chrome has ONE global egress proxy (a single host port baked at + // container start), so one bundle can exercise exactly one browser gate. + // Supporting several would need per-browser-context proxies; no Phase A + // bundle uses more than one toolbox. Fail loudly rather than bind-collide. + require.LessOrEqual(t, len(specs), 1, + "browser bundle %q declares %d BrowserToolbox CRs; the shared-Chrome fixture supports one per bundle", b.Name, len(specs)) + + cdp := testbrowser.SharedChrome(t) + _, hostForPolicy, port := testbrowser.ServeSite(t, browserSitePages(t, dir)) + + fix := &browserFixture{siteValue: fmt.Sprintf("%s:%d", hostForPolicy, port)} + conn := map[string]browserFixtureToolbox{} + for ref, spec := range specs { + effSpec := spec + // In allowlist mode the test site must be reachable: add it to BOTH the + // gate policy (egress enforcement) and the effective spec (the + // runner-side navigate pre-check reads EffectiveSites). Blocklist mode + // admits an unlisted host by default, so no addition is needed. + if effSpec.Access.Mode == browserpolicy.ModeAllowlist { + effSpec.Access.Sites = append(append([]spiceboxv1alpha1.BrowserSite{}, effSpec.Access.Sites...), + spiceboxv1alpha1.BrowserSite{Host: hostForPolicy}) + } + token := randomBrowserToken(t) + gateURL := testbrowser.StartGate(t, browserPolicyFromSpec(effSpec), token, cdp, port) + tb := browserFixtureToolbox{ref: ref, llmName: ref, spec: effSpec, gateURL: gateURL, token: token} + fix.toolboxes = append(fix.toolboxes, tb) + conn[ref] = tb + } + + h.SetBrowserConnect(func(rt spiceboxv1alpha1.ResolvedBrowserToolbox) (string, string) { + tb, ok := conn[rt.Ref] + if !ok { + // Errorf (not Fatalf): runs on the runner's goroutine, where a + // Goexit would abandon the loop's stack rather than the test's. + t.Errorf("BrowserConnect: no gate wired for browser toolbox ref %q", rt.Ref) + return "", "" + } + return tb.gateURL, tb.token + }) + return fix +} + +// markBrowserFixtureReady stamps each toolbox into the session's status once the +// session exists, so the browser refresher synthesizes its tools on the next +// turn. Called after the first user turn is sent (the session is created from +// the first inbound). +func markBrowserFixtureReady(t *testing.T, h *e2e.Harness, fix *browserFixture) { + t.Helper() + ns, name := singleSessionForBrowser(t, h) + for _, tb := range fix.toolboxes { + h.MarkBrowserReady(ns, name, tb.llmName, tb.ref, tb.gateURL, tb.token, tb.spec) + } +} + +// singleSessionForBrowser polls until exactly one AgentSession exists and +// returns its (namespace, name). Each bundle gets its own session, so there is +// exactly one. Mirrors secret_gated_sidecar's sessionNameAfterFirstReply. +func singleSessionForBrowser(t *testing.T, h *e2e.Harness) (ns, name string) { + t.Helper() + deadline := time.Now().Add(20 * time.Second) + for time.Now().Before(deadline) { + var list spiceboxv1alpha1.AgentSessionList + if err := h.K8s.List(context.Background(), &list); err == nil && len(list.Items) == 1 { + return list.Items[0].Namespace, list.Items[0].Name + } + time.Sleep(100 * time.Millisecond) + } + t.Fatal("markBrowserFixtureReady: no single AgentSession within deadline") + return "", "" +} + +// browserToolboxSpecs parses the bundle's manifests for BrowserToolbox CRs, +// returning ref (metadata.name) → spec. The LLM-facing tool prefix is the ref +// (setupBrowserFixture uses ref as llmName), since — per the secret_gated +// pattern — the AgentClass does not reference the toolbox to supply a name. +func browserToolboxSpecs(t *testing.T, dir string, b bt.Bundle) map[string]spiceboxv1alpha1.BrowserToolboxSpec { + t.Helper() + out := map[string]spiceboxv1alpha1.BrowserToolboxSpec{} + for _, blob := range loadManifests(t, dir, b) { + for _, doc := range splitYAMLDocs(blob) { + var probe struct { + Kind string `json:"kind"` + Metadata struct { + Name string `json:"name"` + } `json:"metadata"` + } + if err := yaml.Unmarshal([]byte(doc), &probe); err != nil || probe.Kind != "BrowserToolbox" { + continue + } + var tb spiceboxv1alpha1.BrowserToolbox + require.NoError(t, yaml.Unmarshal([]byte(doc), &tb), + "parse BrowserToolbox %q", probe.Metadata.Name) + out[probe.Metadata.Name] = tb.Spec + } + } + return out +} + +// splitYAMLDocs splits a multi-document YAML blob on `---` separators, dropping +// empty documents. +func splitYAMLDocs(blob string) []string { + var docs []string + for _, d := range regexp.MustCompile(`(?m)^---\s*$`).Split(blob, -1) { + if strings.TrimSpace(d) != "" { + docs = append(docs, d) + } + } + return docs +} + +// browserPolicyFromSpec builds the gate PolicyDoc from a BrowserToolbox spec — +// the same mapping agentsession.BuildBrowserPolicyDoc applies, from the spec +// rather than the resolved status entry. +func browserPolicyFromSpec(spec spiceboxv1alpha1.BrowserToolboxSpec) browserpolicy.PolicyDoc { + sites := make([]browserpolicy.Site, 0, len(spec.Access.Sites)) + for _, s := range spec.Access.Sites { + sites = append(sites, browserpolicy.Site{Host: s.Host, Exact: s.Exact}) + } + return browserpolicy.PolicyDoc{Mode: spec.Access.Mode, Sites: sites} +} + +// browserSitePages loads the HTML the test site serves: every file under the +// bundle's site/ directory (served at "/"+relpath, with index.html also at "/"), +// or a single default page when the directory is absent. +func browserSitePages(t *testing.T, dir string) map[string]string { + t.Helper() + siteDir := filepath.Join(dir, "site") + entries, err := os.ReadDir(siteDir) + if err != nil { + return map[string]string{ + "/": `Browser Toolbox Test Site` + + `

browser toolbox test site

default page

`, + } + } + pages := map[string]string{} + for _, e := range entries { + if e.IsDir() { + continue + } + raw, rerr := os.ReadFile(filepath.Join(siteDir, e.Name())) + require.NoError(t, rerr, "read site page %q", e.Name()) + pages["/"+e.Name()] = string(raw) + if e.Name() == "index.html" { + pages["/"] = string(raw) + } + } + return pages +} + +// randomBrowserToken mints a per-toolbox gate token. A fresh random value per +// run keeps the gate's constant-time auth meaningful and never reuses a secret. +func randomBrowserToken(t *testing.T) string { + t.Helper() + var buf [16]byte + _, err := rand.Read(buf[:]) + require.NoError(t, err, "mint browser gate token") + return hex.EncodeToString(buf[:]) +} + +// substituteBrowserSite replaces the literal {{browserSite}} with value +// throughout the bundle's replay-relevant strings — toolUse args and the expect +// strings the divergence contract checks — so a browser bundle names the site +// portably. Mutates b's backing slices in place; call before registerTranscript. +func substituteBrowserSite(b *bt.Bundle, value string) { + const ph = "{{browserSite}}" + repl := func(s string) string { return strings.ReplaceAll(s, ph, value) } + + for i := range b.UserTurns { + b.UserTurns[i].Text = repl(b.UserTurns[i].Text) + } + for i := range b.Assert.AgentReplyContains { + b.Assert.AgentReplyContains[i] = repl(b.Assert.AgentReplyContains[i]) + } + for i := range b.Assert.AgentReplyNotContains { + b.Assert.AgentReplyNotContains[i] = repl(b.Assert.AgentReplyNotContains[i]) + } + for i := range b.LLM { + e := &b.LLM[i].Expect + e.UserTextContains = repl(e.UserTextContains) + e.LastToolResult = repl(e.LastToolResult) + e.LastToolResultContains = repl(e.LastToolResultContains) + e.LastToolResultNotContains = repl(e.LastToolResultNotContains) + e.ToolOffered = repl(e.ToolOffered) + e.ToolNotOffered = repl(e.ToolNotOffered) + for j := range b.LLM[i].Reply { + p := &b.LLM[i].Reply[j] + p.Text = repl(p.Text) + p.BareText = repl(p.BareText) + if p.ToolUse != nil && len(p.ToolUse.Args) > 0 { + p.ToolUse.Args = []byte(repl(string(p.ToolUse.Args))) + } + } + } +} + // startAutoApprover clears approval prompts in the background for the // categories a bundle opted into. // diff --git a/test/testbrowser/labels.go b/test/testbrowser/labels.go new file mode 100644 index 00000000..d1206ecd --- /dev/null +++ b/test/testbrowser/labels.go @@ -0,0 +1,28 @@ +// This file carries NO build tag, unlike the rest of the package, so tooling +// that never links the fixture itself — the magefile, a reaper — can still name +// the labels it filters on instead of copying the strings. Mirrors +// test/testspicedb/labels.go exactly; only LabelFixtureValue differs so a +// browser-fixture container is never confused with a spicedb one. +package testbrowser + +// Docker labels stamped onto every container this fixture starts, so a counter +// or a reaper can tell a suite's headless-Chrome container from a Chromium the +// developer runs on the same machine. LabelRunID is empty unless EnvRunID is +// set; set it to scope containers to a single `go test` invocation. +const ( + LabelFixture = "com.authzed.agentprimitives.testfixture" + LabelFixtureValue = "browser" + LabelRunID = "com.authzed.agentprimitives.testrun" + EnvRunID = "AP_TEST_RUN_ID" + + // LabelOwnerPID carries the PID of the test process that started the + // container, so a later run can tell an abandoned container from one a + // concurrently-running suite is still using. See reapAbandoned. + LabelOwnerPID = "com.authzed.agentprimitives.testowner" + + // LabelOwnerStart carries a token identifying the OWNER PROCESS INSTANCE, + // not just its number, so a recycled PID cannot masquerade as a live owner. + // See test/testspicedb/labels.go for the full rationale — a PID alone is + // not an identity on a box that recycles them quickly. + LabelOwnerStart = "com.authzed.agentprimitives.testownerstart" +) diff --git a/test/testbrowser/main_test.go b/test/testbrowser/main_test.go new file mode 100644 index 00000000..f9d3ed71 --- /dev/null +++ b/test/testbrowser/main_test.go @@ -0,0 +1,21 @@ +//go:build integration || e2e + +package testbrowser + +import ( + "os" + "testing" +) + +// TestMain purges the shared headless-Chrome container immediately after this +// package's tests finish, instead of leaving it for the idleGrace timer (which +// often cannot fire before the test process exits) or the 20-minute Expire +// backstop. This is the "a package that DOES define TestMain should call +// StopShared after m.Run()" path SharedChrome's doc and test/testspicedb call +// out — for a package with its own fixture-using tests, prompt cleanup belongs +// here rather than relying on the next run's reapAbandoned sweep. +func TestMain(m *testing.M) { + code := m.Run() + StopShared() + os.Exit(code) +} diff --git a/test/testbrowser/reap.go b/test/testbrowser/reap.go new file mode 100644 index 00000000..43a3fad1 --- /dev/null +++ b/test/testbrowser/reap.go @@ -0,0 +1,319 @@ +// This file carries NO build tag, like labels.go, because two callers that +// never link each other have to reap by exactly the same rules: the fixture +// itself (tagged `integration || e2e`, sweeping on every container start) and +// the magefile (untagged, sweeping after a suite finishes). A second copy of +// these rules is how the one safety property that matters — never touch a +// container this fixture did not start — silently stops holding on one path. +// +// Mirrors test/testspicedb/reap.go; the ONLY differences are the package name +// (so the browser fixture reaps on the browser label, LabelFixtureValue) and +// the log-prefix strings. The owner-liveness logic is identical and is kept +// identical deliberately — see that file's commentary for why each rule exists. + +package testbrowser + +import ( + "errors" + "fmt" + "os" + "os/exec" + "strconv" + "strings" + "sync" + "syscall" + + "github.com/ory/dockertest/v3" + "github.com/ory/dockertest/v3/docker" +) + +// ErrDockerUnavailable marks the one condition these tests may legitimately +// skip on: no reachable Docker daemon, so the machine cannot run them at all. +// Any other startup failure is a real defect and fails the test. Reap returns +// it too, so a sweep on a machine with Docker stopped stays quiet instead of +// warning about containers that cannot exist. +var ErrDockerUnavailable = errors.New("testbrowser: no reachable docker daemon") + +// Container is the slice of a docker container listing the reaper reasons +// about. Taking a struct rather than docker.APIContainers keeps the decision +// rules testable without a daemon. +type Container struct { + ID string + Name string + Labels map[string]string +} + +// OwnerStatus is what the reaper could establish about the test process that +// started a container. Only OwnerGone and OwnerRecycled permit removal. +type OwnerStatus string + +const ( + // OwnerLive: the PID is held by the very process that started the + // container. A suite running right now, possibly in another worktree. + OwnerLive OwnerStatus = "live" + // OwnerGone: no process holds the PID. The container is abandoned. + OwnerGone OwnerStatus = "gone" + // OwnerRecycled: a process holds the PID, but it started at a different + // time than the one that created the container — so the creator is dead + // and the kernel handed its PID to someone else. Abandoned. + OwnerRecycled OwnerStatus = "recycled" + // OwnerUnknown: a process holds the PID and its identity could not be + // confirmed. Treated as live: every unresolvable case must err toward + // leaving a container in place. + OwnerUnknown OwnerStatus = "unknown" +) + +// OwnerChecker reports the state of the process that started a container, given +// the PID and start token stamped onto it. Injectable so the decision rules can +// be tested against every owner state without arranging real processes. +type OwnerChecker func(pid int, startedAt string) OwnerStatus + +// Decision records what the reaper resolved to do about one container and why. +type Decision struct { + ID string + Name string + Reap bool + Reason string + // Err is set when the reaper decided to remove this container and the + // removal itself failed. Such a decision is reported as kept. + Err error +} + +// Classify decides, for each listed container, whether it may be removed. The +// rules mirror test/testspicedb.Classify exactly; see there for the ordered +// rationale of each. +func Classify(containers []Container, self int, ownRunID string, owner OwnerChecker) []Decision { + out := make([]Decision, 0, len(containers)) + for _, c := range containers { + out = append(out, classifyOne(c, self, ownRunID, owner)) + } + return out +} + +func classifyOne(c Container, self int, ownRunID string, owner OwnerChecker) Decision { + d := Decision{ID: c.ID, Name: c.Name} + + if c.Labels[LabelFixture] != LabelFixtureValue { + d.Reason = fmt.Sprintf("not a fixture container: no %s=%s label", LabelFixture, LabelFixtureValue) + return d + } + + rawPID, hasPID := c.Labels[LabelOwnerPID] + pid, pidErr := strconv.Atoi(rawPID) + if hasPID && pidErr == nil && pid == self { + d.Reason = fmt.Sprintf("owner pid %d is this process", pid) + return d + } + + if ownRunID != "" && c.Labels[LabelRunID] == ownRunID { + d.Reap = true + d.Reason = fmt.Sprintf("started by this run (%s=%s), which has finished", LabelRunID, ownRunID) + return d + } + + if !hasPID { + d.Reason = fmt.Sprintf("no %s label: cannot tell an abandoned container from one in use", LabelOwnerPID) + return d + } + if pidErr != nil { + d.Reason = fmt.Sprintf("owner pid label %q does not parse: %v", rawPID, pidErr) + return d + } + + startedAt := c.Labels[LabelOwnerStart] + switch owner(pid, startedAt) { + case OwnerGone: + d.Reap = true + d.Reason = fmt.Sprintf("owner pid %d is gone", pid) + case OwnerRecycled: + d.Reap = true + d.Reason = fmt.Sprintf("owner pid %d was reused: it now holds a process started at a different time than %q", pid, startedAt) + case OwnerUnknown: + d.Reason = fmt.Sprintf("owner pid %d holds a process whose identity could not be confirmed", pid) + default: // OwnerLive + d.Reason = fmt.Sprintf("owner pid %d is still running", pid) + } + return d +} + +// DockerAPI is the slice of the docker client the reaper uses. *docker.Client +// satisfies it; a test can supply a fake. +type DockerAPI interface { + ListContainers(docker.ListContainersOptions) ([]docker.APIContainers, error) + RemoveContainer(docker.RemoveContainerOptions) error +} + +// ReapResult separates what the sweep actually removed from what it left in +// place. +type ReapResult struct { + Removed []Decision + Kept []Decision +} + +// ReapWith sweeps the fixture's containers through api, applying Classify. +// Nothing here removes a container by image, by name, or in bulk. +func ReapWith(api DockerAPI, self int, ownRunID string, owner OwnerChecker) (ReapResult, error) { + listed, err := api.ListContainers(docker.ListContainersOptions{ + All: true, + Filters: map[string][]string{"label": {LabelFixture + "=" + LabelFixtureValue}}, + }) + if err != nil { + return ReapResult{}, fmt.Errorf("list fixture containers: %w", err) + } + + containers := make([]Container, 0, len(listed)) + for _, c := range listed { + containers = append(containers, Container{ID: c.ID, Name: containerName(c), Labels: c.Labels}) + } + + var res ReapResult + for _, d := range Classify(containers, self, ownRunID, owner) { + if !d.Reap { + res.Kept = append(res.Kept, d) + continue + } + err := api.RemoveContainer(docker.RemoveContainerOptions{ + ID: d.ID, Force: true, RemoveVolumes: true, + }) + var gone *docker.NoSuchContainer + if err != nil && !errors.As(err, &gone) { + d.Err = err + res.Kept = append(res.Kept, d) + continue + } + res.Removed = append(res.Removed, d) + } + return res, nil +} + +// Reap connects to Docker and sweeps this fixture's abandoned containers, plus +// — when ownRunID is non-empty — every container the caller's own finished `go +// test` invocation started. +func Reap(ownRunID string) (ReapResult, error) { + pool, err := dockertest.NewPool("") + if err != nil { + return ReapResult{}, fmt.Errorf("%w: %v", ErrDockerUnavailable, err) + } + return ReapWith(pool.Client, os.Getpid(), ownRunID, LiveOwnerChecker()) +} + +// containerName returns a container's primary name without the leading slash. +func containerName(c docker.APIContainers) string { + if len(c.Names) == 0 { + return "" + } + return strings.TrimPrefix(c.Names[0], "/") +} + +// SelfStartToken returns this process's start token, memoized. Empty when the +// process table could not be read, which degrades the reaper to PID-only +// liveness rather than breaking container startup. +var SelfStartToken = sync.OnceValue(func() string { return StartedAt(os.Getpid()) }) + +// StartedAt returns a token identifying the process INSTANCE that currently +// holds pid, or "" if none does. +func StartedAt(pid int) string { + starts, err := procStarts() + if err != nil { + return "" + } + return starts[pid] +} + +// LiveOwnerChecker returns an OwnerChecker backed by the real process table. +func LiveOwnerChecker() OwnerChecker { + var ( + once sync.Once + starts map[int]string + err error + ) + return func(pid int, startedAt string) OwnerStatus { + once.Do(func() { + starts, err = procStarts() + if err != nil { + fmt.Fprintf(os.Stderr, + "testbrowser: %v; falling back to pid-only owner liveness, which cannot detect a reused pid\n", err) + } + }) + if err != nil { + if processAlive(pid) { + return OwnerUnknown + } + return OwnerGone + } + return ownerStatus(pid, startedAt, starts) + } +} + +// ownerStatus is the hardened liveness rule, split out from its data source so +// every branch is reachable in a test. +func ownerStatus(pid int, startedAt string, starts map[int]string) OwnerStatus { + if pid <= 0 { + return OwnerGone + } + now, held := starts[pid] + if !held { + if processAlive(pid) { + return OwnerUnknown + } + return OwnerGone + } + if startedAt == "" { + return OwnerUnknown + } + if now != startedAt { + return OwnerRecycled + } + return OwnerLive +} + +// procStarts snapshots the start time of every visible process, keyed by PID. +// `lstart` is the one start-time column both BSD (macOS) and procps (Linux) `ps` +// render identically and does not change between readings. +func procStarts() (map[int]string, error) { + out, err := exec.Command("ps", "-ax", "-o", "pid=,lstart=").Output() + if err != nil { + return nil, fmt.Errorf("read process start times: %w", err) + } + return parseProcStarts(string(out)), nil +} + +// parseProcStarts parses `ps -ax -o pid=,lstart=` output. Rows that do not +// parse are skipped rather than fatal. +func parseProcStarts(psOutput string) map[int]string { + starts := make(map[int]string) + for _, line := range strings.Split(psOutput, "\n") { + pidField, rest, found := strings.Cut(strings.TrimSpace(line), " ") + if !found { + continue + } + pid, err := strconv.Atoi(pidField) + if err != nil { + continue + } + token := normalizeStart(rest) + if token == "" { + continue + } + starts[pid] = token + } + return starts +} + +// normalizeStart collapses ps's column padding so a token read back later +// compares equal to the one stamped onto a container. +func normalizeStart(s string) string { return strings.Join(strings.Fields(s), " ") } + +// processAlive reports whether pid names a live process. Signal 0 performs the +// existence check without delivering anything; anything other than a +// definitely-gone answer is treated as ALIVE. +func processAlive(pid int) bool { + if pid <= 0 { + return false + } + p, err := os.FindProcess(pid) + if err != nil { + return false + } + err = p.Signal(syscall.Signal(0)) + return !errors.Is(err, os.ErrProcessDone) && !errors.Is(err, syscall.ESRCH) +} diff --git a/test/testbrowser/testbrowser.go b/test/testbrowser/testbrowser.go new file mode 100644 index 00000000..0635009c --- /dev/null +++ b/test/testbrowser/testbrowser.go @@ -0,0 +1,469 @@ +//go:build integration || e2e + +// Package testbrowser spins up ONE chromedp/headless-shell container per test +// BINARY (shared, lazily, refcounted) and runs the browser gate +// (pkg/tools/browsergate) IN-PROCESS in front of it, so an e2e test can drive a +// real headless Chrome through exactly the authenticated-DevTools + +// policy-enforced-egress boundary production uses. +// +// Architecture lifted verbatim from test/testspicedb: dockertest pool, a pinned +// NUMERIC image tag (never :latest — a moving tag makes a green run +// unreproducible), a binary-scoped refcounted container (SharedChrome), reaping +// labels (labels.go, reap.go, AP_TEST_RUN_ID), res.Expire(1200) as the +// SIGKILL backstop, a 3-attempt start retry, and the EXACT skip-vs-fail policy: +// t.Skipf ONLY when the Docker daemon is unreachable; a container that will not +// start after retries is t.Fatalf — skipping that case is how a suite reports +// green having run nothing. +// +// # Topology (and why it is what it is) +// +// The e2e harness runs every FIRST-PARTY component in-process (the runner, and +// here the gate), so only Chrome is containerized — the newest numeric upstream +// headless-shell. The first-party images/browser image's flag set is covered by +// Task 8a's pod-builder unit tests; building it here would couple `mage +// test:e2e` to `docker build`. (Recorded deviation from the spec's §7 +// "testbrowser starts the first-party image"; revisit in Phase C when real +// Chrome bot-check fidelity starts to matter.) +// +// chromedp client ──(gate DevTools listener, 127.0.0.1:, token)──▶ gate ──▶ Chrome CDP (container 9222) +// Chrome egress ──(host.docker.internal:)──▶ gate proxy (127.0.0.1:) ──▶ ServeSite +// +// Two cross-boundary facts the fixture leans on, both verified on Docker +// Desktop for Mac and provided on Linux by the host-gateway ExtraHost: +// +// - A container reaches a HOST service bound to 127.0.0.1 via +// host.docker.internal, so the in-process gate's loopback proxy is Chrome's +// reachable egress. Chrome's --proxy-server is baked at container start to +// host.docker.internal:; SharedChrome picks that port and +// StartGate binds the gate's proxy to it (one shared port per binary — the +// container is shared, so its proxy target is fixed, and gates in a binary +// run sequentially). +// - Containerized Chrome ECHOES the request Host in /json/version's +// webSocketDebuggerUrl, so the gate's existing host-rewrite (devtools.go) +// keeps chromedp on the authenticated gate rather than dialing Chrome +// directly. +// +// The gate's production dialer refuses loopback/private by design +// (controlRejectPrivate), so it cannot reach a host-side httptest server on its +// own; StartGate installs a TEST-ONLY dialer (the browsergate.SetDialContextForTest +// seam the field's own comment anticipates) that maps host.docker.internal to +// the loopback ServeSite and DELEGATES every other host to the guarded dialer, +// so off-site denials still fire. +package testbrowser + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strconv" + "sync" + "testing" + "time" + + "github.com/ory/dockertest/v3" + "github.com/stretchr/testify/require" + + "github.com/authzed/openagentprimitives/pkg/tools/browsergate" + "github.com/authzed/openagentprimitives/pkg/tools/browserpolicy" + "github.com/authzed/openagentprimitives/pkg/x/safehttp" +) + +const ( + // chromeImage / chromeTag pin the upstream headless Chrome. NUMERIC tag, + // never :latest/:stable — a moving tag makes a green run unreproducible. + // This was the newest numeric chromedp/headless-shell tag when the fixture + // landed (== :latest/:stable on 2026-08-11); bump it deliberately. + chromeImage = "chromedp/headless-shell" + chromeTag = "151.0.7922.109" + + // hostDockerInternal is the name a container resolves to reach the test + // host. ServeSite returns urlForChrome / hostForPolicy in terms of it, and + // StartGate's test dialer maps it back to the loopback ServeSite. + hostDockerInternal = "host.docker.internal" + + // chromeDebugContainerPort is the port the headless-shell image publishes + // its DevTools on (its entrypoint socat-forwards it to Chrome's internal + // debug port). + chromeDebugContainerPort = "9222/tcp" + + // idleGrace is how long the shared container outlives its last user before + // it is purged — see test/testspicedb/shared.go for the full rationale + // (bridges the sub-millisecond gaps between serial tests without restarting + // a container per test). + idleGrace = 5 * time.Second + + // containerStartAttempts bounds retries of the run+probe cycle. A genuinely + // broken image must still fail rather than spin. + containerStartAttempts = 3 + + // expireSeconds is the dockertest Expire backstop: the daemon force-stops + // the container this long after creation even if the process is SIGKILLed + // without running cleanups. A ceiling on a shared container's life, not a + // grace period (see testspicedb). + expireSeconds = 1200 +) + +var ( + sharedMu sync.Mutex + sharedCDP string // "http://127.0.0.1:" + sharedProxyPort int // host port Chrome's --proxy-server targets; the gate binds it + sharedRes *dockertest.Resource + sharedPool *dockertest.Pool + sharedUsers int + sharedIdle *time.Timer + sharedFail error + sharedTried bool +) + +// SharedChrome starts ONE chromedp/headless-shell container for the whole test +// BINARY (lazily, on first call) and returns its CDP base URL +// ("http://127.0.0.1:"). Chrome's only egress is baked to the in-process +// gate's proxy (host.docker.internal:); StartGate binds that port. +// +// Lifetime is reference-counted: every caller's t.Cleanup releases, and the +// container is purged idleGrace after the LAST release, so the fixture is +// self-cleaning and needs no TestMain. A package that DOES define TestMain +// should call StopShared after m.Run() to purge immediately. +// +// If Docker is unreachable the calling test is SKIPPED. If Docker is reachable +// and the container still fails, the test FAILS — skipping that is how a suite +// reports green having run nothing. +func SharedChrome(t *testing.T) string { + t.Helper() + + sharedMu.Lock() + defer sharedMu.Unlock() + + if sharedIdle != nil { + sharedIdle.Stop() + sharedIdle = nil + } + + if !sharedTried { + sharedTried = true + cdp, proxyPort, res, pool, err := startContainer() + sharedCDP, sharedProxyPort, sharedRes, sharedPool, sharedFail = cdp, proxyPort, res, pool, err + if err != nil { + t.Logf("testbrowser: could not start shared headless-shell container: %v", err) + } + } + if errors.Is(sharedFail, ErrDockerUnavailable) { + t.Skipf("skipping: %v", sharedFail) + } + if sharedFail != nil || sharedCDP == "" { + t.Fatalf("testbrowser: docker is reachable but the shared headless-shell container would not start: %v", sharedFail) + } + + sharedUsers++ + t.Cleanup(releaseShared) + return sharedCDP +} + +// sharedProxyAddr returns the host:port Chrome's --proxy-server was baked to, +// which is the address StartGate must bind the gate's forward proxy to. Fatals +// if SharedChrome has not run — StartGate is always paired with it. +func sharedProxyAddr(t *testing.T) string { + t.Helper() + sharedMu.Lock() + defer sharedMu.Unlock() + if sharedProxyPort == 0 { + t.Fatal("testbrowser: StartGate called before SharedChrome (no proxy port reserved)") + } + return net.JoinHostPort("127.0.0.1", strconv.Itoa(sharedProxyPort)) +} + +func releaseShared() { + sharedMu.Lock() + defer sharedMu.Unlock() + sharedUsers-- + if sharedUsers > 0 { + return + } + sharedIdle = time.AfterFunc(idleGrace, purgeIfIdle) +} + +func purgeIfIdle() { + sharedMu.Lock() + defer sharedMu.Unlock() + if sharedUsers > 0 { + return + } + purgeSharedLocked() +} + +// StopShared purges the package's shared container immediately. Safe when none +// was started. Call from TestMain after m.Run(). +func StopShared() { + sharedMu.Lock() + defer sharedMu.Unlock() + if sharedIdle != nil { + sharedIdle.Stop() + sharedIdle = nil + } + purgeSharedLocked() +} + +func purgeSharedLocked() { + if sharedRes == nil || sharedPool == nil { + return + } + if err := sharedPool.Purge(sharedRes); err != nil { + // Reachable from TestMain and from a timer goroutine, both outside any + // *testing.T — stderr is the only surface. Never silent (AGENTS.md). + fmt.Fprintf(os.Stderr, "testbrowser: purge shared container: %v\n", err) + } + sharedRes, sharedPool, sharedCDP, sharedProxyPort = nil, nil, "", 0 + sharedTried, sharedFail = false, nil +} + +// startContainer launches the headless-shell container, retrying transient +// startup failures. No Docker is terminal (retrying cannot conjure a daemon). +func startContainer() (cdp string, proxyPort int, res *dockertest.Resource, pool *dockertest.Pool, err error) { + var lastErr error + for attempt := 1; attempt <= containerStartAttempts; attempt++ { + cdp, proxyPort, res, pool, err = startContainerOnce() + if err == nil { + return cdp, proxyPort, res, pool, nil + } + if errors.Is(err, ErrDockerUnavailable) { + return "", 0, nil, nil, err + } + lastErr = err + fmt.Fprintf(os.Stderr, "testbrowser: container start attempt %d/%d failed: %v\n", + attempt, containerStartAttempts, err) + } + return "", 0, nil, nil, fmt.Errorf("after %d attempts: %w", containerStartAttempts, lastErr) +} + +func startContainerOnce() (cdp string, proxyPort int, res *dockertest.Resource, pool *dockertest.Pool, err error) { + pool, err = dockertest.NewPool("") + if err != nil { + // The ONLY skippable outcome: no reachable Docker daemon. + return "", 0, nil, nil, fmt.Errorf("%w: %v", ErrDockerUnavailable, err) + } + pool.MaxWait = 60 * time.Second + + // Reserve the host port Chrome's egress proxy (the in-process gate, started + // later by StartGate) will listen on, and bake it into Chrome's + // --proxy-server now — the container is shared, so its proxy target is fixed + // for the binary. + proxyPort, err = freeLoopbackPort() + if err != nil { + return "", 0, nil, nil, fmt.Errorf("reserve proxy port: %w", err) + } + + res, err = pool.RunWithOptions(&dockertest.RunOptions{ + Repository: chromeImage, + Tag: chromeTag, + ExposedPorts: []string{chromeDebugContainerPort}, + // host-gateway makes host.docker.internal resolve from inside the + // container on Linux; harmless on macOS where Docker Desktop already + // provides it. Chrome reaches the host gate proxy, and ServeSite, + // through this name. + ExtraHosts: []string{hostDockerInternal + ":host-gateway"}, + // Appended to the image entrypoint's own headless-shell invocation: + // - remote-allow-origins=*: Chrome 111+ rejects a DevTools ws whose + // Origin is not allow-listed, and the gate reaches Chrome over a + // rewritten host (mirrors pkg/agent/tool/browser's chrome_test.go). + // - proxy-server: force every page fetch through the gate's proxy. + Cmd: []string{ + "--remote-allow-origins=*", + "--proxy-server=" + net.JoinHostPort(hostDockerInternal, strconv.Itoa(proxyPort)), + }, + Labels: map[string]string{ + LabelFixture: LabelFixtureValue, + LabelOwnerPID: strconv.Itoa(os.Getpid()), + LabelOwnerStart: SelfStartToken(), + LabelRunID: os.Getenv(EnvRunID), + }, + }) + if err != nil { + return "", 0, nil, nil, fmt.Errorf("run headless-shell container: %w", err) + } + + // Having paid for a docker round trip, sweep up any container this fixture + // started for a process that is now gone. See reapAbandoned. + reapAbandoned(pool) + + // Hard backstop for a test process that dies without running cleanups. + _ = res.Expire(expireSeconds) + + hostPort := res.GetPort(chromeDebugContainerPort) + cdp = "http://127.0.0.1:" + hostPort + + // Wait for DevTools to answer. Only a REAL 200 from /json/version proves the + // service is serving; a connection error means the port is not bound yet. + if retryErr := pool.Retry(func() error { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + req, reqErr := http.NewRequestWithContext(ctx, http.MethodGet, cdp+"/json/version", nil) + if reqErr != nil { + return reqErr + } + resp, getErr := http.DefaultClient.Do(req) + if getErr != nil { + return getErr + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("devtools /json/version: status %d", resp.StatusCode) + } + return nil + }); retryErr != nil { + _ = pool.Purge(res) // failed start — purge immediately + return "", 0, nil, nil, fmt.Errorf("headless-shell container did not become ready: %w", retryErr) + } + + return cdp, proxyPort, res, pool, nil +} + +// reapAbandoned removes containers THIS fixture started whose creating process +// no longer exists. Best-effort: failures are reported, never fatal. +func reapAbandoned(pool *dockertest.Pool) { + res, err := ReapWith(pool.Client, os.Getpid(), "", LiveOwnerChecker()) + if err != nil { + fmt.Fprintf(os.Stderr, "testbrowser: sweep abandoned fixture containers: %v\n", err) + return + } + for _, d := range res.Kept { + if d.Err != nil { + fmt.Fprintf(os.Stderr, "testbrowser: reap abandoned container %s (%s): %v\n", + shortID(d.ID), d.Reason, d.Err) + } + } +} + +func shortID(id string) string { + if len(id) > 12 { + return id[:12] + } + return id +} + +// StartGate runs the browser gate IN-PROCESS on 127.0.0.1 listeners in front of +// cdpURL and returns the gate DevTools URL the runner-side tools dial +// ("http://127.0.0.1:"). +// +// policy is written to a temp file (the gate reloads it on mtime change, so a +// later rewrite would take effect). token authenticates the DevTools reverse +// proxy. extraPorts admits CONNECT/HTTP to non-80/443 ports — pass ServeSite's +// port so the gate proxy will forward to it. +// +// The gate's forward proxy binds the host port Chrome's --proxy-server was +// baked to (sharedProxyAddr), so Chrome's egress reaches it. A TEST-ONLY dialer +// maps host.docker.internal to the loopback ServeSite and delegates every other +// host to the guarded dialer — see the package doc. +func StartGate(t *testing.T, policy browserpolicy.PolicyDoc, token, cdpURL string, extraPorts ...int) (gateURL string) { + t.Helper() + + policyPath := writePolicy(t, policy) + proxyAddr := sharedProxyAddr(t) + + g, err := browsergate.New(browsergate.Config{ + ProxyAddr: proxyAddr, + GateAddr: "127.0.0.1:0", + DevtoolsURL: cdpURL, + Token: token, + PolicyPath: policyPath, + ExtraPorts: extraPorts, + }) + require.NoError(t, err, "browsergate.New") + + // The production guard refuses loopback, so the gate cannot reach a + // host-side httptest server on its own. Map host.docker.internal (what + // Chrome navigates to, and what the gate therefore receives) to the loopback + // ServeSite; delegate everything else to the guarded dialer so off-site + // private-address denials still fire. + var prev func(ctx context.Context, network, addr string) (net.Conn, error) + prev = g.SetDialContextForTest(func(ctx context.Context, network, addr string) (net.Conn, error) { + host, port, splitErr := net.SplitHostPort(addr) + if splitErr == nil && host == hostDockerInternal { + return (&net.Dialer{}).DialContext(ctx, network, net.JoinHostPort("127.0.0.1", port)) + } + return prev(ctx, network, addr) + }) + + // Bind our own listeners so we can read the ephemeral gate port and control + // lifecycle; mirrors what browsergate.Gate.Run does, minus its blocking + // select (the test owns teardown via t.Cleanup). + proxyLn, err := net.Listen("tcp", proxyAddr) + require.NoError(t, err, "bind gate proxy listener on %s", proxyAddr) + gateLn, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + _ = proxyLn.Close() + require.NoError(t, err, "bind gate devtools listener") + } + + proxySrv := &http.Server{Handler: g.ProxyHandler()} + gateSrv := &http.Server{Handler: g.GateHandler()} + safehttp.HardenServer(proxySrv) + safehttp.HardenServer(gateSrv) + go func() { _ = proxySrv.Serve(proxyLn) }() + go func() { _ = gateSrv.Serve(gateLn) }() + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = proxySrv.Shutdown(ctx) + _ = gateSrv.Shutdown(ctx) + }) + + gatePort := gateLn.Addr().(*net.TCPAddr).Port + return fmt.Sprintf("http://127.0.0.1:%d", gatePort) +} + +// writePolicy marshals policy to a fresh temp file and returns its path. +func writePolicy(t *testing.T, policy browserpolicy.PolicyDoc) string { + t.Helper() + raw, err := json.Marshal(policy) + require.NoError(t, err, "marshal browser policy") + path := filepath.Join(t.TempDir(), "policy.json") + require.NoError(t, os.WriteFile(path, raw, 0o600), "write browser policy") + return path +} + +// ServeSite starts an httptest server serving the given path→HTML map and +// returns (urlForChrome, hostForPolicy, port). urlForChrome uses +// host.docker.internal so the containerized Chrome can reach the test host; +// hostForPolicy is the host a StartGate allowlist must admit; port is what to +// pass as StartGate's extraPorts entry. +// +// Chrome reaches the site THROUGH the gate proxy (its baked --proxy-server), and +// the gate's test dialer maps host.docker.internal back to this loopback +// server — so a page load exercises the gate's egress path end to end. +func ServeSite(t *testing.T, pages map[string]string) (urlForChrome, hostForPolicy string, port int) { + t.Helper() + mux := http.NewServeMux() + for path, html := range pages { + body := html + mux.HandleFunc(path, func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write([]byte(body)) + }) + } + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + + addr := srv.Listener.Addr().(*net.TCPAddr) + return fmt.Sprintf("http://%s:%d/", hostDockerInternal, addr.Port), hostDockerInternal, addr.Port +} + +// freeLoopbackPort grabs a free 127.0.0.1 TCP port and releases it. There is a +// small race before it is reclaimed; acceptable for a test harness (same shape +// as pkg/agent/tool/browser's chrome_test.go freePort). +func freeLoopbackPort() (int, error) { + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return 0, err + } + port := l.Addr().(*net.TCPAddr).Port + if err := l.Close(); err != nil { + return 0, err + } + return port, nil +} diff --git a/test/testbrowser/testbrowser_smoke_test.go b/test/testbrowser/testbrowser_smoke_test.go new file mode 100644 index 00000000..af0377ab --- /dev/null +++ b/test/testbrowser/testbrowser_smoke_test.go @@ -0,0 +1,61 @@ +//go:build e2e + +package testbrowser + +import ( + "context" + "testing" + "time" + + "github.com/chromedp/chromedp" + "github.com/stretchr/testify/require" + + "github.com/authzed/openagentprimitives/pkg/tools/browserpolicy" +) + +// TestSmokeChromeThroughGateLoadsPage is the fixture's own end-to-end test: a +// chromedp client drives the shared headless-Chrome container THROUGH the +// in-process gate — the DevTools control plane over the gate's authenticated +// reverse proxy (token in the query), and Chrome's page fetch through the gate's +// forward proxy — and reads the loaded page's title. It proves SharedChrome, +// StartGate and ServeSite compose into the exact boundary production uses. +// +// Per the fixture's skip-vs-fail policy, this is SKIPPED only when the Docker +// daemon is unreachable (SharedChrome's t.Skipf); a container that will not +// start is a hard failure. +func TestSmokeChromeThroughGateLoadsPage(t *testing.T) { + const token = "testbrowser-smoke-secret-token" + + cdp := SharedChrome(t) + + urlForChrome, hostForPolicy, port := ServeSite(t, map[string]string{ + "/": `Browser Toolbox Smoke` + + `

hello from the smoke site

`, + }) + + gateURL := StartGate(t, browserpolicy.PolicyDoc{ + Mode: browserpolicy.ModeAllowlist, + Sites: []browserpolicy.Site{{Host: hostForPolicy}}, + }, token, cdp, port) + + // Connect THROUGH the gate's DevTools listener, presenting the token in the + // query (the gate's /json rewrite carries it onto the ws URL so chromedp + // stays on the authenticated gate). Mirrors pkg/agent/tool/browser's + // newSession allocator. + allocCtx, cancelAlloc := chromedp.NewRemoteAllocator(context.Background(), gateURL+"/json/version?t="+token) + t.Cleanup(cancelAlloc) + browserCtx, cancelBrowser := chromedp.NewContext(allocCtx) + t.Cleanup(cancelBrowser) + + runCtx, cancelRun := context.WithTimeout(browserCtx, 40*time.Second) + t.Cleanup(cancelRun) + + var title string + require.NoError(t, chromedp.Run(runCtx, + chromedp.Navigate(urlForChrome), // egress forced through the gate's forward proxy + chromedp.Title(&title), + ), "drive Chrome through the gate") + + require.Contains(t, title, "Browser Toolbox Smoke", + "the gate-proxied page load must return the served title") +}