@@ -15,14 +15,14 @@ const REQUESTER_WRITE_HEADERS = {
1515 "x-openclaw-scopes" : "operator.write" ,
1616 "x-openclaw-requester-session-key" : "agent:main:main" ,
1717} ;
18+ const REQUESTER_ADMIN_HEADERS = {
19+ "x-openclaw-scopes" : "operator.admin" ,
20+ "x-openclaw-requester-session-key" : "agent:other:main" ,
21+ } ;
1822
1923let cfg : Record < string , unknown > = { } ;
2024const authMock = vi . fn ( async ( ) : Promise < GatewayAuthResult > => ( { ok : true } ) ) ;
21- const isLocalDirectRequestMock = vi . fn ( ( ) => true ) ;
2225const loadSessionEntryMock = vi . fn ( ) ;
23- const getLatestSubagentRunByChildSessionKeyMock = vi . fn ( ) ;
24- const resolveSubagentControllerMock = vi . fn ( ) ;
25- const killControlledSubagentRunMock = vi . fn ( ) ;
2626const killSubagentRunAdminMock = vi . fn ( ) ;
2727
2828vi . mock ( "../config/config.js" , ( ) => ( {
@@ -35,21 +35,14 @@ vi.mock("../config/io.js", () => ({
3535
3636vi . mock ( "./auth.js" , ( ) => ( {
3737 authorizeHttpGatewayConnect : authMock ,
38- isLocalDirectRequest : isLocalDirectRequestMock ,
3938} ) ) ;
4039
4140vi . mock ( "./session-utils.js" , ( ) => ( {
4241 loadSessionEntry : loadSessionEntryMock ,
4342} ) ) ;
4443
45- vi . mock ( "../agents/subagent-registry.js" , ( ) => ( {
46- getLatestSubagentRunByChildSessionKey : getLatestSubagentRunByChildSessionKeyMock ,
47- } ) ) ;
48-
4944vi . mock ( "../agents/subagent-control.js" , ( ) => ( {
50- killControlledSubagentRun : killControlledSubagentRunMock ,
5145 killSubagentRunAdmin : killSubagentRunAdminMock ,
52- resolveSubagentController : resolveSubagentControllerMock ,
5346} ) ) ;
5447
5548const { handleSessionKillHttpRequest } = await import ( "./session-kill-http.js" ) ;
@@ -93,13 +86,7 @@ beforeEach(() => {
9386 cfg = { } ;
9487 authMock . mockReset ( ) ;
9588 authMock . mockResolvedValue ( { ok : true , method : "token" } ) ;
96- isLocalDirectRequestMock . mockReset ( ) ;
97- isLocalDirectRequestMock . mockReturnValue ( true ) ;
9889 loadSessionEntryMock . mockReset ( ) ;
99- getLatestSubagentRunByChildSessionKeyMock . mockReset ( ) ;
100- resolveSubagentControllerMock . mockReset ( ) ;
101- resolveSubagentControllerMock . mockReturnValue ( { controllerSessionKey : "agent:main:main" } ) ;
102- killControlledSubagentRunMock . mockReset ( ) ;
10390 killSubagentRunAdminMock . mockReset ( ) ;
10491} ) ;
10592
@@ -134,12 +121,6 @@ function mockWorkerSession() {
134121 } ) ;
135122}
136123
137- function allowRemoteRequesterKill ( ) {
138- isLocalDirectRequestMock . mockReturnValue ( false ) ;
139- allowTrustedProxyAuth ( ) ;
140- mockWorkerSession ( ) ;
141- }
142-
143124async function expectForbiddenMissingScope ( response : Response , message : string ) {
144125 expect ( response . status ) . toBe ( 403 ) ;
145126 expectErrorResponse ( await response . json ( ) , {
@@ -148,11 +129,6 @@ async function expectForbiddenMissingScope(response: Response, message: string)
148129 } ) ;
149130}
150131
151- async function expectRequesterKillResponse ( response : Response , killed : boolean ) {
152- expect ( response . status ) . toBe ( 200 ) ;
153- await expect ( response . json ( ) ) . resolves . toEqual ( { ok : true , killed } ) ;
154- }
155-
156132function expectErrorResponse ( body : unknown , expected : { type : string ; message ?: string } ) {
157133 const response = body as {
158134 ok ?: unknown ;
@@ -210,7 +186,6 @@ describe("POST /sessions/:sessionKey/kill", () => {
210186 expect ( authMock ) . not . toHaveBeenCalled ( ) ;
211187 expect ( loadSessionEntryMock ) . not . toHaveBeenCalled ( ) ;
212188 expect ( killSubagentRunAdminMock ) . not . toHaveBeenCalled ( ) ;
213- expect ( killControlledSubagentRunMock ) . not . toHaveBeenCalled ( ) ;
214189 } ,
215190 ) ;
216191
@@ -261,8 +236,7 @@ describe("POST /sessions/:sessionKey/kill", () => {
261236 expect ( killSubagentRunAdminMock ) . not . toHaveBeenCalled ( ) ;
262237 } ) ;
263238
264- it ( "rejects remote bearer-auth kills without requester ownership" , async ( ) => {
265- isLocalDirectRequestMock . mockReturnValue ( false ) ;
239+ it ( "rejects bearer-auth kills without a trusted admin scope surface" , async ( ) => {
266240 mockWorkerSession ( ) ;
267241
268242 const response = await postWorkerKill ( ) ;
@@ -271,63 +245,29 @@ describe("POST /sessions/:sessionKey/kill", () => {
271245 expect ( killSubagentRunAdminMock ) . not . toHaveBeenCalled ( ) ;
272246 } ) ;
273247
274- it ( "rejects remote kills without requester ownership or an authorized token" , async ( ) => {
275- isLocalDirectRequestMock . mockReturnValue ( false ) ;
276- authMock . mockResolvedValueOnce ( { ok : true } ) ;
277- mockWorkerSession ( ) ;
278-
279- const response = await postWorkerKill ( "" , {
280- authorization : "" ,
281- } ) ;
282- expect ( response . status ) . toBe ( 403 ) ;
248+ it ( "rejects trusted-proxy requester-session kills without admin scope" , async ( ) => {
249+ allowTrustedProxyAuth ( ) ;
250+ const response = await postWorkerKill ( "" , REQUESTER_WRITE_HEADERS ) ;
251+ await expectForbiddenMissingScope ( response , "missing scope: operator.admin" ) ;
252+ expect ( loadSessionEntryMock ) . not . toHaveBeenCalled ( ) ;
283253 expect ( killSubagentRunAdminMock ) . not . toHaveBeenCalled ( ) ;
284254 } ) ;
285255
286- it ( "uses requester ownership checks when a requester session header is provided without admin bypass" , async ( ) => {
287- allowRemoteRequesterKill ( ) ;
288- getLatestSubagentRunByChildSessionKeyMock . mockReturnValue ( {
289- runId : "run-1" ,
290- childSessionKey : WORKER_SESSION_KEY ,
291- } ) ;
292- killControlledSubagentRunMock . mockResolvedValue ( { status : "ok" } ) ;
256+ it ( "uses the admin kill path even when the requester session header is present" , async ( ) => {
257+ allowTrustedProxyAuth ( ) ;
258+ mockWorkerSession ( ) ;
259+ killSubagentRunAdminMock . mockResolvedValue ( { found : true , killed : true } ) ;
293260
294- const response = await postWorkerKill ( "" , REQUESTER_WRITE_HEADERS ) ;
295- await expectRequesterKillResponse ( response , true ) ;
296- expect ( resolveSubagentControllerMock ) . toHaveBeenCalledWith ( {
261+ const response = await postWorkerKill ( "" , REQUESTER_ADMIN_HEADERS ) ;
262+ expect ( response . status ) . toBe ( 200 ) ;
263+ await expect ( response . json ( ) ) . resolves . toEqual ( { ok : true , killed : true } ) ;
264+ expect ( killSubagentRunAdminMock ) . toHaveBeenCalledWith ( {
297265 cfg,
298- agentSessionKey : "agent:main:main" ,
299- } ) ;
300- expect ( getLatestSubagentRunByChildSessionKeyMock ) . toHaveBeenCalledWith ( WORKER_SESSION_KEY ) ;
301- expect ( killSubagentRunAdminMock ) . not . toHaveBeenCalled ( ) ;
302- } ) ;
303-
304- it ( "uses the newest child-session row for requester-owned kills when stale rows still exist" , async ( ) => {
305- allowRemoteRequesterKill ( ) ;
306- getLatestSubagentRunByChildSessionKeyMock . mockReturnValue ( {
307- runId : "run-current-ended" ,
308- childSessionKey : WORKER_SESSION_KEY ,
309- endedAt : Date . now ( ) - 1 ,
266+ sessionKey : WORKER_SESSION_KEY ,
310267 } ) ;
311- killControlledSubagentRunMock . mockResolvedValue ( { status : "done" } ) ;
312-
313- const response = await postWorkerKill ( "" , REQUESTER_WRITE_HEADERS ) ;
314- await expectRequesterKillResponse ( response , false ) ;
315- expect ( killControlledSubagentRunMock ) . toHaveBeenCalledTimes ( 1 ) ;
316- const killCall = killControlledSubagentRunMock . mock . calls . at ( 0 ) ?. [ 0 ] as
317- | {
318- cfg ?: unknown ;
319- controller ?: { controllerSessionKey ?: string } ;
320- entry ?: { runId ?: string ; childSessionKey ?: string } ;
321- }
322- | undefined ;
323- expect ( killCall ?. cfg ) . toBe ( cfg ) ;
324- expect ( killCall ?. controller ?. controllerSessionKey ) . toBe ( "agent:main:main" ) ;
325- expect ( killCall ?. entry ?. runId ) . toBe ( "run-current-ended" ) ;
326- expect ( killCall ?. entry ?. childSessionKey ) . toBe ( WORKER_SESSION_KEY ) ;
327268 } ) ;
328269
329- it ( "rejects bearer-auth requester kills without a trusted write scope surface" , async ( ) => {
330- isLocalDirectRequestMock . mockReturnValue ( false ) ;
270+ it ( "rejects bearer-auth requester kills without a trusted admin scope surface" , async ( ) => {
331271 const response = await post (
332272 "/sessions/agent%3Amain%3Asubagent%3Aworker/kill" ,
333273 TEST_GATEWAY_TOKEN ,
@@ -336,10 +276,9 @@ describe("POST /sessions/:sessionKey/kill", () => {
336276 expect ( response . status ) . toBe ( 403 ) ;
337277 expectErrorResponse ( await response . json ( ) , {
338278 type : "forbidden" ,
339- message : "missing scope: operator.write " ,
279+ message : "missing scope: operator.admin " ,
340280 } ) ;
341281 expect ( loadSessionEntryMock ) . not . toHaveBeenCalled ( ) ;
342282 expect ( killSubagentRunAdminMock ) . not . toHaveBeenCalled ( ) ;
343- expect ( killControlledSubagentRunMock ) . not . toHaveBeenCalled ( ) ;
344283 } ) ;
345284} ) ;
0 commit comments