Skip to content

Commit 2ebac91

Browse files
authored
fix(agentos): honor canonical add-agent readback (#14614) (#15183)
1 parent d839f64 commit 2ebac91

7 files changed

Lines changed: 352 additions & 97 deletions

File tree

ai/services/fleet/FleetControlBridge.mjs

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -141,10 +141,21 @@ class FleetControlBridge extends Base {
141141
* @param {Object} [definition.metadata] Free-form non-secret metadata.
142142
* @param {String} [definition.modelProvider] The agent's model-provider login; resolves via the AiConfig SSOT leaf when omitted.
143143
* @param {Object|null} [definition.mcpServers] Complete sparse MCP overrides; omitted/null follows live defaults, exactly like configureAgent.
144-
* @returns {Object} the public agent definition (no credential).
144+
* @returns {Object} The public agent definition (no credential), or a controlled
145+
* `{status:'rejected', reason}` outcome for FleetRegistryService validation failures.
145146
*/
146147
defineAgent(definition) {
147-
return this.getRegistry().defineAgent(definition);
148+
try {
149+
return this.getRegistry().defineAgent(definition)
150+
} catch (error) {
151+
const prefix = 'FleetRegistryService.defineAgent:';
152+
153+
if (error?.message?.startsWith(prefix)) {
154+
return {status: 'rejected', reason: error.message.slice(prefix.length).trim()}
155+
}
156+
157+
throw error
158+
}
148159
}
149160

150161
/**

apps/agentos/view/Accounts.mjs

Lines changed: 53 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,11 @@ import Toolbar from '../../../src/toolbar/Base.mjs';
2121
* Capability-security boundary (the load-bearing reason this is its own surface): a credential is
2222
* collected only long enough to submit it to the Brain-side Fleet Registry bridge. If that bridge is
2323
* absent, submission fails closed, the PAT field is cleared, and **nothing is stored in the browser /
24-
* App Worker** — only a redacted projection reaches the shared `AgentDefinitions` roster, never a
25-
* credential byte (mirrors `AgentOS.model.AgentDefinition`'s deliberately credential-free shape).
24+
* App Worker** — only the Brain's canonical redacted response reaches the shared
25+
* `AgentDefinitions` roster, never a credential byte (mirrors
26+
* `AgentOS.model.AgentDefinition`'s deliberately credential-free shape). An accepted definition
27+
* emits `agentDefinitionAccepted`; the Viewport composition root owns the separate Fleet-cockpit
28+
* refresh, so Accounts never maps or reaches into a sibling `FleetAgent` surface.
2629
*/
2730
class Accounts extends DashboardPanel {
2831
static config = {
@@ -270,9 +273,9 @@ class Accounts extends DashboardPanel {
270273
*/
271274
async onAgentConfigIntent(intent={}) {
272275
const
273-
me = this,
274-
agentId = intent.id,
275-
bridge = globalThis.AgentOS?.fleet?.registryBridge,
276+
me = this,
277+
agentId = intent.id,
278+
bridge = globalThis.AgentOS?.fleet?.registryBridge,
276279
// Neo events add transport-irrelevant envelope fields such as `source`. Reconstruct the
277280
// curated wire intent explicitly so no event metadata can cross the Brain allowlist.
278281
wireIntent = {id: agentId};
@@ -444,23 +447,6 @@ class Accounts extends DashboardPanel {
444447
super.destroy(...args)
445448
}
446449

447-
/**
448-
* @summary Create the redacted projection that can safely render in the Body-side roster.
449-
* @param {Object} values
450-
* @returns {Object} Public agent definition suitable for the shared store; never includes a credential.
451-
*/
452-
createPublicAgentDefinition(values) {
453-
return {
454-
id : values.githubUsername,
455-
githubUsername : values.githubUsername,
456-
harnessType : values.harnessType,
457-
credentialState: 'stored-node-side',
458-
lifecycleState : 'gated',
459-
statusText : 'Agent added; lifecycle controls remain gated.',
460-
updatedAt : new Date().toISOString()
461-
}
462-
}
463-
464450
/**
465451
* @summary Load a sample public identity without inserting credential bytes.
466452
* @returns {Promise<void>}
@@ -481,7 +467,12 @@ class Accounts extends DashboardPanel {
481467
}
482468

483469
/**
484-
* @summary Validate the form, attempt the Brain-side bridge submit, then clear the PAT field.
470+
* @summary Validate the form, attempt the Brain-side bridge submit, and apply only the canonical
471+
* redacted response to the provider-owned AgentDefinitions store. A controlled registry-domain
472+
* rejection renders its reason without mutating Body state; an unexpected or malformed response
473+
* stays sanitized. After an accepted readback, `agentDefinitionAccepted` tells the Viewport
474+
* composition root to refresh the separately-owned Fleet roster from its Brain assembler.
475+
* The PAT field clears after every attempted bridge submit.
485476
* @returns {Promise<void>}
486477
*/
487478
async onSubmitAgentClick() {
@@ -504,8 +495,15 @@ class Accounts extends DashboardPanel {
504495
};
505496

506497
try {
507-
await this.submitToFleetRegistryBridge(payload);
508-
this.upsertPublicAgentDefinition(this.createPublicAgentDefinition(payload));
498+
const outcome = await this.submitToFleetRegistryBridge(payload);
499+
500+
if (outcome?.status === 'rejected') {
501+
this.updateBridgeStatus('is-error', outcome.reason || 'Agent definition was rejected. Nothing was changed.');
502+
return
503+
}
504+
505+
this.upsertPublicAgentDefinition(outcome, payload.credential);
506+
this.fire('agentDefinitionAccepted', {agent: outcome});
509507
this.updateBridgeStatus('is-live', 'Agent added. PAT was not retained in the app worker.')
510508
} catch (error) {
511509
this.updateBridgeStatus('is-error', 'Could not add agent. Nothing was stored in browser state; PAT field was cleared.')
@@ -545,7 +543,8 @@ class Accounts extends DashboardPanel {
545543
* app has no Brain-side bridge object, so the view fails closed instead of inventing browser
546544
* persistence.
547545
* @param {Object} payload
548-
* @returns {Promise<*>}
546+
* @returns {Promise<Object>} Canonical public agent definition on acceptance, or a controlled
547+
* `{status:'rejected', reason}` domain outcome.
549548
*/
550549
async submitToFleetRegistryBridge(payload) {
551550
const bridge = globalThis.AgentOS?.fleet?.registryBridge;
@@ -577,17 +576,36 @@ class Accounts extends DashboardPanel {
577576
}
578577

579578
/**
580-
* @summary Write the redacted projection into the shared roster store (the Viewport-provider-
581-
* hosted `AgentDefinitions` instance this view binds), replacing any prior row for the same
582-
* agent. The Fleet view's grid (bound to the same provider store) re-renders reactively — no
583-
* cross-view reference is needed.
584-
* @param {Object} definition
579+
* @summary Validate and write the Brain's canonical redacted response into the Viewport-owned
580+
* `AgentDefinitions` store. Required public identity fields and the exact submitted credential
581+
* are checked before mutation; a malformed or echoing response fails closed. Existing records
582+
* update in place, while a new definition becomes the selected Accounts resident. This is the
583+
* configuration projection only — the separate FleetAgent roster refreshes through the
584+
* Viewport-owned `agentDefinitionAccepted` composition seam.
585+
* @param {Object} definition Canonical public definition returned by the Brain bridge.
586+
* @param {String} submittedCredential Ephemeral PAT used only to reject an accidental echo.
585587
*/
586-
upsertPublicAgentDefinition(definition) {
587-
const store = this.agentDefinitionsStore;
588+
upsertPublicAgentDefinition(definition, submittedCredential) {
589+
const
590+
store = this.agentDefinitionsStore,
591+
hasTopLevelSecret = definition && ['authorization', 'credential', 'password', 'pat', 'token']
592+
.some(key => Object.hasOwn(definition, key));
593+
594+
let serializedDefinition;
595+
596+
try {
597+
serializedDefinition = JSON.stringify(definition)
598+
} catch (error) {/* invalid response */}
599+
600+
if (!store || !definition?.id || !definition.githubUsername || !definition.harnessType ||
601+
!serializedDefinition || hasTopLevelSecret ||
602+
(submittedCredential && serializedDefinition.includes(submittedCredential))) {
603+
throw new Error('Fleet Registry returned an invalid public agent definition')
604+
}
605+
606+
const record = store.get(definition.id);
588607

589-
store.remove(definition.id);
590-
store.add(definition);
608+
record ? record.set(definition) : store.add(definition);
591609

592610
// a just-added agent becomes the scoped one — the operator configures it next
593611
this.selectedAgentId = definition.id

apps/agentos/view/Viewport.mjs

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@ import ViewportController from './ViewportController.mjs';
1818
* cockpit, the default), **Accounts** (identity setup), **Chat** (prompt → live pane, the dockable
1919
* work-area seam). The Fleet keeper-view renders the roster as CARDS (the design SSOT), not a
2020
* data-grid table. Renders through `neo-theme-neo-dark` / `neo-theme-neo-light`.
21+
*
22+
* The Viewport is also the composition authority between two deliberately separate projections:
23+
* Accounts owns `AgentDefinitions`, while FleetCockpit owns `FleetRoster`. An accepted definition
24+
* event is routed here so the cockpit can re-poll its Brain-side `fleetRoster()` assembler; neither
25+
* sibling reaches into or locally maps the other's store.
2126
*/
2227
class Viewport extends BaseViewport {
2328
static config = {
@@ -101,8 +106,9 @@ class Viewport extends BaseViewport {
101106
'<p class="agent-welcome-lede">Your fleet\'s state at a glance, its work streaming in real time, commanded from the cockpit — not a terminal. Select <b>Fleet</b> in the rail to enter mission control.</p>' +
102107
'</div>'
103108
}, {
104-
module: FleetCockpit,
105-
header: {iconCls: 'fa-solid fa-satellite-dish', route: '/fleet', text: 'Fleet'}
109+
module : FleetCockpit,
110+
header : {iconCls: 'fa-solid fa-satellite-dish', route: '/fleet', text: 'Fleet'},
111+
reference: 'fleet-cockpit'
106112
}, {
107113
// FleetSettingsPanel is a dashboard.Panel, so its keeper-view rides a dashboard.Container
108114
// to keep the detach-to-window (pop-out) host — the WindowOps E2E contract + the working
@@ -130,6 +136,7 @@ class Viewport extends BaseViewport {
130136
items: [{
131137
module : Accounts,
132138
flex : 1,
139+
listeners: {agentDefinitionAccepted: 'up.onAgentDefinitionAccepted'},
133140
reference: 'accounts'
134141
}]
135142
}, {
@@ -140,6 +147,26 @@ class Viewport extends BaseViewport {
140147
}]
141148
}]
142149
}
150+
151+
/**
152+
* @summary Route an accepted Accounts definition across the keeper-view composition boundary:
153+
* FleetCockpit re-polls the Brain's authoritative roster assembler into its own FleetRoster
154+
* store. Invalid events or an absent cockpit fail closed without a sibling-store mutation.
155+
* @param {Object} data
156+
* @param {Object} data.agent Canonical public agent definition accepted by Accounts.
157+
* @returns {Promise<Boolean>} True after the cockpit refresh settles; false when no valid route exists.
158+
*/
159+
async onAgentDefinitionAccepted({agent}={}) {
160+
const cockpit = this.getReference('fleet-cockpit');
161+
162+
if (!agent?.id || typeof cockpit?.loadRoster !== 'function') {
163+
return false
164+
}
165+
166+
await cockpit.loadRoster();
167+
168+
return true
169+
}
143170
}
144171

145172
export default Neo.setupClass(Viewport);

0 commit comments

Comments
 (0)