diff --git a/.agents/notes/verified/architecture/2026-09-30-git-credential-broker-verified.md b/.agents/notes/verified/architecture/2026-09-30-git-credential-broker-verified.md index b6cd4126..5f67b894 100644 --- a/.agents/notes/verified/architecture/2026-09-30-git-credential-broker-verified.md +++ b/.agents/notes/verified/architecture/2026-09-30-git-credential-broker-verified.md @@ -14,7 +14,7 @@ ``` 宿主 NAS /volume1/docker/git-cred-broker/ - docker-compose.broker.yml sidecar 定义 + docker-compose.yml sidecar 定义 app/ 代码(挂载之外,agent 写不到) app.pem App 私钥 0600 ← 唯一长期凭据 broker.config.json 白名单/策略 0600 @@ -125,7 +125,7 @@ git-credential-broker probe --socket /home/app/.dsh/git-broker/broker.sock \ git-credential-broker diagnose --config /volume1/docker/git-cred-broker/broker.config.json # sidecar -docker compose -f /volume1/docker/git-cred-broker/docker-compose.broker.yml ps|logs|restart|down +docker compose -f /volume1/docker/git-cred-broker/docker-compose.yml ps|logs|restart|down # 每次签发 tail -f /volume1/docker/git-cred-broker/log/audit.jsonl diff --git a/.changeset/compose-layout.md b/.changeset/compose-layout.md new file mode 100644 index 00000000..f211c18e --- /dev/null +++ b/.changeset/compose-layout.md @@ -0,0 +1,8 @@ +--- +'gcb-client': patch +'gcb-host': patch +--- + +Default `gcb probe` and `gcb logs` to `github.com`, name the rendered sidecar file +`docker-compose.yml` in the deployment directory, and document the deployment layout and the +`--user` requirement. diff --git a/.gitignore b/.gitignore index b65ca21e..64eb32e2 100644 --- a/.gitignore +++ b/.gitignore @@ -19,10 +19,6 @@ node_modules/ # GIT_BROKER_CONFIG at it, so nothing here needs a personal copy. /broker.config.yml /broker.config.yaml -# `compose` output, which the README tells you to redirect to this name inside the repo. It -# carries one host's deployment paths, so it is per-deployment, not source. -/docker-compose.broker.yml - # The person's refresh token, if anyone keeps one here by mistake. user.refresh user.token diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 820d73b1..36a559ae 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -83,29 +83,55 @@ npm install -g /tmp/gcb-packs/gcb-core-*.tgz /tmp/gcb-packs/gcb-host-*.tgz gcb --version ``` -As an image, build the host stage of the root Dockerfile: +### Deploying the dev images + +The two dev tags are built from the current checkout and are never pushed: ```sh -docker build --target host -t gcb-host:local . -gcb compose --image gcb-host:local > docker-compose.broker.yml +docker build --network=host --target host -t code-vaults/gcb-host:dev . +docker build --network=host --target client -t code-vaults/gcb-client:dev . ``` -Then keep the rendered `sh -c` wrapper (it applies `GCB_PROXY`) and change only the final -`exec npx ...` to the binary the image installed, since the default one fetches the published package: - -```yaml - command: - - sh - - -c - - | - case "${GCB_PROXY-default}" in - default) ;; - "") unset http_proxy https_proxy all_proxy no_proxy HTTP_PROXY HTTPS_PROXY ALL_PROXY NO_PROXY NODE_USE_ENV_PROXY ;; - *) export http_proxy="$GCB_PROXY" https_proxy="$GCB_PROXY" no_proxy=localhost,127.0.0.1 NODE_USE_ENV_PROXY=1 ;; - esac - exec gcb daemon --config /etc/git-cred-broker/broker.config.yml +The `host` image carries `gcb` and `gh` and runs the broker. The `client` image is the +embedding source for an idsh-style base (`COPY --from=...`); it is not a second broker. + +Render a deployment for the host image on the host that runs it: + +```sh +DEPLOY=/path/to/deployment +SOCKET_DIR=/path/to/shared/socket +PUSHER_UID=1000 +PUSHER_GID=1000 +gcb compose --dir "$DEPLOY" --socket-dir "$SOCKET_DIR" \ + --user "$PUSHER_UID:$PUSHER_GID" --image code-vaults/gcb-host:dev \ + > "$DEPLOY/docker-compose.yml" ``` +Change only the `exec npx --yes --package … gcb` prefix to `exec gcb`, keeping `daemon --config …`: + +```sh +exec gcb daemon --config /etc/git-cred-broker/broker.config.yml +``` + +The dev image already installs the package; do **not** keep a staged `/opt/gcb` mount, or the +image's code is ignored. +Then recreate from the deployment directory, choosing the proxy at runtime: + +```sh +cd "$DEPLOY" +GCB_PROXY=http://proxy.example:7890 docker compose up -d --force-recreate +docker compose logs --tail 20 gcb +``` + +Verify from the pushing side: + +```sh +gcb probe --repo owner/repo +``` + +`GCB_PROXY` is read at `docker compose up` time. An unset value keeps the host proxy variables, an +empty value goes direct, and a URL overrides them; changing it needs `--force-recreate`. + ## Tests ```sh @@ -185,6 +211,7 @@ scripts/postbuild.mjs the shebang and exec bit on the two bins test/unit/ policy, JWT, provider, helper, broker, config, commands, stage, daemon test/e2e/push.test.ts a real push over authenticated smart HTTP test/e2e/packages.test.ts npm pack and install into separate client/host prefixes -examples/ configuration and compose snippets +examples/ configuration snippets +docker-compose.example.yml tracked sidecar example .agents/notes/ the design, its review, the verified record, environment facts ``` diff --git a/Dockerfile b/Dockerfile index e6b97170..8e851ecc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,8 +5,8 @@ # host the broker, with gh, for running `gcb daemon` # # Local build: -# docker build --target client -t ghcr.io/code-vaults/gcb-client:dev . -# docker build --target host -t ghcr.io/code-vaults/gcb-host:dev . +# docker build --target client -t code-vaults/gcb-client:dev . +# docker build --target host -t code-vaults/gcb-host:dev . FROM node:24-slim AS build WORKDIR /src diff --git a/README.md b/README.md index a7d48219..b69a2df6 100644 --- a/README.md +++ b/README.md @@ -101,11 +101,11 @@ Keep the checkout outside every container mount. ## GitHub actions The action path is wired through the broker; the host image must include `gh` before these commands -can run. The container never receives the token. Build the image, then pass `--image ghcr.io/code-vaults/gcb-host:dev` to +can run. The container never receives the token. Build the image, then pass `--image code-vaults/gcb-host:dev` to the compose command in [Quick start](#quick-start-sidecar): ```sh -docker build --target host -t ghcr.io/code-vaults/gcb-host:dev . +docker build --target host -t code-vaults/gcb-host:dev . ``` The image's `CMD` starts `gcb daemon`, but `compose` still renders the registry-fetching `npx` @@ -159,16 +159,42 @@ the default sidecar fetches `gcb-host` with `npx` at start, while `--code` runs registry access at boot. ```sh -DEPLOY=/srv/git-cred-broker # broker.config.yml, app.pem and log/ live here +DEPLOY=/srv/git-cred-broker # the deployment directory SOCKET_DIR=/srv/git-broker-socket # a host directory the pushing container already mounts +PUSHER_UID=1000 # uid of the container that will use the socket +PUSHER_GID=1000 # gid of that container gcb init --mode sidecar --dir "$DEPLOY" \ --cert ~/Downloads/app.private-key.pem \ --allow owner/repo --client-id Iv23li… --app-id 123456 gcb compose --dir "$DEPLOY" --socket-dir "$SOCKET_DIR" \ - > "$DEPLOY/docker-compose.broker.yml" -docker compose -f "$DEPLOY/docker-compose.broker.yml" up -d + --user "$PUSHER_UID:$PUSHER_GID" > "$DEPLOY/docker-compose.yml" +cd "$DEPLOY" && docker compose up -d +``` + +`--user` must be the uid:gid of the container that will use the socket; the socket is mode 0660 and +created by that identity. A sidecar running as a different user cannot connect. If the pusher runs +as the same user as the sidecar, the default is correct. + +`init` writes the configuration and key as the invoking user, with the key mode 0600. If +`authorize` has run, it also writes `user.refresh` beside the key, mode 0600 and **read-write**: +the broker rotates that token on every exchange. The sidecar's user must read the config, key and +`user.refresh`, and write `log/` (including an existing audit file). If `PUSHER_UID` differs from +the user running `init`/`authorize`, either run both as that uid or `chown` the config, key, +`user.refresh`, the log directory and its audit file to it; re-run the `chown` after `authorize` +creates `user.refresh`. Keep the key and `user.refresh` at 0600; do not make them world-readable. + +A deployment directory holds the config, key, log and compose file; `app/` is only a convention +for a staged commit, and `--code` accepts any directory outside every mount. + +```text +$DEPLOY/ + broker.config.yml + app.pem + log/ + docker-compose.yml + app/ optional staged commit ``` Nothing above has to name the shared directories. A host process asks the container runtime — docker, @@ -186,7 +212,8 @@ instead, run `gcb stage --to "$DEPLOY/app"`, install its dependencies in that di root you intend to export, or pass `--repo `: it defaults to the current directory and exports that repository's `HEAD`, never the working tree. `stage` is export-only: it does not install dependencies or build, and it never runs the commit's code. It leaves `STAGED.json` beside the code -recording the commit. +recording the commit; `--code` does not require that file, and the directory may have any name outside +the mounts. ## Quick start: host process @@ -272,7 +299,8 @@ app's **private key**, so run it where the key is. On a host-process deployment a sidecar it is inside the container: ```sh -docker compose -f docker-compose.broker.yml exec gcb \ +DEPLOY=/srv/git-cred-broker +cd "$DEPLOY" && docker compose exec gcb \ npx --yes --package gcb-host gcb \ diagnose --config /etc/git-cred-broker/broker.config.yml ``` @@ -583,7 +611,8 @@ packages/client/src/ credential helper, setup, probe, logs, pr, gh packages/host/src/ YAML config, providers, broker, daemon and host commands packages/*/bin/ the two installed gcb bins (client and host) test/ unit tests, plus an end-to-end push over authenticated smart HTTP -examples/ configuration and compose snippets +examples/ configuration snippets +docker-compose.example.yml tracked sidecar example DEVELOPMENT.md building from source, tests, releasing .agents/notes/ the design, its review, and the measured environment facts ``` @@ -605,12 +634,12 @@ The contract between the pushing container and the broker is deliberately small: ## Images -Two GHCR repositories mirror the two packages: +Two local dev images mirror the two packages; the release publishes the same repositories on GHCR: | Image | Contents | Use | |---|---|---| -| `ghcr.io/code-vaults/gcb-client:dev` | `gcb` client and Node runtime only | idsh base / `COPY --from` | -| `ghcr.io/code-vaults/gcb-host:dev` | broker, `gh`, and broker dependencies | run `gcb daemon` on the host | +| `code-vaults/gcb-client:dev` | `gcb` client and Node runtime only | idsh base / `COPY --from` | +| `code-vaults/gcb-host:dev` | broker, `gh`, and broker dependencies | run `gcb daemon` on the host | The release publishes both repositories with `latest`, `` and `sha-` tags. The `:dev` tags are local build tags and are not published; do not pull them from GHCR. @@ -618,8 +647,8 @@ The release publishes both repositories with `latest`, `` and `sha- \ -# > docker-compose.broker.yml +# > docker-compose.yml +# +# Then, from that directory: docker compose up -d # # Generate it rather than retyping it. The mount targets inside the container must match the # paths `init --mode sidecar` recorded, and both commands read them from one definition, so a @@ -55,7 +57,8 @@ services: container_name: git-cred-broker restart: unless-stopped # Must match the uid/gid of the container that will use the socket, or it is unusable. - user: "1026:100" + # `compose --user ":"` renders this from the deployment. + user: "1000:1000" read_only: true tmpfs: # npx needs somewhere writable for its cache; /tmp is a tmpfs above. diff --git a/packages/client/src/commands/logs.ts b/packages/client/src/commands/logs.ts index 16bad269..6031d830 100644 --- a/packages/client/src/commands/logs.ts +++ b/packages/client/src/commands/logs.ts @@ -24,7 +24,7 @@ export const LOGS_USAGE = `Usage: gcb logs [options] Read a workflow job log through the broker, without the credential ever reaching this side. --socket Broker socket (default: $GIT_BROKER_SOCKET or the socket file) - --host Host as configured, e.g. github.com [required] + --host Host as configured (default: github.com) --repo Repository the job belongs to [required] --job Workflow job id, which is also the check run id [required] --proxy Use this proxy for this request @@ -49,13 +49,15 @@ export async function runLogs(argv: readonly string[]): Promise { } const socketPath = resolveSocketPath(args.value('socket'), process.env); - const host = args.value('host'); + const hostArg = args.value('host'); + if (args.has('host') && hostArg === undefined) fail('--host needs a value'); + if (hostArg === '') fail('--host needs a value'); + const host = hostArg ?? 'github.com'; const repo = args.value('repo'); const rawJob = args.value('job'); if (!socketPath) { fail('no broker socket: pass --socket, set GIT_BROKER_SOCKET, or run `gcb setup`'); } - if (!host) fail('--host is required (e.g. github.com)'); if (!repo) fail('--repo is required (e.g. owner/name)'); if (!rawJob) fail('--job is required (the workflow job id, which is also the check run id)'); const jobId = Number(rawJob); diff --git a/packages/client/src/commands/probe.ts b/packages/client/src/commands/probe.ts index 1ab911b9..18bdb409 100644 --- a/packages/client/src/commands/probe.ts +++ b/packages/client/src/commands/probe.ts @@ -15,7 +15,7 @@ export const PROBE_USAGE = `Usage: gcb probe [options] Ask the broker for a credential without pushing. Prints a fingerprint, never the credential. --socket Broker socket (default: $GIT_BROKER_SOCKET or the socket file) - --host Host as git reports it, e.g. github.com [required] + --host Host as git reports it (default: github.com) --repo Repository path [required] --protocol

https (default) or http for a host that opted in --proxy Use this proxy for this request @@ -40,12 +40,14 @@ export async function runProbe(argv: readonly string[]): Promise { } const socketPath = resolveSocketPath(args.value('socket'), process.env); - const host = args.value('host'); + const hostArg = args.value('host'); + if (args.has('host') && hostArg === undefined) fail('--host needs a value'); + if (hostArg === '') fail('--host needs a value'); + const host = hostArg ?? 'github.com'; const repo = args.value('repo'); if (!socketPath) { fail('no broker socket: pass --socket, set GIT_BROKER_SOCKET, or run `gcb setup`'); } - if (!host) fail('--host is required (e.g. github.com)'); if (!repo) fail('--repo is required (e.g. owner/name)'); const protocol = args.value('protocol') ?? 'https'; diff --git a/packages/host/src/commands/init.ts b/packages/host/src/commands/init.ts index 70b81acb..9c83de2a 100644 --- a/packages/host/src/commands/init.ts +++ b/packages/host/src/commands/init.ts @@ -28,6 +28,7 @@ import { type DeploymentMode, } from '../config.ts'; import { parseAllowEntry } from '../policy.ts'; + import type { BrokerConfig, GithubAppHostConfig } from 'gcb-core'; import { brokerPaths, @@ -39,6 +40,11 @@ import { import type { BrokerPaths } from './deployment.ts'; import { fail, insideMountedPath, isInsideContainer, parseArgs, say, writeSecretFile } from 'gcb-core'; +/** Quote one value as a single shell word, including paths with spaces. */ +function shellQuote(value: string): string { + return `'${value.replaceAll("'", "'\\''")}'`; +} + /** The environment `init` reasons about, injectable so the guard rails can be tested. */ export interface InitEnvironment { /** Whether this process is inside the container. */ @@ -604,7 +610,7 @@ export async function runInit(argv: readonly string[]): Promise { if (result.previous) { say('Restart the broker to pick this up:'); if (result.mode === 'sidecar') { - say(' docker compose -f docker-compose.broker.yml up -d # sidecar'); + say(` cd ${shellQuote(path.dirname(result.keyPath))} && docker compose up -d # sidecar`); } else { say(` gcb daemon --config ${result.configPath} # host process`); } @@ -626,9 +632,10 @@ export async function runInit(argv: readonly string[]): Promise { if (result.mode === 'sidecar') { say('Then start the sidecar, which mounts those paths exactly as recorded:'); say( - ` gcb compose --dir ${path.dirname(result.keyPath)} --config ${result.configPath} --socket-dir \\`, + ` gcb compose --dir ${shellQuote(path.dirname(result.keyPath))} --config ${shellQuote(result.configPath)} --socket-dir --user \\`, ); - say(' > docker-compose.broker.yml && docker compose -f docker-compose.broker.yml up -d'); + say(` > ${shellQuote(path.join(path.dirname(result.keyPath), 'docker-compose.yml'))}`); + say(` cd ${shellQuote(path.dirname(result.keyPath))} && docker compose up -d`); } else { say('Then start the broker as a host process:'); say(` gcb daemon --config ${result.configPath} --check # validate, bind nothing`); diff --git a/test/unit/commands.test.ts b/test/unit/commands.test.ts index 2b685bca..aa58ac76 100644 --- a/test/unit/commands.test.ts +++ b/test/unit/commands.test.ts @@ -880,7 +880,7 @@ describe('socket discovery', () => { delete process.env['GIT_BROKER_SOCKET']; process.env['GIT_BROKER_SOCKET_FILE'] = socketFile; try { - const code = await runProbe(['--host', 'github.com', '--repo', 'a/one']); + const code = await runProbe(['--repo', 'a/one']); assert.equal(code, 3, 'it must use the path from the file and report the broker unreachable'); } finally { if (savedSocket === undefined) delete process.env['GIT_BROKER_SOCKET']; @@ -891,6 +891,73 @@ describe('socket discovery', () => { }); }); +describe('host flag validation at the CLI boundary', () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gcb-host-home-')); + after(() => fs.rmSync(home, { recursive: true, force: true })); + + /** Run a client command as a process and return its refusal. */ + const refuse = (command: string, args: readonly string[]): { status: number; stderr: string } => { + try { + execFileSync('node', ['--conditions=development', 'packages/client/bin/gcb.js', command, ...args], { + cwd: path.resolve('.'), + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + env: { ...process.env, HOME: home, GIT_BROKER_SOCKET: '', GIT_BROKER_SOCKET_FILE: '' }, + }); + return { status: 0, stderr: '' }; + } catch (error) { + const failed = error as { status?: number | null; stderr?: string }; + return { status: failed.status ?? -1, stderr: String(failed.stderr ?? '') }; + } + }; + + it('probe sends github.com when --host is omitted', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gcb-probe-default-')); + const socketPath = path.join(root, 'broker.sock'); + let request: Record | undefined; + const server = net.createServer((socket) => { + let buffer = ''; + socket.on('data', (chunk: Buffer) => { + buffer += chunk.toString('utf8'); + const newline = buffer.indexOf('\n'); + if (newline < 0) return; + request = parseMessage(buffer.slice(0, newline)) ?? undefined; + socket.end(encodeMessage({ ok: true, username: 'x-access-token', password: 'secret' })); + }); + }); + await new Promise((resolve) => server.listen(socketPath, resolve)); + const savedSocket = process.env['GIT_BROKER_SOCKET']; + const savedFile = process.env['GIT_BROKER_SOCKET_FILE']; + try { + process.env['GIT_BROKER_SOCKET'] = socketPath; + delete process.env['GIT_BROKER_SOCKET_FILE']; + assert.equal(await runProbe(['--repo', 'a/one']), 0); + assert.equal(request?.['host'], 'github.com'); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + if (savedSocket === undefined) delete process.env['GIT_BROKER_SOCKET']; + else process.env['GIT_BROKER_SOCKET'] = savedSocket; + if (savedFile === undefined) delete process.env['GIT_BROKER_SOCKET_FILE']; + else process.env['GIT_BROKER_SOCKET_FILE'] = savedFile; + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + for (const command of ['probe', 'logs']) { + it(`${command} refuses a bare --host`, () => { + const result = refuse(command, ['--host']); + assert.equal(result.status, 1); + assert.match(result.stderr, /--host needs a value/); + }); + + it(`${command} refuses an empty --host`, () => { + const result = refuse(command, ['--host', '']); + assert.equal(result.status, 1); + assert.match(result.stderr, /--host needs a value/); + }); + } +}); + describe('config discovery', () => { it('prefers --config, then GIT_BROKER_CONFIG, then ./broker.config.yml', () => { assert.equal(resolveConfigPath('/a/b.json', {}), '/a/b.json');