icoa-cli 2.19.356 → 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/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/lang.js +202 -1
- package/dist/commands/log.js +171 -1
- 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.js +53 -1
- 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.js +137 -1
- 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 +7 -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
|
@@ -1 +1,102 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* demo2 progress persistence — opt-in resume + replay hooks.
|
|
3
|
+
*
|
|
4
|
+
* State lives in `~/.icoa/demo2-progress.json`. Records the last card
|
|
5
|
+
* index the user finished plus a completedAt timestamp when they reach
|
|
6
|
+
* the outro. Auto-clears anything older than 7 days so re-runs after a
|
|
7
|
+
* gap feel like a fresh start (no manual cleanup needed — see
|
|
8
|
+
* feedback_no_manual_rm).
|
|
9
|
+
*
|
|
10
|
+
* Out of scope: scores, answers, language history. Those are run-scoped
|
|
11
|
+
* and re-initialised every call to runDemo2Once().
|
|
12
|
+
*/
|
|
13
|
+
import { mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
import { homedir } from 'node:os';
|
|
16
|
+
const PROGRESS_FILE = join(homedir(), '.icoa', 'demo2-progress.json');
|
|
17
|
+
// Auto-expire stale partial progress so coming back next month re-greets
|
|
18
|
+
// the user fresh instead of dangling on card 5/10.
|
|
19
|
+
const STALE_AFTER_MS = 7 * 24 * 60 * 60 * 1000;
|
|
20
|
+
function ensureDir() {
|
|
21
|
+
try {
|
|
22
|
+
mkdirSync(join(homedir(), '.icoa'), { recursive: true });
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
// Non-fatal — caller's writeFileSync will surface the real failure
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Load progress, auto-clearing anything stale. Returns null if no usable
|
|
30
|
+
* record exists.
|
|
31
|
+
*/
|
|
32
|
+
export function loadDemo2Progress() {
|
|
33
|
+
let raw;
|
|
34
|
+
try {
|
|
35
|
+
raw = readFileSync(PROGRESS_FILE, 'utf-8');
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
let parsed;
|
|
41
|
+
try {
|
|
42
|
+
parsed = JSON.parse(raw);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
clearDemo2Progress();
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
if (typeof parsed.nextCardIndex !== 'number' ||
|
|
49
|
+
typeof parsed.totalCards !== 'number' ||
|
|
50
|
+
typeof parsed.lang !== 'string' ||
|
|
51
|
+
typeof parsed.startedAt !== 'number') {
|
|
52
|
+
clearDemo2Progress();
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
const anchor = parsed.completedAt ?? parsed.startedAt;
|
|
56
|
+
if (typeof anchor === 'number' && Date.now() - anchor > STALE_AFTER_MS) {
|
|
57
|
+
clearDemo2Progress();
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
nextCardIndex: parsed.nextCardIndex,
|
|
62
|
+
totalCards: parsed.totalCards,
|
|
63
|
+
lang: parsed.lang,
|
|
64
|
+
startedAt: parsed.startedAt,
|
|
65
|
+
completedAt: typeof parsed.completedAt === 'number' ? parsed.completedAt : undefined,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
export function saveDemo2Progress(p) {
|
|
69
|
+
ensureDir();
|
|
70
|
+
try {
|
|
71
|
+
writeFileSync(PROGRESS_FILE, JSON.stringify(p));
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
// Non-fatal — progress is convenience, not correctness
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
export function clearDemo2Progress() {
|
|
78
|
+
try {
|
|
79
|
+
unlinkSync(PROGRESS_FILE);
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
// Already absent — fine
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/** Convenience: record that the user finished card N (0-based). */
|
|
86
|
+
export function markCardDone(cardIndex, totalCards, lang, startedAt) {
|
|
87
|
+
saveDemo2Progress({
|
|
88
|
+
nextCardIndex: cardIndex + 1,
|
|
89
|
+
totalCards,
|
|
90
|
+
lang,
|
|
91
|
+
startedAt,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
export function markDemo2Complete(totalCards, lang, startedAt) {
|
|
95
|
+
saveDemo2Progress({
|
|
96
|
+
nextCardIndex: totalCards,
|
|
97
|
+
totalCards,
|
|
98
|
+
lang,
|
|
99
|
+
startedAt,
|
|
100
|
+
completedAt: Date.now(),
|
|
101
|
+
});
|
|
102
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detect whether the Docker daemon is running WITHOUT waking it.
|
|
3
|
+
*
|
|
4
|
+
* The `docker` CLI is the trigger: on macOS, running any `docker` command
|
|
5
|
+
* (including `docker info`) can auto-launch Docker Desktop. The old probe
|
|
6
|
+
* shelled out to `docker info`, so every REPL shell-out and every `shell`
|
|
7
|
+
* invocation kept starting Docker on the user's Mac even though the sandbox is
|
|
8
|
+
* OPTIONAL and the host-shell fallback is the norm (2026-07 feedback).
|
|
9
|
+
*
|
|
10
|
+
* This probes the daemon's listening SOCKET directly (Node `net`) and never
|
|
11
|
+
* invokes the `docker` binary. Docker already up → the socket connects → we use
|
|
12
|
+
* the sandbox. Docker down → connection refused → we silently use the host
|
|
13
|
+
* shell, and Docker is never started.
|
|
14
|
+
*/
|
|
15
|
+
export type DockerProbeKind = 'unix' | 'tcp' | 'npipe';
|
|
16
|
+
export interface DockerProbeTarget {
|
|
17
|
+
kind: DockerProbeKind;
|
|
18
|
+
/** unix/npipe: a filesystem/pipe path. tcp: "host:port". */
|
|
19
|
+
target: string;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Ordered daemon endpoints to probe. A `DOCKER_HOST` (unix://, tcp://, npipe://)
|
|
23
|
+
* overrides everything — it's the user's explicit daemon. Otherwise fall back to
|
|
24
|
+
* the well-known per-platform sockets, most-specific first. Pure + testable:
|
|
25
|
+
* platform/env/home are all injected.
|
|
26
|
+
*/
|
|
27
|
+
export declare function dockerProbeTargets(platform: NodeJS.Platform, env: NodeJS.ProcessEnv, home: string): DockerProbeTarget[];
|
|
28
|
+
/**
|
|
29
|
+
* True if ANY target accepts a connection, probing in order and stopping at the
|
|
30
|
+
* first hit. `connect` is injected (returns whether a target is reachable) so
|
|
31
|
+
* the ordering/short-circuit is unit-tested without real sockets.
|
|
32
|
+
*/
|
|
33
|
+
export declare function anyReachable(targets: DockerProbeTarget[], connect: (t: DockerProbeTarget) => Promise<boolean>): Promise<boolean>;
|
|
34
|
+
/**
|
|
35
|
+
* True iff `raw` is a Docker Engine `/_ping` response from a LIVE daemon — an
|
|
36
|
+
* HTTP 200. A bare connect isn't enough: after Docker Desktop quits, a lingering
|
|
37
|
+
* helper can keep the socket connectable while the daemon is down, so we require
|
|
38
|
+
* the daemon to actually answer the API. Pure + tested.
|
|
39
|
+
*/
|
|
40
|
+
export declare function isDockerPingResponse(raw: string): boolean;
|
|
41
|
+
/**
|
|
42
|
+
* The public check: is a Docker daemon reachable right now, without starting it?
|
|
43
|
+
* platform/env/home are injectable for tests; production uses process defaults.
|
|
44
|
+
*/
|
|
45
|
+
export declare function isDockerRunning(platform?: NodeJS.Platform, env?: NodeJS.ProcessEnv, home?: string): Promise<boolean>;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detect whether the Docker daemon is running WITHOUT waking it.
|
|
3
|
+
*
|
|
4
|
+
* The `docker` CLI is the trigger: on macOS, running any `docker` command
|
|
5
|
+
* (including `docker info`) can auto-launch Docker Desktop. The old probe
|
|
6
|
+
* shelled out to `docker info`, so every REPL shell-out and every `shell`
|
|
7
|
+
* invocation kept starting Docker on the user's Mac even though the sandbox is
|
|
8
|
+
* OPTIONAL and the host-shell fallback is the norm (2026-07 feedback).
|
|
9
|
+
*
|
|
10
|
+
* This probes the daemon's listening SOCKET directly (Node `net`) and never
|
|
11
|
+
* invokes the `docker` binary. Docker already up → the socket connects → we use
|
|
12
|
+
* the sandbox. Docker down → connection refused → we silently use the host
|
|
13
|
+
* shell, and Docker is never started.
|
|
14
|
+
*/
|
|
15
|
+
import net from 'node:net';
|
|
16
|
+
/**
|
|
17
|
+
* Ordered daemon endpoints to probe. A `DOCKER_HOST` (unix://, tcp://, npipe://)
|
|
18
|
+
* overrides everything — it's the user's explicit daemon. Otherwise fall back to
|
|
19
|
+
* the well-known per-platform sockets, most-specific first. Pure + testable:
|
|
20
|
+
* platform/env/home are all injected.
|
|
21
|
+
*/
|
|
22
|
+
export function dockerProbeTargets(platform, env, home) {
|
|
23
|
+
const host = env.DOCKER_HOST?.trim();
|
|
24
|
+
if (host) {
|
|
25
|
+
if (host.startsWith('unix://'))
|
|
26
|
+
return [{ kind: 'unix', target: host.slice('unix://'.length) }];
|
|
27
|
+
if (host.startsWith('tcp://'))
|
|
28
|
+
return [{ kind: 'tcp', target: host.slice('tcp://'.length) }];
|
|
29
|
+
if (host.startsWith('npipe://'))
|
|
30
|
+
return [{ kind: 'npipe', target: host.slice('npipe://'.length) }];
|
|
31
|
+
// Unknown scheme — ignore and fall through to platform defaults.
|
|
32
|
+
}
|
|
33
|
+
if (platform === 'win32') {
|
|
34
|
+
return [{ kind: 'npipe', target: '\\\\.\\pipe\\docker_engine' }];
|
|
35
|
+
}
|
|
36
|
+
if (platform === 'darwin') {
|
|
37
|
+
return [
|
|
38
|
+
{ kind: 'unix', target: `${home}/.docker/run/docker.sock` },
|
|
39
|
+
{ kind: 'unix', target: '/var/run/docker.sock' },
|
|
40
|
+
];
|
|
41
|
+
}
|
|
42
|
+
// linux / wsl / crostini
|
|
43
|
+
const targets = [{ kind: 'unix', target: '/var/run/docker.sock' }];
|
|
44
|
+
const xdg = env.XDG_RUNTIME_DIR?.trim();
|
|
45
|
+
if (xdg)
|
|
46
|
+
targets.push({ kind: 'unix', target: `${xdg}/docker.sock` }); // rootless docker
|
|
47
|
+
targets.push({ kind: 'unix', target: `${home}/.docker/run/docker.sock` }); // Docker Desktop for Linux
|
|
48
|
+
return targets;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* True if ANY target accepts a connection, probing in order and stopping at the
|
|
52
|
+
* first hit. `connect` is injected (returns whether a target is reachable) so
|
|
53
|
+
* the ordering/short-circuit is unit-tested without real sockets.
|
|
54
|
+
*/
|
|
55
|
+
export async function anyReachable(targets, connect) {
|
|
56
|
+
for (const t of targets) {
|
|
57
|
+
if (await connect(t))
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* True iff `raw` is a Docker Engine `/_ping` response from a LIVE daemon — an
|
|
64
|
+
* HTTP 200. A bare connect isn't enough: after Docker Desktop quits, a lingering
|
|
65
|
+
* helper can keep the socket connectable while the daemon is down, so we require
|
|
66
|
+
* the daemon to actually answer the API. Pure + tested.
|
|
67
|
+
*/
|
|
68
|
+
export function isDockerPingResponse(raw) {
|
|
69
|
+
return /^HTTP\/1\.[01] 200\b/.test(raw.trimStart());
|
|
70
|
+
}
|
|
71
|
+
// A minimal, version-less Engine API ping. Connection: close so the daemon ends
|
|
72
|
+
// the response and we don't hold the socket.
|
|
73
|
+
const PING_REQUEST = 'GET /_ping HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n';
|
|
74
|
+
/**
|
|
75
|
+
* Real socket probe: connect, ask the Engine API `/_ping`, resolve true only on
|
|
76
|
+
* a 200. Uses `net.connect` (NOT the docker CLI), so a stopped Docker Desktop is
|
|
77
|
+
* NOT auto-started. Short timeout — the socket is local, a live daemon answers
|
|
78
|
+
* in milliseconds.
|
|
79
|
+
*/
|
|
80
|
+
function connectTarget(t, timeoutMs = 600) {
|
|
81
|
+
return new Promise((resolve) => {
|
|
82
|
+
let settled = false;
|
|
83
|
+
let buf = '';
|
|
84
|
+
const finish = (v) => {
|
|
85
|
+
if (settled)
|
|
86
|
+
return;
|
|
87
|
+
settled = true;
|
|
88
|
+
try {
|
|
89
|
+
socket.destroy();
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
/* ignore */
|
|
93
|
+
}
|
|
94
|
+
resolve(v);
|
|
95
|
+
};
|
|
96
|
+
const socket = t.kind === 'tcp'
|
|
97
|
+
? net.connect({ host: t.target.split(':')[0], port: Number(t.target.split(':')[1]) })
|
|
98
|
+
: net.connect(t.target); // unix socket path or windows named pipe
|
|
99
|
+
socket.setTimeout(timeoutMs);
|
|
100
|
+
socket.once('connect', () => socket.write(PING_REQUEST));
|
|
101
|
+
socket.on('data', (chunk) => {
|
|
102
|
+
buf += chunk.toString('utf8');
|
|
103
|
+
// The status line arrives in the first packet; decide as soon as we have it.
|
|
104
|
+
if (buf.includes('\n') || buf.length > 15)
|
|
105
|
+
finish(isDockerPingResponse(buf));
|
|
106
|
+
});
|
|
107
|
+
socket.once('timeout', () => finish(false));
|
|
108
|
+
socket.once('error', () => finish(false));
|
|
109
|
+
socket.once('close', () => finish(isDockerPingResponse(buf)));
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* The public check: is a Docker daemon reachable right now, without starting it?
|
|
114
|
+
* platform/env/home are injectable for tests; production uses process defaults.
|
|
115
|
+
*/
|
|
116
|
+
export async function isDockerRunning(platform = process.platform, env = process.env, home = process.env.HOME || process.env.USERPROFILE || '') {
|
|
117
|
+
return anyReachable(dockerProbeTargets(platform, env, home), connectTarget);
|
|
118
|
+
}
|
package/dist/lib/editor-spawn.js
CHANGED
|
@@ -1 +1,53 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* editor-spawn — hand the terminal to the user's $EDITOR for one text
|
|
3
|
+
* fragment, then take it back. The `git commit` / `crontab -e` idiom: write a
|
|
4
|
+
* temp file, block on the editor, read the file back.
|
|
5
|
+
*
|
|
6
|
+
* BUG-008 note: this must NOT create any readline. `spawnSync` blocks the
|
|
7
|
+
* event loop, so the main REPL's readline cannot fire while the editor owns
|
|
8
|
+
* the TTY; we only save/drop/restore raw mode around the child (editors expect
|
|
9
|
+
* a cooked terminal and manage their own termios).
|
|
10
|
+
*/
|
|
11
|
+
import { spawnSync } from 'node:child_process';
|
|
12
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
13
|
+
import { tmpdir } from 'node:os';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
/** $VISUAL → $EDITOR → nano (notepad on native Windows). Values like
|
|
16
|
+
* "code -w" split on whitespace into cmd + args. Pure for testability. */
|
|
17
|
+
export function resolveEditor(env = process.env, platform = process.platform) {
|
|
18
|
+
const raw = (env.VISUAL ?? '').trim() || (env.EDITOR ?? '').trim();
|
|
19
|
+
if (raw) {
|
|
20
|
+
const parts = raw.split(/\s+/);
|
|
21
|
+
return { cmd: parts[0], args: parts.slice(1) };
|
|
22
|
+
}
|
|
23
|
+
return platform === 'win32' ? { cmd: 'notepad', args: [] } : { cmd: 'nano', args: [] };
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Open `initial` in the user's editor; return the edited text, or null if the
|
|
27
|
+
* editor failed to launch or exited non-zero (a deliberate cancel, vi `:cq`).
|
|
28
|
+
* Caller is expected to `rl.pause()` before and `rl.resume()` after.
|
|
29
|
+
*/
|
|
30
|
+
export function editTextInEditor(initial, suffix = '.py') {
|
|
31
|
+
const dir = mkdtempSync(join(tmpdir(), 'icoa-cell-'));
|
|
32
|
+
const file = join(dir, `cell${suffix}`);
|
|
33
|
+
writeFileSync(file, initial === '' || initial.endsWith('\n') ? initial : `${initial}\n`);
|
|
34
|
+
const ed = resolveEditor();
|
|
35
|
+
const stdin = process.stdin;
|
|
36
|
+
const wasRaw = stdin.isTTY ? stdin.isRaw : false;
|
|
37
|
+
try {
|
|
38
|
+
if (stdin.isTTY)
|
|
39
|
+
stdin.setRawMode(false);
|
|
40
|
+
const r = spawnSync(ed.cmd, [...ed.args, file], { stdio: 'inherit' });
|
|
41
|
+
if (r.error || r.status !== 0)
|
|
42
|
+
return null;
|
|
43
|
+
return readFileSync(file, 'utf8');
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
finally {
|
|
49
|
+
if (stdin.isTTY && wasRaw)
|
|
50
|
+
stdin.setRawMode(true);
|
|
51
|
+
rmSync(dir, { recursive: true, force: true });
|
|
52
|
+
}
|
|
53
|
+
}
|
package/dist/lib/exam-client.js
CHANGED
|
@@ -1 +1,54 @@
|
|
|
1
|
-
export class ExamClient
|
|
1
|
+
export class ExamClient {
|
|
2
|
+
baseUrl;
|
|
3
|
+
token;
|
|
4
|
+
constructor(baseUrl, token) {
|
|
5
|
+
this.baseUrl = baseUrl.replace(/\/+$/, '');
|
|
6
|
+
this.token = token;
|
|
7
|
+
}
|
|
8
|
+
async request(method, path, body) {
|
|
9
|
+
// Try nginx proxy first, fallback to direct port
|
|
10
|
+
const urls = [`${this.baseUrl}/api/icoa/exams${path}`, `${this.baseUrl}:9090/api/icoa/exams${path}`];
|
|
11
|
+
let lastError = null;
|
|
12
|
+
for (const url of urls) {
|
|
13
|
+
try {
|
|
14
|
+
return await this._fetch(method, url, body);
|
|
15
|
+
}
|
|
16
|
+
catch (e) {
|
|
17
|
+
lastError = e;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
throw lastError || new Error('Exam API unreachable');
|
|
21
|
+
}
|
|
22
|
+
async _fetch(method, url, body) {
|
|
23
|
+
const res = await fetch(url, {
|
|
24
|
+
method,
|
|
25
|
+
headers: {
|
|
26
|
+
Authorization: `Token ${this.token}`,
|
|
27
|
+
'Content-Type': 'application/json',
|
|
28
|
+
},
|
|
29
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
30
|
+
signal: AbortSignal.timeout(10000),
|
|
31
|
+
});
|
|
32
|
+
if (!res.ok) {
|
|
33
|
+
const text = await res.text().catch(() => 'Unknown error');
|
|
34
|
+
throw new Error(`Exam API error (${res.status}): ${text}`);
|
|
35
|
+
}
|
|
36
|
+
const json = (await res.json());
|
|
37
|
+
if (json.success === false) {
|
|
38
|
+
throw new Error(json.message || 'Exam API error');
|
|
39
|
+
}
|
|
40
|
+
return json.data;
|
|
41
|
+
}
|
|
42
|
+
async getExams() {
|
|
43
|
+
return this.request('GET', '');
|
|
44
|
+
}
|
|
45
|
+
async startExam(examId) {
|
|
46
|
+
return this.request('POST', `/${examId}/start`);
|
|
47
|
+
}
|
|
48
|
+
async submitExam(examId, answers) {
|
|
49
|
+
return this.request('POST', `/${examId}/submit`, { answers });
|
|
50
|
+
}
|
|
51
|
+
async getResult(examId) {
|
|
52
|
+
return this.request('GET', `/${examId}/result`);
|
|
53
|
+
}
|
|
54
|
+
}
|
package/dist/lib/exam-sandbox.js
CHANGED
|
@@ -1 +1,201 @@
|
|
|
1
|
-
import{mkdtempSync
|
|
1
|
+
import { mkdtempSync, mkdirSync, existsSync, appendFileSync, statSync } from 'node:fs';
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
3
|
+
import { tmpdir, homedir, platform } from 'node:os';
|
|
4
|
+
import { join, delimiter } from 'node:path';
|
|
5
|
+
import { getIcoaDir, getConfig } from './config.js';
|
|
6
|
+
import { getRealExamState } from './exam-state.js';
|
|
7
|
+
// L3 — AI coding-assistant CLIs that can read terminal output / clipboard
|
|
8
|
+
// and answer exam questions. Detection at exam start makes their presence a
|
|
9
|
+
// knowing, logged choice rather than a silent cheat surface.
|
|
10
|
+
const AI_BINARIES = [
|
|
11
|
+
'claude',
|
|
12
|
+
'cursor-agent',
|
|
13
|
+
'aider',
|
|
14
|
+
'codex',
|
|
15
|
+
'ollama',
|
|
16
|
+
'llm',
|
|
17
|
+
'cody',
|
|
18
|
+
'continue',
|
|
19
|
+
'windsurf',
|
|
20
|
+
'mods',
|
|
21
|
+
'gemini',
|
|
22
|
+
'q', // Amazon Q CLI
|
|
23
|
+
// 2026-06-19 — kept in sync with the L2 typed-command regex below (RISK_PATTERNS
|
|
24
|
+
// 'invokes AI agent CLI'). Any name added here must also appear there and vice versa.
|
|
25
|
+
'chatgpt',
|
|
26
|
+
'sgpt', // shell-gpt
|
|
27
|
+
'aichat',
|
|
28
|
+
'copilot', // GitHub Copilot CLI (standalone; `gh copilot` handled separately)
|
|
29
|
+
];
|
|
30
|
+
function whichBinary(bin) {
|
|
31
|
+
// PATH-scan implementation — avoids invoking a shell with bin in it.
|
|
32
|
+
const PATH = process.env.PATH || '';
|
|
33
|
+
const exts = platform() === 'win32' ? (process.env.PATHEXT || '.EXE;.CMD;.BAT').split(';') : [''];
|
|
34
|
+
for (const dir of PATH.split(delimiter)) {
|
|
35
|
+
if (!dir)
|
|
36
|
+
continue;
|
|
37
|
+
for (const ext of exts) {
|
|
38
|
+
const p = join(dir, bin + ext);
|
|
39
|
+
try {
|
|
40
|
+
if (statSync(p).isFile())
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
/* not present */
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
export function scanForAIBinaries() {
|
|
51
|
+
const detected = [];
|
|
52
|
+
for (const bin of AI_BINARIES) {
|
|
53
|
+
if (whichBinary(bin))
|
|
54
|
+
detected.push(bin);
|
|
55
|
+
}
|
|
56
|
+
// `gh copilot` is a subcommand of GitHub CLI — only flag `gh` when the
|
|
57
|
+
// copilot extension is also installed (avoids dinging every gh user).
|
|
58
|
+
if (whichBinary('gh')) {
|
|
59
|
+
try {
|
|
60
|
+
const out = execFileSync('gh', ['extension', 'list'], {
|
|
61
|
+
encoding: 'utf-8',
|
|
62
|
+
timeout: 2000,
|
|
63
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
64
|
+
});
|
|
65
|
+
if (/copilot/i.test(out))
|
|
66
|
+
detected.push('gh-copilot');
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
/* gh has no extensions or call failed */
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return detected;
|
|
73
|
+
}
|
|
74
|
+
// L2 — patterns whose presence in a `!cmd` strongly suggests reaching
|
|
75
|
+
// outside the exam sandbox for cheat material. Not a block: we log + warn.
|
|
76
|
+
const RISK_PATTERNS = [
|
|
77
|
+
{
|
|
78
|
+
pattern: /(?:^|[\s|;&`$(!])(?:cat|less|more|head|tail|bat)\s+(?:~|\$HOME|\/home\/[^/\s]+|\/Users\/[^/\s]+)/,
|
|
79
|
+
label: 'reads home directory',
|
|
80
|
+
},
|
|
81
|
+
{ pattern: /\.bash_history|\.zsh_history|\.fish_history|\.python_history/, label: 'reads shell history' },
|
|
82
|
+
{
|
|
83
|
+
pattern: /(?:^|[\s|;&`$(!])(?:find|grep\s+-[rR]\S*|rg|fd|ack)\s+(?:~|\$HOME|\/home|\/Users)/,
|
|
84
|
+
label: 'searches home directory',
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
// Multi-char AI CLIs — kept in sync with AI_BINARIES above (the L3 PATH scan).
|
|
88
|
+
pattern: /(?:^|[\s|;&`$(!])(?:claude|cursor-agent|aider|codex|ollama|llm|cody|continue|windsurf|mods|gemini|chatgpt|sgpt|aichat|copilot)\b/,
|
|
89
|
+
label: 'invokes AI agent CLI',
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
// Amazon Q (`q`) — single-char, so only flag it at a command boundary
|
|
93
|
+
// (start, or after ; & |, with an optional `!` bang) to avoid 误伤 on a
|
|
94
|
+
// stray `q` elsewhere in a line (e.g. `grep q file`). Bare `q` at the REPL
|
|
95
|
+
// is intercepted as "quit" before reaching here.
|
|
96
|
+
pattern: /(?:^|[;&|]\s*)!?\s*q\b/,
|
|
97
|
+
label: 'invokes AI agent CLI',
|
|
98
|
+
},
|
|
99
|
+
{ pattern: /(?:^|[\s|;&`$(!])gh\s+copilot\b/, label: 'invokes gh copilot' },
|
|
100
|
+
{ pattern: /(?:^|[\s|;&`$(!])history\b/, label: 'inspects shell history' },
|
|
101
|
+
{ pattern: /(?:^|[\s|;&`$(!])cd\s+(?:~|\$HOME|\/home|\/Users|\/etc|\/var)/, label: 'cd outside exam workspace' },
|
|
102
|
+
];
|
|
103
|
+
export function checkShellRisk(input) {
|
|
104
|
+
const flags = [];
|
|
105
|
+
for (const { pattern, label } of RISK_PATTERNS) {
|
|
106
|
+
if (pattern.test(input))
|
|
107
|
+
flags.push(label);
|
|
108
|
+
}
|
|
109
|
+
return flags;
|
|
110
|
+
}
|
|
111
|
+
function auditLogPath() {
|
|
112
|
+
return join(getIcoaDir(), 'exam-audit.log');
|
|
113
|
+
}
|
|
114
|
+
// Identity attached to every audit POST so the server (and post-competition
|
|
115
|
+
// review) can attribute a flagged action to a PERSON — not just an exam id.
|
|
116
|
+
// Without this the server can only record examId+country, and a shared-NAT
|
|
117
|
+
// room collapses to one row. Mirrors the AI-chat account binding in gemini.ts:
|
|
118
|
+
// 1. a proctored exam token wins when a real exam is active (un-forgeable),
|
|
119
|
+
// 2. else the CTFd-join logged-in username (self-reported),
|
|
120
|
+
// 3. the device fingerprint is always included as a last-resort anon key.
|
|
121
|
+
// Self-reported fields are audit signal, not proof — the un-forgeable boundary
|
|
122
|
+
// stays server-side, same as the AI budget. NOTE: this only carries the
|
|
123
|
+
// SANCTIONED-AI-orthogonal external signals (shell-escape risk flags, AI
|
|
124
|
+
// binaries in PATH); the in-CLI ai4ctf AI never routes through here.
|
|
125
|
+
export function auditIdentity() {
|
|
126
|
+
const id = {};
|
|
127
|
+
const cfg = getConfig();
|
|
128
|
+
if (cfg.deviceFingerprint)
|
|
129
|
+
id.deviceFingerprint = cfg.deviceFingerprint;
|
|
130
|
+
const realExam = getRealExamState();
|
|
131
|
+
if (realExam?.session?.token) {
|
|
132
|
+
id.examToken = realExam.session.token;
|
|
133
|
+
}
|
|
134
|
+
else if (cfg.ctfdUrl && cfg.token && cfg.userName) {
|
|
135
|
+
// CTFd-join session mode (no exam token): attribute to the logged-in account.
|
|
136
|
+
id.account = cfg.userName;
|
|
137
|
+
}
|
|
138
|
+
return id;
|
|
139
|
+
}
|
|
140
|
+
export function logShellAudit(entry) {
|
|
141
|
+
const state = getRealExamState();
|
|
142
|
+
if (!state)
|
|
143
|
+
return; // audit only fires inside a real exam
|
|
144
|
+
const line = JSON.stringify({
|
|
145
|
+
ts: new Date().toISOString(),
|
|
146
|
+
examId: state.session.examId,
|
|
147
|
+
country: state.session.country,
|
|
148
|
+
...auditIdentity(),
|
|
149
|
+
cwd: entry.cwd,
|
|
150
|
+
input: entry.input.slice(0, 500),
|
|
151
|
+
riskFlags: entry.riskFlags,
|
|
152
|
+
});
|
|
153
|
+
try {
|
|
154
|
+
appendFileSync(auditLogPath(), `${line}\n`);
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
/* local log is best-effort */
|
|
158
|
+
}
|
|
159
|
+
fetch('https://practice.icoa2026.au/api/icoa/exam-audit', {
|
|
160
|
+
method: 'POST',
|
|
161
|
+
headers: { 'Content-Type': 'application/json' },
|
|
162
|
+
body: line,
|
|
163
|
+
signal: AbortSignal.timeout(3000),
|
|
164
|
+
}).catch(() => { });
|
|
165
|
+
}
|
|
166
|
+
export function reportAIBinaryDetection(binaries) {
|
|
167
|
+
if (binaries.length === 0)
|
|
168
|
+
return;
|
|
169
|
+
const state = getRealExamState();
|
|
170
|
+
const body = JSON.stringify({
|
|
171
|
+
ts: new Date().toISOString(),
|
|
172
|
+
examId: state?.session.examId ?? '(pre-start)',
|
|
173
|
+
country: state?.session.country ?? '(pre-start)',
|
|
174
|
+
...auditIdentity(),
|
|
175
|
+
binaries,
|
|
176
|
+
platform: process.platform,
|
|
177
|
+
});
|
|
178
|
+
fetch('https://practice.icoa2026.au/api/icoa/exam-ai-binaries', {
|
|
179
|
+
method: 'POST',
|
|
180
|
+
headers: { 'Content-Type': 'application/json' },
|
|
181
|
+
body,
|
|
182
|
+
signal: AbortSignal.timeout(3000),
|
|
183
|
+
}).catch(() => { });
|
|
184
|
+
}
|
|
185
|
+
// L1 — per-exam tmpdir. Replaces ~/icoa-workspace during a real exam so the
|
|
186
|
+
// contestant lands in a clean slate; leaving this dir trips a cwd risk flag
|
|
187
|
+
// via L2. Persists in ExamSession so subsequent REPL invocations reuse it.
|
|
188
|
+
export function createExamWorkspace(examId) {
|
|
189
|
+
const safeId = examId.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 32);
|
|
190
|
+
return mkdtempSync(join(tmpdir(), `icoa-exam-${safeId}-`));
|
|
191
|
+
}
|
|
192
|
+
const LEGACY_WORKSPACE = join(homedir(), 'icoa-workspace');
|
|
193
|
+
export function getActiveCwd() {
|
|
194
|
+
const state = getRealExamState();
|
|
195
|
+
const w = state?.session?.workspaceDir;
|
|
196
|
+
if (w && existsSync(w))
|
|
197
|
+
return w;
|
|
198
|
+
if (!existsSync(LEGACY_WORKSPACE))
|
|
199
|
+
mkdirSync(LEGACY_WORKSPACE, { recursive: true });
|
|
200
|
+
return LEGACY_WORKSPACE;
|
|
201
|
+
}
|
package/dist/lib/exam-setup.js
CHANGED
|
@@ -1 +1,36 @@
|
|
|
1
|
-
import{existsSync
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { getIcoaDir } from './config.js';
|
|
4
|
+
const SETUP_FILE = () => join(getIcoaDir(), 'exam-setup.json');
|
|
5
|
+
export function getExamSetup() {
|
|
6
|
+
try {
|
|
7
|
+
if (!existsSync(SETUP_FILE()))
|
|
8
|
+
return null;
|
|
9
|
+
return JSON.parse(readFileSync(SETUP_FILE(), 'utf-8'));
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export function saveExamSetup(state) {
|
|
16
|
+
try {
|
|
17
|
+
writeFileSync(SETUP_FILE(), JSON.stringify(state, null, 2));
|
|
18
|
+
}
|
|
19
|
+
catch { }
|
|
20
|
+
}
|
|
21
|
+
export function isExamSetupComplete() {
|
|
22
|
+
return getExamSetup() !== null;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Papers that are MCQ / short-answer only (no Python, no Unix tooling, no
|
|
26
|
+
* practical/CTF questions) skip the `exam setup` gate entirely — running it
|
|
27
|
+
* would be pointless and could block a valid token.
|
|
28
|
+
*
|
|
29
|
+
* - Paper C: exam_id ends in `-c` (e.g. `zz-2026-c`)
|
|
30
|
+
* - SIMOC / Paper M: exam_id starts `simoc-` (e.g. `simoc-2026-g6`)
|
|
31
|
+
*/
|
|
32
|
+
export function examIdSkipsSetupGate(examId) {
|
|
33
|
+
if (!examId)
|
|
34
|
+
return false;
|
|
35
|
+
return examId.endsWith('-c') || examId.startsWith('simoc-');
|
|
36
|
+
}
|