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.
Files changed (81) hide show
  1. package/dist/commands/ai4ctf.js +1 -1
  2. package/dist/commands/arena.js +1 -1
  3. package/dist/commands/ctf.js +787 -1
  4. package/dist/commands/ctf4ai-demo.js +1 -1
  5. package/dist/commands/ctf4vla.js +1 -1
  6. package/dist/commands/demo2.js +1502 -1
  7. package/dist/commands/exam.js +1 -1
  8. package/dist/commands/files.js +59 -1
  9. package/dist/commands/ipynb.d.ts +10 -4
  10. package/dist/commands/ipynb.js +1 -1
  11. package/dist/commands/lang.js +202 -1
  12. package/dist/commands/log.js +171 -1
  13. package/dist/commands/shell.d.ts +15 -0
  14. package/dist/commands/shell.js +151 -1
  15. package/dist/commands/sim.js +389 -1
  16. package/dist/index.js +355 -1
  17. package/dist/lib/access.js +184 -1
  18. package/dist/lib/aienv.js +205 -1
  19. package/dist/lib/arena-submit.js +21 -1
  20. package/dist/lib/banner.js +31 -1
  21. package/dist/lib/budget.js +6 -1
  22. package/dist/lib/challenge-dir.js +16 -1
  23. package/dist/lib/colors.js +17 -1
  24. package/dist/lib/comms.js +212 -1
  25. package/dist/lib/config.js +93 -1
  26. package/dist/lib/countdown.js +43 -1
  27. package/dist/lib/country-lang.js +39 -1
  28. package/dist/lib/ctfd-client.js +417 -1
  29. package/dist/lib/demo-exam.js +478 -1
  30. package/dist/lib/demo-flags.js +27 -1
  31. package/dist/lib/demo-stats.js +62 -1
  32. package/dist/lib/demo2-progress.js +102 -1
  33. package/dist/lib/docker-probe.d.ts +45 -0
  34. package/dist/lib/docker-probe.js +118 -0
  35. package/dist/lib/editor-spawn.d.ts +23 -0
  36. package/dist/lib/editor-spawn.js +53 -0
  37. package/dist/lib/exam-client.js +54 -1
  38. package/dist/lib/exam-sandbox.js +201 -1
  39. package/dist/lib/exam-setup.js +36 -1
  40. package/dist/lib/exam-state.js +273 -1
  41. package/dist/lib/gemini.js +247 -1
  42. package/dist/lib/i18n.js +302 -1
  43. package/dist/lib/integrity-snapshot.js +88 -1
  44. package/dist/lib/interactive-spawn.js +55 -1
  45. package/dist/lib/ipynb-input.js +65 -1
  46. package/dist/lib/kernel-protocol.js +88 -1
  47. package/dist/lib/kernel.js +146 -2
  48. package/dist/lib/learn-curricula.js +309 -1
  49. package/dist/lib/learn-i18n.js +184 -1
  50. package/dist/lib/learn-input.js +101 -1
  51. package/dist/lib/learn-render.js +863 -1
  52. package/dist/lib/learn-state.js +103 -1
  53. package/dist/lib/log-sync.js +155 -1
  54. package/dist/lib/logger.js +49 -1
  55. package/dist/lib/main-rl.js +7 -1
  56. package/dist/lib/menu-nav.js +105 -1
  57. package/dist/lib/notebook-doc.d.ts +38 -0
  58. package/dist/lib/notebook-doc.js +137 -0
  59. package/dist/lib/open-file.js +55 -1
  60. package/dist/lib/paper-upgrade.js +119 -1
  61. package/dist/lib/platform.js +99 -1
  62. package/dist/lib/render-card.js +112 -1
  63. package/dist/lib/repl-asker.js +67 -1
  64. package/dist/lib/sample-runner.js +227 -1
  65. package/dist/lib/sandbox.d.ts +25 -1
  66. package/dist/lib/sandbox.js +144 -1
  67. package/dist/lib/shell-split.js +69 -1
  68. package/dist/lib/sim-cooldown.js +75 -1
  69. package/dist/lib/theme.js +119 -1
  70. package/dist/lib/token-format.js +74 -1
  71. package/dist/lib/tool-man.js +418 -1
  72. package/dist/lib/toolset-hash.js +48 -1
  73. package/dist/lib/translation.js +80 -1
  74. package/dist/lib/translations-fetcher.js +95 -1
  75. package/dist/lib/ui.js +99 -1
  76. package/dist/lib/update-check.js +114 -1
  77. package/dist/lib/version.js +24 -1
  78. package/dist/postinstall.js +48 -1
  79. package/dist/repl.js +2391 -1
  80. package/dist/types/index.js +63 -1
  81. package/package.json +1 -1
package/dist/lib/aienv.js CHANGED
@@ -1 +1,205 @@
1
- import{posix as o,win32 as t}from"node:path";export const REQUIRED_PY={major:3,minor:12};export const AIENV_MARKER_VERSION=2;export const AIENV_PACKAGES=[{name:"jupyter_client",import:"jupyter_client",spec:"jupyter_client>=8.6",group:"core"},{name:"ipykernel",import:"ipykernel",spec:"ipykernel>=6.29",group:"core"},{name:"numpy",import:"numpy",spec:"numpy>=1.26",group:"data"},{name:"scipy",import:"scipy",spec:"scipy>=1.13",group:"data"},{name:"pandas",import:"pandas",spec:"pandas>=2.2",group:"data"},{name:"scikit-learn",import:"sklearn",spec:"scikit-learn>=1.4",group:"data"},{name:"matplotlib",import:"matplotlib",spec:"matplotlib>=3.8",group:"data"},{name:"scikit-image",import:"skimage",spec:"scikit-image>=0.23",group:"data"},{name:"Pillow",import:"PIL",spec:"Pillow>=10.2",group:"data",probe:"from PIL import Image"},{name:"imageio",import:"imageio",spec:"imageio>=2.34",group:"data"},{name:"requests",import:"requests",spec:"requests>=2.31",group:"data"},{name:"pyyaml",import:"yaml",spec:"pyyaml>=6.0",group:"data"},{name:"tqdm",import:"tqdm",spec:"tqdm>=4.66",group:"data"},{name:"torch",import:"torch",spec:"torch>=2.2",group:"deep",note:"large download (~2 GB)"},{name:"transformers",import:"transformers",spec:"transformers>=4.40",group:"deep"},{name:"datasets",import:"datasets",spec:"datasets>=2.19",group:"deep"},{name:"mujoco",import:"mujoco",spec:"mujoco==3.9.0",group:"physics",note:"CPU physics for the ctf4eai arena (~60 MB, version-pinned for fair judging)"}];export function packagesForGroups(o){const t=new Set(o);return AIENV_PACKAGES.filter(o=>t.has(o.group))}export function aienvPaths(n,r=process.platform){const e="win32"===r?t:o,p=e.join(n,".icoa","aienv");if("win32"===r){const o=e.join(p,"Scripts");return{root:p,binDir:o,python:e.join(o,"python.exe"),pip:e.join(o,"pip.exe"),marker:e.join(p,"icoa-aienv.json")}}const i=e.join(p,"bin");return{root:p,binDir:i,python:e.join(i,"python"),pip:e.join(i,"pip"),marker:e.join(p,"icoa-aienv.json")}}export function icoaStandalonePython(n,r=process.platform){const e="win32"===r?t:o;return"win32"===r?e.join(n,".icoa","python312","python.exe"):e.join(n,".icoa","python312","bin","python3.12")}export function hostPythonCandidates(o=process.platform,t){const n=t?[icoaStandalonePython(t,o)]:[];return"win32"===o?["py -3.12",...n,"python","py -3","py"]:"darwin"===o?["python3.12","/opt/homebrew/opt/python@3.12/bin/python3.12","/usr/local/opt/python@3.12/bin/python3.12","python3"]:["python3.12",...n,"python3"]}export function parsePyVersion(o){if(!o)return null;const t=o.match(/(\d+)\.(\d+)(?:\.\d+)?/);return t?{major:Number(t[1]),minor:Number(t[2])}:null}export function meetsMinPy(o,t){return o.major!==t.major?o.major>t.major:o.minor>=t.minor}export function resolveHostPython(o,t=process.platform,n){for(const r of hostPythonCandidates(t,n)){const t=parsePyVersion(o(r));if(t&&t.major===REQUIRED_PY.major&&t.minor===REQUIRED_PY.minor)return{cmd:r,version:t}}return null}export function importCheckCmd(o,t){return`"${o}" -c "${t.probe??`import ${t.import}`}"`}
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
+ }
@@ -1 +1,21 @@
1
- export function buildSubmitPayload(e,r,t){let i=e.trim();return i.startsWith("file://")&&(i=i.slice(7)),i&&t.exists(i)?{arena_token:r,predictions_csv:t.read(i),source:"local"}:{arena_token:r,gdrive_url:e.trim(),source:"gdrive"}}
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
+ }
@@ -1 +1,31 @@
1
- import chalk from"chalk";export const ICOA_BIG=["██╗ ██████╗ ██████╗ █████╗","██║██╔════╝██╔═══██╗██╔══██╗","██║██║ ██║ ██║███████║","██║██║ ██║ ██║██╔══██║","██║╚██████╗╚██████╔╝██║ ██║","╚═╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝"];export function icoaBannerLines(o=" "){return(process.stdout.columns||80)<60?[o+chalk.bold.white("I C O A")+chalk.gray(" 2026")]:ICOA_BIG.map(t=>o+chalk.bold.white(t))}
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
+ }
@@ -1 +1,6 @@
1
- import{getBudget as o,saveBudget as n}from"./config.js";export function addTokenUsage(e){const s=o();s.tokensUsed+=e,n(s)}
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 as o}from"node:path";import{getActiveCwd as r}from"./exam-sandbox.js";export function challengeDownloadDir(n,e=r()){return o(e,n)}
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
+ }
@@ -1 +1,17 @@
1
- const e=(e,n,o)=>t=>`[38;2;${e};${n};${o}m${t}`;export const c={fg:e(169,183,198),muted:e(85,85,85),red:e(255,107,104),green:e(168,192,35),yellow:e(214,191,85),blue:e(126,174,241),cyan:e(40,123,222),orange:e(204,120,50),white:e(255,255,255)};
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"chalk";import{getConfig as t}from"./config.js";const e=/\x1b\[[0-9;?]*[ -/]*[@-~]/g,o=/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g;let n=!1;export function isCommsActive(){return n}export function setCommsActive(t){n=t}export function isLeaderToken(t){return/^LDR[A-Z0-9]{10,}$/i.test((t||"").trim())}export function isLeaderSession(){return Boolean(t().leaderToken)}export function isLeaderAccount(t){return(t||"").trim().toUpperCase().startsWith("TMLD")}export function sanitizeCommsLine(t){let n=(t??"").toString();return n=n.replace(e,""),n=n.replace(/[\r\n\t]/g," "),n=n.replace(o," "),n=n.replace(/\s+/g," ").trim(),n.slice(0,500)}export function commsBaseUrl(){return(t().ctfdUrl||"").replace(/\/+$/,"")}export function isCompetitionJoined(){const e=t();return Boolean((e.token||e.leaderToken)&&e.ctfdUrl&&e.userName)}export function isMelCompetition(){return isCompetitionJoined()&&function(t){try{return new URL(t).host}catch{return(t||"").replace(/^https?:\/\//,"").replace(/\/.*$/,"")}}(t().ctfdUrl).endsWith("au.icoa2026.au")}export function formatBroadcastBar(t){const e=sanitizeCommsLine(t);if(!e)return chalk.green(" ✓ No issues reported — enjoy the competition!");const o="─".repeat(49);return[chalk.yellow(` ${o}`),chalk.bold.yellow(" ⚠ BROADCAST ")+chalk.yellow(e),chalk.yellow(` ${o}`)].join("\n")}function r(){const e=t(),o={"Content-Type":"application/json"};return e.token&&(o.Authorization=`Token ${e.token}`),o}export async function fetchBroadcast(){try{const t=await fetch(`${commsBaseUrl()}/api/icoa/broadcast`,{headers:r(),signal:AbortSignal.timeout(5e3)});if(!t.ok)return{text:"",ts:null};const e=await t.json(),o=e?.data??{};return{text:sanitizeCommsLine(o.text||""),ts:o.ts??null}}catch{return{text:"",ts:null}}}async function a(e,o){try{const n=await fetch(`${commsBaseUrl()}/api/icoa/${e}`,{method:"POST",headers:r(),body:JSON.stringify({text:sanitizeCommsLine(o),account:t().userName||"",leader_token:t().leaderToken||void 0}),signal:AbortSignal.timeout(8e3)}),a=await n.json().catch(()=>({}));return n.ok&&a?.success?{ok:!0,id:a.data?.id,ts:a.data?.ts,status:n.status}:{ok:!1,status:n.status,message:a?.message}}catch(t){return{ok:!1,status:0,message:t?.message||"network error"}}}export function postMemo(t){return a("memo",t)}export function postJury(t){return a("jury",t)}export async function leaderAuth(t,e){try{const o=await fetch(`${t.replace(/\/+$/,"")}/api/icoa/leader-auth`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({token:(e||"").trim()}),signal:AbortSignal.timeout(8e3)}),n=await o.json().catch(()=>({}));return o.ok&&n?.success&&n.data?.account?{account:String(n.data.account),team:String(n.data.team||"")}:null}catch{return null}}export async function fetchPublicScoreboard(){try{const t=await fetch(`${commsBaseUrl()}/api/v1/scoreboard`,{headers:{Accept:"application/json"},signal:AbortSignal.timeout(8e3)});if(!t.ok)return[];const e=await t.json().catch(()=>({}));return(Array.isArray(e?.data)?e.data:[]).map((t,e)=>({pos:t.pos??e+1,name:String(t.name??""),score:Number(t.score??0)}))}catch{return[]}}let s=null,c="",i=!1;export function showBroadcast(){console.log(),console.log(formatBroadcastBar(c))}export function startBroadcastPoll(t){if(s)return;const e=async()=>{const{text:e}=await fetchBroadcast(),o=e!==c;c=e,i&&o&&e&&(console.log(),console.log(formatBroadcastBar(e)),t()),i=!0};e(),s=setInterval(()=>{e()},45e3)}export function stopBroadcastPoll(){s&&clearInterval(s),s=null,c="",i=!1}
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
+ }
@@ -1 +1,93 @@
1
- import{mkdirSync as t,readFileSync as n,writeFileSync as o,existsSync as r,chmodSync as e,statSync as i}from"node:fs";import{join as c}from"node:path";import{homedir as f}from"node:os";import{randomUUID as s}from"node:crypto";import{DEFAULT_CONFIG as u,DEFAULT_BUDGET as g}from"../types/index.js";const p=c(f(),".icoa"),d=c(p,"config.json"),m=c(p,"budget.json");function a(t,n){o(t,n);try{e(t,384)}catch{}}function y(t){try{63&i(t).mode&&e(t,384)}catch{}}function l(){r(p)||t(p,{recursive:!0,mode:448})}export function getConfig(){if(l(),!r(d)){const t={...u,sessionId:s()};return a(d,JSON.stringify(t,null,2)),t}y(d);try{const t=n(d,"utf-8");return{...u,...JSON.parse(t)}}catch{return{...u,sessionId:s()}}}export function saveConfig(t){l();const n={...getConfig(),...t};a(d,JSON.stringify(n,null,2))}export function getBudget(){if(l(),!r(m))return a(m,JSON.stringify(g,null,2)),{...g};y(m);try{const t=n(m,"utf-8");return{...g,...JSON.parse(t)}}catch{return{...g}}}export function saveBudget(t){l(),a(m,JSON.stringify(t,null,2))}export function getIcoaDir(){return l(),p}export function isConnected(){const t=getConfig();return!(!t.ctfdUrl||!t.token)}
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
+ }