- POST /oidc/token: GitHub OIDC JWT exchange (bootstrap + agent-scoped modes) - POST/GET/DELETE /oidc/trust-policies: trust policy CRUD with enforcement - DB migration 022: oidc_trust_policies table with provider/repo/branch/agent_id - GitHub Actions: register-agent and issue-token actions with full READMEs - Trust policy enforcement rejects token exchanges not matching registered policies - Bootstrap mode issues agents:write token for new agent registration without agentId Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
201 lines
6.7 KiB
JavaScript
201 lines
6.7 KiB
JavaScript
/**
|
|
* register-agent GitHub Action script.
|
|
*
|
|
* Flow:
|
|
* 1. Request a GitHub OIDC token via @actions/core.getIDToken()
|
|
* 2. Exchange the OIDC token for a SentryAgent.ai access token via POST /oidc/token
|
|
* 3. Register a new agent via POST /agents using the access token
|
|
* 4. Set the `agent-id` output
|
|
*
|
|
* Error handling:
|
|
* - OIDC exchange failures emit a clear message with a link to the trust policy setup docs
|
|
* - Agent registration failures surface the API error message
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
const core = require('@actions/core');
|
|
const { HttpClient, BearerCredentialHandler } = require('@actions/http-client');
|
|
|
|
/**
|
|
* Exchanges a GitHub OIDC JWT for a SentryAgent.ai access token.
|
|
*
|
|
* @param {string} apiUrl - Base URL of the SentryAgent.ai AgentIdP API.
|
|
* @param {string} oidcToken - GitHub OIDC JWT obtained from core.getIDToken().
|
|
* @returns {Promise<string>} The SentryAgent.ai access token.
|
|
* @throws {Error} If the exchange fails, with a message including trust policy setup instructions.
|
|
*/
|
|
async function exchangeOIDCToken(apiUrl, oidcToken) {
|
|
const client = new HttpClient('sentryagent-register-agent/1.0');
|
|
const url = `${apiUrl}/api/v1/oidc/token`;
|
|
|
|
const body = JSON.stringify({
|
|
provider: 'github',
|
|
token: oidcToken,
|
|
});
|
|
|
|
let response;
|
|
try {
|
|
response = await client.post(url, body, {
|
|
'Content-Type': 'application/json',
|
|
Accept: 'application/json',
|
|
});
|
|
} catch (err) {
|
|
throw new Error(
|
|
`Failed to reach the SentryAgent.ai OIDC token endpoint at ${url}. ` +
|
|
`Check that the api-url input is correct and the API is reachable.\n` +
|
|
`Underlying error: ${err instanceof Error ? err.message : String(err)}`,
|
|
);
|
|
}
|
|
|
|
const rawBody = await response.readBody();
|
|
const statusCode = response.message.statusCode ?? 0;
|
|
|
|
if (statusCode === 403) {
|
|
throw new Error(
|
|
'GitHub OIDC token exchange was rejected with HTTP 403 (Forbidden). ' +
|
|
'This usually means no trust policy has been registered for this repository.\n\n' +
|
|
'To fix this, register a trust policy by calling:\n' +
|
|
` POST ${apiUrl}/oidc/trust-policies\n` +
|
|
' Body: { "provider": "github", "repository": "org/repo", "agentId": "<agent-id>" }\n\n' +
|
|
'For full setup instructions, visit: https://docs.sentryagent.ai/github-actions#trust-policy',
|
|
);
|
|
}
|
|
|
|
if (statusCode < 200 || statusCode >= 300) {
|
|
let detail = rawBody;
|
|
try {
|
|
const parsed = JSON.parse(rawBody);
|
|
detail = parsed.message ?? parsed.error_description ?? rawBody;
|
|
} catch {
|
|
// use rawBody as-is
|
|
}
|
|
throw new Error(
|
|
`OIDC token exchange failed with HTTP ${statusCode}: ${detail}\n` +
|
|
'For trust policy setup instructions, visit: https://docs.sentryagent.ai/github-actions#trust-policy',
|
|
);
|
|
}
|
|
|
|
let tokenData;
|
|
try {
|
|
tokenData = JSON.parse(rawBody);
|
|
} catch {
|
|
throw new Error(`OIDC token exchange returned non-JSON response: ${rawBody}`);
|
|
}
|
|
|
|
if (typeof tokenData.access_token !== 'string' || tokenData.access_token.length === 0) {
|
|
throw new Error('OIDC token exchange response did not include an access_token.');
|
|
}
|
|
|
|
return tokenData.access_token;
|
|
}
|
|
|
|
/**
|
|
* Registers a new agent via POST /agents.
|
|
*
|
|
* @param {string} apiUrl - Base URL of the SentryAgent.ai AgentIdP API.
|
|
* @param {string} accessToken - A valid SentryAgent.ai Bearer access token.
|
|
* @param {string} agentName - Email (unique name) for the new agent.
|
|
* @param {string} agentDescription - Optional description stored as the owner field.
|
|
* @returns {Promise<string>} The UUID of the newly registered agent.
|
|
* @throws {Error} If the API returns a non-2xx response.
|
|
*/
|
|
async function registerAgent(apiUrl, accessToken, agentName, agentDescription) {
|
|
const auth = new BearerCredentialHandler(accessToken);
|
|
const client = new HttpClient('sentryagent-register-agent/1.0', [auth]);
|
|
const url = `${apiUrl}/api/v1/agents`;
|
|
|
|
const payload = {
|
|
email: agentName,
|
|
agentType: 'custom',
|
|
version: '1.0.0',
|
|
capabilities: [],
|
|
owner: agentDescription || agentName,
|
|
deploymentEnv: 'production',
|
|
};
|
|
|
|
let response;
|
|
try {
|
|
response = await client.post(url, JSON.stringify(payload), {
|
|
'Content-Type': 'application/json',
|
|
Accept: 'application/json',
|
|
});
|
|
} catch (err) {
|
|
throw new Error(
|
|
`Failed to reach the SentryAgent.ai agents endpoint at ${url}.\n` +
|
|
`Underlying error: ${err instanceof Error ? err.message : String(err)}`,
|
|
);
|
|
}
|
|
|
|
const rawBody = await response.readBody();
|
|
const statusCode = response.message.statusCode ?? 0;
|
|
|
|
if (statusCode < 200 || statusCode >= 300) {
|
|
let detail = rawBody;
|
|
try {
|
|
const parsed = JSON.parse(rawBody);
|
|
detail = parsed.message ?? parsed.error ?? rawBody;
|
|
} catch {
|
|
// use rawBody as-is
|
|
}
|
|
throw new Error(`Agent registration failed with HTTP ${statusCode}: ${detail}`);
|
|
}
|
|
|
|
let agentData;
|
|
try {
|
|
agentData = JSON.parse(rawBody);
|
|
} catch {
|
|
throw new Error(`Agent registration returned non-JSON response: ${rawBody}`);
|
|
}
|
|
|
|
if (typeof agentData.agentId !== 'string' || agentData.agentId.length === 0) {
|
|
throw new Error('Agent registration response did not include an agentId.');
|
|
}
|
|
|
|
return agentData.agentId;
|
|
}
|
|
|
|
/**
|
|
* Main entry point for the register-agent GitHub Action.
|
|
*
|
|
* @returns {Promise<void>}
|
|
*/
|
|
async function run() {
|
|
try {
|
|
// Read inputs
|
|
const apiUrl = core.getInput('api-url', { required: true }).replace(/\/$/, '');
|
|
const agentName = core.getInput('agent-name', { required: true });
|
|
const agentDescription = core.getInput('agent-description') || '';
|
|
|
|
core.info(`Requesting GitHub OIDC token for audience: ${apiUrl}`);
|
|
let oidcToken;
|
|
try {
|
|
oidcToken = await core.getIDToken(apiUrl);
|
|
} catch (err) {
|
|
throw new Error(
|
|
'Failed to obtain a GitHub OIDC token. ' +
|
|
"Ensure the workflow has 'id-token: write' permission in its permissions block.\n\n" +
|
|
'Example:\n' +
|
|
'permissions:\n' +
|
|
' id-token: write\n' +
|
|
' contents: read\n\n' +
|
|
`Underlying error: ${err instanceof Error ? err.message : String(err)}\n` +
|
|
'For setup instructions, visit: https://docs.sentryagent.ai/github-actions#trust-policy',
|
|
);
|
|
}
|
|
|
|
core.info('Exchanging GitHub OIDC token for SentryAgent.ai access token...');
|
|
const accessToken = await exchangeOIDCToken(apiUrl, oidcToken);
|
|
|
|
core.info(`Registering agent: ${agentName}`);
|
|
const agentId = await registerAgent(apiUrl, accessToken, agentName, agentDescription);
|
|
|
|
core.setOutput('agent-id', agentId);
|
|
core.info(`Agent registered successfully. agent-id: ${agentId}`);
|
|
} catch (err) {
|
|
core.setFailed(err instanceof Error ? err.message : String(err));
|
|
}
|
|
}
|
|
|
|
run();
|