From 98f5a6c0b6ff05be17b298035d90ecec6ff1d61f Mon Sep 17 00:00:00 2001 From: Daniel Leomil Date: Tue, 6 Oct 2026 20:38:37 -0300 Subject: [PATCH] Usa sessao ChatGPT no runner editorial --- config/governance-guidelines.json | 8 +- docs/specs/editorial-agent-output-contract.md | 5 + docs/specs/executable-codex-agents.md | 5 + scripts/run-editorial-agent.cjs | 117 +++++++++++++-- tests/agents/editorial-agent-runner.spec.cjs | 135 ++++++++++++++++++ 5 files changed, 254 insertions(+), 16 deletions(-) diff --git a/config/governance-guidelines.json b/config/governance-guidelines.json index 9d6773e..85a7429 100644 --- a/config/governance-guidelines.json +++ b/config/governance-guidelines.json @@ -92,24 +92,24 @@ }, { "id": "editorial-agent-output", - "version": "1.0.0", + "version": "1.0.1", "status": "active", "type": "contract", "owner": "content", "sourcePath": "docs/specs/editorial-agent-output-contract.md", - "sha256": "f150dc26b1cfe911521f2cf6207f70e6bfd75b7d5b8019e95e7830224fd4c274", + "sha256": "bd158110bdddd209fa0d4a9277f715fb9d79f34a6e36fc59275ae20f425b4bcf", "appliesTo": ["editorial-agents", "editorial-workflows"], "enforcement": "automated-blocking", "evidence": ["validated editorial agent output"] }, { "id": "executable-codex-agents", - "version": "1.2.0", + "version": "1.2.1", "status": "active", "type": "standard", "owner": "architecture", "sourcePath": "docs/specs/executable-codex-agents.md", - "sha256": "b46c517ee0e39a35cf3c0d38b68e008bd4c8dd44bc193261e7d97dae35dc5d8a", + "sha256": "e12392121a16bc90a008e932fde0c673aa64edfc91db2e932847645cdcb3c5d0", "appliesTo": ["agent-configuration", "agent-workflows"], "enforcement": "automated-blocking", "evidence": ["agent configuration validation"] diff --git a/docs/specs/editorial-agent-output-contract.md b/docs/specs/editorial-agent-output-contract.md index e34b432..5782735 100644 --- a/docs/specs/editorial-agent-output-contract.md +++ b/docs/specs/editorial-agent-output-contract.md @@ -81,6 +81,11 @@ que inicia processo Codex separado com `CODEX_HOME` temporario, sem heranca de ferramentas ou connectors da sessao pai. Antes da execucao, o runner exige `codex mcp list --json` vazio e bloqueia qualquer preflight invalido. +O runner confirma `codex login status` como ChatGPT, copia somente o cache local +da sessao para o diretorio temporario com permissao restrita e o remove ao +encerrar. `OPENAI_API_KEY` nao e encaminhada nem usada; se a sessao ChatGPT ou +o cache local nao estiverem disponiveis, a execucao falha sem fallback. + O runner recebe `--agent`, `--input` e `--output`. A entrada segue `config/editorial-agent-input.schema.json` e e um documento com `scenarioId`, `inputState` e `input`; a saida gravada inclui o mesmo diff --git a/docs/specs/executable-codex-agents.md b/docs/specs/executable-codex-agents.md index a20fb43..3d7e155 100644 --- a/docs/specs/executable-codex-agents.md +++ b/docs/specs/executable-codex-agents.md @@ -33,6 +33,11 @@ processo Codex separado com `CODEX_HOME` temporario e nao herda ferramentas ou connectors da sessao pai. O runner tambem exige `codex mcp list --json` vazio e falha fechado quando qualquer preflight ou validacao falhar. +A autenticacao usa somente uma sessao local confirmada como ChatGPT. O runner +copia temporariamente o cache local para o `CODEX_HOME` isolado, restringe sua +permissao e o remove ao encerrar. A chave `OPENAI_API_KEY` nao e encaminhada; +ausencia de sessao ou cache invalido bloqueia a execucao, sem fallback. + O modelo central exige `mcpPolicy: none` explicitamente para todos os adapters. O validador rejeita a omissao da politica e qualquer servidor MCP configurado. diff --git a/scripts/run-editorial-agent.cjs b/scripts/run-editorial-agent.cjs index 8ff1755..31448b0 100644 --- a/scripts/run-editorial-agent.cjs +++ b/scripts/run-editorial-agent.cjs @@ -27,8 +27,8 @@ const ALLOWED_ENVIRONMENT = [ 'LC_ALL', 'TERM', 'NO_COLOR', - 'OPENAI_API_KEY', ]; +const AUTH_CACHE_FILE = 'auth.json'; function argument(name) { const index = process.argv.indexOf(name); @@ -67,6 +67,73 @@ function isolatedEnvironment(tempHome) { return environment; } +function sourceEnvironment() { + const environment = {}; + [ + ...ALLOWED_ENVIRONMENT, + 'HOME', + 'USERPROFILE', + 'LOCALAPPDATA', + 'CODEX_HOME', + ].forEach((name) => { + if (process.env[name]) environment[name] = process.env[name]; + }); + return environment; +} + +function authenticatedChatGptCache(codexBin) { + const status = spawnSync(codexBin, ['login', 'status'], { + cwd: ROOT, + env: sourceEnvironment(), + encoding: 'utf8', + }); + const statusText = `${status.stdout || ''}\n${status.stderr || ''}`; + if (status.status !== 0 || !/logged in using chatgpt/i.test(statusText)) { + throw new Error('sessao ChatGPT do Codex indisponivel'); + } + + const sourceHome = + process.env.CODEX_HOME || path.join(os.homedir(), '.codex'); + const authPath = path.join(sourceHome, AUTH_CACHE_FILE); + let authStat; + try { + authStat = fs.lstatSync(authPath); + } catch { + throw new Error('cache local da sessao ChatGPT indisponivel'); + } + if (!authStat.isFile() || authStat.isSymbolicLink()) { + throw new Error('cache local da sessao ChatGPT invalido'); + } + return authPath; +} + +function copyAuthenticatedCache(sourcePath, tempHome) { + const targetPath = path.join(tempHome, AUTH_CACHE_FILE); + fs.copyFileSync(sourcePath, targetPath, fs.constants.COPYFILE_EXCL); + fs.chmodSync(targetPath, 0o600); +} + +function safeExecutionFailure(result) { + const details = + `${result.stdout || ''}\n${result.stderr || ''}`.toLowerCase(); + if (/\b401\b/.test(details)) return 'resposta 401 do servico'; + const categories = [ + [ + /authentication|unauthorized|not authenticated|login required|logged out|invalid (?:bearer |refresh )?token|expired token|\b403\b/, + 'autenticacao', + ], + [/network|connect|timeout|econn|dns|socket/, 'rede'], + [/sandbox|permission|operation not permitted/, 'sandbox'], + [/model|not available|unsupported/, 'modelo'], + [/schema|json/, 'schema'], + ]; + const category = categories.find(([pattern]) => pattern.test(details))?.[1]; + if (category) return `falha de ${category}`; + if (result.error?.code) return `spawn ${result.error.code}`; + if (result.signal) return `sinal ${result.signal}`; + return `codigo ${result.status}`; +} + function buildMcpArguments() { return ['mcp', 'list', '--json']; } @@ -121,18 +188,34 @@ function run() { return fail('inputState invalido'); } - const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'quiz-codex-home-')); - const tempWorkspace = fs.mkdtempSync( - path.join(os.tmpdir(), 'quiz-editorial-workspace-'), - ); - const tempMessage = path.join(tempHome, 'agent-output.json'); - const env = isolatedEnvironment(tempHome); + let tempHome; + let tempWorkspace; const cleanup = () => { - fs.rmSync(tempHome, { recursive: true, force: true }); - fs.rmSync(tempWorkspace, { recursive: true, force: true }); + if (tempHome) fs.rmSync(tempHome, { recursive: true, force: true }); + if (tempWorkspace) + fs.rmSync(tempWorkspace, { recursive: true, force: true }); }; try { - if (!env.OPENAI_API_KEY) return fail('OPENAI_API_KEY nao configurada'); + const sourceAuthPath = authenticatedChatGptCache(codexBin); + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'quiz-codex-home-')); + tempWorkspace = fs.mkdtempSync( + path.join(os.tmpdir(), 'quiz-editorial-workspace-'), + ); + copyAuthenticatedCache(sourceAuthPath, tempHome); + const tempMessage = path.join(tempHome, 'agent-output.json'); + const env = isolatedEnvironment(tempHome); + const isolatedLogin = spawnSync(codexBin, ['login', 'status'], { + cwd: tempWorkspace, + env, + encoding: 'utf8', + }); + const isolatedStatus = `${isolatedLogin.stdout || ''}\n${isolatedLogin.stderr || ''}`; + if ( + isolatedLogin.status !== 0 || + !/logged in using chatgpt/i.test(isolatedStatus) + ) { + return fail('cache ChatGPT nao autenticado no perfil isolado'); + } const mcpCheck = spawnSync(codexBin, buildMcpArguments(), { cwd: tempWorkspace, env, @@ -163,7 +246,10 @@ function run() { buildExecArguments(agentConfig, tempMessage, tempWorkspace, prompt), { cwd: tempWorkspace, env, encoding: 'utf8' }, ); - if (result.status !== 0) return fail('execucao do agente falhou'); + if (result.status !== 0) { + const detail = safeExecutionFailure(result); + return fail(`execucao do agente falhou (${detail})`); + } let output; try { output = JSON.parse(fs.readFileSync(tempMessage, 'utf8')); @@ -193,4 +279,11 @@ function run() { if (require.main === module) run(); -module.exports = { buildExecArguments, buildMcpArguments, run }; +module.exports = { + authenticatedChatGptCache, + buildExecArguments, + buildMcpArguments, + copyAuthenticatedCache, + run, + safeExecutionFailure, +}; diff --git a/tests/agents/editorial-agent-runner.spec.cjs b/tests/agents/editorial-agent-runner.spec.cjs index 964f4dd..dd89328 100644 --- a/tests/agents/editorial-agent-runner.spec.cjs +++ b/tests/agents/editorial-agent-runner.spec.cjs @@ -6,6 +6,9 @@ const { spawnSync } = require('node:child_process'); const root = path.resolve(__dirname, '..', '..'); const runner = path.join(root, 'scripts', 'run-editorial-agent.cjs'); +const { + safeExecutionFailure, +} = require('../../scripts/run-editorial-agent.cjs'); const scenarios = require('../fixtures/agents/editorial-scenarios.json'); function writeJson(filePath, value) { @@ -13,14 +16,30 @@ function writeJson(filePath, value) { } function run() { + assert.equal( + safeExecutionFailure({ status: 1, stderr: '401 Unauthorized token-value' }), + 'resposta 401 do servico', + ); + assert.equal( + safeExecutionFailure({ status: 1, stderr: 'connect timeout' }), + 'falha de rede', + ); + const directory = fs.mkdtempSync( path.join(os.tmpdir(), 'quiz-editorial-runner-'), ); const fakeCodex = path.join(directory, 'fake-codex.js'); const fakeMcpCodex = path.join(directory, 'fake-mcp-codex.js'); + const fakeApiCodex = path.join(directory, 'fake-api-codex.js'); + const fakeFailureCodex = path.join(directory, 'fake-failure-codex.js'); const inputPath = path.join(directory, 'input.json'); const outputPath = path.join(directory, 'output.json'); const recordPath = path.join(directory, 'record.json'); + const statusRecordPath = path.join(directory, 'status-record.json'); + const mcpHomePath = path.join(directory, 'mcp-home.json'); + const authHome = path.join(directory, 'auth-home'); + fs.mkdirSync(authHome); + fs.writeFileSync(path.join(authHome, 'auth.json'), '{"session":"test"}\n'); const source = scenarios[0]; const pedagogicalSource = scenarios[1]; fs.writeFileSync( @@ -28,8 +47,15 @@ function run() { `#!/usr/bin/env node const fs = require('node:fs'); const args = process.argv.slice(2); +if (args.join(' ') === 'login status') { + fs.writeFileSync(${JSON.stringify(statusRecordPath)}, JSON.stringify({ env: process.env })); + process.stdout.write('Logged in using ChatGPT'); + process.exit(0); +} if (args[0] === 'mcp') { if (args.join(' ') !== 'mcp list --json') process.exit(2); + fs.writeFileSync(${JSON.stringify(mcpHomePath)}, JSON.stringify({ home: process.env.CODEX_HOME })); + if (!fs.existsSync(process.env.CODEX_HOME + '/auth.json')) process.exit(3); process.stdout.write('[]'); process.exit(0); } @@ -38,6 +64,8 @@ fs.writeFileSync(${JSON.stringify(recordPath)}, JSON.stringify({ argv: args, cwd: process.cwd(), env: process.env, + authMode: fs.existsSync(process.env.CODEX_HOME + '/auth.json'), + authModeBits: fs.statSync(process.env.CODEX_HOME + '/auth.json').mode & 0o777, })); const output = process.argv[process.argv.indexOf('--output-last-message') + 1]; const response = process.argv.some((argument) => argument.includes('pedagogical_quality')) @@ -47,6 +75,13 @@ fs.writeFileSync(output, response); `, { mode: 0o755 }, ); + fs.writeFileSync( + fakeApiCodex, + fs + .readFileSync(fakeCodex, 'utf8') + .replace('Logged in using ChatGPT', 'Logged in using an API key'), + { mode: 0o755 }, + ); fs.writeFileSync( fakeMcpCodex, fs @@ -57,6 +92,16 @@ fs.writeFileSync(output, response); ), { mode: 0o755 }, ); + fs.writeFileSync( + fakeFailureCodex, + fs + .readFileSync(fakeCodex, 'utf8') + .replace( + "if (args[0] !== 'exec') process.exit(2);", + "if (args[0] === 'exec') process.exit(7);\nif (args[0] !== 'exec') process.exit(2);", + ), + { mode: 0o755 }, + ); writeJson(inputPath, { scenarioId: source.scenarioId, inputState: source.inputState, @@ -82,6 +127,7 @@ fs.writeFileSync(output, response); encoding: 'utf8', env: { PATH: process.env.PATH, + CODEX_HOME: authHome, OPENAI_API_KEY: 'test-only-not-a-secret', INHERITED_CONNECTOR_TOKEN: 'must-not-reach-child', }, @@ -107,6 +153,18 @@ fs.writeFileSync(output, response); assert.notEqual(record.cwd, root); assert.equal(record.env.INHERITED_CONNECTOR_TOKEN, undefined); assert.equal(record.env.HOME, record.env.CODEX_HOME); + assert.equal(record.env.OPENAI_API_KEY, undefined); + assert.equal(record.authMode, true); + assert.equal(record.authModeBits, 0o600); + assert.equal(fs.existsSync(record.env.CODEX_HOME), false); + assert.equal( + JSON.parse(fs.readFileSync(statusRecordPath, 'utf8')).env.OPENAI_API_KEY, + undefined, + ); + assert.equal( + fs.existsSync(JSON.parse(fs.readFileSync(mcpHomePath, 'utf8')).home), + false, + ); const pedagogicalInputPath = path.join(directory, 'pedagogical-input.json'); const pedagogicalOutputPath = path.join( @@ -136,6 +194,7 @@ fs.writeFileSync(output, response); encoding: 'utf8', env: { PATH: process.env.PATH, + CODEX_HOME: authHome, OPENAI_API_KEY: 'test-only-not-a-secret', }, }, @@ -160,12 +219,88 @@ fs.writeFileSync(output, response); encoding: 'utf8', env: { PATH: process.env.PATH, + CODEX_HOME: authHome, OPENAI_API_KEY: 'test-only-not-a-secret', }, }, ); assert.notEqual(blocked.status, 0); assert.match(blocked.stderr, /MCP ou connector configurado/); + + const unauthenticated = spawnSync( + process.execPath, + [ + runner, + '--agent', + 'content_curator', + '--input', + inputPath, + '--output', + outputPath, + '--codex-bin', + fakeApiCodex, + ], + { + cwd: root, + encoding: 'utf8', + env: { PATH: process.env.PATH, CODEX_HOME: authHome }, + }, + ); + assert.notEqual(unauthenticated.status, 0); + assert.match( + unauthenticated.stderr, + /sessao ChatGPT do Codex indisponivel/, + ); + + const missingCache = spawnSync( + process.execPath, + [ + runner, + '--agent', + 'content_curator', + '--input', + inputPath, + '--output', + outputPath, + '--codex-bin', + fakeCodex, + ], + { + cwd: root, + encoding: 'utf8', + env: { PATH: process.env.PATH, CODEX_HOME: directory }, + }, + ); + assert.notEqual(missingCache.status, 0); + assert.match( + missingCache.stderr, + /cache local da sessao ChatGPT indisponivel/, + ); + + const failedExecution = spawnSync( + process.execPath, + [ + runner, + '--agent', + 'content_curator', + '--input', + inputPath, + '--output', + outputPath, + '--codex-bin', + fakeFailureCodex, + ], + { + cwd: root, + encoding: 'utf8', + env: { PATH: process.env.PATH, CODEX_HOME: authHome }, + }, + ); + assert.notEqual(failedExecution.status, 0); + assert.match( + failedExecution.stderr, + /execucao do agente falhou \(codigo 7\)/, + ); } finally { fs.rmSync(directory, { recursive: true, force: true }); }