Fix owner context loss after finalize; green the test suite

Owner continuity:
- Seed a freshly created owner task with the previous task's latest owner
  final on a cold start after the previous task already closed, so a user
  reply to a finalized TASK_DONE no longer produces a "no context" answer.
  Activated for this deployment via PAIRED_CARRY_FORWARD_LATEST_OWNER_FINAL
  (.env); carried text is injected as clearly-marked background only.
- Skip intermediate STEP_DONE outputs when picking the carry-forward anchor.

Single-mode routing:
- enforceRoomModeOnLease strips a stale reviewer/arbiter lease from a room
  switched back to single, preventing single-mode messages from stalling in
  the paired path on a stuck execution lease.

Session auth / credentials:
- Pre-sync Claude credentials into each session dir before the agent spawns.
- Honor CLAUDE_CREDENTIALS_PATH in setup/login.ts (per-service isolation).
- Add a relogin-required gate so a permanently logged-out claude-code room
  asks the user to re-login instead of spawning a doomed agent.

Other:
- Arbiter verdicts written in the user's language (verdict keyword stays EN).
- status-dashboard chatName field; runtime-inventory credential path resolver.

Tests (make suite fully green: 1595 pass / 3 skip):
- service-routing: default owner is now the claude service and reviewer is
  codex-review; update the 7 failover/default expectations accordingly.
- migrate-room-registrations: owner inferred as claude-code (configured
  OWNER_AGENT_TYPE) for a dual legacy room; reviewer becomes codex.
- register: mock paired-workspace provisioning + reload signal (registration
  now provisions a workspace and hot-reloads); assert RELOADED status.
- paired-execution-context: force a claude-code reviewer to exercise the
  Claude read-only branch regardless of the deployment default.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Codex
2026-08-25 18:40:14 +09:00
parent 80df025672
commit 712664ca00
17 changed files with 457 additions and 91 deletions

View File

@@ -5,13 +5,13 @@
* builds (captured from the official CLI). This is the manual / paste-code
* variant: the user visits the authorize URL, completes login in their
* browser, and pastes the code back. We exchange it for tokens and write
* `~/.claude/.credentials.json`.
* `CLAUDE_CREDENTIALS_PATH` or `~/.claude/.credentials.json`.
*
* Usage:
* bun setup/index.ts --step login # phase 1: print authorize URL
* bun setup/index.ts --step login --code <c> # phase 2: exchange the code
*
* Phase 1 stashes the PKCE verifier + state in /tmp/ejclaw-claude-login.json
* Phase 1 stashes the PKCE verifier + state in a /tmp login state file
* (mode 0600). Phase 2 reads it back, exchanges the code, writes credentials.
*
* This step is run by hand for re-auth. Once `.credentials.json` exists, the
@@ -39,8 +39,21 @@ const SCOPES = [
'user:file_upload',
];
const STATE_FILE = path.join(os.tmpdir(), 'ejclaw-claude-login.json');
const CREDS_PATH = path.join(os.homedir(), '.claude', '.credentials.json');
function credentialsPath(): string {
const configured = process.env.CLAUDE_CREDENTIALS_PATH?.trim();
return configured
? path.resolve(configured)
: path.join(os.homedir(), '.claude', '.credentials.json');
}
function stateFilePath(): string {
const hash = crypto
.createHash('sha256')
.update(credentialsPath())
.digest('hex')
.slice(0, 12);
return path.join(os.tmpdir(), `ejclaw-claude-login-${hash}.json`);
}
interface PendingState {
verifier: string;
@@ -90,13 +103,14 @@ function buildAuthorizeUrl(state: string, challenge: string): string {
}
function writePendingState(p: PendingState): void {
fs.writeFileSync(STATE_FILE, JSON.stringify(p), { mode: 0o600 });
fs.writeFileSync(stateFilePath(), JSON.stringify(p), { mode: 0o600 });
}
function readPendingState(): PendingState | null {
if (!fs.existsSync(STATE_FILE)) return null;
const stateFile = stateFilePath();
if (!fs.existsSync(stateFile)) return null;
try {
return JSON.parse(fs.readFileSync(STATE_FILE, 'utf-8')) as PendingState;
return JSON.parse(fs.readFileSync(stateFile, 'utf-8')) as PendingState;
} catch {
return null;
}
@@ -136,6 +150,7 @@ async function exchangeCode(
}
function writeCredentials(resp: ExchangeResponse): void {
const credsPath = credentialsPath();
const expiresAt = Date.now() + resp.expires_in * 1000;
const creds = {
claudeAiOauth: {
@@ -150,11 +165,11 @@ function writeCredentials(resp: ExchangeResponse): void {
: '',
},
};
const dir = path.dirname(CREDS_PATH);
const dir = path.dirname(credsPath);
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
const tmp = `${CREDS_PATH}.tmp`;
const tmp = `${credsPath}.tmp`;
fs.writeFileSync(tmp, JSON.stringify(creds, null, 2), { mode: 0o600 });
fs.renameSync(tmp, CREDS_PATH);
fs.renameSync(tmp, credsPath);
}
interface Args {
@@ -208,15 +223,15 @@ export async function run(args: string[]): Promise<void> {
try {
const resp = await exchangeCode(code, pending.verifier, pending.state);
writeCredentials(resp);
fs.unlinkSync(STATE_FILE);
fs.unlinkSync(stateFilePath());
const newScopes = (resp.scope || SCOPES.join(' ')).split(' ').sort();
logger.info(
{ scopes: newScopes, expiresInMin: Math.round(resp.expires_in / 60) },
'Wrote ~/.claude/.credentials.json',
'Wrote Claude credentials',
);
emitStatus('LOGIN', {
STATUS: 'success',
CREDENTIALS_PATH: CREDS_PATH,
CREDENTIALS_PATH: credentialsPath(),
SCOPES: newScopes.join(','),
});
} catch (err) {

View File

@@ -126,7 +126,7 @@ describe('migrate room registrations step', () => {
chat_jid: 'dc:legacy-room',
room_mode: 'tribunal',
mode_source: 'inferred',
owner_agent_type: 'codex',
owner_agent_type: 'claude-code',
},
]);
expect(
@@ -141,12 +141,12 @@ describe('migrate room registrations step', () => {
{
chat_jid: 'dc:legacy-room',
role: 'owner',
agent_type: 'codex',
agent_type: 'claude-code',
},
{
chat_jid: 'dc:legacy-room',
role: 'reviewer',
agent_type: 'claude-code',
agent_type: 'codex',
},
]);
expect(

View File

@@ -28,6 +28,19 @@ vi.mock('../src/group-folder.js', () => ({
isValidGroupFolder: isValidGroupFolderMock,
}));
// Registration now provisions the paired workspace (which shells out to git)
// and hot-reloads the running service. Stub both so the unit test stays hermetic
// and only verifies the registration delegation, not workspace/git side effects.
vi.mock('../src/paired-workspace-manager.js', () => ({
ensurePairedWorkspaceProvisioned: vi.fn(
() => '/tmp/ejclaw-groups/test-room/owner',
),
}));
vi.mock('../src/runtime-reload-signal.js', () => ({
signalEjclawReload: vi.fn(() => false),
}));
vi.mock('../src/logger.js', () => ({
logger: {
info: loggerInfoMock,
@@ -84,6 +97,7 @@ describe('register step', () => {
FOLDER: 'test-room',
CHANNEL: 'discord',
STATUS: 'success',
RELOADED: 'no',
LOG: 'logs/setup.log',
});
});