@@ -25,6 +25,7 @@ import {
2525 opencodeCatalogFromProxyRows ,
2626 opencodeGlobalConfigPath ,
2727 opencodeLaunchNativeSlugs ,
28+ opencodeManagementToken ,
2829 opencodeModelKey ,
2930 opencodeNotFoundHint ,
3031 opencodeProviderOverridePath ,
@@ -718,6 +719,105 @@ describe("ocx opencode admission key", () => {
718719 } ) ;
719720} ) ;
720721
722+ describe ( "ocx opencode management token" , ( ) => {
723+ // GET /api/models is a management route, so the launcher must present the admin credential there;
724+ // sending the data-plane admission key is what produced "opencodex admin token required" (401).
725+ const TOUCHED = [ "OPENCODEX_ADMIN_AUTH_TOKEN" , "OPENCODEX_HOME" , "OPENCODEX_API_AUTH_TOKEN" , "OCX_API_TOKEN_FILE" ] as const ;
726+
727+ function withEnv ( overrides : Partial < Record < ( typeof TOUCHED ) [ number ] , string > > , run : ( ) => void ) : void {
728+ const saved = new Map < string , string | undefined > ( TOUCHED . map ( key => [ key , process . env [ key ] ] ) ) ;
729+ try {
730+ for ( const key of TOUCHED ) delete process . env [ key ] ;
731+ for ( const [ key , value ] of Object . entries ( overrides ) ) process . env [ key ] = value ;
732+ run ( ) ;
733+ } finally {
734+ for ( const [ key , value ] of saved ) {
735+ if ( value === undefined ) delete process . env [ key ] ;
736+ else process . env [ key ] = value ;
737+ }
738+ }
739+ }
740+
741+ const adminToken = `ocx_admin_${ "a" . repeat ( 43 ) } ` ;
742+
743+ test ( "the configured admin token wins over the admission key" , ( ) => {
744+ withEnv ( { } , ( ) => {
745+ const config = cfg ( { apiKeys : [ { id : "1" , name : "main" , key : "sk-cfg" , createdAt : "2026-01-01" } ] } ) ;
746+ expect ( opencodeManagementToken ( config , { OPENCODEX_ADMIN_AUTH_TOKEN : adminToken } ) ) . toBe ( adminToken ) ;
747+ } ) ;
748+ } ) ;
749+
750+ test ( "falls back to the admin-api-token file in OPENCODEX_HOME" , ( ) => {
751+ const dir = mkdtempSync ( join ( tmpdir ( ) , "ocx-opencode-admin-" ) ) ;
752+ writeFileSync ( join ( dir , "admin-api-token" ) , `${ adminToken } \n` , { mode : 0o600 } ) ;
753+ try {
754+ withEnv ( { OPENCODEX_HOME : dir } , ( ) => {
755+ const config = cfg ( { apiKeys : [ { id : "1" , name : "main" , key : "sk-cfg" , createdAt : "2026-01-01" } ] } ) ;
756+ expect ( opencodeManagementToken ( config , { OPENCODEX_ADMIN_AUTH_TOKEN : " " } ) ) . toBe ( adminToken ) ;
757+ } ) ;
758+ } finally {
759+ removeTreeWithRetry ( dir ) ;
760+ }
761+ } ) ;
762+
763+ test ( "falls back to the admission key when no admin token is configured" , ( ) => {
764+ // An explicit empty OPENCODEX_HOME keeps this independent of any admin token the runner's
765+ // sandbox home may already carry.
766+ const dir = mkdtempSync ( join ( tmpdir ( ) , "ocx-opencode-no-admin-" ) ) ;
767+ try {
768+ withEnv ( { OPENCODEX_HOME : dir } , ( ) => {
769+ const config = cfg ( { apiKeys : [ { id : "1" , name : "main" , key : "sk-cfg" , createdAt : "2026-01-01" } ] } ) ;
770+ expect ( opencodeManagementToken ( config , { } ) ) . toBe ( "sk-cfg" ) ;
771+ } ) ;
772+ } finally {
773+ removeTreeWithRetry ( dir ) ;
774+ }
775+ } ) ;
776+
777+ test ( "cmdOpencode sends the admin token — not the admission key — on GET /api/models" , async ( ) => {
778+ const home = mkdtempSync ( join ( tmpdir ( ) , "ocx-opencode-admin-read-" ) ) ;
779+ const envKeys = [ ...TOUCHED , "CODEX_HOME" , "XDG_CONFIG_HOME" , OPENCODE_CONFIG_CONTENT_ENV ] ;
780+ const previous = Object . fromEntries ( envKeys . map ( key => [ key , process . env [ key ] ] ) ) ;
781+ const liveness = await import ( "../../src/server/proxy-liveness" ) ;
782+ const finder = spyOn ( liveness , "findLiveProxy" ) . mockResolvedValue ( {
783+ port : 10123 , hostname : "127.0.0.1" , pid : null , source : "config" ,
784+ } ) ;
785+ let sentKey : string | null = null ;
786+ const fetcher = spyOn ( globalThis , "fetch" ) . mockImplementation ( async ( input , init ) => {
787+ expect ( String ( input ) ) . toBe ( "http://127.0.0.1:10123/api/models" ) ;
788+ sentKey = new Headers ( init ?. headers ) . get ( "X-OpenCodex-API-Key" ) ;
789+ return Response . json ( [ ] ) ;
790+ } ) ;
791+ const spawn = spyOn ( childProcess , "spawn" ) . mockImplementation ( ( ) => {
792+ const child = new childProcess . ChildProcess ( ) ;
793+ queueMicrotask ( ( ) => child . emit ( "exit" , 0 , null ) ) ;
794+ return child ;
795+ } ) ;
796+ const stderr = spyOn ( console , "error" ) . mockImplementation ( ( ) => { } ) ;
797+ try {
798+ for ( const key of envKeys ) delete process . env [ key ] ;
799+ process . env . OPENCODEX_HOME = home ;
800+ process . env . CODEX_HOME = join ( home , "codex" ) ;
801+ process . env . XDG_CONFIG_HOME = join ( home , "xdg" ) ;
802+ process . env . OPENCODEX_ADMIN_AUTH_TOKEN = adminToken ;
803+ mkdirSync ( process . env . CODEX_HOME ) ;
804+ writeFileSync ( join ( home , "config.json" ) , JSON . stringify (
805+ cfg ( { apiKeys : [ { id : "1" , name : "main" , key : "sk-cfg" , createdAt : "2026-01-01" } ] } ) ,
806+ ) ) ;
807+ expect ( await cmdOpencode ( [ ] ) ) . toBe ( 0 ) ;
808+ expect ( sentKey ) . toBe ( adminToken ) ;
809+ expect ( sentKey ) . not . toBe ( "sk-cfg" ) ;
810+ } finally {
811+ finder . mockRestore ( ) ; fetcher . mockRestore ( ) ; spawn . mockRestore ( ) ; stderr . mockRestore ( ) ;
812+ for ( const [ key , value ] of Object . entries ( previous ) ) {
813+ if ( value === undefined ) delete process . env [ key ] ;
814+ else process . env [ key ] = value ;
815+ }
816+ removeTreeWithRetry ( home ) ;
817+ }
818+ } ) ;
819+ } ) ;
820+
721821describe ( "ocx opencode proxy auto-start env" , ( ) => {
722822 test ( "passes OCX_API_TOKEN_FILE to ocx start when only the hardened service token exists" , ( ) => {
723823 const dir = mkdtempSync ( join ( tmpdir ( ) , "ocx-opencode-start-" ) ) ;
0 commit comments