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.
- package/dist/commands/ai4ctf.js +1 -1
- package/dist/commands/arena-eai.js +1 -1
- package/dist/commands/connect.js +1 -1
- package/dist/commands/ctf.js +1 -1
- package/dist/commands/ctf4ai-demo.js +1 -1
- package/dist/commands/ctf4vla.js +1 -1
- package/dist/commands/exam.js +1 -1
- package/dist/index.js +355 -1
- package/dist/lib/access.js +184 -1
- package/dist/lib/arena-submit.js +21 -1
- package/dist/lib/budget.js +6 -1
- package/dist/lib/challenge-dir.js +16 -1
- package/dist/lib/comms.js +212 -1
- package/dist/lib/config.js +93 -1
- package/dist/lib/country-lang.js +39 -1
- package/dist/lib/demo-exam.js +478 -1
- package/dist/lib/demo-flags.js +27 -1
- package/dist/lib/demo-stats.js +62 -1
- package/dist/lib/demo2-progress.js +102 -1
- package/dist/lib/exam-client.js +54 -1
- package/dist/lib/exam-state.js +273 -1
- package/dist/lib/gemini.js +247 -1
- package/dist/lib/integrity-snapshot.js +88 -1
- package/dist/lib/interactive-spawn.js +55 -1
- package/dist/lib/ipynb-input.js +65 -1
- package/dist/lib/kernel-protocol.js +88 -1
- package/dist/lib/kernel.js +146 -2
- package/dist/lib/learn-curricula.js +309 -1
- package/dist/lib/learn-i18n.js +184 -1
- package/dist/lib/log-sync.js +155 -1
- package/dist/lib/logger.js +49 -1
- package/dist/lib/open-file.js +55 -1
- package/dist/lib/paper-upgrade.js +119 -1
- package/dist/lib/render-card.js +112 -1
- package/dist/lib/repl-asker.js +67 -1
- package/dist/lib/sample-runner.js +227 -1
- package/dist/lib/shell-split.js +69 -1
- package/dist/lib/sim-cooldown.js +75 -1
- package/dist/lib/theme.js +119 -1
- package/dist/lib/token-format.js +74 -1
- package/dist/lib/toolset-hash.js +48 -1
- package/dist/lib/translations-fetcher.js +95 -1
- package/dist/lib/ui.js +99 -1
- package/dist/lib/version.js +24 -1
- package/dist/postinstall.js +48 -1
- package/dist/repl.js +2251 -1
- package/dist/types/index.js +63 -1
- package/package.json +1 -1
package/dist/lib/gemini.js
CHANGED
|
@@ -1 +1,247 @@
|
|
|
1
|
-
import
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|
package/dist/lib/ipynb-input.js
CHANGED
|
@@ -1 +1,65 @@
|
|
|
1
|
-
|
|
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
|
-
|
|
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
|
+
}
|