2026-08-10 20:23:45 +03:00
import express from 'express' ;
2026-04-18 13:46:38 +03:00
import { createProjectIdFromPath } from '../projects/project-id.js' ;
2026-04-30 14:39:55 -07:00
import fs from 'fs' ;
import path from 'path' ;
2026-08-03 06:40:01 +00:00
import {
buildDeferredRestartResponse ,
} from './config-mutation-response.js' ;
2026-08-15 01:59:26 +03:00
import { getClaudeCliAuthStatus } from './claude-cli-auth.js' ;
2026-09-04 17:14:26 -06:00
import { OPENCODE_CONFIG_DIR } from './shared.js' ;
2026-09-07 17:50:55 +03:00
import { settingsSurfaceOf } from './settings-files.js' ;
2026-04-18 13:46:38 +03:00
2026-03-31 18:47:00 +03:00
export const registerOpenCodeRoutes = ( app , dependencies ) => {
const {
crypto ,
getOpenCodeResolutionSnapshot ,
2026-07-29 19:59:41 +03:00
getOpenCodeUpgradeCapability ,
2026-03-31 18:47:00 +03:00
formatSettingsResponse ,
readSettingsFromDisk ,
readSettingsFromDiskMigrated ,
persistSettings ,
sanitizeProjects ,
validateDirectoryPath ,
resolveProjectDirectory ,
getProviderSources ,
removeProviderConfig ,
2026-08-02 09:12:28 +00:00
upsertProviderConfig ,
2026-03-31 18:47:00 +03:00
refreshOpenCodeAfterConfigChange ,
2026-05-14 14:45:04 +03:00
buildOpenCodeUrl ,
getOpenCodeAuthHeaders ,
2026-08-10 16:10:03 +03:00
fsPromises = fs . promises ,
2026-03-31 18:47:00 +03:00
} = dependencies ;
let authLibrary = null ;
2026-04-21 11:46:51 -06:00
const pendingMcpAuthContextByState = new Map ();
const PENDING_MCP_AUTH_TTL_MS = 30 * 60 * 1000 ;
2026-03-31 18:47:00 +03:00
const getAuthLibrary = async () => {
if ( ! authLibrary ) {
authLibrary = await import ( './auth.js' );
}
return authLibrary ;
};
2026-04-21 11:46:51 -06:00
const normalizePendingString = ( value ) => {
if ( typeof value !== 'string' ) {
return null ;
}
const trimmed = value . trim ();
return trimmed || null ;
};
2026-08-10 20:23:45 +03:00
const escapeHtml = ( value ) => String ( value )
. replace ( /&/g , '&' )
. replace ( /</g , '<' )
. replace ( />/g , '>' )
. replace ( /"/g , '"' )
. replace ( /'/g , ''' );
// Self-contained page for the OAuth return leg: the system browser has no UI
// session, so it cannot load the SPA behind the auth gate — everything it
// needs ships inline. `openchamber://focus/mcp-auth` raises the desktop app;
// the link stays visible because some browsers only follow custom-protocol
// URLs from a user gesture.
const renderMcpOAuthCallbackPage = ({ title , message , desktopReturn }) => `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title> ${ escapeHtml ( title ) } — OpenChamber</title>
<style>
:root { color-scheme: light dark; }
body { margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: Canvas; color: CanvasText; }
main { max-width: 34rem; padding: 2.5rem 2rem; text-align: center; }
h1 { font-size: 1.25rem; margin: 0 0 0.75rem; }
p { margin: 0; line-height: 1.5; opacity: 0.85; }
a.return { display: inline-block; margin-top: 1.5rem; padding: 0.5rem 1.25rem; border-radius: 0.5rem;
border: 1px solid color-mix(in srgb, CanvasText 25%, transparent); color: inherit; text-decoration: none; }
</style>
</head>
<body>
<main>
<h1> ${ escapeHtml ( title ) } </h1>
<p> ${ escapeHtml ( message ) } </p>
${ desktopReturn ? `<a class="return" href="openchamber://focus/mcp-auth">Return to OpenChamber</a>
<script>window.location.href = 'openchamber://focus/mcp-auth';</script>` : '' }
</main>
</body>
</html>` ;
2026-07-02 17:43:33 +03:00
const readOpenCodeCurrentVersion = async () => {
const healthResponse = await fetch ( buildOpenCodeUrl ( '/global/health' , '' ), {
method : 'GET' ,
headers : { Accept : 'application/json' , ... getOpenCodeAuthHeaders () },
});
const health = await healthResponse . json (). catch (() => null );
if ( ! healthResponse . ok ) {
return { ok : false , status : healthResponse . status , error : health ? . error || healthResponse . statusText };
}
const currentVersion = typeof health ? . version === 'string' ? health . version . replace ( /^v/ , '' ) : null ;
return { ok : true , currentVersion };
};
2026-05-14 14:45:04 +03:00
const parseVersionForComparison = ( value ) => {
const normalized = String ( value || '' ). replace ( /^v/ , '' ). split ( '+' )[ 0 ];
const prereleaseIndex = normalized . indexOf ( '-' );
const core = prereleaseIndex >= 0 ? normalized . slice ( 0 , prereleaseIndex ) : normalized ;
const parts = core . split ( '.' ). map (( part ) => {
const parsed = Number . parseInt ( part || '0' , 10 );
return Number . isFinite ( parsed ) ? parsed : 0 ;
});
return { parts , prerelease : prereleaseIndex >= 0 };
};
const compareVersions = ( left , right ) => {
const a = parseVersionForComparison ( left );
const b = parseVersionForComparison ( right );
const length = Math . max ( a . parts . length , b . parts . length );
for ( let index = 0 ; index < length ; index += 1 ) {
const diff = ( a . parts [ index ] || 0 ) - ( b . parts [ index ] || 0 );
if ( diff !== 0 ) return diff ;
}
if ( a . prerelease !== b . prerelease ) return a . prerelease ? - 1 : 1 ;
return 0 ;
};
const fetchLatestOpenCodeVersionFromGithub = async () => {
const response = await fetch ( 'https://api.github.com/repos/anomalyco/opencode/releases/latest' , {
headers : { Accept : 'application/json' },
signal : AbortSignal . timeout ( 10_000 ),
});
if ( ! response . ok ) {
throw new Error ( `OpenCode releases responded with ${ response . status } ` );
}
const payload = await response . json ();
const tag = typeof payload ? . tag_name === 'string' ? payload . tag_name . trim () : '' ;
return tag . replace ( /^v/ , '' );
};
const fetchLatestOpenCodeVersionFromNpm = async () => {
const response = await fetch ( 'https://registry.npmjs.org/opencode-ai/latest' , {
headers : { Accept : 'application/json' },
signal : AbortSignal . timeout ( 10_000 ),
});
if ( ! response . ok ) {
throw new Error ( `OpenCode npm registry responded with ${ response . status } ` );
}
const payload = await response . json ();
return typeof payload ? . version === 'string' ? payload . version . trim (). replace ( /^v/ , '' ) : '' ;
};
const fetchLatestOpenCodeVersion = async () => {
const results = await Promise . allSettled ([
fetchLatestOpenCodeVersionFromNpm (),
fetchLatestOpenCodeVersionFromGithub (),
]);
const versions = results
. filter (( result ) => result . status === 'fulfilled' && result . value )
. map (( result ) => result . value );
if ( versions . length === 0 ) {
const failure = results . find (( result ) => result . status === 'rejected' );
throw failure ? . reason instanceof Error ? failure . reason : new Error ( 'Failed to resolve latest OpenCode version' );
}
return versions . sort (( left , right ) => compareVersions ( right , left ))[ 0 ];
};
2026-08-28 18:45:40 +03:00
// OpenCode's `/global/upgrade` requires an explicit semver target and rejects
// a bodyless call, so "update to the latest" has to name the version. The
// release lookup is the same one the upgrade-status check already uses to
// decide there is anything to offer.
const resolveOpenCodeUpgradeTarget = async ( requestedTarget ) => {
if ( typeof requestedTarget === 'string' && requestedTarget . trim (). length > 0 ) {
return { resolved : true , target : requestedTarget . trim () };
}
try {
const latest = await fetchLatestOpenCodeVersion ();
if ( ! latest ) {
return { resolved : false , reason : 'The latest OpenCode version could not be determined.' };
}
return { resolved : true , target : latest };
} catch ( error ) {
return {
resolved : false ,
reason : error instanceof Error ? error . message : 'The latest OpenCode version could not be determined.' ,
};
}
};
// OpenCode reports a rejected upgrade as `{ name, data: { message, kind } }`,
// which carries no `error` field. Reading only `error` left the user with the
// bare HTTP status text ("Bad Request") and nothing to act on.
const readOpenCodeUpgradeErrorMessage = ( payload , response ) => {
const candidates = [ payload ? . error , payload ? . data ? . message , payload ? . message ];
for ( const candidate of candidates ) {
if ( typeof candidate === 'string' && candidate . trim (). length > 0 ) {
return candidate . trim ();
}
}
return response . statusText || 'Failed to upgrade OpenCode' ;
};
2026-04-21 11:46:51 -06:00
const pruneExpiredPendingMcpAuthContexts = () => {
const now = Date . now ();
for ( const [ state , entry ] of pendingMcpAuthContextByState . entries ()) {
if ( ! entry || typeof entry . expiresAt !== 'number' || entry . expiresAt <= now ) {
pendingMcpAuthContextByState . delete ( state );
}
}
};
2026-09-07 17:50:55 +03:00
app . get ( '/api/config/settings' , async ( req , res ) => {
2026-03-31 18:47:00 +03:00
try {
2026-09-07 17:50:55 +03:00
// The surface kind resolves the per-surface profile keys; absent means base.
const settings = await readSettingsFromDiskMigrated ({ surface : settingsSurfaceOf ( req ) });
2026-03-31 18:47:00 +03:00
res . json ( formatSettingsResponse ( settings ));
} catch ( error ) {
console . error ( 'Failed to read settings:' , error );
res . status ( 500 ). json ({ error : 'Failed to read settings' });
}
});
app . get ( '/api/config/opencode-resolution' , async ( _req , res ) => {
try {
const settings = await readSettingsFromDiskMigrated ();
const resolution = await getOpenCodeResolutionSnapshot ( settings );
res . json ( resolution );
} catch ( error ) {
console . error ( 'Failed to resolve OpenCode binary:' , error );
res . status ( 500 ). json ({ error : 'Failed to resolve OpenCode binary' });
}
});
2026-07-29 19:59:41 +03:00
let openCodeUpgradePromise = null ;
2026-05-14 14:45:04 +03:00
app . post ( '/api/opencode/upgrade' , async ( req , res ) => {
try {
2026-07-29 19:59:41 +03:00
const capability = getOpenCodeUpgradeCapability ();
if ( ! capability . supported ) {
return res . status ( 409 ). json ({
success : false ,
code : capability . reason === 'bundled'
? 'OPENCODE_UPGRADE_MANAGED_BY_OPENCHAMBER'
: 'OPENCODE_UPGRADE_UNSUPPORTED' ,
error : capability . reason === 'bundled'
? 'OpenCode is bundled with OpenChamber Desktop and updates with the app.'
: 'This OpenCode runtime cannot be upgraded by OpenChamber.' ,
});
}
if ( openCodeUpgradePromise ) {
2026-07-02 17:43:33 +03:00
return res . status ( 409 ). json ({
success : false ,
2026-07-29 19:59:41 +03:00
code : 'OPENCODE_UPGRADE_IN_PROGRESS' ,
error : 'An OpenCode upgrade is already in progress.' ,
2026-07-02 17:43:33 +03:00
});
}
2026-08-28 18:45:40 +03:00
const requestedTarget = req . body ? . target ;
// The target lookup reaches the network, so it runs inside the operation:
// the in-flight lock is taken synchronously above, and a second click
// cannot slip past while the release version is being resolved.
2026-07-29 19:59:41 +03:00
const upgradeOperation = ( async () => {
2026-08-28 18:45:40 +03:00
const targetResolution = await resolveOpenCodeUpgradeTarget ( requestedTarget );
if ( ! targetResolution . resolved ) {
return {
status : 502 ,
body : {
success : false ,
code : 'OPENCODE_UPGRADE_TARGET_UNRESOLVED' ,
error : `Could not determine which OpenCode version to install: ${ targetResolution . reason } ` ,
},
};
}
2026-07-29 19:59:41 +03:00
const response = await fetch ( buildOpenCodeUrl ( '/global/upgrade' , '' ), {
method : 'POST' ,
headers : {
'Content-Type' : 'application/json' ,
Accept : 'application/json' ,
... getOpenCodeAuthHeaders (),
},
2026-08-28 18:45:40 +03:00
body : JSON . stringify ({ target : targetResolution . target }),
2026-05-14 14:45:04 +03:00
});
2026-07-29 19:59:41 +03:00
const payload = await response . json (). catch (() => null );
if ( ! response . ok ) {
return {
status : response . status ,
body : {
success : false ,
2026-08-28 18:45:40 +03:00
error : readOpenCodeUpgradeErrorMessage ( payload , response ),
2026-07-29 19:59:41 +03:00
},
};
}
try {
await refreshOpenCodeAfterConfigChange ( 'OpenCode upgrade' );
} catch ( restartError ) {
return {
status : 500 ,
body : {
success : false ,
upgraded : true ,
error : restartError instanceof Error
? `OpenCode upgraded, but restart failed: ${ restartError . message } `
: 'OpenCode upgraded, but restart failed' ,
},
};
}
return {
status : 200 ,
body : { ...( payload ?? { success : true }), restarted : true },
};
})();
openCodeUpgradePromise = upgradeOperation ;
2026-05-20 17:29:00 +03:00
try {
2026-07-29 19:59:41 +03:00
const result = await upgradeOperation ;
return res . status ( result . status ). json ( result . body );
} finally {
if ( openCodeUpgradePromise === upgradeOperation ) {
openCodeUpgradePromise = null ;
}
2026-05-20 17:29:00 +03:00
}
2026-05-14 14:45:04 +03:00
} catch ( error ) {
console . error ( 'Failed to upgrade OpenCode:' , error );
return res . status ( 500 ). json ({
success : false ,
error : error instanceof Error ? error . message : 'Failed to upgrade OpenCode' ,
});
}
});
app . get ( '/api/opencode/upgrade-status' , async ( _req , res ) => {
try {
2026-07-29 19:59:41 +03:00
const capability = getOpenCodeUpgradeCapability ();
if ( ! capability . supported ) {
2026-07-02 17:43:33 +03:00
const current = await readOpenCodeCurrentVersion (). catch (() => ({ ok : false , currentVersion : null }));
return res . json ({
available : false ,
currentVersion : current . ok ? current . currentVersion : null ,
latestVersion : null ,
2026-07-29 19:59:41 +03:00
upgrade : capability ,
2026-07-02 17:43:33 +03:00
});
}
2026-05-14 14:45:04 +03:00
const [ healthResponse , latestVersion ] = await Promise . all ([
fetch ( buildOpenCodeUrl ( '/global/health' , '' ), {
method : 'GET' ,
headers : { Accept : 'application/json' , ... getOpenCodeAuthHeaders () },
}),
fetchLatestOpenCodeVersion (),
]);
const health = await healthResponse . json (). catch (() => null );
if ( ! healthResponse . ok ) {
return res . status ( healthResponse . status ). json ({
available : null ,
error : health ? . error || healthResponse . statusText || 'Failed to read OpenCode version' ,
});
}
const currentVersion = typeof health ? . version === 'string' ? health . version . replace ( /^v/ , '' ) : null ;
if ( ! currentVersion || ! latestVersion ) {
return res . json ({ available : null , currentVersion , latestVersion : latestVersion || null });
}
const available = compareVersions ( latestVersion , currentVersion ) > 0 ;
return res . json ({
available ,
currentVersion ,
latestVersion ,
2026-07-29 19:59:41 +03:00
upgrade : capability ,
2026-05-14 14:45:04 +03:00
});
} catch ( error ) {
return res . status ( 500 ). json ({
available : null ,
error : error instanceof Error ? error . message : 'Failed to check OpenCode upgrade status' ,
});
}
});
2026-06-05 15:09:24 +03:00
app . get ( '/api/opencode/health' , async ( _req , res ) => {
try {
2026-06-05 18:43:13 +03:00
const healthResponse = await fetch ( buildOpenCodeUrl ( '/global/health' , '' ), {
2026-06-05 15:09:24 +03:00
method : 'GET' ,
headers : { Accept : 'application/json' , ... getOpenCodeAuthHeaders () },
});
const health = await healthResponse . json (). catch (() => null );
if ( ! healthResponse . ok ) {
return res . status ( healthResponse . status ). json ({
healthy : false ,
error : health ? . error || healthResponse . statusText || 'OpenCode health check failed' ,
});
}
return res . json ({ healthy : health ? . healthy === true });
} catch ( error ) {
return res . status ( 503 ). json ({
healthy : false ,
error : error instanceof Error ? error . message : 'OpenCode health check failed' ,
});
}
});
app . get ( '/api/opencode/version' , async ( _req , res ) => {
try {
const healthResponse = await fetch ( buildOpenCodeUrl ( '/global/health' , '' ), {
method : 'GET' ,
headers : { Accept : 'application/json' , ... getOpenCodeAuthHeaders () },
});
const health = await healthResponse . json (). catch (() => null );
if ( ! healthResponse . ok ) {
return res . status ( healthResponse . status ). json ({
version : null ,
error : health ? . error || healthResponse . statusText || 'Failed to read OpenCode version' ,
});
}
const version = typeof health ? . version === 'string' ? health . version . replace ( /^v/ , '' ) : null ;
return res . json ({ version });
} catch ( error ) {
return res . status ( 500 ). json ({
version : null ,
error : error instanceof Error ? error . message : 'Failed to read OpenCode version' ,
});
}
});
2026-03-31 18:47:00 +03:00
app . put ( '/api/config/settings' , async ( req , res ) => {
try {
2026-09-07 17:50:55 +03:00
const updated = await persistSettings ( req . body ?? {}, { surface : settingsSurfaceOf ( req ) });
2026-03-31 18:47:00 +03:00
res . json ( updated );
} catch ( error ) {
console . error ( '[API:PUT /api/config/settings] Failed to save settings:' , error );
console . error ( '[API:PUT /api/config/settings] Error stack:' , error . stack );
res . status ( 500 ). json ({ error : 'Failed to save settings' });
}
});
2026-08-10 20:23:45 +03:00
// The body parser is per-route on this server; without it req.body is
// undefined here, the state read as absent, and the "parked" context was
// silently never stored — the callback then always failed as unknown.
app . post ( '/api/mcp/auth/pending' , express . json ({ limit : '16kb' }), async ( req , res ) => {
2026-04-21 11:46:51 -06:00
try {
pruneExpiredPendingMcpAuthContexts ();
const state = normalizePendingString ( req . body ? . state );
if ( ! state ) {
return res . json ({ success : true , context : null });
}
const name = normalizePendingString ( req . body ? . name );
if ( ! name ) {
return res . status ( 400 ). json ({ error : 'MCP server name is required' });
}
const entry = {
name ,
directory : normalizePendingString ( req . body ? . directory ),
2026-08-09 19:30:25 +03:00
// Which surface started the flow. It belongs here rather than in the
// redirect URI: that URI is written into the server's config once and
// deliberately never rewritten, so anything encoded in it would be
// frozen at whatever runtime authorised first.
origin : normalizePendingString ( req . body ? . origin ),
2026-04-21 11:46:51 -06:00
expiresAt : Date . now () + PENDING_MCP_AUTH_TTL_MS ,
};
pendingMcpAuthContextByState . set ( state , entry );
return res . json ({
success : true ,
context : {
name : entry . name ,
directory : entry . directory ,
2026-08-09 19:30:25 +03:00
origin : entry . origin ,
2026-04-21 11:46:51 -06:00
},
});
} catch ( error ) {
console . error ( 'Failed to store pending MCP auth context:' , error );
return res . status ( 500 ). json ({ error : error . message || 'Failed to store pending MCP auth context' });
}
});
app . get ( '/api/mcp/auth/pending' , async ( req , res ) => {
try {
pruneExpiredPendingMcpAuthContexts ();
const state = normalizePendingString ( Array . isArray ( req . query ? . state ) ? req . query . state [ 0 ] : req . query ? . state );
if ( ! state ) {
return res . json ( null );
}
const pendingMcpAuthContext = pendingMcpAuthContextByState . get ( state ) ?? null ;
if ( ! pendingMcpAuthContext ) {
return res . status ( 404 ). json ({ error : 'No pending MCP auth context' });
}
return res . json ( pendingMcpAuthContext );
} catch ( error ) {
console . error ( 'Failed to read pending MCP auth context:' , error );
return res . status ( 500 ). json ({ error : error . message || 'Failed to read pending MCP auth context' });
}
});
app . delete ( '/api/mcp/auth/pending' , async ( req , res ) => {
try {
const state = normalizePendingString ( Array . isArray ( req . query ? . state ) ? req . query . state [ 0 ] : req . query ? . state );
if ( ! state ) {
return res . json ({ success : true });
}
pendingMcpAuthContextByState . delete ( state );
return res . json ({ success : true });
} catch ( error ) {
console . error ( 'Failed to clear pending MCP auth context:' , error );
return res . status ( 500 ). json ({ error : error . message || 'Failed to clear pending MCP auth context' });
}
});
2026-08-10 20:23:45 +03:00
// Browser return leg of the MCP OAuth flow, completed entirely server-side.
//
// The provider redirects the SYSTEM browser here, and that browser has no
// OpenChamber UI session — the SPA route this path used to land on sits
// behind the client-side auth gate, so the user saw a login page instead of
// a finished authorization. No session can be required on this path.
//
// Safe without auth because it acts only on a code+state pair whose `state`
// matches a context parked by an authenticated start call: `state` is the
// OAuth CSRF secret, generated per flow and known only to the initiating
// client and the provider. Without a match the code is NOT forwarded, so an
// unauthenticated caller cannot bind this server's MCP entry to a foreign
// account by fabricating a callback. The endpoint reads nothing and mutates
// nothing else.
app . get ( '/mcp/oauth/callback' , async ( req , res ) => {
const queryValue = ( key ) => normalizePendingString ( Array . isArray ( req . query ? .[ key ]) ? req . query [ key ][ 0 ] : req . query ? .[ key ]);
const state = queryValue ( 'state' );
const code = queryValue ( 'code' );
const providerError = queryValue ( 'error' );
const providerErrorDescription = queryValue ( 'error_description' );
pruneExpiredPendingMcpAuthContexts ();
const context = state ? pendingMcpAuthContextByState . get ( state ) ?? null : null ;
const startedFromDesktop = context ? . origin === 'desktop' ;
const finish = ( status , { title , message }) => {
if ( state ) pendingMcpAuthContextByState . delete ( state );
res . status ( status ). type ( 'html' ). send ( renderMcpOAuthCallbackPage ({
title ,
message ,
// Browsers only follow custom-protocol links from a user gesture in
// some configurations, so the page both tries the jump and keeps a
// visible link as the fallback.
desktopReturn : startedFromDesktop ,
}));
};
if ( providerError ) {
return finish ( 400 , {
title : 'Authorization Failed' ,
message : providerErrorDescription || providerError ,
});
}
if ( ! code ) {
return finish ( 400 , {
title : 'Authorization Failed' ,
message : 'The provider did not return an authorization code. Start authorization again from MCP Settings.' ,
});
}
if ( ! context ? . name ) {
return finish ( 400 , {
title : 'Authorization Failed' ,
message : 'This authorization session has expired or is unknown to the running app. Return to OpenChamber and click Authorize again.' ,
});
}
try {
const callbackUrl = new URL ( buildOpenCodeUrl ( `/mcp/ ${ encodeURIComponent ( context . name ) } /auth/callback` , '' ));
if ( context . directory ) callbackUrl . searchParams . set ( 'directory' , context . directory );
const upstream = await fetch ( callbackUrl , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' , Accept : 'application/json' , ... getOpenCodeAuthHeaders () },
body : JSON . stringify ({ code }),
});
if ( ! upstream . ok ) {
const payload = await upstream . json (). catch (() => null );
return finish ( 502 , {
title : 'Authorization Failed' ,
message : payload ? . error || payload ? . message || `OpenCode rejected the authorization code ( ${ upstream . status } ). Start authorization again from MCP Settings.` ,
});
}
return finish ( 200 , {
title : 'Authorization Complete' ,
message : 'You can close this tab and return to OpenChamber.' ,
});
} catch ( error ) {
return finish ( 502 , {
title : 'Authorization Failed' ,
message : error ? . message || 'Failed to complete MCP authorization.' ,
});
}
});
2026-03-31 18:47:00 +03:00
app . get ( '/api/provider/:providerId/source' , async ( req , res ) => {
try {
const { providerId } = req . params ;
if ( ! providerId ) {
return res . status ( 400 ). json ({ error : 'Provider ID is required' });
}
const headerDirectory = typeof req . get === 'function' ? req . get ( 'x-opencode-directory' ) : null ;
const queryDirectory = Array . isArray ( req . query ? . directory )
? req . query . directory [ 0 ]
: req . query ? . directory ;
const requestedDirectory = headerDirectory || queryDirectory || null ;
let directory = null ;
const resolved = await resolveProjectDirectory ( req );
if ( resolved . directory ) {
directory = resolved . directory ;
} else if ( requestedDirectory ) {
return res . status ( 400 ). json ({ error : resolved . error });
}
const sources = getProviderSources ( providerId , directory );
const { getProviderAuth } = await getAuthLibrary ();
const auth = getProviderAuth ( providerId );
2026-08-15 01:59:26 +03:00
sources . sources . auth . exists = providerId === 'claude-code'
? getClaudeCliAuthStatus (). connected
: Boolean ( auth );
2026-03-31 18:47:00 +03:00
return res . json ({
providerId ,
sources : sources . sources ,
});
} catch ( error ) {
console . error ( 'Failed to get provider sources:' , error );
return res . status ( 500 ). json ({ error : error . message || 'Failed to get provider sources' });
}
});
2026-08-02 09:12:28 +00:00
app . put ( '/api/provider' , async ( req , res ) => {
try {
const providerID = typeof req . body ? . providerID === 'string'
? req . body . providerID . trim ()
: ( typeof req . body ? . providerId === 'string' ? req . body . providerId . trim () : '' );
const config = req . body ? . config ;
const scope = typeof req . body ? . scope === 'string' ? req . body . scope : 'user' ;
if ( ! providerID ) {
return res . status ( 400 ). json ({ error : 'Provider ID is required' });
}
if ( ! config || typeof config !== 'object' || Array . isArray ( config )) {
return res . status ( 400 ). json ({ error : 'Provider config is required' });
}
if ( scope !== 'user' && scope !== 'project' && scope !== 'custom' ) {
return res . status ( 400 ). json ({ error : 'Invalid scope' });
}
const headerDirectory = typeof req . get === 'function' ? req . get ( 'x-opencode-directory' ) : null ;
const queryDirectory = Array . isArray ( req . query ? . directory )
? req . query . directory [ 0 ]
: req . query ? . directory ;
const requestedDirectory = headerDirectory || queryDirectory || null ;
let directory = null ;
if ( scope === 'project' || requestedDirectory ) {
const resolved = await resolveProjectDirectory ( req );
if ( ! resolved . directory ) {
return res . status ( 400 ). json ({ error : resolved . error || 'Working directory is required' });
}
directory = resolved . directory ;
} else {
const resolved = await resolveProjectDirectory ( req );
if ( resolved . directory ) {
directory = resolved . directory ;
}
}
2026-08-02 11:40:02 +00:00
const { getProviderAuth } = await getAuthLibrary ();
const hasStoredAuth = Boolean ( getProviderAuth ( providerID ));
const upsertResult = upsertProviderConfig ( providerID , config , directory , scope , { hasStoredAuth });
2026-08-02 09:12:28 +00:00
return res . json ({
2026-08-03 13:57:05 +00:00
... buildDeferredRestartResponse (
`Provider ${ providerID } saved. Restart OpenCode to apply.` ,
),
2026-08-02 11:40:02 +00:00
providerId : upsertResult . providerId ,
path : upsertResult . path ,
config : upsertResult . config ,
2026-08-02 09:12:28 +00:00
});
} catch ( error ) {
const status = typeof error ? . statusCode === 'number' ? error . statusCode : 500 ;
console . error ( 'Failed to upsert provider config:' , error );
return res . status ( status ). json ({ error : error . message || 'Failed to save provider config' });
}
});
2026-03-31 18:47:00 +03:00
app . delete ( '/api/provider/:providerId/auth' , async ( req , res ) => {
try {
const { providerId } = req . params ;
if ( ! providerId ) {
return res . status ( 400 ). json ({ error : 'Provider ID is required' });
}
const scope = typeof req . query ? . scope === 'string' ? req . query . scope : 'auth' ;
const headerDirectory = typeof req . get === 'function' ? req . get ( 'x-opencode-directory' ) : null ;
const queryDirectory = Array . isArray ( req . query ? . directory )
? req . query . directory [ 0 ]
: req . query ? . directory ;
const requestedDirectory = headerDirectory || queryDirectory || null ;
let directory = null ;
if ( scope === 'project' || requestedDirectory ) {
const resolved = await resolveProjectDirectory ( req );
if ( ! resolved . directory ) {
return res . status ( 400 ). json ({ error : resolved . error });
}
directory = resolved . directory ;
} else {
const resolved = await resolveProjectDirectory ( req );
if ( resolved . directory ) {
directory = resolved . directory ;
}
}
let removed = false ;
if ( scope === 'auth' ) {
const { removeProviderAuth } = await getAuthLibrary ();
removed = removeProviderAuth ( providerId );
} else if ( scope === 'user' || scope === 'project' || scope === 'custom' ) {
removed = removeProviderConfig ( providerId , directory , scope );
} else if ( scope === 'all' ) {
const { removeProviderAuth } = await getAuthLibrary ();
const authRemoved = removeProviderAuth ( providerId );
const userRemoved = removeProviderConfig ( providerId , directory , 'user' );
const projectRemoved = directory ? removeProviderConfig ( providerId , directory , 'project' ) : false ;
const customRemoved = removeProviderConfig ( providerId , directory , 'custom' );
removed = authRemoved || userRemoved || projectRemoved || customRemoved ;
} else {
return res . status ( 400 ). json ({ error : 'Invalid scope' });
}
if ( removed ) {
2026-08-03 06:40:01 +00:00
return res . json ({
success : true ,
removed ,
... buildDeferredRestartResponse ( 'Provider disconnected successfully. Restart OpenCode to apply.' ),
});
2026-03-31 18:47:00 +03:00
}
return res . json ({
success : true ,
removed ,
2026-08-03 06:40:01 +00:00
requiresReload : false ,
message : 'Provider was not connected' ,
2026-03-31 18:47:00 +03:00
});
} catch ( error ) {
console . error ( 'Failed to disconnect provider:' , error );
return res . status ( 500 ). json ({ error : error . message || 'Failed to disconnect provider' });
}
});
app . post ( '/api/opencode/directory' , async ( req , res ) => {
try {
const requestedPath = typeof req . body ? . path === 'string' ? req . body . path . trim () : '' ;
if ( ! requestedPath ) {
return res . status ( 400 ). json ({ error : 'Path is required' });
}
2026-08-10 16:10:03 +03:00
if ( req . body ? . create === true ) {
await fsPromises . mkdir ( path . resolve ( requestedPath ), { recursive : true });
}
2026-03-31 18:47:00 +03:00
const validated = await validateDirectoryPath ( requestedPath );
if ( ! validated . ok ) {
return res . status ( 400 ). json ({ error : validated . error });
}
const resolvedPath = validated . directory ;
const currentSettings = await readSettingsFromDisk ();
const existingProjects = sanitizeProjects ( currentSettings . projects ) || [];
const existing = existingProjects . find (( project ) => project . path === resolvedPath ) || null ;
const nextProjects = existing
? existingProjects
: [
... existingProjects ,
{
2026-04-18 13:46:38 +03:00
id : createProjectIdFromPath ( resolvedPath ),
2026-03-31 18:47:00 +03:00
path : resolvedPath ,
addedAt : Date . now (),
lastOpenedAt : Date . now (),
},
];
const activeProjectId = existing ? existing . id : nextProjects [ nextProjects . length - 1 ]. id ;
const updated = await persistSettings ({
projects : nextProjects ,
activeProjectId ,
lastDirectory : resolvedPath ,
});
return res . json ({
success : true ,
restarted : false ,
path : resolvedPath ,
settings : updated ,
});
} catch ( error ) {
console . error ( 'Failed to update OpenCode working directory:' , error );
return res . status ( 500 ). json ({ error : error . message || 'Failed to update working directory' });
}
});
2026-04-30 14:39:55 -07:00
// Behavior / Global AGENTS.md endpoints
2026-09-04 17:14:26 -06:00
const AGENTS_MD_PATH = path . join ( OPENCODE_CONFIG_DIR , 'AGENTS.md' );
2026-04-30 14:39:55 -07:00
const MAX_BEHAVIOR_PROMPT_SIZE = 1024 * 1024 ; // 1 MB
app . get ( '/api/behavior/agents-md' , async ( _req , res ) => {
try {
try {
await fs . promises . access ( AGENTS_MD_PATH );
} catch {
2026-09-05 02:19:59 +03:00
return res . json ({ content : '' , exists : false , path : AGENTS_MD_PATH });
2026-04-30 14:39:55 -07:00
}
const content = await fs . promises . readFile ( AGENTS_MD_PATH , 'utf8' );
2026-09-05 02:19:59 +03:00
return res . json ({ content , exists : true , path : AGENTS_MD_PATH });
2026-04-30 14:39:55 -07:00
} catch ( error ) {
console . error ( 'Failed to read AGENTS.md:' , error );
return res . status ( 500 ). json ({ error : 'Failed to read AGENTS.md' });
}
});
app . put ( '/api/behavior/agents-md' , async ( req , res ) => {
try {
const content = typeof req . body ? . content === 'string' ? req . body . content : '' ;
if ( content . length > MAX_BEHAVIOR_PROMPT_SIZE ) {
return res . status ( 413 ). json ({ error : `Content exceeds maximum size of ${ MAX_BEHAVIOR_PROMPT_SIZE } bytes` });
}
// Ensure parent directory exists
const parentDir = path . dirname ( AGENTS_MD_PATH );
try {
await fs . promises . access ( parentDir );
} catch {
await fs . promises . mkdir ( parentDir , { recursive : true });
}
await fs . promises . writeFile ( AGENTS_MD_PATH , content , 'utf8' );
2026-08-03 06:40:01 +00:00
return res . json ( buildDeferredRestartResponse (
'AGENTS.md saved. Restart OpenCode to apply.' ,
));
2026-04-30 14:39:55 -07:00
} catch ( error ) {
console . error ( 'Failed to write AGENTS.md:' , error );
return res . status ( 500 ). json ({ error : error . message || 'Failed to write AGENTS.md' });
}
});
2026-03-31 18:47:00 +03:00
};