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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -73,22 +73,6 @@ function ActivatedAutomationRunnerBridge() {
if (!isCurrent()) return
const agent = navigator.userAgent
const platform = /Mac/i.test(agent) ? "darwin" : /Win/i.test(agent) ? "win32" : "linux"
// Recovery changes admission semantics. Negotiate it only for an
// explicitly enabled organization; old Den/missing features fail
// closed to the released capability ladder, not to a new engine.
const features = await client.getOrgFeatures(organizationId).catch((error): Record<string, boolean> => {
// Released Automation registration can fall back to its old
// protocol. A remote-only desktop must retry an unavailable
// feature lookup rather than silently wait thirty minutes.
if (deploymentEnabled) return {}
throw error
})
if (!isCurrent()) return
const recoveryEnabled = features.remoteSessionTargets === true
if (!deploymentEnabled && !recoveryEnabled) {
await disconnect()
return
}
const mintToken = deploymentEnabled ? client.mintAutomationRunnerToken : client.mintSessionRunnerToken
const mintRunner = async (id: string) => {
const registration = (
Expand All @@ -112,7 +96,7 @@ function ActivatedAutomationRunnerBridge() {
REMOTE_SESSION_RECOVERY_RUNNER_CAPABILITY,
]
const capabilitySets: AutomationDesktopRunnerCapability[][] = deploymentEnabled ? [
...(recoveryEnabled ? [recoveryCapabilities] : []),
recoveryCapabilities,
[
AUTOMATION_MODEL_ATTENTION_CAPABILITY,
REMOTE_SESSION_DESKTOP_RUNNER_CAPABILITY,
Expand Down Expand Up @@ -140,6 +124,15 @@ function ActivatedAutomationRunnerBridge() {
try {
runner = await mintRunner(runnerId)
} catch (error) {
// An older Den answers 404 for remote session targets when that
// organization never had them turned on (or the route predates
// them). Nothing to connect: wait for the next refresh quietly
// instead of retrying every few seconds.
if (!deploymentEnabled && error instanceof DenApiError && error.status === 404) {
if (!isCurrent()) return
await disconnect()
return
}
if (!(error instanceof DenApiError) || error.status !== 409 || error.code !== "automation_runner_identity_conflict") {
throw error
}
Expand Down
81 changes: 81 additions & 0 deletions apps/app/tests/automation-runner-bridge.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/** @jsxImportSource react */
import { GlobalRegistrator } from "@happy-dom/global-registrator"
import { afterAll, afterEach, expect, mock, spyOn, test } from "bun:test"
import { act } from "react"
import { createRoot } from "react-dom/client"
import type { AutomationRunnerTokenResponse } from "@openwork/types/automations"

import * as den from "../src/app/lib/den"
import * as auth from "../src/react-app/domains/cloud/den-auth-provider"
import * as activation from "../src/react-app/domains/cloud/enterprise-activation-gate"
import * as availability from "../src/react-app/domains/automations/automation-availability"
import { AutomationRunnerBridge } from "../src/react-app/domains/automations/automation-runner-bridge"

const ownedDom = typeof window === "undefined"
if (ownedDom) GlobalRegistrator.register({ url: "http://localhost/" })
const previousAct = Reflect.get(globalThis, "IS_REACT_ACT_ENVIRONMENT")
Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true)
const cleanups: (() => Promise<void>)[] = []

afterEach(async () => {
for (const cleanup of cleanups.splice(0).reverse()) await cleanup()
mock.restore()
})
afterAll(async () => {
Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", previousAct)
if (ownedDom) await GlobalRegistrator.unregister()
})

test("a stale session-runner 404 cannot disconnect the replacement Automation runner", async () => {
const previousBridge = window.__OPENWORK_ELECTRON__
const previousRunnerId = localStorage.getItem("openwork.automations.desktop-runner-id")
const configurations: unknown[] = []
Reflect.set(window, "__OPENWORK_ELECTRON__", {
invokeDesktop: async (command: string, configuration: unknown) => {
if (command === "appBuildInfo") return { version: "test" }
if (command === "automationRunnerConfigure") configurations.push(configuration)
},
})
cleanups.push(async () => {
Reflect.set(window, "__OPENWORK_ELECTRON__", previousBridge)
if (previousRunnerId === null) localStorage.removeItem("openwork.automations.desktop-runner-id")
else localStorage.setItem("openwork.automations.desktop-runner-id", previousRunnerId)
})

spyOn(activation, "useEnterpriseActivationRequired").mockReturnValue(false)
spyOn(auth, "useDenAuth").mockReturnValue({
status: "signed_in", user: null, verifiedIdentity: null,
error: null, isSignedIn: true, refresh: async () => {},
})
const deployment = spyOn(availability, "useAutomationDeploymentEnabled").mockReturnValue(false)
const settings = den.readDenSettings()
spyOn(den, "readDenSettings").mockReturnValue({ ...settings, authToken: "test-token", activeOrgId: "org_test" })
const client = den.createDenClient({ baseUrl: "https://den.example.test", token: "test-token" })
const request = Promise.withResolvers<AutomationRunnerTokenResponse>()
let sessionRequests = 0
spyOn(den, "createDenClient").mockReturnValue({
...client,
mintSessionRunnerToken: async () => { sessionRequests += 1; return request.promise },
mintAutomationRunnerToken: async () => ({
token: "replacement-token", expiresAt: "2026-10-12T23:00:00Z", eventsPath: "/v1/automation-runners/events",
}),
})

const host = document.createElement("div")
document.body.append(host)
const root = createRoot(host)
cleanups.push(async () => { await act(async () => root.unmount()); host.remove() })
await act(async () => { root.render(<AutomationRunnerBridge />) })
expect(sessionRequests).toBe(1)

deployment.mockReturnValue(true)
await act(async () => { root.render(<AutomationRunnerBridge />) })
expect(configurations.at(-1)).toMatchObject({ token: "replacement-token" })
const count = configurations.length

await act(async () => {
request.reject(new den.DenApiError(404, "feature_disabled", "Old Den has no session runner route"))
})
expect(configurations).toHaveLength(count)
expect(configurations.at(-1)).toMatchObject({ token: "replacement-token" })
})
36 changes: 10 additions & 26 deletions docs/marketplace-capabilities-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,43 +197,27 @@ Scope note: these are `mcp:read`-class operations; nothing on this path writes;

---

## 7. Org kill switch
## 7. Availability

Marketplace capability search/execute uses the same effective Connect rail check
as External MCP connections: the `mcpConnections` feature, read with
`getOrganizationFeatures(organizationId)` from `ee/apps/den-api/src/features.ts`
and declared in `packages/features/src/registry.ts`.

Connect is default-on, so local dev, self-hosted, evals, and hosted production
get the feature immediately unless a platform admin turns it off for an
organization in /admin (stored in the `organization_feature` table), or an
operator locks it with `config.features.mcpConnections` / `DEN_FEATURE_MCP_CONNECTIONS`.

Check the kill switch in both paths: disabled orgs get an empty marketplace
merge; execute returns `unknown_capability`. Opted-out is byte-identical to
nonexistence.
Marketplace capability search/execute is available in every organization, like
External MCP connections; the former `mcpConnections` org switch is retired.

### Additive invariants

1. Desktop users without the cloud connection: this code is unreachable; zero change.
2. Cloud-connected users in opted-out orgs: byte-identical search results and execute behavior.
3. Tool surface unchanged: still exactly `search_capabilities` + `execute_capability`.
4. The desktop install flow was later removed (#2857 stopped desktop installs; the local GitHub import and the install route followed). `apps/server/src/cloud-plugins.ts` now only lists and removes local copies made by older builds.
5. The rich `/mcp` endpoint and `/mcp/admin` are untouched. External connections also merged only into `/mcp/agent`.
6. No schema changes, no migrations, no new tables in Phase 1; everything derives from existing plugin-arch tables in `ee/packages/den-db/src/schema/sharables/plugin-arch.ts`.
7. No prompt changes and no tool-description changes in Phase 1; results are self-describing via `summary`, `status`, and `hint`.

Kill switch uses the same layer as connections: turn the `mcpConnections`
feature off for the organization in /admin (or with the `den_set_org_capability`
admin tool).
2. Tool surface unchanged: still exactly `search_capabilities` + `execute_capability`.
3. The desktop install flow was later removed (#2857 stopped desktop installs; the local GitHub import and the install route followed). `apps/server/src/cloud-plugins.ts` now only lists and removes local copies made by older builds.
4. The rich `/mcp` endpoint and `/mcp/admin` are untouched. External connections also merged only into `/mcp/agent`.
5. No schema changes, no migrations, no new tables in Phase 1; everything derives from existing plugin-arch tables in `ee/packages/den-db/src/schema/sharables/plugin-arch.ts`.
6. No prompt changes and no tool-description changes in Phase 1; results are self-describing via `summary`, `status`, and `hint`.

---

## 8. Phase plan

### Phase 1: this document's scope, Den-only, additive

Build the marketplace search source, the execute branch for all classes with honest statuses, the org kill switch, tests, and eval flow. There are zero desktop changes; member-facing Connect rail behavior is default-on unless an org explicitly opts out.
Build the marketplace search source, the execute branch for all classes with honest statuses, tests, and eval flow. There are zero desktop changes; member-facing Connect rail behavior is on for every organization.

Deliverables:

Expand Down Expand Up @@ -330,7 +314,7 @@ Unit tests in `ee/apps/den-api/test/marketplace-capabilities.test.ts`:

Existing contract must keep passing untouched: `evals/flows/mcp-search-capabilities.flow.mjs` asserts exactly two tools.

New eval flow `evals/flows/marketplace-capabilities.flow.mjs`: seed marketplace + plugin + skill config object via the plugin-system API → real chat turn → assert `search_capabilities` returns the `plugin:` match → `execute_capability` returns the skill content → the answer reflects the skill's instructions; control frame proves an opted-out org sees no `plugin:` matches.
New eval flow `evals/flows/marketplace-capabilities.flow.mjs`: seed marketplace + plugin + skill config object via the plugin-system API → real chat turn → assert `search_capabilities` returns the `plugin:` match → `execute_capability` returns the skill content → the answer reflects the skill's instructions.

Test evidence follows `AGENTS.md`: `evals/results/test-runs/<test-run-id>/test-run.json` exists, and every claim is backed by observable assertion evidence or a validated screenshot.

Expand Down
4 changes: 2 additions & 2 deletions docs/opencode-app-parity.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,9 @@ Cloud skills now follow the same v1 path on both engines: the shared Connect met

### Implicit Cloud skill discovery (ENG-691)

`implicitCloudSkills` is a default-off organization rollout on Cloud and self-hosted deployments. When enabled in Den, the discovery index opts its entries into the v2 native skill registry. The existing context plugin refreshes that metadata in the background; setup and prompt admission never wait for the index. A cold first turn may run before the index arrives. Later engine steps see the updated native skill guidance without restarting the conversation.
Implicit Cloud skill discovery is on for every organization on Cloud and self-hosted deployments. The discovery index opts its entries into the v2 native skill registry. The existing context plugin refreshes that metadata in the background; setup and prompt admission never wait for the index. A cold first turn may run before the index arrives. Later engine steps see the updated native skill guidance without restarting the conversation.

Only names, descriptions and retrieval capabilities are held in memory. No skill bodies, credentials or authorization decisions are persisted by this feature. Native entries contain a retrieval note, not copied instructions: the model must load the current body through Connect `get_skill`, which keeps Den's per-request access checks. Removal therefore denies the next body fetch even if old discovery metadata or an earlier body remains in context. Disabling the rollout removes native entries on the next metadata refresh and leaves the existing Connect path intact. V1 is unchanged.
Only names, descriptions and retrieval capabilities are held in memory. No skill bodies, credentials or authorization decisions are persisted by this feature. Native entries contain a retrieval note, not copied instructions: the model must load the current body through Connect `get_skill`, which keeps Den's per-request access checks. Removal therefore denies the next body fetch even if old discovery metadata or an earlier body remains in context. V1 is unchanged.

The inline inventory is bounded to 150 skills and 24,000 metadata characters (descriptions at most 200 characters); larger catalogs remain discoverable through Connect. `SKILL-CLOUD-DISCOVERY` in `opencode-v2-skill-jit.e2e.test.ts` exercises a held first index read, rollout-off behavior, unnamed skill use and removal with stale metadata against the pinned v2 engine and identity-scoped witnesses. This is integration evidence, not a live-model trigger-rate benchmark.

Expand Down
5 changes: 2 additions & 3 deletions docs/org-install-links.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,8 @@ The guided flow is scoped to Den organization install links. The public
required.
3. Restart or roll the deployment. No install-link capability toggle is
required for a normal self-hosted installation.
4. No rollout flag is required: install links are on by default. Configure
`config.public.bootstrapAdminEmails` only if platform admins need `/admin`
access to turn an org dark as a kill switch.
4. No rollout flag is required: install links are available in every
organization.

### Docker Compose

Expand Down
5 changes: 3 additions & 2 deletions docs/quality/analytics-integration.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
# Analytics integration

The `library-usage`, `workflow-run-previews`, and `org-model-analytics`
journeys compare the existing presentation with `analyticsIntegrated` on.
journeys compare the existing presentation with the integrated one, which
comes with `libraryUsage` (it replaced the retired `analyticsIntegrated` flag).
The Library journey also records Members and Connectors in the same workspace.

## Mismatches
Expand All @@ -21,7 +22,7 @@ The Library journey also records Members and Connectors in the same workspace.

## Scope and rollback

`analyticsIntegrated` starts off on cloud and self-hosted. It changes
The integrated presentation follows `libraryUsage`. It changes
presentation only: no counts, collection, consent, entitlements, permissions,
API queries, or persisted history change. Turning it off restores the original
views immediately. Rows keep their existing Library/Connectors destinations;
Expand Down
9 changes: 4 additions & 5 deletions docs/quality/visual-feature-audit.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ claimed. Raw PR material and historical images are not committed here.
| Desktop Calendar | Readable initial hour, bounded creation/details, setup and blocked-state direction | `automation-calendar.e2e.test.ts` |
| Den text and search | Readable sentence-case labels, long workspace heading, clear palette empty state | `den-dashboard-activity.e2e.test.ts`, `den-command-palette.e2e.test.ts` |
| Shared save bars | Painted bottom inset and focus scroll clearance; Remove-only rows stay inline | `permissions-ui.e2e.test.ts`, `ai-gateway-cloud-sign-in.e2e.test.ts` |
| Gateway | Consistent provider filtering, loading/error/empty recovery, access-removal confirmation, searchable team assignment | `litellm-preview-flag.e2e.test.ts`, `ai-gateway-admin-provider.e2e.test.ts`, `ai-gateway-cloud-sign-in.e2e.test.ts`, `ai-gateway-spend-limits.e2e.test.ts` |
| Gateway | Consistent provider filtering, loading/error/empty recovery, access-removal confirmation, searchable team assignment | `ai-gateway-provider-catalog.e2e.test.ts`, `ai-gateway-admin-provider.e2e.test.ts`, `ai-gateway-cloud-sign-in.e2e.test.ts`, `ai-gateway-spend-limits.e2e.test.ts` |
| Deployments | Explicit removal and cancel, consistent lanes, readable pending state, responsive consent | `managed-deployments.e2e.test.ts` |
| Permissions and members | Readable tabs and identity, usable narrow rows, real team-admin controls and denied identities | `permissions-ui.e2e.test.ts`, `team-organization-admin-ui.e2e.test.ts` |
| Analytics | Neutral zero-failure and plan-locked states, contextual reasons, compact filtering | `library-usage.e2e.test.ts`, `workflow-run-previews.e2e.test.ts` |
Expand Down Expand Up @@ -66,10 +66,9 @@ absence. Neither observation workaround weakens the actual UI claim.
before its implementation. It changes only presentation; turning it off
restores the existing header without changing objects, permissions,
navigation, or running work. No production rollout is changed by this audit.
- `auditLogsCompact` is declared **off** on cloud and self-hosted. It changes
only audit presentation, through Den's resolved feature map. Turning it off
restores the previous operation table and capture settings; recording,
retained evidence, filters, redaction and permission checks remain unchanged.
- Compact audit rows are now the only audit presentation (the former
`auditLogsCompact` flag is retired). Recording, retained evidence, filters,
redaction and permission checks are unchanged.
- Other repairs preserve their existing feature gates and server contracts.
- `Cloud default` is the organization's cloud-agent default, not the free
`Auto` model. Explicit Automation model choices remain intact.
Expand Down
6 changes: 2 additions & 4 deletions docs/session-offloading-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,8 @@ remote session lifecycle.
- Standalone `opencode-openwork` uses the existing approved OpenWork account.
It requires explicit `remoteSessions: true`; signing in for models or MCP
connections alone does not authorize remote control.
- `remoteSessionTargets` is off by default for Cloud and self-hosted installs.
Den's ordinary feature resolver controls admission; no second rollout system.
- Registered offloading is available on Cloud and self-hosted installs; there
is no rollout switch.
- Runner tokens are time-limited, audience-bound credentials. Every runner
request rechecks the owner's active membership. Credentials are not stored
in execution journals.
Expand Down Expand Up @@ -121,8 +121,6 @@ context is not a session error. Native status events are live hints, not a
replayable source of truth. Recovered observation uses native session/context
and permission state rather than an in-memory event alone.

Turning off `remoteSessionTargets` prevents new registered offloads/turns. It
must not hide existing receipts or prevent stopping an admitted session.
Runner unload/account switch stops its control loop and releases ownership;
normal native permission handling is never bypassed.

Expand Down
Loading
Loading