Skip to content
Draft
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

This file was deleted.

This file was deleted.

This file was deleted.

This file was deleted.

11 changes: 9 additions & 2 deletions .github/scripts/pr-labeler.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -165,8 +165,15 @@ describe("pr-labeler workflow", () => {
it("keeps trusted default-branch checkout, concurrency cancel, and minimal permissions", () => {
assert.match(workflow, /ref:\s*\$\{\{\s*github\.event\.repository\.default_branch\s*\}\}/);
assert.match(workflow, /cancel-in-progress:\s*true/);
assert.match(workflow, /pull-requests:\s*read/);
assert.match(workflow, /issues:\s*write/);
assert.doesNotMatch(workflow, /pull-requests:\s*write/);
// The issues label endpoints are shared with pull requests: writing a label
// onto a PR number needs pull_requests=write alongside issues=write, which
// GitHub reports as `issues=write; pull_requests=write`. Pinning this to
// read made the workflow fail closed on the first PR that actually needed a
// label applied (#565), so write is the minimum here, not an escalation.
assert.match(workflow, /pull-requests:\s*write/);
// contents stays read — the labeler never pushes.
assert.match(workflow, /contents:\s*read/);
assert.doesNotMatch(workflow, /contents:\s*write/);
});
});
10 changes: 8 additions & 2 deletions .github/workflows/pr-labeler.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,14 @@ concurrency:

permissions:
contents: read
# pulls.get only needs read; label mutations use the issues API.
pull-requests: read
# pulls.get only needs read, but the issues label endpoints are shared with
# pull requests: adding or removing a label on a PR number is rejected with
# "Resource not accessible by integration" unless the token also carries
# pull_requests=write (the API reports `issues=write; pull_requests=write`
# in x-accepted-github-permissions). Read-only here silently worked while
# every run happened to be a no-op sync, and failed on the first PR that
# actually needed a label written.
pull-requests: write
issues: write

jobs:
Expand Down
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,14 @@ devlog/
.opencode/

# Local agent/session artifacts
# These are per-machine agent state (goalplans, ledgers, evidence scratch).
# They are never part of the product and must not be committed, not even with
# `git add -f` — see tests/repo-hygiene.test.ts, which fails if any path here
# becomes tracked again.
.codexclaw/
**/.codexclaw/
.omo/
**/.omo/

# Test-generated artifacts
tests/.tmp-*/
Expand Down
Binary file removed devlog/.DS_Store
Binary file not shown.
Binary file removed devlog/_plan/.DS_Store
Binary file not shown.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@bitkyc08/opencodex",
"version": "2.7.41",
"version": "2.7.42",
"description": "Universal provider proxy for OpenAI Codex & Claude Code — use any LLM with Codex CLI/App/SDK and Claude Code",
"type": "module",
"main": "./bin/package-main.mjs",
Expand Down
1 change: 0 additions & 1 deletion src/providers/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -978,7 +978,6 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
authKind: "key",
dashboardUrl: "https://ollama.com/settings/keys",
// Live IDs verified 2026-07-10; qwen3-coder:480b retires 2026-07-15.
// Evidence: .codexclaw/evidence/260710_wp9_ollama_cloud_model_ids.md.
models: ["glm-5.2", "deepseek-v4-pro", "qwen3-coder:480b", "gpt-oss:120b", "kimi-k2.6", "minimax-m3", "qwen3.5:397b", "gemma4:31b"],
defaultModel: "glm-5.2",
noVisionModels: [
Expand Down
17 changes: 11 additions & 6 deletions src/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,11 @@ import { execFileSync, execSync } from "node:child_process";
import { chmodSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { expandUserPath, getConfigDir, readPid, removePid, removeRuntimePort } from "./config";
import { getConfigDir, readPid, removePid, removeRuntimePort } from "./config";
import { loadConfig } from "./config";
import { restoreNativeCodex } from "./codex/inject";
import { stripGrokConfig } from "./grok/inject";
import { isWslRuntime } from "./codex/home";
import { isWslRuntime, resolveCodexHomeDir } from "./codex/home";
import { durableBunPath, durableBunRuntime } from "./lib/bun-runtime";
import { isProcessAlive, stopProxy } from "./lib/process-control";
import { serviceApiTokenFilePath } from "./lib/service-secrets";
Expand Down Expand Up @@ -87,8 +87,9 @@ function serviceStatePaths(): string[] {
}

function currentCodexHome(): string {
const raw = process.env.CODEX_HOME?.trim();
return raw ? resolve(expandUserPath(raw)) : join(homedir(), ".codex");
// Service ownership must use the same CODEX_HOME resolver as Codex injection,
// tray startup, and the rest of the runtime. On this host that is E:\\codex.
return resolveCodexHomeDir();
}

function currentOpenCodexHome(): string {
Expand Down Expand Up @@ -1069,15 +1070,19 @@ export function windowsTaskRegistrationHealthy(
const action = taskXmlSection(scrubbed, "Exec");
// A self-closing <LogonTrigger /> leaves an empty section, so look for the element
// itself — scoped to <Triggers> so a decoy elsewhere cannot satisfy it.
const registeredAction = action.toLowerCase();
const registeredCommand = `<Command>${taskXmlString(wscript)}</Command>`.toLowerCase();
const registeredArguments = `<Arguments>${taskXmlString(`/b /nologo "${launcher}"`)}</Arguments>`.toLowerCase();
return taskXmlElementCount(triggers, "LogonTrigger") > 0
&& taskXmlOptionalValueEquals(trigger, "Enabled", "true")
&& /<LogonType>\s*InteractiveToken\s*<\/LogonType>/i.test(principal)
&& taskXmlOptionalValueEquals(principal, "RunLevel", "LeastPrivilege")
&& taskXmlOptionalValueEquals(settings, "Enabled", "true")
&& /<MultipleInstancesPolicy>\s*IgnoreNew\s*<\/MultipleInstancesPolicy>/i.test(settings)
&& /<ExecutionTimeLimit>\s*PT0S\s*<\/ExecutionTimeLimit>/i.test(settings)
&& action.includes(`<Command>${taskXmlString(wscript)}</Command>`)
&& action.includes(`<Arguments>${taskXmlString(`/b /nologo "${launcher}"`)}</Arguments>`);
// Windows paths are case-insensitive; schtasks may normalize their casing.
&& registeredAction.includes(registeredCommand)
&& registeredAction.includes(registeredArguments);
}

export interface WindowsSchedulerXmlState {
Expand Down
10 changes: 6 additions & 4 deletions src/tray/windows.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { execFile, execFileSync, spawn } from "node:child_process";
import { createHash } from "node:crypto";
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { join, resolve } from "node:path";
import { expandUserPath, getConfigDir } from "../config";
import { getConfigDir } from "../config";
import { durableBunPath } from "../lib/bun-runtime";
import { hardenSecretDir, hardenSecretPath } from "../lib/windows-secret-acl";
import { resolveCodexHomeDir } from "../codex/home";

const RUN_KEY = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run";
const RUN_PARENT_KEY = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion";
Expand Down Expand Up @@ -75,8 +75,10 @@ function sourceTrayIconPaths(): string[] {
}

function currentCodexHome(): string {
const raw = process.env.CODEX_HOME?.trim();
return raw ? resolve(expandUserPath(raw)) : join(homedir(), ".codex");
// Keep the tray's listener/launcher target aligned with Codex's active home.
// This matters on the Windows desktop setup, where CODEX_HOME is E:\\codex
// instead of the historical %USERPROFILE%\\.codex fallback.
return resolveCodexHomeDir();
Comment on lines 77 to +81

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Add a focused regression test for the active Codex home.

This changes tray behavior from local resolution to resolveCodexHomeDir(). Add a test covering a trimmed CODEX_HOME such as E:\codex and the fallback path, so a future tray change cannot silently revert to %USERPROFILE%\.codex.

🧰 Tools
🪛 ast-grep (0.45.0)

[warning] Importing child_process exposes a command-execution surface; ensure any command/argument built from input is validated, and prefer execFile/spawn with an argument array over exec.
Context: import { execFile, execFileSync, spawn } from "node:child_process";
Note: [CWE-78] Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').

(detect-child-process-typescript)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/tray/windows.ts` around lines 77 - 81, Add focused regression coverage
for currentCodexHome, verifying that a trimmed CODEX_HOME value such as E:\codex
is returned and that the fallback resolves to the expected user-profile .codex
path. Mock or configure the environment and resolveCodexHomeDir consistently
with existing test patterns, preserving both active-home and fallback behavior.

Source: Path instructions

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Fix the actual tray home-selection path

On Windows, resolveCodexHomeDir() is semantically identical to the removed implementation: a set CODEX_HOME is still trimmed, expanded, and resolved, while an unset value still becomes homedir()/.codex because WSL discovery is inapplicable. Consequently, the cited CODEX_HOME=E:\codex scenario produces exactly the same tray entry before and after this change, so the reported tray mis-targeting remains unresolved. Trace the persisted/listener path that is selecting the wrong home and add a focused Windows tray regression test demonstrating the failure and fix.

AGENTS.md reference: AGENTS.md:L93-L95

Useful? React with 👍 / 👎.

}

function currentEntry(): WindowsTrayEntry {
Expand Down
57 changes: 57 additions & 0 deletions tests/repo-hygiene.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { describe, expect, test } from "bun:test";
import { fileURLToPath } from "node:url";

const repoRoot = fileURLToPath(new URL("../", import.meta.url));

/**
* Local agent/session state must never reach a commit.
*
* `.gitignore` alone does not enforce this: `git add -f` overrides it silently,
* and once a path is tracked the ignore rule stops applying to it entirely. The
* `.codexclaw/` goalplans and ledgers were committed exactly that way and rode
* along into `main` and `preview` before anyone noticed.
*
* This test closes that gap by asserting against the real index instead of the
* ignore file, so a forced add fails CI on the commit that introduces it.
*/
const FORBIDDEN_TRACKED_DIRS = [".codexclaw", ".omo", ".claude", "node_modules", ".tmp"];

const FORBIDDEN_TRACKED_FILENAMES = [".DS_Store", "Thumbs.db"];

function trackedFiles(): string[] {
const result = Bun.spawnSync(["git", "ls-files"], { cwd: repoRoot });
if (result.exitCode !== 0) {
throw new Error(`git ls-files failed: ${new TextDecoder().decode(result.stderr)}`);
}
return new TextDecoder()
.decode(result.stdout)
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
}

describe("repository hygiene", () => {
test("no local agent or session state is tracked", () => {
const offenders = trackedFiles().filter((path) =>
path.split("/").some((segment) => FORBIDDEN_TRACKED_DIRS.includes(segment)),
);

expect(offenders).toEqual([]);
});

test("no OS metadata files are tracked", () => {
const offenders = trackedFiles().filter((path) =>
FORBIDDEN_TRACKED_FILENAMES.includes(path.split("/").pop() ?? ""),
);

expect(offenders).toEqual([]);
});

test("gitignore still declares the agent-state directories", async () => {
const ignore = await Bun.file(new URL("../.gitignore", import.meta.url)).text();

for (const dir of FORBIDDEN_TRACKED_DIRS) {
expect(ignore).toContain(`${dir}/`);
}
});
});
Loading