Q2: one-time notice naming the Codex provider and account for outside reviews

Outside reviews are on by default and use whatever Codex login exists
(#965). The first automatic Codex review on a machine now prints one
non-blocking notice: the provider (config.toml model_provider, else openai),
where the credential comes from (CODEX_API_KEY, OPENAI_API_KEY, the ChatGPT
login or the key saved by codex login; never the value), that reviews stay
on, and how to turn them off (gstack-config set codex_reviews disabled). A
marker in the gstack state root keeps it to once per machine. Tests that run
the generated command now use private state roots. Also shortens the
no-sandbox provenance line.
This commit is contained in:
garrytan committed 2026-10-03 21:15:23 +00:00
1 parent e2f9e144c0
commit 605f49d3d5
6 files changed
+87 -2

No files matched your search

+25
View File
@@ -7,6 +7,7 @@
# _gstack_codex_select_model — resolve + print the runtime model (#2914)
# _gstack_codex_sandbox_mode — read-only, or full access only for GSTACK_CODEX_NO_SANDBOX=1
# _gstack_codex_sandbox_preflight — free `codex sandbox` check before paid calls (Linux)
# _gstack_codex_first_use_notice — once per machine: provider, account, how to turn off
# _gstack_codex_model_probe — round-trip probe of the selected model (#2477)
# _gstack_codex_version_check — warn on known-bad Codex CLI versions
# _gstack_codex_timeout_wrapper — gtimeout -> timeout -> bash-native watchdog
@@ -126,6 +127,30 @@ _gstack_codex_sandbox_preflight() {
return 3
}
# --- First-use notice (#965) ------------------------------------------------
_gstack_codex_first_use_notice() {
# Outside reviews are on by default and use whatever Codex login exists, so
# the first one on a machine says where the code goes, with which account,
# and how to turn reviews off. Shown once per state root; never blocks and
# never prints a credential, only where it comes from.
local _root _marker _codex_home="${CODEX_HOME:-$HOME/.codex}" _account _provider
_root="$(. "${BASH_SOURCE[0]%/*}/gstack-state-root.sh" 2>/dev/null && gstack_state_root)" || return 0
[ -n "$_root" ] || return 0
_marker="$_root/.codex-review-notice-shown"
[ -e "$_marker" ] && return 0
if [ -n "$(printf '%s' "${CODEX_API_KEY:-}" | tr -d '[:space:]')" ]; then _account="the API key in CODEX_API_KEY"
elif [ -n "$(printf '%s' "${OPENAI_API_KEY:-}" | tr -d '[:space:]')" ]; then _account="the API key in OPENAI_API_KEY"
elif grep -q '"auth_mode"[[:space:]]*:[[:space:]]*"chatgpt"' "$_codex_home/auth.json" 2>/dev/null; then _account="the ChatGPT account Codex is logged in with (see: codex login status)"
elif [ -f "$_codex_home/auth.json" ]; then _account="the API key saved by codex login in $_codex_home/auth.json"
else _account="the credentials your Codex config.toml provider names"
fi
_provider=$(sed -n 's/^[[:space:]]*model_provider[[:space:]]*=[[:space:]]*"\([A-Za-z0-9_.-]*\)".*/\1/p' "$_codex_home/config.toml" 2>/dev/null | head -1)
echo "NOTICE: gstack outside reviews send the review prompt and code to Codex (provider: ${_provider:-openai}) using $_account. Reviews stay on; this notice shows once per machine. To turn them off: gstack-config set codex_reviews disabled" >&2
mkdir -p "$_root" 2>/dev/null && : > "$_marker" 2>/dev/null
return 0
}
# --- Model round-trip probe (#2477) ------------------------------------------
_gstack_codex_model_probe() {
+17
View File
@@ -189,3 +189,20 @@ never an outage.
```bash
gstack-config set codex_reviews enabled
```
<a id="codex-review-notice"></a>
### `NOTICE: gstack outside reviews send the review prompt and code to Codex (...) using ...`
**Meaning.** Outside reviews are on by default. The first one on a machine
says which provider receives your prompt and code and which login or key pays
for it. It shows once (gstack records `.codex-review-notice-shown` in its state
directory) and never blocks the review.
**Fix.** Nothing, if that is what you want. To stop sending code to Codex:
```bash
gstack-config set codex_reviews disabled
```
**Expected result.** Later reviews print `Codex review skipped (codex_reviews disabled)`
or `CODEX_MODE: disabled`, and /ship and /review show the outside review as not run.
+2 -1
View File
@@ -133,6 +133,7 @@ export function outsideVoiceCommand(ctx: TemplateContext, opts: OutsideCommandOp
const invocation = v.id === 'codex'
? `source "${bin}/gstack-codex-probe" && _gstack_codex_select_model ${opts.structuredBase ? 'review' : 'exec'} || exit 1
_gstack_codex_sandbox_preflight >/dev/null || exit 1
_gstack_codex_first_use_notice
_OUTSIDE_EXIT=0
_gstack_codex_timeout_wrapper ${Math.ceil(opts.timeoutMs / 1000)} ${codex} >"$_OUTSIDE_TMP/${opts.structuredBase ? 'text' : 'events'}" 2>"$_OUTSIDE_TMP/stderr" || _OUTSIDE_EXIT=$?
${opts.structuredBase ? 'cat "$_OUTSIDE_TMP/text"' : 'cat "$_OUTSIDE_TMP/text" 2>/dev/null || tail -n 20 "$_OUTSIDE_TMP/events"'}`
@@ -242,7 +243,7 @@ export function outsideVoiceFailurePolicy(ctx: TemplateContext, opts: OutsideVoi
export function outsideVoiceProvenance(ctx: TemplateContext, phase: string): string {
const v = outsideVoiceFor(ctx);
return `Retain the historical review-log skill ID; add \`"host":"${ctx.host}","outside_provider":"${v.id}","outside_status":"completed|unavailable|disabled|skipped","phase":"${phase}"\`. Record differing attempt outcomes separately. \`source:"${v.id}"\` requires completed CLI output; native uses \`source:"in-host"\` (historical \`source:"claude"\`: native Claude). Availability/native fallback is not outside completion. Preserve all reported modelUsage; unknown model identity stays unknown.${v.id === 'codex' ? ' If `WARNING: GSTACK_CODEX_NO_SANDBOX=1` printed, add `"codex_sandbox":"danger-full-access"`.' : ''}`;
return `Retain the historical review-log skill ID; add \`"host":"${ctx.host}","outside_provider":"${v.id}","outside_status":"completed|unavailable|disabled|skipped","phase":"${phase}"\`. Record differing attempt outcomes separately. \`source:"${v.id}"\` requires completed CLI output; native uses \`source:"in-host"\` (historical \`source:"claude"\`: native Claude). Availability/native fallback is not outside completion. Preserve all reported modelUsage; unknown model identity stays unknown.${v.id === 'codex' ? ' Under `GSTACK_CODEX_NO_SANDBOX=1` add `"codex_sandbox":"danger-full-access"`.' : ''}`;
}
export function generateOutsideVoiceRouting(ctx: TemplateContext): string {
+26
View File
@@ -473,3 +473,29 @@ describe('B2: a 404 model rejection is unusable, never ready (#2843)', () => {
});
}
});
describe('Q2: one-time outside-review notice (#965)', () => {
test('names the account source per auth mode, never the credential, and shows once per state root', () => {
for (const [env, files, account] of [
[{ CODEX_API_KEY: 'sk-codex-secret' }, {}, 'the API key in CODEX_API_KEY'],
[{ OPENAI_API_KEY: 'sk-openai-secret' }, {}, 'the API key in OPENAI_API_KEY'],
[{}, { 'auth.json': '{"auth_mode":"chatgpt","tokens":{"id_token":"secret-token"}}' }, 'the ChatGPT account Codex is logged in with'],
[{}, { 'auth.json': '{"auth_mode":"apikey","OPENAI_API_KEY":"sk-file-secret"}' }, 'the API key saved by codex login'],
[{}, { 'config.toml': 'model_provider = "mimo"\n[model_providers.mimo]\nenv_key = "MIMO_API_KEY"\n' }, 'Codex (provider: mimo) using the credentials your Codex config.toml provider names'],
] as const) {
const f = makeFixture();
try {
fs.rmSync(path.join(f.codexHome, 'auth.json'));
fs.rmSync(path.join(f.codexHome, 'config.toml'));
for (const [name, body] of Object.entries(files)) fs.writeFileSync(path.join(f.codexHome, name), body);
const first = runProbe(f, 'ok', { ...env }, '_gstack_codex_first_use_notice; echo "rc=$?"');
expect(first.stdout).toBe('rc=0\n');
expect(first.stderr).toContain(account);
expect(first.stderr).toContain('To turn them off: gstack-config set codex_reviews disabled');
expect(first.stderr).not.toMatch(/secret/);
expect(fs.existsSync(path.join(f.gstackHome, '.codex-review-notice-shown'))).toBe(true);
expect(runProbe(f, 'ok', { ...env }, '_gstack_codex_first_use_notice').stderr).toBe('');
} finally { fs.rmSync(f.home, { recursive: true, force: true }); }
}
});
});
+2 -1
View File
@@ -2198,7 +2198,8 @@ describe('DESIGN_OUTSIDE_VOICES resolver', () => {
const prepare = (file: string) => command.replace("'<prepared-prompt-file>'", quote(file))
.replaceAll('$HOME/.claude/skills/gstack', ROOT);
const env = { ...process.env, PATH: dir + path.delimiter + process.env.PATH, CAPTURE: capture,
CODEX_THREAD_ID: '', CODEX_SANDBOX: '', CLAUDECODE: '1', GSTACK_ACTIVE_HOST: 'claude' };
CODEX_THREAD_ID: '', CODEX_SANDBOX: '', CLAUDECODE: '1', GSTACK_ACTIVE_HOST: 'claude',
GSTACK_HOME: path.join(dir, 'state'), GSTACK_STATE_ROOT: '' };
const result = spawnSync('bash', ['-c', prepare(brief)], { cwd: ROOT, env, encoding: 'utf8', timeout: 5_000 });
expect(result.status, result.stderr).toBe(0);
const { args, stdin } = JSON.parse(fs.readFileSync(capture, 'utf8'));
+15
View File
@@ -56,6 +56,7 @@ function environment(host: 'codex' | 'claude'): NodeJS.ProcessEnv {
CAPTURE, FAKE_PROVIDER:host === 'codex' ? 'claude-code' : 'codex',
CODEX_THREAD_ID:host === 'codex' ? 'codex-fixture' : '', CODEX_SANDBOX:'',
CLAUDECODE:host === 'claude' ? '1' : '', GSTACK_ACTIVE_HOST:host, CODEX_HOME:TMP, GSTACK_CODEX_MODEL:'',
GSTACK_HOME:path.join(TMP,'state'), GSTACK_STATE_ROOT:'', CODEX_API_KEY:'', OPENAI_API_KEY:'',
PATH:`${BIN}${path.delimiter}${process.env.PATH}`,
GIT_AUTHOR_NAME:'Test',GIT_AUTHOR_EMAIL:'test@example.invalid',GIT_COMMITTER_NAME:'Test',GIT_COMMITTER_EMAIL:'test@example.invalid'};
}
@@ -278,6 +279,19 @@ describe('generated outside-review dispatch', () => {
expect(fs.existsSync(path.join(DIR, 'NEVER'))).toBe(false);
});
test('Q2: the first automatic Codex review on a machine names the provider and account, once, without blocking', () => {
const state = path.join(TMP, 'notice-state');
const first = invoke('claude', {}, { GSTACK_HOME: state, OPENAI_API_KEY: 'sk-test-secret-value' });
expect(first.status).toBe(0);
expect(first.stderr).toContain('NOTICE: gstack outside reviews send the review prompt and code to Codex (provider: openai) using the API key in OPENAI_API_KEY.');
expect(first.stderr).toContain('To turn them off: gstack-config set codex_reviews disabled');
expect(first.stderr).not.toContain('sk-test-secret-value');
expect(first.stdout).toContain('OUTSIDE_STATUS: completed');
const second = invoke('claude', {}, { GSTACK_HOME: state, OPENAI_API_KEY: 'sk-test-secret-value' });
expect(second.status).toBe(0);
expect(second.stderr).not.toContain('NOTICE:');
});
test('malformed Claude JSON cannot reach completion evaluation', () => {
const result = invoke('codex',{}, {FAKE_MODE:'malformed'});
expect(result.status).toBe(1);
@@ -320,6 +334,7 @@ describe('generated outside-review dispatch', () => {
const probe = path.join(BIN, 'gstack-codex-probe');
fs.writeFileSync(probe, `_gstack_codex_select_model() { _GSTACK_CODEX_SEL=gpt-6-astra; _GSTACK_CODEX_SANDBOX=read-only; }
_gstack_codex_sandbox_preflight() { return 0; }
_gstack_codex_first_use_notice() { :; }
_gstack_codex_timeout_wrapper() { echo 'Partial finding'; return 124; }
_gstack_codex_log_event() { printf '%s %s\\n' "$1" "$2" >> "$FAKE_EVENTS"; }
_gstack_codex_log_hang() { printf '%s %s\\n' "$1" "$2" >> "$FAKE_EVENTS"; }