Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 11 additions & 5 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ jobs:

- name: Configure git identity
run: |
git config --global user.email "bot@code.redrob.ai"
git config --global user.email "44862514+savagemanage@users.noreply.github.com"
git config --global user.name "redrob"

- name: Run engine tests
Expand Down Expand Up @@ -109,7 +109,7 @@ jobs:

- name: Configure git identity
run: |
git config --global user.email "bot@code.redrob.ai"
git config --global user.email "44862514+savagemanage@users.noreply.github.com"
git config --global user.name "redrob"

# Not sharded: 933 tests in ~22s. Splitting it would cost more in runner setup than
Expand All @@ -122,6 +122,12 @@ jobs:
working-directory: packages/client
run: bun run check:generated

# The JSON Schemas the Console serves (config.json, tui.json, theme.json) are committed under
# packages/redrob/schema and regenerated from the live Effect schemas; a stale copy fails here.
- name: Check generated schemas
working-directory: packages/redrob
run: bun run check:generated

httpapi:
name: httpapi (linux)
runs-on: ubuntu-latest
Expand All @@ -148,7 +154,7 @@ jobs:

- name: Configure git identity
run: |
git config --global user.email "bot@code.redrob.ai"
git config --global user.email "44862514+savagemanage@users.noreply.github.com"
git config --global user.name "redrob"

# Its own job rather than a step after the tests: ~1m38s that has no reason to sit
Expand Down Expand Up @@ -224,7 +230,7 @@ jobs:

- name: Configure git identity
run: |
git config --global user.email "bot@code.redrob.ai"
git config --global user.email "44862514+savagemanage@users.noreply.github.com"
git config --global user.name "redrob"

- name: Run engine tests
Expand Down Expand Up @@ -259,7 +265,7 @@ jobs:

- name: Configure git identity
run: |
git config --global user.email "bot@code.redrob.ai"
git config --global user.email "44862514+savagemanage@users.noreply.github.com"
git config --global user.name "redrob"

- name: Run core tests
Expand Down
2 changes: 1 addition & 1 deletion .redrob/redrob.jsonc
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"$schema": "https://code.redrob.ai/config.json",
"$schema": "https://console.redrob.ai/code/config.json",
"provider": {},
"permission": {},
"references": {
Expand Down
18 changes: 10 additions & 8 deletions github/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,12 +56,11 @@ Run the following command in the terminal from your GitHub repo:
Redrob Code github install
```

This will walk you through installing the GitHub app, creating the workflow, and setting up secrets.
This will walk you through creating the workflow and setting up secrets. The agent acts with the repository's own `GITHUB_TOKEN`; there is no GitHub App to install.

### Manual Setup

1. Install the GitHub app https://github.com/apps/redrob-agent. Make sure it is installed on the target repository.
2. Add the following workflow file to `.github/workflows/redrob.yml` in your repo. Set the appropriate `model` and required API keys in `env`.
1. Add the following workflow file to `.github/workflows/redrob.yml` in your repo. Set the appropriate `model` and required API keys in `env`.

```yml
name: Redrob Code
Expand All @@ -79,7 +78,9 @@ This will walk you through installing the GitHub app, creating the workflow, and
contains(github.event.comment.body, '/redrob')
runs-on: ubuntu-latest
permissions:
id-token: write
contents: write
pull-requests: write
issues: write
steps:
- name: Checkout repository
uses: actions/checkout@v6
Expand All @@ -91,13 +92,14 @@ This will walk you through installing the GitHub app, creating the workflow, and
uses: redrob-labs/redrob-code/github@latest
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
model: anthropic/claude-sonnet-4-20250514
use_github_token: true
token: ${{ secrets.GITHUB_TOKEN }}
```

3. Store the API keys in secrets. In your organization or project **settings**, expand **Secrets and variables** on the left and select **Actions**. Add the required API keys.
`token` defaults to the workflow's own `GITHUB_TOKEN`; pass a personal access token instead when the agent must act as a user (for example to trigger other workflows with its pushes).

2. Store the API keys in secrets. In your organization or project **settings**, expand **Secrets and variables** on the left and select **Actions**. Add the required API keys.

## Support

Expand Down Expand Up @@ -127,7 +129,7 @@ To test locally:
- `MODEL`: The model used by Redrob Code. Same as the `MODEL` defined in the GitHub workflow.
- `ANTHROPIC_API_KEY`: Your model provider API key. Same as the keys defined in the GitHub workflow.
- `GITHUB_RUN_ID`: Dummy value to emulate GitHub action environment.
- `MOCK_TOKEN`: A GitHub personal access token. This token is used to verify you have `admin` or `write` access to the test repo. Generate a token [here](https://github.com/settings/personal-access-tokens).
- `MOCK_TOKEN`: A GitHub personal access token. The agent acts with it directly (comments, pushes) and uses it to verify you have `admin` or `write` access to the test repo. Generate a token [here](https://github.com/settings/personal-access-tokens).
- `MOCK_EVENT`: Mock GitHub event payload (see templates below).
- `/path/to/redrob`: Path to your cloned Redrob Code repo. `bun /path/to/redrob/github/index.ts` runs your local version of `Redrob Code`.

Expand Down
13 changes: 4 additions & 9 deletions github/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,10 @@ inputs:
description: "Custom prompt to override the default prompt"
required: false

use_github_token:
description: "Use GITHUB_TOKEN directly instead of Redrob Code App token exchange. When true, skips OIDC and uses the GITHUB_TOKEN env var."
token:
description: "GitHub token the agent acts with: the workflow's own GITHUB_TOKEN (the default) or a personal access token with contents, pull-requests and issues write access. There is no GitHub App or token exchange; this token is the only credential."
required: false
default: "false"
default: ${{ github.token }}

mentions:
description: "Comma-separated list of trigger phrases (case-insensitive). Defaults to '/redrob,/rr,/opencode,/oc' -- the upstream phrases are kept for compatibility"
Expand All @@ -34,10 +34,6 @@ inputs:
description: "Model variant for provider-specific reasoning effort (e.g., high, max, minimal)"
required: false

oidc_base_url:
description: "Base URL for OIDC token exchange API. Only required when running a custom GitHub App install. Defaults to https://api.code.redrob.ai"
required: false

runs:
using: "composite"
steps:
Expand Down Expand Up @@ -73,7 +69,6 @@ runs:
AGENT: ${{ inputs.agent }}
SHARE: ${{ inputs.share }}
PROMPT: ${{ inputs.prompt }}
USE_GITHUB_TOKEN: ${{ inputs.use_github_token }}
GITHUB_TOKEN: ${{ inputs.token }}
MENTIONS: ${{ inputs.mentions }}
VARIANT: ${{ inputs.variant }}
OIDC_BASE_URL: ${{ inputs.oidc_base_url }}
90 changes: 30 additions & 60 deletions github/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,8 @@ let octoGraph: typeof graphql
let commentId: number
let gitConfig: string
let session: { id: string; title: string; version: string }
let shareId: string | undefined
// The share service answers with the public URL of a shared session; nothing here constructs one.
let shareUrl: string | undefined
let exitCode = 0
type PromptFiles = Awaited<ReturnType<typeof getUserPrompt>>["promptFiles"]

Expand All @@ -153,7 +154,7 @@ try {
assertPayloadKeyword()
await assertRedrobConnected()

accessToken = await getAccessToken()
accessToken = getAccessToken()
octoRest = new Octokit({ auth: accessToken })
octoGraph = graphql.defaults({
headers: { authorization: `token ${accessToken}` },
Expand All @@ -170,15 +171,15 @@ try {
const repoData = await fetchRepo()
session = await client.session.create<true>().then((r) => r.data)
await subscribeSessionEvents()
shareId = await (async () => {
shareUrl = await (async () => {
if (useEnvShare() === false) return
if (!useEnvShare() && repoData.data.private) return
await client.session.share<true>({ path: session })
return session.id.slice(-8)
const shared = await client.session.share<true>({ path: session })
return shared.data.share?.url
})()
console.log("Redrob Code session", session.id)
if (shareId) {
console.log("Share link:", `${useShareUrl()}/s/${shareId}`)
if (shareUrl) {
console.log("Share link:", shareUrl)
}

// Handle 3 cases
Expand All @@ -196,7 +197,7 @@ try {
const summary = await summarize(response)
await pushToLocalBranch(summary)
}
const hasShared = prData.comments.nodes.some((c) => c.body.includes(`${useShareUrl()}/s/${shareId}`))
const hasShared = prData.comments.nodes.some((c) => shareUrl !== undefined && c.body.includes(shareUrl))
await updateComment(`${response}${footer({ image: !hasShared })}`)
}
// Fork PR
Expand All @@ -208,7 +209,7 @@ try {
const summary = await summarize(response)
await pushToForkBranch(summary, prData)
}
const hasShared = prData.comments.nodes.some((c) => c.body.includes(`${useShareUrl()}/s/${shareId}`))
const hasShared = prData.comments.nodes.some((c) => shareUrl !== undefined && c.body.includes(shareUrl))
await updateComment(`${response}${footer({ image: !hasShared })}`)
}
}
Expand Down Expand Up @@ -248,7 +249,6 @@ try {
} finally {
server.close()
await restoreGitConfig()
await revokeAppToken()
}
process.exit(exitCode)

Expand Down Expand Up @@ -386,42 +386,24 @@ function useIssueId() {
return payload.issue.number
}

function useShareUrl() {
return isMock() ? "https://dev.code.redrob.ai" : "https://code.redrob.ai"
}

async function getAccessToken() {
const { repo } = useContext()

// The action authenticates with a token the workflow hands it: `${{ secrets.GITHUB_TOKEN }}` or a
// PAT. There is no Redrob GitHub App and no token-exchange service behind it, so a run without a
// token cannot do anything useful and stops here with the fix spelled out.
function getAccessToken() {
const envToken = useEnvGithubToken()
if (envToken) return envToken

let response
if (isMock()) {
response = await fetch("https://api.code.redrob.ai/exchange_github_app_token_with_pat", {
method: "POST",
headers: {
Authorization: `Bearer ${useEnvMock().mockToken}`,
},
body: JSON.stringify({ owner: repo.owner, repo: repo.repo }),
})
} else {
const oidcToken = await core.getIDToken("opencode-github-action")
response = await fetch("https://api.code.redrob.ai/exchange_github_app_token", {
method: "POST",
headers: {
Authorization: `Bearer ${oidcToken}`,
},
})
const { mockToken } = useEnvMock()
if (mockToken) return mockToken
}

if (!response.ok) {
const responseJson = (await response.json()) as { error?: string }
throw new Error(`App token exchange failed: ${response.status} ${response.statusText} - ${responseJson.error}`)
}

const responseJson = (await response.json()) as { token: string }
return responseJson.token
throw new Error(
[
"No GitHub token was provided.",
"Pass one to the action: `with: { token: ${{ secrets.GITHUB_TOKEN }} }` (or a personal access token with",
"`contents: write`, `pull-requests: write` and `issues: write`).",
].join(" "),
)
}

async function createComment() {
Expand Down Expand Up @@ -674,7 +656,7 @@ async function chat(text: string, files: PromptFiles = []) {
return match.text
}

async function configureGit(appToken: string) {
async function configureGit(token: string) {
// Do not change git config when running locally
if (isMock()) return

Expand All @@ -683,7 +665,7 @@ async function configureGit(appToken: string) {
const ret = await $`git config --local --get ${config}`
gitConfig = ret.stdout.toString().trim()

const newCredentials = Buffer.from(`x-access-token:${appToken}`, "utf8").toString("base64")
const newCredentials = Buffer.from(`x-access-token:${token}`, "utf8").toString("base64")

await $`git config --local --unset-all ${config}`
await $`git config --local ${config} "AUTHORIZATION: basic ${newCredentials}"`
Expand Down Expand Up @@ -875,16 +857,17 @@ function footer(opts?: { image?: boolean }) {
const { providerID, modelID } = useEnvModel()

const image = (() => {
if (!shareId) return ""
if (!shareUrl) return ""
if (!opts?.image) return ""

const titleAlt = encodeURIComponent(session.title.substring(0, 50))
const title64 = Buffer.from(session.title.substring(0, 700), "utf8").toString("base64")
const shareId = shareUrl.split("/").pop()

return `<a href="${useShareUrl()}/s/${shareId}"><img width="200" alt="${titleAlt}" src="https://social-cards.sst.dev/opencode-share/${title64}.png?model=${providerID}/${modelID}&version=${session.version}&id=${shareId}" /></a>\n`
return `<a href="${shareUrl}"><img width="200" alt="${titleAlt}" src="https://social-cards.sst.dev/opencode-share/${title64}.png?model=${providerID}/${modelID}&version=${session.version}&id=${shareId}" /></a>\n`
})()
const shareUrl = shareId ? `[Redrob Code session](${useShareUrl()}/s/${shareId})&nbsp;&nbsp;|&nbsp;&nbsp;` : ""
return `\n\n${image}${shareUrl}[github run](${useEnvRunUrl()})`
const shareLink = shareUrl ? `[Redrob Code session](${shareUrl})&nbsp;&nbsp;|&nbsp;&nbsp;` : ""
return `\n\n${image}${shareLink}[github run](${useEnvRunUrl()})`
}

async function fetchRepo() {
Expand Down Expand Up @@ -1107,16 +1090,3 @@ function buildPromptDataForPR(pr: GitHubPullRequest) {
].join("\n")
}

async function revokeAppToken() {
if (!accessToken) return
console.log("Revoking app token...")

await fetch("https://api.github.com/installation/token", {
method: "DELETE",
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
})
}
22 changes: 22 additions & 0 deletions packages/core/src/links.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// Leaf module holding every public Redrob Code URL that ships in the binary. Dependency-free
// on purpose so core, the CLI and the TUI can all import it without pulling in anything heavier.
//
// The old "code." host never existed. Everything user-facing lives under the Console:
// the product page, the docs, the install script, and the JSON Schemas that editors fetch from a
// config file's `$schema` field. Keep the literals here so a dead link cannot creep back in one
// file at a time; JSON assets (packages/redrob/config.json, the TUI theme files) repeat the
// schema URLs as literals because JSON cannot import, and `redrob generate` regenerates the schema
// documents at `packages/redrob/schema/*.json` with matching `$id`s.

export const CONSOLE_HOME_URL = "https://console.redrob.ai"
export const CODE_HOME_URL = `${CONSOLE_HOME_URL}/code`
export const CODE_DOCS_URL = `${CONSOLE_HOME_URL}/docs`
export const CODE_INSTALL_URL = `${CODE_HOME_URL}/install.sh`
export const CODE_INSTALL_COMMAND = `curl -fsSL ${CODE_INSTALL_URL} | sh`

/** `$schema` for redrob.json / redrob.jsonc (generated from ConfigV1.Info). */
export const CONFIG_SCHEMA_URL = `${CODE_HOME_URL}/config.json`
/** `$schema` for tui.json (generated from TuiConfig.Info). */
export const TUI_SCHEMA_URL = `${CODE_HOME_URL}/tui.json`
/** `$schema` for a TUI theme file (hand-written from the ThemeJson type). */
export const THEME_SCHEMA_URL = `${CODE_HOME_URL}/theme.json`
4 changes: 2 additions & 2 deletions packages/core/src/plugin/provider/kilo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ export const KiloPlugin = define({
if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue
if (item.provider.api.url !== "https://api.kilo.ai/api/gateway") continue
evt.provider.update(item.provider.id, (provider) => {
provider.request.headers["HTTP-Referer"] = "https://code.redrob.ai/"
provider.request.headers["X-Title"] = "redrob"
provider.request.headers["HTTP-Referer"] = "https://console.redrob.ai/code"
provider.request.headers["X-Title"] = "Redrob Code"
})
}
}),
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/plugin/provider/llmgateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ export const LLMGatewayPlugin = define({
if (item.provider.api.url !== "https://api.llmgateway.io/v1") continue
if (!(yield* integrations.get(Integration.ID.make(item.provider.id)))) continue
evt.provider.update(item.provider.id, (provider) => {
provider.request.headers["HTTP-Referer"] = "https://code.redrob.ai/"
provider.request.headers["X-Title"] = "redrob"
provider.request.headers["HTTP-Referer"] = "https://console.redrob.ai/code"
provider.request.headers["X-Title"] = "Redrob Code"
provider.request.headers["X-Source"] = "redrob"
})
}
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/plugin/provider/nvidia.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ export const NvidiaPlugin = define({
if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue
if (item.provider.api.url !== "https://integrate.api.nvidia.com/v1") continue
evt.provider.update(item.provider.id, (provider) => {
provider.request.headers["HTTP-Referer"] = "https://code.redrob.ai/"
provider.request.headers["X-Title"] = "redrob"
provider.request.headers["HTTP-Referer"] = "https://console.redrob.ai/code"
provider.request.headers["X-Title"] = "Redrob Code"
provider.request.headers["X-BILLING-INVOKE-ORIGIN"] ??= "RedrobCode"
})
}
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/plugin/provider/openrouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ export const OpenRouterPlugin = define({
if (item.provider.api.type !== "aisdk") continue
if (item.provider.api.package !== "@openrouter/ai-sdk-provider") continue
evt.provider.update(item.provider.id, (provider) => {
provider.request.headers["HTTP-Referer"] = "https://code.redrob.ai/"
provider.request.headers["X-Title"] = "redrob"
provider.request.headers["HTTP-Referer"] = "https://console.redrob.ai/code"
provider.request.headers["X-Title"] = "Redrob Code"
})
for (const modelID of [ModelV2.ID.make("gpt-5-chat-latest"), ModelV2.ID.make("openai/gpt-5-chat")]) {
if (!item.models.has(modelID)) continue
Expand Down
Loading
Loading