Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion bin/cli.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -559,7 +559,7 @@ async function main() {
const pollerApiKey = config?.poller?.api_key || config?.poller?.apiKey || config?.intent?.apiKey || process.env.ANTIGRAVITY_API_KEY;
const pollerHandle = config?.poller?.handle;
if (!pollerRooms || !pollerApiKey || !pollerHandle) {
console.error('Error: poller.rooms, poller.api_key, and poller.handle must be set in config');
console.error('Error: poller.rooms, poller.api_key (or poller.api_key_file), and poller.handle must be set in config');
process.exit(1);
}
await startRoomPoller({
Expand Down
4 changes: 3 additions & 1 deletion bin/iak-degradation-watch.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
// Flags: --once (single tick, for tests/cron), --dry-run (print, don't post).

import { readFileSync, readdirSync, statSync } from 'node:fs';
import { resolveSecretFiles } from '../src/config.mjs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';

Expand All @@ -47,8 +48,9 @@ function loadConfig() {
if (!watch || !Array.isArray(watch.agents) || watch.agents.length === 0) {
throw new Error('config missing degradation_watch.agents');
}
resolveSecretFiles(cfg);
const apiKey = watch.api_key || cfg.poller?.api_key;
if (!apiKey) throw new Error('no api key (degradation_watch.api_key or poller.api_key)');
if (!apiKey) throw new Error('no api key (degradation_watch.api_key[_file] or poller.api_key[_file])');
return {
intervalSec: watch.interval_sec || 300,
alertRoom: watch.alert_room || 'thinkoff-development',
Expand Down
3 changes: 2 additions & 1 deletion config/codex.desktop.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@
"feature-admin-planning",
"lattice-qcd"
],
"api_key": "antfarm_xxx",
"api_key_file": "/Users/you/.iak/codexmb_api_key.txt",
"heartbeat_file": "/tmp/iak-poller.heartbeat",
"handle": "@CodexMB",
"interval_sec": 60,
"nudge_mode": "command",
Expand Down
16 changes: 16 additions & 0 deletions scripts/codex-webhook-supervisor.sh
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ PORT=8791
INTERVAL=30
CF_LOG=/tmp/codex-cloudflared-webhook.log
LOG=/tmp/codex-webhook-supervisor.log
# The room poller's heartbeat (poller.heartbeat_file in the IAK config). The
# nudge refuses to type while it is stale, and the health alert below posts
# once when it goes stale and once when it returns (issue #86).
POLLER_HEARTBEAT="${IAK_POLLER_HEARTBEAT:-/tmp/iak-poller.heartbeat}"
POLLER_ERR_LOG="${IAK_POLLER_ERR_LOG:-/tmp/codexmb.poller.err}"
HEALTH_ALERT="$HOME/ide-agent-kit/scripts/poller-health-alert.mjs"

log(){ echo "[$(date '+%F %T')] $*" | tee -a "$LOG"; }

Expand Down Expand Up @@ -58,6 +64,7 @@ start_receiver(){
WEBHOOK_WAKE_LOG="/tmp/codex-webhook-wake.log" \
IAK_CODEX_APP_NAME="ChatGPT" \
IAK_NUDGE_TEXT="check rooms [codex]" \
IAK_POLLER_HEARTBEAT="$POLLER_HEARTBEAT" \
"$NODE_BIN" "$RECEIVER" >>/tmp/codex-webhook-wake.log 2>&1 &
sleep 1
if kill -0 $! 2>/dev/null; then log "receiver started"; else log "receiver FAILED to start"; fi
Expand Down Expand Up @@ -108,9 +115,18 @@ URL=$(get_url)
# (the PUT is idempotent).
REREGISTER_EVERY="${REREGISTER_EVERY:-30}"
LOOPS=0
poller_health(){
[ -f "$HEALTH_ALERT" ] || return 0
# Key via environment only (same rule as register()).
IAK_ALERT_KEY="$KEY" IAK_POLLER_HEARTBEAT="$POLLER_HEARTBEAT" IAK_POLLER_ERR_LOG="$POLLER_ERR_LOG" \
IAK_ALERT_LABEL="@codexmb room poller" \
"$(command -v node || echo /usr/local/bin/node)" "$HEALTH_ALERT" >>"$LOG" 2>&1 || true
}

while true; do
sleep "$INTERVAL"
start_receiver
poller_health
if ! pgrep -f "cloudflared tunnel --url http://127.0.0.1:$PORT" >/dev/null; then
log "cloudflared died; restarting"
start_tunnel
Expand Down
84 changes: 84 additions & 0 deletions scripts/poller-health-alert.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
#!/usr/bin/env node
// SPDX-License-Identifier: AGPL-3.0-only
// Post ONE room alert when the room poller stops heartbeating, and one
// all-clear when it comes back. Run from a supervisor loop; idempotent via a
// state file. A launchd job with KeepAlive that dies on start restarts every
// few seconds and writes only to a log nobody reads (1134 lines on
// 2026-09-01, issue #86) - this is the line that reaches the phone instead.
//
// Env: IAK_ALERT_KEY (required; the room API key, never an argv),
// IAK_POLLER_HEARTBEAT (default /tmp/iak-poller.heartbeat),
// IAK_POLLER_MAX_AGE_SEC (default 180),
// IAK_POLLER_ERR_LOG (optional; last line is quoted in the alert),
// IAK_ALERT_ROOM (default thinkoff-development),
// IAK_ALERT_STATE (default /tmp/iak-poller-alert.state),
// IAK_ALERT_BASE (default https://groupmind.one/api/v1),
// IAK_ALERT_LABEL (default "room poller", names the job in the text),
// IAK_ALERT_TIMEOUT_MS (default 15000; a stalled POST must not block the
// supervisor loop - codex review of PR #87).
import { existsSync, statSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs';

const key = process.env.IAK_ALERT_KEY;
const heartbeat = process.env.IAK_POLLER_HEARTBEAT || '/tmp/iak-poller.heartbeat';
const maxAge = Number(process.env.IAK_POLLER_MAX_AGE_SEC || 180);
const errLog = process.env.IAK_POLLER_ERR_LOG || '';
const room = process.env.IAK_ALERT_ROOM || 'thinkoff-development';
const stateFile = process.env.IAK_ALERT_STATE || '/tmp/iak-poller-alert.state';
const base = (process.env.IAK_ALERT_BASE || 'https://groupmind.one/api/v1').replace(/\/$/, '');
const label = process.env.IAK_ALERT_LABEL || 'room poller';
const timeoutMs = Number(process.env.IAK_ALERT_TIMEOUT_MS || 15000);

if (!key) {
console.error('poller-health-alert: IAK_ALERT_KEY missing');
process.exit(2);
}

export function heartbeatAge(path, now = Date.now()) {
if (!existsSync(path)) return Infinity;
return Math.round((now - statSync(path).mtimeMs) / 1000);
}

export function lastLine(path) {
try {
const lines = readFileSync(path, 'utf8').split('\n').filter(Boolean);
return lines.length ? lines[lines.length - 1].slice(0, 300) : '';
} catch {
return '';
}
}

async function post(body) {
const res = await fetch(`${base}/rooms/${encodeURIComponent(room)}/messages`, {
method: 'POST',
headers: { 'X-API-Key': key, 'Content-Type': 'application/json' },
body: JSON.stringify({ body }),
signal: AbortSignal.timeout(timeoutMs)
});
if (!res.ok) throw new Error(`room post failed: HTTP ${res.status}`);
}

const age = heartbeatAge(heartbeat);
const down = age > maxAge;
const alerted = existsSync(stateFile);

// A failed or timed-out post leaves the state untouched, so the next loop
// simply tries again; the supervisor never waits longer than timeoutMs.
try {
if (down && !alerted) {
const err = errLog ? lastLine(errLog) : '';
const since = age === Infinity ? 'no heartbeat file' : `last heartbeat ${age}s ago`;
await post(`⚠️ ${label} is down (${since}, heartbeat ${heartbeat}).` + (err ? `\nLast error: ${err}` : '') +
'\nGUI nudges are suspended until it heartbeats again; this alert is posted once.');
writeFileSync(stateFile, new Date().toISOString() + '\n');
console.log('alert posted');
} else if (!down && alerted) {
await post(`✅ ${label} is back (heartbeat ${age}s old).`);
unlinkSync(stateFile);
console.log('all-clear posted');
} else {
console.log(down ? 'down, already alerted' : 'healthy');
}
} catch (e) {
console.error(`poller-health-alert: post failed, will retry next loop: ${e.message}`);
process.exit(1);
}
48 changes: 45 additions & 3 deletions src/config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { readFileSync, existsSync } from 'node:fs';
import { resolve } from 'node:path';
import { homedir } from 'node:os';

const DEFAULT_CONFIG = {
listen: { host: '127.0.0.1', port: 8787 },
Expand Down Expand Up @@ -75,11 +76,50 @@ const DEFAULT_CONFIG = {
}
};

// Secrets may be given as `<name>_file` instead of inline: the file's trimmed
// contents become `<name>`. A file reference is the safer default for a key
// (it never lands in a config that gets pasted into a room), and codex.json was
// written that way - which the CLI silently did not read, so its poller
// crash-looped 1134 times behind launchd KeepAlive (issue #86, 2026-09-01).
// An inline value wins when both are present; a missing file is an error with
// the path in it, never a silent empty key.
const SECRET_FILE_FIELDS = [
['poller', 'api_key'],
['dm_poller', 'api_key'],
['xfor', 'api_key'],
['intent', 'apiKey'],
['degradation_watch', 'api_key']
];

export function readSecretFile(path) {
const expanded = path.startsWith('~/') ? resolve(homedir(), path.slice(2)) : resolve(path);
if (!existsSync(expanded)) throw new Error(`secret file not found: ${expanded}`);
const value = readFileSync(expanded, 'utf8').trim();
if (!value) throw new Error(`secret file is empty: ${expanded}`);
return value;
}

export function resolveSecretFiles(cfg, fields = SECRET_FILE_FIELDS) {
for (const [section, key] of fields) {
const block = cfg?.[section];
if (!block || typeof block !== 'object') continue;
const fileKey = `${key === 'apiKey' ? 'api_key' : key}_file`;
const file = block[fileKey];
// Either spelling counts as an inline value: the CLI still honours the
// legacy camelCase `apiKey`, so a file must not overrule it either
// (codex review of PR #87).
const inline = block[key] || block.api_key || block.apiKey;
if (!file || inline) continue;
block[key] = readSecretFile(file);
}
return cfg;
}

export function loadConfig(configPath) {
const p = resolve(configPath || 'ide-agent-kit.json');
if (!existsSync(p)) return { ...DEFAULT_CONFIG };
const raw = JSON.parse(readFileSync(p, 'utf8'));
return {
return resolveSecretFiles({
listen: { ...DEFAULT_CONFIG.listen, ...raw.listen },
queue: { ...DEFAULT_CONFIG.queue, ...raw.queue },
receipts: { ...DEFAULT_CONFIG.receipts, ...raw.receipts },
Expand Down Expand Up @@ -113,6 +153,8 @@ export function loadConfig(configPath) {
intent: raw.intent || {},
memory_api: raw.memory_api || {},
moltbook: raw.moltbook || {},
mcp: raw.mcp || {}
};
mcp: raw.mcp || {},
xfor: raw.xfor || {},
degradation_watch: raw.degradation_watch || {}
});
}
20 changes: 20 additions & 0 deletions src/team-relay/room-poller.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -151,8 +151,26 @@ export function checkRoomMessages(config) {
}
}

const HEARTBEAT_FILE_DEFAULT = '/tmp/iak-poller.heartbeat';

// The poller's liveness signal for everything that must not act while it is
// down: the GUI nudge (a nudge with no seen-state re-answers every open
// mention) and the supervisor's one-time alert. A crash-looping launchd job
// leaves no other trace a script can read (issue #86). This is the module the
// CLI actually runs - src/room-poller.mjs is the unimported legacy copy
// (codex review of PR #87 caught the heartbeat landing there first).
export function writeHeartbeat(path) {
try {
writeFileSync(path, new Date().toISOString() + '\n');
return true;
} catch {
return false;
}
}

export async function startRoomPoller({ rooms, apiKey, handle, interval, config, sessionOpt }) {
const seenFile = config?.poller?.seen_file || SEEN_FILE_DEFAULT;
const heartbeatFile = config?.poller?.heartbeat_file || HEARTBEAT_FILE_DEFAULT;
const notifyFile = config?.poller?.notification_file || NOTIFY_FILE_DEFAULT;
const queuePath = config?.queue?.path || './ide-agent-queue.jsonl';
const session = sessionOpt || config?.tmux?.ide_session || config?.tmux?.default_session || 'claude';
Expand Down Expand Up @@ -202,6 +220,7 @@ export async function startRoomPoller({ rooms, apiKey, handle, interval, config,
console.log(` nudge command: ${nudgeCommandText || '(missing)'}`);
}
console.log(` seen file: ${seenFile}`);
console.log(` heartbeat: ${heartbeatFile}`);
if (dmEnabled) {
console.log(` direct messages: enabled`);
console.log(` dm handle: ${dmHandle}`);
Expand Down Expand Up @@ -248,6 +267,7 @@ export async function startRoomPoller({ rooms, apiKey, handle, interval, config,
if (roomPollInFlight) return;
roomPollInFlight = true;
try {
writeHeartbeat(heartbeatFile);
let newCount = 0;
let hasOwnerMessage = false;
let hasMention = false;
Expand Down
45 changes: 44 additions & 1 deletion test/config.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { strict as assert } from 'node:assert';
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { loadConfig } from '../src/config.mjs';
import { loadConfig, resolveSecretFiles } from '../src/config.mjs';

const tempPaths = [];

Expand Down Expand Up @@ -106,3 +106,46 @@ describe('config', () => {
assert.equal(cfg.moltbook.accounts[0].name, 'claudemm');
});
});

describe('secret file references (issue #86)', () => {
let dir;
afterEach(() => { if (dir) rmSync(dir, { recursive: true, force: true }); dir = null; });

it('reads poller.api_key_file into poller.api_key, trimmed', () => {
dir = mkdtempSync(join(tmpdir(), 'iak-secret-'));
writeFileSync(join(dir, 'key.txt'), ' xfb_secret_from_file\n');
writeFileSync(join(dir, 'c.json'), JSON.stringify({ poller: { rooms: 'r', handle: '@x', api_key_file: join(dir, 'key.txt') } }));
const cfg = loadConfig(join(dir, 'c.json'));
assert.equal(cfg.poller.api_key, 'xfb_secret_from_file');
});

it('covers dm_poller, xfor and intent too', () => {
dir = mkdtempSync(join(tmpdir(), 'iak-secret-'));
writeFileSync(join(dir, 'k'), 'K1');
const cfg = loadConfig(join(dir, 'c.json'));
assert.equal(cfg.poller.api_key, '');
writeFileSync(join(dir, 'c.json'), JSON.stringify({
dm_poller: { api_key_file: join(dir, 'k') },
xfor: { api_key_file: join(dir, 'k') },
intent: { api_key_file: join(dir, 'k') }
}));
const c2 = loadConfig(join(dir, 'c.json'));
assert.equal(c2.dm_poller.api_key, 'K1');
assert.equal(c2.xfor.api_key, 'K1');
assert.equal(c2.intent.apiKey, 'K1');
});

it('an inline key wins over the file, and a missing file names its path', () => {
dir = mkdtempSync(join(tmpdir(), 'iak-secret-'));
writeFileSync(join(dir, 'k'), 'FILEKEY');
const both = resolveSecretFiles({ poller: { api_key: 'INLINE', api_key_file: join(dir, 'k') } });
assert.equal(both.poller.api_key, 'INLINE');
// legacy camelCase spelling is inline too (codex review of PR #87)
const legacy = resolveSecretFiles({ poller: { apiKey: 'LEGACY', api_key_file: join(dir, 'k') } });
assert.equal(legacy.poller.apiKey, 'LEGACY');
assert.equal(legacy.poller.api_key, undefined);
assert.throws(() => resolveSecretFiles({ poller: { api_key_file: join(dir, 'nope') } }), /secret file not found: .*nope/);
writeFileSync(join(dir, 'empty'), '\n');
assert.throws(() => resolveSecretFiles({ poller: { api_key_file: join(dir, 'empty') } }), /secret file is empty/);
});
});
Loading
Loading