Просмотр исходного кода

feat: support immutable Git package installs

David Nabraczky-Hajos 4 недель назад
Родитель
Сommit
a1c4a6e95e

+ 3 - 1
.gitignore

@@ -1,5 +1,7 @@
 node_modules/
-dist/
+dist/*
+!dist/cli.js
+!dist/config.js
 *.tgz
 coverage/
 *.log

+ 81 - 16
README.md

@@ -64,36 +64,101 @@ such as `account/logout` are forwarded unchanged. Logging out through any
 connected client therefore logs out the shared server and affects every client
 using that server.
 
-## Source-based release and installation
+## Install from an immutable Git revision
 
-No registry publication is used. From a clean source checkout:
+The canonical repository is
+`https://git.bodicsek.host/bodicsek/codex-app-server-bridge.git`. Git access and
+any required trust or authentication configuration are host prerequisites;
+keep credentials out of package URLs and command output. Preflight the exact
+release tag before installation:
 
 ```sh
-npm ci
-npm run typecheck
-npm run build
-npm test
-npm pack --dry-run
-npm pack
-npm install --global ./codex-app-server-bridge-0.1.0.tgz
+git ls-remote --exit-code \
+  https://git.bodicsek.host/bodicsek/codex-app-server-bridge.git \
+  refs/tags/v0.1.1
+```
+
+Install only an immutable release tag or full 40-character commit. Do not use
+`#main` for a reproducible installation. The repository contains the compiled
+runtime, so lifecycle scripts are disabled and no TypeScript toolchain is
+needed on the consumer machine:
+
+```sh
+npm run verify:git-package -- \
+  'git+https://git.bodicsek.host/bodicsek/codex-app-server-bridge.git#v0.1.1'
+npm install --global \
+  --ignore-scripts \
+  --cache /private/tmp/codex-app-server-bridge-npm-cache \
+  'git+https://git.bodicsek.host/bodicsek/codex-app-server-bridge.git#v0.1.1'
 command -v codex-app-server-bridge
+npm list --global --depth=0 codex-app-server-bridge
 codex-app-server-bridge --unsupported
 ```
 
-The dry-run file list is the review boundary for the installable unit. It
-should contain only `package.json`, `README.md`, `LICENSE`, `bin/`, and the
-generated `dist/` JavaScript. TypeScript source, tests, credentials, and local
-artifacts are excluded. The generated build output is reproducible and remains
-uncommitted.
+For a commit pin, replace `v0.1.1` in both commands with the full commit
+reported by `git ls-remote`; abbreviated commits and branches are rejected by
+the verifier. The verifier uses a temporary global prefix and never contacts a
+Codex App Server. The end-user install intentionally omits `--prefix` so the
+executable is placed on the normal global PATH used to launch Emacs.
+
+Installing `v0.1.1` upgrades the existing `0.1.0` local-tarball installation
+in place. The package name, executable, and `CODEX_PATH` value do not change.
+Existing bridge processes continue using the version with which they started;
+new agent-shell launches discover the replacement PATH executable.
+
+To roll back, reinstall the verified `0.1.0` tarball or a previously verified
+immutable Git revision:
+
+```sh
+npm install --global \
+  --ignore-scripts \
+  --cache /private/tmp/codex-app-server-bridge-npm-cache \
+  /Users/dnabracz/Work/packages/codex-app-server-bridge-0.1.0.tgz
+npm install --global \
+  --ignore-scripts \
+  --cache /private/tmp/codex-app-server-bridge-npm-cache \
+  'git+https://git.bodicsek.host/bodicsek/codex-app-server-bridge.git#v0.1.1'
+```
+
+Remove the globally installed package without changing Emacs configuration:
+
+```sh
+npm uninstall --global codex-app-server-bridge
+```
+
+## Build and verify a local npm package
+
+No registry publication is used. The package remains private. Maintainers use
+the explicit `pack:verified` workflow, which compiles, tests, checks committed
+runtime freshness, and invokes `npm pack --ignore-scripts`. A raw `npm pack`
+does not run those release gates.
+
+```sh
+mkdir -p /Users/dnabracz/Work/packages
+npm run pack:verified -- \
+  --pack-destination /Users/dnabracz/Work/packages \
+  --cache /private/tmp/codex-app-server-bridge-npm-cache
+npm run verify:package -- \
+  /Users/dnabracz/Work/packages/codex-app-server-bridge-0.1.1.tgz
+```
+
+The artifact contains only package metadata, `README.md`, `LICENSE`, the
+launcher under `bin/`, and the two compiled runtime files under `dist/`.
+TypeScript source, tests, credentials, caches, and local artifacts are
+excluded. The verifier installs the tarball in a temporary prefix and exercises
+the installed launcher without connecting to the shared App Server.
 
 ## Development
 
 ```sh
 npm ci
 npm run typecheck
-npm run build
+npm run compile
 npm test
+npm run verify:runtime
 ```
 
 Tests use only `node:test` and a loopback WebSocket peer built from Node core
-APIs. They do not require Codex, credentials, or external network access.
+APIs. They do not require Codex, credentials, or external network access. When
+source changes, commit the matching `dist/cli.js` and `dist/config.js` output;
+`npm run verify:runtime` fails when those files are stale.

+ 165 - 0
dist/cli.js

@@ -0,0 +1,165 @@
+import { createInterface } from "node:readline";
+import { ConfigurationError, parseEndpoint, validateInvocation, } from "./config.js";
+const STARTUP_TIMEOUT_MS = 5_000;
+function writeDiagnostic(output, message) {
+    output.write(`codex-app-server-bridge: ${message}\n`);
+}
+export async function runBridge(options) {
+    try {
+        validateInvocation(options.arguments);
+    }
+    catch (error) {
+        const message = error instanceof ConfigurationError ? error.message : "invalid invocation";
+        writeDiagnostic(options.errorOutput, message);
+        return 1;
+    }
+    let endpoint;
+    try {
+        endpoint = parseEndpoint(options.environment.CODEX_APP_SERVER_URL);
+    }
+    catch (error) {
+        const message = error instanceof ConfigurationError ? error.message : "invalid configuration";
+        writeDiagnostic(options.errorOutput, `configuration error: ${message}`);
+        return 1;
+    }
+    return await new Promise((resolve) => {
+        let socket;
+        let lineReader;
+        let completed = false;
+        let localShutdown = false;
+        let failure = false;
+        let startupTimer;
+        const removeProcessListeners = () => {
+            process.removeListener("SIGINT", handleSignal);
+            process.removeListener("SIGTERM", handleSignal);
+        };
+        const finish = (exitCode) => {
+            if (completed) {
+                return;
+            }
+            completed = true;
+            if (startupTimer !== undefined) {
+                clearTimeout(startupTimer);
+            }
+            lineReader?.close();
+            options.input.pause();
+            removeProcessListeners();
+            resolve(exitCode);
+        };
+        const closeSocket = (code = 1000, reason = "bridge shutdown") => {
+            if (socket.readyState === WebSocket.OPEN) {
+                socket.close(code, reason);
+            }
+            else if (socket.readyState === WebSocket.CONNECTING) {
+                try {
+                    socket.close();
+                }
+                catch {
+                    // Process completion still closes a connection that has not opened.
+                }
+            }
+        };
+        const fail = (message, closeCode = 1011) => {
+            if (failure || completed) {
+                return;
+            }
+            failure = true;
+            writeDiagnostic(options.errorOutput, message);
+            closeSocket(closeCode, "bridge failure");
+            finish(1);
+        };
+        const beginLocalShutdown = () => {
+            if (completed || localShutdown) {
+                return;
+            }
+            localShutdown = true;
+            if (startupTimer !== undefined) {
+                clearTimeout(startupTimer);
+            }
+            lineReader?.close();
+            options.input.pause();
+            closeSocket();
+            if (socket.readyState !== WebSocket.OPEN && socket.readyState !== WebSocket.CLOSING) {
+                finish(0);
+            }
+        };
+        function handleSignal() {
+            beginLocalShutdown();
+        }
+        process.once("SIGINT", handleSignal);
+        process.once("SIGTERM", handleSignal);
+        try {
+            socket = new WebSocket(endpoint.url);
+            socket.binaryType = "arraybuffer";
+        }
+        catch {
+            removeProcessListeners();
+            writeDiagnostic(options.errorOutput, `connection error for ${endpoint.display}`);
+            resolve(1);
+            return;
+        }
+        startupTimer = setTimeout(() => {
+            fail(`startup timeout after ${STARTUP_TIMEOUT_MS} ms for ${endpoint.display}`);
+        }, STARTUP_TIMEOUT_MS);
+        socket.addEventListener("open", () => {
+            if (completed) {
+                closeSocket();
+                return;
+            }
+            if (startupTimer !== undefined) {
+                clearTimeout(startupTimer);
+                startupTimer = undefined;
+            }
+            lineReader = createInterface({
+                input: options.input,
+                crlfDelay: Infinity,
+                terminal: false,
+            });
+            lineReader.on("line", (line) => {
+                if (line.trim().length === 0) {
+                    return;
+                }
+                try {
+                    socket.send(line);
+                }
+                catch {
+                    fail(`transport error for ${endpoint.display}`);
+                }
+            });
+            lineReader.once("close", beginLocalShutdown);
+        });
+        socket.addEventListener("message", (event) => {
+            if (typeof event.data !== "string") {
+                fail("protocol error: binary WebSocket frames are not supported", 1003);
+                return;
+            }
+            options.output.write(`${event.data}\n`);
+        });
+        socket.addEventListener("error", () => {
+            if (localShutdown) {
+                return;
+            }
+            fail(`connection error for ${endpoint.display}`);
+        });
+        socket.addEventListener("close", () => {
+            if (localShutdown) {
+                finish(0);
+            }
+            else if (failure) {
+                finish(1);
+            }
+            else {
+                fail(`connection closed unexpectedly for ${endpoint.display}`);
+            }
+        });
+    });
+}
+export async function main() {
+    process.exitCode = await runBridge({
+        arguments: process.argv.slice(2),
+        environment: process.env,
+        input: process.stdin,
+        output: process.stdout,
+        errorOutput: process.stderr,
+    });
+}

+ 32 - 0
dist/config.js

@@ -0,0 +1,32 @@
+export const USAGE = "Usage: codex-app-server-bridge app-server";
+export class ConfigurationError extends Error {
+    constructor(message) {
+        super(message);
+        this.name = "ConfigurationError";
+    }
+}
+export function validateInvocation(arguments_) {
+    if (arguments_.length !== 1 || arguments_[0] !== "app-server") {
+        throw new ConfigurationError(USAGE);
+    }
+}
+export function parseEndpoint(value) {
+    if (value === undefined || value.length === 0) {
+        throw new ConfigurationError("CODEX_APP_SERVER_URL is required");
+    }
+    const match = /^ws:\/\/(127\.0\.0\.1|\[::1\]):([0-9]+)\/?$/.exec(value);
+    if (match === null) {
+        throw new ConfigurationError("CODEX_APP_SERVER_URL must be ws://127.0.0.1:<port> or ws://[::1]:<port>");
+    }
+    const host = match[1];
+    const portText = match[2];
+    if (host === undefined || portText === undefined) {
+        throw new ConfigurationError("CODEX_APP_SERVER_URL is invalid");
+    }
+    const port = Number(portText);
+    if (!Number.isSafeInteger(port) || port < 1 || port > 65_535) {
+        throw new ConfigurationError("CODEX_APP_SERVER_URL must contain a valid explicit port");
+    }
+    const display = `ws://${host}:${port}`;
+    return { url: display, display };
+}

+ 2 - 2
package-lock.json

@@ -1,12 +1,12 @@
 {
   "name": "codex-app-server-bridge",
-  "version": "0.1.0",
+  "version": "0.1.1",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "codex-app-server-bridge",
-      "version": "0.1.0",
+      "version": "0.1.1",
       "bin": {
         "codex-app-server-bridge": "bin/codex-app-server-bridge.js"
       },

+ 9 - 5
package.json

@@ -1,6 +1,6 @@
 {
   "name": "codex-app-server-bridge",
-  "version": "0.1.0",
+  "version": "0.1.1",
   "description": "Bridge codex-acp stdio JSONL to an existing loopback Codex App Server WebSocket.",
   "private": true,
   "type": "module",
@@ -9,15 +9,19 @@
   },
   "files": [
     "bin/",
-    "dist/",
+    "dist/cli.js",
+    "dist/config.js",
     "README.md",
     "LICENSE"
   ],
   "scripts": {
     "typecheck": "tsc --project tsconfig.json",
-    "build": "tsc --project tsconfig.build.json",
-    "test": "npm run build && node --test --test-concurrency=1 test/*.test.ts",
-    "prepack": "npm run build"
+    "compile": "tsc --project tsconfig.build.json",
+    "test": "npm run compile && node --test --test-concurrency=1 test/*.test.ts",
+    "verify:runtime": "node scripts/verify-generated-runtime.mjs",
+    "verify:git-package": "node scripts/verify-git-package.mjs",
+    "verify:package": "node scripts/verify-packed-package.mjs",
+    "pack:verified": "npm run compile && npm test && npm run verify:runtime && npm pack --ignore-scripts"
   },
   "engines": {
     "node": ">=24"

+ 49 - 0
scripts/verify-generated-runtime.mjs

@@ -0,0 +1,49 @@
+#!/usr/bin/env node
+
+import { execFile } from "node:child_process";
+import { promisify } from "node:util";
+
+const execFileAsync = promisify(execFile);
+const cwd = process.cwd();
+const runtimePaths = ["dist/cli.js", "dist/config.js"];
+
+async function compileRuntime() {
+  try {
+    await execFileAsync("npm", ["run", "compile", "--silent"], {
+      cwd,
+      maxBuffer: 10 * 1024 * 1024,
+    });
+  } catch (error) {
+    const code = typeof error === "object" && error !== null && "code" in error
+      ? String(error.code)
+      : "unknown";
+    throw new Error(`runtime compilation failed with exit code ${code}`);
+  }
+}
+
+async function generatedRuntimeDrift() {
+  const [{ stdout: changed }, { stdout: untracked }] = await Promise.all([
+    execFileAsync("git", ["diff", "--name-status", "--", ...runtimePaths], { cwd }),
+    execFileAsync(
+      "git",
+      ["ls-files", "--others", "--exclude-standard", "--", ...runtimePaths],
+      { cwd },
+    ),
+  ]);
+  return [changed.trim(), untracked.trim()].filter(Boolean).join("\n");
+}
+
+async function verify() {
+  await compileRuntime();
+  const drift = await generatedRuntimeDrift();
+  if (drift !== "") {
+    throw new Error(`generated runtime drift detected:\n${drift}`);
+  }
+  process.stdout.write("Generated runtime is fresh.\n");
+}
+
+verify().catch((error) => {
+  const message = error instanceof Error ? error.message : String(error);
+  process.stderr.write(`Generated-runtime verification failed: ${message}\n`);
+  process.exitCode = 1;
+});

+ 135 - 0
scripts/verify-git-package.mjs

@@ -0,0 +1,135 @@
+#!/usr/bin/env node
+
+import assert from "node:assert/strict";
+import { execFile } from "node:child_process";
+import { constants } from "node:fs";
+import { access, lstat, mkdtemp, readFile, realpath, rm } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { isAbsolute, join, relative } from "node:path";
+import { promisify } from "node:util";
+
+const execFileAsync = promisify(execFile);
+const PACKAGE_NAME = "codex-app-server-bridge";
+const DEFAULT_CACHE = "/private/tmp/codex-app-server-bridge-npm-cache";
+const USAGE = "usage: npm run verify:git-package -- <git+https|git+ssh|git+file URL>#<vX.Y.Z|40-character-commit>";
+
+function selectedGitPackage(argv) {
+  if (argv.length !== 1) throw new Error(USAGE);
+  const packageSpec = argv[0];
+  const fragmentAt = packageSpec.lastIndexOf("#");
+  const repository = packageSpec.slice(0, fragmentAt);
+  const revision = packageSpec.slice(fragmentAt + 1);
+  const supportedRepository = /^(?:git\+https|git\+ssh|git\+file):\/\/.+\.git$/.test(repository);
+  const immutableRevision = /^(?:[0-9a-fA-F]{40}|v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)$/.test(revision);
+  if (fragmentAt <= 0 || !supportedRepository || !immutableRevision) {
+    throw new Error("Git package must use a supported URL pinned to a full commit or release tag");
+  }
+  return { packageSpec, revision };
+}
+
+function isWithin(parent, child) {
+  const pathFromParent = relative(parent, child);
+  return pathFromParent === "" || (!pathFromParent.startsWith("..") && !isAbsolute(pathFromParent));
+}
+
+function commandExit(error) {
+  return typeof error === "object" && error !== null && "code" in error
+    ? String(error.code)
+    : "unknown";
+}
+
+async function installPackage(prefix, cache, packageSpec) {
+  try {
+    await execFileAsync("npm", [
+      "install",
+      "--global",
+      "--prefix",
+      prefix,
+      "--ignore-scripts",
+      "--cache",
+      cache,
+      packageSpec,
+    ], { maxBuffer: 10 * 1024 * 1024 });
+  } catch (error) {
+    throw new Error(`isolated npm installation failed with exit code ${commandExit(error)}`);
+  }
+}
+
+async function installedPackage(prefix, cache) {
+  const packageRoot = join(prefix, "lib", "node_modules", PACKAGE_NAME);
+  const executable = join(prefix, "bin", PACKAGE_NAME);
+  await Promise.all([
+    access(executable, constants.X_OK),
+    access(join(packageRoot, "dist", "cli.js"), constants.R_OK),
+    access(join(packageRoot, "dist", "config.js"), constants.R_OK),
+  ]);
+
+  const packageMetadata = await lstat(packageRoot);
+  if (packageMetadata.isSymbolicLink()) {
+    throw new Error("installed package must not be a temporary link");
+  }
+
+  const [realPrefix, realCache, realPackage, realExecutable] = await Promise.all([
+    realpath(prefix),
+    realpath(cache),
+    realpath(packageRoot),
+    realpath(executable),
+  ]);
+  if (!isWithin(realPrefix, realPackage) || isWithin(realCache, realPackage)) {
+    throw new Error("installed package resolved outside the isolated prefix");
+  }
+  if (!isWithin(realPackage, realExecutable) || isWithin(realCache, realExecutable)) {
+    throw new Error("installed executable resolved outside the installed package");
+  }
+
+  const manifest = JSON.parse(await readFile(join(realPackage, "package.json"), "utf8"));
+  assert.equal(manifest.name, PACKAGE_NAME);
+  assert.deepEqual(manifest.bin, { [PACKAGE_NAME]: `bin/${PACKAGE_NAME}.js` });
+  return { executable, version: String(manifest.version) };
+}
+
+async function verifyExecutable(executable) {
+  try {
+    await execFileAsync(executable, ["--unsupported"], {
+      env: process.env,
+      maxBuffer: 1024 * 1024,
+    });
+    throw new Error("installed executable unexpectedly accepted an unsupported invocation");
+  } catch (error) {
+    if (typeof error !== "object" || error === null || !("code" in error) || error.code !== 1) {
+      throw error;
+    }
+    const stdout = "stdout" in error ? String(error.stdout) : "";
+    const stderr = "stderr" in error ? String(error.stderr) : "";
+    assert.equal(stdout, "");
+    assert.match(stderr, /Usage: codex-app-server-bridge app-server/);
+  }
+}
+
+async function verify() {
+  const { packageSpec, revision } = selectedGitPackage(process.argv.slice(2));
+  const prefix = await mkdtemp(join(tmpdir(), "codex-app-server-bridge-git-package-"));
+  const cache = process.env.CODEX_APP_SERVER_BRIDGE_NPM_CACHE ?? DEFAULT_CACHE;
+
+  try {
+    await installPackage(prefix, cache, packageSpec);
+    const installed = await installedPackage(prefix, cache);
+    await verifyExecutable(installed.executable);
+    process.stdout.write(`${JSON.stringify({
+      ok: true,
+      package: `${PACKAGE_NAME}@${installed.version}`,
+      revision,
+      executable: `bin/${PACKAGE_NAME}`,
+      storage: "isolated-prefix",
+      invocation: "verified",
+    })}\n`);
+  } finally {
+    await rm(prefix, { recursive: true, force: true });
+  }
+}
+
+verify().catch((error) => {
+  const message = error instanceof Error ? error.message : String(error);
+  process.stderr.write(`Git-package verification failed: ${message}\n`);
+  process.exitCode = 1;
+});

+ 73 - 0
scripts/verify-packed-package.mjs

@@ -0,0 +1,73 @@
+#!/usr/bin/env node
+
+import assert from "node:assert/strict";
+import { execFile } from "node:child_process";
+import { constants } from "node:fs";
+import { access, mkdtemp, rm, stat } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { basename, join, resolve } from "node:path";
+import { promisify } from "node:util";
+
+const execFileAsync = promisify(execFile);
+const PACKAGE_NAME = "codex-app-server-bridge";
+const DEFAULT_CACHE = "/private/tmp/codex-app-server-bridge-npm-cache";
+
+async function selectedTarball(argv) {
+  if (argv.length !== 1) {
+    throw new Error("usage: npm run verify:package -- /absolute/path/to/codex-app-server-bridge-VERSION.tgz");
+  }
+  const tarball = resolve(argv[0]);
+  if (!tarball.endsWith(".tgz")) throw new Error("package path must end in .tgz");
+  const metadata = await stat(tarball);
+  if (!metadata.isFile()) throw new Error("package path must identify a readable file");
+  await access(tarball, constants.R_OK);
+  return tarball;
+}
+
+async function verify() {
+  const tarball = await selectedTarball(process.argv.slice(2));
+  const prefix = await mkdtemp(join(tmpdir(), "codex-app-server-bridge-package-"));
+  const cache = process.env.CODEX_APP_SERVER_BRIDGE_NPM_CACHE ?? DEFAULT_CACHE;
+
+  try {
+    await execFileAsync("npm", [
+      "install",
+      "--prefix",
+      prefix,
+      "--ignore-scripts",
+      "--cache",
+      cache,
+      tarball,
+    ], { maxBuffer: 10 * 1024 * 1024 });
+
+    const executable = join(prefix, "node_modules", ".bin", PACKAGE_NAME);
+    await access(executable, constants.X_OK);
+    try {
+      await execFileAsync(executable, ["--unsupported"], { maxBuffer: 1024 * 1024 });
+      throw new Error("installed executable unexpectedly accepted an unsupported invocation");
+    } catch (error) {
+      if (typeof error !== "object" || error === null || !("code" in error) || error.code !== 1) {
+        throw error;
+      }
+      const stdout = "stdout" in error ? String(error.stdout) : "";
+      const stderr = "stderr" in error ? String(error.stderr) : "";
+      assert.equal(stdout, "");
+      assert.match(stderr, /Usage: codex-app-server-bridge app-server/);
+    }
+
+    process.stdout.write(`${JSON.stringify({
+      ok: true,
+      package: basename(tarball),
+      executable: `node_modules/.bin/${PACKAGE_NAME}`,
+      invocation: "verified",
+    })}\n`);
+  } finally {
+    await rm(prefix, { recursive: true, force: true });
+  }
+}
+
+verify().catch((error) => {
+  const message = error instanceof Error ? error.message : String(error);
+  process.stderr.write(`Packed-package verification failed: ${message}\n`);
+  process.exitCode = 1;
+});

+ 55 - 0
test/docs.test.ts

@@ -0,0 +1,55 @@
+import assert from "node:assert/strict";
+import { readFile } from "node:fs/promises";
+import test from "node:test";
+
+test("README documents the lifecycle-free immutable Git package workflow", async () => {
+  const [readme, packageText, gitVerifier] = await Promise.all([
+    readFile(new URL("../README.md", import.meta.url), "utf8"),
+    readFile(new URL("../package.json", import.meta.url), "utf8"),
+    readFile(new URL("../scripts/verify-git-package.mjs", import.meta.url), "utf8"),
+  ]);
+  const packageJson = JSON.parse(packageText) as {
+    bin: Record<string, string>;
+    files: string[];
+    scripts: Record<string, string>;
+  };
+  for (const script of [
+    "typecheck",
+    "compile",
+    "test",
+    "verify:runtime",
+    "verify:git-package",
+    "verify:package",
+    "pack:verified",
+  ]) {
+    assert.equal(typeof packageJson.scripts[script], "string");
+    assert.equal(
+      readme.includes(`npm run ${script}`) || (script === "test" && readme.includes("npm test")),
+      true,
+    );
+  }
+  assert.equal(
+    packageJson.bin["codex-app-server-bridge"],
+    "bin/codex-app-server-bridge.js",
+  );
+  assert.deepEqual(packageJson.files, [
+    "bin/",
+    "dist/cli.js",
+    "dist/config.js",
+    "README.md",
+    "LICENSE",
+  ]);
+  assert.match(readme, /npm install --global/);
+  assert.doesNotMatch(readme, /npm run build|prepack/);
+  assert.match(readme, /git\+https:\/\/git\.bodicsek\.host\/bodicsek\/codex-app-server-bridge\.git#v0\.1\.1/);
+  assert.match(readme, /full 40-character commit/i);
+  assert.match(readme, /Do not use\s+`#main`/);
+  assert.match(readme, /end-user install intentionally omits `--prefix`/);
+  assert.match(readme, /0\.1\.0\.tgz/);
+  assert.match(readme, /npm uninstall --global codex-app-server-bridge/);
+  assert.match(readme, /command -v codex-app-server-bridge/);
+  assert.match(readme, /CODEX_PATH/);
+  for (const argument of ["--global", "--prefix", "--ignore-scripts", "--cache"]) {
+    assert.equal(gitVerifier.includes(`"${argument}"`), true, `Git verifier is missing ${argument}`);
+  }
+});

+ 66 - 0
test/generated-runtime.test.ts

@@ -0,0 +1,66 @@
+import assert from "node:assert/strict";
+import { execFileSync, spawnSync } from "node:child_process";
+import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import test from "node:test";
+
+const verifier = new URL("../scripts/verify-generated-runtime.mjs", import.meta.url);
+const gitEnvironment = { ...process.env };
+delete gitEnvironment.GIT_INDEX_FILE;
+
+async function runtimeFixture() {
+  const root = await mkdtemp(join(tmpdir(), "codex-app-server-bridge-runtime-test-"));
+  await Promise.all([
+    mkdir(join(root, "dist"), { recursive: true }),
+    mkdir(join(root, "scripts"), { recursive: true }),
+    mkdir(join(root, "src"), { recursive: true }),
+  ]);
+  await writeFile(join(root, "package.json"), JSON.stringify({
+    private: true,
+    scripts: { compile: "node scripts/compile.mjs" },
+  }));
+  await writeFile(
+    join(root, "scripts", "compile.mjs"),
+    "import { copyFile } from 'node:fs/promises';\nawait Promise.all([copyFile('src/cli.js', 'dist/cli.js'), copyFile('src/config.js', 'dist/config.js')]);\n",
+  );
+  await writeFile(join(root, "src", "cli.js"), "export const version = 1;\n");
+  await writeFile(join(root, "src", "config.js"), "export const configured = true;\n");
+  await writeFile(join(root, "dist", "cli.js"), "export const version = 1;\n");
+  await writeFile(join(root, "dist", "config.js"), "export const configured = true;\n");
+  execFileSync("git", ["init", "--quiet"], { cwd: root, env: gitEnvironment });
+  execFileSync("git", ["add", "."], { cwd: root, env: gitEnvironment });
+  return root;
+}
+
+test("generated-runtime verifier accepts a fresh staged baseline", async () => {
+  const root = await runtimeFixture();
+  try {
+    const result = spawnSync(process.execPath, [verifier.pathname], {
+      cwd: root,
+      encoding: "utf8",
+      env: gitEnvironment,
+    });
+    assert.equal(result.status, 0, result.stderr);
+    assert.match(result.stdout, /Generated runtime is fresh/);
+  } finally {
+    await rm(root, { recursive: true, force: true });
+  }
+});
+
+test("generated-runtime verifier rejects source and runtime drift", async () => {
+  const root = await runtimeFixture();
+  try {
+    await writeFile(join(root, "src", "cli.js"), "export const version = 2;\n");
+    const result = spawnSync(process.execPath, [verifier.pathname], {
+      cwd: root,
+      encoding: "utf8",
+      env: gitEnvironment,
+    });
+    assert.notEqual(result.status, 0);
+    assert.match(result.stderr, /generated runtime drift detected/i);
+    assert.match(result.stderr, /cli\.js/);
+  } finally {
+    await rm(root, { recursive: true, force: true });
+  }
+});

+ 105 - 0
test/git-package.test.ts

@@ -0,0 +1,105 @@
+import assert from "node:assert/strict";
+import { execFileSync, spawnSync } from "node:child_process";
+import { chmod, mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { pathToFileURL } from "node:url";
+import test from "node:test";
+
+const verifier = new URL("../scripts/verify-git-package.mjs", import.meta.url);
+
+function isolatedGitEnvironment() {
+  const environment = { ...process.env };
+  delete environment.GIT_INDEX_FILE;
+  return environment;
+}
+
+function runVerifier(args: string[], cache?: string) {
+  const environment = isolatedGitEnvironment();
+  if (cache !== undefined) environment.CODEX_APP_SERVER_BRIDGE_NPM_CACHE = cache;
+  return spawnSync(process.execPath, [verifier.pathname, ...args], {
+    encoding: "utf8",
+    env: environment,
+    timeout: 60_000,
+  });
+}
+
+test("Git-package verifier requires exactly one immutable package spec without leaking credentials", () => {
+  const secret = "credential-must-not-appear";
+  const invalid = [
+    [],
+    ["git+https://git.example.invalid/team/codex-app-server-bridge.git#main"],
+    [`git+https://user:${secret}@git.example.invalid/team/codex-app-server-bridge.git#main`],
+    ["git+https://git.example.invalid/team/codex-app-server-bridge.git#abc123"],
+    [
+      "git+https://git.example.invalid/team/codex-app-server-bridge.git#v0.1.1",
+      "git+https://git.example.invalid/team/codex-app-server-bridge.git#v0.1.0",
+    ],
+  ];
+
+  for (const args of invalid) {
+    const result = runVerifier(args);
+    assert.notEqual(result.status, 0);
+    assert.equal(result.stderr.includes(secret), false);
+    assert.match(result.stderr, /Git-package verification failed:/);
+  }
+});
+
+test("Git-package verifier installs and launches a disposable prebuilt Git revision", async () => {
+  const root = await mkdtemp(join(tmpdir(), "codex-app-server-bridge-git-test-"));
+  const repository = join(root, "codex-app-server-bridge.git");
+  const cache = join(root, "npm-cache");
+  try {
+    await Promise.all([
+      mkdir(join(repository, "bin"), { recursive: true }),
+      mkdir(join(repository, "dist"), { recursive: true }),
+    ]);
+    await writeFile(join(repository, "package.json"), `${JSON.stringify({
+      name: "codex-app-server-bridge",
+      version: "9.9.9",
+      private: true,
+      type: "module",
+      bin: { "codex-app-server-bridge": "bin/codex-app-server-bridge.js" },
+      files: ["bin/", "dist/cli.js", "dist/config.js"],
+      engines: { node: ">=24" },
+    }, null, 2)}\n`);
+    const launcher = `#!/usr/bin/env node
+if (process.argv.slice(2).join(" ") !== "app-server") {
+  process.stderr.write("codex-app-server-bridge: Usage: codex-app-server-bridge app-server\\n");
+  process.exitCode = 1;
+}
+`;
+    const executable = join(repository, "bin", "codex-app-server-bridge.js");
+    await writeFile(executable, launcher);
+    await chmod(executable, 0o755);
+    await writeFile(join(repository, "dist", "cli.js"), "export async function runBridge() { return 1; }\n");
+    await writeFile(join(repository, "dist", "config.js"), "export const USAGE = 'fixture';\n");
+    const gitEnvironment = isolatedGitEnvironment();
+    execFileSync("git", ["init", "--quiet"], { cwd: repository, env: gitEnvironment });
+    execFileSync("git", ["config", "user.name", "Codex Test"], { cwd: repository, env: gitEnvironment });
+    execFileSync("git", ["config", "user.email", "codex-test@example.invalid"], { cwd: repository, env: gitEnvironment });
+    execFileSync("git", ["add", "."], { cwd: repository, env: gitEnvironment });
+    execFileSync("git", ["commit", "--quiet", "-m", "prebuilt fixture"], { cwd: repository, env: gitEnvironment });
+    const revision = execFileSync("git", ["rev-parse", "HEAD"], {
+      cwd: repository,
+      encoding: "utf8",
+      env: gitEnvironment,
+    }).trim();
+    const packageSpec = `git+${pathToFileURL(repository).href}#${revision}`;
+
+    const result = runVerifier([packageSpec], cache);
+    assert.equal(result.status, 0, result.stderr);
+    const report = JSON.parse(result.stdout) as {
+      ok: boolean;
+      revision: string;
+      storage: string;
+      invocation: string;
+    };
+    assert.equal(report.ok, true);
+    assert.equal(report.revision, revision);
+    assert.equal(report.storage, "isolated-prefix");
+    assert.equal(report.invocation, "verified");
+  } finally {
+    await rm(root, { recursive: true, force: true });
+  }
+});

+ 75 - 0
test/package.test.ts

@@ -0,0 +1,75 @@
+import assert from "node:assert/strict";
+import { spawnSync } from "node:child_process";
+import { readFile } from "node:fs/promises";
+import test from "node:test";
+
+interface PackageContract {
+  version?: string;
+  private?: boolean;
+  bin?: Record<string, string>;
+  files?: string[];
+  scripts?: Record<string, string>;
+}
+
+test("npm package contract exposes the prebuilt private CLI", async () => {
+  const [packageText, launcher, compiledCli, compiledConfig] = await Promise.all([
+    readFile(new URL("../package.json", import.meta.url), "utf8"),
+    readFile(new URL("../bin/codex-app-server-bridge.js", import.meta.url), "utf8"),
+    readFile(new URL("../dist/cli.js", import.meta.url), "utf8"),
+    readFile(new URL("../dist/config.js", import.meta.url), "utf8"),
+  ]);
+  const packageJson = JSON.parse(packageText) as PackageContract;
+
+  assert.equal(packageJson.private, true);
+  assert.equal(packageJson.version, "0.1.1");
+  assert.deepEqual(packageJson.bin, {
+    "codex-app-server-bridge": "bin/codex-app-server-bridge.js",
+  });
+  assert.deepEqual(packageJson.files, [
+    "bin/",
+    "dist/cli.js",
+    "dist/config.js",
+    "README.md",
+    "LICENSE",
+  ]);
+  assert.equal(packageJson.scripts?.compile, "tsc --project tsconfig.build.json");
+  assert.equal(packageJson.scripts?.build, undefined);
+  for (const lifecycle of ["prepack", "prepare", "preinstall", "install", "postinstall"]) {
+    assert.equal(packageJson.scripts?.[lifecycle], undefined);
+  }
+  assert.match(packageJson.scripts?.test ?? "", /npm run compile/);
+  assert.equal(
+    packageJson.scripts?.["verify:runtime"],
+    "node scripts/verify-generated-runtime.mjs",
+  );
+  assert.equal(
+    packageJson.scripts?.["verify:git-package"],
+    "node scripts/verify-git-package.mjs",
+  );
+  assert.match(packageJson.scripts?.["pack:verified"] ?? "", /npm run verify:runtime/);
+  assert.match(packageJson.scripts?.["pack:verified"] ?? "", /npm pack --ignore-scripts/);
+  assert.match(launcher, /^#!\/usr\/bin\/env node\n/);
+  assert.match(compiledCli, /export async function runBridge/);
+  assert.match(compiledConfig, /export function parseEndpoint/);
+});
+
+test("Git ignore rules admit only the compiled bridge runtime", () => {
+  const repository = new URL("..", import.meta.url);
+  const checkIgnored = (path: string) => spawnSync(
+    "git",
+    ["check-ignore", "--no-index", "--quiet", path],
+    { cwd: repository, encoding: "utf8" },
+  ).status;
+
+  assert.equal(checkIgnored("dist/cli.js"), 1);
+  assert.equal(checkIgnored("dist/config.js"), 1);
+  assert.equal(checkIgnored("dist/test/bridge.test.js"), 0);
+  assert.equal(checkIgnored("dist/other-output.js"), 0);
+});
+
+test("packed-artifact verifier rejects a missing tarball argument", () => {
+  const verifier = new URL("../scripts/verify-packed-package.mjs", import.meta.url);
+  const result = spawnSync(process.execPath, [verifier.pathname], { encoding: "utf8" });
+  assert.notEqual(result.status, 0);
+  assert.match(result.stderr, /Packed-package verification failed: usage:/);
+});