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
142 changes: 142 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ on:
branches: [main, master]
pull_request:
branches: [main, master]
workflow_dispatch:

env:
CARGO_TERM_COLOR: always
Expand Down Expand Up @@ -150,6 +151,147 @@ jobs:
- run: npm test
- run: node --import tsx --test lib/coding-agent/src/modes/interactive/interactive-mode-model-candidates.test.ts lib/coding-agent/src/modes/interactive/interactive-mode-session-ancillary.test.ts

tmux-secrets:
name: Detect live TUI provider secrets
runs-on: ubuntu-latest
outputs:
live: ${{ steps.detect.outputs.live }}
steps:
# Job-level `if` cannot read the secrets context; fork PRs see empty
# secrets, so this doubles as the fork gate for the live job.
- id: detect
env:
DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }}
run: |
if [ -n "$DEEPSEEK_API_KEY" ]; then
echo "live=true" >> "$GITHUB_OUTPUT"
else
echo "live=false" >> "$GITHUB_OUTPUT"
fi

tmux:
needs: changes
if: needs.changes.outputs.rust == 'true' || needs.changes.outputs.tui == 'true'
name: TUI tmux smoke (mock provider)
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- uses: actions/setup-node@v4
with:
node-version: "24"
cache: npm
cache-dependency-path: apps/tui/package-lock.json
- name: Ensure tmux
run: command -v tmux || (sudo apt-get update && sudo apt-get install -y tmux)
- name: Install TUI dependencies
working-directory: apps/tui
run: npm ci --ignore-scripts --no-audit --no-fund
- name: Build CLI
run: cargo build -p devo-cli --bin devo --locked
- name: Boot and outcome scenarios (no provider credentials)
working-directory: apps/tui
run: |
for scenario in T1 goal heartbeat; do
echo "::group::tmux smoke: $scenario"
node scripts/run-tmux-smoke.js "$scenario"
echo "::endgroup::"
done
- name: Completed-turn scenario against the local mock provider
working-directory: apps/tui
env:
DEVO_TUI_TEST_TIMEOUT_MS: "120000"
run: |
home="$(mktemp -d)"
node scripts/smoke-mock-provider.mjs 8801 &
mock_pid=$!
trap 'kill "$mock_pid" 2>/dev/null || true' EXIT
printf '%s\n' \
'{"model": "deepseek/deepseek-v4-flash", "provider": {"deepseek": {"credential": "deepseek", "enabled": true, "base_url": "http://127.0.0.1:8801"}}}' \
> "$home/providers.json"
printf '%s\n' '{"deepseek": {"type": "api_key", "key": "ci-mock-key"}}' > "$home/auth.json"
chmod 600 "$home/auth.json"
DEVO_HOME="$home" node scripts/run-tmux-smoke.js T2

tmux-live:
needs: [changes, tmux-secrets]
if: >-
needs.tmux-secrets.outputs.live == 'true' &&
(needs.changes.outputs.rust == 'true' || needs.changes.outputs.tui == 'true')
name: TUI tmux live LLM (${{ matrix.os }})
runs-on: ${{ matrix.os }}
timeout-minutes: 40
strategy:
fail-fast: false
matrix:
# macOS covers the seatbelt fence and python3 fallback paths that
# Linux cannot reach with the same code.
os: [ubuntu-latest, macos-latest]
env:
DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- uses: actions/setup-node@v4
with:
node-version: "24"
cache: npm
cache-dependency-path: apps/tui/package-lock.json
- name: Ensure tmux
run: command -v tmux || (if [ "$(uname)" = Darwin ]; then brew install tmux; else sudo apt-get update && sudo apt-get install -y tmux; fi)
- name: Install TUI dependencies
working-directory: apps/tui
run: npm ci --ignore-scripts --no-audit --no-fund
- name: Build CLI
run: cargo build -p devo-cli --bin devo --locked
- name: Seed live Devo homes (one per wire API)
run: |
# chat-completions: builtin DeepSeek endpoint
chat="$(mktemp -d)/home"; mkdir -p "$chat"
printf '%s\n' '{"model": "deepseek/deepseek-v4-flash", "provider": {"deepseek": {"credential": "deepseek", "enabled": true, "base_url": "https://api.deepseek.com", "wire_api": "openai_chat_completions"}}}' > "$chat/providers.json"
# anthropic-messages: DeepSeek's Anthropic-compatible endpoint, same key
anth="$(mktemp -d)/home"; mkdir -p "$anth"
printf '%s\n' '{"model": "deepseek/deepseek-v4-flash", "provider": {"deepseek": {"credential": "deepseek", "enabled": true, "base_url": "https://api.deepseek.com/anthropic", "wire_api": "anthropic_messages"}}}' > "$anth/providers.json"
# responses: builtin OpenAI (openai_responses wire)
resp="$(mktemp -d)/home"; mkdir -p "$resp"
printf '%s\n' '{"model": "openai/gpt-5-mini", "provider": {"openai": {"credential": "openai", "enabled": true, "base_url": "https://api.openai.com/v1", "wire_api": "openai_responses"}}}' > "$resp/providers.json"
for home in "$chat" "$anth"; do
node -e 'const fs = require("fs"); fs.writeFileSync(process.argv[1], JSON.stringify({ deepseek: { type: "api_key", key: process.env.DEEPSEEK_API_KEY } }, null, 2));' "$home/auth.json"
chmod 600 "$home/auth.json"
done
node -e 'const fs = require("fs"); fs.writeFileSync(process.argv[1], JSON.stringify({ openai: { type: "api_key", key: process.env.OPENAI_API_KEY } }, null, 2));' "$resp/auth.json"
chmod 600 "$resp/auth.json"
echo "DEVO_LIVE_CHAT_HOME=$chat" >> "$GITHUB_ENV"
echo "DEVO_LIVE_ANTHROPIC_HOME=$anth" >> "$GITHUB_ENV"
echo "DEVO_LIVE_RESPONSES_HOME=$resp" >> "$GITHUB_ENV"
- name: Chat Completions wire (real DeepSeek)
working-directory: apps/tui
env:
DEVO_TUI_TEST_TIMEOUT_MS: "180000"
run: DEVO_HOME="$DEVO_LIVE_CHAT_HOME" node scripts/run-tmux-smoke.js T2
- name: Anthropic Messages wire (real DeepSeek)
working-directory: apps/tui
env:
DEVO_TUI_TEST_TIMEOUT_MS: "180000"
run: DEVO_HOME="$DEVO_LIVE_ANTHROPIC_HOME" node scripts/run-tmux-smoke.js T2
- name: Responses wire (real OpenAI)
# Optional second secret; skipped when absent. The secrets context is
# not available in step-level `if`, so read the job env mirror.
if: env.OPENAI_API_KEY != ''
working-directory: apps/tui
env:
DEVO_TUI_TEST_TIMEOUT_MS: "180000"
run: DEVO_HOME="$DEVO_LIVE_RESPONSES_HOME" node scripts/run-tmux-smoke.js T2
- name: Interrupted-turn scenario (real DeepSeek, Chat Completions)
working-directory: apps/tui
env:
DEVO_TUI_TEST_TIMEOUT_MS: "180000"
run: DEVO_HOME="$DEVO_LIVE_CHAT_HOME" node scripts/run-tmux-smoke.js T3

check:
needs: changes
if: needs.changes.outputs.rust == 'true'
Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,13 @@ Terminal bundles include Node.js and Python. On Windows, install
[Git for Windows](https://gitforwindows.org/) for shell commands and approve
the one-time sandbox setup prompt.

> **Temporarily disabled.** OS sandboxing and the permission picker are
> disabled repo-wide: every process runs with full user permissions and new
> sessions default to full-access. Revert by flipping
> `SANDBOX_ENFORCEMENT_TEMPORARILY_DISABLED` (Rust, `crates/sandbox/src/lib.rs`),
> `PERMISSIONS_COMMAND_ENABLED` (TUI), `PERMISSION_PICKER_ENABLED` (desktop),
> and the default-preset literals back to their asking defaults.

## Configuration

Connect endpoints using **OpenAI-compatible Chat Completions**, **OpenAI-compatible
Expand Down
13 changes: 8 additions & 5 deletions apps/desktop/src/renderer/components/chat/chat-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ import {
DEFAULT_COMPOSER_PERMISSION_PROFILE,
type ComposerPermissionProfile,
parseComposerPermissionProfile,
PERMISSION_PICKER_ENABLED,
takeComposerPermissionForSession,
} from "./composer-permission"
import { goalPromptText, parseComposerSlash } from "./composer-slash"
Expand Down Expand Up @@ -2389,11 +2390,13 @@ export function ChatInputSection({
<PromptInputFooter className="flex-wrap">
<PromptInputTools>
<AttachButton disabled={!isConnected} />
<ComposerPermissionPicker
value={permissionProfile}
onChange={handlePermissionProfileChange}
disabled={!isConnected}
/>
{PERMISSION_PICKER_ENABLED && (
<ComposerPermissionPicker
value={permissionProfile}
onChange={handlePermissionProfileChange}
disabled={!isConnected}
/>
)}
{collaborationMode === "plan" && (
<ComposerModeChip
variant="plan"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ describe("composer permission profiles", () => {
expect(parseComposerPermissionProfile("auto-review")).toBe("autoReview")
expect(parseComposerPermissionProfile("fullAccess")).toBe("fullAccess")
expect(parseComposerPermissionProfile("default")).toBe("default")
expect(parseComposerPermissionProfile(undefined)).toBe("autoReview")
expect(parseComposerPermissionProfile(undefined)).toBe("fullAccess")
expect(composerPermissionLabel("autoReview")).toBe("Approve for me")
expect(composerPermissionLabel("default")).toBe("Ask for approval")
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,13 @@

export type ComposerPermissionProfile = "default" | "autoReview" | "fullAccess"

/** Matches server `new_session_state`: new sessions default to AutoReview. */
export const DEFAULT_COMPOSER_PERMISSION_PROFILE: ComposerPermissionProfile = "autoReview"
/** The picker is temporarily hidden while OS sandboxing is disabled repo-wide;
* flip to true to restore it. */
export const PERMISSION_PICKER_ENABLED = false

/** Matches server `new_session_state`: new sessions default to FullAccess
* (temporary product default while the sandbox is disabled). */
export const DEFAULT_COMPOSER_PERMISSION_PROFILE: ComposerPermissionProfile = "fullAccess"

export const COMPOSER_PERMISSION_PROFILES: readonly {
id: ComposerPermissionProfile
Expand Down
13 changes: 8 additions & 5 deletions apps/desktop/src/renderer/components/new-chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ import { ComposerPermissionPicker } from "./chat/composer-permission-picker"
import {
DEFAULT_COMPOSER_PERMISSION_PROFILE,
type ComposerPermissionProfile,
PERMISSION_PICKER_ENABLED,
stashComposerPermissionForSession,
} from "./chat/composer-permission"
import { goalPromptText, parseComposerSlash } from "./chat/composer-slash"
Expand Down Expand Up @@ -846,11 +847,13 @@ export function NewChat() {
<PromptInputFooter className="px-4 pb-2">
<PromptInputTools>
<AttachButton disabled={launching || !selectedDirectory} />
<ComposerPermissionPicker
value={permissionProfile}
onChange={setPermissionProfile}
disabled={launching || !selectedDirectory}
/>
{PERMISSION_PICKER_ENABLED && (
<ComposerPermissionPicker
value={permissionProfile}
onChange={setPermissionProfile}
disabled={launching || !selectedDirectory}
/>
)}
{collaborationMode === "plan" && (
<ComposerModeChip
variant="plan"
Expand Down
5 changes: 5 additions & 0 deletions apps/tui/lib/coding-agent/src/core/slash-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@ import type { SourceInfo } from "./source-info.js";

export type SlashCommandSource = "extension" | "prompt" | "skill";

/** /permissions is temporarily hidden: OS sandboxing is disabled repo-wide and
* new sessions default to full-access, so the profile selector has nothing to
* switch. Flip to true to restore the command. */
export const PERMISSIONS_COMMAND_ENABLED = false;

export interface SlashCommandInfo {
name: string;
description?: string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ import {
builtinSlashCommandTakesArgument,
isBuiltinSlashCommandName,
parseSlashCommand,
PERMISSIONS_COMMAND_ENABLED,
resolveBuiltinSlashCommandName,
} from "../../core/slash-commands.js";
import {
Expand Down Expand Up @@ -1103,6 +1104,7 @@ export class InteractiveMode {
const slashCommands: SlashCommand[] = BUILTIN_SLASH_COMMANDS.filter(
(command) => command.name !== "fast" || this.currentModelSupportsFastMode(),
)
.filter((command) => command.name !== "permissions" || PERMISSIONS_COMMAND_ENABLED)
.filter((command) => !this.isExcludedBuiltinCommand(command.name))
.map((command) => ({
name: command.name,
Expand Down Expand Up @@ -4655,7 +4657,11 @@ export class InteractiveMode {
}
if (commandName === "permissions") {
this.editor.setText("");
await this.handlePermissionCommand(commandArgs);
if (PERMISSIONS_COMMAND_ENABLED) {
await this.handlePermissionCommand(commandArgs);
} else {
this.showStatus("/permissions is temporarily unavailable");
}
return;
}
if (commandName === "fast") {
Expand Down Expand Up @@ -8900,18 +8906,20 @@ export class InteractiveMode {
const selectedModel =
currentModel?.provider === "unknown" && currentModel.id === "unknown" ? undefined : currentModel;
let action = resolvePrimeInferencePostLoginModelAction(authResult, selectedModel, this.modelRegistry);
if (!action.openModelPicker) {
// Devo's debrand stub returns undefined (no hosted-inference auto-select).
if (!action?.openModelPicker) {
return false;
}

if (!selectedModel) {
try {
const availableModels = await this.getConnectionAvailableModels();
action = resolvePrimeInferencePostLoginModelAction(authResult, selectedModel, {
find: (provider, modelId) =>
availableModels.find((model) => model.provider === provider && model.id === modelId) ??
this.modelRegistry.find(provider, modelId),
});
action =
resolvePrimeInferencePostLoginModelAction(authResult, selectedModel, {
find: (provider, modelId) =>
availableModels.find((model) => model.provider === provider && model.id === modelId) ??
this.modelRegistry.find(provider, modelId),
}) ?? action;
} catch {
// Preserve the registry fallback so selection can still report a specific failure below.
}
Expand Down
Loading
Loading