icoa-cli 2.19.355 → 2.19.357
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.js +1 -1
- package/dist/commands/ctf.js +787 -1
- package/dist/commands/ctf4ai-demo.js +1 -1
- package/dist/commands/ctf4vla.js +1 -1
- package/dist/commands/demo2.js +1502 -1
- package/dist/commands/exam.js +1 -1
- package/dist/commands/files.js +59 -1
- package/dist/commands/ipynb.d.ts +10 -4
- package/dist/commands/ipynb.js +1 -1
- package/dist/commands/lang.js +202 -1
- package/dist/commands/log.js +171 -1
- package/dist/commands/shell.d.ts +15 -0
- package/dist/commands/shell.js +151 -1
- package/dist/commands/sim.js +389 -1
- package/dist/index.js +355 -1
- package/dist/lib/access.js +184 -1
- package/dist/lib/aienv.js +205 -1
- package/dist/lib/arena-submit.js +21 -1
- package/dist/lib/banner.js +31 -1
- package/dist/lib/budget.js +6 -1
- package/dist/lib/challenge-dir.js +16 -1
- package/dist/lib/colors.js +17 -1
- package/dist/lib/comms.js +212 -1
- package/dist/lib/config.js +93 -1
- package/dist/lib/countdown.js +43 -1
- package/dist/lib/country-lang.js +39 -1
- package/dist/lib/ctfd-client.js +417 -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/docker-probe.d.ts +45 -0
- package/dist/lib/docker-probe.js +118 -0
- package/dist/lib/editor-spawn.d.ts +23 -0
- package/dist/lib/editor-spawn.js +53 -0
- package/dist/lib/exam-client.js +54 -1
- package/dist/lib/exam-sandbox.js +201 -1
- package/dist/lib/exam-setup.js +36 -1
- package/dist/lib/exam-state.js +273 -1
- package/dist/lib/gemini.js +247 -1
- package/dist/lib/i18n.js +302 -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/learn-input.js +101 -1
- package/dist/lib/learn-render.js +863 -1
- package/dist/lib/learn-state.js +103 -1
- package/dist/lib/log-sync.js +155 -1
- package/dist/lib/logger.js +49 -1
- package/dist/lib/main-rl.js +7 -1
- package/dist/lib/menu-nav.js +105 -1
- package/dist/lib/notebook-doc.d.ts +38 -0
- package/dist/lib/notebook-doc.js +137 -0
- package/dist/lib/open-file.js +55 -1
- package/dist/lib/paper-upgrade.js +119 -1
- package/dist/lib/platform.js +99 -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/sandbox.d.ts +25 -1
- package/dist/lib/sandbox.js +144 -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/tool-man.js +418 -1
- package/dist/lib/toolset-hash.js +48 -1
- package/dist/lib/translation.js +80 -1
- package/dist/lib/translations-fetcher.js +95 -1
- package/dist/lib/ui.js +99 -1
- package/dist/lib/update-check.js +114 -1
- package/dist/lib/version.js +24 -1
- package/dist/postinstall.js +48 -1
- package/dist/repl.js +2391 -1
- package/dist/types/index.js +63 -1
- package/package.json +1 -1
package/dist/lib/aienv.js
CHANGED
|
@@ -1 +1,205 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* aienv — the AI/ML notebook-arena environment (Phase 0 of the CLI notebook
|
|
3
|
+
* arena, see `project_cli_notebook_arena_plan`).
|
|
4
|
+
*
|
|
5
|
+
* This is the PURE, testable core. The orchestration (venv creation, pip
|
|
6
|
+
* installs, status rendering) lives in `src/commands/aienv.ts`.
|
|
7
|
+
*
|
|
8
|
+
* Hard rule (BUG3 / Team Indonesia): aienv provisions its OWN venv under
|
|
9
|
+
* ~/.icoa/aienv and NEVER repoints the system python3. It also stays fully
|
|
10
|
+
* independent of `env.ts` so the heavy ML stack (jupyter/numpy/torch...) can
|
|
11
|
+
* never drag down the lightweight exam-only `env setup` path, and so a broken
|
|
12
|
+
* ML install is fault-isolated from the competition toolkit.
|
|
13
|
+
*/
|
|
14
|
+
import { posix as pathPosix, win32 as pathWin32 } from 'node:path';
|
|
15
|
+
// The host python aienv builds its venv from is LOCKED to the 3.12 line — the
|
|
16
|
+
// same minor version env.ts (the exam env) installs/targets. Locking the minor
|
|
17
|
+
// version pins the cp312 wheel ABI, so every student's ML stack
|
|
18
|
+
// (numpy/pandas/scikit-learn/torch...) resolves to the SAME mature, well-tested
|
|
19
|
+
// wheels: uniform across the cohort + parity with the server's fixed-model box.
|
|
20
|
+
// We accept any 3.12.x PATCH (patch is a floor, like env.ts) but deliberately
|
|
21
|
+
// refuse 3.10 / 3.11 / 3.13 — version fragmentation and wheel-availability gaps
|
|
22
|
+
// are exactly what we're avoiding. No 3.12 → an actionable "install it" error.
|
|
23
|
+
export const REQUIRED_PY = { major: 3, minor: 12 };
|
|
24
|
+
// The marker schema version — bump when the marker shape or the package set
|
|
25
|
+
// changes in a way that should trigger a re-setup.
|
|
26
|
+
// v2 (2026-06-22): data group expanded with scipy / scikit-image / Pillow /
|
|
27
|
+
// imageio / requests / pyyaml / tqdm. Re-run `aienv setup` to pull the new ones
|
|
28
|
+
// (the install loop adds only the missing packages).
|
|
29
|
+
export const AIENV_MARKER_VERSION = 2;
|
|
30
|
+
/**
|
|
31
|
+
* ML stack for the notebook arena.
|
|
32
|
+
*
|
|
33
|
+
* Versions are FLOOR-pinned (`>=`), not exact-pinned like the CTF toolkit in
|
|
34
|
+
* env.ts. The scientific/ML stack ships platform- and arch-specific wheels that
|
|
35
|
+
* churn fast; exact pins across macOS/Linux/WSL × x64/arm64 routinely fail wheel
|
|
36
|
+
* resolution. Phase 0 only provisions — fairness-critical determinism lives
|
|
37
|
+
* server-side on the (fixed-model) GPU box, not in the student's local venv.
|
|
38
|
+
*
|
|
39
|
+
* - core: the Jupyter kernel-protocol foundation Phase 1's cell loop talks to.
|
|
40
|
+
* - data: the everyday data-science stack students compute with on CPU.
|
|
41
|
+
* - deep: multi-GB DL stack — OPT-IN via `aienv setup --deep` only. Under the
|
|
42
|
+
* fixed-model GPU design, most students call the server's inference API and
|
|
43
|
+
* never need torch locally, so it must not be in the default footprint.
|
|
44
|
+
*/
|
|
45
|
+
export const AIENV_PACKAGES = [
|
|
46
|
+
// ── core (Jupyter kernel protocol) ──────────────────────────────────────
|
|
47
|
+
{ name: 'jupyter_client', import: 'jupyter_client', spec: 'jupyter_client>=8.6', group: 'core' },
|
|
48
|
+
{ name: 'ipykernel', import: 'ipykernel', spec: 'ipykernel>=6.29', group: 'core' },
|
|
49
|
+
// ── data (CPU compute surface) ──────────────────────────────────────────
|
|
50
|
+
// NB: pip name ≠ import name for several — Pillow→PIL, scikit-image→skimage,
|
|
51
|
+
// scikit-learn→sklearn, pyyaml→yaml. The `import` field is the readiness probe.
|
|
52
|
+
{ name: 'numpy', import: 'numpy', spec: 'numpy>=1.26', group: 'data' },
|
|
53
|
+
{ name: 'scipy', import: 'scipy', spec: 'scipy>=1.13', group: 'data' },
|
|
54
|
+
{ name: 'pandas', import: 'pandas', spec: 'pandas>=2.2', group: 'data' },
|
|
55
|
+
{ name: 'scikit-learn', import: 'sklearn', spec: 'scikit-learn>=1.4', group: 'data' },
|
|
56
|
+
{ name: 'matplotlib', import: 'matplotlib', spec: 'matplotlib>=3.8', group: 'data' },
|
|
57
|
+
{ name: 'scikit-image', import: 'skimage', spec: 'scikit-image>=0.23', group: 'data' },
|
|
58
|
+
// probe: `import PIL` passes even when the `_imaging` C extension is broken
|
|
59
|
+
// (the env.ts ABI-shadow lesson). Exercise the real usable import instead.
|
|
60
|
+
{ name: 'Pillow', import: 'PIL', spec: 'Pillow>=10.2', group: 'data', probe: 'from PIL import Image' },
|
|
61
|
+
{ name: 'imageio', import: 'imageio', spec: 'imageio>=2.34', group: 'data' },
|
|
62
|
+
{ name: 'requests', import: 'requests', spec: 'requests>=2.31', group: 'data' },
|
|
63
|
+
{ name: 'pyyaml', import: 'yaml', spec: 'pyyaml>=6.0', group: 'data' },
|
|
64
|
+
{ name: 'tqdm', import: 'tqdm', spec: 'tqdm>=4.66', group: 'data' },
|
|
65
|
+
// ── deep (opt-in, multi-GB) ─────────────────────────────────────────────
|
|
66
|
+
{ name: 'torch', import: 'torch', spec: 'torch>=2.2', group: 'deep', note: 'large download (~2 GB)' },
|
|
67
|
+
{ name: 'transformers', import: 'transformers', spec: 'transformers>=4.40', group: 'deep' },
|
|
68
|
+
{ name: 'datasets', import: 'datasets', spec: 'datasets>=2.19', group: 'deep' },
|
|
69
|
+
// ── physics (opt-in, ctf4eai MuJoCo-physics arena) ──────────────────────
|
|
70
|
+
// EXACT-pinned (== not >=) — the one deliberate deviation from the floor-pin
|
|
71
|
+
// rule above. The ctf4eai physics judge is fairness-critical: contact-solver
|
|
72
|
+
// behaviour can shift between MuJoCo minor versions, so the student self-check
|
|
73
|
+
// and the server judge MUST agree on one version (arena-design risk #3). A
|
|
74
|
+
// single pure-CPU cp312 wheel, headless (mj_step needs no renderer).
|
|
75
|
+
{
|
|
76
|
+
name: 'mujoco',
|
|
77
|
+
import: 'mujoco',
|
|
78
|
+
spec: 'mujoco==3.9.0',
|
|
79
|
+
group: 'physics',
|
|
80
|
+
note: 'CPU physics for the ctf4eai arena (~60 MB, version-pinned for fair judging)',
|
|
81
|
+
},
|
|
82
|
+
];
|
|
83
|
+
export function packagesForGroups(groups) {
|
|
84
|
+
const want = new Set(groups);
|
|
85
|
+
return AIENV_PACKAGES.filter((p) => want.has(p.group));
|
|
86
|
+
}
|
|
87
|
+
/** Compute the venv paths for a given home directory + platform. */
|
|
88
|
+
export function aienvPaths(home, plat = process.platform) {
|
|
89
|
+
// Use the platform-specific path flavour keyed off `plat` (not the host) so a
|
|
90
|
+
// win32 result has backslashes even when computed on a posix box, and vice
|
|
91
|
+
// versa — deterministic and testable.
|
|
92
|
+
const p = plat === 'win32' ? pathWin32 : pathPosix;
|
|
93
|
+
const root = p.join(home, '.icoa', 'aienv');
|
|
94
|
+
if (plat === 'win32') {
|
|
95
|
+
const binDir = p.join(root, 'Scripts');
|
|
96
|
+
return {
|
|
97
|
+
root,
|
|
98
|
+
binDir,
|
|
99
|
+
python: p.join(binDir, 'python.exe'),
|
|
100
|
+
pip: p.join(binDir, 'pip.exe'),
|
|
101
|
+
marker: p.join(root, 'icoa-aienv.json'),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
const binDir = p.join(root, 'bin');
|
|
105
|
+
return {
|
|
106
|
+
root,
|
|
107
|
+
binDir,
|
|
108
|
+
python: p.join(binDir, 'python'),
|
|
109
|
+
pip: p.join(binDir, 'pip'),
|
|
110
|
+
marker: p.join(root, 'icoa-aienv.json'),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Absolute path to the icoa-provisioned standalone CPython 3.12. `env setup`
|
|
115
|
+
* installs a relocatable python-build-standalone build here ONLY on Linux
|
|
116
|
+
* distros where neither apt nor the Ubuntu deadsnakes PPA can deliver 3.12
|
|
117
|
+
* (e.g. Kali / non-Ubuntu Debian). Side-by-side: the system python3 is never
|
|
118
|
+
* touched. On the four already-supported platforms this path simply never
|
|
119
|
+
* exists, so probing it is a no-op.
|
|
120
|
+
*/
|
|
121
|
+
export function icoaStandalonePython(home, plat = process.platform) {
|
|
122
|
+
const p = plat === 'win32' ? pathWin32 : pathPosix;
|
|
123
|
+
return plat === 'win32'
|
|
124
|
+
? p.join(home, '.icoa', 'python312', 'python.exe')
|
|
125
|
+
: p.join(home, '.icoa', 'python312', 'bin', 'python3.12');
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Ordered host-python candidates to build the venv from. We probe a versioned
|
|
129
|
+
* 3.12 BEFORE the bare `python3` so we don't accidentally seed the venv with a
|
|
130
|
+
* distro 3.10 when a 3.12 is present. We never touch the system python3 beyond
|
|
131
|
+
* reading its --version.
|
|
132
|
+
*
|
|
133
|
+
* When `home` is given, the icoa-provisioned standalone 3.12 (Kali / non-Ubuntu
|
|
134
|
+
* Debian — see icoaStandalonePython) is probed right after the on-PATH
|
|
135
|
+
* `python3.12` and before the distro `python3`. Omitting `home` (the default,
|
|
136
|
+
* and what the unit tests use) leaves the candidate list byte-identical to
|
|
137
|
+
* before, so the four working platforms are unaffected.
|
|
138
|
+
*/
|
|
139
|
+
export function hostPythonCandidates(plat = process.platform, home) {
|
|
140
|
+
const standalone = home ? [icoaStandalonePython(home, plat)] : [];
|
|
141
|
+
if (plat === 'win32') {
|
|
142
|
+
// The `py` launcher can target an exact minor; fall back to bare python.
|
|
143
|
+
return ['py -3.12', ...standalone, 'python', 'py -3', 'py'];
|
|
144
|
+
}
|
|
145
|
+
if (plat === 'darwin') {
|
|
146
|
+
return [
|
|
147
|
+
'python3.12',
|
|
148
|
+
'/opt/homebrew/opt/python@3.12/bin/python3.12',
|
|
149
|
+
'/usr/local/opt/python@3.12/bin/python3.12',
|
|
150
|
+
'python3',
|
|
151
|
+
];
|
|
152
|
+
}
|
|
153
|
+
// linux / wsl / crostini
|
|
154
|
+
return ['python3.12', ...standalone, 'python3'];
|
|
155
|
+
}
|
|
156
|
+
/** Parse `Python 3.12.13` → {major:3, minor:12}. Tolerant of trailing newline. */
|
|
157
|
+
export function parsePyVersion(out) {
|
|
158
|
+
if (!out)
|
|
159
|
+
return null;
|
|
160
|
+
const m = out.match(/(\d+)\.(\d+)(?:\.\d+)?/);
|
|
161
|
+
if (!m)
|
|
162
|
+
return null;
|
|
163
|
+
return { major: Number(m[1]), minor: Number(m[2]) };
|
|
164
|
+
}
|
|
165
|
+
/** True if `v` is at least `min` (major-then-minor comparison). */
|
|
166
|
+
export function meetsMinPy(v, min) {
|
|
167
|
+
if (v.major !== min.major)
|
|
168
|
+
return v.major > min.major;
|
|
169
|
+
return v.minor >= min.minor;
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Resolve the best host python to create the venv from.
|
|
173
|
+
*
|
|
174
|
+
* `probe(cmd)` runs `cmd --version` and returns its stdout (or null if the
|
|
175
|
+
* command is absent / errored) — injected so this stays pure + testable.
|
|
176
|
+
*
|
|
177
|
+
* Returns the first interpreter on the locked 3.12 line (any patch); otherwise
|
|
178
|
+
* null. We deliberately do NOT fall back to 3.10 / 3.11 / 3.13 — a uniform 3.12
|
|
179
|
+
* cohort is the whole point (see REQUIRED_PY). The caller surfaces an actionable
|
|
180
|
+
* "install Python 3.12" message when this is null.
|
|
181
|
+
*/
|
|
182
|
+
export function resolveHostPython(probe, plat = process.platform, home) {
|
|
183
|
+
for (const cmd of hostPythonCandidates(plat, home)) {
|
|
184
|
+
const version = parsePyVersion(probe(cmd));
|
|
185
|
+
if (!version)
|
|
186
|
+
continue;
|
|
187
|
+
if (version.major === REQUIRED_PY.major && version.minor === REQUIRED_PY.minor) {
|
|
188
|
+
return { cmd, version };
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Build the readiness probe for one package, run via the VENV python (absolute
|
|
195
|
+
* path) — never the system python3. Mirrors env.ts's `python3 -c "import x"`
|
|
196
|
+
* convention but always targets the venv interpreter.
|
|
197
|
+
*/
|
|
198
|
+
export function importCheckCmd(venvPython, pkg) {
|
|
199
|
+
// A `probe` override exercises the real usable import (e.g. Pillow's
|
|
200
|
+
// `from PIL import Image`, which loads the `_imaging` C extension) so a
|
|
201
|
+
// package whose bare `import x` passes but is actually broken still reads as
|
|
202
|
+
// not-ready. Falls back to the plain `import x` for everything else.
|
|
203
|
+
const stmt = pkg.probe ?? `import ${pkg.import}`;
|
|
204
|
+
return `"${venvPython}" -c "${stmt}"`;
|
|
205
|
+
}
|
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/banner.js
CHANGED
|
@@ -1 +1,31 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* ICOA logo — single source of truth for the big block-letter "ICOA" art.
|
|
3
|
+
*
|
|
4
|
+
* Defined ONCE here and reused by the boot banner (src/index.ts) and the learn
|
|
5
|
+
* score screens (learn-render.ts) so the logo can never drift between surfaces
|
|
6
|
+
* (same anti-drift principle as docker/constraints.txt). Raw, uncolored rows;
|
|
7
|
+
* callers apply chalk + indent.
|
|
8
|
+
*/
|
|
9
|
+
import chalk from 'chalk';
|
|
10
|
+
export const ICOA_BIG = [
|
|
11
|
+
'██╗ ██████╗ ██████╗ █████╗',
|
|
12
|
+
'██║██╔════╝██╔═══██╗██╔══██╗',
|
|
13
|
+
'██║██║ ██║ ██║███████║',
|
|
14
|
+
'██║██║ ██║ ██║██╔══██║',
|
|
15
|
+
'██║╚██████╗╚██████╔╝██║ ██║',
|
|
16
|
+
'╚═╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝',
|
|
17
|
+
];
|
|
18
|
+
/**
|
|
19
|
+
* Ready-to-print logo lines, bold white, indented by `indent`.
|
|
20
|
+
*
|
|
21
|
+
* Degrades to a one-line wordmark when the terminal is too narrow for the block
|
|
22
|
+
* art (Chromebook split-screen / narrow WSL pane), so it never overflow-wraps.
|
|
23
|
+
* 60 cols is the rich-display floor from the cross-platform baseline.
|
|
24
|
+
*/
|
|
25
|
+
export function icoaBannerLines(indent = ' ') {
|
|
26
|
+
const cols = process.stdout.columns || 80;
|
|
27
|
+
if (cols < 60) {
|
|
28
|
+
return [indent + chalk.bold.white('I C O A') + chalk.gray(' 2026')];
|
|
29
|
+
}
|
|
30
|
+
return ICOA_BIG.map((l) => indent + chalk.bold.white(l));
|
|
31
|
+
}
|
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
|
+
}
|
package/dist/lib/colors.js
CHANGED
|
@@ -1 +1,17 @@
|
|
|
1
|
-
|
|
1
|
+
// Darcula palette — from ICOA Terminal xterm.js theme spec.
|
|
2
|
+
// Use c.* helpers when you need brand-accurate truecolor (e.g. the orange
|
|
3
|
+
// accent #CC7832). For generic success/error/warning, chalk.green/.red/.yellow
|
|
4
|
+
// remain fine — they render as the terminal's Darcula 16-color when using an
|
|
5
|
+
// ICOA theme and as close-enough defaults elsewhere.
|
|
6
|
+
const tc = (r, g, b) => (s) => `\x1b[38;2;${r};${g};${b}m${s}\x1b[39m`;
|
|
7
|
+
export const c = {
|
|
8
|
+
fg: tc(169, 183, 198), // #A9B7C6 body text
|
|
9
|
+
muted: tc(85, 85, 85), // #555555 comments / secondary
|
|
10
|
+
red: tc(255, 107, 104), // #FF6B68 error
|
|
11
|
+
green: tc(168, 192, 35), // #A8C023 success
|
|
12
|
+
yellow: tc(214, 191, 85), // #D6BF55 warning
|
|
13
|
+
blue: tc(126, 174, 241), // #7EAEF1 link
|
|
14
|
+
cyan: tc(40, 123, 222), // #287BDE command / path
|
|
15
|
+
orange: tc(204, 120, 50), // #CC7832 brand accent
|
|
16
|
+
white: tc(255, 255, 255), // #FFFFFF highlight
|
|
17
|
+
};
|
package/dist/lib/comms.js
CHANGED
|
@@ -1 +1,212 @@
|
|
|
1
|
-
import chalk from
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { getConfig } from './config.js';
|
|
3
|
+
const MEL_HOST = 'au.icoa2026.au';
|
|
4
|
+
const MAX_TEXT_LEN = 500;
|
|
5
|
+
// eslint-disable-next-line no-control-regex
|
|
6
|
+
const ANSI_RE = /\x1b\[[0-9;?]*[ -/]*[@-~]/g;
|
|
7
|
+
// eslint-disable-next-line no-control-regex
|
|
8
|
+
const CTRL_RE = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g;
|
|
9
|
+
// Comms sub-screen (memo/jury) active flag. The main REPL wraps rl.prompt() to
|
|
10
|
+
// force computePrompt() (→ `icoa 2026>`) on every prompt; chat/exam sub-modes
|
|
11
|
+
// bypass that wrap via their own active flags. Memo/jury need the same, or the
|
|
12
|
+
// wrapper clobbers their `icoa memo>` / `icoa jury>` prompt back to `icoa 2026>`.
|
|
13
|
+
let _commsActive = false;
|
|
14
|
+
export function isCommsActive() {
|
|
15
|
+
return _commsActive;
|
|
16
|
+
}
|
|
17
|
+
export function setCommsActive(active) {
|
|
18
|
+
_commsActive = active;
|
|
19
|
+
}
|
|
20
|
+
// A leader token (printed on the leader's card) — `LDR` + confusable-free body.
|
|
21
|
+
// Typed at the join Username prompt; the CLI authenticates it via token-api
|
|
22
|
+
// instead of doing a CTFd login, so the leader never gets a web session.
|
|
23
|
+
export function isLeaderToken(s) {
|
|
24
|
+
return /^LDR[A-Z0-9]{10,}$/i.test((s || '').trim());
|
|
25
|
+
}
|
|
26
|
+
// A leader SESSION = authenticated by a leader token (config.leaderToken set).
|
|
27
|
+
// Distinct from isLeaderAccount (name-prefix), which still gates legacy logins.
|
|
28
|
+
export function isLeaderSession() {
|
|
29
|
+
return Boolean(getConfig().leaderToken);
|
|
30
|
+
}
|
|
31
|
+
export function isLeaderAccount(name) {
|
|
32
|
+
// Team-leader accounts use the `TMLD` prefix (confirmed 2026-06-23). This is
|
|
33
|
+
// collision-free: `ZZRH` and `ZZCP` are BOTH contestants (ZZRH = reused AU-camp
|
|
34
|
+
// student logins) and must never be blocked from challenges. Only `TMLD` gets
|
|
35
|
+
// the minimal leader REPL + the hidden `jury` command; everyone else competes.
|
|
36
|
+
// (Replaces the earlier broken `ZZRH`=leader rule and the interim fail-open.)
|
|
37
|
+
return (name || '').trim().toUpperCase().startsWith('TMLD');
|
|
38
|
+
}
|
|
39
|
+
export function sanitizeCommsLine(s) {
|
|
40
|
+
let out = (s ?? '').toString();
|
|
41
|
+
out = out.replace(ANSI_RE, '');
|
|
42
|
+
out = out.replace(/[\r\n\t]/g, ' ');
|
|
43
|
+
// remap (not delete) remaining control chars to space, matching comms_store.py
|
|
44
|
+
out = out.replace(CTRL_RE, ' ');
|
|
45
|
+
out = out.replace(/\s+/g, ' ').trim();
|
|
46
|
+
return out.slice(0, MAX_TEXT_LEN);
|
|
47
|
+
}
|
|
48
|
+
function hostOf(url) {
|
|
49
|
+
try {
|
|
50
|
+
return new URL(url).host;
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return (url || '').replace(/^https?:\/\//, '').replace(/\/.*$/, '');
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
export function commsBaseUrl() {
|
|
57
|
+
return (getConfig().ctfdUrl || '').replace(/\/+$/, '');
|
|
58
|
+
}
|
|
59
|
+
export function isCompetitionJoined() {
|
|
60
|
+
const c = getConfig();
|
|
61
|
+
// Contestants join with a CTFd token; leaders join with a leader token (no
|
|
62
|
+
// CTFd login). Either counts as "in the competition" for comms gating.
|
|
63
|
+
return Boolean((c.token || c.leaderToken) && c.ctfdUrl && c.userName);
|
|
64
|
+
}
|
|
65
|
+
export function isMelCompetition() {
|
|
66
|
+
return isCompetitionJoined() && hostOf(getConfig().ctfdUrl).endsWith(MEL_HOST);
|
|
67
|
+
}
|
|
68
|
+
export function formatBroadcastBar(text) {
|
|
69
|
+
const clean = sanitizeCommsLine(text);
|
|
70
|
+
if (!clean) {
|
|
71
|
+
return chalk.green(' ✓ No issues reported — enjoy the competition!');
|
|
72
|
+
}
|
|
73
|
+
const rule = '─'.repeat(49);
|
|
74
|
+
return [
|
|
75
|
+
chalk.yellow(` ${rule}`),
|
|
76
|
+
chalk.bold.yellow(' ⚠ BROADCAST ') + chalk.yellow(clean),
|
|
77
|
+
chalk.yellow(` ${rule}`),
|
|
78
|
+
].join('\n');
|
|
79
|
+
}
|
|
80
|
+
// ─── network ───
|
|
81
|
+
function authHeaders() {
|
|
82
|
+
const c = getConfig();
|
|
83
|
+
const h = { 'Content-Type': 'application/json' };
|
|
84
|
+
if (c.token)
|
|
85
|
+
h.Authorization = `Token ${c.token}`;
|
|
86
|
+
return h;
|
|
87
|
+
}
|
|
88
|
+
export async function fetchBroadcast() {
|
|
89
|
+
try {
|
|
90
|
+
const res = await fetch(`${commsBaseUrl()}/api/icoa/broadcast`, {
|
|
91
|
+
headers: authHeaders(),
|
|
92
|
+
signal: AbortSignal.timeout(5000),
|
|
93
|
+
});
|
|
94
|
+
if (!res.ok)
|
|
95
|
+
return { text: '', ts: null };
|
|
96
|
+
const json = await res.json();
|
|
97
|
+
const d = json?.data ?? {};
|
|
98
|
+
return { text: sanitizeCommsLine(d.text || ''), ts: d.ts ?? null };
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
return { text: '', ts: null };
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
async function postComms(kind, text) {
|
|
105
|
+
try {
|
|
106
|
+
const res = await fetch(`${commsBaseUrl()}/api/icoa/${kind}`, {
|
|
107
|
+
method: 'POST',
|
|
108
|
+
headers: authHeaders(),
|
|
109
|
+
// account = self-reported fallback for session-mode logins (no API token).
|
|
110
|
+
// leader_token = un-forgeable leader identity (server validates it) so a
|
|
111
|
+
// leader with no CTFd login can still file jury appeals.
|
|
112
|
+
// Server prefers token-verified > leader-token > self-reported account.
|
|
113
|
+
body: JSON.stringify({
|
|
114
|
+
text: sanitizeCommsLine(text),
|
|
115
|
+
account: getConfig().userName || '',
|
|
116
|
+
leader_token: getConfig().leaderToken || undefined,
|
|
117
|
+
}),
|
|
118
|
+
signal: AbortSignal.timeout(8000),
|
|
119
|
+
});
|
|
120
|
+
const json = await res.json().catch(() => ({}));
|
|
121
|
+
if (res.ok && json?.success) {
|
|
122
|
+
return { ok: true, id: json.data?.id, ts: json.data?.ts, status: res.status };
|
|
123
|
+
}
|
|
124
|
+
return { ok: false, status: res.status, message: json?.message };
|
|
125
|
+
}
|
|
126
|
+
catch (e) {
|
|
127
|
+
return { ok: false, status: 0, message: e?.message || 'network error' };
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
export function postMemo(text) {
|
|
131
|
+
return postComms('memo', text);
|
|
132
|
+
}
|
|
133
|
+
export function postJury(text) {
|
|
134
|
+
return postComms('jury', text);
|
|
135
|
+
}
|
|
136
|
+
// Authenticate a leader token against token-api. No CTFd login happens.
|
|
137
|
+
// Returns the resolved account + team on success, null otherwise.
|
|
138
|
+
export async function leaderAuth(baseUrl, token) {
|
|
139
|
+
try {
|
|
140
|
+
const res = await fetch(`${baseUrl.replace(/\/+$/, '')}/api/icoa/leader-auth`, {
|
|
141
|
+
method: 'POST',
|
|
142
|
+
headers: { 'Content-Type': 'application/json' },
|
|
143
|
+
body: JSON.stringify({ token: (token || '').trim() }),
|
|
144
|
+
signal: AbortSignal.timeout(8000),
|
|
145
|
+
});
|
|
146
|
+
const json = await res.json().catch(() => ({}));
|
|
147
|
+
if (res.ok && json?.success && json.data?.account) {
|
|
148
|
+
return { account: String(json.data.account), team: String(json.data.team || '') };
|
|
149
|
+
}
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
// Read the PUBLIC CTFd scoreboard (score_visibility=public → no auth needed).
|
|
157
|
+
// Leaders use this since they have no CTFd session. Returns [] on any failure.
|
|
158
|
+
export async function fetchPublicScoreboard() {
|
|
159
|
+
try {
|
|
160
|
+
const res = await fetch(`${commsBaseUrl()}/api/v1/scoreboard`, {
|
|
161
|
+
headers: { Accept: 'application/json' },
|
|
162
|
+
signal: AbortSignal.timeout(8000),
|
|
163
|
+
});
|
|
164
|
+
if (!res.ok)
|
|
165
|
+
return [];
|
|
166
|
+
const json = await res.json().catch(() => ({}));
|
|
167
|
+
const data = Array.isArray(json?.data) ? json.data : [];
|
|
168
|
+
return data.map((e, i) => ({
|
|
169
|
+
pos: e.pos ?? i + 1,
|
|
170
|
+
name: String(e.name ?? ''),
|
|
171
|
+
score: Number(e.score ?? 0),
|
|
172
|
+
}));
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
return [];
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
// ─── background poll + render ───
|
|
179
|
+
let pollTimer = null;
|
|
180
|
+
let cachedText = '';
|
|
181
|
+
let primed = false;
|
|
182
|
+
export function showBroadcast() {
|
|
183
|
+
console.log();
|
|
184
|
+
console.log(formatBroadcastBar(cachedText));
|
|
185
|
+
}
|
|
186
|
+
export function startBroadcastPoll(redraw) {
|
|
187
|
+
if (pollTimer)
|
|
188
|
+
return;
|
|
189
|
+
const tick = async () => {
|
|
190
|
+
const { text } = await fetchBroadcast();
|
|
191
|
+
const changed = text !== cachedText;
|
|
192
|
+
cachedText = text;
|
|
193
|
+
// Print a fresh alert when the text changes after the first prime — never
|
|
194
|
+
// on the very first fetch (that would clobber the join screen), and only a
|
|
195
|
+
// non-empty change is loud (clearing is silent).
|
|
196
|
+
if (primed && changed && text) {
|
|
197
|
+
console.log();
|
|
198
|
+
console.log(formatBroadcastBar(text));
|
|
199
|
+
redraw();
|
|
200
|
+
}
|
|
201
|
+
primed = true;
|
|
202
|
+
};
|
|
203
|
+
void tick();
|
|
204
|
+
pollTimer = setInterval(() => void tick(), 45_000);
|
|
205
|
+
}
|
|
206
|
+
export function stopBroadcastPoll() {
|
|
207
|
+
if (pollTimer)
|
|
208
|
+
clearInterval(pollTimer);
|
|
209
|
+
pollTimer = null;
|
|
210
|
+
cachedText = '';
|
|
211
|
+
primed = false;
|
|
212
|
+
}
|
package/dist/lib/config.js
CHANGED
|
@@ -1 +1,93 @@
|
|
|
1
|
-
import{mkdirSync
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync, existsSync, chmodSync, statSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { DEFAULT_CONFIG, DEFAULT_BUDGET } from '../types/index.js';
|
|
6
|
+
const ICOA_DIR = join(homedir(), '.icoa');
|
|
7
|
+
const CONFIG_FILE = join(ICOA_DIR, 'config.json');
|
|
8
|
+
const BUDGET_FILE = join(ICOA_DIR, 'budget.json');
|
|
9
|
+
// V16 fix (v2.19.181): config.json holds the exam token, geminiApiKey, and
|
|
10
|
+
// accessToken in plaintext. On shared/multi-user hosts (school labs, family
|
|
11
|
+
// machines) a world-readable 0o644 leaks them to anyone with a shell. We
|
|
12
|
+
// force 0o600 (owner-only read/write) at every write, and proactively re-
|
|
13
|
+
// chmod on read for files created before this fix.
|
|
14
|
+
function writePrivate(path, contents) {
|
|
15
|
+
// Write first so we can chmod afterwards. The Node fs.writeFile mode
|
|
16
|
+
// option only applies on file CREATION, not existing files — chmodSync
|
|
17
|
+
// is the reliable cross-platform path. Best-effort on Windows where
|
|
18
|
+
// POSIX modes don't apply.
|
|
19
|
+
writeFileSync(path, contents);
|
|
20
|
+
try {
|
|
21
|
+
chmodSync(path, 0o600);
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
/* ignore — Windows/restricted FS */
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function ensurePrivateMode(path) {
|
|
28
|
+
// For files written by older versions at 0o644: tighten on first read.
|
|
29
|
+
try {
|
|
30
|
+
const st = statSync(path);
|
|
31
|
+
// Mode low bits: group + other. If any non-zero, tighten.
|
|
32
|
+
if ((st.mode & 0o077) !== 0) {
|
|
33
|
+
chmodSync(path, 0o600);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
/* ignore */
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
function ensureDir() {
|
|
41
|
+
if (!existsSync(ICOA_DIR)) {
|
|
42
|
+
mkdirSync(ICOA_DIR, { recursive: true, mode: 0o700 });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
export function getConfig() {
|
|
46
|
+
ensureDir();
|
|
47
|
+
if (!existsSync(CONFIG_FILE)) {
|
|
48
|
+
const config = { ...DEFAULT_CONFIG, sessionId: randomUUID() };
|
|
49
|
+
writePrivate(CONFIG_FILE, JSON.stringify(config, null, 2));
|
|
50
|
+
return config;
|
|
51
|
+
}
|
|
52
|
+
ensurePrivateMode(CONFIG_FILE);
|
|
53
|
+
try {
|
|
54
|
+
const raw = readFileSync(CONFIG_FILE, 'utf-8');
|
|
55
|
+
return { ...DEFAULT_CONFIG, ...JSON.parse(raw) };
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return { ...DEFAULT_CONFIG, sessionId: randomUUID() };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
export function saveConfig(config) {
|
|
62
|
+
ensureDir();
|
|
63
|
+
const current = getConfig();
|
|
64
|
+
const merged = { ...current, ...config };
|
|
65
|
+
writePrivate(CONFIG_FILE, JSON.stringify(merged, null, 2));
|
|
66
|
+
}
|
|
67
|
+
export function getBudget() {
|
|
68
|
+
ensureDir();
|
|
69
|
+
if (!existsSync(BUDGET_FILE)) {
|
|
70
|
+
writePrivate(BUDGET_FILE, JSON.stringify(DEFAULT_BUDGET, null, 2));
|
|
71
|
+
return { ...DEFAULT_BUDGET };
|
|
72
|
+
}
|
|
73
|
+
ensurePrivateMode(BUDGET_FILE);
|
|
74
|
+
try {
|
|
75
|
+
const raw = readFileSync(BUDGET_FILE, 'utf-8');
|
|
76
|
+
return { ...DEFAULT_BUDGET, ...JSON.parse(raw) };
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return { ...DEFAULT_BUDGET };
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
export function saveBudget(budget) {
|
|
83
|
+
ensureDir();
|
|
84
|
+
writePrivate(BUDGET_FILE, JSON.stringify(budget, null, 2));
|
|
85
|
+
}
|
|
86
|
+
export function getIcoaDir() {
|
|
87
|
+
ensureDir();
|
|
88
|
+
return ICOA_DIR;
|
|
89
|
+
}
|
|
90
|
+
export function isConnected() {
|
|
91
|
+
const config = getConfig();
|
|
92
|
+
return !!(config.ctfdUrl && config.token);
|
|
93
|
+
}
|