@lazyingart/agintiflow 0.20.125 → 0.20.127

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/README.md CHANGED
@@ -114,6 +114,16 @@ aginti web --port 3210
114
114
  # opens http://127.0.0.1:3210, or the next available port
115
115
  ```
116
116
 
117
+ Check or prepare the Docker/LaTeX sandbox explicitly:
118
+
119
+ ```bash
120
+ aginti docker status
121
+ aginti docker setup
122
+ aginti docker install-host --yes # Ubuntu host only; explicit opt-in
123
+ ```
124
+
125
+ AgInTiFlow does not silently install Docker during npm postinstall. Docker changes host services and permissions, so host installation is explicit. Normal mode can install packages and TeX/Python dependencies inside the Docker sandbox. Danger mode is trusted host mode for tasks that really need host package managers or system services.
126
+
117
127
  Run without live model credentials for smoke tests:
118
128
 
119
129
  ```bash
@@ -43,6 +43,15 @@ Docker package installs are safe when they match the sandbox contract.
43
43
  - OS package installs such as `apt-get install htop` affect only the current short-lived command container unless the Docker image is rebuilt.
44
44
  - To make OS packages portable, add them to `docker/sandbox.Dockerfile` and rebuild with `scripts/setup-agent-toolchain-docker.sh`.
45
45
 
46
+ Use the built-in Docker setup command for the normal sandbox/toolchain path:
47
+
48
+ ```bash
49
+ aginti docker status
50
+ aginti docker setup
51
+ ```
52
+
53
+ `aginti docker setup` builds or verifies the companion sandbox image and checks Node, npm, Python, matplotlib/numpy, `latexmk`, `pdflatex`, git, and ripgrep inside Docker. If host Docker itself is missing, AgInTiFlow prints platform-specific install guidance. `aginti docker install-host --yes` is only an explicit opt-in helper for supported Ubuntu hosts; npm postinstall does not silently install Docker or mutate host services.
54
+
46
55
  This is why a task can safely install Python packages in Docker, but cannot keep an interactive tmux server alive inside a one-shot Docker command.
47
56
 
48
57
  ## Permission Contract
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lazyingart/agintiflow",
3
- "version": "0.20.125",
3
+ "version": "0.20.127",
4
4
  "type": "module",
5
5
  "description": "AgInTiFlow is a project-aware agent workspace for hybrid wet-dry R&D, hardware-aware intelligence, software automation, and industrial workflows.",
6
6
  "license": "Apache-2.0",
@@ -57,6 +57,7 @@
57
57
  "scripts/smoke-canvas-artifacts.js",
58
58
  "scripts/smoke-cli-chat.js",
59
59
  "scripts/smoke-coding-tools.js",
60
+ "scripts/smoke-docker-command.js",
60
61
  "scripts/smoke-dynamic-step-budget.js",
61
62
  "scripts/smoke-capabilities.js",
62
63
  "scripts/smoke-auto-update.js",
@@ -94,6 +95,7 @@
94
95
  "smoke:auth": "node scripts/smoke-auth.js",
95
96
  "smoke:canvas-artifacts": "node scripts/smoke-canvas-artifacts.js",
96
97
  "smoke:cli-chat": "node scripts/smoke-cli-chat.js",
98
+ "smoke:docker-command": "node scripts/smoke-docker-command.js",
97
99
  "smoke:skills": "node scripts/smoke-skills.js",
98
100
  "smoke:skillmesh": "node scripts/smoke-skillmesh.js",
99
101
  "smoke:toolchain-docker": "node scripts/smoke-toolchain-docker.js",
@@ -112,7 +114,7 @@
112
114
  "postinstall": "node scripts/postinstall-webapp.js",
113
115
  "supervision:seed": "node scripts/seed-supervised-homework.js",
114
116
  "storage:migrate": "node bin/aginti-cli.js storage migrate",
115
- "test": "npm run check && npm run smoke:autoupdate && npm run smoke:web-api && npm run smoke:web-autostart && npm run smoke:webapp-command && npm run smoke:web-port-fallback && npm run smoke:coding-tools && npm run smoke:dynamic-step-budget && npm run smoke:aaps-adapter && npm run smoke:auxiliary-tools && npm run smoke:perception-research && npm run smoke:auth && npm run smoke:canvas-artifacts && npm run smoke:capabilities && npm run smoke:model-roles && npm run smoke:platform && npm run smoke:permission-modes && npm run smoke:skills && npm run smoke:skillmesh && npm run smoke:tmux-tools && npm run smoke:cli-chat && npm run smoke:inbox",
117
+ "test": "npm run check && npm run smoke:autoupdate && npm run smoke:web-api && npm run smoke:web-autostart && npm run smoke:webapp-command && npm run smoke:web-port-fallback && npm run smoke:docker-command && npm run smoke:coding-tools && npm run smoke:dynamic-step-budget && npm run smoke:aaps-adapter && npm run smoke:auxiliary-tools && npm run smoke:perception-research && npm run smoke:auth && npm run smoke:canvas-artifacts && npm run smoke:capabilities && npm run smoke:model-roles && npm run smoke:platform && npm run smoke:permission-modes && npm run smoke:skills && npm run smoke:skillmesh && npm run smoke:tmux-tools && npm run smoke:cli-chat && npm run smoke:inbox",
116
118
  "pack:dry-run": "npm pack --dry-run",
117
119
  "smoke:capabilities": "node scripts/smoke-capabilities.js"
118
120
  },
package/public/app.js CHANGED
@@ -2061,6 +2061,12 @@ function resetArtifactViewer() {
2061
2061
  artifactViewerBodyEl.innerHTML = `<p class="subtle">${t("artifactViewerEmpty")}</p>`;
2062
2062
  }
2063
2063
 
2064
+ function artifactRawUrl(artifactId, { download = false } = {}) {
2065
+ if (!currentSessionId || !artifactId) return "";
2066
+ const base = `/api/sessions/${encodeURIComponent(currentSessionId)}/artifacts/${encodeURIComponent(artifactId)}/raw`;
2067
+ return download ? `${base}?download=1` : base;
2068
+ }
2069
+
2064
2070
  function renderArtifactShell() {
2065
2071
  renderArtifactBadge();
2066
2072
  renderArtifactList();
@@ -2075,23 +2081,40 @@ function renderArtifactContent(content) {
2075
2081
  artifactViewerTitleEl.textContent = content.title || item?.title || t("artifactViewerEmptyTitle");
2076
2082
  artifactViewerMetaEl.textContent = [content.path || item?.path, item ? artifactLabel(item) : ""].filter(Boolean).join(" · ");
2077
2083
  artifactViewerKindEl.textContent = content.kind || item?.kind || "";
2084
+ const streamedUrl = content.url || (content.id ? artifactRawUrl(content.id) : "");
2085
+ const downloadUrl = content.downloadUrl || (content.id ? artifactRawUrl(content.id, { download: true }) : "");
2086
+ const renderUrl = content.dataUrl || streamedUrl;
2078
2087
 
2079
- if (content.dataUrl && (content.kind === "pdf" || content.mime === "application/pdf")) {
2088
+ if (renderUrl && (content.kind === "pdf" || content.mime === "application/pdf")) {
2080
2089
  artifactViewerBodyEl.innerHTML = `
2081
- <iframe class="artifact-pdf-frame" src="${content.dataUrl}" title="${escapeHtml(content.title || "Artifact PDF")}"></iframe>
2090
+ <iframe class="artifact-pdf-frame" src="${renderUrl}" title="${escapeHtml(content.title || "Artifact PDF")}"></iframe>
2082
2091
  `;
2083
2092
  return;
2084
2093
  }
2085
2094
 
2086
- if (content.dataUrl) {
2095
+ if (renderUrl && (content.kind === "image" || String(content.mime || "").startsWith("image/"))) {
2087
2096
  artifactViewerBodyEl.innerHTML = `
2088
2097
  <figure class="artifact-image-frame">
2089
- <img class="artifact-preview-image" src="${content.dataUrl}" alt="${escapeHtml(content.title || "Artifact image")}" />
2098
+ <img class="artifact-preview-image" src="${renderUrl}" alt="${escapeHtml(content.title || "Artifact image")}" />
2090
2099
  </figure>
2091
2100
  `;
2092
2101
  return;
2093
2102
  }
2094
2103
 
2104
+ if ((content.tooLargeForInline || content.binary) && streamedUrl) {
2105
+ artifactViewerBodyEl.innerHTML = `
2106
+ <div class="artifact-large-file">
2107
+ <strong>${escapeHtml(content.preview || "Artifact is available as a streamed file.")}</strong>
2108
+ <p>${escapeHtml(content.path || item?.path || content.title || "Generated artifact")}</p>
2109
+ <div class="artifact-file-actions">
2110
+ <a class="secondary-button" href="${streamedUrl}" target="_blank" rel="noreferrer">Open preview</a>
2111
+ <a class="secondary-button" href="${downloadUrl}" download="${escapeHtml(artifactDownloadName(item, content))}">Download</a>
2112
+ </div>
2113
+ </div>
2114
+ `;
2115
+ return;
2116
+ }
2117
+
2095
2118
  const text = typeof content.text === "string" ? content.text : "";
2096
2119
  if (content.kind === "markdown" || /markdown/i.test(content.mime || "")) {
2097
2120
  artifactViewerBodyEl.innerHTML = `<div class="artifact-markdown markdown-body">${renderMarkdown(text)}</div>`;
@@ -2153,6 +2176,21 @@ async function downloadArtifact(artifactId) {
2153
2176
  const data = await response.json().catch(() => ({}));
2154
2177
  if (!response.ok) throw new Error(data.error || t("artifactDownloadFailed"));
2155
2178
 
2179
+ if ((data.downloadUrl || data.url) && !data.dataUrl && typeof data.text !== "string") {
2180
+ const link = document.createElement("a");
2181
+ link.href = data.downloadUrl || artifactRawUrl(artifactId, { download: true }) || data.url;
2182
+ link.download = artifactDownloadName(item, data);
2183
+ document.body.append(link);
2184
+ link.click();
2185
+ link.remove();
2186
+
2187
+ markArtifactRead(artifactId);
2188
+ renderArtifactBadge();
2189
+ renderArtifactList();
2190
+ artifactStatusEl.textContent = t("artifactDownloadReady");
2191
+ return;
2192
+ }
2193
+
2156
2194
  const blob = data.dataUrl
2157
2195
  ? blobFromDataUrl(data.dataUrl)
2158
2196
  : new Blob([typeof data.text === "string" ? data.text : JSON.stringify(data, null, 2)], {
package/public/styles.css CHANGED
@@ -1602,6 +1602,29 @@ button.danger {
1602
1602
  background: white;
1603
1603
  }
1604
1604
 
1605
+ .artifact-large-file {
1606
+ display: grid;
1607
+ gap: 10px;
1608
+ align-content: start;
1609
+ margin: 0;
1610
+ padding: 18px;
1611
+ border: 1px solid var(--line);
1612
+ border-radius: 16px;
1613
+ background: rgba(255, 255, 255, 0.72);
1614
+ }
1615
+
1616
+ .artifact-large-file p {
1617
+ margin: 0;
1618
+ color: var(--muted);
1619
+ overflow-wrap: anywhere;
1620
+ }
1621
+
1622
+ .artifact-file-actions {
1623
+ display: flex;
1624
+ flex-wrap: wrap;
1625
+ gap: 8px;
1626
+ }
1627
+
1605
1628
  .artifact-editor {
1606
1629
  min-height: 100%;
1607
1630
  height: 100%;
@@ -14,7 +14,7 @@ AgInTiFlow is a project-aware agent workspace for hybrid wet-dry R&D, hardware-a
14
14
 
15
15
  Project-oriented programming for agentic workflows.
16
16
 
17
- AAPS is a project oriented and prompt-native programming language and visual studio for turning prompts into structured, verifiable pipelines. It connects wet and dry experiments, hardware and software, and human intent with executable agent work through tasks, typed inputs, declared outputs, validation gates, recovery steps, and durable artifacts.
17
+ AAPS is a prompt-native programming language and visual studio for turning prompts into structured, verifiable pipelines. It connects wet and dry experiments, hardware and software, and human intent with executable agent work through tasks, typed inputs, declared outputs, validation gates, recovery steps, and durable artifacts.
18
18
 
19
19
  ## Shorter Version
20
20
 
@@ -1,7 +1,13 @@
1
1
  import fs from "node:fs/promises";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
- import { buildArtifacts, normalizeCanvasPayload, persistCanvasPayloadFile, readArtifactContent } from "../src/artifact-tunnel.js";
4
+ import {
5
+ buildArtifacts,
6
+ normalizeCanvasPayload,
7
+ persistCanvasPayloadFile,
8
+ readArtifactContent,
9
+ resolveArtifactFile,
10
+ } from "../src/artifact-tunnel.js";
5
11
  import { SessionStore } from "../src/session-store.js";
6
12
 
7
13
  async function main() {
@@ -54,6 +60,45 @@ async function main() {
54
60
  if (!content.ok) throw new Error(content.error || "persisted artifact could not be read");
55
61
  if (!String(content.text || "").includes("Durable report")) throw new Error("persisted artifact content mismatch");
56
62
 
63
+ const largeImagePath = path.join(workspace, "large-preview.png");
64
+ const pngHeader = Buffer.from("89504e470d0a1a0a", "hex");
65
+ await fs.writeFile(largeImagePath, Buffer.concat([pngHeader, Buffer.alloc(4_200_000)]));
66
+ const largeNormalized = normalizeCanvasPayload(
67
+ {
68
+ title: "Large preview image",
69
+ kind: "image",
70
+ path: "large-preview.png",
71
+ selected: true,
72
+ },
73
+ config
74
+ );
75
+ if (!largeNormalized.ok) throw new Error(largeNormalized.reason || "large image canvas payload normalization failed");
76
+ const largePersisted = await persistCanvasPayloadFile(largeNormalized.payload, { config, store });
77
+ if (!largePersisted.ok) throw new Error(largePersisted.reason || "large image canvas artifact persistence failed");
78
+ if (!largePersisted.payload.artifactPersisted) throw new Error("large image was not persisted into session artifacts");
79
+
80
+ const largeEvents = [
81
+ {
82
+ timestamp: new Date().toISOString(),
83
+ type: "canvas.item",
84
+ data: {
85
+ ...largePersisted.payload,
86
+ commandCwd: workspace,
87
+ },
88
+ },
89
+ ];
90
+ const { items: largeItems } = buildArtifacts({ sessionId: store.sessionId, events: largeEvents, store });
91
+ const largeContent = await readArtifactContent(largeItems[0], { store, config });
92
+ if (!largeContent.ok) throw new Error(largeContent.error || "large artifact metadata could not be read");
93
+ if (largeContent.dataUrl) throw new Error("large artifact should not be inlined as a data URL");
94
+ if (!largeContent.url || !largeContent.downloadUrl || !largeContent.tooLargeForInline) {
95
+ throw new Error("large artifact did not expose streamed preview URLs");
96
+ }
97
+ const largeFile = await resolveArtifactFile(largeItems[0], { store, config });
98
+ if (!largeFile.ok || largeFile.size <= 4_000_000 || largeFile.mime !== "image/png") {
99
+ throw new Error("large artifact file resolver returned invalid metadata");
100
+ }
101
+
57
102
  const missing = await persistCanvasPayloadFile({ ...normalized.payload, path: "missing.png" }, { config, store });
58
103
  if (missing.ok) throw new Error("missing canvas path should fail persistence");
59
104
 
@@ -219,6 +219,7 @@ try {
219
219
  "helpScouts",
220
220
  "helpRouting",
221
221
  "helpProvider",
222
+ "helpDocker",
222
223
  "helpDockerOn",
223
224
  "helpDockerOff",
224
225
  "helpLatex",
@@ -0,0 +1,42 @@
1
+ #!/usr/bin/env node
2
+ import assert from "node:assert/strict";
3
+ import { execFile } from "node:child_process";
4
+ import fs from "node:fs/promises";
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+ import { promisify } from "node:util";
9
+
10
+ const execFileAsync = promisify(execFile);
11
+ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
12
+ const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "agintiflow-docker-command-"));
13
+
14
+ try {
15
+ const { stdout } = await execFileAsync(process.execPath, [path.join(repoRoot, "bin/aginti-cli.js"), "docker", "status", "--json", "--cwd", tempRoot], {
16
+ cwd: tempRoot,
17
+ env: {
18
+ ...process.env,
19
+ AGINTIFLOW_NO_WEB_AUTO_START: "1",
20
+ },
21
+ timeout: 20000,
22
+ maxBuffer: 1024 * 1024,
23
+ });
24
+ const parsed = JSON.parse(stdout);
25
+ assert.equal(parsed.ok, true, "docker status command should return ok envelope");
26
+ assert.equal(parsed.summary.workspace, tempRoot, "docker status should honor --cwd");
27
+ assert.equal(typeof parsed.summary.dockerAvailable, "boolean", "docker status should include docker availability");
28
+ assert(parsed.summary.install?.command, "docker status should include install guidance");
29
+
30
+ const install = await execFileAsync(process.execPath, [path.join(repoRoot, "bin/aginti-cli.js"), "docker", "install-host", "--json"], {
31
+ cwd: tempRoot,
32
+ timeout: 20000,
33
+ maxBuffer: 1024 * 1024,
34
+ });
35
+ const installPlan = JSON.parse(install.stdout);
36
+ assert.equal(typeof installPlan.supported, "boolean", "install-host plan should state support");
37
+ assert(installPlan.command, "install-host plan should include a command or guidance");
38
+
39
+ console.log("docker command smoke ok");
40
+ } finally {
41
+ await fs.rm(tempRoot, { recursive: true, force: true });
42
+ }
@@ -350,6 +350,37 @@ try {
350
350
  throw new Error("artifact content endpoint did not return renderable content");
351
351
  }
352
352
 
353
+ const largeSessionId = "large-artifact-smoke";
354
+ const largeStore = new SessionStore(paths.globalSessionsDir, largeSessionId, sessionStoreOptions(runtimeDir, largeSessionId));
355
+ await largeStore.ensure();
356
+ const largeCanvasDir = path.join(largeStore.artifactsDir, "canvas");
357
+ await fs.mkdir(largeCanvasDir, { recursive: true });
358
+ const largeImagePath = path.join(largeCanvasDir, "large-image.png");
359
+ await fs.writeFile(largeImagePath, Buffer.concat([Buffer.from("89504e470d0a1a0a", "hex"), Buffer.alloc(4_200_000)]));
360
+ await largeStore.appendEvent("canvas.item", {
361
+ artifactId: "large-image",
362
+ title: "Large streamed image",
363
+ kind: "image",
364
+ path: "large-image.png",
365
+ sessionFilePath: largeImagePath,
366
+ selected: true,
367
+ });
368
+ const largeArtifacts = await fetchJson(`/api/sessions/${encodeURIComponent(largeSessionId)}/artifacts`);
369
+ if (!largeArtifacts.items?.some((item) => item.id === "large-image")) {
370
+ throw new Error("large artifact endpoint did not list session artifact");
371
+ }
372
+ const largeContent = await fetchJson(`/api/sessions/${encodeURIComponent(largeSessionId)}/artifacts/large-image`);
373
+ if (!largeContent.tooLargeForInline || !largeContent.url || largeContent.dataUrl) {
374
+ throw new Error("large artifact metadata did not switch to streamed preview");
375
+ }
376
+ const rawResponse = await fetch(`${baseUrl}/api/sessions/${encodeURIComponent(largeSessionId)}/artifacts/large-image/raw`);
377
+ if (!rawResponse.ok) throw new Error(`large artifact raw endpoint failed: ${rawResponse.status}`);
378
+ if (!/^image\/png\b/i.test(rawResponse.headers.get("content-type") || "")) {
379
+ throw new Error("large artifact raw endpoint did not preserve image content type");
380
+ }
381
+ const rawBytes = await rawResponse.arrayBuffer();
382
+ if (rawBytes.byteLength <= 4_000_000) throw new Error("large artifact raw endpoint returned truncated content");
383
+
353
384
  const deleted = await fetchJson(`/api/sessions/${encodeURIComponent(runStart.sessionId)}`, {
354
385
  method: "DELETE",
355
386
  });
@@ -384,6 +415,7 @@ try {
384
415
  "/api/workspace/changes",
385
416
  "/api/sessions/:id/artifacts",
386
417
  "/api/sessions/:id/artifacts/:artifactId",
418
+ "/api/sessions/:id/artifacts/:artifactId/raw",
387
419
  "POST /api/sessions/:id/artifacts/select",
388
420
  ],
389
421
  provider: run.provider,
@@ -60,6 +60,16 @@ function mimeForPath(filePath) {
60
60
  return IMAGE_MIME_BY_EXT.get(ext) || BINARY_RENDER_MIME_BY_EXT.get(ext) || "text/plain; charset=utf-8";
61
61
  }
62
62
 
63
+ function isRenderableBinaryMime(mime) {
64
+ return String(mime || "").startsWith("image/") || mime === "application/pdf";
65
+ }
66
+
67
+ function artifactRawUrl(item, download = false) {
68
+ if (!item?.sessionId || !item?.id) return "";
69
+ const base = `/api/sessions/${encodeURIComponent(item.sessionId)}/artifacts/${encodeURIComponent(item.id)}/raw`;
70
+ return download ? `${base}?download=1` : base;
71
+ }
72
+
63
73
  function safeCanvasFilename(artifactId, filePath) {
64
74
  const rawBase = path.basename(String(filePath || "artifact"));
65
75
  const safeBase = rawBase
@@ -285,24 +295,13 @@ export function findArtifact(items, artifactId) {
285
295
  return items.find((item) => item.id === artifactId) || null;
286
296
  }
287
297
 
288
- export async function readArtifactContent(item, { store, config }) {
298
+ export async function resolveArtifactFile(item, { store, config } = {}) {
289
299
  if (!item) {
290
300
  return { ok: false, error: "Artifact not found." };
291
301
  }
292
302
 
293
- if (item.ref?.type === "inline") {
294
- return {
295
- ok: true,
296
- id: item.id,
297
- kind: item.kind,
298
- title: item.title,
299
- path: item.path || "",
300
- mime: item.mime || "text/plain; charset=utf-8",
301
- text: redactSensitiveText(String(item.ref.text || "")),
302
- };
303
- }
304
-
305
303
  if (item.ref?.type === "session-file") {
304
+ if (!store?.sessionDir) return { ok: false, error: "Artifact session store is unavailable." };
306
305
  const absolutePath = path.resolve(item.ref.path);
307
306
  if (!isInside(store.sessionDir, absolutePath)) {
308
307
  return { ok: false, error: "Artifact path is outside this session." };
@@ -311,32 +310,14 @@ export async function readArtifactContent(item, { store, config }) {
311
310
  const stat = await fs.stat(absolutePath).catch(() => null);
312
311
  if (!stat?.isFile()) return { ok: false, error: "Artifact file is missing." };
313
312
  const mime = mimeForPath(absolutePath);
314
- const isBinaryRenderable = mime.startsWith("image/") || mime === "application/pdf";
315
- const maxBytes = isBinaryRenderable ? MAX_ARTIFACT_IMAGE_BYTES : MAX_ARTIFACT_TEXT_BYTES;
316
- if (stat.size > maxBytes) return { ok: false, error: "Artifact is too large to preview safely." };
317
-
318
- const buffer = await fs.readFile(absolutePath);
319
- if (isBinaryRenderable) {
320
- return {
321
- ok: true,
322
- id: item.id,
323
- kind: mime === "application/pdf" ? "pdf" : "image",
324
- title: item.title,
325
- path: item.path || "",
326
- mime,
327
- dataUrl: `data:${mime};base64,${buffer.toString("base64")}`,
328
- };
329
- }
330
-
331
- if (buffer.includes(0)) return { ok: false, error: "Binary artifact cannot be rendered as text." };
332
313
  return {
333
314
  ok: true,
334
- id: item.id,
335
- kind: item.kind,
315
+ absolutePath,
316
+ size: stat.size,
317
+ mime,
318
+ kind: mime === "application/pdf" ? "pdf" : mime.startsWith("image/") ? "image" : item.kind,
336
319
  title: item.title,
337
320
  path: item.path || "",
338
- mime,
339
- text: redactSensitiveText(buffer.toString("utf8")),
340
321
  };
341
322
  }
342
323
 
@@ -350,33 +331,87 @@ export async function readArtifactContent(item, { store, config }) {
350
331
  const target = resolveWorkspacePath(itemConfig, item.ref.path);
351
332
  const stat = await fs.stat(target.absolutePath).catch(() => null);
352
333
  if (!stat?.isFile()) return { ok: false, error: "Workspace file is missing." };
353
-
354
334
  const mime = mimeForPath(target.absolutePath);
355
- const isBinaryRenderable = mime.startsWith("image/") || mime === "application/pdf";
335
+ return {
336
+ ok: true,
337
+ absolutePath: target.absolutePath,
338
+ size: stat.size,
339
+ mime,
340
+ kind: mime === "application/pdf" ? "pdf" : mime.startsWith("image/") ? "image" : item.kind,
341
+ title: item.title,
342
+ path: item.path || "",
343
+ };
344
+ }
345
+
346
+ return { ok: false, error: "Artifact has no readable file." };
347
+ }
348
+
349
+ export async function readArtifactContent(item, { store, config }) {
350
+ if (!item) {
351
+ return { ok: false, error: "Artifact not found." };
352
+ }
353
+
354
+ if (item.ref?.type === "inline") {
355
+ return {
356
+ ok: true,
357
+ id: item.id,
358
+ kind: item.kind,
359
+ title: item.title,
360
+ path: item.path || "",
361
+ mime: item.mime || "text/plain; charset=utf-8",
362
+ text: redactSensitiveText(String(item.ref.text || "")),
363
+ };
364
+ }
365
+
366
+ if (item.ref?.type === "session-file" || item.ref?.type === "workspace-file") {
367
+ const resolved = await resolveArtifactFile(item, { store, config });
368
+ if (!resolved.ok) return resolved;
369
+
370
+ const isBinaryRenderable = isRenderableBinaryMime(resolved.mime);
356
371
  const maxBytes = isBinaryRenderable ? MAX_ARTIFACT_IMAGE_BYTES : MAX_ARTIFACT_TEXT_BYTES;
357
- if (stat.size > maxBytes) return { ok: false, error: "Workspace file is too large to preview safely." };
372
+ const url = artifactRawUrl(item);
373
+ const downloadUrl = artifactRawUrl(item, true);
374
+ const base = {
375
+ ok: true,
376
+ id: item.id,
377
+ kind: resolved.kind,
378
+ title: item.title,
379
+ path: item.path || "",
380
+ mime: resolved.mime,
381
+ size: resolved.size,
382
+ url,
383
+ downloadUrl,
384
+ };
385
+
386
+ if (resolved.size > maxBytes) {
387
+ return {
388
+ ...base,
389
+ tooLargeForInline: true,
390
+ preview: isBinaryRenderable
391
+ ? "Large renderable artifact is available through the streamed artifact endpoint."
392
+ : "Large artifact is available through the streamed artifact endpoint.",
393
+ };
394
+ }
358
395
 
359
- const buffer = await fs.readFile(target.absolutePath);
396
+ const buffer = await fs.readFile(resolved.absolutePath);
360
397
  if (isBinaryRenderable) {
361
398
  return {
362
- ok: true,
363
- id: item.id,
364
- kind: mime === "application/pdf" ? "pdf" : "image",
365
- title: item.title,
366
- path: item.path || "",
367
- mime,
368
- dataUrl: `data:${mime};base64,${buffer.toString("base64")}`,
399
+ ...base,
400
+ dataUrl: `data:${resolved.mime};base64,${buffer.toString("base64")}`,
401
+ };
402
+ }
403
+
404
+ if (buffer.includes(0)) {
405
+ return {
406
+ ...base,
407
+ binary: true,
408
+ preview: "Binary artifact is available through the streamed artifact endpoint.",
369
409
  };
370
410
  }
371
411
 
372
- if (buffer.includes(0)) return { ok: false, error: "Binary workspace file cannot be rendered as text." };
373
412
  return {
374
- ok: true,
375
- id: item.id,
413
+ ...base,
376
414
  kind: item.kind,
377
- title: item.title,
378
- path: item.path || "",
379
- mime,
380
415
  text: redactSensitiveText(buffer.toString("utf8")),
381
416
  };
382
417
  }
package/src/cli.js CHANGED
@@ -38,9 +38,11 @@ import { handleAapsCliCommand } from "./aaps-adapter.js";
38
38
  import { formatInstructionTemplateList, normalizeInstructionTemplate } from "./behavior-contract.js";
39
39
  import { applyPermissionMode, normalizePermissionMode } from "./permission-modes.js";
40
40
  import { ensureAgintiWebApp } from "./web-autostart.js";
41
+ import { dockerHostInstallPlan, formatDockerSetupText, summarizeDockerSetup } from "./docker-setup.js";
41
42
  import fs from "node:fs/promises";
42
43
  import path from "node:path";
43
44
  import { fileURLToPath } from "node:url";
45
+ import { spawn } from "node:child_process";
44
46
  import readline from "node:readline/promises";
45
47
  import * as readlineRaw from "node:readline";
46
48
 
@@ -716,12 +718,104 @@ function exitOnUnknownOptions(parsed) {
716
718
 
717
719
  function printUsage() {
718
720
  console.log(
719
- 'Usage: aginti [chat] OR aginti init [--template minimal|disciplined|coding|research|writing|design|aaps|supervision] OR aginti web [--port 3210] OR aginti update OR aginti models OR aginti aaps [status|init|files|validate|compile|check|run] OR aginti skills [query] OR aginti skillmesh [status|off|record|share|sync|serve|service] OR aginti housekeeping [--json] OR aginti auth [deepseek|openai|qwen|venice|grsai] OR aginti resume [--all-sessions] [latest|<session-id>] ["prompt"] OR aginti --remove-empty-sessions OR aginti --remove-sessions OR aginti queue <session-id> "message" OR aginti [--no-auto-update] [-s safe|normal|danger] [--language en|ja|zh-Hans|zh-Hant|ko|fr|es|ar|vi|de|ru] [--image] [--latex] [--scs|--scs auto|--no-scs] [--dynamic-steps auto|on|off] [--routing smart|fast|complex|manual] [--provider deepseek|openai|qwen|venice|mock] [--model MODEL] [--route-model MODEL] [--main-model MODEL] [--spare-model MODEL --spare-reasoning medium] [--aux-provider grsai|venice --aux-model MODEL] [--sandbox-mode host|docker-readonly|docker-workspace] [--package-install-policy block|prompt|allow] [--approve-package-installs] [--allow-shell|--no-shell] [--allow-file-tools|--no-file-tools] [--web-search|--no-web-search] [--parallel-scouts|--no-parallel-scouts --scout-count 1..10] [--allow-auxiliary-tools|--no-auxiliary-tools] [--allow-wrappers --wrapper codex --wrapper-model gpt-5.5] [--list-models|--list-routes] "your task"'
721
+ 'Usage: aginti [chat] OR aginti init [--template minimal|disciplined|coding|research|writing|design|aaps|supervision] OR aginti web [--port 3210] OR aginti docker [status|setup|install-host] OR aginti update OR aginti models OR aginti aaps [status|init|files|validate|compile|check|run] OR aginti skills [query] OR aginti skillmesh [status|off|record|share|sync|serve|service] OR aginti housekeeping [--json] OR aginti auth [deepseek|openai|qwen|venice|grsai] OR aginti resume [--all-sessions] [latest|<session-id>] ["prompt"] OR aginti --remove-empty-sessions OR aginti --remove-sessions OR aginti queue <session-id> "message" OR aginti [--no-auto-update] [-s safe|normal|danger] [--language en|ja|zh-Hans|zh-Hant|ko|fr|es|ar|vi|de|ru] [--image] [--latex] [--scs|--scs auto|--no-scs] [--dynamic-steps auto|on|off] [--routing smart|fast|complex|manual] [--provider deepseek|openai|qwen|venice|mock] [--model MODEL] [--route-model MODEL] [--main-model MODEL] [--spare-model MODEL --spare-reasoning medium] [--aux-provider grsai|venice --aux-model MODEL] [--sandbox-mode host|docker-readonly|docker-workspace] [--package-install-policy block|prompt|allow] [--approve-package-installs] [--allow-shell|--no-shell] [--allow-file-tools|--no-file-tools] [--web-search|--no-web-search] [--parallel-scouts|--no-parallel-scouts --scout-count 1..10] [--allow-auxiliary-tools|--no-auxiliary-tools] [--allow-wrappers --wrapper codex --wrapper-model gpt-5.5] [--list-models|--list-routes] "your task"'
720
722
  );
721
723
  console.log("Permission shortcuts: -s safe asks before writes/setup; -s normal allows current-project writes and Docker setup; -s danger enables trusted host/full-access mode.");
722
724
  console.log(`Languages: ${["en", "ja", "zh-Hans", "zh-Hant", "ko", "fr", "es", "ar", "vi", "de", "ru"].map((code) => `${code}=${languageLabel(code)}`).join(", ")}`);
723
725
  }
724
726
 
727
+ function parseDockerCommandArgs(argv = [], fallbackCwd = process.cwd()) {
728
+ const result = {
729
+ action: "status",
730
+ json: false,
731
+ yes: false,
732
+ commandCwd: fallbackCwd,
733
+ };
734
+ for (let index = 0; index < argv.length; index += 1) {
735
+ const arg = argv[index];
736
+ if (arg === "--json") {
737
+ result.json = true;
738
+ continue;
739
+ }
740
+ if (arg === "--yes" || arg === "-y") {
741
+ result.yes = true;
742
+ continue;
743
+ }
744
+ if (arg === "--cwd") {
745
+ result.commandCwd = readOption(argv, index) || result.commandCwd;
746
+ index += 1;
747
+ continue;
748
+ }
749
+ if (!arg.startsWith("-") && result.action === "status") {
750
+ result.action = arg;
751
+ continue;
752
+ }
753
+ }
754
+ return result;
755
+ }
756
+
757
+ function runInherited(command, args = [], options = {}) {
758
+ return new Promise((resolve) => {
759
+ const child = spawn(command, args, { stdio: "inherit", ...options });
760
+ child.on("close", (code) => resolve(Number(code || 0)));
761
+ child.on("error", (error) => {
762
+ console.error(error instanceof Error ? error.message : String(error));
763
+ resolve(1);
764
+ });
765
+ });
766
+ }
767
+
768
+ async function handleDockerCommand(argv = [], { commandCwd = process.cwd() } = {}) {
769
+ const parsed = parseDockerCommandArgs(argv, commandCwd);
770
+ const config = loadConfig(
771
+ {
772
+ goal: "docker setup",
773
+ commandCwd: parsed.commandCwd,
774
+ sandboxMode: "docker-workspace",
775
+ packageInstallPolicy: "allow",
776
+ provider: "mock",
777
+ routingMode: "manual",
778
+ },
779
+ { packageDir, baseDir: parsed.commandCwd }
780
+ );
781
+ const action = String(parsed.action || "status").toLowerCase();
782
+
783
+ if (["install", "install-host", "host-install"].includes(action)) {
784
+ const plan = dockerHostInstallPlan(packageDir);
785
+ if (parsed.json) {
786
+ console.log(JSON.stringify(plan, null, 2));
787
+ return;
788
+ }
789
+ if (!plan.supported) {
790
+ console.log(formatDockerSetupText(summarizeDockerSetup({ status: await getDockerSandboxStatus(config), packageDir })));
791
+ console.log("");
792
+ console.log("Host Docker auto-install is intentionally not run on this platform.");
793
+ console.log(`Recommended path: ${plan.command}`);
794
+ return;
795
+ }
796
+ if (!parsed.yes) {
797
+ console.log("Host Docker install requires explicit confirmation because it changes system packages/services.");
798
+ console.log(`Run: aginti docker install-host --yes`);
799
+ console.log(`Script: ${plan.command}`);
800
+ return;
801
+ }
802
+ const exitCode = await runInherited("bash", [plan.command], { cwd: packageDir });
803
+ process.exitCode = exitCode;
804
+ return;
805
+ }
806
+
807
+ const buildImage = ["setup", "build", "preflight", "latex", "toolchain"].includes(action);
808
+ const result = buildImage
809
+ ? await runDockerPreflight(config, { buildImage: true })
810
+ : { ok: true, status: await getDockerSandboxStatus(config), checks: [] };
811
+ const summary = summarizeDockerSetup({ status: result.status, preflight: buildImage ? result : null, packageDir });
812
+ if (parsed.json) {
813
+ console.log(JSON.stringify({ ok: buildImage ? result.ok : true, summary, result }, null, 2));
814
+ return;
815
+ }
816
+ console.log(formatDockerSetupText(summary));
817
+ }
818
+
725
819
  function stripLeadingGlobalOptions(argv = []) {
726
820
  let index = 0;
727
821
  const options = {
@@ -1607,6 +1701,11 @@ export async function main(argv = process.argv.slice(2)) {
1607
1701
  return;
1608
1702
  }
1609
1703
 
1704
+ if (commandArgv[0] === "docker") {
1705
+ await handleDockerCommand(commandArgv.slice(1), { commandCwd });
1706
+ return;
1707
+ }
1708
+
1610
1709
  if (commandArgv.includes("--remove-empty-sessions") || commandArgv[0] === "remove-empty-sessions") {
1611
1710
  await handleRemoveSessionsCommand({ emptyOnly: true });
1612
1711
  return;
@@ -0,0 +1,107 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+ import { platformInfo, platformLabel, platformSetupHints } from "./platform.js";
4
+
5
+ function latexStatusFromChecks(checks = []) {
6
+ const latexChecks = checks.filter((check) => /latexmk|pdflatex/i.test(check.command || ""));
7
+ if (!latexChecks.length) return "unchecked";
8
+ return latexChecks.every((check) => check.ok) ? "ready" : "missing";
9
+ }
10
+
11
+ export function dockerHostInstallPlan(packageDir = process.cwd(), info = platformInfo()) {
12
+ const hints = platformSetupHints(info);
13
+ const script = path.join(packageDir, "scripts", "install-docker-ubuntu.sh");
14
+ if (info.isMac) {
15
+ return {
16
+ supported: false,
17
+ platform: platformLabel(info),
18
+ command: "Install Docker Desktop or Colima, then run: aginti docker setup",
19
+ hints,
20
+ };
21
+ }
22
+ if (info.isWindows && !info.isWsl) {
23
+ return {
24
+ supported: false,
25
+ platform: platformLabel(info),
26
+ command: "Use WSL2 with Docker Desktop WSL integration, then run: aginti docker setup inside WSL",
27
+ hints,
28
+ };
29
+ }
30
+ if (info.isLinux && info.linuxFamily === "debian" && /ubuntu/i.test(`${info.linuxId} ${info.distro}`)) {
31
+ return {
32
+ supported: true,
33
+ platform: platformLabel(info),
34
+ command: script,
35
+ hints,
36
+ };
37
+ }
38
+ return {
39
+ supported: false,
40
+ platform: platformLabel(info),
41
+ command: "Install a Docker-compatible engine with your OS package manager, then run: aginti docker setup",
42
+ hints,
43
+ };
44
+ }
45
+
46
+ export function summarizeDockerSetup({ status, preflight = null, packageDir = process.cwd() } = {}) {
47
+ const info = platformInfo();
48
+ const install = dockerHostInstallPlan(packageDir, info);
49
+ const checks = preflight?.checks || [];
50
+ const failedChecks = checks.filter((check) => !check.ok);
51
+ const latex = latexStatusFromChecks(checks);
52
+ return {
53
+ platform: platformLabel(info),
54
+ sandboxMode: status?.sandboxMode || "unknown",
55
+ packageInstallPolicy: status?.packageInstallPolicy || "unknown",
56
+ dockerAvailable: Boolean(status?.dockerAvailable),
57
+ image: status?.image || "agintiflow-sandbox:latest",
58
+ imageReady: Boolean(status?.imageReady),
59
+ dockerfileExists: Boolean(status?.dockerfileExists),
60
+ workspace: status?.workspace || process.cwd(),
61
+ persistentDocker: status?.persistentDocker || {},
62
+ latex,
63
+ preflightOk: preflight ? Boolean(preflight.ok && failedChecks.length === 0) : undefined,
64
+ failedChecks: failedChecks.map((check) => check.command || check.error || "unknown"),
65
+ install,
66
+ };
67
+ }
68
+
69
+ export function formatDockerSetupText(summary = {}) {
70
+ const lines = [
71
+ "AgInTiFlow Docker setup",
72
+ `platform=${summary.platform || `${os.type()} (${process.arch})`}`,
73
+ `sandbox=${summary.sandboxMode || "unknown"} installs=${summary.packageInstallPolicy || "unknown"}`,
74
+ `docker=${summary.dockerAvailable ? "available" : "missing"} image=${summary.image || "agintiflow-sandbox:latest"} ${
75
+ summary.imageReady ? "ready" : "not-ready"
76
+ }`,
77
+ `dockerfile=${summary.dockerfileExists ? "present" : "missing"}`,
78
+ `workspace=${summary.workspace || process.cwd()}`,
79
+ ];
80
+
81
+ if (summary.persistentDocker?.root || summary.persistentDocker?.env) {
82
+ lines.push(`persistent=${summary.persistentDocker.root || path.dirname(summary.persistentDocker.env)}`);
83
+ }
84
+ if (summary.latex && summary.latex !== "unchecked") lines.push(`latex=${summary.latex}`);
85
+ if (summary.preflightOk !== undefined) lines.push(`preflight=${summary.preflightOk ? "passed" : "failed"}`);
86
+ if (summary.failedChecks?.length) {
87
+ lines.push(`failedChecks=${summary.failedChecks.join(", ")}`);
88
+ }
89
+
90
+ lines.push("");
91
+ if (!summary.dockerAvailable) {
92
+ lines.push("Docker is not available.");
93
+ lines.push(`Install path: ${summary.install?.command || "Install Docker, then run: aginti docker setup"}`);
94
+ } else if (!summary.imageReady || summary.preflightOk === false) {
95
+ lines.push("Next: aginti docker setup");
96
+ } else {
97
+ lines.push("Docker sandbox is ready for normal-mode package setup and LaTeX/PDF work.");
98
+ }
99
+
100
+ if (summary.install?.hints?.length) {
101
+ lines.push("");
102
+ lines.push("Platform notes:");
103
+ for (const hint of summary.install.hints) lines.push(`- ${hint}`);
104
+ }
105
+
106
+ return lines.join("\n");
107
+ }
package/src/i18n.js CHANGED
@@ -146,6 +146,7 @@ const TRANSLATIONS = {
146
146
  helpRouting: "Set routing: smart, fast, complex, manual.",
147
147
  helpProvider: "Open provider selector, or set deepseek/openai/qwen/venice/mock.",
148
148
  helpPermissionMode: "Switch safe/normal/danger permission posture for this session.",
149
+ helpDocker: "Inspect or prepare the Docker sandbox/toolchain.",
149
150
  helpDockerOn: "Use docker-workspace with approved package installs.",
150
151
  helpDockerOff: "Use host shell policy.",
151
152
  helpLatex: "Use the LaTeX/PDF profile in Docker with a larger step budget.",
@@ -197,6 +198,7 @@ const TRANSLATIONS = {
197
198
  helpScouts: "並列 DeepSeek スカウトを有効化し数を設定します。",
198
199
  helpRouting: "routing を smart、fast、complex、manual に設定します。",
199
200
  helpProvider: "プロバイダ選択を開くか deepseek/openai/qwen/venice/mock を設定します。",
201
+ helpDocker: "Docker サンドボックス/ツールチェーンを確認または準備します。",
200
202
  helpDockerOn: "承認済みパッケージインストール付き docker-workspace を使います。",
201
203
  helpDockerOff: "ホストシェルポリシーを使います。",
202
204
  helpLatex: "Docker で LaTeX/PDF プロファイルと大きめのステップ数を使います。",
@@ -248,6 +250,7 @@ const TRANSLATIONS = {
248
250
  helpScouts: "启用并设置并行 DeepSeek scouts 数量。",
249
251
  helpRouting: "设置 routing: smart、fast、complex、manual。",
250
252
  helpProvider: "打开 provider 选择器,或设置 deepseek/openai/qwen/venice/mock。",
253
+ helpDocker: "检查或准备 Docker 沙箱/工具链。",
251
254
  helpDockerOn: "使用允许安装包的 docker-workspace。",
252
255
  helpDockerOff: "使用主机 shell 策略。",
253
256
  helpLatex: "在 Docker 中使用 LaTeX/PDF profile 和更大步骤数。",
@@ -299,6 +302,7 @@ const TRANSLATIONS = {
299
302
  helpScouts: "啟用並設定並行 DeepSeek scouts 數量。",
300
303
  helpRouting: "設定 routing: smart、fast、complex、manual。",
301
304
  helpProvider: "開啟 provider 選擇器,或設定 deepseek/openai/qwen/venice/mock。",
305
+ helpDocker: "檢查或準備 Docker 沙箱/工具鏈。",
302
306
  helpDockerOn: "使用允許安裝套件的 docker-workspace。",
303
307
  helpDockerOff: "使用主機 shell 策略。",
304
308
  helpLatex: "在 Docker 中使用 LaTeX/PDF profile 和較大步數。",
@@ -353,6 +357,7 @@ const FALLBACKS = {
353
357
  helpScouts: "병렬 DeepSeek 스카우트를 켜고 수를 설정합니다.",
354
358
  helpRouting: "routing 을 smart, fast, complex, manual 로 설정합니다.",
355
359
  helpProvider: "provider 선택기를 열거나 deepseek/openai/qwen/venice/mock 을 설정합니다.",
360
+ helpDocker: "Docker 샌드박스/툴체인을 확인하거나 준비합니다.",
356
361
  helpDockerOn: "승인된 패키지 설치가 가능한 docker-workspace 를 사용합니다.",
357
362
  helpDockerOff: "호스트 shell 정책을 사용합니다.",
358
363
  helpLatex: "Docker 에서 LaTeX/PDF 프로필과 더 큰 단계 예산을 사용합니다.",
@@ -404,6 +409,7 @@ const FALLBACKS = {
404
409
  helpScouts: "Activer les scouts DeepSeek parallèles et régler leur nombre.",
405
410
  helpRouting: "Définir le routage: smart, fast, complex, manual.",
406
411
  helpProvider: "Ouvrir le sélecteur provider, ou définir deepseek/openai/qwen/venice/mock.",
412
+ helpDocker: "Inspecter ou préparer le sandbox/la toolchain Docker.",
407
413
  helpDockerOn: "Utiliser docker-workspace avec installations de paquets approuvées.",
408
414
  helpDockerOff: "Utiliser la politique shell hôte.",
409
415
  helpLatex: "Utiliser le profil LaTeX/PDF dans Docker avec plus d'étapes.",
@@ -455,6 +461,7 @@ const FALLBACKS = {
455
461
  helpScouts: "Activar scouts DeepSeek paralelos y configurar cantidad.",
456
462
  helpRouting: "Configurar routing: smart, fast, complex, manual.",
457
463
  helpProvider: "Abrir selector provider, o configurar deepseek/openai/qwen/venice/mock.",
464
+ helpDocker: "Inspeccionar o preparar el sandbox/toolchain Docker.",
458
465
  helpDockerOn: "Usar docker-workspace con instalaciones aprobadas.",
459
466
  helpDockerOff: "Usar política de shell host.",
460
467
  helpLatex: "Usar perfil LaTeX/PDF en Docker con más pasos.",
@@ -506,6 +513,7 @@ const FALLBACKS = {
506
513
  helpScouts: "تفعيل كشافات DeepSeek المتوازية وتحديد عددها.",
507
514
  helpRouting: "تعيين routing: smart أو fast أو complex أو manual.",
508
515
  helpProvider: "فتح محدد provider أو تعيين deepseek/openai/qwen/venice/mock.",
516
+ helpDocker: "فحص أو تجهيز صندوق Docker والأدوات.",
509
517
  helpDockerOn: "استخدام docker-workspace مع تثبيت حزم معتمد.",
510
518
  helpDockerOff: "استخدام سياسة shell المضيف.",
511
519
  helpLatex: "استخدام ملف LaTeX/PDF في Docker مع ميزانية خطوات أكبر.",
@@ -557,6 +565,7 @@ const FALLBACKS = {
557
565
  helpScouts: "Bật DeepSeek scouts song song và đặt số lượng.",
558
566
  helpRouting: "Đặt routing: smart, fast, complex, manual.",
559
567
  helpProvider: "Mở bộ chọn provider, hoặc đặt deepseek/openai/qwen/venice/mock.",
568
+ helpDocker: "Kiểm tra hoặc chuẩn bị sandbox/toolchain Docker.",
560
569
  helpDockerOn: "Dùng docker-workspace với cài package đã duyệt.",
561
570
  helpDockerOff: "Dùng chính sách shell host.",
562
571
  helpLatex: "Dùng profile LaTeX/PDF trong Docker với ngân sách bước lớn hơn.",
@@ -608,6 +617,7 @@ const FALLBACKS = {
608
617
  helpScouts: "Parallele DeepSeek-Scouts aktivieren und Anzahl setzen.",
609
618
  helpRouting: "Routing setzen: smart, fast, complex, manual.",
610
619
  helpProvider: "Provider-Auswahl öffnen oder deepseek/openai/qwen/venice/mock setzen.",
620
+ helpDocker: "Docker-Sandbox/Toolchain prüfen oder vorbereiten.",
611
621
  helpDockerOn: "docker-workspace mit genehmigten Paketinstallationen verwenden.",
612
622
  helpDockerOff: "Host-Shell-Policy verwenden.",
613
623
  helpLatex: "LaTeX/PDF-Profil in Docker mit größerem Schrittbudget verwenden.",
@@ -659,6 +669,7 @@ const FALLBACKS = {
659
669
  helpScouts: "Включить параллельных DeepSeek scouts и задать количество.",
660
670
  helpRouting: "Задать routing: smart, fast, complex, manual.",
661
671
  helpProvider: "Открыть выбор provider или задать deepseek/openai/qwen/venice/mock.",
672
+ helpDocker: "Проверить или подготовить Docker sandbox/toolchain.",
662
673
  helpDockerOn: "Использовать docker-workspace с одобренными установками пакетов.",
663
674
  helpDockerOff: "Использовать политику host shell.",
664
675
  helpLatex: "Использовать профиль LaTeX/PDF в Docker с большим числом шагов.",
@@ -13,6 +13,8 @@ import {
13
13
  sessionStoreOptions,
14
14
  } from "./project.js";
15
15
  import { normalizePackageInstallPolicy, normalizeSandboxMode } from "./command-policy.js";
16
+ import { getDockerSandboxStatus, runDockerPreflight } from "./docker-sandbox.js";
17
+ import { formatDockerSetupText, summarizeDockerSetup } from "./docker-setup.js";
16
18
  import { defaultMaxStepsForProfile, normalizeTaskProfile } from "./task-profiles.js";
17
19
  import { recommendedMaxStepsForTask } from "./engineering-guidance.js";
18
20
  import { normalizeAuthProvider, runAuthWizard, shouldPromptForDeepSeek } from "./auth-onboarding.js";
@@ -889,8 +891,7 @@ function printHelp() {
889
891
  ` ${command("/routing <mode>", "Set routing: smart, fast, complex, manual.", "helpRouting")}`,
890
892
  ` ${command("/provider [name]", "Open provider selector, or set deepseek/openai/qwen/venice/mock.", "helpProvider")}`,
891
893
  ` ${command("/safe | /normal | /danger", "Switch permission posture for this session.", "helpPermissionMode")}`,
892
- ` ${command("/docker on", "Use docker-workspace with approved package installs.", "helpDockerOn")}`,
893
- ` ${command("/docker off", "Use host shell policy.", "helpDockerOff")}`,
894
+ ` ${command("/docker [status|setup|on|off]", "Inspect or prepare the Docker sandbox/toolchain.", "helpDocker")}`,
894
895
  ` ${command("/latex on", "Use the LaTeX/PDF profile in Docker with a larger step budget.", "helpLatex")}`,
895
896
  ` ${command("/installs block|prompt|allow", "Set package install policy.", "helpInstalls")}`,
896
897
  ` ${command("/cwd <path>", "Change command workspace.", "helpCwd")}`,
@@ -3619,8 +3620,35 @@ async function handleCommand(line, state, packageDir) {
3619
3620
  state.sandboxMode = "host";
3620
3621
  state.packageInstallPolicy = "prompt";
3621
3622
  printSystemLine("docker=off sandbox=host installs=prompt");
3623
+ } else if (value === "status" || value === "") {
3624
+ const config = loadConfig(
3625
+ {
3626
+ ...state,
3627
+ goal: "docker status",
3628
+ commandCwd: state.commandCwd || process.cwd(),
3629
+ sandboxMode: state.sandboxMode || "docker-workspace",
3630
+ packageInstallPolicy: state.packageInstallPolicy || "allow",
3631
+ },
3632
+ { packageDir, baseDir: state.commandCwd || process.cwd() }
3633
+ );
3634
+ const status = await getDockerSandboxStatus(config);
3635
+ printAgentMessage(formatDockerSetupText(summarizeDockerSetup({ status, packageDir })));
3636
+ } else if (value === "setup" || value === "preflight" || value === "build" || value === "latex") {
3637
+ const config = loadConfig(
3638
+ {
3639
+ ...state,
3640
+ goal: "docker setup",
3641
+ commandCwd: state.commandCwd || process.cwd(),
3642
+ sandboxMode: "docker-workspace",
3643
+ packageInstallPolicy: "allow",
3644
+ },
3645
+ { packageDir, baseDir: state.commandCwd || process.cwd() }
3646
+ );
3647
+ printSystemLine("docker=setup starting");
3648
+ const result = await runDockerPreflight(config, { buildImage: true });
3649
+ printAgentMessage(formatDockerSetupText(summarizeDockerSetup({ status: result.status, preflight: result, packageDir })));
3622
3650
  } else {
3623
- printAgentMessage("Usage: /docker on OR /docker off");
3651
+ printAgentMessage("Usage: /docker status OR /docker setup OR /docker on OR /docker off");
3624
3652
  }
3625
3653
  return true;
3626
3654
  }
package/web.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { createReadStream } from "node:fs";
1
2
  import fs from "node:fs/promises";
2
3
  import express from "express";
3
4
  import path from "node:path";
@@ -43,6 +44,7 @@ import {
43
44
  countUnreadArtifacts,
44
45
  findArtifact,
45
46
  readArtifactContent,
47
+ resolveArtifactFile,
46
48
  serializeArtifacts,
47
49
  } from "./src/artifact-tunnel.js";
48
50
 
@@ -75,6 +77,17 @@ function isSafeSessionId(sessionId) {
75
77
  return /^[A-Za-z0-9._:-]+$/.test(String(sessionId || "")) && !String(sessionId || "").includes("..");
76
78
  }
77
79
 
80
+ function safeDownloadFilename(value) {
81
+ const basename = path.basename(String(value || "artifact").replace(/\\/g, "/"));
82
+ return (
83
+ basename
84
+ .replace(/[\r\n"\\]+/g, "_")
85
+ .replace(/^\.+/, "")
86
+ .replace(/[^A-Za-z0-9._ -]+/g, "-")
87
+ .slice(0, 120) || "artifact"
88
+ );
89
+ }
90
+
78
91
  function mapEventLogs(events) {
79
92
  return events.map((event) => ({
80
93
  at: event.timestamp,
@@ -877,6 +890,39 @@ app.get("/api/sessions/:sessionId/artifacts/:artifactId", async (req, res) => {
877
890
  res.json(content);
878
891
  });
879
892
 
893
+ app.get("/api/sessions/:sessionId/artifacts/:artifactId/raw", async (req, res) => {
894
+ const bundle = await loadArtifactBundle(req.params.sessionId);
895
+ if (bundle.error) {
896
+ res.status(bundle.status || 500).json({ error: bundle.error });
897
+ return;
898
+ }
899
+
900
+ const artifact = findArtifact(bundle.items, req.params.artifactId);
901
+ const file = await resolveArtifactFile(artifact, bundle);
902
+ if (!file.ok) {
903
+ res.status(artifact ? 400 : 404).json({ error: file.error || "Artifact not found." });
904
+ return;
905
+ }
906
+
907
+ const filename = safeDownloadFilename(file.path || file.title || file.absolutePath);
908
+ const disposition = req.query.download === "1" ? "attachment" : "inline";
909
+ res.setHeader("Content-Type", file.mime || "application/octet-stream");
910
+ res.setHeader("Content-Length", String(file.size));
911
+ res.setHeader("Content-Disposition", `${disposition}; filename="${filename}"`);
912
+ res.setHeader("Cache-Control", "private, max-age=0, no-cache");
913
+ res.setHeader("X-Content-Type-Options", "nosniff");
914
+
915
+ const stream = createReadStream(file.absolutePath);
916
+ stream.on("error", (error) => {
917
+ if (!res.headersSent) {
918
+ res.status(500).json({ error: error instanceof Error ? error.message : String(error) });
919
+ return;
920
+ }
921
+ res.destroy(error);
922
+ });
923
+ stream.pipe(res);
924
+ });
925
+
880
926
  app.post("/api/sessions/:sessionId/artifacts/select", async (req, res) => {
881
927
  const bundle = await loadArtifactBundle(req.params.sessionId);
882
928
  if (bundle.error) {