mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
fix(serve): serve qualified hooks status for untrusted workspaces (#16388)
* fix(serve): serve qualified hooks status for untrusted workspaces The workspace-qualified hooks status GET sat behind the trusted-runtime gate alongside env/preflight, so with folder trust enabled an untrusted workspace got 403 exactly when the Web Shell approval card needed the pending decision. The card treats the failed read as no hooks and never renders, leaving no interactive way to approve. Route the hooks read through the same trust-free resolver as the qualified approve POST; env/preflight keep the trusted gate. Fixes #16387 Co-authored-by: Qwen-Coder <[email protected]> * test(serve): pin pending hooks decision for untrusted workspace Co-authored-by: Qwen-Coder <[email protected]> --------- Co-authored-by: Qwen-Coder <[email protected]>
This commit is contained in:
1 parent
2be58e52ee
commit
b87d60ddf0
3 files changed
+41
-3
No files matched your search
@@ -333,7 +333,7 @@ The optional top-level `/capabilities` field `sessionLiveStatePollIntervalMs` ad
|
||||
|
||||
`session_archive` advertises the v1 directory-state archive API: `POST /sessions/archive`, `POST /sessions/unarchive`, and `GET /workspace/:id/sessions?archiveState=active|archived`. Archived sessions cannot be loaded or resumed until they are unarchived. `session_archive_idle_guard` advertises the additive `requireIdle` archive option: the owning runtime checks that active work is confirmed idle immediately before it begins the close, and leaves the active transcript in place otherwise. `session_storage_conflict_repair` advertises the additive `resolveConflicts` request option and `resolvedConflicts` response bucket described below.
|
||||
|
||||
`workspace_qualified_rest_core` advertises plural core REST routes under `/workspaces/:workspace/...`. The selector resolves as exact workspace id first, then as a URL-encoded absolute cwd after canonicalization. Newer single-workspace daemons include the primary runtime in `workspaces[]` even when `multi_workspace_sessions` is absent, allowing clients to discover the id required by workspace-qualified routes; clients should fall back to `capabilities.workspaceCwd` for older daemons that omit the array. Trust status and trust request routes are available for registered untrusted workspaces; file read routes follow the existing filesystem read policy. Registered untrusted secondary workspaces also expose persisted-only session and session-group catalogs: these reads do not attach to a session, start ACP, or merge live bridge state. File writes, catalog mutations, and other plural core routes require a trusted workspace unless a separate capability documents a narrower policy: `workspace_persisted_transcript` (read-only), or the trust grant mutation, which is accepted for a registered untrusted workspace whether or not `workspace_trust_grant` is advertised — that tag reports only whether trust hot-reload is wired, so a recorded decision can reach the running runtime without a daemon restart; it never reports whether the route is served. An untrusted primary continues to receive `403 { code: "untrusted_workspace" }` from the plural catalog and transcript routes; legacy singular primary routes keep their existing compatibility behavior. This tag covers the core file, status, settings, permissions, trust, lifecycle, MCP control, tool and skill toggles, memory, workspace agent CRUD, and session storage surfaces. It does not cover auth, voice, extensions, ACP/WebSocket transport, channel-worker routing, or workspace-qualified session export; pre-flight `workspace_session_export` or `workspace_archived_session_export` separately. Workspace trust is not an ACL: a client holding the daemon token can read every registered workspace surface allowed by this policy.
|
||||
`workspace_qualified_rest_core` advertises plural core REST routes under `/workspaces/:workspace/...`. The selector resolves as exact workspace id first, then as a URL-encoded absolute cwd after canonicalization. Newer single-workspace daemons include the primary runtime in `workspaces[]` even when `multi_workspace_sessions` is absent, allowing clients to discover the id required by workspace-qualified routes; clients should fall back to `capabilities.workspaceCwd` for older daemons that omit the array. Trust status and trust request routes are available for registered untrusted workspaces; the qualified hooks status read (`GET /workspaces/:workspace/hooks`) is also served for registered untrusted workspaces because it reports the pending project-hooks trust decision, matching the qualified approve mutation; file read routes follow the existing filesystem read policy. Registered untrusted secondary workspaces also expose persisted-only session and session-group catalogs: these reads do not attach to a session, start ACP, or merge live bridge state. File writes, catalog mutations, and other plural core routes require a trusted workspace unless a separate capability documents a narrower policy: `workspace_persisted_transcript` (read-only), or the trust grant mutation, which is accepted for a registered untrusted workspace whether or not `workspace_trust_grant` is advertised — that tag reports only whether trust hot-reload is wired, so a recorded decision can reach the running runtime without a daemon restart; it never reports whether the route is served. An untrusted primary continues to receive `403 { code: "untrusted_workspace" }` from the plural catalog and transcript routes; legacy singular primary routes keep their existing compatibility behavior. This tag covers the core file, status, settings, permissions, trust, lifecycle, MCP control, tool and skill toggles, memory, workspace agent CRUD, and session storage surfaces. It does not cover auth, voice, extensions, ACP/WebSocket transport, channel-worker routing, or workspace-qualified session export; pre-flight `workspace_session_export` or `workspace_archived_session_export` separately. Workspace trust is not an ACL: a client holding the daemon token can read every registered workspace surface allowed by this policy.
|
||||
|
||||
`workspace_qualified_voice` advertises Voice routes selected by a trusted workspace runtime: `GET` and `POST /workspaces/:workspace/voice`, `POST /workspaces/:workspace/voice/transcribe`, and `WS /workspaces/:workspace/voice/stream`. It is advertised only when multi-workspace runtimes and the shared ACP/Voice WebSocket listener are both enabled. The selector follows the same id-or-encoded-absolute-cwd rules as other plural routes. For REST, an unknown selector returns `400 { code: "workspace_mismatch" }` and an untrusted selector returns `403 { code: "untrusted_workspace" }`; WebSocket upgrade rejection exposes the corresponding HTTP 400/403 status without a structured JSON envelope. Neither transport falls back to primary. Legacy `/workspace/voice`, `/workspace/voice/transcribe`, and `/voice/stream` remain primary-only. Clients use `workspace_qualified_voice` for all qualified Voice modalities and let the selected runtime report configuration-specific errors. The legacy `workspace_voice`, `workspace_voice_transcription`, and `voice_transcribe` tags describe only the primary-bound routes and must not hide a qualified secondary configuration.
|
||||
|
||||
|
||||
@@ -565,7 +565,14 @@ export function registerWorkspaceQualifiedDiagnosticStatusRoutes(
|
||||
['hooks', 'getWorkspaceHooksStatus'],
|
||||
] as const) {
|
||||
app.get(`/workspaces/:workspace/${pathSuffix}`, async (req, res) => {
|
||||
const runtime = resolveTrustedRuntime(workspaceRegistry, req, res);
|
||||
// The hooks status reports the pending trust decision, so it must
|
||||
// stay readable exactly when the workspace is untrusted — same
|
||||
// posture as the qualified approve POST. Env/preflight keep the
|
||||
// trusted gate.
|
||||
const runtime =
|
||||
methodName === 'getWorkspaceHooksStatus'
|
||||
? resolveWorkspaceRuntimeFromParam(workspaceRegistry, req, res)
|
||||
: resolveTrustedRuntime(workspaceRegistry, req, res);
|
||||
if (!runtime) return;
|
||||
const route = `GET /workspaces/:workspace/${pathSuffix}`;
|
||||
const ctx = createBuildWorkspaceCtx(runtime.workspaceCwd)(route);
|
||||
|
||||
@@ -637,7 +637,6 @@ describe('workspace-qualified core REST', () => {
|
||||
'providers',
|
||||
'env',
|
||||
'preflight',
|
||||
'hooks',
|
||||
'tools',
|
||||
'settings',
|
||||
'permissions',
|
||||
@@ -658,6 +657,38 @@ describe('workspace-qualified core REST', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('serves the hooks trust decision on an untrusted workspace (#16387)', async () => {
|
||||
const h = await makeHarness({ secondaryTrusted: false });
|
||||
const pendingApprovalHash = 'a'.repeat(64);
|
||||
vi.mocked(
|
||||
h.secondaryWorkspaceService.getWorkspaceHooksStatus,
|
||||
).mockResolvedValueOnce({
|
||||
v: 1,
|
||||
workspaceCwd: h.secondaryCwd,
|
||||
initialized: true,
|
||||
disabled: false,
|
||||
hooks: [],
|
||||
events: {},
|
||||
pendingApproval: true,
|
||||
pendingApprovalHooks: ['SessionStart: echo untrusted'],
|
||||
pendingApprovalHash,
|
||||
});
|
||||
try {
|
||||
const res = await request(h.app)
|
||||
.get(`/workspaces/${encodeURIComponent(h.secondaryId)}/hooks`)
|
||||
.set('Host', host());
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toMatchObject({
|
||||
workspaceCwd: h.secondaryCwd,
|
||||
pendingApproval: true,
|
||||
pendingApprovalHooks: ['SessionStart: echo untrusted'],
|
||||
pendingApprovalHash,
|
||||
});
|
||||
} finally {
|
||||
await fsp.rm(h.scratch, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('routes workspace-qualified status reads to the selected workspace', async () => {
|
||||
const h = await makeHarness();
|
||||
try {
|
||||
|
||||
Reference in new issue
Block a user