Push the assigned branch with the delivered scoped credential

3d716e9fce8f · AtlantisPleb · · parent d9a54393a29d

Push the assigned branch with the delivered scoped credential

Issue #20's missing half: an ACP delegation can now push exactly its
assigned branch. The child never holds the credential — it sends a
git/push request over ACP and the parent side validates the refspec
(single ref only; multi-ref, force, and empty-destination refused),
verifies the remote resolves to the assigned repository, and runs the
push with a one-shot URL-scoped credential helper: global helpers
reset, token in a 0600 file inside a 0700 temp dir removed in a
finally, scrubbed environment, terminal prompts off, and redaction
over every journal and error path. Refusals are typed and distinct
from a push that failed on its merits.

Built by a Devin child through the openagents coder's delegate tool;
full CLI suite (732 tests) re-run before landing. The live opt-in
end-to-end run remains for the owner.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GoYpb8FEmdxVErsv7ABCYi
Co-Authored-By
Claude Fable 5 <noreply@anthropic.com>

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified docs/assure-repo/false-green-candidates.v1.json
  • modified docs/assure-repo/surface-inventory.v1.json
  • modified packages/openagents-cli/src/computer-agents.ts
  • modified packages/openagents-cli/src/computer-up.ts
  • added packages/openagents-cli/src/delegation-push.ts
  • modified packages/openagents-cli/test/computer.test.ts
  • added packages/openagents-cli/test/delegation-push.test.ts

Diff

7 files changed, +630 -9

docs/assure-repo/false-green-candidates.v1.json modified +1 -1

@@ -4,7 +4,7 @@

4 4
  "note": "Heuristic false-green LEADS, not findings. A finding requires a demonstrated reproduction (surviving mutation via mutation-runner). Do not treat a candidate as a confirmed false green. Coverage-theater leads may include tests that delegate their assertion to a custom helper the classifier does not recognise; verify before acting.",
5 5
  "sourceDigest": "sha256:dd810dd48c5bdbc9becd7fcc01dd41a4ca2abf0b2d6f6a545907247f6e3e8361",
6 6
  "summary": {
7
    "filesScanned": 2458,
7
    "filesScanned": 2459,
8 8
    "candidateCount": 16,
9 9
    "byMode": {
10 10
      "false_green_coverage_theater": 15,
docs/assure-repo/surface-inventory.v1.json modified +2 -2

@@ -1,7 +1,7 @@

1 1
{
2 2
  "schemaVersion": "1",
3 3
  "repository": "OpenAgentsInc/openagents",
4
  "sourceDigest": "sha256:d492fe3229c64f1d4b8d114ab4ed45bef787f8e7fc2277927d6b9d60491c4ee2",
4
  "sourceDigest": "sha256:59b3aac646bddcbc39932e2474e2720da8c7ced4b3591d31a65725d1ec4f97df",
5 5
  "surfaces": [
6 6
    {
7 7
      "id": "app:@openagentsinc/acceptance-runner",

@@ -1876,7 +1876,7 @@

1876 1876
      "oracles": [
1877 1877
        {
1878 1878
          "type": "test",
1879
          "ref": "packages/openagents-cli (58 tracked test files)"
1879
          "ref": "packages/openagents-cli (59 tracked test files)"
1880 1880
        },
1881 1881
        {
1882 1882
          "type": "behavior-contract",
packages/openagents-cli/src/computer-agents.ts modified +32

@@ -6,6 +6,7 @@ import { StringDecoder } from "node:string_decoder";

6 6
7 7
import type { AgentConfigEntry } from "./computer-config.js";
8 8
import { defaultCuratedExecute, type Tier, withinRoot } from "./computer-policy.js";
9
import { pushDelegated, type ForgeCredentials } from "./delegation-push.js";
9 10
import { VERSION } from "./version.js";
10 11
11 12
export type AgentCatalogSource = "local" | "configured" | "remote";

@@ -216,6 +217,8 @@ export interface AgentDelegationRequest {

216 217
  readonly onChunk: (text: string) => void;
217 218
  readonly onSession: (sessionId: string) => void;
218 219
  readonly onPermission: (allowed: boolean, detail: string) => void;
220
  readonly forgeOrigin?: string;
221
  readonly forgeCredentials?: ForgeCredentials;
219 222
}
220 223
221 224
export interface AgentDelegationOutcome {

@@ -322,6 +325,35 @@ export const startAgentDelegation = (

322 325
        ? { outcome: { outcome: "cancelled" } }
323 326
        : { outcome: { outcome: "selected", optionId: String(record(option).optionId ?? "") } };
324 327
    });
328
    const forgeCredentials = request.forgeCredentials;
329
    const forgeOrigin = request.forgeOrigin;
330
    if (forgeCredentials !== undefined && forgeOrigin !== undefined) {
331
      process.onRequest("git/push", async (rawParams: unknown) => {
332
        const params = record(rawParams);
333
        const refspec = firstString(params, ["refspec"]);
334
        const remote = firstString(params, ["remote"]) ?? "origin";
335
        if (refspec === undefined) {
336
          return { ok: false, error: "delegated push requires a refspec" };
337
        }
338
        try {
339
          await Effect.runPromise(
340
            pushDelegated({
341
              directory: request.cwd,
342
              remote,
343
              refspec,
344
              repository: forgeCredentials.repository,
345
              branch: forgeCredentials.branch,
346
              credential: forgeCredentials.token,
347
              origin: forgeOrigin,
348
            }),
349
          );
350
          return { ok: true };
351
        } catch (cause) {
352
          const message = cause instanceof Error ? cause.message : String(cause);
353
          return { ok: false, error: scrub(message) };
354
        }
355
      });
356
    }
325 357
    const initialized = record(
326 358
      await process.request("initialize", {
327 359
        protocolVersion: 1,
packages/openagents-cli/src/computer-up.ts modified +47 -4

@@ -1,6 +1,8 @@

1
import { Effect, Layer, Option } from "effect";
1
import { Effect, Layer, Option, Redacted } from "effect";
2 2
import * as Context from "effect/Context";
3 3
4
import type { ForgeCredentials } from "./delegation-push.js";
5
4 6
import {
5 7
  ComputerChannel,
6 8
  type ComputerAgentResponder,

@@ -138,6 +140,35 @@ export const computerUpLayer = Layer.effect(

138 140
        outcome: string,
139 141
        detail: string,
140 142
      ) => journal(journalService, requestId, request, decision, outcome, detail);
143
      const recordValue = (value: unknown): Record<string, unknown> =>
144
        typeof value === "object" && value !== null && !Array.isArray(value)
145
          ? { ...(value as Record<string, unknown>) }
146
          : {};
147
      const asString = (value: unknown): string | undefined =>
148
        typeof value === "string" && value !== "" ? value : undefined;
149
      const extractForgeCredentials = (
150
        payload: Record<string, unknown>,
151
      ): ForgeCredentials | undefined => {
152
        const raw = payload.assignment_credential ?? payload.forge_credentials;
153
        let token: string | undefined;
154
        let repository: string | undefined;
155
        let branch: string | undefined;
156
        if (typeof raw === "string") {
157
          token = raw;
158
          repository = asString(payload.assignment_repository);
159
          branch = asString(payload.assignment_branch);
160
        } else if (typeof raw === "object" && raw !== null) {
161
          const cred = recordValue(raw);
162
          const candidate =
163
            cred.token ?? cred.value ?? cred.password ?? cred.access_token;
164
          token = asString(candidate);
165
          repository = asString(cred.repository ?? payload.assignment_repository);
166
          branch = asString(cred.branch ?? payload.assignment_branch);
167
        }
168
        if (token === undefined || repository === undefined || branch === undefined)
169
          return undefined;
170
        return { token: Redacted.make(token), repository, branch };
171
      };
141 172
      const handlers: ComputerChannelHandlers = {
142 173
        onProbe: async (requestId) => {
143 174
          const request = { argv: ["<probe>"], cwd: config.roots[0] ?? "" };

@@ -266,7 +297,16 @@ export const computerUpLayer = Layer.effect(

266 297
          const cwd = typeof payload.cwd === "string" ? resolve(payload.cwd) : "";
267 298
          const request = { argv: ["<agent>", agentId.slice(0, 64)], cwd };
268 299
          append(requestId, request, "received", "pending", "ACP delegation received");
269
          if (
300
          const forgeCredentials = extractForgeCredentials(payload);
301
          if (forgeCredentials !== undefined) {
302
            append(
303
              requestId,
304
              request,
305
              "credentials_delivered",
306
              "configured",
307
              "scoped forge credentials configured for delegated push",
308
            );
309
          } else if (
270 310
            Object.hasOwn(payload, "assignment_credential") ||
271 311
            Object.hasOwn(payload, "forge_credentials")
272 312
          ) {

@@ -274,8 +314,8 @@ export const computerUpLayer = Layer.effect(

274 314
              requestId,
275 315
              request,
276 316
              "credentials_delivered",
277
              "not_used",
278
              "scoped forge credentials delivered; not used by this ACP delegation",
317
              "incomplete",
318
              "scoped forge credentials delivered but missing repository or branch",
279 319
            );
280 320
          }
281 321
          if (Option.isNone(agentProcess)) {

@@ -382,6 +422,9 @@ export const computerUpLayer = Layer.effect(

382 422
            roots: config.roots,
383 423
            curatedExecute: config.curatedExecute ?? [],
384 424
            env: environment,
425
            ...(forgeCredentials !== undefined
426
              ? { forgeCredentials, forgeOrigin: origin }
427
              : {}),
385 428
            timeoutMs: numberField(
386 429
              payload,
387 430
              ["timeout_ms", "timeout"],
packages/openagents-cli/src/delegation-push.ts added +273

@@ -0,0 +1,273 @@

1
import { Effect, Redacted } from "effect";
2
import { execFile, spawn } from "node:child_process";
3
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
4
import { join } from "node:path";
5
import { tmpdir } from "node:os";
6
7
import { GitExecutionError, InputError } from "./errors.js";
8
import { repositoryFromRemoteUrl, validateRemoteName } from "./git-runner.js";
9
import { scrubbedEnvironment } from "./computer-executor.js";
10
11
export interface ForgeCredentials {
12
  readonly token: Redacted.Redacted<string>;
13
  readonly repository: string;
14
  readonly branch: string;
15
}
16
17
export interface DelegatedPushInput {
18
  readonly directory: string;
19
  readonly remote: string;
20
  readonly refspec: string;
21
  readonly repository: string;
22
  readonly branch: string;
23
  readonly credential: Redacted.Redacted<string>;
24
  readonly origin: string;
25
}
26
27
export const redactSecret = (value: string): string =>
28
  value
29
    .replaceAll(
30
      /(?:oa_(?:pat|agent|assignment)_[A-Za-z0-9._-]+|smct_[A-Za-z0-9._-]+)/gu,
31
      "[REDACTED]",
32
    )
33
    .replaceAll(/Bearer\s+\S+/giu, "Bearer [REDACTED]")
34
    .replaceAll(
35
      /(?:api[-_]?key|token|secret|password|authorization)\s*[=:]\s*\S+/giu,
36
      "[REDACTED]",
37
    );
38
39
const canonicalBranch = (branch: string): string =>
40
  branch.startsWith("refs/heads/") ? branch : `refs/heads/${branch}`;
41
42
const toCanonical = (value: string, branch: string): string | undefined => {
43
  const target = canonicalBranch(branch);
44
  if (value === branch || value === target) return target;
45
  return undefined;
46
};
47
48
export const validateRefspec = Effect.fn("DelegationPush.validateRefspec")(
49
  function* (refspec: string, branch: string) {
50
    if (refspec === "") {
51
      return yield* new InputError({ message: "The refspec is empty." });
52
    }
53
    if (/[\s,]/u.test(refspec)) {
54
      return yield* new InputError({
55
        message: `Multi-ref push is not allowed: ${redactSecret(refspec)}`,
56
      });
57
    }
58
    if (refspec.startsWith("+") || refspec.startsWith("-")) {
59
      return yield* new InputError({
60
        message: `Force or option refspecs are not allowed: ${redactSecret(refspec)}`,
61
      });
62
    }
63
    const target = canonicalBranch(branch);
64
    const colon = refspec.indexOf(":");
65
    if (colon >= 0) {
66
      const src = refspec.slice(0, colon);
67
      const dst = refspec.slice(colon + 1);
68
      if (dst === "") {
69
        return yield* new InputError({
70
          message: `A refspec with an empty destination is not allowed: ${redactSecret(refspec)}`,
71
        });
72
      }
73
      const srcCanonical = toCanonical(src, branch);
74
      const dstCanonical = toCanonical(dst, branch);
75
      if (srcCanonical === undefined || dstCanonical === undefined || srcCanonical !== dstCanonical) {
76
        return yield* new InputError({
77
          message: `Refspec ${redactSecret(refspec)} is not the assigned branch ${target}.`,
78
        });
79
      }
80
      return;
81
    }
82
    if (toCanonical(refspec, branch) === undefined) {
83
      return yield* new InputError({
84
        message: `Refspec ${redactSecret(refspec)} is not the assigned branch ${target}.`,
85
      });
86
    }
87
  },
88
);
89
90
const getRemoteUrl = (
91
  directory: string,
92
  remote: string,
93
): Effect.Effect<string, GitExecutionError> =>
94
  Effect.tryPromise({
95
    try: () =>
96
      new Promise<string>((resolve, reject) => {
97
        execFile(
98
          "git",
99
          ["remote", "get-url", "--", remote],
100
          { cwd: directory },
101
          (error, stdout, stderr) => {
102
            if (error) {
103
              reject(
104
                new Error(
105
                  `git remote get-url failed: ${redactSecret(stderr.trim() || String(error))}`,
106
                ),
107
              );
108
              return;
109
            }
110
            resolve(stdout.trim());
111
          },
112
        );
113
      }),
114
    catch: (cause) =>
115
      new GitExecutionError({
116
        operation: "git remote get-url",
117
        message: redactSecret(
118
          cause instanceof Error ? cause.message : String(cause),
119
        ),
120
      }),
121
  });
122
123
const runGit = (
124
  operation: string,
125
  args: ReadonlyArray<string>,
126
  directory: string,
127
  env: Record<string, string>,
128
): Effect.Effect<{ readonly exitCode: number; readonly stderr: string }, GitExecutionError> =>
129
  Effect.tryPromise({
130
    try: () =>
131
      new Promise<{ exitCode: number; stderr: string }>((resolve, reject) => {
132
        let stderr = "";
133
        const child = spawn("git", [...args], {
134
          cwd: directory,
135
          env,
136
          shell: false,
137
          stdio: ["ignore", "pipe", "pipe"],
138
        });
139
        child.on("error", (cause) =>
140
          reject(
141
            new Error(
142
              `git ${operation} could not start: ${redactSecret(cause.message)}`,
143
            ),
144
          ),
145
        );
146
        child.stderr.on("data", (chunk: Buffer) => {
147
          if (stderr.length < 16_384) {
148
            stderr += chunk.toString("utf8");
149
          }
150
        });
151
        child.on("close", (code) =>
152
          resolve({
153
            exitCode: code ?? 1,
154
            stderr: redactSecret(stderr.trim()),
155
          }),
156
        );
157
      }),
158
    catch: (cause) =>
159
      new GitExecutionError({
160
        operation: `git ${operation}`,
161
        message: redactSecret(
162
          cause instanceof Error ? cause.message : String(cause),
163
        ),
164
      }),
165
  });
166
167
const buildCredentialHelper = (
168
  directory: string,
169
  token: string,
170
  expectedHost: string,
171
  expectedPath: string,
172
): string => {
173
  const tokenPath = join(directory, "token");
174
  const helperPath = join(directory, "helper");
175
  writeFileSync(tokenPath, `${token}\n`, { mode: 0o600 });
176
  chmodSync(tokenPath, 0o600);
177
178
  const script = `#!/bin/sh
179
if [ "$1" != "get" ]; then
180
  exit 0
181
fi
182
host=""
183
path=""
184
while IFS= read -r line; do
185
  [ -z "$line" ] && break
186
  case "$line" in
187
    host=*) host="\${line#host=}" ;;
188
    path=*) path="\${line#path=}" ;;
189
  esac
190
done
191
if [ "$host" != ${JSON.stringify(expectedHost)} ] || [ "$path" != ${JSON.stringify(expectedPath)} ]; then
192
  exit 0
193
fi
194
PASSWORD=$(tr -d '\\n' < ${JSON.stringify(tokenPath)})
195
printf 'username=openagents\\npassword=%s\\n\\n' "$PASSWORD"
196
`;
197
  writeFileSync(helperPath, script, { mode: 0o700 });
198
  chmodSync(helperPath, 0o700);
199
  return helperPath;
200
};
201
202
export const pushDelegated = (
203
  input: DelegatedPushInput,
204
): Effect.Effect<void, GitExecutionError | InputError> =>
205
  Effect.tryPromise({
206
    try: async () => {
207
      await Effect.runPromise(validateRefspec(input.refspec, input.branch));
208
209
      const remote = await Effect.runPromise(validateRemoteName(input.remote));
210
      const rawUrl = await Effect.runPromise(getRemoteUrl(input.directory, remote));
211
      const actualRepository = await Effect.runPromise(
212
        repositoryFromRemoteUrl(input.origin, rawUrl),
213
      );
214
      if (actualRepository !== input.repository) {
215
        throw new InputError({
216
          message: `The remote repository is ${actualRepository}, not the assigned ${input.repository}.`,
217
        });
218
      }
219
220
      const token = Redacted.value(input.credential);
221
      const tempDir = mkdtempSync(join(tmpdir(), "oa-delegation-push-"));
222
      chmodSync(tempDir, 0o700);
223
224
      try {
225
        const remoteUrl = new URL(rawUrl);
226
        const helperPath = buildCredentialHelper(
227
          tempDir,
228
          token,
229
          remoteUrl.host,
230
          remoteUrl.pathname.replace(/^\//u, ""),
231
        );
232
        const helperConfig = `!${helperPath}`;
233
        const gitArgs = [
234
          "-c",
235
          "credential.helper=",
236
          "-c",
237
          `credential.${remoteUrl.origin}.helper=${helperConfig}`,
238
          "push",
239
          "--",
240
          remote,
241
          input.refspec,
242
        ];
243
        const result = await Effect.runPromise(
244
          runGit("push", gitArgs, input.directory, {
245
            ...scrubbedEnvironment(process.env),
246
            GIT_TERMINAL_PROMPT: "0",
247
          }),
248
        );
249
        if (result.exitCode !== 0) {
250
          throw new GitExecutionError({
251
            operation: "delegated git push",
252
            exitCode: result.exitCode,
253
            message: `git push failed: ${result.stderr}`,
254
          });
255
        }
256
      } finally {
257
        try {
258
          rmSync(tempDir, { recursive: true, force: true });
259
        } catch {
260
          // Cleanup is best-effort; the token file is already as protected as the
261
          // temporary directory allows.
262
        }
263
      }
264
    },
265
    catch: (cause) => {
266
      if (cause instanceof InputError) return cause;
267
      if (cause instanceof GitExecutionError) return cause;
268
      return new GitExecutionError({
269
        operation: "delegated git push",
270
        message: redactSecret(String(cause)),
271
      });
272
    },
273
  });
packages/openagents-cli/test/computer.test.ts modified +4 -2

@@ -752,6 +752,8 @@ describe("Computer channel", () => {

752 752
        prompt: "delegate",
753 753
        cwd: "/workspace",
754 754
        assignment_credential: "forge-secret",
755
        assignment_repository: "owner/repo",
756
        assignment_branch: "feature-1",
755 757
        env: { FORGE_TOKEN: "forge-secret" },
756 758
      },
757 759
      {

@@ -772,8 +774,8 @@ describe("Computer channel", () => {

772 774
    expect(terminal).toContainEqual(
773 775
      expect.objectContaining({
774 776
        decision: "credentials_delivered",
775
        outcome: "not_used",
776
        detail: "scoped forge credentials delivered; not used by this ACP delegation",
777
        outcome: "configured",
778
        detail: "scoped forge credentials configured for delegated push",
777 779
      }),
778 780
    );
779 781
  });
packages/openagents-cli/test/delegation-push.test.ts added +271

@@ -0,0 +1,271 @@

1
import { Effect, Redacted } from "effect";
2
import {
3
  chmodSync,
4
  mkdtempSync,
5
  readFileSync,
6
  readdirSync,
7
  rmSync,
8
  writeFileSync,
9
} from "node:fs";
10
import { join } from "node:path";
11
import { tmpdir } from "node:os";
12
import { afterEach, beforeEach, describe, expect, it } from "vitest";
13
14
import {
15
  pushDelegated,
16
  redactSecret,
17
  validateRefspec,
18
} from "../src/delegation-push.js";
19
20
const CANARY = "oa_assignment_canary_12345";
21
22
const makeFakeGit = (opts: {
23
  readonly fail?: boolean;
24
  readonly stderr?: string;
25
}): { readonly dir: string; readonly log: string; readonly originalPath: string } => {
26
  const dir = mkdtempSync(join(tmpdir(), "oa-fake-git-"));
27
  const log = join(dir, "git.log");
28
  const script = `#!/bin/sh
29
CANARY="${CANARY}"
30
for arg in "$@"; do
31
  if [ "$arg" = "get-url" ]; then
32
    echo "https://openagents.com/owner/repo.git"
33
    exit 0
34
  fi
35
  if [ "$arg" = "push" ]; then
36
    for a in "$@"; do
37
      printf "%s\\n" "$a" >> ${JSON.stringify(log)}
38
    done
39
    printf "%s\\n" "---" >> ${JSON.stringify(log)}
40
    if [ -n "${opts.stderr ?? ""}" ]; then
41
      printf "%s\\n" "${opts.stderr ?? ""}" >&2
42
    fi
43
    exit ${opts.fail ? 1 : 0}
44
  fi
45
done
46
exit 1
47
`;
48
  writeFileSync(join(dir, "git"), script, { mode: 0o755 });
49
  chmodSync(join(dir, "git"), 0o755);
50
  return { dir, log, originalPath: process.env.PATH ?? "" };
51
};
52
53
const setPath = (dir: string): void => {
54
  process.env.PATH = `${dir}:${process.env.PATH ?? ""}`;
55
};
56
57
const restorePath = (original: string): void => {
58
  process.env.PATH = original;
59
};
60
61
describe("delegated push refspec validation", () => {
62
  it("allows the assigned branch by short name, full ref, or matching src:dst", async () => {
63
    await expect(
64
      Effect.runPromise(validateRefspec("feature-1", "feature-1")),
65
    ).resolves.toBeUndefined();
66
    await expect(
67
      Effect.runPromise(validateRefspec("refs/heads/feature-1", "feature-1")),
68
    ).resolves.toBeUndefined();
69
    await expect(
70
      Effect.runPromise(
71
        validateRefspec("refs/heads/feature-1:refs/heads/feature-1", "feature-1"),
72
      ),
73
    ).resolves.toBeUndefined();
74
    await expect(
75
      Effect.runPromise(validateRefspec("feature-1:feature-1", "feature-1")),
76
    ).resolves.toBeUndefined();
77
  });
78
79
  it("refuses an unauthorized branch, a mismatched src:dst, and invalid forms", async () => {
80
    await expect(
81
      Effect.runPromise(validateRefspec("main", "feature-1")),
82
    ).rejects.toThrow(/not the assigned branch/);
83
    await expect(
84
      Effect.runPromise(validateRefspec("refs/heads/main", "feature-1")),
85
    ).rejects.toThrow(/not the assigned branch/);
86
    await expect(
87
      Effect.runPromise(
88
        validateRefspec("refs/heads/feature-1:refs/heads/main", "feature-1"),
89
      ),
90
    ).rejects.toThrow(/not the assigned branch/);
91
    await expect(
92
      Effect.runPromise(validateRefspec("refs/tags/v1", "feature-1")),
93
    ).rejects.toThrow(/not the assigned branch/);
94
    await expect(
95
      Effect.runPromise(validateRefspec("", "feature-1")),
96
    ).rejects.toThrow(/empty/);
97
    await expect(
98
      Effect.runPromise(validateRefspec("+feature-1", "feature-1")),
99
    ).rejects.toThrow(/Force or option/);
100
    await expect(
101
      Effect.runPromise(validateRefspec("feature-1:", "feature-1")),
102
    ).rejects.toThrow(/empty destination/);
103
  });
104
105
  it("refuses a multi-ref push", async () => {
106
    await expect(
107
      Effect.runPromise(validateRefspec("feature-1 main", "feature-1")),
108
    ).rejects.toThrow(/Multi-ref/);
109
    await expect(
110
      Effect.runPromise(validateRefspec("feature-1,main", "feature-1")),
111
    ).rejects.toThrow(/Multi-ref/);
112
  });
113
});
114
115
describe("delegated push credential redaction", () => {
116
  it("redacts known credential patterns", () => {
117
    expect(redactSecret("token oa_assignment_canary_12345 here")).not.toContain(
118
      CANARY,
119
    );
120
    expect(redactSecret("token oa_assignment_canary_12345 here")).toContain(
121
      "[REDACTED]",
122
    );
123
    const auth = redactSecret("Authorization: Bearer abc.def");
124
    expect(auth).not.toContain("abc.def");
125
    expect(auth).toContain("[REDACTED]");
126
    expect(redactSecret("api-key=secret")).toBe("[REDACTED]");
127
    expect(redactSecret("password=s3cr3t")).toBe("[REDACTED]");
128
  });
129
});
130
131
describe("delegated push lifecycle", () => {
132
  const pushDir = () => mkdtempSync(join(tmpdir(), "oa-delegation-test-root-"));
133
134
  const cleanupTemp = (): void => {
135
    for (const name of readdirSync(tmpdir())) {
136
      if (
137
        name.startsWith("oa-delegation-push-") ||
138
        name.startsWith("oa-delegation-test-root-")
139
      ) {
140
        try {
141
          rmSync(join(tmpdir(), name), { recursive: true, force: true });
142
        } catch {
143
          // ignore
144
        }
145
      }
146
    }
147
  };
148
149
  let fake: ReturnType<typeof makeFakeGit> | undefined;
150
151
  beforeEach(() => {
152
    cleanupTemp();
153
  });
154
155
  afterEach(() => {
156
    if (fake !== undefined) {
157
      restorePath(fake.originalPath);
158
      rmSync(fake.dir, { recursive: true, force: true });
159
      fake = undefined;
160
    }
161
    cleanupTemp();
162
  });
163
164
  it("pushes the assigned branch and cleans up the credential temp directory", async () => {
165
    fake = makeFakeGit({ fail: false });
166
    setPath(fake.dir);
167
    const directory = pushDir();
168
    await expect(
169
      Effect.runPromise(
170
        pushDelegated({
171
          directory,
172
          remote: "origin",
173
          refspec: "feature-1",
174
          repository: "owner/repo",
175
          branch: "feature-1",
176
          credential: Redacted.make(CANARY),
177
          origin: "https://openagents.com",
178
        }),
179
      ),
180
    ).resolves.toBeUndefined();
181
    const log = fake.log;
182
    const logged = readLog(log);
183
    expect(logged.join("\n")).not.toContain(CANARY);
184
    expect(logged).toContain("origin");
185
    expect(logged).toContain("feature-1");
186
    expect(logged).toContain("push");
187
    expect(logged.some((line) => line.startsWith("credential."))).toBe(true);
188
    expect(readdirSync(tmpdir()).some((n) => n.startsWith("oa-delegation-push-"))).toBe(false);
189
  });
190
191
  it("fails distinguishably for a ref mismatch before any git push", async () => {
192
    fake = makeFakeGit({ fail: false });
193
    setPath(fake.dir);
194
    const directory = pushDir();
195
    await expect(
196
      Effect.runPromise(
197
        pushDelegated({
198
          directory,
199
          remote: "origin",
200
          refspec: "main",
201
          repository: "owner/repo",
202
          branch: "feature-1",
203
          credential: Redacted.make(CANARY),
204
          origin: "https://openagents.com",
205
        }),
206
      ),
207
    ).rejects.toThrow(/not the assigned branch/);
208
  });
209
210
  it("fails distinguishably when the remote repository does not match the assignment", async () => {
211
    fake = makeFakeGit({ fail: false });
212
    setPath(fake.dir);
213
    const directory = pushDir();
214
    await expect(
215
      Effect.runPromise(
216
        pushDelegated({
217
          directory,
218
          remote: "origin",
219
          refspec: "feature-1",
220
          repository: "wrong/repo",
221
          branch: "feature-1",
222
          credential: Redacted.make(CANARY),
223
          origin: "https://openagents.com",
224
        }),
225
      ),
226
    ).rejects.toThrow(/not the assigned/);
227
  });
228
229
  it("fails with a redacted message and cleans up after a git push failure", async () => {
230
    fake = makeFakeGit({
231
      fail: true,
232
      stderr: `error: invalid token ${CANARY}`,
233
    });
234
    setPath(fake.dir);
235
    const directory = pushDir();
236
    let error: unknown;
237
    try {
238
      await Effect.runPromise(
239
        pushDelegated({
240
          directory,
241
          remote: "origin",
242
          refspec: "feature-1",
243
          repository: "owner/repo",
244
          branch: "feature-1",
245
          credential: Redacted.make(CANARY),
246
          origin: "https://openagents.com",
247
        }),
248
      );
249
    } catch (cause) {
250
      error = cause;
251
    }
252
    expect(error).toBeInstanceOf(Error);
253
    const message = error instanceof Error ? error.message : String(error);
254
    expect(message).toContain("git push failed");
255
    expect(message).not.toContain(CANARY);
256
    expect(message).toContain("[REDACTED]");
257
    expect(
258
      readdirSync(tmpdir()).some((n) => n.startsWith("oa-delegation-push-")),
259
    ).toBe(false);
260
  });
261
});
262
263
const readLog = (path: string): ReadonlyArray<string> => {
264
  try {
265
    return readFileSync(path, "utf8")
266
      .split("\n")
267
      .filter((line) => line !== "");
268
  } catch {
269
    return [];
270
  }
271
};

This page updates live while a promote is in flight · changelog