Skip to content

Commit 27049bd

Browse files
committed
Harden automated candidate promotion
1 parent 4fbe3b5 commit 27049bd

17 files changed

Lines changed: 415 additions & 19 deletions

AGENTS.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,11 @@ Primary human docs: [architecture](docs/architecture.md),
117117
`systemd --user` updater service on a best-effort basis.
118118
- Failed privileged updater installs stay failed until a newer rebuild or an
119119
explicit retry path; avoid auto-retrying every reconcile cycle.
120+
- Automated user-local updater paths must force acceptance and running-app
121+
overrides off. They may build alongside a running app, but promotion must
122+
wait for exit or fail without replacing the installed runtime.
123+
- Transactional app promotion retains only the immediately previous managed
124+
app backup; older exact managed backups are pruned under the promotion lock.
120125
- Manual rollback uses the last-known-good package recorded in updater state
121126
and the same format-specific command layer as normal installs.
122127
- Local installs, updater rebuilds, and scheduled CI use the same upstream DMG

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/).
2323

2424
### Fixed
2525

26+
- Automated user-local updates no longer inherit or set developer overrides
27+
that could replace a running Electron app or bypass DMG acceptance. Manual
28+
and timer rebuilds now fail safely at promotion, transactional installs retain
29+
only the immediately previous app backup, and drift issue automation mutates
30+
only issues carrying its valid fingerprint marker.
2631
- The Add Project folder picker is no longer parented to the Codex window on
2732
Linux X11. This avoids a GNOME Shell modal input grab that could lock desktop
2833
input and flood system logs, while preserving parented dialogs on Wayland,

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

contrib/user-local-install/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,3 +94,5 @@ codex-desktop-version
9494
- The helper scripts are copied into `~/.local/opt` and do not run from the git checkout directly.
9595
- The X11/XWayland preference is stored in `~/.config/codex-desktop-linux/user-local.env` and is preserved across updater refreshes.
9696
- The weekly timer runs `codex-desktop-update --quiet`. It is opt-in: pass `--enable-timer` to `install-user-local.sh` to activate it, or run `systemctl --user enable --now codex-desktop-update.timer` manually after install.
97+
- Automated rebuilds never bypass the running-app or DMG acceptance gates. They may build a candidate while ChatGPT Desktop is open, but promotion waits for the in-app after-exit flow or fails safely for a manual/timer run. Retry after closing the app.
98+
- A successful transactional update retains only the immediately previous app backup. Older exact managed backups are pruned; manually named paths, files, and symlinks are left alone.

contrib/user-local-install/files/.local/bin/codex-desktop-update

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ log "Rebuilding wrapper..."
126126
export CODEX_LINUX_FEATURES_CONFIG="$SOURCE_REPO_DIR/linux-features/features.json"
127127
fi
128128
TMPDIR="$STATE_DIR/tmp" \
129-
CODEX_INSTALL_ALLOW_RUNNING=1 \
129+
CODEX_INSTALL_ALLOW_RUNNING=0 \
130130
CODEX_INSTALL_ROOT="$OPT_ROOT" \
131131
CODEX_INSTALL_DIR="$APP_DIR" \
132132
./install.sh

docs/updater.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,20 @@ configuration. Drift in any selected feature rejects the candidate; disable
144144
that feature and retry if receiving the upstream update is more important than
145145
retaining it.
146146

147+
Automated user-local rebuilds always force
148+
`CODEX_INSTALL_ALLOW_RUNNING=0` and `CODEX_ACCEPTANCE_OVERRIDE=0`, even if the
149+
service or invoking shell inherited developer overrides. The in-app update path
150+
continues through its after-exit hook and relaunches after a successful update.
151+
A manual command or timer may build while the app is open, but final promotion
152+
is refused and the working app remains unchanged until Electron exits. Failed
153+
promotion candidates are disposable by default; opt in to diagnostic retention
154+
with `CODEX_KEEP_REJECTED_CANDIDATE=1`.
155+
156+
Transactional user-local installs retain one previous-app directory for manual
157+
recovery. Each successful promotion replaces that retained backup with the
158+
version that was working immediately beforehand; older exact managed backup
159+
directories are pruned under the promotion lock.
160+
147161
Updater downloads are streamed to unique temporary files and published as
148162
`Codex-<sha256>.dmg` only after the file and parent directory are synced. The
149163
content-addressed path stays immutable while daemon and wrapper rebuild flows

docs/upstream-dmg-acceptance.md

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,11 +36,22 @@ app. First installation remains a single atomic sibling rename.
3636

3737
Each build evaluates reports from its own transaction directory, and a
3838
per-target promotion lock serializes the short final replacement and recovery
39-
window.
39+
window. Automated user-local update paths explicitly disable the developer-only
40+
running-app override. A timer or manual helper may finish building a candidate
41+
while Electron is open, but promotion is refused until that exact installed
42+
Electron process exits; the existing app and evidence remain unchanged.
43+
44+
After a successful promotion or journal recovery, cleanup retains exactly one
45+
managed `<app>.backup-TIMESTAMP[-N]` directory: the app version that was working
46+
immediately before the accepted candidate. Older managed backups are removed
47+
under the promotion lock, including read-only local copies derived from Nix.
48+
Symlinks, regular files, and non-exact/manual backup names are never removed.
49+
Cleanup failures only warn and are retried by the next install or recovery.
4050

4151
`--fresh` refreshes the DMG and candidate without deleting the working app
42-
early. Set `CODEX_KEEP_REJECTED_CANDIDATE=1` to retain a rejected candidate for
43-
debugging. `CODEX_ACCEPTANCE_OVERRIDE=1` is a developer-only emergency escape
52+
early. Set `CODEX_KEEP_REJECTED_CANDIDATE=1` to retain a rejected or safely
53+
unpromoted candidate for debugging; otherwise disposable candidates are
54+
removed. `CODEX_ACCEPTANCE_OVERRIDE=1` is a developer-only emergency escape
4455
hatch for a completely built candidate; CI and the updater do not set it.
4556

4657
The updater also publishes downloads to immutable, content-addressed paths
@@ -61,6 +72,9 @@ headers so rerunning an obsolete workflow cannot reopen an old issue. The
6172
identity must contain an ETag or both Last-Modified and Content-Length. If
6273
either the tested or current identity is unavailable, reconciliation makes no
6374
issue changes.
75+
Only issues carrying both the label and a valid hidden 64-character fingerprint
76+
marker are managed. Manually created labeled issues and malformed markers are
77+
never updated, reopened, superseded, or closed by the workflow.
6478

6579
## Manual Validation
6680

install.sh

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,14 @@ transactional_install() {
187187

188188
mkdir -p "$candidate_dir/.codex-linux"
189189
cp "$decision_path" "$candidate_dir/.codex-linux/upstream-dmg-decision.json"
190-
promote_candidate_install "$candidate_dir" "$final_dir"
190+
if ! promote_candidate_install "$candidate_dir" "$final_dir"; then
191+
if [ "${CODEX_KEEP_REJECTED_CANDIDATE:-0}" != "1" ]; then
192+
remove_tree_safely "$candidate_dir"
193+
else
194+
warn "Unpromoted candidate retained for diagnostics: $candidate_dir"
195+
fi
196+
error "Accepted candidate could not be promoted; the existing app was not changed"
197+
fi
191198
info "Acceptance transaction reports: $report_dir"
192199
info "Acceptance decision: $published_decision_path"
193200
if [ -n "${PROMOTED_BACKUP_APP_DIR:-}" ]; then

scripts/ci/upstream-dmg-issue.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,10 @@ async function listTrackingIssues(github, repo) {
5151
const issues = github.paginate
5252
? await github.paginate(github.rest.issues.listForRepo, params)
5353
: (await github.rest.issues.listForRepo(params)).data;
54-
return issues.filter((issue) => issue.pull_request == null);
54+
// The label is public repository metadata and may also be useful on a
55+
// maintainer-created issue. Only the hidden fingerprint marker proves that
56+
// this automation owns an issue and may mutate its lifecycle.
57+
return issues.filter((issue) => issue.pull_request == null && issueFingerprint(issue) !== null);
5558
}
5659

5760
async function ensureLabel(github, repo) {

scripts/ci/upstream-dmg-issue.test.js

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,3 +117,63 @@ test("does not mutate accepted or rejected issues when either HTTP identity is m
117117
}
118118
}
119119
});
120+
121+
test("accepted candidates ignore labeled issues without a valid automation marker", async () => {
122+
const fixture = fakeGithub([
123+
{ number: 20, state: "open", body: "Manually tracked upstream investigation" },
124+
{ number: 21, state: "open", body: "<!-- upstream-dmg-fingerprint:not-a-sha -->" },
125+
]);
126+
127+
const result = await reconcileUpstreamDmgIssue({
128+
github: fixture.github,
129+
repo: { owner: "o", repo: "r" },
130+
decision: decision("accepted", "5".repeat(64)),
131+
currentHttpIdentityKey: "current",
132+
});
133+
134+
assert.equal(result.action, "closed-resolved");
135+
assert.equal(result.count, 0);
136+
assert.equal(fixture.calls.length, 0);
137+
});
138+
139+
test("rejected candidates create a managed issue without mutating an unowned labeled issue", async () => {
140+
const fixture = fakeGithub([
141+
{ number: 22, state: "open", body: "Manually tracked upstream investigation" },
142+
]);
143+
144+
const result = await reconcileUpstreamDmgIssue({
145+
github: fixture.github,
146+
repo: { owner: "o", repo: "r" },
147+
decision: decision("rejected", "6".repeat(64)),
148+
currentHttpIdentityKey: "current",
149+
});
150+
151+
assert.equal(result.action, "created");
152+
assert.equal(fixture.calls.filter(([name]) => name === "create").length, 1);
153+
assert.equal(
154+
fixture.calls.some(([, args]) => args.issue_number === 22),
155+
false,
156+
);
157+
});
158+
159+
test("mixed reconciliation mutates only issues carrying a valid fingerprint marker", async () => {
160+
const fixture = fakeGithub([
161+
{ number: 23, state: "open", body: "Manually tracked upstream investigation" },
162+
{ number: 24, state: "open", body: fingerprintMarker("7".repeat(64)) },
163+
]);
164+
165+
await reconcileUpstreamDmgIssue({
166+
github: fixture.github,
167+
repo: { owner: "o", repo: "r" },
168+
decision: decision("rejected", "8".repeat(64)),
169+
currentHttpIdentityKey: "current",
170+
});
171+
172+
assert.equal(
173+
fixture.calls.some(([, args]) => args.issue_number === 23),
174+
false,
175+
);
176+
assert.ok(fixture.calls.some(([name, args]) => (
177+
name === "update" && args.issue_number === 24 && args.state === "closed"
178+
)));
179+
});

0 commit comments

Comments
 (0)