Repository navigation
Connect page: owner-approved private local listeners #2484
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
7647e74
28f13f6
4026f66
e361150
000899e
d9d565c
bb7ac1e
e0f6437
c47010f
5ff61da
1228f3c
172ebeb
c291e16
8968d38
f25ed3a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,8 +3,9 @@ | |
| const express = require('express'); | ||
| const auth = require('../../middleware/auth'); | ||
| const { AgentInstallation } = require('../../models/AgentRegistry'); | ||
| const AgentProfile = require('../../models/AgentProfile'); | ||
| const User = require('../../models/User'); | ||
| const AgentIdentityService = require('../../services/agentIdentityService'); | ||
| const AgentProfile = require('../../models/AgentProfile'); | ||
| const Pod = require('../../models/Pod'); | ||
| const { | ||
| writeWorkspaceIdentityFile, | ||
|
|
@@ -24,12 +25,17 @@ const { | |
| const { | ||
| normalizeToolPolicy, | ||
| normalizeContextPolicy, | ||
| revokeRuntimeTokensForAgent, | ||
| } = require('./tokens'); | ||
| const { validateEnvironmentMcpEntries } = require('../../utils/environmentSpecValidation'); | ||
| const { ownsRuntimeIdentity, webhookUrlRefusal } = require('../../services/runtimeIdentityOwnership'); | ||
| const { configWriteRefusal, runtimeKeepingType } = require('../../services/configWriteGuard'); | ||
| const { assertAgentRuntimeMayJoinPod } = require('../../services/outsiderGrantAgentJoinPolicy'); | ||
| const { hostedEngineInEffect } = require('../../services/hostedEngine'); | ||
| const { | ||
| isPrivateListenerEnvironment, privateListenerConfigurationValid, | ||
| privateListenerPodAllowed, privateListenerIdentityAllowed, | ||
| } = require('../../services/privateListenerPolicy'); | ||
|
|
||
| const agentConfigRouter = express.Router(); | ||
|
|
||
|
|
@@ -172,6 +178,19 @@ agentConfigRouter.patch('/pods/:podId/agents/:name', auth, async (req: any, res: | |
| fields: environmentErrors, | ||
| }); | ||
| } | ||
| if (isPrivateListenerEnvironment(config.environment)) { | ||
| const existingConfig = normalizeConfigMap(installation.config) || {}; | ||
| const runtimeType = config.runtime?.runtimeType || existingConfig.runtime?.runtimeType; | ||
| const eligible = privateListenerConfigurationValid(config.environment) | ||
| && await privateListenerPodAllowed(podId, userId, name.toLowerCase(), normalizedInstanceId) | ||
| && await privateListenerIdentityAllowed(name.toLowerCase(), normalizedInstanceId, userId, true); | ||
| if (!eligible || !['wrapper', 'webhook'].includes(runtimeType)) { | ||
| return res.status(403).json({ | ||
| code: 'private_listener_not_allowed', | ||
| error: 'Full computer access requires an invite-only pod with only you and this listener, and an absolute repo path', | ||
| }); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // Being this row's installer is not enough to point its push webhook | ||
|
|
@@ -208,6 +227,17 @@ agentConfigRouter.patch('/pods/:podId/agents/:name', auth, async (req: any, res: | |
| ...(existingConfig.environment?.sandbox || {}), ...environmentControls.sandbox, | ||
| } } : {}) }; | ||
| } | ||
| const requestedTrust = config.environment?.sandbox?.trust; | ||
| if ((requestedTrust === 'private' || requestedTrust === 'public') | ||
| && existingConfig.environment && typeof existingConfig.environment === 'object') { | ||
| const nextEnvironment = { ...existingConfig.environment, ...config.environment }; | ||
| if (isPrivateListenerEnvironment(existingConfig.environment) | ||
| && !isPrivateListenerEnvironment(nextEnvironment) | ||
| && !Object.prototype.hasOwnProperty.call(config.environment, 'workspace')) { | ||
| delete nextEnvironment.workspace; | ||
| } | ||
| nextConfig.environment = nextEnvironment; | ||
| } | ||
| if (config.runtime && typeof config.runtime === 'object') { | ||
| nextConfig.runtime = runtimeKeepingType(config.runtime, existingConfig, { mayChangeType: isInstanceAdmin }); | ||
| } | ||
|
|
@@ -370,6 +400,29 @@ agentConfigRouter.patch('/pods/:podId/agents/:name', auth, async (req: any, res: | |
| } | ||
| } | ||
|
|
||
| const downgradesPrivateListener = config?.environment !== undefined | ||
| && !isPrivateListenerEnvironment(config.environment) | ||
| && installationsToUpdate.some((entry: any) => isPrivateListenerEnvironment( | ||
| normalizeConfigMap(entry.config)?.environment, | ||
| )); | ||
|
Comment on lines
+403
to
+407
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔎 Supported by static analysis
Compute private-listener transitions from the effective environment. The Settings PATCH sends only fields such as The same merge lets Extract the existing environment merge into a helper. Use that helper for saving, downgrade detection, and validation. Preserve the current replace behavior for environment payloads that are not Settings control patches. Validate each effective private environment before saving, and revoke tokens only when an effective stored private environment becomes non-private. Suggested fix
|
||
| const revokePrivateListenerTokens = async () => { | ||
| // A running one-command listener cached its private environment at boot. | ||
| // Reload the User on each pass: a concurrent mint may have saved a new | ||
| // token on that row since the previous pass. | ||
| const resolvedType = AgentIdentityService.resolveAgentType(name.toLowerCase()); | ||
| const username = AgentIdentityService.buildAgentUsername(resolvedType, normalizedInstanceId); | ||
| const agentUser = await User.findOne({ username, isBot: true }); | ||
| await revokeRuntimeTokensForAgent({ | ||
| agentUser, agentName: name.toLowerCase(), instanceId: normalizedInstanceId, | ||
| }); | ||
| }; | ||
| if (downgradesPrivateListener) { | ||
| // The first pass stops the running private listener before the public | ||
| // config is visible. The second catches any mint that passed its private | ||
| // eligibility read while this config write was in flight. | ||
| await revokePrivateListenerTokens(); | ||
| } | ||
|
|
||
| // The User row is the name source for chat and the pod roster. An explicit | ||
| // rename must update that portable identity, not only per-pod projections. | ||
| let renamedIdentity; | ||
|
|
@@ -393,6 +446,7 @@ agentConfigRouter.patch('/pods/:podId/agents/:name', auth, async (req: any, res: | |
| // eslint-disable-next-line no-await-in-loop | ||
| await targetInstallation.save(); | ||
| } | ||
| if (downgradesPrivateListener) await revokePrivateListenerTokens(); | ||
|
|
||
| if ( | ||
| status | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Validate every installation that receives private trust.
If an eligible active installation has a paused peer in a shared pod, this check passes:
privateListenerIdentityAllowedexamines active installations only. The PATCH then writes private trust to the paused peer throughinstallationsToUpdate. When the peer is reactivated, the identity-wide runtime check rejects the listener’s tokens. Check pod eligibility for every fan-out target before saving private trust, including paused targets. Add a regression test with a paused peer in a shared pod.As per coding guidelines: “Auth/scope/ownership checks on every endpoint.”
Source: Coding guidelines