open-managed-agents 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. package/dist/scripts/alpha-smoke.mjs +40 -2
  2. package/dist/scripts/oma-doctor.js +1 -1
  3. package/dist/scripts/oma-models.js +20 -16
  4. package/dist/src/control-plane/app.js +75 -4
  5. package/dist/src/control-plane/events/service.js +82 -8
  6. package/dist/src/control-plane/models/catalog.js +29 -13
  7. package/dist/src/control-plane/models/credential-store.js +203 -0
  8. package/dist/src/control-plane/sessions/pi/mcp/client.js +5 -1
  9. package/dist/src/control-plane/sessions/pi/runner.js +59 -32
  10. package/dist/src/control-plane/sessions/pi/sandbox/container-engine.js +32 -0
  11. package/dist/src/control-plane/sessions/pi/sandbox/docker-egress.js +8 -0
  12. package/dist/src/control-plane/sessions/pi/sandbox/docker.js +20 -5
  13. package/dist/src/control-plane/sessions/pi/sandbox/microsandbox.js +18 -3
  14. package/dist/src/control-plane/sessions/pi/sandbox/provider.js +16 -9
  15. package/dist/src/control-plane/sessions/service.js +5 -0
  16. package/dist/src/control-plane/vaults/console-mcp-oauth.js +545 -0
  17. package/dist/src/control-plane/vaults/service.js +4 -1
  18. package/dist/src/control-plane/vaults/store.js +8 -2
  19. package/dist/src/main.js +1 -1
  20. package/dist/ui/managed-agents-console/README.md +22 -15
  21. package/dist/ui/managed-agents-console/docs/vaults.md +16 -2
  22. package/dist/ui/managed-agents-console/index.html +4 -0
  23. package/dist/ui/managed-agents-console/src/api.js +40 -0
  24. package/dist/ui/managed-agents-console/src/console.css +4 -1
  25. package/dist/ui/managed-agents-console/src/forms.jsx +79 -16
  26. package/dist/ui/managed-agents-console/src/session-vaults-data.js +40 -0
  27. package/dist/ui/managed-agents-console/src/vaults.jsx +104 -9
  28. package/package.json +7 -7
@@ -14,24 +14,31 @@ hosts.
14
14
 
15
15
  ## Logging in
16
16
 
17
- The console asks for a key on load (unless the server runs with
18
- `OMA_AUTH_MODE=disabled`, in which case it browses `wrk_default` directly):
19
-
20
- - **Workspace key** (`oma_…`, e.g. the one printed on first boot) — read-only
21
- browsing of that workspace's agents, sessions, events, spans, and files,
22
- including authenticated file downloads.
17
+ The normal `oma onboard` path opens the loopback console through a short-lived,
18
+ single-use bootstrap nonce and establishes an opaque HttpOnly browser session;
19
+ no raw workspace key enters the URL or browser storage. A user can also enter a
20
+ workspace key manually when no console session exists. If the server runs with
21
+ `OMA_AUTH_MODE=disabled`, it browses `wrk_default` directly.
22
+
23
+ - **Workspace key** (`oma_…`) — workspace-scoped browsing and the console's
24
+ narrowly allowlisted agent, environment, session, vault, credential, and
25
+ skill workflows, including authenticated event streams and file downloads.
23
26
  - **Admin key** (`OMA_ADMIN_KEY`) — everything above plus the Admin panel:
24
27
  create workspaces, mint/list/revoke API keys. Minted plaintext is shown
25
28
  once, with copy and a "browse as this workspace" shortcut. Setup:
26
29
  [dev-deployment.md](../../docs/dev-deployment.md#the-admin-api-and-console-admin-mode).
27
30
 
28
- Keys live in page memory only — never `localStorage`, `sessionStorage`, or a
29
- cookie (enforced by `src/control-plane/__tests__/console-security.test.ts`).
30
- A reload asks again.
31
+ Raw keys are used only for the same-origin login exchange and are never stored
32
+ in `localStorage` or `sessionStorage`. The server returns an opaque HttpOnly
33
+ cookie whose authority is revalidated against key revocation. Reloading keeps
34
+ the console session until it expires, is revoked, or the user signs out.
31
35
 
32
- Admin mutations (workspace/key CRUD) are live. `/v1` mutations — create
33
- agent/session, send message, interrupt, archive — remain deliberately
34
- disabled in this slice.
36
+ Admin workspace/key CRUD is live. The workspace console also supports the
37
+ reviewed mutation set required for the single-agent flow: agent creation and
38
+ versioning/archive, environment creation/archive/delete, session creation,
39
+ prompt/interrupt/confirmation and idle archive/delete, vault/credential
40
+ creation, MCP validation, and custom-skill upload. Arbitrary `/v1` writes remain
41
+ blocked by narrow client-side capability wrappers and server authorization.
35
42
 
36
43
  ## Dev server (optional)
37
44
 
@@ -47,6 +54,6 @@ expose it as a shared gateway; it forwards browser request headers to the
47
54
  configured API. Admin mode needs same-origin `/admin` routes, so use the
48
55
  appliance-served `/console` for that.
49
56
 
50
- Demo review mode with bundled fake data: append `?mode=demo` to either URL.
51
- If the API is unreachable, the console falls back to the same demo data with
52
- a warning banner.
57
+ Demo review mode with bundled fake data is explicit: append `?mode=demo` to
58
+ either URL. A failed live API request never falls back to demo data; the console
59
+ clears live rows and renders a retryable error state.
@@ -6,14 +6,28 @@
6
6
 
7
7
  Vaults organize workspace-scoped credentials. The console can show vault and credential metadata and health without rendering secret values. The API supports vault and credential lifecycle operations, including archive and delete where the server permits them.
8
8
 
9
+ ## Connect an OAuth MCP server
10
+
11
+ OAuth connections require MCP support to be enabled on the appliance (`OMA_ENABLE_MCP=true`) and a configured secrets master key. In the console:
12
+
13
+ 1. Open **Vaults**, create or select a vault, and choose **Add credential**.
14
+ 2. Leave **Connect with OAuth** selected and enter the exact URL used in the agent's `mcp_servers` configuration, such as `https://mcp.notion.com/mcp`.
15
+ 3. Choose **Connect** and finish authorization in the provider window.
16
+
17
+ The browser receives an authorization URL and opaque flow status only. Access tokens, refresh tokens, client secrets, and authorization codes are exchanged and encrypted by the appliance; they are never displayed or pasted into the console.
18
+
19
+ This guided path supports URL-based, standards-compliant MCP servers that publish OAuth discovery metadata and either dynamic client registration or an existing reusable registration. A provider that lacks those capabilities produces an explicit unsupported-provider error. OMA does not ship a third-party MCP marketplace.
20
+
9
21
  ## Use a credential in a session
10
22
 
11
23
  Attach the needed vault at session creation and configure the compatible MCP server on the agent. OMA resolves supported authentication inside the connector path; the sandbox does not receive a browser-visible plaintext secret.
12
24
 
13
25
  ## Supported and deferred types
14
26
 
15
- OMA supports the shipped static bearer and MCP OAuth flows, including validation where configured. Environment-variable substitution and arbitrary secret injection are not current public credential types.
27
+ OMA supports static bearer credentials and guided MCP OAuth, including validation and automatic refresh where the provider issues a refresh token. Static bearer remains available for servers that intentionally use a long-lived token. Environment-variable substitution and arbitrary secret injection are not current public credential types.
16
28
 
17
29
  ## Rotation and failures
18
30
 
19
- Archive, update, or replace credentials through their real lifecycle actions. If an OAuth refresh or connector call fails, inspect the resulting session events and credential health; OMA does not claim that a credential is usable merely because its metadata exists.
31
+ Use **Validate** to probe the MCP server, and **Reauthorize** to renew a refreshable OAuth connection without creating a duplicate credential. **Disconnect** archives the OMA credential immediately; it does not revoke the provider-side grant, which must be revoked with the provider when required.
32
+
33
+ If an OAuth refresh or connector call fails, inspect the validation result, session events, and credential health. OMA does not claim that a credential is usable merely because its metadata exists.
@@ -22,6 +22,10 @@
22
22
  import * as VaultsData from './src/vaults-data.js';
23
23
  window.VaultsData = VaultsData;
24
24
  </script>
25
+ <script type="module">
26
+ import * as SessionVaultsData from './src/session-vaults-data.js';
27
+ window.SessionVaultsData = SessionVaultsData;
28
+ </script>
25
29
  <script type="text/babel" src="src/icons.jsx"></script>
26
30
  <script type="text/babel" src="src/tweaks-panel.jsx"></script>
27
31
  <script type="text/babel" src="src/ui.jsx"></script>
@@ -24,6 +24,7 @@ const ARCHIVE_SESSION_CAPABILITY = Symbol("archive-session");
24
24
  const DELETE_SESSION_CAPABILITY = Symbol("delete-session");
25
25
  const CREATE_VAULT_CAPABILITY = Symbol("create-vault");
26
26
  const CREATE_VAULT_CREDENTIAL_CAPABILITY = Symbol("create-vault-credential");
27
+ const ARCHIVE_VAULT_CREDENTIAL_CAPABILITY = Symbol("archive-vault-credential");
27
28
  const CREATE_SKILL_CAPABILITY = Symbol("create-skill");
28
29
  const CMA_BUILTIN_TOOL_NAMES = [
29
30
  "bash",
@@ -167,6 +168,11 @@ function isAllowedWorkspaceWrite(path, method, capability) {
167
168
  method === "POST" &&
168
169
  /^\/v1\/vaults\/[^/]+\/credentials$/.test(new URL(path, "http://oma.local").pathname)
169
170
  ) ||
171
+ (
172
+ capability === ARCHIVE_VAULT_CREDENTIAL_CAPABILITY &&
173
+ method === "POST" &&
174
+ /^\/v1\/vaults\/[^/]+\/credentials\/[^/]+\/archive$/.test(new URL(path, "http://oma.local").pathname)
175
+ ) ||
170
176
  (capability === CREATE_SKILL_CAPABILITY && method === "POST" && path === "/v1/skills") ||
171
177
  (
172
178
  capability === ARCHIVE_AGENT_CAPABILITY &&
@@ -266,6 +272,36 @@ export function createVaultCredential(vaultId, body) {
266
272
  });
267
273
  }
268
274
 
275
+ export function archiveVaultCredential(vaultId, credentialId) {
276
+ const path = `/v1/vaults/${encodeURIComponent(vaultId)}/credentials/${encodeURIComponent(credentialId)}/archive`;
277
+ return request(path, {
278
+ method: "POST",
279
+ capability: ARCHIVE_VAULT_CREDENTIAL_CAPABILITY,
280
+ });
281
+ }
282
+
283
+ export function startMcpOauthFlow(vaultId, displayName, mcpServerUrl) {
284
+ return request("/console/mcp-oauth/flows", {
285
+ method: "POST",
286
+ body: {
287
+ vault_id: vaultId,
288
+ ...(displayName ? { display_name: displayName } : {}),
289
+ mcp_server_url: mcpServerUrl,
290
+ },
291
+ });
292
+ }
293
+
294
+ export function reauthorizeMcpOauthCredential(vaultId, credentialId) {
295
+ return request("/console/mcp-oauth/reauthorize", {
296
+ method: "POST",
297
+ body: { vault_id: vaultId, credential_id: credentialId },
298
+ });
299
+ }
300
+
301
+ export function getMcpOauthFlow(flowId) {
302
+ return request(`/console/mcp-oauth/flows/${encodeURIComponent(flowId)}`);
303
+ }
304
+
269
305
  export function listSkills() {
270
306
  return fetchCursorPages("/v1/skills");
271
307
  }
@@ -1025,6 +1061,10 @@ if (typeof window !== "undefined") {
1025
1061
  listVaultCredentials,
1026
1062
  createVault,
1027
1063
  createVaultCredential,
1064
+ archiveVaultCredential,
1065
+ startMcpOauthFlow,
1066
+ reauthorizeMcpOauthCredential,
1067
+ getMcpOauthFlow,
1028
1068
  listSkills,
1029
1069
  createSkill,
1030
1070
  listWorkspaceCredentialHealth,
@@ -636,7 +636,7 @@ a.dl { text-decoration:none; }
636
636
  .modal-head .x { margin-left:auto; }
637
637
  .modal-body { padding:18px 20px; display:flex; flex-direction:column; gap:16px; max-height:60vh; overflow:auto; }
638
638
  .modal-foot { display:flex; align-items:center; gap:10px; justify-content:flex-end; padding:14px 20px; border-top:1px solid var(--border); }
639
- .modal-foot .left { margin-right:auto; color:var(--faint); font-size:12px; }
639
+ .modal-foot .left { min-width:0; margin-right:auto; color:var(--faint); font-size:12px; overflow-wrap:anywhere; }
640
640
 
641
641
  .form-row { display:flex; flex-direction:column; gap:7px; }
642
642
  .form-row.two { display:grid; grid-template-columns:1fr 1fr; gap:14px; }
@@ -657,6 +657,7 @@ a.dl { text-decoration:none; }
657
657
  .tool-toggle { appearance:none; font-family:inherit; display:inline-flex; align-items:center; gap:7px; padding:6px 11px; border-radius:999px; border:1px solid var(--border); background:var(--surface-2); color:var(--soft); font-size:12.5px; font-weight:500; cursor:pointer; transition:.12s; }
658
658
  .tool-toggle:hover { border-color:var(--border-2); color:var(--ink); }
659
659
  .tool-toggle.on { background:var(--accent-wash); border-color:var(--accent-line); color:var(--accent); }
660
+ .tool-toggle:disabled { cursor:not-allowed; opacity:.45; border-color:var(--border); color:var(--faint); }
660
661
  .tool-toggle .chk { width:13px; height:13px; border-radius:4px; border:1.5px solid currentColor; opacity:.55; display:flex; align-items:center; justify-content:center; }
661
662
  .tool-toggle.on .chk { background:var(--accent); border-color:var(--accent); opacity:1; }
662
663
  .radio-set { display:flex; flex-direction:column; gap:8px; }
@@ -697,6 +698,8 @@ a.dl { text-decoration:none; }
697
698
  /* small confirm dialog */
698
699
  .modal.sm { max-width:420px; }
699
700
  .modal.sm .modal-body { gap:0; }
701
+ .modal.sm .modal-foot { flex-wrap:wrap; }
702
+ .modal.sm .modal-foot .left { flex:1 0 100%; margin-right:0; }
700
703
  .confirm-msg { font-size:13.5px; color:var(--soft); line-height:1.6; }
701
704
  .confirm-msg b { color:var(--ink); font-weight:600; }
702
705
  .modal-head .ic.danger { background:var(--red-wash); color:var(--red); }
@@ -44,6 +44,7 @@ function CreateSession({ agents = AGENTS, environments = ENVIRONMENTS, vaults =
44
44
  const [title, setTitle] = useStateF('');
45
45
  const [msg, setMsg] = useStateF('');
46
46
  const [vaultIds, setVaultIds] = useStateF([]);
47
+ const [vaultCredentialStates, setVaultCredentialStates] = useStateF({});
47
48
  const [busy, setBusy] = useStateF(false);
48
49
  const [error, setError] = useStateF('');
49
50
  const [partial, setPartial] = useStateF(null);
@@ -53,8 +54,17 @@ function CreateSession({ agents = AGENTS, environments = ENVIRONMENTS, vaults =
53
54
  const agent = agents.find((a) => a.id === agentId);
54
55
  const usingCustom = env === '__custom';
55
56
  const finalEnv = usingCustom ? customEnv.trim() : env;
56
- const valid = !!agentId && !!finalEnv && !busy;
57
57
  const live = apiMode === 'api';
58
+ const selectedVaultCompatibility = vaultIds.map((vaultId) => ({
59
+ vault: vaults.find((item) => item.id === vaultId),
60
+ result: window.SessionVaultsData.vaultCompatibility(agent, vaultCredentialStates[vaultId]),
61
+ }));
62
+ const incompatibleVaults = selectedVaultCompatibility.filter(({ result }) => result.status === 'incompatible');
63
+ const unavailableVaults = selectedVaultCompatibility.filter(({ result }) => result.status === 'error');
64
+ const loadingVaults = selectedVaultCompatibility.filter(({ result }) => result.status === 'loading');
65
+ const agentHasMcp = window.SessionVaultsData.agentMcpServerUrls(agent).length > 0;
66
+ const vaultsReady = !live || selectedVaultCompatibility.every(({ result }) => result.compatible);
67
+ const valid = !!agentId && !!finalEnv && !busy && vaultsReady;
58
68
  const initialMessageEvent = () => ({
59
69
  type: 'user.message',
60
70
  content: [{ type: 'text', text: msg.trim() }],
@@ -69,6 +79,34 @@ function CreateSession({ agents = AGENTS, environments = ENVIRONMENTS, vaults =
69
79
  created:'Just now', updated:'Just now', dur:'—', tokens:'0 / 0', resources:0,
70
80
  });
71
81
 
82
+ const toggleVault = async (vault) => {
83
+ const selected = vaultIds.includes(vault.id);
84
+ if (selected) {
85
+ setVaultIds((current) => current.filter((id) => id !== vault.id));
86
+ return;
87
+ }
88
+ if (live && !agentHasMcp) return;
89
+ setVaultIds((current) => [...current, vault.id]);
90
+ if (!live || vaultCredentialStates[vault.id]) return;
91
+ setVaultCredentialStates((current) => ({
92
+ ...current,
93
+ [vault.id]: { status:'loading', credentials:[] },
94
+ }));
95
+ try {
96
+ const page = await api.listVaultCredentials(vault.id);
97
+ setVaultCredentialStates((current) => ({
98
+ ...current,
99
+ [vault.id]: { status:'loaded', credentials:page.data },
100
+ }));
101
+ } catch (credentialError) {
102
+ setVaultCredentialStates((current) => ({
103
+ ...current,
104
+ [vault.id]: { status:'error', credentials:[], error:credentialError.message || 'Request failed' },
105
+ }));
106
+ if (credentialError.status === 401 && onAuthExpired) onAuthExpired();
107
+ }
108
+ };
109
+
72
110
  const submit = async () => {
73
111
  if (!valid) return;
74
112
  setBusy(true);
@@ -169,12 +207,27 @@ function CreateSession({ agents = AGENTS, environments = ENVIRONMENTS, vaults =
169
207
  {vaults.length === 0 ? <div className="field-hint">No active vaults in this workspace.</div>
170
208
  : <div className="seg-tools">{vaults.filter((vault) => !vault.archived_at).map((vault) => {
171
209
  const selected = vaultIds.includes(vault.id);
210
+ const compatibility = live && vaultCredentialStates[vault.id]
211
+ ? window.SessionVaultsData.vaultCompatibility(agent, vaultCredentialStates[vault.id])
212
+ : null;
213
+ const disabled = live && (!agentHasMcp || (!selected && compatibility && !compatibility.compatible));
214
+ const title = !agentHasMcp
215
+ ? 'The selected agent declares no MCP servers.'
216
+ : compatibility?.status === 'incompatible'
217
+ ? 'No active credential exactly matches this agent’s MCP server URLs.'
218
+ : compatibility?.status === 'error'
219
+ ? 'Credential compatibility could not be verified.'
220
+ : undefined;
172
221
  return <button type="button" key={vault.id} className={'tool-toggle' + (selected ? ' on' : '')} aria-pressed={selected}
173
- onClick={() => setVaultIds((current) => selected ? current.filter((id) => id !== vault.id) : [...current, vault.id])}>
222
+ disabled={disabled} title={title} onClick={() => toggleVault(vault)}>
174
223
  <span className="chk">{selected && <Icon name="checkCircle" size={9} />}</span><span>{vault.display_name}</span>
175
224
  </button>;
176
225
  })}</div>}
177
226
  </Labeled>
227
+ {live && vaults.length > 0 && !agentHasMcp && <div className="inline-warn" role="status"><Icon name="alert" size={14} /><span>The selected agent declares no MCP servers. Choose an MCP-enabled agent before attaching a credential vault.</span></div>}
228
+ {live && loadingVaults.length > 0 && <div className="inline-note" role="status"><Icon name="info" size={14} /><span>Checking whether the selected vault contains a credential matching this agent’s MCP server URLs…</span></div>}
229
+ {live && incompatibleVaults.length > 0 && <div className="inline-warn" role="alert"><Icon name="alert" size={14} /><span>{incompatibleVaults.map(({ vault }) => vault?.display_name || vault?.id).join(', ')} has no active credential whose server URL exactly matches this agent. Choose a compatible vault or update the agent’s MCP server URL.</span></div>}
230
+ {live && unavailableVaults.length > 0 && <div className="inline-warn" role="alert"><Icon name="alert" size={14} /><span>Could not verify credentials in {unavailableVaults.map(({ vault }) => vault?.display_name || vault?.id).join(', ')}. Remove the vault or retry after reconnecting the workspace.</span></div>}
178
231
 
179
232
  <Labeled label="Title" htmlFor="create-session-title" opt hint="Defaults to the first message if left blank.">
180
233
  <input id="create-session-title" name="title" className="input" placeholder="e.g. Ship your first Managed Agent" value={title} onChange={(e) => setTitle(e.target.value)} />
@@ -213,35 +266,45 @@ function CreateVaultModal({ onClose, onCreated, onAuthExpired, apiMode = 'demo',
213
266
 
214
267
  function CreateVaultCredentialModal({ vault, onClose, onCreated, onAuthExpired, apiMode = 'demo', api = window.OmaConsoleApi }) {
215
268
  const [displayName, setDisplayName] = useStateF('');
216
- const [type, setType] = useStateF('static_bearer');
269
+ const [type, setType] = useStateF('mcp_oauth');
217
270
  const [serverUrl, setServerUrl] = useStateF('');
218
271
  const [token, setToken] = useStateF('');
219
- const [expiresAt, setExpiresAt] = useStateF('');
220
272
  const [busy, setBusy] = useStateF(false);
221
273
  const [error, setError] = useStateF('');
222
- const valid = /^https?:\/\//.test(serverUrl.trim()) && token.trim().length >= 8 && !busy;
274
+ const validUrl = /^https?:\/\//.test(serverUrl.trim());
275
+ const valid = validUrl && (type === 'mcp_oauth' || token.trim().length >= 8) && !busy;
223
276
  const submit = async () => {
224
277
  if (!valid) return;
225
278
  setBusy(true); setError('');
226
- const auth = type === 'static_bearer'
227
- ? { type, mcp_server_url:serverUrl.trim(), token:token.trim() }
228
- : { type, mcp_server_url:serverUrl.trim(), access_token:token.trim(), ...(expiresAt.trim() ? { expires_at:expiresAt.trim() } : {}) };
229
279
  try {
280
+ if (type === 'mcp_oauth') {
281
+ if (apiMode !== 'api') {
282
+ onCreated({ id:`vcrd_demo_${Date.now()}`, vault_id:vault.id, display_name:displayName.trim() || null, auth:{ type, mcp_server_url:serverUrl.trim() } });
283
+ return;
284
+ }
285
+ const status = await runMcpOauthPopup(
286
+ () => api.startMcpOauthFlow(vault.id, displayName.trim(), serverUrl.trim()),
287
+ api,
288
+ );
289
+ onCreated({ id:status.credential_id, vault_id:vault.id });
290
+ return;
291
+ }
292
+ const auth = { type, mcp_server_url:serverUrl.trim(), token:token.trim() };
230
293
  if (apiMode !== 'api') { onCreated({ id:`vcrd_demo_${Date.now()}`, vault_id:vault.id, display_name:displayName.trim() || null, auth }); return; }
231
294
  onCreated(await api.createVaultCredential(vault.id, { ...(displayName.trim() ? { display_name:displayName.trim() } : {}), auth }));
232
295
  } catch (createError) {
233
- setError(createError.message || 'Credential creation failed.');
296
+ setError(createError.message || 'Credential connection failed.');
234
297
  if (createError.status === 401 && onAuthExpired) onAuthExpired();
235
298
  } finally { setBusy(false); }
236
299
  };
237
- return <Modal icon="database" title="Add credential" sub={`Write-only credential for ${vault.display_name || vault.displayName}.`} onClose={onClose}
238
- footer={<><span className="left">Sends to <span className="mono">POST /v1/vaults/:id/credentials</span></span><button className="btn" onClick={onClose}>Cancel</button><button className="btn btn-primary" disabled={!valid} onClick={submit}>{busy ? 'Saving…' : 'Save credential'}</button></>}>
300
+ const endpoint = type === 'mcp_oauth' ? 'POST /console/mcp-oauth/flows' : 'POST /v1/vaults/:id/credentials';
301
+ return <Modal icon="database" title="Add credential" sub={`Authorize an MCP server for ${vault.display_name || vault.displayName}.`} onClose={onClose}
302
+ footer={<><span className="left">Sends to <span className="mono">{endpoint}</span></span><button className="btn" onClick={onClose}>Cancel</button><button className="btn btn-primary" disabled={!valid} onClick={submit}>{busy ? (type === 'mcp_oauth' ? 'Connecting…' : 'Saving…') : (type === 'mcp_oauth' ? 'Connect' : 'Save credential')}</button></>}>
239
303
  <Labeled label="Credential name" htmlFor="credential-name" opt><input id="credential-name" className="input" value={displayName} onChange={(event) => setDisplayName(event.target.value)} /></Labeled>
240
- <Labeled label="Credential type" htmlFor="credential-type"><select id="credential-type" className="selectbox" value={type} onChange={(event) => setType(event.target.value)}><option value="static_bearer">Static bearer token</option><option value="mcp_oauth">MCP OAuth access token</option></select></Labeled>
241
- <Labeled label="MCP server URL" htmlFor="credential-server-url" hint="This must exactly match the URL configured on the agent."><input id="credential-server-url" className="input mono" placeholder="https://mcp.example.com/mcp" value={serverUrl} onChange={(event) => setServerUrl(event.target.value)} /></Labeled>
242
- <Labeled label={type === 'static_bearer' ? 'Bearer token' : 'OAuth access token'} htmlFor="credential-token" hint="Write-only. OMA never returns or renders this value after it is submitted."><input id="credential-token" type="password" className="input mono" autoComplete="off" value={token} onChange={(event) => setToken(event.target.value)} /></Labeled>
243
- {type === 'mcp_oauth' && <Labeled label="Expires at" htmlFor="credential-expires" opt hint="ISO-8601 timestamp. Leave blank only for a non-expiring access token."><input id="credential-expires" className="input mono" placeholder="2027-01-01T00:00:00Z" value={expiresAt} onChange={(event) => setExpiresAt(event.target.value)} /></Labeled>}
244
- {type === 'mcp_oauth' && <div className="field-hint">Use the API for OAuth refresh-token and client-secret configuration; this initial console path safely supports access-token credentials and validation.</div>}
304
+ <Labeled label="Credential type" htmlFor="credential-type"><select id="credential-type" className="selectbox" value={type} onChange={(event) => setType(event.target.value)}><option value="mcp_oauth">Connect with OAuth</option><option value="static_bearer">Static bearer token</option></select></Labeled>
305
+ <Labeled label="MCP server URL" htmlFor="credential-server-url" hint="Use the URL configured on the agent, for example https://mcp.notion.com/mcp."><input id="credential-server-url" className="input mono" placeholder="https://mcp.example.com/mcp" value={serverUrl} onChange={(event) => setServerUrl(event.target.value)} /></Labeled>
306
+ {type === 'static_bearer' && <Labeled label="Bearer token" htmlFor="credential-token" hint="Write-only. OMA never returns or renders this value after it is submitted."><input id="credential-token" type="password" className="input mono" autoComplete="off" value={token} onChange={(event) => setToken(event.target.value)} /></Labeled>}
307
+ {type === 'mcp_oauth' && <div className="field-hint">Connect opens the provider in a new window. OMA stores the resulting credential and refreshes it automatically; the browser never receives an access or refresh token.</div>}
245
308
  {error && <div className="inline-warn" role="alert"><Icon name="alert" size={14} /><span>{error}</span></div>}
246
309
  </Modal>;
247
310
  }
@@ -0,0 +1,40 @@
1
+ export function agentMcpServerUrls(agent) {
2
+ return uniqueStrings(
3
+ (Array.isArray(agent?.mcpServers) ? agent.mcpServers : [])
4
+ .map((server) => server?.url),
5
+ );
6
+ }
7
+
8
+ export function credentialMcpServerUrls(credentials) {
9
+ return uniqueStrings(
10
+ (Array.isArray(credentials) ? credentials : [])
11
+ .filter((credential) => !credential?.archived_at)
12
+ .map((credential) => credential?.auth?.mcp_server_url),
13
+ );
14
+ }
15
+
16
+ export function vaultCompatibility(agent, credentialState) {
17
+ const agentUrls = agentMcpServerUrls(agent);
18
+ if (agentUrls.length === 0) {
19
+ return { status:"agent_without_mcp", compatible:false, agentUrls, credentialUrls:[] };
20
+ }
21
+ if (!credentialState || credentialState.status === "loading") {
22
+ return { status:"loading", compatible:false, agentUrls, credentialUrls:[] };
23
+ }
24
+ if (credentialState.status === "error") {
25
+ return { status:"error", compatible:false, agentUrls, credentialUrls:[] };
26
+ }
27
+ const credentialUrls = credentialMcpServerUrls(credentialState.credentials);
28
+ const matchingUrls = credentialUrls.filter((url) => agentUrls.includes(url));
29
+ return {
30
+ status: matchingUrls.length > 0 ? "compatible" : "incompatible",
31
+ compatible: matchingUrls.length > 0,
32
+ agentUrls,
33
+ credentialUrls,
34
+ matchingUrls,
35
+ };
36
+ }
37
+
38
+ function uniqueStrings(values) {
39
+ return [...new Set(values.filter((value) => typeof value === "string" && value.length > 0))];
40
+ }
@@ -3,6 +3,45 @@ const { useState: useVaultState, useEffect: useVaultEffect, useRef: useVaultRef
3
3
  const DEMO_VAULTS = [{ id: 'vlt_demo', display_name: 'Demo integrations', created_at: '2026-07-01T00:00:00Z', archived_at: null }];
4
4
  const DEMO_CREDENTIALS = [{ id: 'vcrd_demo', display_name: 'Demo MCP OAuth', archived_at: null, auth: { type: 'mcp_oauth', mcp_server_url: 'https://mcp.example.test/mcp', expires_at: '2026-12-01T00:00:00Z', refresh: { token_endpoint: 'https://auth.example.test/token', scope: 'read', token_endpoint_auth: { type: 'none' } } } }];
5
5
 
6
+ async function runMcpOauthPopup(startFlow, api = window.OmaConsoleApi) {
7
+ const popup = window.open('', 'oma-mcp-oauth', 'popup,width=720,height=760');
8
+ if (!popup) throw new Error('The authorization window was blocked. Allow popups for this console and try again.');
9
+ let flowId = null;
10
+ let wakePoll = null;
11
+ const onMessage = (event) => {
12
+ if (event.origin !== window.location.origin || event.data?.type !== 'oma:mcp-oauth') return;
13
+ if (flowId !== null && event.data.flowId !== flowId) return;
14
+ wakePoll?.();
15
+ };
16
+ window.addEventListener('message', onMessage);
17
+ try {
18
+ const started = await startFlow();
19
+ flowId = started.flow_id;
20
+ popup.location.replace(started.authorization_url);
21
+ const expiresAt = Date.parse(started.expires_at);
22
+ while (Date.now() < expiresAt) {
23
+ const status = await api.getMcpOauthFlow(started.flow_id);
24
+ if (status.status === 'connected') { popup.close(); return status; }
25
+ if (status.status === 'failed' || status.status === 'expired') {
26
+ popup.close();
27
+ throw new Error(status.error?.message || 'The provider did not complete authorization.');
28
+ }
29
+ if (popup.closed) throw new Error('The authorization window was closed before the connection completed.');
30
+ await new Promise((resolve) => {
31
+ const timer = setTimeout(resolve, 750);
32
+ wakePoll = () => { clearTimeout(timer); resolve(); };
33
+ });
34
+ wakePoll = null;
35
+ }
36
+ throw new Error('The authorization window expired. Start Connect again.');
37
+ } catch (error) {
38
+ popup.close();
39
+ throw error;
40
+ } finally {
41
+ window.removeEventListener('message', onMessage);
42
+ }
43
+ }
44
+
6
45
  function ToneBadge({ tone, children }) {
7
46
  return <span className={'badge ' + VaultsData.toneBadgeClass(tone)}><i className="dot" />{children}</span>;
8
47
  }
@@ -17,12 +56,14 @@ function VaultsView({ mode, initialVaultId, onCreate, onCreateCredential, readOn
17
56
  const [confirm, setConfirm] = useVaultState(null);
18
57
  const [validation, setValidation] = useVaultState(null);
19
58
  const [validating, setValidating] = useVaultState(false);
59
+ const [oauthBusy, setOauthBusy] = useVaultState(null);
60
+ const [oauthNotice, setOauthNotice] = useVaultState(null);
20
61
  const epoch = useVaultRef(0);
21
62
  const selectedId = selected?.id || null;
22
63
 
23
64
  const refresh = () => {
24
65
  const current = ++epoch.current;
25
- setError(null); setVaults(null); setSelected(null); setCredentials(null); setWarning(null);
66
+ setError(null); setVaults(null); setSelected(null); setCredentials(null); setWarning(null); setOauthNotice(null);
26
67
  if (mode !== 'api') { setVaults(DEMO_VAULTS.map(VaultsData.vaultRow)); return; }
27
68
  OmaConsoleApi.listVaults().then((page) => {
28
69
  if (current !== epoch.current) return;
@@ -38,7 +79,7 @@ function VaultsView({ mode, initialVaultId, onCreate, onCreateCredential, readOn
38
79
  selectedRef.current = selectedId;
39
80
  const open = (vault) => {
40
81
  const current = ++epoch.current;
41
- selectedRef.current = vault.id; setSelected(vault); setCredentials(null); setDetailError(null); setValidation(null); setValidating(false); setWarning(null);
82
+ selectedRef.current = vault.id; setSelected(vault); setCredentials(null); setDetailError(null); setValidation(null); setValidating(false); setOauthBusy(null); setOauthNotice(null); setWarning(null);
42
83
  if (mode !== 'api') { setCredentials(DEMO_CREDENTIALS.map(VaultsData.credentialRow)); return; }
43
84
  OmaConsoleApi.listVaultCredentials(vault.id).then((page) => {
44
85
  if (!VaultsData.isCurrentVaultResult(current, epoch.current, vault.id, selectedRef.current)) return;
@@ -46,7 +87,7 @@ function VaultsView({ mode, initialVaultId, onCreate, onCreateCredential, readOn
46
87
  if (page.truncated) setWarning(VaultsData.truncationWarning('credentials'));
47
88
  }).catch((err) => { if (VaultsData.isCurrentVaultResult(current, epoch.current, vault.id, selectedRef.current)) setDetailError(err); });
48
89
  };
49
- const back = () => { ++epoch.current; setSelected(null); setCredentials(null); setValidation(null); setValidating(false); setWarning(null); onBackToVaults(); };
90
+ const back = () => { ++epoch.current; setSelected(null); setCredentials(null); setValidation(null); setValidating(false); setOauthBusy(null); setOauthNotice(null); setWarning(null); onBackToVaults(); };
50
91
  const validate = (credential) => {
51
92
  // Guard the result the same way the list/detail fetches are guarded: a
52
93
  // probe can take seconds, and the operator can navigate to another vault
@@ -61,6 +102,49 @@ function VaultsView({ mode, initialVaultId, onCreate, onCreateCredential, readOn
61
102
  .catch((err) => { if (isCurrent()) setValidation({ error: err.message }); })
62
103
  .finally(() => { if (isCurrent()) setValidating(false); });
63
104
  };
105
+ const reauthorize = async (credential) => {
106
+ const startedEpoch = epoch.current;
107
+ const vaultId = selected.id;
108
+ const isCurrent = () => VaultsData.isCurrentVaultResult(startedEpoch, epoch.current, vaultId, selectedRef.current);
109
+ setOauthBusy(credential.id); setOauthNotice(null);
110
+ try {
111
+ if (mode === 'demo') {
112
+ setOauthNotice({ tone:'ok', message:'Demo OAuth connection renewed.' });
113
+ return;
114
+ }
115
+ await runMcpOauthPopup(
116
+ () => OmaConsoleApi.reauthorizeMcpOauthCredential(vaultId, credential.id),
117
+ );
118
+ const page = await OmaConsoleApi.listVaultCredentials(vaultId);
119
+ if (isCurrent()) {
120
+ setCredentials(page.data.map(VaultsData.credentialRow));
121
+ setOauthNotice({ tone:'ok', message:'OAuth connection renewed. Automatic refresh remains enabled.' });
122
+ }
123
+ } catch (err) {
124
+ if (isCurrent()) setOauthNotice({ tone:'error', message:err.message || 'Reauthorization failed.' });
125
+ } finally {
126
+ if (isCurrent()) setOauthBusy(null);
127
+ }
128
+ };
129
+ const disconnect = async (credential) => {
130
+ const startedEpoch = epoch.current;
131
+ const vaultId = selected.id;
132
+ const isCurrent = () => VaultsData.isCurrentVaultResult(startedEpoch, epoch.current, vaultId, selectedRef.current);
133
+ setConfirm(null); setOauthBusy(credential.id); setOauthNotice(null);
134
+ try {
135
+ if (mode === 'demo') {
136
+ if (isCurrent()) setCredentials((rows) => rows.map((row) => row.id === credential.id ? { ...row, archivedAt:new Date().toISOString() } : row));
137
+ } else {
138
+ const archived = VaultsData.credentialRow(await OmaConsoleApi.archiveVaultCredential(vaultId, credential.id));
139
+ if (isCurrent()) setCredentials((rows) => rows.map((row) => row.id === credential.id ? archived : row));
140
+ }
141
+ if (isCurrent()) setOauthNotice({ tone:'neutral', message:'Credential disconnected and archived. Existing sessions can no longer use it.' });
142
+ } catch (err) {
143
+ if (isCurrent()) setOauthNotice({ tone:'error', message:err.message || 'Disconnect failed.' });
144
+ } finally {
145
+ if (isCurrent()) setOauthBusy(null);
146
+ }
147
+ };
64
148
  useVaultEffect(() => {
65
149
  if (!initialVaultId || !vaults || selected) return;
66
150
  const vault = vaults.find((item) => item.id === initialVaultId);
@@ -70,7 +154,9 @@ function VaultsView({ mode, initialVaultId, onCreate, onCreateCredential, readOn
70
154
  const body = selected
71
155
  ? <VaultDetail vault={selected} credentials={credentials} error={detailError} warning={warning}
72
156
  onBack={back} onRetry={() => open(selected)}
73
- mode={mode} readOnly={readOnly} onCreateCredential={() => onCreateCredential(selected)} validating={validating} validation={validation} onValidate={(credential) => setConfirm(credential)} />
157
+ mode={mode} readOnly={readOnly} onCreateCredential={() => onCreateCredential(selected)} validating={validating} validation={validation}
158
+ oauthBusy={oauthBusy} oauthNotice={oauthNotice} onValidate={(credential) => setConfirm({ kind:'validate', credential })}
159
+ onReauthorize={reauthorize} onDisconnect={(credential) => setConfirm({ kind:'disconnect', credential })} />
74
160
  : <div className="main-scroll scroll fade-in">
75
161
  <PageHead title="Vaults" sub="Manage workspace vaults and validate MCP OAuth credentials."
76
162
  action="Create vault" onAction={onCreate} readOnly={readOnly} endpoint="POST /v1/vaults" />
@@ -92,14 +178,18 @@ function VaultsView({ mode, initialVaultId, onCreate, onCreateCredential, readOn
92
178
  // made Validate a no-op once a vault was open).
93
179
  return <>
94
180
  {body}
95
- {confirm && <ConfirmDialog icon="alert" title="Validate credential"
181
+ {confirm?.kind === 'validate' && <ConfirmDialog icon="alert" title="Validate credential"
96
182
  message="Validate contacts the MCP server with this credential and may refresh the token at the provider."
97
183
  confirmLabel="Validate" endpoint="POST /v1/vaults/:id/credentials/:id/mcp_oauth_validate"
98
- onClose={() => setConfirm(null)} onConfirm={() => validate(confirm)} />}
184
+ onClose={() => setConfirm(null)} onConfirm={() => validate(confirm.credential)} />}
185
+ {confirm?.kind === 'disconnect' && <ConfirmDialog icon="archive" danger title="Disconnect credential"
186
+ message="Archive this credential? Existing sessions will no longer be able to use it. Provider-side authorization is not revoked."
187
+ confirmLabel="Disconnect" endpoint="POST /v1/vaults/:id/credentials/:id/archive"
188
+ onClose={() => setConfirm(null)} onConfirm={() => disconnect(confirm.credential)} />}
99
189
  </>;
100
190
  }
101
191
 
102
- function VaultDetail({ vault, credentials, error, warning, onBack, onRetry, mode, readOnly, onCreateCredential, validating, validation, onValidate }) {
192
+ function VaultDetail({ vault, credentials, error, warning, onBack, onRetry, mode, readOnly, onCreateCredential, validating, validation, onValidate, oauthBusy, oauthNotice, onReauthorize, onDisconnect }) {
103
193
  return <div className="main-scroll scroll fade-in"><PageHead title={vault.displayName} sub={vault.id} />
104
194
  <div className="toolbar"><button className="btn" onClick={onBack}>Back to vaults</button>{!readOnly && <button className="btn btn-primary" onClick={onCreateCredential}><Icon name="plus" size={15} />Add credential</button>}</div>
105
195
  {warning && <div className="inline-warn"><Icon name="alert" size={14} /><span>{warning}</span></div>}
@@ -111,8 +201,13 @@ function VaultDetail({ vault, credentials, error, warning, onBack, onRetry, mode
111
201
  <div style={{ display:'flex', gap:12, alignItems:'center' }}><span className="mono" style={{ color:'var(--soft)' }}>{c.id}</span><b>{c.displayName}</b><span className="pill">{c.authType}</span>{c.archivedAt && <St k="archived" />}</div>
112
202
  <div className="field-hint" style={{ marginTop:6 }}>{c.serverUrl || 'No server URL'} · expires {VaultsData.relativeTime(c.expiresAt)}</div>
113
203
  {c.refresh && <div className="field-hint">token host {c.refresh.tokenEndpointHost} · {c.refresh.scope || 'no scope'} · {c.refresh.endpointAuth || 'no endpoint auth'}</div>}
114
- {mode !== 'mock' && !c.archivedAt && c.authType === 'mcp_oauth' && <button className="btn" disabled={validating} onClick={() => onValidate(c)} style={{ marginTop:8 }}>{validating ? 'Validating…' : 'Validate'}</button>}
204
+ {mode !== 'mock' && !c.archivedAt && c.authType === 'mcp_oauth' && <div style={{ display:'flex', gap:8, marginTop:8 }}>
205
+ <button className="btn" disabled={validating || oauthBusy === c.id} onClick={() => onValidate(c)}>{validating ? 'Validating…' : 'Validate'}</button>
206
+ {c.refresh && <button className="btn" disabled={oauthBusy === c.id} onClick={() => onReauthorize(c)}>{oauthBusy === c.id ? 'Connecting…' : 'Reauthorize'}</button>}
207
+ <button className="btn btn-danger" disabled={oauthBusy === c.id || readOnly} onClick={() => onDisconnect(c)}>Disconnect</button>
208
+ </div>}
115
209
  </div>)}</div>}
210
+ {oauthNotice && <div className={oauthNotice.tone === 'error' ? 'inline-warn' : 'panel'} role="status" style={{ marginTop:12, padding:14 }}><span>{oauthNotice.message}</span></div>}
116
211
  {validation && <ValidationResult value={validation} />}
117
212
  </div>;
118
213
  }
@@ -168,4 +263,4 @@ function CredentialHealthView({ workspaceId, onBack, onReauth }) {
168
263
  </div>;
169
264
  }
170
265
 
171
- Object.assign(window, { VaultsView, CredentialHealthView, VaultsData: window.VaultsData || {} });
266
+ Object.assign(window, { VaultsView, CredentialHealthView, runMcpOauthPopup, VaultsData: window.VaultsData || {} });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "open-managed-agents",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "Self-hostable implementation of Anthropic's Managed Agents API surface.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -38,15 +38,15 @@
38
38
  },
39
39
  "dependencies": {
40
40
  "@clack/prompts": "1.7.0",
41
- "@earendil-works/pi-coding-agent": "0.80.6",
42
- "@hono/node-server": "2.0.0",
43
- "@modelcontextprotocol/sdk": "1.29.0",
44
- "hono": "4.12.29",
41
+ "@earendil-works/pi-coding-agent": "0.85.1",
42
+ "@hono/node-server": "2.1.1",
43
+ "@modelcontextprotocol/sdk": "1.31.0",
44
+ "hono": "4.13.7",
45
45
  "node-forge": "1.4.0",
46
46
  "picocolors": "1.1.1",
47
47
  "proper-lockfile": "4.1.2",
48
48
  "typebox": "1.1.29",
49
- "undici": "7.28.0",
49
+ "undici": "7.29.1",
50
50
  "yauzl": "3.4.0"
51
51
  },
52
52
  "devDependencies": {
@@ -59,6 +59,6 @@
59
59
  "playwright-core": "1.61.1",
60
60
  "tsx": "4.21.0",
61
61
  "typescript": "6.0.3",
62
- "vitest": "4.1.5"
62
+ "vitest": "4.1.11"
63
63
  }
64
64
  }