icoa-cli 2.19.348 → 2.19.349

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/dist/commands/ai4ctf.js +1 -1
  2. package/dist/commands/arena-eai.js +1 -1
  3. package/dist/commands/connect.js +1 -1
  4. package/dist/commands/ctf.js +1 -1
  5. package/dist/commands/ctf4ai-demo.js +1 -1
  6. package/dist/commands/ctf4vla.js +1 -1
  7. package/dist/commands/exam.js +1 -1
  8. package/dist/index.js +355 -1
  9. package/dist/lib/access.js +184 -1
  10. package/dist/lib/arena-submit.js +21 -1
  11. package/dist/lib/budget.js +6 -1
  12. package/dist/lib/challenge-dir.js +16 -1
  13. package/dist/lib/comms.js +212 -1
  14. package/dist/lib/config.js +93 -1
  15. package/dist/lib/country-lang.js +39 -1
  16. package/dist/lib/demo-exam.js +478 -1
  17. package/dist/lib/demo-flags.js +27 -1
  18. package/dist/lib/demo-stats.js +62 -1
  19. package/dist/lib/demo2-progress.js +102 -1
  20. package/dist/lib/exam-client.js +54 -1
  21. package/dist/lib/exam-state.js +273 -1
  22. package/dist/lib/gemini.js +247 -1
  23. package/dist/lib/integrity-snapshot.js +88 -1
  24. package/dist/lib/interactive-spawn.js +55 -1
  25. package/dist/lib/ipynb-input.js +65 -1
  26. package/dist/lib/kernel-protocol.js +88 -1
  27. package/dist/lib/kernel.js +146 -2
  28. package/dist/lib/learn-curricula.js +309 -1
  29. package/dist/lib/learn-i18n.js +184 -1
  30. package/dist/lib/log-sync.js +155 -1
  31. package/dist/lib/logger.js +49 -1
  32. package/dist/lib/open-file.js +55 -1
  33. package/dist/lib/paper-upgrade.js +119 -1
  34. package/dist/lib/render-card.js +112 -1
  35. package/dist/lib/repl-asker.js +67 -1
  36. package/dist/lib/sample-runner.js +227 -1
  37. package/dist/lib/shell-split.js +69 -1
  38. package/dist/lib/sim-cooldown.js +75 -1
  39. package/dist/lib/theme.js +119 -1
  40. package/dist/lib/token-format.js +74 -1
  41. package/dist/lib/toolset-hash.js +48 -1
  42. package/dist/lib/translations-fetcher.js +95 -1
  43. package/dist/lib/ui.js +99 -1
  44. package/dist/lib/version.js +24 -1
  45. package/dist/postinstall.js +48 -1
  46. package/dist/repl.js +2251 -1
  47. package/dist/types/index.js +63 -1
  48. package/package.json +1 -1
@@ -1 +1,247 @@
1
- import{GoogleGenAI as e}from"@google/genai";import chalk from"chalk";import{readFileSync as t}from"node:fs";import{dirname as n,join as o}from"node:path";import{fileURLToPath as a}from"node:url";import{getConfig as r,saveConfig as s}from"./config.js";import{getRealExamState as i}from"./exam-state.js";const c=n(a(import.meta.url));let l=null;function u(){if(l)return l;try{const e=JSON.parse(t(o(c,"..","..","package.json"),"utf-8"));l=e.version||"unknown"}catch{l="unknown"}return l}export async function translateText(e,t){throw new Error("Local AI model path has been disabled (2026-04-24). ICOA provides all AI server-side — no API key setup needed. Use `ai4ctf` (chat) or `exam` (with integrated help/hint) instead.")}export function setApiKey(e){s({geminiApiKey:e})}export async function createChatSession(e,t){const n=r();let o=t||"You are an AI teammate in the ICOA cybersecurity CTF competition (International Cyber Olympiad in AI 2026, Sydney).\n\nYou're a friendly, knowledgeable cybersecurity partner — like a fellow competitor sitting next to the user. Be conversational, encouraging, and collaborative.\n\nRULES:\n- Help the competitor think through challenges, brainstorm approaches, explain concepts\n- You MAY discuss vulnerability types, tools, techniques, and methodologies\n- You MAY suggest approaches and help debug code\n- Do NOT provide complete working exploits or full solution scripts\n- Do NOT provide flags or flag fragments\n- Never output anything matching flag format: icoa{...}\n- If you don't know something, say so honestly\n- Keep responses concise unless the user asks for detail\n- When the user opens a challenge, use the context to give relevant advice";e&&(o+=`\n\nThe competitor is currently working on:\nChallenge: ${e.name}\nCategory: ${e.category}`),o+='\n\nINTEGRITY — give method, not the produced answer:\n- You may name the tool/command/technique (e.g. "this is base64; run base64 -d on the file").\n- Do NOT perform a solution step for the competitor and hand back its result: never decode,\n decrypt, deobfuscate, run, execute, or compute over data they paste or that is attached,\n and then reveal the produced value. They must run it themselves and read the output.\n- Do NOT reveal the exact final answer, and never output icoa{...} / flag{...} in any case.\n- If asked to "just give the answer" / "solve it" / "decode this and tell me", refuse and\n nudge them to do the step themselves.';const a=n.ctfdUrl||"https://practice.icoa2026.au",s=n.deviceFingerprint||"",c=n.geminiModel||"gemini-2.5-flash-lite",l=[];return{async sendMessage(e){l.push({role:"user",text:e});const t=i(),r={systemPrompt:o,messages:l,model:c,maxTokens:2048,deviceFingerprint:s};t?.session?.token&&(r.examToken=t.session.token);const p={"Content-Type":"application/json","User-Agent":`icoa-cli/${u()}`,"X-Device-Fingerprint":s};!r.examToken&&n.ctfdUrl&&n.token&&n.userName&&(r.account=n.userName,p.Authorization=`Token ${n.token}`);const d=e=>new Promise(t=>setTimeout(t,e)),h=[2e3,5e3];let m=null,g=null;for(let e=0;e<=h.length&&(m=await fetch(`${a}/api/icoa/ai/chat`,{method:"POST",headers:p,body:JSON.stringify(r),signal:AbortSignal.timeout(6e4)}),!m.ok&&429===m.status);e++)e<h.length?await d(h[e]):g=m;if(!m)throw new Error("AI proxy: no response");if(!m.ok){const e=(await m.json().catch(()=>({message:"AI proxy error"}))).message||`AI proxy returned ${m.status}`;if(401===m.status)throw new Error(`${chalk.yellow("⚠ ")}Exam token expired. Re-enter via \`exam <token>\`.`);if(403===m.status)throw new Error(chalk.yellow("⚠ ")+e);if(429===m.status)throw new Error(chalk.yellow("⏳ ")+e+chalk.gray(" (retried twice — quota burst, try again in ~30s)"));throw new Error(e)}const f=await m.json(),y=function(e){return e.replace(/icoa\{[^}]*\}/gi,"[FLAG REDACTED]")}(f.data?.text||""),w=f.data?.tokensUsed||0,v="number"==typeof f.data?.accountSpent?f.data.accountSpent:void 0,k="number"==typeof f.data?.accountCap?f.data.accountCap:void 0;return l.push({role:"model",text:y}),{text:y,tokensUsed:w,accountSpent:v,accountCap:k}}}}
1
+ import { GoogleGenAI } from '@google/genai';
2
+ import chalk from 'chalk';
3
+ import { readFileSync } from 'node:fs';
4
+ import { dirname, join } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { getConfig, saveConfig } from './config.js';
7
+ import { getRealExamState } from './exam-state.js';
8
+ const __dirname_gemini = dirname(fileURLToPath(import.meta.url));
9
+ let _cachedVersion = null;
10
+ function getCliVersion() {
11
+ if (_cachedVersion)
12
+ return _cachedVersion;
13
+ try {
14
+ const pkg = JSON.parse(readFileSync(join(__dirname_gemini, '..', '..', 'package.json'), 'utf-8'));
15
+ _cachedVersion = pkg.version || 'unknown';
16
+ }
17
+ catch {
18
+ _cachedVersion = 'unknown';
19
+ }
20
+ return _cachedVersion;
21
+ }
22
+ const SYSTEM_PROMPTS = {
23
+ A: `You are an AI assistant in a cybersecurity CTF competition called ICOA.
24
+ You are providing Level A (General Guidance) to a competitor.
25
+
26
+ STRICT RULES:
27
+ - Only answer conceptual questions
28
+ - Do NOT mention specific vulnerability names
29
+ - Do NOT provide any code, commands, or tool usage
30
+ - Do NOT mention specific attack techniques
31
+ - Use questions to guide the competitor toward their own discovery
32
+ - If the competitor asks you to solve the challenge, refuse and redirect
33
+ - Never output anything matching flag format: icoa{...}`,
34
+ B: `You are an AI assistant in ICOA CTF, providing Level B (Deep Analysis).
35
+
36
+ RULES:
37
+ - You MAY identify specific vulnerability types (e.g., "buffer overflow")
38
+ - You MAY suggest which category of tool to use (e.g., "a debugger")
39
+ - Do NOT provide complete commands or working code
40
+ - Do NOT provide exploit code or payloads
41
+ - Do NOT provide flags or flag fragments
42
+ - Never output anything matching: icoa{...}`,
43
+ C: `You are an AI assistant in ICOA CTF, providing Level C (Critical Assist).
44
+
45
+ RULES:
46
+ - You MAY provide the key conceptual breakthrough
47
+ - You MAY name specific algorithms or approaches
48
+ - Do NOT provide complete exploit code
49
+ - Do NOT provide the flag
50
+ - Never output anything matching: icoa{...}`,
51
+ };
52
+ function _buildSystemPrompt(level, context) {
53
+ let prompt = SYSTEM_PROMPTS[level];
54
+ if (context) {
55
+ prompt += `\n\nThe competitor is currently working on:\nChallenge: ${context.name}\nCategory: ${context.category}`;
56
+ }
57
+ prompt += METHOD_NOT_ANSWER_RULE;
58
+ return prompt;
59
+ }
60
+ function filterFlagPatterns(text) {
61
+ return text.replace(/icoa\{[^}]*\}/gi, '[FLAG REDACTED]');
62
+ }
63
+ // No DEFAULT_API_KEY in client code — keys stay server-side only.
64
+ // 2026-04-24: **Local model path disabled**. All AI routes through the
65
+ // server proxy at /api/icoa/ai/chat regardless of whether the user has
66
+ // their own GEMINI_API_KEY. See reference_ai_models.md / feedback_icoa_all_keys.md.
67
+ // ICOA centrally owns all AI keys; students do not need to configure anything.
68
+ const LOCAL_MODEL_DISABLED_MESSAGE = 'Local AI model path has been disabled (2026-04-24). ' +
69
+ 'ICOA provides all AI server-side — no API key setup needed. ' +
70
+ 'Use `ai4ctf` (chat) or `exam` (with integrated help/hint) instead.';
71
+ function getApiKey() {
72
+ // Returns empty string to disable Path A in createChatSession / generateHint.
73
+ // Preserved as a function to keep the call sites stable for future refactors.
74
+ return '';
75
+ }
76
+ function getClient(apiKey) {
77
+ return new GoogleGenAI({ apiKey });
78
+ }
79
+ /**
80
+ * @deprecated Runtime translation is disabled. Translations are pre-baked
81
+ * at build time via `panda/translate-*.js` scripts and shipped in
82
+ * `translations/<lang>/` — CLI never translates at runtime.
83
+ */
84
+ export async function translateText(_text, _targetLang) {
85
+ throw new Error(LOCAL_MODEL_DISABLED_MESSAGE);
86
+ }
87
+ export function setApiKey(key) {
88
+ saveConfig({ geminiApiKey: key });
89
+ }
90
+ const CHAT_SYSTEM_PROMPT = `You are an AI teammate in the ICOA cybersecurity CTF competition (International Cyber Olympiad in AI 2026, Sydney).
91
+
92
+ You're a friendly, knowledgeable cybersecurity partner — like a fellow competitor sitting next to the user. Be conversational, encouraging, and collaborative.
93
+
94
+ RULES:
95
+ - Help the competitor think through challenges, brainstorm approaches, explain concepts
96
+ - You MAY discuss vulnerability types, tools, techniques, and methodologies
97
+ - You MAY suggest approaches and help debug code
98
+ - Do NOT provide complete working exploits or full solution scripts
99
+ - Do NOT provide flags or flag fragments
100
+ - Never output anything matching flag format: icoa{...}
101
+ - If you don't know something, say so honestly
102
+ - Keep responses concise unless the user asks for detail
103
+ - When the user opens a challenge, use the context to give relevant advice`;
104
+ // Shared "method, not answer" rule appended to every system prompt. The
105
+ // server (token-api.py) enforces the authoritative copy of these rules so a
106
+ // modified/curl client can't strip them; this client copy keeps the stock CLI
107
+ // well-behaved without a server round-trip. Phrased to allow naming the
108
+ // command but forbid performing the step and returning its result.
109
+ const METHOD_NOT_ANSWER_RULE = `
110
+
111
+ INTEGRITY — give method, not the produced answer:
112
+ - You may name the tool/command/technique (e.g. "this is base64; run base64 -d on the file").
113
+ - Do NOT perform a solution step for the competitor and hand back its result: never decode,
114
+ decrypt, deobfuscate, run, execute, or compute over data they paste or that is attached,
115
+ and then reveal the produced value. They must run it themselves and read the output.
116
+ - Do NOT reveal the exact final answer, and never output icoa{...} / flag{...} in any case.
117
+ - If asked to "just give the answer" / "solve it" / "decode this and tell me", refuse and
118
+ nudge them to do the step themselves.`;
119
+ export async function createChatSession(context, customSystemPrompt) {
120
+ const config = getConfig();
121
+ const apiKey = getApiKey();
122
+ let systemPrompt = customSystemPrompt || CHAT_SYSTEM_PROMPT;
123
+ if (context) {
124
+ systemPrompt += `\n\nThe competitor is currently working on:\nChallenge: ${context.name}\nCategory: ${context.category}`;
125
+ }
126
+ // Always append the integrity rule (covers chat + exam custom prompts).
127
+ systemPrompt += METHOD_NOT_ANSWER_RULE;
128
+ // ─── Path A: user has their own key → direct Gemini SDK ───
129
+ if (apiKey) {
130
+ const modelName = config.geminiModel || 'gemini-2.5-flash-lite';
131
+ const ai = getClient(apiKey);
132
+ const chat = ai.chats.create({
133
+ model: modelName,
134
+ config: { systemInstruction: systemPrompt },
135
+ });
136
+ return {
137
+ async sendMessage(msg) {
138
+ const response = await chat.sendMessage({ message: msg });
139
+ const text = filterFlagPatterns(response.text ?? '');
140
+ const usage = response.usageMetadata;
141
+ const tokensUsed = usage?.totalTokenCount || (usage?.promptTokenCount || 0) + (usage?.candidatesTokenCount || 0);
142
+ return { text, tokensUsed };
143
+ },
144
+ };
145
+ }
146
+ // ─── Path B: no key → server proxy (key stays server-side) ───
147
+ const serverUrl = config.ctfdUrl || 'https://practice.icoa2026.au';
148
+ const fp = config.deviceFingerprint || '';
149
+ const modelName = config.geminiModel || 'gemini-2.5-flash-lite';
150
+ const messages = [];
151
+ return {
152
+ async sendMessage(msg) {
153
+ messages.push({ role: 'user', text: msg });
154
+ // Attach exam token if contestant is in a real exam — grants full
155
+ // 2048 maxTokens and higher rate limit. Demo/anonymous users rely on
156
+ // the User-Agent gate on the server side.
157
+ const realExam = getRealExamState();
158
+ const payload = {
159
+ systemPrompt,
160
+ messages,
161
+ model: modelName,
162
+ maxTokens: 2048,
163
+ deviceFingerprint: fp,
164
+ };
165
+ if (realExam?.session?.token) {
166
+ payload.examToken = realExam.session.token;
167
+ }
168
+ // CTFd-join account binding (2026-06-17, AU-camp audit). When connected
169
+ // to a CTFd event (no exam token), attribute AI usage to the logged-in
170
+ // account so the server can (a) audit per-student and (b) meter a
171
+ // per-account budget — a shared-NAT room can no longer pool unlimited
172
+ // AI. Ignored server-side when an exam token is present (exam-token
173
+ // budget wins); demo/practice has no CTFd login → no account → stays on
174
+ // the client 5k cap.
175
+ //
176
+ // 2026-06-20 (Olympic audit upgrade): also send the CTFd API token in an
177
+ // Authorization header so the server can VERIFY the account against CTFd's
178
+ // own tokens→users tables instead of trusting the self-reported username
179
+ // below. The header turns attribution from a forgeable signal into
180
+ // evidence (account_verified=1 server-side). payload.account stays as a
181
+ // fallback for any pre-upgrade server. Mirrors the /api/icoa/audit auth.
182
+ const reqHeaders = {
183
+ 'Content-Type': 'application/json',
184
+ 'User-Agent': `icoa-cli/${getCliVersion()}`,
185
+ 'X-Device-Fingerprint': fp,
186
+ };
187
+ if (!payload.examToken && config.ctfdUrl && config.token && config.userName) {
188
+ payload.account = config.userName;
189
+ reqHeaders.Authorization = `Token ${config.token}`;
190
+ }
191
+ // V2.19.185: 429 retry-with-backoff. UZ Paper A (2026-05-17) had a
192
+ // sustained Gemini upstream quota burst that caused 45 client-visible
193
+ // 429s. Server-side key rotation eventually recovers, so a short
194
+ // backoff before retrying transparently absorbs most transient dips
195
+ // without the contestant ever seeing an error. We retry up to twice
196
+ // with 2s and 5s backoff (max ~7s added latency on a worst-case path).
197
+ // 401/403 are surfaced immediately — they need contestant action.
198
+ const sleepMs = (ms) => new Promise((r) => setTimeout(r, ms));
199
+ const BACKOFFS_MS = [2000, 5000];
200
+ let res = null;
201
+ let lastErr = null;
202
+ for (let attempt = 0; attempt <= BACKOFFS_MS.length; attempt++) {
203
+ res = await fetch(`${serverUrl}/api/icoa/ai/chat`, {
204
+ method: 'POST',
205
+ headers: reqHeaders,
206
+ body: JSON.stringify(payload),
207
+ signal: AbortSignal.timeout(60_000),
208
+ });
209
+ if (res.ok || res.status !== 429)
210
+ break;
211
+ // 429 — try again after backoff if we have budget left
212
+ if (attempt < BACKOFFS_MS.length) {
213
+ await sleepMs(BACKOFFS_MS[attempt]);
214
+ continue;
215
+ }
216
+ // Out of retries — fall through to error handling below
217
+ lastErr = res;
218
+ }
219
+ if (!res)
220
+ throw new Error('AI proxy: no response');
221
+ if (!res.ok) {
222
+ const err = (await res.json().catch(() => ({ message: 'AI proxy error' })));
223
+ const msg = err.message || `AI proxy returned ${res.status}`;
224
+ if (res.status === 401) {
225
+ throw new Error(`${chalk.yellow('⚠ ')}Exam token expired. Re-enter via \`exam <token>\`.`);
226
+ }
227
+ if (res.status === 403) {
228
+ throw new Error(chalk.yellow('⚠ ') + msg);
229
+ }
230
+ if (res.status === 429) {
231
+ throw new Error(chalk.yellow('⏳ ') + msg + chalk.gray(' (retried twice — quota burst, try again in ~30s)'));
232
+ }
233
+ throw new Error(msg);
234
+ }
235
+ const json = (await res.json());
236
+ const text = filterFlagPatterns(json.data?.text || '');
237
+ const tokensUsed = json.data?.tokensUsed || 0;
238
+ // Server-authoritative cumulative per-account spend (CTFd-join comp mode).
239
+ // Present only when the server metered a verified account; the client uses
240
+ // it to render a TRUE budget bar instead of a per-session counter.
241
+ const accountSpent = typeof json.data?.accountSpent === 'number' ? json.data.accountSpent : undefined;
242
+ const accountCap = typeof json.data?.accountCap === 'number' ? json.data.accountCap : undefined;
243
+ messages.push({ role: 'model', text });
244
+ return { text, tokensUsed, accountSpent, accountCap };
245
+ },
246
+ };
247
+ }
@@ -1 +1,88 @@
1
- import{readFileSync as t}from"node:fs";import{join as n}from"node:path";import{homedir as o}from"node:os";import{getConfig as e,getBudget as i}from"./config.js";import{getCliVersion as r}from"./version.js";function s(n){try{return JSON.parse(t(n,"utf-8"))}catch{return null}}export async function postIntegritySnapshot(){try{const t=e();if("live"!==t.competitionState)return;const a=t.ctfdUrl;if(!a)return;const c=n(o(),".icoa"),p=i(),d={deviceFingerprint:t.deviceFingerprint||"",installSalt:t.installSalt||"",budget:{tokensUsed:p.tokensUsed,tokenCap:p.tokenCap},simCooldown:s(n(c,"sim-cooldown.json")),tokenBindings:s(n(c,"token-bindings.json"))},m={"Content-Type":"application/json","User-Agent":`icoa-cli/${r()}`,"X-Device-Fingerprint":t.deviceFingerprint||""};t.token&&t.userName&&(d.account=t.userName,m.Authorization=`Token ${t.token}`),await fetch(`${a}/api/icoa/snapshot`,{method:"POST",headers:m,body:JSON.stringify(d),signal:AbortSignal.timeout(8e3)})}catch{}}
1
+ // Submit-triggered integrity sampling (P1.5 detective layer).
2
+ //
3
+ // On the flag-submit path we fire a SECOND, fire-and-forget POST to our own
4
+ // server carrying a SCRUBBED snapshot of client-only-knowable state, anchored
5
+ // to a real server-recorded scoring event (the CTFd submission). The server
6
+ // debounces (first per 5-min window, server-side so a cheater can't suppress
7
+ // it), reconciles against its own authoritative ledger, and stores the row in
8
+ // an auditable SQLite table.
9
+ //
10
+ // This is DETECTIVE-ONLY: it never auto-judges, never blocks a submit, and
11
+ // never reveals any verdict to the client. It catches casual tampering
12
+ // (`echo > budget.json`, `rm sim-cooldown.json`) and cross-account fingerprint
13
+ // reuse. A sophisticated forger who also fakes consistent snapshots evades it —
14
+ // it COMPLEMENTS the server-authoritative scoring surfaces (§9 of
15
+ // docs/POST_COMPETITION_AUDIT_STANDARD.md), it does not replace them.
16
+ //
17
+ // HARD RULES honored here (see §11):
18
+ // - Gate on competitionState === 'live' (sample only during the contest).
19
+ // - NEVER upload secrets: config.accessToken / config.token are excluded.
20
+ // - Carry the CTFd account identity (Authorization: Token <t> + account) so
21
+ // the server can VERIFY against CTFd's tokens→users tables — mirrors the
22
+ // gemini.ts / /api/icoa/audit auth so attribution is evidence, not a
23
+ // forgeable self-report.
24
+ // - Fire-and-forget: any failure is swallowed so the submit UX is untouched.
25
+ import { readFileSync } from 'node:fs';
26
+ import { join } from 'node:path';
27
+ import { homedir } from 'node:os';
28
+ import { getConfig, getBudget } from './config.js';
29
+ import { getCliVersion } from './version.js';
30
+ function readJsonFile(path) {
31
+ try {
32
+ return JSON.parse(readFileSync(path, 'utf-8'));
33
+ }
34
+ catch {
35
+ return null;
36
+ }
37
+ }
38
+ /**
39
+ * Fire a scrubbed integrity snapshot to the server. Best-effort, non-blocking,
40
+ * and self-swallowing — callers may `await` it inside an existing try/catch
41
+ * without risking the submit flow.
42
+ */
43
+ export async function postIntegritySnapshot() {
44
+ try {
45
+ const config = getConfig();
46
+ // Gate: only sample during the live contest. Practice/learn traffic is
47
+ // non-scoring (§9) and carries no integrity interest.
48
+ if (config.competitionState !== 'live')
49
+ return;
50
+ const serverUrl = config.ctfdUrl;
51
+ if (!serverUrl)
52
+ return;
53
+ const icoaDir = join(homedir(), '.icoa');
54
+ const budget = getBudget();
55
+ // SCRUBBED payload — only client-only-knowable, non-secret state. The
56
+ // accessToken / token in config.json are deliberately NOT included.
57
+ const payload = {
58
+ deviceFingerprint: config.deviceFingerprint || '',
59
+ installSalt: config.installSalt || '',
60
+ budget: { tokensUsed: budget.tokensUsed, tokenCap: budget.tokenCap },
61
+ simCooldown: readJsonFile(join(icoaDir, 'sim-cooldown.json')),
62
+ tokenBindings: readJsonFile(join(icoaDir, 'token-bindings.json')),
63
+ };
64
+ const headers = {
65
+ 'Content-Type': 'application/json',
66
+ 'User-Agent': `icoa-cli/${getCliVersion()}`,
67
+ 'X-Device-Fingerprint': config.deviceFingerprint || '',
68
+ };
69
+ // CTFd-account identity — same mechanism as gemini.ts. Only attach when
70
+ // connected to a CTFd event; the server VERIFIES the token in-process
71
+ // (account_verified=1) and falls back to the self-reported account only
72
+ // when it can't resolve.
73
+ if (config.token && config.userName) {
74
+ payload.account = config.userName;
75
+ headers.Authorization = `Token ${config.token}`;
76
+ }
77
+ await fetch(`${serverUrl}/api/icoa/snapshot`, {
78
+ method: 'POST',
79
+ headers,
80
+ body: JSON.stringify(payload),
81
+ signal: AbortSignal.timeout(8000),
82
+ });
83
+ // Response intentionally ignored: detective-only, server reveals nothing.
84
+ }
85
+ catch {
86
+ // Swallow everything — the snapshot must never affect the submit UX.
87
+ }
88
+ }
@@ -1 +1,55 @@
1
- import{spawn as e}from"node:child_process";import{getMainRl as t}from"./main-rl.js";export function runInteractive(o,s=[],i={}){return new Promise(r=>{const n=t(),c=process.stdin,a=!!c.isTTY&&!!c.isRaw;if(n?.pause(),c.isTTY&&"function"==typeof c.setRawMode)try{c.setRawMode(!1)}catch{}const f=i.shell?e(o,{shell:!0,stdio:"inherit"}):e(o,s,{stdio:"inherit"});let d=!1;const l=e=>{if(!d){if(d=!0,c.isTTY&&"function"==typeof c.setRawMode&&a)try{c.setRawMode(!0)}catch{}n?.resume(),r(e)}};f.on("close",e=>l(e??0)),f.on("error",()=>l(1))})}
1
+ import { spawn } from 'node:child_process';
2
+ import { getMainRl } from './main-rl.js';
3
+ /**
4
+ * Run an interactive child process (nc / ncat / ssh) with the terminal fully
5
+ * released so the user's INPUT and Ctrl-C reach the child.
6
+ *
7
+ * Why this exists (BUG: pwn `connect` could read output but not send input and
8
+ * Ctrl-C was dead): when dispatched IN-PROCESS from the icoa REPL, the main
9
+ * readline holds stdin in RAW mode. A child spawned with `stdio: 'inherit'`
10
+ * then inherits a raw, non-blocking stdin → its reads see no keystrokes, and
11
+ * raw mode means Ctrl-C is delivered as a byte (0x03), never as SIGINT → the
12
+ * session looks frozen and won't quit. `execSync`/`execFileSync` never dropped
13
+ * raw mode, which is the bug.
14
+ *
15
+ * This mirrors repl.ts `runSystemCommand`'s proven TTY dance (pause the rl +
16
+ * `setRawMode(false)` + async spawn + restore), but pulls the active REPL
17
+ * readline via getMainRl() so it works BOTH inside the REPL and standalone
18
+ * (`icoa connect …`, where getMainRl() === null → pause/restore are no-ops).
19
+ *
20
+ * Pass a file + args (no shell — injection-safe, preferred for server-supplied
21
+ * host/port) OR set `shell: true` to run `file` as a full command string.
22
+ *
23
+ * Resolves with the child's exit code (1 on spawn error).
24
+ */
25
+ export function runInteractive(file, args = [], opts = {}) {
26
+ return new Promise((resolve) => {
27
+ const rl = getMainRl();
28
+ const stdin = process.stdin;
29
+ const wasRaw = stdin.isTTY ? !!stdin.isRaw : false;
30
+ rl?.pause();
31
+ if (stdin.isTTY && typeof stdin.setRawMode === 'function') {
32
+ try {
33
+ stdin.setRawMode(false);
34
+ }
35
+ catch { }
36
+ }
37
+ const child = opts.shell ? spawn(file, { shell: true, stdio: 'inherit' }) : spawn(file, args, { stdio: 'inherit' });
38
+ let settled = false;
39
+ const restore = (code) => {
40
+ if (settled)
41
+ return;
42
+ settled = true;
43
+ if (stdin.isTTY && typeof stdin.setRawMode === 'function' && wasRaw) {
44
+ try {
45
+ stdin.setRawMode(true);
46
+ }
47
+ catch { }
48
+ }
49
+ rl?.resume();
50
+ resolve(code);
51
+ };
52
+ child.on('close', (code) => restore(code ?? 0));
53
+ child.on('error', () => restore(1));
54
+ });
55
+ }
@@ -1 +1,65 @@
1
- export function isBlockOpener(t){const e=t.split("#")[0];return/:\s*$/.test(e)}export function shouldExecuteCell(t){if(0===t.length)return!1;const e=t[t.length-1];return t.length>1?""===e.trim():!(""===e.trim()||isBlockOpener(e)||/\\\s*$/.test(e)||function(t){let e=0;for(const n of t)"("===n||"["===n||"{"===n?e++:")"!==n&&"]"!==n&&"}"!==n||e--;return e}(e)>0)}
1
+ /**
2
+ * ipynb-input — pure cell-input decision logic for the `icoa ipynb` REPL
3
+ * (Phase 1 UI of the CLI notebook arena).
4
+ *
5
+ * IPython-style model: a one-liner runs the moment you press Enter; a block
6
+ * (def/for/if/…, an open bracket, or a trailing backslash) keeps accepting
7
+ * lines until you end it with a blank line. Kept pure so the rule is unit-
8
+ * tested without a kernel or readline.
9
+ */
10
+ /**
11
+ * Does this line open a multi-line block? True when its CODE portion (trailing
12
+ * `#` comment stripped) ends with a colon. Naive comment-strip — good enough for
13
+ * opener detection (a real block opener never hides its colon in a comment).
14
+ */
15
+ export function isBlockOpener(line) {
16
+ const code = line.split('#')[0];
17
+ return /:\s*$/.test(code);
18
+ }
19
+ /** Trailing line-continuation backslash. */
20
+ function hasLineContinuation(line) {
21
+ return /\\\s*$/.test(line);
22
+ }
23
+ /**
24
+ * Count of unclosed brackets across the buffer. Naive: ignores brackets inside
25
+ * string literals (rare in interactive one-liners; a blank line always forces
26
+ * execution as the escape hatch). Positive → still open.
27
+ */
28
+ function openBracketDepth(code) {
29
+ let depth = 0;
30
+ for (const ch of code) {
31
+ if (ch === '(' || ch === '[' || ch === '{')
32
+ depth++;
33
+ else if (ch === ')' || ch === ']' || ch === '}')
34
+ depth--;
35
+ }
36
+ return depth;
37
+ }
38
+ /**
39
+ * Given the lines accumulated SO FAR (including the just-entered line), decide
40
+ * whether to execute the cell now or keep taking input.
41
+ *
42
+ * - empty buffer → nothing to run (false),
43
+ * - multi-line cell → execute as soon as the last line is blank,
44
+ * - single line → execute unless it opens a block / ends with `\` / has an
45
+ * unbalanced bracket.
46
+ */
47
+ export function shouldExecuteCell(lines) {
48
+ if (lines.length === 0)
49
+ return false;
50
+ const last = lines[lines.length - 1];
51
+ if (lines.length > 1) {
52
+ // In multi-line mode a blank line is the universal "run it now".
53
+ return last.trim() === '';
54
+ }
55
+ // Single line.
56
+ if (last.trim() === '')
57
+ return false;
58
+ if (isBlockOpener(last))
59
+ return false;
60
+ if (hasLineContinuation(last))
61
+ return false;
62
+ if (openBracketDepth(last) > 0)
63
+ return false;
64
+ return true;
65
+ }
@@ -1 +1,88 @@
1
- const e=new Set(["ready","stream","result","display","error","done","fatal"]);export function parseKernelEvent(t){const a=t.trim();if(!a)return null;let r;try{r=JSON.parse(a)}catch{return null}if(!r||"object"!=typeof r)return null;const n=r.type;return"string"==typeof n&&e.has(n)?r:null}export function foldCellOutputs(e){const t=[];let a=!0,r=null;for(const n of e)switch(n.type){case"stream":{const e=t[t.length-1];e&&"stream"===e.kind&&e.name===n.name?e.text+=n.text:t.push({kind:"stream",name:n.name,text:n.text});break}case"result":t.push({kind:"result",text:n.text,...n.data?{data:n.data}:{}});break;case"display":t.push({kind:"display",data:n.data});break;case"error":a=!1,t.push({kind:"error",ename:n.ename,evalue:n.evalue,traceback:n.traceback});break;case"done":r=n.count;break;case"fatal":a=!1,t.push({kind:"error",ename:"KernelError",evalue:n.message,traceback:[n.message]})}return{outputs:t,ok:a,execCount:r}}
1
+ /**
2
+ * Kernel protocol — the pure Node side of the Node ↔ Python-bridge boundary
3
+ * (Phase 1 of the CLI notebook arena, see `project_cli_notebook_arena_plan`).
4
+ *
5
+ * icoa is TypeScript/Node, but Jupyter kernels are driven by `jupyter_client`
6
+ * (Python). So a thin Python bridge (shipped alongside, run from the aienv
7
+ * venv) owns the kernel and speaks a line-delimited JSON protocol to Node: one
8
+ * event object per stdout line. This module parses those lines and folds them
9
+ * into renderable cell outputs.
10
+ *
11
+ * Keeping this layer pure (no I/O) means the wire contract is unit-tested
12
+ * without spawning Python — and it stays identical whether the bridge drives a
13
+ * LOCAL kernel (Phase 1) or a REMOTE one over a Kernel Gateway (Phase 2/4). The
14
+ * Node side never changes; only the bridge's connection does.
15
+ */
16
+ const KNOWN_TYPES = new Set(['ready', 'stream', 'result', 'display', 'error', 'done', 'fatal']);
17
+ /**
18
+ * Parse one stdout line from the bridge into a KernelEvent. Returns null for
19
+ * blank lines, non-JSON, or JSON without a recognized `type` (the bridge may
20
+ * interleave incidental output; we ignore anything not in the protocol).
21
+ */
22
+ export function parseKernelEvent(line) {
23
+ const trimmed = line.trim();
24
+ if (!trimmed)
25
+ return null;
26
+ let obj;
27
+ try {
28
+ obj = JSON.parse(trimmed);
29
+ }
30
+ catch {
31
+ return null;
32
+ }
33
+ if (!obj || typeof obj !== 'object')
34
+ return null;
35
+ const type = obj.type;
36
+ if (typeof type !== 'string' || !KNOWN_TYPES.has(type))
37
+ return null;
38
+ return obj;
39
+ }
40
+ /**
41
+ * Fold a cell's event stream into renderable outputs.
42
+ *
43
+ * - consecutive `stream` events of the SAME name coalesce into one text block
44
+ * (matches Jupyter's stdout/stderr batching → clean rendering),
45
+ * - `result` / `display` / `error` pass through as discrete blocks,
46
+ * - any `error` sets ok=false,
47
+ * - `done.count` becomes execCount (null if the cell never finished).
48
+ */
49
+ export function foldCellOutputs(events) {
50
+ const outputs = [];
51
+ let ok = true;
52
+ let execCount = null;
53
+ for (const e of events) {
54
+ switch (e.type) {
55
+ case 'stream': {
56
+ const last = outputs[outputs.length - 1];
57
+ if (last && last.kind === 'stream' && last.name === e.name) {
58
+ last.text += e.text;
59
+ }
60
+ else {
61
+ outputs.push({ kind: 'stream', name: e.name, text: e.text });
62
+ }
63
+ break;
64
+ }
65
+ case 'result':
66
+ outputs.push({ kind: 'result', text: e.text, ...(e.data ? { data: e.data } : {}) });
67
+ break;
68
+ case 'display':
69
+ outputs.push({ kind: 'display', data: e.data });
70
+ break;
71
+ case 'error':
72
+ ok = false;
73
+ outputs.push({ kind: 'error', ename: e.ename, evalue: e.evalue, traceback: e.traceback });
74
+ break;
75
+ case 'done':
76
+ execCount = e.count;
77
+ break;
78
+ case 'fatal':
79
+ ok = false;
80
+ outputs.push({ kind: 'error', ename: 'KernelError', evalue: e.message, traceback: [e.message] });
81
+ break;
82
+ default:
83
+ // 'ready' and any future event types carry no cell output.
84
+ break;
85
+ }
86
+ }
87
+ return { outputs, ok, execCount };
88
+ }