Skip to content

Commit 8d03e57

Browse files
committed
feat(serve): add GET /workspace/env route (#4175 Wave 3 PR 13)
Wire `buildEnvStatusFromProcess` from the previous commit through the bridge, server, and SDK so remote clients can pre-flight the daemon's runtime environment without spawning an ACP child. - `workspace_env` capability tag (always advertised on a current daemon). - `bridge.getWorkspaceEnvStatus()` answers entirely from `process.*` — the route never consults ACP. `acpChannelLive` reflects whether a child exists but does not change the payload, so an idle daemon and a busy one return the same env shape. - `app.get('/workspace/env', ...)` mirrors PR 12's one-liner pattern. - SDK: `DaemonClient.workspaceEnv()` returning `DaemonWorkspaceEnvStatus`. - Docs: bullet in `docs/users/qwen-serve.md` calling out the presence-only redaction policy and the no-ACP-spawn guarantee. Tests: server-level (env returned + `'value' in env_var === false` assertion), bridge-level (idle and live both answer locally without hitting ACP extMethod), SDK-level (recording-fetch round-trip on `/workspace/env`). The `workspace_env` tag is added to the `EXPECTED_STAGE1_FEATURES` capability list assertion.
1 parent a210924 commit 8d03e57

8 files changed

Lines changed: 166 additions & 3 deletions

File tree

docs/users/qwen-serve.md

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,16 @@ The `workspaceCwd` field surfaces the bound workspace so clients can pre-flight
4040

4141
The daemon also exposes read-only runtime snapshots for client UIs:
4242
`GET /workspace/mcp`, `GET /workspace/skills`, `GET /workspace/providers`,
43-
`GET /session/:id/context`, and `GET /session/:id/supported-commands`. The
44-
workspace routes report the live daemon runtime and do not start the ACP child
45-
when idle; an idle daemon returns `initialized: false` with an empty snapshot.
43+
`GET /workspace/env`, `GET /session/:id/context`, and
44+
`GET /session/:id/supported-commands`. The workspace routes report the live
45+
daemon runtime and do not start the ACP child when idle; an idle daemon
46+
returns `initialized: false` with an empty snapshot.
47+
48+
`GET /workspace/env` reports the daemon process's runtime, platform, sandbox,
49+
proxy, and the **presence** (never the value) of whitelisted secret env vars
50+
such as `OPENAI_API_KEY`. Proxy URLs are stripped of credentials and reduced
51+
to `host:port` before they hit the wire. The route always answers from the
52+
daemon process directly and never spawns an ACP child.
4653

4754
### 3. Open a session
4855

packages/cli/src/serve/capabilities.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ export const SERVE_CAPABILITY_REGISTRY = {
5454
workspace_mcp: { since: 'v1' },
5555
workspace_skills: { since: 'v1' },
5656
workspace_providers: { since: 'v1' },
57+
workspace_env: { since: 'v1' },
5758
session_context: { since: 'v1' },
5859
session_supported_commands: { since: 'v1' },
5960
session_close: { since: 'v1' },

packages/cli/src/serve/httpAcpBridge.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -458,6 +458,41 @@ describe('createHttpAcpBridge', () => {
458458
await bridge.shutdown();
459459
});
460460

461+
it('answers /workspace/env from process state without consulting ACP, idle or live', async () => {
462+
const handles: ChannelHandle[] = [];
463+
const bridge = makeBridge({
464+
channelFactory: async () => {
465+
const h = makeChannel();
466+
handles.push(h);
467+
return h.channel;
468+
},
469+
});
470+
471+
// Idle path — daemon answers env from `process.*`; no ACP child spawn.
472+
const idle = await bridge.getWorkspaceEnvStatus();
473+
expect(idle).toMatchObject({
474+
v: 1,
475+
workspaceCwd: WS_A,
476+
initialized: true,
477+
acpChannelLive: false,
478+
});
479+
expect(idle.cells.length).toBeGreaterThan(0);
480+
expect(handles).toHaveLength(0);
481+
482+
// Live path — bridge still answers locally; the ACP child sees no
483+
// ext-method invocation for env.
484+
await bridge.spawnOrAttach({ workspaceCwd: WS_A });
485+
const live = await bridge.getWorkspaceEnvStatus();
486+
expect(live.acpChannelLive).toBe(true);
487+
expect(handles).toHaveLength(1);
488+
expect(
489+
handles[0]?.agent.extMethodCalls.some((c) =>
490+
c.method.includes('/workspace/env'),
491+
),
492+
).toBe(false);
493+
494+
await bridge.shutdown();
495+
});
461496
it('requests session status through the existing ACP channel', async () => {
462497
const handles: ChannelHandle[] = [];
463498
const bridge = makeBridge({

packages/cli/src/serve/httpAcpBridge.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,12 @@ import {
2929
createIdleWorkspaceSkillsStatus,
3030
type ServeSessionContextStatus,
3131
type ServeSessionSupportedCommandsStatus,
32+
type ServeWorkspaceEnvStatus,
3233
type ServeWorkspaceMcpStatus,
3334
type ServeWorkspaceProvidersStatus,
3435
type ServeWorkspaceSkillsStatus,
3536
} from './status.js';
37+
import { buildEnvStatusFromProcess } from './envSnapshot.js';
3638
import type {
3739
CancelNotification,
3840
Client,
@@ -354,6 +356,14 @@ export interface HttpAcpBridge {
354356
*/
355357
getWorkspaceProvidersStatus(): Promise<ServeWorkspaceProvidersStatus>;
356358

359+
/**
360+
* Read the daemon-process environment snapshot for the bound workspace.
361+
* Answered entirely from `process.*` state — does not consult ACP. Always
362+
* returns `initialized: true`; `acpChannelLive` reports whether a child is
363+
* currently up.
364+
*/
365+
getWorkspaceEnvStatus(): Promise<ServeWorkspaceEnvStatus>;
366+
357367
/** Read the current ACP context/config state for a live session. */
358368
getSessionContextStatus(
359369
sessionId: string,
@@ -3188,6 +3198,10 @@ export function createHttpAcpBridge(opts: BridgeOptions): HttpAcpBridge {
31883198
);
31893199
},
31903200

3201+
async getWorkspaceEnvStatus() {
3202+
return buildEnvStatusFromProcess(boundWorkspace, !!liveChannelInfo());
3203+
},
3204+
31913205
async getSessionContextStatus(sessionId) {
31923206
return requestSessionStatus(
31933207
sessionId,

packages/cli/src/serve/server.test.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ import type { BridgeEvent, SubscribeOptions } from './eventBus.js';
5252
import type {
5353
ServeSessionContextStatus,
5454
ServeSessionSupportedCommandsStatus,
55+
ServeWorkspaceEnvStatus,
5556
ServeWorkspaceMcpStatus,
5657
ServeWorkspaceProvidersStatus,
5758
ServeWorkspaceSkillsStatus,
@@ -94,6 +95,7 @@ const EXPECTED_STAGE1_FEATURES = [
9495
'workspace_mcp',
9596
'workspace_skills',
9697
'workspace_providers',
98+
'workspace_env',
9799
'session_context',
98100
'session_supported_commands',
99101
'session_close',
@@ -149,6 +151,7 @@ interface FakeBridgeOpts {
149151
workspaceMcpImpl?: () => Promise<ServeWorkspaceMcpStatus>;
150152
workspaceSkillsImpl?: () => Promise<ServeWorkspaceSkillsStatus>;
151153
workspaceProvidersImpl?: () => Promise<ServeWorkspaceProvidersStatus>;
154+
workspaceEnvImpl?: () => Promise<ServeWorkspaceEnvStatus>;
152155
sessionContextImpl?: (
153156
sessionId: string,
154157
) => Promise<ServeSessionContextStatus>;
@@ -211,6 +214,7 @@ interface FakeBridge extends HttpAcpBridge {
211214
workspaceMcpCalls: number;
212215
workspaceSkillsCalls: number;
213216
workspaceProvidersCalls: number;
217+
workspaceEnvCalls: number;
214218
sessionContextCalls: string[];
215219
sessionSupportedCommandsCalls: string[];
216220
setModelCalls: Array<{
@@ -252,6 +256,7 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge {
252256
let workspaceMcpCalls = 0;
253257
let workspaceSkillsCalls = 0;
254258
let workspaceProvidersCalls = 0;
259+
let workspaceEnvCalls = 0;
255260
const sessionContextCalls: string[] = [];
256261
const sessionSupportedCommandsCalls: string[] = [];
257262
const setModelCalls: FakeBridge['setModelCalls'] = [];
@@ -317,6 +322,15 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge {
317322
initialized: false,
318323
providers: [],
319324
}));
325+
const workspaceEnvImpl =
326+
opts.workspaceEnvImpl ??
327+
(async () => ({
328+
v: 1 as const,
329+
workspaceCwd: WS_BOUND,
330+
initialized: true as const,
331+
acpChannelLive: false,
332+
cells: [],
333+
}));
320334
const sessionContextImpl =
321335
opts.sessionContextImpl ??
322336
(async (sessionId) => ({
@@ -385,6 +399,9 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge {
385399
get workspaceProvidersCalls() {
386400
return workspaceProvidersCalls;
387401
},
402+
get workspaceEnvCalls() {
403+
return workspaceEnvCalls;
404+
},
388405
get sessionCount() {
389406
return calls.length;
390407
},
@@ -466,6 +483,10 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge {
466483
workspaceProvidersCalls += 1;
467484
return workspaceProvidersImpl();
468485
},
486+
async getWorkspaceEnvStatus() {
487+
workspaceEnvCalls += 1;
488+
return workspaceEnvImpl();
489+
},
469490
async getSessionContextStatus(sessionId) {
470491
sessionContextCalls.push(sessionId);
471492
return sessionContextImpl(sessionId);
@@ -795,6 +816,44 @@ describe('createServeApp', () => {
795816
expect(bridge.workspaceProvidersCalls).toBe(1);
796817
});
797818

819+
it('returns workspace env status from the bridge', async () => {
820+
const env: ServeWorkspaceEnvStatus = {
821+
v: 1,
822+
workspaceCwd: WS_BOUND,
823+
initialized: true,
824+
acpChannelLive: false,
825+
cells: [
826+
{ kind: 'runtime', name: 'node', status: 'ok', value: '22.4.0' },
827+
{
828+
kind: 'env_var',
829+
name: 'OPENAI_API_KEY',
830+
status: 'ok',
831+
present: true,
832+
},
833+
],
834+
};
835+
const bridge = fakeBridge({ workspaceEnvImpl: async () => env });
836+
const app = createServeApp(
837+
{ ...baseOpts, workspace: WS_BOUND },
838+
undefined,
839+
{ bridge },
840+
);
841+
const res = await request(app)
842+
.get('/workspace/env')
843+
.set('Host', `127.0.0.1:${baseOpts.port}`);
844+
845+
expect(res.status).toBe(200);
846+
expect(res.body).toEqual(env);
847+
expect(bridge.workspaceEnvCalls).toBe(1);
848+
// Strict assertion: env_var cells never carry a value field, even
849+
// when the env var is set, to preserve the presence-only contract.
850+
const envVarCell = (res.body as ServeWorkspaceEnvStatus).cells.find(
851+
(c) => c.kind === 'env_var',
852+
);
853+
expect(envVarCell).toBeDefined();
854+
expect('value' in envVarCell!).toBe(false);
855+
});
856+
798857
it('returns session context and supported commands from the bridge', async () => {
799858
const context: ServeSessionContextStatus = {
800859
v: 1,

packages/cli/src/serve/server.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ export interface ServeAppDeps {
7373
* - `GET /workspace/mcp`
7474
* - `GET /workspace/skills`
7575
* - `GET /workspace/providers`
76+
* - `GET /workspace/env`
7677
* - `POST /session`
7778
* - `POST /session/:id/load`
7879
* - `POST /session/:id/resume`
@@ -288,6 +289,14 @@ export function createServeApp(
288289
}
289290
});
290291

292+
app.get('/workspace/env', async (_req, res) => {
293+
try {
294+
res.status(200).json(await bridge.getWorkspaceEnvStatus());
295+
} catch (err) {
296+
sendBridgeError(res, err, { route: 'GET /workspace/env' });
297+
}
298+
});
299+
291300
app.post('/session', mutate(), async (req, res) => {
292301
const body = safeBody(req);
293302
// #3803 §02: 1 daemon = 1 workspace. Three input shapes:

packages/sdk-typescript/src/daemon/DaemonClient.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import type {
1313
DaemonSession,
1414
DaemonSessionSummary,
1515
DaemonSessionSupportedCommandsStatus,
16+
DaemonWorkspaceEnvStatus,
1617
DaemonWorkspaceMcpStatus,
1718
DaemonWorkspaceProvidersStatus,
1819
DaemonWorkspaceSkillsStatus,
@@ -340,6 +341,17 @@ export class DaemonClient {
340341
);
341342
}
342343

344+
async workspaceEnv(): Promise<DaemonWorkspaceEnvStatus> {
345+
return await this.fetchWithTimeout(
346+
`${this.baseUrl}/workspace/env`,
347+
{ headers: this.headers() },
348+
async (res) => {
349+
if (!res.ok) throw await this.failOnError(res, 'GET /workspace/env');
350+
return (await res.json()) as DaemonWorkspaceEnvStatus;
351+
},
352+
);
353+
}
354+
343355
// -- Sessions ----------------------------------------------------------
344356

345357
async createOrAttachSession(

packages/sdk-typescript/test/unit/DaemonClient.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import type {
1919
DaemonCapabilities,
2020
DaemonSessionContextStatus,
2121
DaemonSessionSupportedCommandsStatus,
22+
DaemonWorkspaceEnvStatus,
2223
DaemonWorkspaceMcpStatus,
2324
DaemonWorkspaceProvidersStatus,
2425
DaemonWorkspaceSkillsStatus,
@@ -217,6 +218,31 @@ describe('DaemonClient', () => {
217218
]);
218219
});
219220

221+
it('GETs /workspace/env and returns the env envelope unchanged', async () => {
222+
const env: DaemonWorkspaceEnvStatus = {
223+
v: 1,
224+
workspaceCwd: '/work/a',
225+
initialized: true,
226+
acpChannelLive: false,
227+
cells: [
228+
{ kind: 'runtime', name: 'node', status: 'ok', value: '22.4.0' },
229+
{
230+
kind: 'env_var',
231+
name: 'OPENAI_API_KEY',
232+
status: 'ok',
233+
present: true,
234+
},
235+
],
236+
};
237+
const { fetch, calls } = recordingFetch(() => jsonResponse(200, env));
238+
const client = new DaemonClient({ baseUrl: 'http://daemon', fetch });
239+
240+
await expect(client.workspaceEnv()).resolves.toEqual(env);
241+
expect(calls.map((c) => [c.method, c.url])).toEqual([
242+
['GET', 'http://daemon/workspace/env'],
243+
]);
244+
});
245+
220246
it('GETs session status routes with encoded session ids', async () => {
221247
const context: DaemonSessionContextStatus = {
222248
v: 1,

0 commit comments

Comments
 (0)