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
65 changes: 65 additions & 0 deletions .github/workflows/verify.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
name: Verify

on:
push:
pull_request:

permissions:
contents: read

jobs:
node-quality:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run check:syntax
- run: npm run lint
- run: npm run frontend:check
- run: npm run test:frontend
- run: npm run frontend:build

unit-tests:
runs-on: windows-latest
strategy:
fail-fast: false
matrix:
shard: [0, 1, 2, 3]
env:
TEST_SHARD_COUNT: 4
TEST_SHARD_INDEX: ${{ matrix.shard }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run test:unit

integration-tests:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run test:thinking
- run: npm run test:env
- run: npm run test:integration

rust-tests:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
workspaces: src-tauri
- run: cargo test --manifest-path src-tauri/Cargo.toml
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ node_modules/

# Local config and secrets
proxy.config.json
proxy.config.before-*.json
.env
.env.*
!.env.example
Expand Down Expand Up @@ -30,6 +31,7 @@ build/
out/
*.zip
tauri-ui/dist/
/src-tauri/target*/

# Frontend mirrors (canonical source: tauri-ui/)
/app.js
Expand Down
2 changes: 1 addition & 1 deletion .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build/
coverage/
tauri-ui/dist/
tauri-ui/node_modules/
src-tauri/target/
src-tauri/target*/
src-tauri/gen/
**/*.min.js
**/*.lock
Expand Down
32 changes: 32 additions & 0 deletions docs/release-governance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Release package governance

Release builds use a clean, generated runtime under `dist/release-runtime`. The staging step installs only production dependencies from `package-lock.json`, bundles the current Node executable, and writes a runtime manifest.

## Commands

- `npm run clean:release` removes `dist/` and every Cargo `src-tauri/target*` directory after verifying that each path is inside the repository.
- `npm run clean:release:cache` removes only Cargo build caches and preserves collected installers and portable archives.
- `npm run release:prepare` recreates the Node runtime and production dependency staging directory.
- `npm run release:check` verifies the static Tauri resource and script configuration.
- `npm run tauri:build` normalizes `CARGO_TARGET_DIR` to an absolute path, builds the Tauri installers, then copies final installers and a SHA-256 manifest to `dist/release/installers`.
- `npm run release:portable` builds a clean portable directory, validates it, and creates the zip archive.

## Security rules

Published packages must never contain:

- `proxy.config.json`, `.env*`, management tokens, logs, JSONL audit files, or PID/lock files;
- `.hpm-runtime`, `.certs`, `.claude`, private keys, or symbolic links;
- npm lifecycle scripts or development dependencies.

The portable build is intentionally stateless. It ships only `proxy.config.example.json`; the application creates local state on first run. Existing portable state is never copied into a new release.

## Windows NSIS process-cleanup rules

The current-user NSIS installer runs `src-tauri/nsis/stop-installed-processes.ps1` before install and uninstall. Keep these constraints in place:

- Do not pass `$INSTDIR` or another path containing spaces through an `nsExec` command-line argument. NSIS and PowerShell quoting can split or shift the arguments. The cleanup script derives the fixed current-user location from `LOCALAPPDATA`.
- Never normalize a filesystem root from `C:\` to `C:`. On Windows, `C:` is drive-relative and resolves against that drive's current directory. Because NSIS sets its working directory to the install directory, this can make a normal installation path compare equal to the filesystem root.
- Preserve the filesystem-root safety check so cleanup can never scan, stop processes from, or perform lock checks against a whole drive.
- `test/installer-process-cleanup.test.js` must run the cleanup script with its working directory set to the installation directory. It must verify both that a real root is rejected and that the normal installation directory is accepted.
- Before publishing an installer change, run `npm run verify`, build the NSIS package, and perform one end-to-end current-user install. A successful source-level test alone is not sufficient for command-line and installer-host behavior.
4 changes: 3 additions & 1 deletion lib/proxy/audit.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ const MAX_TEXT_CHARS = parseBoundedInteger(
1024,
1024 * 1024
);
const SENSITIVE_AUDIT_KEY =
/^(?:authorization|proxy-authorization|(?:x-)?api[-_]?key|access[-_]?token|refresh[-_]?token|id[-_]?token|bearer[-_]?token|cookie|password|passwd|secret|credential)$/i;

const writeQueue = [];
let writing = false;
Expand All @@ -35,7 +37,7 @@ function compactAuditValue(value, depth = 0) {

const result = {};
for (const [key, item] of Object.entries(value)) {
result[key] = compactAuditValue(item, depth + 1);
result[key] = SENSITIVE_AUDIT_KEY.test(key) ? '[redacted]' : compactAuditValue(item, depth + 1);
}
return result;
}
Expand Down
51 changes: 51 additions & 0 deletions lib/proxy/builtin-model-routes.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
'use strict';

const BUILTIN_MODEL_ROUTES = [
{ from: 'gpt-5.6', profileId: 'openai-official', to: 'gpt-5.6-sol' },
{ from: 'gpt-5.6-sol', profileId: 'openai-official', to: 'gpt-5.6-sol' },
{ from: 'gpt-5.6-terra', profileId: 'openai-official', to: 'gpt-5.6-terra' },
{ from: 'gpt-5.6-luna', profileId: 'openai-official', to: 'gpt-5.6-luna' },
{ from: 'codex/gpt-5.6-sol', profileId: 'openai-official', to: 'gpt-5.6-sol' },
{ from: 'codex/gpt-5.6-terra', profileId: 'openai-official', to: 'gpt-5.6-terra' },
{ from: 'codex/gpt-5.6-luna', profileId: 'openai-official', to: 'gpt-5.6-luna' },
{ from: 'gemini-3.6-flash', profileId: 'google-official', to: 'gemini-3.6-flash' },
{ from: 'gemini-3.6-flash-medium', profileId: 'google-official', to: 'gemini-3.6-flash' },
{ from: 'gemini-3.6-flash-high', profileId: 'google-official', to: 'gemini-3.6-flash' },
{ from: 'gemini-3.6-flash-low', profileId: 'google-official', to: 'gemini-3.6-flash' },
{ from: 'Gemini 3.6 Flash (Medium)', profileId: 'google-official', to: 'gemini-3.6-flash' },
{ from: 'Gemini 3.6 Flash (High)', profileId: 'google-official', to: 'gemini-3.6-flash' },
{ from: 'Gemini 3.6 Flash (Low)', profileId: 'google-official', to: 'gemini-3.6-flash' },
];

function mergeBuiltinModelRoutes(config) {
if (!config || typeof config !== 'object') return config;
const profiles = new Set(
(Array.isArray(config.profiles) ? config.profiles : [])
.map((profile) => String((profile && profile.id) || '').trim())
.filter(Boolean)
);
const routes = Array.isArray(config.modelRoutes) ? config.modelRoutes.slice() : [];
const existing = new Set(
routes
.map((route) =>
String((route && route.from) || '')
.trim()
.toLowerCase()
)
.filter(Boolean)
);

for (const route of BUILTIN_MODEL_ROUTES) {
const key = route.from.toLowerCase();
if (!profiles.has(route.profileId) || existing.has(key)) continue;
routes.push({ ...route });
existing.add(key);
}

return { ...config, modelRoutes: routes };
}

module.exports = {
BUILTIN_MODEL_ROUTES,
mergeBuiltinModelRoutes,
};
73 changes: 73 additions & 0 deletions lib/proxy/claude-self-test-target.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
function appendClaudeMessagesPath(baseUrl) {
const normalized = String(baseUrl || '').replace(/\/+$/, '');
if (!normalized) return '';
return /\/v1$/i.test(normalized) ? normalized + '/messages' : normalized + '/v1/messages';
}

function createClaudeSelfTestTargetResolver(dependencies = {}) {
const {
claudeDesktopBaseUrl,
claudeDesktopProxyPrefix,
getClaudeDesktopGatewayConfig,
getClaudeDesktopRoutes,
getClaudeDesktopTraeProcessStyle,
getConfig,
getProfiles,
isTraeOfficialProfile,
isTraeOfficialTarget,
} = dependencies;
if (typeof getConfig !== 'function' || typeof getProfiles !== 'function') {
throw new TypeError('createClaudeSelfTestTargetResolver requires runtime getters');
}

return function getClaudeDesktopSelfTestTarget() {
const config = getConfig();
const profiles = getProfiles();
const routes = getClaudeDesktopRoutes(config);
if (!Array.isArray(routes) || routes.length === 0) return null;
const route =
routes.find((item) => {
const profile = profiles.find((candidate) => candidate?.id === item.profileId) || {};
return isTraeOfficialProfile(profile) || isTraeOfficialTarget(profile.targetUrl);
}) || routes[0];
if (!route?.from) return null;

const gateway = getClaudeDesktopGatewayConfig();
let baseUrl = '';
let token = '';
if (gateway?.isLocalProxy && gateway.baseUrl) {
baseUrl = gateway.baseUrl;
token = gateway.token || '';
} else {
baseUrl = claudeDesktopBaseUrl(config) + claudeDesktopProxyPrefix;
token = typeof config?.claudeDesktop?.token === 'string' ? config.claudeDesktop.token : '';
}

const endpoint = appendClaudeMessagesPath(baseUrl);
if (!endpoint) return null;
const headers = {
'Content-Type': 'application/json',
'anthropic-version': '2023-06-01',
};
if (token) headers.Authorization = 'Bearer ' + token;
const profile = profiles.find((item) => item?.id === route.profileId) || {};
return {
source: gateway?.isLocalProxy
? 'claude-desktop-gateway'
: 'configured-claude-desktop-gateway',
endpoint,
headers,
model: route.from,
expectedStyle: getClaudeDesktopTraeProcessStyle(config),
route: {
from: route.from,
profileId: route.profileId,
profileProtocol: profile.protocol || '',
targetUrl: profile.targetUrl || '',
isTraeOfficial: isTraeOfficialProfile(profile) || isTraeOfficialTarget(profile.targetUrl),
},
};
};
}

module.exports = { appendClaudeMessagesPath, createClaudeSelfTestTargetResolver };
Loading
Loading