-
Notifications
You must be signed in to change notification settings - Fork 456
Fallback to deployment manifest when workspace registry denies read #5039
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 3 commits
c9d11f7
9c2269d
ae19172
f703a0d
038ae7f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "@agent-native/dispatch": patch | ||
| --- | ||
|
|
||
| Keep the Apps page readable when the hosted workspace registry denies a read. A gateway 401/403 now falls back to the deployment-owned app manifest, which is still access-filtered per caller, and only throws when no source can answer the registry at all. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -117,6 +117,20 @@ | |
| statusCode: 401 | 403; | ||
| } | ||
|
|
||
| /** | ||
| * A denial that a fallback source answered is still a misconfiguration worth | ||
| * diagnosing, so it must stay visible in logs even though the read succeeded. | ||
| */ | ||
| function warnWorkspaceAppsGatewayDenial( | ||
| denial: WorkspaceAppsGatewayAuthorizationError | null, | ||
| source: string, | ||
| ): void { | ||
| if (!denial) return; | ||
| console.warn( | ||
| `[dispatch] workspace apps gateway denied the registry read with HTTP ${denial.statusCode}; served the ${source} instead`, | ||
| ); | ||
| } | ||
|
|
||
| type WorkspaceAppAudience = "internal" | "public"; | ||
| type WorkspaceAppVisibility = "private" | "org"; | ||
|
|
||
|
|
@@ -150,6 +164,13 @@ | |
| workspaceSso?: boolean; | ||
| } | ||
|
|
||
| interface FinalizeWorkspaceAppsOptions { | ||
| /** Delete org rows absent from an authoritative manifest. */ | ||
| reconcile?: boolean; | ||
| /** Write registry rows. False for a source the caller could not authenticate. */ | ||
| persist?: boolean; | ||
| } | ||
|
|
||
| interface WorkspaceAppDiscovery { | ||
| apps: WorkspaceAppSummary[]; | ||
| authoritative: boolean; | ||
|
|
@@ -1252,12 +1273,17 @@ | |
| */ | ||
| async function ensureWorkspaceAppRecords( | ||
| apps: WorkspaceAppSummary[], | ||
| options: { reconcile?: boolean } = {}, | ||
| options: { reconcile?: boolean; persist?: boolean } = {}, | ||
| ): Promise<WorkspaceAppSummary[]> { | ||
| const readyApps = apps.filter( | ||
| (app) => app.status !== "pending" && !app.isDispatch, | ||
| ); | ||
| const shouldReconcile = options.reconcile === true; | ||
| // A source the caller could not authenticate annotates from existing rows | ||
| // only. Minting a row here would create the very authorization the access | ||
| // filter then checks, and reconciling would delete rows and shares on the | ||
| // word of a manifest no authoritative registry confirmed. | ||
| const shouldPersist = options.persist !== false; | ||
| const shouldReconcile = options.reconcile === true && shouldPersist; | ||
| if (!shouldReconcile && readyApps.length === 0) return apps; | ||
|
|
||
| const orgId = currentOrgId(); | ||
|
|
@@ -1303,6 +1329,7 @@ | |
| for (const app of readyApps) { | ||
| const existing = existingRecords.get(app.id); | ||
| if (!existing) { | ||
| if (!shouldPersist) continue; | ||
| const override = metadata.apps[app.id]; | ||
| // Never infer ownership from the person who happened to list apps. | ||
| // Legacy manifests without trusted creation metadata remain | ||
|
|
@@ -1340,7 +1367,7 @@ | |
| const existingOrgId = cleanOptionalText(existing.org_id) ?? null; | ||
| // A registry row belongs to the org that created it. Never reassign a | ||
| // row from another org just because a caller listed the same manifest. | ||
| if (existingOrgId && existingOrgId !== orgId) { | ||
| if (!shouldPersist || (existingOrgId && existingOrgId !== orgId)) { | ||
| records.set(app.id, { | ||
| ownerEmail: existingOwnerEmail, | ||
| orgId: existingOrgId, | ||
|
|
@@ -1976,11 +2003,17 @@ | |
| export async function listWorkspaceApps( | ||
| options: ListWorkspaceAppsOptions = {}, | ||
| ): Promise<WorkspaceAppSummary[]> { | ||
| const finalize = async (apps: WorkspaceAppSummary[], reconcile = false) => { | ||
| const finalize = async ( | ||
| apps: WorkspaceAppSummary[], | ||
| { reconcile = false, persist = true }: FinalizeWorkspaceAppsOptions = {}, | ||
| ) => { | ||
| // Reconcile from the complete manifest. Archive and audience filters only | ||
| // control the response; treating hidden apps as absent deletes their rows. | ||
| const annotated = await applyArchivedAndPending(apps); | ||
| const recorded = await ensureWorkspaceAppRecords(annotated, { reconcile }); | ||
| const recorded = await ensureWorkspaceAppRecords(annotated, { | ||
| reconcile, | ||
| persist, | ||
| }); | ||
| const listed = options.includeArchived | ||
| ? recorded | ||
| : recorded.filter((app) => !app.archived); | ||
|
|
@@ -1989,26 +2022,54 @@ | |
| ); | ||
| return maybeIncludeAgentCards(visible, options); | ||
| }; | ||
| const gatewayApps = await readWorkspaceAppsFromGateway(); | ||
| let gatewayDenial: WorkspaceAppsGatewayAuthorizationError | null = null; | ||
| let gatewayApps: WorkspaceAppDiscovery | null = null; | ||
| try { | ||
| gatewayApps = await readWorkspaceAppsFromGateway(); | ||
| } catch (error) { | ||
| if (!(error instanceof WorkspaceAppsGatewayAuthorizationError)) throw error; | ||
| // A denial answers for the gateway hop, not for what this caller may see. | ||
|
Contributor
Author
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. 🟡 Classify local gateway authorization failures as denialsThe local gateway branch only handles Additional Info
Contributor
Author
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. Required — not fixing. The mechanics are as you describe: a local 401/403 falls through, and with no That branch is gated on The change would also carry a real cost. Downgrading to This behaviour is also unchanged by this PR; the local branch predates it. If we do want loopback denials classified, it deserves its own change with local-dev verification rather than riding along here. |
||
| // The receiver resolves org membership against its own database, so a | ||
| // cross-deployment trust gap reads as 403 for every user at once. Serve | ||
| // the deployment manifests below rather than blanking the workspace, but | ||
| // treat them as unverified: read-only, so an unauthenticated read never | ||
| // writes the access rows it is about to be filtered by. A registry | ||
| // nothing can answer still throws. | ||
| gatewayDenial = error; | ||
| } | ||
| if (gatewayApps) { | ||
| return finalize(gatewayApps.apps, gatewayApps.authoritative); | ||
| return finalize(gatewayApps.apps, { reconcile: gatewayApps.authoritative }); | ||
| } | ||
| const unverified = gatewayDenial !== null; | ||
|
|
||
| const workspaceRoot = findWorkspaceRoot(); | ||
| const localFilesystemApps = | ||
| workspaceRoot && isLocalAppCreationRuntime() | ||
| ? readWorkspaceAppsFromFilesystem(workspaceRoot) | ||
| : null; | ||
| if (localFilesystemApps) { | ||
| return finalize(localFilesystemApps, true); | ||
| warnWorkspaceAppsGatewayDenial(gatewayDenial, "local filesystem"); | ||
| return finalize(localFilesystemApps, { | ||
| reconcile: !unverified, | ||
| persist: !unverified, | ||
|
Comment on lines
+2065
to
+2067
Contributor
Author
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. 🟡 Preserve visibility for manifest apps without existing registry rowsWhen a gateway 401/403 selects this read-only fallback, Additional Info
Contributor
Author
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. Required — not fixing the visibility, fixed the silence. I don't agree with restoring visibility here. The two options that would do it are both worse than the current behaviour:
Dropping them is the fail-closed answer, and it matches what Where you're right is the silence. Access for those apps is unknown, not denied, and dropping them without a word is the same coercion this PR is fixing in the other direction. Good catch on the mock, too — |
||
| }); | ||
| } | ||
|
|
||
| const manifestApps = | ||
| readWorkspaceAppsFromEnv() ?? readWorkspaceAppsFromManifestFile(); | ||
| if (manifestApps) { | ||
| return finalize(manifestApps, true); | ||
| warnWorkspaceAppsGatewayDenial(gatewayDenial, "deployment manifest"); | ||
| return finalize(manifestApps, { | ||
| reconcile: !unverified, | ||
| persist: !unverified, | ||
|
Comment on lines
+2074
to
+2077
Contributor
Author
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. 🟡 Do not reconcile manifests after transient gateway failures
Additional Info
Contributor
Author
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. Required — not fixing here, but I agree it's a real gap and it should be the follow-up. You're right on the mechanics, and right that the line I drew is not the fully principled one. The honest taxonomy is:
The last two are the same epistemic state: we have no authoritative list, so deleting rows and shares on the manifest's word is unjustified in both. I'm not going to argue otherwise. The reason I'm not extending it in this PR is scope and shape, not disagreement. The 401/403 case was in scope precisely because it is the one this PR introduced. The transient-failure case is pre-existing on Happy to open the follow-up for the discriminated-outcome refactor if you'd like it tracked. |
||
| }); | ||
| } | ||
|
|
||
| // Every remaining branch synthesizes a registry instead of reading one, so a | ||
| // denial must stay a denial rather than become an empty or Dispatch-only | ||
| // workspace the caller cannot tell apart from a real answer. | ||
| if (gatewayDenial) throw gatewayDenial; | ||
|
|
||
| if (!workspaceRoot) { | ||
| return finalize([ | ||
| { | ||
|
|
@@ -2480,7 +2541,7 @@ | |
| throw new Error(`Builder app creation returned a blank ${fieldName}`); | ||
| } | ||
| const trimmed = value.trim(); | ||
| if (/[\u0000-\u001f\u007f]/.test(trimmed)) { | ||
| throw new Error(`Builder app creation returned a malformed ${fieldName}`); | ||
| } | ||
| return trimmed; | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.