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/index.js
CHANGED
|
@@ -1,2 +1,356 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import
|
|
2
|
+
import { Command } from 'commander';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
import { registerCtfCommands } from './commands/ctf.js';
|
|
5
|
+
import { registerRefCommand } from './commands/ref.js';
|
|
6
|
+
import { registerShellCommand } from './commands/shell.js';
|
|
7
|
+
import { registerFilesCommand } from './commands/files.js';
|
|
8
|
+
import { registerViewCommand } from './commands/view.js';
|
|
9
|
+
import { registerConnectCommand } from './commands/connect.js';
|
|
10
|
+
import { registerChallengeCommand } from './commands/challenge.js';
|
|
11
|
+
import { registerNoteCommand } from './commands/note.js';
|
|
12
|
+
import { registerLogCommand } from './commands/log.js';
|
|
13
|
+
import { registerLangCommand } from './commands/lang.js';
|
|
14
|
+
import { registerSetupCommand } from './commands/setup.js';
|
|
15
|
+
import { registerEnvCommand } from './commands/env.js';
|
|
16
|
+
import { registerAienvCommand } from './commands/aienv.js';
|
|
17
|
+
import { registerIpynbCommand } from './commands/ipynb.js';
|
|
18
|
+
import { registerAi4ctfCommand } from './commands/ai4ctf.js';
|
|
19
|
+
import { registerExamCommand } from './commands/exam.js';
|
|
20
|
+
import { registerCtf4aiDemoCommand } from './commands/ctf4ai-demo.js';
|
|
21
|
+
import { registerThemeCommand } from './commands/theme.js';
|
|
22
|
+
import { registerLearnCommand } from './commands/learn.js';
|
|
23
|
+
import { registerCtf4VlaCommand } from './commands/ctf4vla.js';
|
|
24
|
+
import { registerDemo2Command } from './commands/demo2.js';
|
|
25
|
+
import { registerSimCommand } from './commands/sim.js';
|
|
26
|
+
import { registerArenaCommand } from './commands/arena.js';
|
|
27
|
+
import { getConfig, saveConfig } from './lib/config.js';
|
|
28
|
+
import { startRepl } from './repl.js';
|
|
29
|
+
import { RELAUNCH_CODE } from './lib/menu-nav.js';
|
|
30
|
+
import { setTerminalTheme } from './lib/theme.js';
|
|
31
|
+
import { checkForUpdates } from './lib/update-check.js';
|
|
32
|
+
import { detectIcoaInstalls } from './lib/platform.js';
|
|
33
|
+
import { ICOA_BIG } from './lib/banner.js';
|
|
34
|
+
import { readFileSync } from 'node:fs';
|
|
35
|
+
import { fileURLToPath } from 'node:url';
|
|
36
|
+
import { dirname, join } from 'node:path';
|
|
37
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
38
|
+
const PKG_VERSION = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf-8')).version;
|
|
39
|
+
const LINE = chalk.cyan(' ─────────────────────────────────────────────────────');
|
|
40
|
+
const BANNER = `
|
|
41
|
+
${LINE}
|
|
42
|
+
|
|
43
|
+
${ICOA_BIG.map((l) => ` ${chalk.bold.white(l)}`).join('\n')}
|
|
44
|
+
|
|
45
|
+
${chalk.yellow('International Cyber Olympiad in AI 2026')}
|
|
46
|
+
${chalk.bold.magenta("The World's First AI-Native CLI Operating System")}
|
|
47
|
+
${chalk.bold.magenta('for Cybersecurity & AI Security Competition')}
|
|
48
|
+
${chalk.bold.magenta('and Olympiad for K-12')}
|
|
49
|
+
|
|
50
|
+
${chalk.green.bold('AI4CTF')} ${chalk.gray('[Day 1]')} ${chalk.white('AI as your teammate')}
|
|
51
|
+
${chalk.red.bold('CTF4AI')} ${chalk.gray('[Day 2]')} ${chalk.white('Challenge & evaluate AI systems')}
|
|
52
|
+
${chalk.cyan.bold('CTF4EAI')} ${chalk.gray('[Pioneer]')} ${chalk.white('Embodied AI security')}
|
|
53
|
+
${chalk.bold.yellow('AI is your teammate. AI is your target. AI is embodied.')}
|
|
54
|
+
|
|
55
|
+
${chalk.white('Sydney, Australia')} ${chalk.gray('Jun 27 - Jul 2, 2026')}
|
|
56
|
+
${chalk.cyan.underline('https://icoa2026.au')}
|
|
57
|
+
|
|
58
|
+
${chalk.gray(`CLI-Native Competition Terminal v${PKG_VERSION}`)}
|
|
59
|
+
|
|
60
|
+
${LINE}
|
|
61
|
+
`;
|
|
62
|
+
// Global error handlers
|
|
63
|
+
process.on('uncaughtException', (err) => {
|
|
64
|
+
if (err.message === '__REPL_NO_EXIT__')
|
|
65
|
+
return;
|
|
66
|
+
console.error(chalk.red('Error:'), err.message);
|
|
67
|
+
process.exit(1);
|
|
68
|
+
});
|
|
69
|
+
process.on('unhandledRejection', (reason) => {
|
|
70
|
+
const msg = reason instanceof Error ? reason.message : String(reason);
|
|
71
|
+
if (msg === '__REPL_NO_EXIT__')
|
|
72
|
+
return;
|
|
73
|
+
console.error(chalk.red('Error:'), msg);
|
|
74
|
+
process.exit(1);
|
|
75
|
+
});
|
|
76
|
+
// ─── Single-process supervisor (fixes the menu-bounce slowdown) ────────────
|
|
77
|
+
// Interactive sessions relaunch on `menu`. The old approach (returnToMainMenu
|
|
78
|
+
// spawning a child while the parent stayed alive) STACKED node processes: K
|
|
79
|
+
// bounces left K+1 live processes all holding the inherited TTY, which slowed
|
|
80
|
+
// the CLI to a crawl and dropped keystrokes ("learn" → "lan"). A 4-deep chain
|
|
81
|
+
// was observed in prod (2026-06-04).
|
|
82
|
+
//
|
|
83
|
+
// Fix: a thin supervisor runs the real CLI as a child via spawnSync and
|
|
84
|
+
// relaunches it ONLY when it exits with RELAUNCH_CODE (what `menu` now does).
|
|
85
|
+
// Exactly one worker is ever alive, so navigation stays fast regardless of how
|
|
86
|
+
// many times the user bounces to the menu.
|
|
87
|
+
//
|
|
88
|
+
// Scoped to interactive TTY sessions only — one-shot flags (--version/--help)
|
|
89
|
+
// and non-TTY/piped runs skip the supervisor entirely, so scripted/CI usage is
|
|
90
|
+
// byte-for-byte unchanged. The worker is marked with ICOA_SUPERVISED=1 so it
|
|
91
|
+
// skips this block and runs the real CLI below.
|
|
92
|
+
{
|
|
93
|
+
const argvRest = process.argv.slice(2);
|
|
94
|
+
const isOneShot = argvRest.some((a) => ['-V', '--version', '-h', '--help'].includes(a));
|
|
95
|
+
const wantSupervisor = process.stdin.isTTY === true && process.env.ICOA_SUPERVISED !== '1' && !isOneShot;
|
|
96
|
+
if (wantSupervisor) {
|
|
97
|
+
const { spawnSync } = await import('node:child_process');
|
|
98
|
+
// The supervisor must survive Ctrl+C: the worker owns SIGINT (exam pause,
|
|
99
|
+
// inquirer cancel) and decides its own exit code. If the supervisor died
|
|
100
|
+
// on SIGINT the whole session would drop to the shell mid-exam.
|
|
101
|
+
process.on('SIGINT', () => { });
|
|
102
|
+
process.on('SIGTERM', () => { });
|
|
103
|
+
let exitCode = 0;
|
|
104
|
+
let relaunch = false;
|
|
105
|
+
do {
|
|
106
|
+
const res = spawnSync(process.execPath, process.argv.slice(1), {
|
|
107
|
+
stdio: 'inherit',
|
|
108
|
+
env: {
|
|
109
|
+
...process.env,
|
|
110
|
+
ICOA_SUPERVISED: '1',
|
|
111
|
+
// After the first launch, tell the worker it's a relaunch so it can
|
|
112
|
+
// skip the 3s intro pause (the banner still shows — it IS the menu).
|
|
113
|
+
...(relaunch ? { ICOA_RELAUNCH: '1' } : {}),
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
if (res.signal) {
|
|
117
|
+
// Worker killed by a signal (e.g. SIGKILL) — don't relaunch.
|
|
118
|
+
exitCode = 0;
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
121
|
+
exitCode = res.status;
|
|
122
|
+
relaunch = true;
|
|
123
|
+
} while (exitCode === RELAUNCH_CODE);
|
|
124
|
+
process.exit(exitCode ?? 0);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
// Pause for ms, skippable by any key press (so users can read the banner)
|
|
128
|
+
async function pauseWithSkip(ms) {
|
|
129
|
+
const stdin = process.stdin;
|
|
130
|
+
if (!stdin.isTTY || typeof stdin.setRawMode !== 'function') {
|
|
131
|
+
await new Promise((r) => setTimeout(r, ms));
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
return new Promise((resolve) => {
|
|
135
|
+
let done = false;
|
|
136
|
+
const finish = () => {
|
|
137
|
+
if (done)
|
|
138
|
+
return;
|
|
139
|
+
done = true;
|
|
140
|
+
stdin.removeListener('data', onKey);
|
|
141
|
+
try {
|
|
142
|
+
stdin.setRawMode(false);
|
|
143
|
+
}
|
|
144
|
+
catch { }
|
|
145
|
+
stdin.pause();
|
|
146
|
+
resolve();
|
|
147
|
+
};
|
|
148
|
+
const onKey = () => finish();
|
|
149
|
+
stdin.setRawMode(true);
|
|
150
|
+
stdin.resume();
|
|
151
|
+
stdin.once('data', onKey);
|
|
152
|
+
console.log(chalk.gray(' (press any key to continue...)'));
|
|
153
|
+
setTimeout(finish, ms);
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Detect duplicate icoa installations on PATH and warn at startup if the
|
|
158
|
+
* shell is currently resolving to a stale older copy. Catches the
|
|
159
|
+
* "8 versions behind, sudo install changes nothing" failure mode (real
|
|
160
|
+
* incident, 2026-05-05) that happens when npm prefix changed across
|
|
161
|
+
* Node.js install methods (apt vs nodesource vs nvm) without cleaning up
|
|
162
|
+
* previous global installs. Silent when there's only one install (the
|
|
163
|
+
* normal case).
|
|
164
|
+
*/
|
|
165
|
+
function warnIfDuplicateInstall() {
|
|
166
|
+
const installs = detectIcoaInstalls();
|
|
167
|
+
if (installs.length <= 1)
|
|
168
|
+
return;
|
|
169
|
+
const first = installs[0];
|
|
170
|
+
// If first is the newest version of all detected installs, the shadowing
|
|
171
|
+
// is benign — the right binary is winning. Skip the warning.
|
|
172
|
+
const versions = installs.map((i) => i.version || '0.0.0');
|
|
173
|
+
const sorted = [...versions].sort((a, b) => cmpSemver(b, a));
|
|
174
|
+
if (sorted[0] === first.version)
|
|
175
|
+
return;
|
|
176
|
+
console.log();
|
|
177
|
+
console.log(chalk.yellow.bold(' ⚠ Multiple icoa installations detected on PATH:'));
|
|
178
|
+
for (let i = 0; i < installs.length; i++) {
|
|
179
|
+
const it = installs[i];
|
|
180
|
+
const verStr = it.version ? `v${it.version}` : '(version unreadable)';
|
|
181
|
+
const arrow = i === 0 ? chalk.yellow('→') : ' ';
|
|
182
|
+
const note = i === 0 ? chalk.gray(' ← currently running (older — shadowing newer install)') : chalk.gray(' ← shadowed');
|
|
183
|
+
console.log(` ${arrow} ${chalk.cyan(it.path.padEnd(28))} ${chalk.white(verStr)}${note}`);
|
|
184
|
+
}
|
|
185
|
+
console.log();
|
|
186
|
+
console.log(chalk.yellow(' The first install on PATH wins. To use the newer one, remove the older:'));
|
|
187
|
+
console.log();
|
|
188
|
+
if (first.pkgDir) {
|
|
189
|
+
console.log(` ${chalk.bold.cyan(`sudo rm -rf ${first.pkgDir} ${first.path}`)}`);
|
|
190
|
+
}
|
|
191
|
+
else {
|
|
192
|
+
console.log(` ${chalk.bold.cyan(`sudo rm -rf ${first.path} # also delete its node_modules dir`)}`);
|
|
193
|
+
}
|
|
194
|
+
console.log();
|
|
195
|
+
console.log(chalk.gray(' Then re-run icoa to confirm version banner shows the newer release.'));
|
|
196
|
+
console.log();
|
|
197
|
+
}
|
|
198
|
+
// Tiny semver comparator — handles 1.2.3 / 10.20.300 etc. but NOT pre-release
|
|
199
|
+
// suffixes (we don't ship those). Returns positive if a > b.
|
|
200
|
+
function cmpSemver(a, b) {
|
|
201
|
+
const pa = a.split('.').map((x) => parseInt(x, 10) || 0);
|
|
202
|
+
const pb = b.split('.').map((x) => parseInt(x, 10) || 0);
|
|
203
|
+
for (let i = 0; i < 3; i++) {
|
|
204
|
+
if ((pa[i] || 0) !== (pb[i] || 0))
|
|
205
|
+
return (pa[i] || 0) - (pb[i] || 0);
|
|
206
|
+
}
|
|
207
|
+
return 0;
|
|
208
|
+
}
|
|
209
|
+
const program = new Command();
|
|
210
|
+
program
|
|
211
|
+
.name('icoa')
|
|
212
|
+
// T4-X2: version now reads live from package.json (PKG_VERSION).
|
|
213
|
+
// Was hardcoded to '1.2.0' which made `icoa --version` misreport on every
|
|
214
|
+
// release — bug since v1.2.0 era, finally fixed.
|
|
215
|
+
.version(PKG_VERSION)
|
|
216
|
+
.description('ICOA CLI — CLI-Native CTF Competition Terminal')
|
|
217
|
+
.option('--resume', 'Resume previous session')
|
|
218
|
+
.action(async (opts) => {
|
|
219
|
+
// Terminal theme — Darcula by default, 'high-contrast' for low-vision users.
|
|
220
|
+
// Variant stored in config.themeVariant; toggled via `theme` REPL command.
|
|
221
|
+
const cfg = getConfig();
|
|
222
|
+
setTerminalTheme(cfg.themeVariant === 'high-contrast' ? 'high-contrast' : 'dark');
|
|
223
|
+
checkForUpdates();
|
|
224
|
+
warnIfDuplicateInstall();
|
|
225
|
+
// T2-7: UTF-8 locale sanity check. Non-UTF-8 terminals mangle the box-
|
|
226
|
+
// drawing banner, the ✓/✗/⚠ glyphs used throughout the CLI, and any
|
|
227
|
+
// Cyrillic/CJK/Arabic/Devanagari/etc. translation text. Warn once at
|
|
228
|
+
// startup (not blocking) so students see a concrete fix hint instead of
|
|
229
|
+
// wondering why "哪个命令..." appears as "????..." on their machine.
|
|
230
|
+
const envLang = process.env.LANG || process.env.LC_ALL || process.env.LC_CTYPE || '';
|
|
231
|
+
if (!/UTF-?8/i.test(envLang)) {
|
|
232
|
+
if (process.platform === 'win32') {
|
|
233
|
+
// Windows cmd.exe default code page (437 US, 936 CN, etc.) mangles
|
|
234
|
+
// non-Latin script. Fix is `chcp 65001`, not Unix-style `export LANG`.
|
|
235
|
+
// Skip the warning if the code page is already 65001 (PowerShell +
|
|
236
|
+
// Windows Terminal often set it automatically).
|
|
237
|
+
let codepage = '';
|
|
238
|
+
try {
|
|
239
|
+
const { execFileSync } = await import('node:child_process');
|
|
240
|
+
codepage = execFileSync('chcp.com', [], {
|
|
241
|
+
encoding: 'utf-8',
|
|
242
|
+
timeout: 1500,
|
|
243
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
244
|
+
}).trim();
|
|
245
|
+
}
|
|
246
|
+
catch {
|
|
247
|
+
/* chcp.com unavailable or timed out */
|
|
248
|
+
}
|
|
249
|
+
if (!codepage.includes('65001')) {
|
|
250
|
+
console.log(chalk.yellow(`⚠ Windows terminal is not using UTF-8 (current: ${codepage || 'unknown'}).`));
|
|
251
|
+
console.log(chalk.gray(' Non-English text (Ukrainian, Chinese, Japanese, etc.) may show as "?" or garbled glyphs.'));
|
|
252
|
+
console.log(chalk.gray(' Fix (run before ') + chalk.cyan('icoa') + chalk.gray('): ') + chalk.cyan('chcp 65001'));
|
|
253
|
+
console.log(chalk.gray(' Or stay in English inside the CLI: ') + chalk.cyan('lang en'));
|
|
254
|
+
console.log();
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
else {
|
|
258
|
+
console.log(chalk.yellow(`⚠ Your terminal locale is not UTF-8 (LANG=${envLang || '(unset)'}).`));
|
|
259
|
+
console.log(chalk.gray(' Non-English text and box characters may display as "?" or garbled glyphs.'));
|
|
260
|
+
console.log(chalk.gray(' Fix: ') +
|
|
261
|
+
chalk.cyan('export LANG=en_US.UTF-8') +
|
|
262
|
+
chalk.gray(' (or your locale, e.g. ') +
|
|
263
|
+
chalk.cyan('zh_CN.UTF-8') +
|
|
264
|
+
chalk.gray(', ') +
|
|
265
|
+
chalk.cyan('uk_UA.UTF-8') +
|
|
266
|
+
chalk.gray(')'));
|
|
267
|
+
console.log();
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
console.log(BANNER);
|
|
271
|
+
// If running interactively (no extra args or --resume), start REPL
|
|
272
|
+
if (process.argv.length <= 2 || opts.resume) {
|
|
273
|
+
// Brief pause so users can read the banner + logo before mode selector appears.
|
|
274
|
+
// Skip on --resume (user is coming back) and on a supervisor relaunch
|
|
275
|
+
// (ICOA_RELAUNCH=1 — they just hit `menu`, no need to re-watch the intro).
|
|
276
|
+
if (!opts.resume && process.env.ICOA_RELAUNCH !== '1') {
|
|
277
|
+
await pauseWithSkip(3000);
|
|
278
|
+
}
|
|
279
|
+
startRepl(program, !!opts.resume);
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
registerCtfCommands(program);
|
|
284
|
+
registerRefCommand(program);
|
|
285
|
+
registerShellCommand(program);
|
|
286
|
+
registerFilesCommand(program);
|
|
287
|
+
registerViewCommand(program);
|
|
288
|
+
registerConnectCommand(program);
|
|
289
|
+
registerChallengeCommand(program);
|
|
290
|
+
registerNoteCommand(program);
|
|
291
|
+
registerLogCommand(program);
|
|
292
|
+
registerLangCommand(program);
|
|
293
|
+
registerSetupCommand(program);
|
|
294
|
+
registerEnvCommand(program);
|
|
295
|
+
registerAienvCommand(program);
|
|
296
|
+
registerIpynbCommand(program);
|
|
297
|
+
registerAi4ctfCommand(program);
|
|
298
|
+
registerExamCommand(program);
|
|
299
|
+
registerCtf4aiDemoCommand(program);
|
|
300
|
+
registerThemeCommand(program);
|
|
301
|
+
registerLearnCommand(program);
|
|
302
|
+
registerCtf4VlaCommand(program);
|
|
303
|
+
registerDemo2Command(program);
|
|
304
|
+
registerSimCommand(program);
|
|
305
|
+
registerArenaCommand(program);
|
|
306
|
+
// Hidden command: switch AI model
|
|
307
|
+
program
|
|
308
|
+
.command('model', { hidden: true })
|
|
309
|
+
.argument('[name]', 'model name to switch to')
|
|
310
|
+
.action((name) => {
|
|
311
|
+
const config = getConfig();
|
|
312
|
+
const current = config.geminiModel || 'gemini-2.5-flash-lite';
|
|
313
|
+
if (!name) {
|
|
314
|
+
console.log();
|
|
315
|
+
console.log(chalk.gray(' Current model: ') + chalk.white(current));
|
|
316
|
+
console.log();
|
|
317
|
+
console.log(chalk.gray(' Available models:'));
|
|
318
|
+
console.log(chalk.bold.white(' Gemini 3.x (Latest)'));
|
|
319
|
+
console.log(chalk.white(' model gemini-3.5-flash ') + chalk.gray('Newest stable, paid Tier 1+'));
|
|
320
|
+
console.log(chalk.white(' model gemini-3-flash-preview ') + chalk.gray('Preview'));
|
|
321
|
+
console.log(chalk.bold.white(' Gemini 2.5 (Stable)'));
|
|
322
|
+
console.log(chalk.white(' model gemini-2.5-flash-lite ') + chalk.gray('Fastest, default, free tier'));
|
|
323
|
+
console.log(chalk.white(' model gemini-2.5-flash ') + chalk.gray('Heavier, thinking enabled'));
|
|
324
|
+
console.log(chalk.white(' model gemini-2.5-pro ') + chalk.gray('Strong reasoning, paid'));
|
|
325
|
+
console.log(chalk.bold.white(' Open Source'));
|
|
326
|
+
console.log(chalk.white(' model gemma-4-31b-it ') + chalk.gray('Free, open-source'));
|
|
327
|
+
console.log(chalk.white(' model <any-model-id> ') + chalk.gray('Custom model'));
|
|
328
|
+
console.log();
|
|
329
|
+
console.log(chalk.gray(' Translation uses gemini-3.5-flash for best quality.'));
|
|
330
|
+
console.log();
|
|
331
|
+
}
|
|
332
|
+
else {
|
|
333
|
+
saveConfig({ geminiModel: name });
|
|
334
|
+
console.log();
|
|
335
|
+
console.log(chalk.green(' Model switched: ') + chalk.gray(current) + chalk.white(' -> ') + chalk.bold.white(name));
|
|
336
|
+
console.log();
|
|
337
|
+
}
|
|
338
|
+
});
|
|
339
|
+
// Proctor-only escape hatch: `ICOA_RESET_STATE=1 icoa` wipes any abandoned
|
|
340
|
+
// exam state *before* any subcommand or REPL runs. Fires for ALL invocations
|
|
341
|
+
// (including `icoa exam <token>`) so proctors have a one-liner recovery that
|
|
342
|
+
// works regardless of which subcommand the student would run next.
|
|
343
|
+
// The token itself is untouched server-side.
|
|
344
|
+
if (process.env.ICOA_RESET_STATE === '1') {
|
|
345
|
+
try {
|
|
346
|
+
const { clearExamState } = await import('./lib/exam-state.js');
|
|
347
|
+
clearExamState();
|
|
348
|
+
console.log(chalk.yellow('⚠ ICOA_RESET_STATE=1 — local exam state wiped.'));
|
|
349
|
+
console.log(chalk.gray(' (Token NOT revoked server-side. Re-enter a fresh token with `exam <token>`.)'));
|
|
350
|
+
console.log();
|
|
351
|
+
}
|
|
352
|
+
catch (e) {
|
|
353
|
+
console.log(chalk.red('⚠ ICOA_RESET_STATE: could not clear state — ') + chalk.gray(String(e)));
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
program.parse();
|
package/dist/lib/access.js
CHANGED
|
@@ -1 +1,184 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { execSync } from 'node:child_process';
|
|
3
|
+
import { platform } from 'node:os';
|
|
4
|
+
import { getConfig, saveConfig, getIcoaDir } from './config.js';
|
|
5
|
+
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
// Access-token format check. The shipped SHA-256 hash table (~300 valid tokens)
|
|
8
|
+
// was REMOVED 2026-06-21 (B3): it was a redundant-for-security UX gate, and
|
|
9
|
+
// shipping the hashes let anyone enumerate valid tokens offline. The real
|
|
10
|
+
// boundary is server-side — exam/learn/AI all re-validate the token + device
|
|
11
|
+
// against the server's `exam_tokens` table at point of use (token-api.py). So
|
|
12
|
+
// the client now only format-checks; `activate` is a UX unlock, and every
|
|
13
|
+
// privileged action stays server-authoritative. Already-activated users are
|
|
14
|
+
// grandfathered (isActivated() trusts a stored accessToken). See
|
|
15
|
+
// docs/POST_COMPETITION_AUDIT_STANDARD.md §10 (B3).
|
|
16
|
+
const TOKEN_FORMAT = /^ICOA-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$/;
|
|
17
|
+
// Check if first run or version upgrade
|
|
18
|
+
export function isFirstRunOrUpgrade(currentVersion) {
|
|
19
|
+
const config = getConfig();
|
|
20
|
+
return config.lastVersion !== currentVersion;
|
|
21
|
+
}
|
|
22
|
+
export function markVersionSeen(version) {
|
|
23
|
+
saveConfig({ lastVersion: version });
|
|
24
|
+
}
|
|
25
|
+
// Free commands that don't require token
|
|
26
|
+
const FREE_COMMANDS = new Set(['ref', 'help', 'exit', 'quit', 'q', 'clear', 'cls', 'activate']);
|
|
27
|
+
function hashToken(token) {
|
|
28
|
+
return createHash('sha256').update(token.trim().toUpperCase()).digest('hex');
|
|
29
|
+
}
|
|
30
|
+
function validateToken(token) {
|
|
31
|
+
// Format only (B3). A well-formed token unlocks the UI; whether it is a real,
|
|
32
|
+
// unspent, device-matched token is decided server-side at point of use.
|
|
33
|
+
return TOKEN_FORMAT.test(token.trim().toUpperCase());
|
|
34
|
+
}
|
|
35
|
+
export function isActivated() {
|
|
36
|
+
// UX gate only (B3). Grandfather anyone already activated (stored accessToken),
|
|
37
|
+
// else require a format-valid token via activate(). Nothing more to check
|
|
38
|
+
// client-side — the server is authoritative for every privileged action.
|
|
39
|
+
const config = getConfig();
|
|
40
|
+
return !!config.accessToken;
|
|
41
|
+
}
|
|
42
|
+
// Stable hardware identifier: the OS-reported machine UUID. Returns '' when it
|
|
43
|
+
// cannot be read reliably. We deliberately do NOT fall back to a MAC address
|
|
44
|
+
// (the old behaviour): MACs change with network-interface state — Wi-Fi vs
|
|
45
|
+
// ethernet, VPN up/down, USB NICs, macOS private-address randomization — and
|
|
46
|
+
// were the classic source of fingerprint drift. The empty-string fallback is
|
|
47
|
+
// fine because the value is always folded with the persisted install salt and
|
|
48
|
+
// then frozen (see getDeviceFingerprint), so it is only ever read once.
|
|
49
|
+
function _getStableHardwareId() {
|
|
50
|
+
try {
|
|
51
|
+
if (platform() === 'darwin') {
|
|
52
|
+
const out = execSync('ioreg -rd1 -c IOPlatformExpertDevice | grep IOPlatformUUID', { encoding: 'utf-8' });
|
|
53
|
+
const match = out.match(/"([A-F0-9-]+)"/);
|
|
54
|
+
if (match)
|
|
55
|
+
return match[1];
|
|
56
|
+
}
|
|
57
|
+
else if (platform() === 'linux') {
|
|
58
|
+
if (existsSync('/etc/machine-id')) {
|
|
59
|
+
return readFileSync('/etc/machine-id', 'utf-8').trim();
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
else if (platform() === 'win32') {
|
|
63
|
+
const out = execSync('powershell -Command "(Get-CimInstance Win32_ComputerSystemProduct).UUID"', {
|
|
64
|
+
encoding: 'utf-8',
|
|
65
|
+
});
|
|
66
|
+
return out.trim();
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
/* hardware UUID unavailable — fall through to the salt-only fingerprint */
|
|
71
|
+
}
|
|
72
|
+
return '';
|
|
73
|
+
}
|
|
74
|
+
// Device fingerprint (NAT-resilient + drift-proof):
|
|
75
|
+
//
|
|
76
|
+
// The Apr-26 Ukraine deployment surfaced a fingerprint *collapse* — students
|
|
77
|
+
// behind a centralized proxy all had identical hostname/platform/arch/MAC at
|
|
78
|
+
// the egress, so 30+ students hashed to one device. v2.19.185 fixed that by
|
|
79
|
+
// mixing a random per-install salt into the hash.
|
|
80
|
+
//
|
|
81
|
+
// This revision fixes the opposite failure — fingerprint *drift*: the salted
|
|
82
|
+
// hash still folded in volatile signals (the MAC fallback, an intermittently
|
|
83
|
+
// readable hardware UUID), so the SAME machine could hash differently between
|
|
84
|
+
// runs and falsely trip the "different device" lockout on re-entry / a second
|
|
85
|
+
// terminal. Fix: (1) the persisted install salt is the stable anchor, (2) only
|
|
86
|
+
// the stable hardware UUID is folded in (never a MAC), and (3) once a
|
|
87
|
+
// fingerprint is stored it is authoritative and returned verbatim — never
|
|
88
|
+
// recomputed. That last rule also preserves backward-compat for every already-
|
|
89
|
+
// bound install (pre-185 salt-less and v185 salted alike): each matches itself.
|
|
90
|
+
export function getDeviceFingerprint() {
|
|
91
|
+
const config = getConfig();
|
|
92
|
+
// Authoritative once bound — return the stored value, never recompute.
|
|
93
|
+
if (config.deviceFingerprint)
|
|
94
|
+
return config.deviceFingerprint;
|
|
95
|
+
// First fingerprint on this install: persisted salt + stable hardware UUID.
|
|
96
|
+
let salt = config.installSalt;
|
|
97
|
+
if (!salt) {
|
|
98
|
+
salt = randomUUID();
|
|
99
|
+
saveConfig({ installSalt: salt });
|
|
100
|
+
}
|
|
101
|
+
const hw = _getStableHardwareId();
|
|
102
|
+
return createHash('sha256').update(`${hw}|${salt}`).digest('hex');
|
|
103
|
+
}
|
|
104
|
+
export function activateToken(token) {
|
|
105
|
+
if (!validateToken(token))
|
|
106
|
+
return 'invalid';
|
|
107
|
+
const fingerprint = getDeviceFingerprint();
|
|
108
|
+
const bindingFile = join(getIcoaDir(), 'token-bindings.json');
|
|
109
|
+
// Load existing bindings
|
|
110
|
+
let bindings = {};
|
|
111
|
+
if (existsSync(bindingFile)) {
|
|
112
|
+
try {
|
|
113
|
+
bindings = JSON.parse(readFileSync(bindingFile, 'utf-8'));
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
/* ignore */
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const tokenHash = hashToken(token);
|
|
120
|
+
const existingDevice = bindings[tokenHash];
|
|
121
|
+
if (existingDevice && existingDevice !== fingerprint) {
|
|
122
|
+
// Token already bound to a different device
|
|
123
|
+
return 'already_bound';
|
|
124
|
+
}
|
|
125
|
+
// Bind token to this device
|
|
126
|
+
bindings[tokenHash] = fingerprint;
|
|
127
|
+
writeFileSync(bindingFile, JSON.stringify(bindings, null, 2));
|
|
128
|
+
saveConfig({ accessToken: token.trim().toUpperCase(), deviceFingerprint: fingerprint });
|
|
129
|
+
return 'ok';
|
|
130
|
+
}
|
|
131
|
+
export function isDeviceMatch() {
|
|
132
|
+
const config = getConfig();
|
|
133
|
+
if (!config.deviceFingerprint)
|
|
134
|
+
return true; // Legacy: no fingerprint yet
|
|
135
|
+
return config.deviceFingerprint === getDeviceFingerprint();
|
|
136
|
+
}
|
|
137
|
+
export function isFreeCommand(cmd) {
|
|
138
|
+
return FREE_COMMANDS.has(cmd.toLowerCase());
|
|
139
|
+
}
|
|
140
|
+
// Session tracking for anti-cheat
|
|
141
|
+
const SESSION_FILE = () => join(getIcoaDir(), 'session-state.json');
|
|
142
|
+
function getDefaultSession() {
|
|
143
|
+
return {
|
|
144
|
+
startedAt: new Date().toISOString(),
|
|
145
|
+
lastExitAt: null,
|
|
146
|
+
lastResumeAt: null,
|
|
147
|
+
exitCount: 0,
|
|
148
|
+
totalAwaySeconds: 0,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
function getSession() {
|
|
152
|
+
const file = SESSION_FILE();
|
|
153
|
+
if (!existsSync(file))
|
|
154
|
+
return getDefaultSession();
|
|
155
|
+
try {
|
|
156
|
+
return { ...getDefaultSession(), ...JSON.parse(readFileSync(file, 'utf-8')) };
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
return getDefaultSession();
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
function saveSession(session) {
|
|
163
|
+
const current = getSession();
|
|
164
|
+
const merged = { ...current, ...session };
|
|
165
|
+
writeFileSync(SESSION_FILE(), JSON.stringify(merged, null, 2));
|
|
166
|
+
}
|
|
167
|
+
export function recordExit() {
|
|
168
|
+
const session = getSession();
|
|
169
|
+
session.lastExitAt = new Date().toISOString();
|
|
170
|
+
session.exitCount++;
|
|
171
|
+
saveSession(session);
|
|
172
|
+
}
|
|
173
|
+
export function recordResume() {
|
|
174
|
+
const session = getSession();
|
|
175
|
+
if (!session.lastExitAt)
|
|
176
|
+
return null;
|
|
177
|
+
const exitTime = new Date(session.lastExitAt).getTime();
|
|
178
|
+
const now = Date.now();
|
|
179
|
+
const awaySeconds = Math.round((now - exitTime) / 1000);
|
|
180
|
+
session.lastResumeAt = new Date().toISOString();
|
|
181
|
+
session.totalAwaySeconds += awaySeconds;
|
|
182
|
+
saveSession(session);
|
|
183
|
+
return { awaySeconds, exitCount: session.exitCount };
|
|
184
|
+
}
|
package/dist/lib/arena-submit.js
CHANGED
|
@@ -1 +1,21 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* arena-submit — pure builder for an arena submission payload (Phase 3 of the
|
|
3
|
+
* CLI notebook arena). The all-in-CLI loop: a student computes in `icoa ipynb`,
|
|
4
|
+
* saves a local predictions.csv, and submits it here.
|
|
5
|
+
*
|
|
6
|
+
* If the input is a path that exists on disk, we send its CONTENT inline
|
|
7
|
+
* (`predictions_csv`) — this is what makes local submission work against a
|
|
8
|
+
* REMOTE server (prod can't read the student's disk, so a bare path is useless;
|
|
9
|
+
* the legacy Google-Drive link existed only to get the file TO the server).
|
|
10
|
+
* Otherwise we fall back to the Drive link (`gdrive_url`). Kept pure (fs deps
|
|
11
|
+
* injected) so the routing decision is unit-tested.
|
|
12
|
+
*/
|
|
13
|
+
export function buildSubmitPayload(input, arenaToken, deps) {
|
|
14
|
+
let path = input.trim();
|
|
15
|
+
if (path.startsWith('file://'))
|
|
16
|
+
path = path.slice('file://'.length);
|
|
17
|
+
if (path && deps.exists(path)) {
|
|
18
|
+
return { arena_token: arenaToken, predictions_csv: deps.read(path), source: 'local' };
|
|
19
|
+
}
|
|
20
|
+
return { arena_token: arenaToken, gdrive_url: input.trim(), source: 'gdrive' };
|
|
21
|
+
}
|
package/dist/lib/budget.js
CHANGED
|
@@ -1 +1,6 @@
|
|
|
1
|
-
import{getBudget
|
|
1
|
+
import { getBudget, saveBudget } from './config.js';
|
|
2
|
+
export function addTokenUsage(tokensUsed) {
|
|
3
|
+
const budget = getBudget();
|
|
4
|
+
budget.tokensUsed += tokensUsed;
|
|
5
|
+
saveBudget(budget);
|
|
6
|
+
}
|
|
@@ -1 +1,16 @@
|
|
|
1
|
-
import{join
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { getActiveCwd } from './exam-sandbox.js';
|
|
3
|
+
// Single source of truth for where `files <id>` downloads challenge attachments
|
|
4
|
+
// and where `view <id>` looks for them. They MUST agree, so both import this.
|
|
5
|
+
//
|
|
6
|
+
// Why under the active workspace (NOT the legacy ~/icoa-challenges/<id>): the
|
|
7
|
+
// in-REPL shell runs every command with cwd = getActiveCwd() (~/icoa-workspace,
|
|
8
|
+
// or the per-exam tmpdir), and the REPL path-guard (repl.ts) blocks any absolute
|
|
9
|
+
// path outside that workspace plus all `..` traversal. A file dropped in
|
|
10
|
+
// ~/icoa-challenges/<id> was therefore UNREACHABLE by ls/file/python/steghide —
|
|
11
|
+
// fatal for steg/forensics challenges. Downloading into <workspace>/<id>/ keeps
|
|
12
|
+
// the file reachable via the relative path `<id>/<file>` while still namespacing
|
|
13
|
+
// per challenge so two challenges shipping the same filename never collide.
|
|
14
|
+
export function challengeDownloadDir(id, base = getActiveCwd()) {
|
|
15
|
+
return join(base, id);
|
|
16
|
+
}
|