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
18 changes: 12 additions & 6 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -155,9 +155,12 @@ jobs:
else
tar -czf "../../ocx-${RELEASE_VERSION}-${STANDALONE_TARGET}.tar.gz" ocx gui
fi
cd ../../..
if [[ "$RUNNER_OS" == "Windows" ]]; then sha256sum "dist/ocx-${RELEASE_VERSION}-${STANDALONE_TARGET}.zip" > "dist/ocx-${RELEASE_VERSION}-${STANDALONE_TARGET}.sha256"
else sha256sum "dist/ocx-${RELEASE_VERSION}-${STANDALONE_TARGET}.tar.gz" > "dist/ocx-${RELEASE_VERSION}-${STANDALONE_TARGET}.sha256"
cd ../..
# shasum -c in attach-release runs from dist/release, where the artifact download
# lands these files flat; the checksum therefore records the bare file name, which
# sha256sum takes verbatim from its argument.
if [[ "$RUNNER_OS" == "Windows" ]]; then sha256sum "ocx-${RELEASE_VERSION}-${STANDALONE_TARGET}.zip" > "ocx-${RELEASE_VERSION}-${STANDALONE_TARGET}.sha256"
else sha256sum "ocx-${RELEASE_VERSION}-${STANDALONE_TARGET}.tar.gz" > "ocx-${RELEASE_VERSION}-${STANDALONE_TARGET}.sha256"
fi

- name: Upload standalone release
Expand Down Expand Up @@ -345,6 +348,7 @@ jobs:
run: bunx tauri build --ci --target ${{ matrix.target }} --bundles ${{ matrix.bundles }}

- name: Rename release assets
shell: bash
env:
RELEASE_VERSION: ${{ inputs.version }}
DESKTOP_TARGET: ${{ matrix.target }}
Expand Down Expand Up @@ -481,7 +485,7 @@ jobs:
gh release upload "v${RELEASE_VERSION}" dist/release/* --clobber

publish:
needs: validate-dispatch
needs: [validate-dispatch, package-standalone, package-desktop]
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
Expand Down Expand Up @@ -632,8 +636,10 @@ jobs:
fi

# Keep in sync with the service-lifecycle.yml trigger paths. src/cli.ts is
# the pre-restructure compat stub that durable launchers still execute.
if printf '%s\n' "$changed_files" | grep -Eq '^(src/service\.ts|src/cli\.ts|src/cli/index\.ts|src/lib/bun-runtime\.ts|package\.json|bun\.lock|\.github/workflows/service-lifecycle\.yml|\.github/workflows/release\.yml)$'; then
# the pre-restructure compat stub that durable launchers still execute; the
# service implementation itself is the src/service/ directory, and the desktop
# shell packages and launches it.
if printf '%s\n' "$changed_files" | grep -Eq '^(src/service\.ts|src/service/.*|desktop/.*|src/cli\.ts|src/cli/index\.ts|src/lib/bun-runtime\.ts|package\.json|bun\.lock|\.github/workflows/service-lifecycle\.yml|\.github/workflows/release\.yml)$'; then
service_url="$(
gh run list \
--workflow service-lifecycle.yml \
Expand Down
8 changes: 8 additions & 0 deletions .github/workflows/service-lifecycle.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ on:
branches: [main, dev]
paths:
- "src/service.ts"
# The service implementation is the src/service/ directory; src/service.ts is only
# the pre-restructure compat facade. The desktop shell packages and launches the
# service, so its changes carry lifecycle evidence too.
- "src/service/**"
- "desktop/**"
# Keep in sync with the release.yml service-gate regex (release.yml "Require
# successful Cross-platform CI" step). src/cli.ts is the pre-restructure compat
# stub that durable launchers still execute.
Expand All @@ -22,6 +27,9 @@ on:
paths:
- "src/service.ts"
# Keep in sync with the release.yml service-gate regex (see above).
- "src/service/**"
- "desktop/**"
# Keep in sync with the release.yml service-gate regex (see above).
- "src/cli.ts"
- "src/cli/index.ts"
- "src/lib/bun-runtime.ts"
Expand Down
4 changes: 4 additions & 0 deletions desktop/src-tauri/src/proxy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ impl ProxyClient {
client: Client::builder()
.timeout(Duration::from_secs(4))
.user_agent(Auth::user_agent())
// The admin token attached to these requests is for loopback only.
// reqwest honours system proxy configuration by default, which would
// route the credential through whatever proxy the machine declares.
.no_proxy()
.build()?,
endpoint,
auth,
Expand Down
4 changes: 3 additions & 1 deletion scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -1633,7 +1633,9 @@
"ambiguous-resend-gate.test.ts": "lib",
"docs-developer-role-policy.test.ts": "ci-workflows",
"ambiguous-resend-composition.test.ts": "lib",
"chat-inbound-developer-position.test.ts": "responses"
"chat-inbound-developer-position.test.ts": "responses",
"release-pipeline-contract.test.ts": "ci-workflows",
"desktop-proxy-direct-transport.test.ts": "clients"
},
"migrated": [
"adapters",
Expand Down
2 changes: 1 addition & 1 deletion src/config/process-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ export function isOcxCommandLine(commandLine: string): boolean {
|| normalized.includes("src/cli/index.ts")
|| normalized.includes("@bitkyc08/opencodex")
|| /@bitkyc08\/\.opencodex-/.test(normalized)
|| /(?:^|[\s/"'])(?:ocx|opencodex)(?:\.cmd)?(?:$|[\s"'])/.test(normalized);
|| /(?:^|[\s/"'])(?:ocx|opencodex)(?:\.cmd|\.exe)?(?:$|[\s"'])/.test(normalized);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Update the owned process-state architecture docs

This changes the command-line identity contract in src/config/process-state.ts, but the commit does not update either structure/runtime.md or structure/config.md, which structure/INDEX.md maps as owners of src/config/. Document the newly accepted Windows .exe process shape and its identity boundary in both mapped documents so the architecture SSoT remains synchronized with runtime behavior.

AGENTS.md reference: src/AGENTS.md:L11-L11

Useful? React with 👍 / 👎.

}

export function isOcxStartCommandLine(commandLine: string): boolean {
Expand Down
6 changes: 4 additions & 2 deletions tests/ci-workflows/ci-workflows.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -828,7 +828,7 @@ describe("GitHub Actions hardening", () => {
};
publish?: {
"runs-on"?: string;
needs?: string;
needs?: string[];
permissions?: Record<string, string>;
};
};
Expand All @@ -843,7 +843,9 @@ describe("GitHub Actions hardening", () => {
contents: "read",
});

expect(release.jobs?.publish?.needs).toBe("validate-dispatch");
// Publication is the irreversible public act, so it waits for both packaging jobs;
// the full ordering contract is in tests/ci-workflows/release-pipeline-contract.test.ts.
expect(release.jobs?.publish?.needs).toEqual(["validate-dispatch", "package-standalone", "package-desktop"]);
expect(release.jobs?.publish?.["runs-on"]).toBe("ubuntu-latest");
expect(release.jobs?.publish?.permissions).toEqual({
contents: "write",
Expand Down
174 changes: 174 additions & 0 deletions tests/ci-workflows/release-pipeline-contract.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import { describe, expect, test } from "bun:test";
import { readFileSync, readdirSync } from "node:fs";
import { repoPath } from "../helpers/repo-root";

type WorkflowStep = {
name?: string;
if?: string;
uses?: string;
with?: Record<string, unknown>;
run?: string;
shell?: string;
};

type WorkflowJob = {
needs?: string[];
strategy?: { matrix?: { include?: Array<{ os?: string }> } };
steps?: WorkflowStep[];
};

type Workflow = { jobs?: Record<string, WorkflowJob | undefined> };

function needsOf(job: WorkflowJob | undefined): string[] {
if (job?.needs === undefined) return [];
return typeof job.needs === "string" ? [job.needs] : job.needs;
}

function readWorkflow(...segments: string[]): Workflow {
return Bun.YAML.parse(readFileSync(repoPath(...segments), "utf8")) as Workflow;
}

function triggerPaths(workflowText: string, trigger: string, until: string): string[] {
const afterTrigger = workflowText.split(`${trigger}:`)[1]?.split(`${until}:`)[0];
expect(afterTrigger).toBeDefined();
return afterTrigger!
.split("\n")
.map(line => line.trim())
.filter(line => line.startsWith('- "'))
.map(line => line.slice(3, -1));
}

/**
* Contracts for the release pipeline itself. Each assertion encodes a defect that a green
* workflow can still carry: a bash script silently reinterpreted by PowerShell on a Windows
* runner, a checksum that records a path its verifier cannot resolve, publication that outruns
* packaging, and a service gate that names one file while the implementation is a directory.
*/
describe("release pipeline contract", () => {
const release = readWorkflow(".github", "workflows", "release.yml");

test("every multi-line script on a Windows runner declares an explicit shell", () => {
for (const jobId of ["package-standalone", "package-desktop"]) {
const job = release.jobs?.[jobId];
expect(job, jobId).toBeDefined();
const runsOnWindows = job!.strategy?.matrix?.include?.some(entry => entry.os === "windows-latest");
expect(runsOnWindows, `${jobId} exercises Windows`).toBe(true);
for (const step of job!.steps ?? []) {
// A single command line is shell-neutral; a script block implies shell-specific
// syntax and must not fall back to the runner's default shell on Windows.
if (typeof step.run !== "string" || !step.run.includes("\n")) continue;
// Steps fenced away from Windows never meet PowerShell.
if (/runner\.os\s*==\s*'(Linux|macOS)'/.test(step.if ?? "")) continue;
expect(step.shell, `${jobId} / ${step.name}`).toBeDefined();
}
}
});

test("the release asset rename runs under bash", () => {
const step = release.jobs?.["package-desktop"]?.steps
?.find(candidate => candidate.run?.includes("collect-release-assets.ts"));
expect(step).toBeDefined();
// The script uses backslash continuations and "$VAR" expansion, which PowerShell does
// not read the way bash does; on the Windows matrix this step is only correct under bash.
expect(step!.shell).toBe("bash");
});

test("standalone checksums record bare names that resolve where the verifier runs", () => {
const archive = release.jobs?.["package-standalone"]?.steps
?.find(candidate => candidate.run?.includes("sha256sum"));
expect(archive).toBeDefined();
const checksumLines = archive!.run!.split("\n")
.filter(line => line.includes("sha256sum") && !line.trim().startsWith("#"));
expect(checksumLines.length).toBeGreaterThanOrEqual(2);
for (const line of checksumLines) {
const argument = /sha256sum\s+"([^"]+)"/.exec(line)?.[1];
expect(argument, line).toBeDefined();
// shasum -c resolves the recorded path relative to the verifier's working directory,
// which is dist/release; any directory prefix names a file that cannot exist there.
expect(argument!).not.toContain("/");
// The redirect target gets the same treatment: a bare output name is what makes the
// checksum file land in the directory the upload glob scans.
const output = />\s+"([^"]+)"/.exec(line)?.[1];
expect(output, line).toBeDefined();
expect(output!).not.toContain("/");
}

// The bare names above only resolve end to end if the step checksums from the directory
// the artifact lives in (it leaves the per-target build directory first), if the upload
// glob picks the checksum file up, and if the download flattens every artifact beside
// the verifier. Locking only the final shasum line would leave those joints unguarded.
// YAML block scalars are dedented on parse, so the script's own lines carry no
// indentation here.
expect(archive!.run).toMatch(/^ *cd \.\.\/\.\.$/m);
const upload = release.jobs?.["package-standalone"]?.steps
?.find(candidate => candidate.uses?.startsWith("actions/upload-artifact@"));
expect(String(upload?.with?.path)).toContain("dist/ocx-*.sha256");

const download = release.jobs?.["attach-release"]?.steps
?.find(candidate => candidate.uses?.startsWith("actions/download-artifact@")
&& candidate.with?.pattern === "standalone-*");
expect(download?.with?.["merge-multiple"]).toBe(true);
expect(download?.with?.path).toBe("dist/release");

const verify = release.jobs?.["attach-release"]?.steps
?.find(candidate => candidate.run?.includes("shasum"));
expect(verify).toBeDefined();
expect(verify!.run).toContain("cd dist/release");
expect(verify!.run).toContain("shasum -a 256 -c ./*.sha256");
});

test("publication waits for both packaging jobs", () => {
const publish = release.jobs?.publish;
expect(publish).toBeDefined();
expect(needsOf(publish).sort())
.toEqual(["package-desktop", "package-standalone", "validate-dispatch"]);

const attach = release.jobs?.["attach-release"];
expect(attach).toBeDefined();
expect(needsOf(attach).sort()).toEqual(["package-desktop", "package-standalone", "publish"]);
});
});

describe("service lifecycle trigger coverage", () => {
const lifecycleText = readFileSync(repoPath(".github", "workflows", "service-lifecycle.yml"), "utf8");
const releaseText = readFileSync(repoPath(".github", "workflows", "release.yml"), "utf8");

test("both triggers cover the service directory and the desktop shell", () => {
const pushPaths = triggerPaths(lifecycleText, "push", "workflow_dispatch");
const prPaths = triggerPaths(lifecycleText, "pull_request", "push");
for (const paths of [prPaths, pushPaths]) {
expect(paths).toContain("src/service.ts");
expect(paths).toContain("src/service/**");
expect(paths).toContain("desktop/**");
}
expect([...prPaths].sort()).toEqual([...pushPaths].sort());
});

test("the release service gate matches every implemented service module and desktop file", () => {
const gateSource = releaseText.match(/grep -Eq '(\^\([^']+\)\$)'/)?.[1];
expect(gateSource).toBeDefined();
const gate = new RegExp(gateSource!);

// Derived from the tree, not restated: the service implementation is a directory, so
// every module in it must satisfy the gate that demands lifecycle evidence.
const serviceModules = readdirSync(repoPath("src", "service"))
.filter(entry => entry.endsWith(".ts"));
expect(serviceModules.length).toBeGreaterThanOrEqual(10);
for (const module of serviceModules) {
expect(gate.test(`src/service/${module}`), `src/service/${module}`).toBe(true);
}
expect(gate.test("src/service.ts")).toBe(true);

const desktopSurfaces = [
...readdirSync(repoPath("desktop", "scripts")).map(entry => `desktop/scripts/${entry}`),
...readdirSync(repoPath("desktop", "src-tauri", "src")).map(entry => `desktop/src-tauri/src/${entry}`),
];
expect(desktopSurfaces.length).toBeGreaterThanOrEqual(10);
for (const path of desktopSurfaces) {
expect(gate.test(path), path).toBe(true);
}

expect(gate.test("src/router.ts")).toBe(false);
expect(gate.test("docs-site/src/pages/index.astro")).toBe(false);
});
});
23 changes: 23 additions & 0 deletions tests/clients/desktop-proxy-direct-transport.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { describe, expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import { repoPath } from "../helpers/repo-root";

/**
* The desktop shell's management client talks to the loopback proxy with the admin token
* attached. reqwest resolves system proxy configuration by default — HTTP_PROXY,
* HTTPS_PROXY, and the platform proxy settings behind them — so a machine-wide proxy
* would receive a credential that is only ever meant for the loopback endpoint. The builder
* has to opt out explicitly; nothing in the request path re-checks it afterwards.
*/
describe("desktop management transport", () => {
const source = readFileSync(repoPath("desktop", "src-tauri", "src", "proxy.rs"), "utf8");

test("the proxy client disables system proxy resolution for its loopback traffic", () => {
// Line comments are stripped first: a .no_proxy() that only exists in prose must not
// satisfy the contract.
const code = source.replace(/\/\/[^\n]*/g, "");
const builder = code.split("Client::builder()")[1]?.split(".build()")[0];
expect(builder).toBeDefined();
expect(builder).toContain(".no_proxy()");
});
});
4 changes: 3 additions & 1 deletion tests/fixtures/test-layout-expected.json
Original file line number Diff line number Diff line change
Expand Up @@ -1465,5 +1465,7 @@
"ambiguous-resend-gate.test.ts": "lib",
"docs-developer-role-policy.test.ts": "ci-workflows",
"ambiguous-resend-composition.test.ts": "lib",
"chat-inbound-developer-position.test.ts": "responses"
"chat-inbound-developer-position.test.ts": "responses",
"release-pipeline-contract.test.ts": "ci-workflows",
"desktop-proxy-direct-transport.test.ts": "clients"
}
17 changes: 17 additions & 0 deletions tests/service/process-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,23 @@ describe("proxy process-state ownership", () => {
expect(isOcxStartCommandLine("opencodex update --tag latest")).toBe(false);
});

test("recognizes the Windows standalone executable, whatever its case or path shape", () => {
// scripts/build-standalone.ts and desktop/scripts/prepare-sidecar.ts both emit ocx.exe
// on Windows targets, and the bundled sidecar is copied as ocx-<triple>.exe. A quoted
// install path with spaces is the realistic WMIC/PowerShell command line for it.
expect(isOcxCommandLine('"C:/Program Files/OpenCodex/bin/ocx.exe" start --port 10100')).toBe(true);
expect(isOcxCommandLine('"C:\\Program Files\\OpenCodex\\bin\\OCX.EXE" start')).toBe(true);
expect(isOcxCommandLine("C:/tools/ocx.exe stop")).toBe(true);
expect(isOcxCommandLine("ocx.exe")).toBe(true);
expect(isOcxCommandLine("opencodex.exe status")).toBe(true);
expect(isOcxStartCommandLine('"C:/Program Files/OpenCodex/bin/ocx.exe" start')).toBe(true);
// Lookalikes stay foreign: the token boundary around the executable name is the whole
// defence, and widening it for .exe must not widen it for neighbours.
expect(isOcxCommandLine("not-ocx.exe start")).toBe(false);
expect(isOcxCommandLine("myocx.exe")).toBe(false);
expect(isOcxCommandLine("ocx.exes start")).toBe(false);
});

test("the ownership probe distinguishes a real owner from a reused PID", () => {
// The stop-side teardown recovery asks this about a PID recorded in a receipt. Bare
// liveness said "still running" for any process that inherited the number, so the
Expand Down
Loading