agentlas 0.5.2 → 0.5.5

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 (40) hide show
  1. package/README.md +48 -6
  2. package/bin/agentlas.cjs +55 -8
  3. package/engine/agentlas-api-agent.cjs +1 -1
  4. package/engine/agentlas-banner.cjs +40 -56
  5. package/engine/agentlas-capabilities.cjs +3 -0
  6. package/engine/agentlas-cloud-runtime.cjs +65 -11
  7. package/engine/agentlas-composer.cjs +109 -44
  8. package/engine/agentlas-doctor.cjs +40 -12
  9. package/engine/agentlas-i18n.cjs +120 -12
  10. package/engine/agentlas-input.cjs +116 -19
  11. package/engine/agentlas-native-host.cjs +381 -83
  12. package/engine/agentlas-parity.cjs +315 -45
  13. package/engine/agentlas-permissions.cjs +90 -0
  14. package/engine/agentlas-repl.cjs +99 -45
  15. package/engine/agentlas-tasks.cjs +111 -0
  16. package/engine/agentlas-tools.cjs +174 -12
  17. package/engine/agentlas-ui.cjs +348 -23
  18. package/engine/agentlas.cjs +2742 -338
  19. package/engine/semver.cjs +64 -0
  20. package/package.json +1 -1
  21. package/test/bootstrap-race.cjs +47 -0
  22. package/test/capture-runtime-guard.cjs +122 -0
  23. package/test/cloud-asset-restore.cjs +423 -0
  24. package/test/cloud-cas-client.cjs +333 -0
  25. package/test/cloud-owner-restore.cjs +183 -0
  26. package/test/cloud-runtime-paths.cjs +40 -0
  27. package/test/cloud-save-publish.cjs +453 -0
  28. package/test/credential-env-regression.cjs +52 -0
  29. package/test/login-loopback-security.cjs +115 -0
  30. package/test/mcp-config-isolation.cjs +36 -0
  31. package/test/permission-mapping.cjs +180 -0
  32. package/test/run-api-regression.cjs +322 -0
  33. package/test/runtime-env-protection.cjs +45 -0
  34. package/test/semver-precedence.cjs +39 -0
  35. package/test/smoke.sh +19 -0
  36. package/test/sqlite-driver-probe.cjs +22 -0
  37. package/test/terminal-ui-regression.cjs +454 -0
  38. package/test/timeout-regression.cjs +218 -0
  39. package/test/tool-workspace-boundary.cjs +165 -0
  40. package/test/update-safety.cjs +376 -0
package/README.md CHANGED
@@ -14,6 +14,17 @@ agents and teams, build new ones, and run the full Agentlas OS surface
14
14
  (`build` · `search` · `install` · `storm` · `network` · …) from one command.
15
15
  Claude Code style, standalone: **no desktop app required.**
16
16
 
17
+ Agentlas Terminal is the already-shipped independent terminal product. It is
18
+ not a Desktop `cli/` mirror and does not require the Desktop app to run.
19
+
20
+ > **We are Agent Trust. Your agent is not a program. It is an asset. — Agentlas —**
21
+
22
+ Agent Trust means owner-scoped, portable, inspectable, and restorable agent
23
+ packages. It is a product principle, not a claim of regulated financial or
24
+ legal trust services. Private Agent Cloud stores owned packages; this existing
25
+ Terminal verifies and runs their local execution copies through supported
26
+ runtimes.
27
+
17
28
  ```sh
18
29
  npm install -g agentlas
19
30
  agentlas
@@ -66,14 +77,30 @@ agentlas chats [n] # 최근 대화 목록
66
77
  **에이전트 & 허브** (Agentlas OS 표면)
67
78
  ```sh
68
79
  agentlas search "할 일" # Hub에서 에이전트 발견 (hep-search)
69
- agentlas install <slug> # Hub 에이전트 설치 (hep-cloud)
80
+ agentlas install <slug> # 공개 Hub 에이전트 설치 (hep-cloud)
70
81
  agentlas build "요청" # 에이전트/팀 빌드·수리·패키징 (hep-build)
71
- agentlas upload <경로> # 에이전트 패키징+Hub 배포 (hep-upload)
82
+ agentlas upload <경로> # Agent Cloud에 비공개 저장 (hep-upload)
83
+ agentlas upload <경로> --visibility marketplace
84
+ # 호환 flag: Agentlas Hub 공개 발행
72
85
  agentlas connect # Telegram/플랫폼 연결 (hep-connect)
73
86
  agentlas import <폴더> # 로컬 에이전트/팀 임포트
74
87
  agentlas list # 설치된 에이전트/회사 + 활성 런타임
75
88
  ```
76
89
 
90
+ **내 Agent Cloud 자산**
91
+ ```sh
92
+ agentlas cloud save <경로> # 소유자 전용 비공개 저장(공개 심사/라우팅 카드 없음)
93
+ agentlas cloud publish <경로> # Agentlas Hub에 명시적으로 공개 발행
94
+ agentlas cloud list # 로그인한 소유자의 비공개 패키지 조회
95
+ agentlas cloud restore <slug> # 전체 hash 검증 후 이 컴퓨터에 exact snapshot 복원
96
+ ```
97
+
98
+ 비공개 저장도 업로드 전 로컬에서 비밀값, 안전하지 않은 경로, 파일별 hash와
99
+ 전체 package hash를 검사한다. 공개 Hub 발행에만 라우팅 카드와 공개 검토가 붙는다.
100
+
101
+ `agentlas cloud install <slug>`은 기존 호환 명령이며 공개 Hub 설치다. 비공개
102
+ Agent Cloud 소유자 복원은 반드시 `agentlas cloud restore <slug>`를 사용한다.
103
+
77
104
  **실행 엔진**
78
105
  ```sh
79
106
  agentlas storm "목표" # 견고 파이프라인 라우팅→검증→실행 (Stormbreaker) [--research]
@@ -107,12 +134,26 @@ agentlas setup # 온보딩 다시 실행
107
134
  ```sh
108
135
  agentlas hep <sub…> # 전체 Hephaestus 패스스루 (wizard·security·cards·ao·plugins…)
109
136
  agentlas netadmin <sub> # 로컬 네트워크 관리 (init|status|reindex|bench)
110
- agentlas cloud <sub> # 에이전트 패키징 (wizard|security|bundle|package|publish)
137
+ agentlas cloud <sub> # 자산 저장·공개·복원 (save|publish|package|list|restore|…)
111
138
  ```
112
139
 
113
140
  공통 옵션: `--runtime claude-code|codex|gemini` · `--permission read|write|full`
114
141
  REPL 안에서는 `/`로 명령 팔레트 (`/build` `/search` `/storm` `/network` …).
115
142
 
143
+ 권한은 이름과 실제 런타임 실행 범위를 일치시킨다.
144
+
145
+ | Agentlas 권한 | Claude Code | Codex | Gemini CLI |
146
+ | --- | --- | --- | --- |
147
+ | `read` | `plan` | `read-only` sandbox | `plan` |
148
+ | `write` | `acceptEdits` | `workspace-write` sandbox | `auto_edit` |
149
+ | `full` | permission 검사 우회 | approval + sandbox 우회 | `yolo` |
150
+
151
+ `write`는 무제한 권한의 다른 이름이 아니다. 외부 상태를 바꿀 수 있는 MCP/Playwright
152
+ 도구는 `full` 턴에서만 주입한다. 입력창에서 `Shift-Tab`으로 권한을 순환할 수 있지만,
153
+ `write → full`은 5초 안에 두 번 연속 눌러야 하며 이 변경은 현재 세션에만 적용된다.
154
+ 실행 중 `Ctrl-T`는 Claude Todo/Task, Codex `todo_list`, Gemini `write_todos`가 실제로
155
+ 보낸 체크리스트만 열고 접는다. 일반 Bash/Read 실행을 계획 항목처럼 꾸며내지 않는다.
156
+
116
157
  ## 동작 방식
117
158
 
118
159
  런처(`bin/agentlas.cjs`)가 이 패키지의 `engine/`(정본)을 시스템 Node로 실행한다 —
@@ -149,6 +190,7 @@ rm ~/.local/bin/agentlas # install.sh 설치 시
149
190
 
150
191
  ## License
151
192
 
152
- Apache-2.0 — engine sources are vendored from the public
153
- [agentlas-ai/agentlas-desktop](https://github.com/agentlas-ai/agentlas-desktop) repo
154
- (see `engine/ENGINE_META.json` for the exact version/commit).
193
+ Apache-2.0 — Agentlas Terminal is the independent terminal runtime for the
194
+ [Agentlas OS](https://github.com/agentlas-ai/Agentlas-OS) package contract. Its
195
+ `engine/` directory is maintained and released from this repository; it is not
196
+ a generated mirror of Agentlas Desktop.
package/bin/agentlas.cjs CHANGED
@@ -51,14 +51,41 @@ function dbPath() {
51
51
  // require.resolve가 아니라 실제 로드로 판별한다 — ABI가 깨진 better-sqlite3나
52
52
  // node:sqlite가 아직 없는/플래그가 필요한 Node(≤22.4 등)를 정확히 걸러낸다.
53
53
  function probeSqliteDriver() {
54
- try { require("better-sqlite3"); return "better-sqlite3"; } catch { /* absent or ABI-broken */ }
55
54
  try {
56
- const { DatabaseSync } = require("node:sqlite");
57
- if (DatabaseSync) return "node:sqlite";
55
+ const Database = require("better-sqlite3");
56
+ // Loading the JS wrapper alone does not prove that its native binding matches
57
+ // this Node ABI. Exercise the constructor before reporting it to --where.
58
+ const db = new Database(":memory:");
59
+ db.close();
60
+ return "better-sqlite3";
61
+ } catch { /* absent or ABI-broken */ }
62
+ try {
63
+ const { DatabaseSync } = loadNodeSqliteQuietly();
64
+ if (DatabaseSync) {
65
+ const db = new DatabaseSync(":memory:");
66
+ db.close();
67
+ return "node:sqlite";
68
+ }
58
69
  } catch { /* not available without a flag on this Node */ }
59
70
  return null;
60
71
  }
61
72
 
73
+ function loadNodeSqliteQuietly() {
74
+ const originalEmitWarning = process.emitWarning;
75
+ process.emitWarning = function agentlasSqliteWarningFilter(warning, ...args) {
76
+ const message = typeof warning === "string" ? warning : String((warning && warning.message) || warning || "");
77
+ const type = typeof args[0] === "string" ? args[0] : "";
78
+ if (type === "ExperimentalWarning" && /SQLite/i.test(message)) return;
79
+ if (/SQLite is an experimental feature/i.test(message)) return;
80
+ return originalEmitWarning.call(process, warning, ...args);
81
+ };
82
+ try {
83
+ return require("node:sqlite");
84
+ } finally {
85
+ process.emitWarning = originalEmitWarning;
86
+ }
87
+ }
88
+
62
89
  function openSqlite(p) {
63
90
  try {
64
91
  const Database = require("better-sqlite3");
@@ -69,7 +96,7 @@ function openSqlite(p) {
69
96
  if (major < 22) {
70
97
  throw new Error(`Node ${process.version} — better-sqlite3가 없으면 Node 22+ (node:sqlite)가 필요합니다.`);
71
98
  }
72
- const { DatabaseSync } = require("node:sqlite");
99
+ const { DatabaseSync } = loadNodeSqliteQuietly();
73
100
  const db = new DatabaseSync(p);
74
101
  return { exec: (sql) => db.exec(sql), close: () => db.close(), driver: "node:sqlite" };
75
102
  }
@@ -86,16 +113,34 @@ function bootstrapDbIfMissing() {
86
113
  }
87
114
  fs.mkdirSync(path.dirname(p), { recursive: true });
88
115
  const sql = fs.readFileSync(schemaFile, "utf8");
89
- const db = openSqlite(p);
116
+ // DB를 정식 경로에서 직접 만들면 두 첫 실행이 모두 exists=false를 본 뒤 한 프로세스의
117
+ // 실패 cleanup이 다른 프로세스의 정상 DB까지 지울 수 있다. 각자 같은 볼륨의 임시 DB를
118
+ // 완성하고 hard-link(EEXIST=다른 프로세스 승리)로만 정식 이름을 원자 획득한다.
119
+ const temp = `${p}.bootstrap-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}.tmp`;
120
+ const db = openSqlite(temp);
90
121
  try {
91
122
  db.exec(sql);
92
123
  } catch (e) {
93
124
  db.close();
94
- try { fs.rmSync(p); } catch { /* leave partial for inspection */ }
125
+ try { fs.rmSync(temp, { force: true }); } catch { /* leave temp for inspection */ }
95
126
  throw new Error(`DB 부트스트랩 실패: ${e.message}`);
96
127
  }
97
128
  db.close();
98
- return { created: true, path: p };
129
+ let created = false;
130
+ try {
131
+ fs.linkSync(temp, p);
132
+ created = true;
133
+ } catch (e) {
134
+ if (!e || e.code !== "EEXIST") {
135
+ throw new Error(`DB 원자 부트스트랩 전환 실패: ${e && e.message ? e.message : e}`);
136
+ }
137
+ } finally {
138
+ try { fs.rmSync(temp, { force: true }); } catch { /* noop */ }
139
+ try { fs.rmSync(temp + "-journal", { force: true }); } catch { /* noop */ }
140
+ try { fs.rmSync(temp + "-wal", { force: true }); } catch { /* noop */ }
141
+ try { fs.rmSync(temp + "-shm", { force: true }); } catch { /* noop */ }
142
+ }
143
+ return { created, path: p };
99
144
  }
100
145
 
101
146
  function main() {
@@ -173,4 +218,6 @@ function main() {
173
218
  });
174
219
  }
175
220
 
176
- main();
221
+ if (require.main === module) main();
222
+
223
+ module.exports = { bootstrapDbIfMissing, dbPath, userDataDir, probeSqliteDriver };
@@ -300,7 +300,7 @@ async function runOpenAILoop(req) {
300
300
  // ── Ollama (openai 스타일 tools, /api/chat) ──────────────
301
301
  async function runOllamaLoop(req) {
302
302
  const { ctx, ui } = req;
303
- const host = process.env.OLLAMA_HOST || "http://127.0.0.1:11434";
303
+ const host = (req.ctx.env && req.ctx.env.OLLAMA_HOST) || process.env.OLLAMA_HOST || "http://127.0.0.1:11434";
304
304
  const messages = req.messages.slice();
305
305
  if (!messages.length || messages[0].role !== "system") messages.unshift({ role: "system", content: req.system });
306
306
  const toolDefs = tools.openaiTools(ctx.permission);
@@ -4,6 +4,8 @@
4
4
  */
5
5
  const path = require("node:path");
6
6
  const os = require("node:os");
7
+ const permissions = require("./agentlas-permissions.cjs");
8
+ const { truncateWidth, visWidth } = require("./agentlas-composer.cjs");
7
9
 
8
10
  // AGENTLAS wordmark (block letters). Rendered with a brand gradient across columns.
9
11
  const WORDMARK = [
@@ -45,28 +47,9 @@ function shorten(p) {
45
47
  // AGENTLAS wordmark, per-column brand gradient. Falls back to plain / compact.
46
48
  function renderMascot(ui) {
47
49
  const c = ui.c;
48
- const cols = (ui.out && ui.out.columns) || 80;
49
- if (!ui.enabled) {
50
- ui.line(" AGENTLAS");
51
- return;
52
- }
53
- // Narrow terminal → compact wordmark.
54
- if (cols < 72) {
55
- ui.line(" " + c.bold(c.emerald(WORDMARK_COMPACT)));
56
- return;
57
- }
58
- const g = (rgb, s) => `\x1b[1;38;2;${rgb[0]};${rgb[1]};${rgb[2]}m${s}\x1b[0m`;
59
- for (const rowStr of WORDMARK) {
60
- let outLine = "";
61
- const n = rowStr.length || 1;
62
- for (let i = 0; i < rowStr.length; i++) {
63
- const ch = rowStr[i];
64
- if (ch === " ") { outLine += " "; continue; }
65
- const idx = Math.min(GRADIENT.length - 1, Math.floor((i / n) * GRADIENT.length));
66
- outLine += g(GRADIENT[idx], ch);
67
- }
68
- ui.line(outLine);
69
- }
50
+ // A one-line mark keeps first launch and setup compact. The old six-row wordmark
51
+ // remains exported for recordings/assets, but is no longer startup chrome.
52
+ ui.line(" " + c.bold(c.emerald(WORDMARK_COMPACT)));
70
53
  }
71
54
 
72
55
  function stripAnsi(s) {
@@ -87,48 +70,49 @@ function row(ui, width, text) {
87
70
  ui.line(ui.c.faint("│ ") + ui.c.text(fit(text, inner)) + ui.c.faint(" │"));
88
71
  }
89
72
 
73
+ function sessionValues(ctx) {
74
+ const ui = ctx.ui;
75
+ return {
76
+ version: ctx.version || readVersion(),
77
+ subject: ctx.subjectLabel || ui.t("composer.autoroute"),
78
+ permission: permissions.copy(ctx.permission || "write", ui.lang).label,
79
+ runtime: ctx.runtimeLabel || "—",
80
+ cwd: ctx.cwd ? shorten(ctx.cwd) : process.cwd(),
81
+ };
82
+ }
83
+
90
84
  function renderStatusCard(ctx, opts = {}) {
91
85
  const ui = ctx.ui;
92
86
  const c = ui.c;
93
- const cols = ui.out.columns || 80;
94
- const width = Math.max(54, Math.min(cols - 2, 78));
95
- const version = ctx.version || readVersion();
96
- const subject = ctx.subjectLabel || "Pick an agent, choose a company, or type a task";
97
- const permission = ctx.permission || "write";
98
- const runtime = ctx.runtimeLabel || "(not configured)";
99
- const cwd = ctx.cwd ? shorten(ctx.cwd) : process.cwd();
100
-
87
+ const value = sessionValues(ctx);
88
+ const columns = ui.out.columns || 80;
89
+ const labels = [ui.t("status.runtime"), ui.t("status.agent"), ui.t("status.permission"), ui.t("status.directory")].map((label) => `${label}:`);
90
+ const labelCells = Math.min(Math.max(...labels.map((label) => visWidth(label))) + 2, Math.max(12, Math.floor(columns * 0.42)));
91
+ const line = (label, text) => {
92
+ const labelText = `${label}:`;
93
+ const padded = labelText + " ".repeat(Math.max(1, labelCells - visWidth(labelText)));
94
+ const room = Math.max(8, columns - 2 - visWidth(padded));
95
+ ui.line(" " + c.faint(padded) + c.text(truncateWidth(text, room)));
96
+ };
101
97
  ui.line("");
102
- if (!opts.noWordmark) {
103
- renderMascot(ui);
104
- ui.line(" " + c.dim("the operating system for agents") + (version ? c.faint(" v" + version) : ""));
105
- }
106
- ui.line("");
107
- ui.line(c.faint("╭" + "─".repeat(width - 2) + "╮"));
108
- row(ui, width, `model: ${runtime}`);
109
- row(ui, width, `agent: ${subject}`);
110
- row(ui, width, `directory: ${cwd}`);
111
- row(ui, width, `permissions: ${permission}`);
112
- ui.line(c.faint("╰" + "─".repeat(width - 2) + "╯"));
113
- if (!opts.noTip) {
114
- ui.line(
115
- " " +
116
- c.bold(c.text("Tip:")) +
117
- c.dim(" Type ") +
118
- c.faint("/help") +
119
- c.dim(" for commands, ") +
120
- c.faint("/status") +
121
- c.dim(" for session state, ") +
122
- c.faint("/exit") +
123
- c.dim(" to quit."),
124
- );
125
- }
98
+ ui.rule(ui.t("status.title"));
99
+ line(ui.t("status.runtime"), value.runtime);
100
+ line(ui.t("status.agent"), value.subject);
101
+ line(ui.t("status.permission"), value.permission);
102
+ line(ui.t("status.directory"), value.cwd);
126
103
  }
127
104
 
128
105
  // Main splash. ctx = { ui, version, runtimeLabel, subjectLabel, permission, cwd }
129
106
  function renderBanner(ctx) {
130
- renderStatusCard(ctx);
131
- ctx.ui.line("");
107
+ const ui = ctx.ui;
108
+ const c = ui.c;
109
+ const value = sessionValues(ctx);
110
+ const room = Math.max(20, (ui.out.columns || 80) - 4);
111
+ ui.line("");
112
+ ui.line(" " + c.bold(c.emerald(WORDMARK_COMPACT)) + (value.version ? c.faint(" v" + value.version) : "") + c.dim(" · " + ui.t("banner.product")));
113
+ ui.line(" " + c.text(truncateWidth(ui.t("banner.session", value.runtime, value.subject, value.permission), room)));
114
+ ui.line(" " + c.faint(truncateWidth(ui.t("banner.location", value.cwd), room)));
115
+ ui.line("");
132
116
  }
133
117
 
134
118
  // runtime · subject · permission · working folder (no wordmark — used for /status).
@@ -15,6 +15,7 @@ const RUNTIME_CAPS = {
15
15
  "claude-code": { code: true, image: false, label: "claude" },
16
16
  codex: { code: true, image: true, label: "codex" }, // Imagen
17
17
  gemini: { code: true, image: true, label: "gemini" }, // nano-banana
18
+ grok: { code: true, image: true, label: "grok" }, // Grok Imagine (generate_image/generate_video, 구독 키리스)
18
19
  anthropic: { code: true, image: false, label: "anthropic" },
19
20
  openai: { code: true, image: true, label: "openai" }, // gpt-image
20
21
  google: { code: true, image: true, label: "google" }, // imagen
@@ -22,6 +23,8 @@ const RUNTIME_CAPS = {
22
23
  upstage: { code: true, image: false, label: "solar" }, // Upstage Solar — Korean sovereign LLM (OpenAI-compatible)
23
24
  };
24
25
 
26
+ // NOTE: grok은 CAPS(멀티모달 능력 인지)만 등록 — 터미널 스폰 러너(RUNTIME_BIN)가 아직 없어
27
+ // CLI_KINDS에 넣으면 repl의 which(RUNTIME_BIN[k]) 탐지가 깨진다. 러너 추가 시 함께 확장할 것.
25
28
  const CLI_KINDS = ["claude-code", "codex", "gemini"];
26
29
 
27
30
  function capsFor(spec) {
@@ -78,7 +78,7 @@ function buildManifest(root, options = {}) {
78
78
  publicExportPolicy: "clean-copy",
79
79
  requiredRuntime: ["mcp-client"],
80
80
  license: "call-only-default",
81
- createdBy: "agentlas-desktop-setup-wizard",
81
+ createdBy: "agentlas-terminal-setup-wizard",
82
82
  };
83
83
  }
84
84
 
@@ -132,9 +132,46 @@ function loadManifest(root) {
132
132
  return JSON.parse(fs.readFileSync(path.join(path.resolve(root), "agentlas.json"), "utf8"));
133
133
  }
134
134
 
135
+ function globRegex(pattern) {
136
+ const source = String(pattern || "").replace(/\\/g, "/");
137
+ let out = "";
138
+ for (let i = 0; i < source.length; i += 1) {
139
+ const ch = source[i];
140
+ if (ch === "*") {
141
+ if (source[i + 1] === "*") {
142
+ i += 1;
143
+ // `**/` includes zero directory levels, so **/secrets/** also
144
+ // protects a root-level secrets directory.
145
+ if (source[i + 1] === "/") {
146
+ i += 1;
147
+ out += "(?:.*/)?";
148
+ } else {
149
+ out += ".*";
150
+ }
151
+ } else {
152
+ out += "[^/]*";
153
+ }
154
+ continue;
155
+ }
156
+ out += /[.+^${}()|[\]\\]/.test(ch) ? `\\${ch}` : ch;
157
+ }
158
+ return new RegExp(`^${out}$`, "i");
159
+ }
160
+
135
161
  function matches(filePath, pattern) {
136
- const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*\*/g, ".*").replace(/\*/g, "[^/]*");
137
- return new RegExp(`^${escaped}$`, "i").test(filePath);
162
+ return globRegex(pattern).test(String(filePath || "").replace(/\\/g, "/"));
163
+ }
164
+
165
+ function normalizeRequestedPath(requestedPath) {
166
+ const raw = typeof requestedPath === "string" ? requestedPath.replace(/\\/g, "/") : "";
167
+ if (!raw || raw.includes("\0") || raw.startsWith("/") || /^[A-Za-z]:\//.test(raw)) return null;
168
+ const parts = [];
169
+ for (const segment of raw.split("/")) {
170
+ if (!segment || segment === ".") continue;
171
+ if (segment === "..") return null;
172
+ parts.push(segment);
173
+ }
174
+ return parts.length > 0 ? parts.join("/") : null;
138
175
  }
139
176
 
140
177
  function compileBundle(root) {
@@ -160,17 +197,25 @@ function compileBundle(root) {
160
197
 
161
198
  function readAgentFile(root, requestedPath) {
162
199
  const manifest = loadManifest(root);
163
- if ((manifest.denyRead || []).some((pattern) => matches(requestedPath, pattern))) {
164
- return { status: "denied", path: requestedPath, reason: "Denied by agentlas.json denyRead.", redacted: true };
200
+ const safePath = normalizeRequestedPath(requestedPath);
201
+ if (!safePath) {
202
+ return { status: "denied", path: String(requestedPath || ""), reason: "Invalid or escaping path.", redacted: true };
203
+ }
204
+ if ((manifest.denyRead || []).some((pattern) => matches(safePath, pattern))) {
205
+ return { status: "denied", path: safePath, reason: "Denied by agentlas.json denyRead.", redacted: true };
165
206
  }
166
- if (!(manifest.allowRead || []).some((pattern) => matches(requestedPath, pattern))) {
167
- return { status: "denied", path: requestedPath, reason: "Path is not in agentlas.json allowRead.", redacted: false };
207
+ if (!(manifest.allowRead || []).some((pattern) => matches(safePath, pattern))) {
208
+ return { status: "denied", path: safePath, reason: "Path is not in agentlas.json allowRead.", redacted: false };
209
+ }
210
+ const base = path.resolve(root);
211
+ const abs = path.resolve(base, safePath);
212
+ if (abs !== base && !abs.startsWith(base + path.sep)) {
213
+ return { status: "denied", path: safePath, reason: "Path escapes the agent package.", redacted: true };
168
214
  }
169
- const abs = path.join(path.resolve(root), requestedPath);
170
- if (!fs.existsSync(abs)) return { status: "missing", path: requestedPath, reason: "File not found." };
215
+ if (!fs.existsSync(abs)) return { status: "missing", path: safePath, reason: "File not found." };
171
216
  const raw = fs.readFileSync(abs, "utf8");
172
217
  const content = redact(raw);
173
- return { status: "allowed", path: requestedPath, content, redacted: content !== raw };
218
+ return { status: "allowed", path: safePath, content, redacted: content !== raw };
174
219
  }
175
220
 
176
221
  function runFieldTest() {
@@ -196,4 +241,13 @@ function runFieldTest() {
196
241
  return report;
197
242
  }
198
243
 
199
- module.exports = { buildManifest, scanFolder, runWizard, compileBundle, readAgentFile, runFieldTest };
244
+ module.exports = {
245
+ buildManifest,
246
+ scanFolder,
247
+ runWizard,
248
+ compileBundle,
249
+ readAgentFile,
250
+ runFieldTest,
251
+ matches,
252
+ normalizeRequestedPath,
253
+ };
@@ -2,10 +2,10 @@
2
2
  /*
3
3
  * agentlas-composer: a raw-mode bottom input box (Claude Code / Hermes style).
4
4
  *
5
- * ╭────────────────────────────────────────────╮
6
- * › your message
7
- * ╰────────────────────────────────────────────╯
8
- * claude-code · full · 12.3k tok · / for commands
5
+ * ───────────────────────────────────────────────
6
+ * › your message
7
+ * ───────────────────────────────────────────────
8
+ * read + write · codex · Agent · / for commands
9
9
  * (slash suggestions render here while typing /…)
10
10
  *
11
11
  * Single-line field with horizontal scroll (fixed 3-line box → flicker-free clear/redraw).
@@ -13,6 +13,8 @@
13
13
  * Zero external deps. Caller falls back to readline when stdin/stdout is not a TTY.
14
14
  */
15
15
  const readline = require("node:readline");
16
+ const i18n = require("./agentlas-i18n.cjs");
17
+ const permissions = require("./agentlas-permissions.cjs");
16
18
 
17
19
  // East-Asian width: CJK / Hangul / Kana / fullwidth glyphs occupy 2 terminal cells.
18
20
  function isWide(cp) {
@@ -43,6 +45,91 @@ function visWidth(s) {
43
45
  return n;
44
46
  }
45
47
 
48
+ function truncateWidth(value, max) {
49
+ const text = String(value || "");
50
+ if (visWidth(text) <= max) return text;
51
+ let out = "";
52
+ let width = 0;
53
+ const room = Math.max(0, max - 1);
54
+ for (const ch of text) {
55
+ const cells = charWidth(ch);
56
+ if (width + cells > room) break;
57
+ out += ch;
58
+ width += cells;
59
+ }
60
+ return out + "…";
61
+ }
62
+
63
+ function identityPalette() {
64
+ const id = (value) => String(value);
65
+ return { faint: id, emerald: id, text: id, paw: id, amber: id, blue: id, dim: id, inverse: id };
66
+ }
67
+
68
+ function permissionPresentation(ctx, c) {
69
+ const permission = permissions.normalize(ctx.permission, "write");
70
+ const fallback = permissions.copy(permission, ctx.lang || "en").label;
71
+ const label = ctx.permissionLabel || fallback;
72
+ if (permission === "full") return c.paw("▶▶ " + label);
73
+ if (permission === "read") return c.blue("◇ " + label);
74
+ return c.amber("◆ " + label);
75
+ }
76
+
77
+ // Pure frame builder so layout can be regression-tested without taking over a real TTY.
78
+ function buildComposerFrame(state, ctx = {}, palette, width = 80) {
79
+ const c = palette || identityPalette();
80
+ const w = Math.max(29, width);
81
+ const fieldW = Math.max(8, w - 2);
82
+
83
+ // horizontal scroll by visual width — keep the cursor visible (CJK-safe)
84
+ let start = Math.min(state.scroll, state.cur);
85
+ while (start < state.cur && visWidth(state.buf.slice(start, state.cur)) > fieldW - 2) start++;
86
+ state.scroll = start;
87
+
88
+ let shown = "";
89
+ let shownWidth = 0;
90
+ for (let i = start; i < state.buf.length; ) {
91
+ const ch = state.buf.codePointAt(i) > 0xffff ? state.buf.slice(i, i + 2) : state.buf[i];
92
+ const cw = charWidth(ch);
93
+ if (shownWidth + cw > fieldW - 2) break;
94
+ shown += ch;
95
+ shownWidth += cw;
96
+ i += ch.length;
97
+ }
98
+
99
+ const prefix = (ctx.glyph || "›") + " ";
100
+ const top = c.faint("─".repeat(w));
101
+ const mid = c.text(prefix) + c.text(shown);
102
+ const bot = c.faint("─".repeat(w));
103
+ const lines = [top, mid, bot];
104
+ if (ctx.status || ctx.permission) {
105
+ const permission = permissions.normalize(ctx.permission, "write");
106
+ const fallback = permissions.copy(permission, ctx.lang || "en").label;
107
+ const permissionLabel = ctx.permissionLabel || fallback;
108
+ const permissionText = (permission === "full" ? "▶▶ " : permission === "read" ? "◇ " : "◆ ") + permissionLabel;
109
+ const available = Math.max(0, w - visWidth(permissionText) - 5);
110
+ const rest = ctx.status && available > 0 ? c.faint(" · " + truncateWidth(ctx.status, available)) : "";
111
+ lines.push(permissionPresentation(ctx, c) + rest);
112
+ }
113
+ if (ctx.confirmation) {
114
+ const prefix = ctx.confirmationTone === "danger" ? "! " : "✓ ";
115
+ const paint = ctx.confirmationTone === "danger" && c.paw ? c.paw : c.green || c.emerald || ((value) => value);
116
+ lines.push(paint(truncateWidth(prefix + ctx.confirmation, w)));
117
+ }
118
+
119
+ const rows = state.suggest || [];
120
+ rows.slice(0, 8).forEach((row, index) => {
121
+ const cmd = String(row.command || "").padEnd(16);
122
+ const desc = String(row.description || "");
123
+ const descRoom = Math.max(0, w - visWidth(" " + cmd + " "));
124
+ const clippedDesc = truncateWidth(desc, descRoom);
125
+ const label = " " + cmd + " " + clippedDesc;
126
+ lines.push(index === state.suggestSel ? c.inverse(label) : " " + c.blue(cmd) + " " + c.dim(clippedDesc));
127
+ });
128
+ if (rows.length) lines.push(c.faint(" " + i18n.t(ctx.lang || "en", "palette.controls")));
129
+
130
+ return { lines, curCol: visWidth(prefix) + visWidth(state.buf.slice(start, state.cur)) };
131
+ }
132
+
46
133
  function createComposer(opts) {
47
134
  const out = opts.stream || process.stdout;
48
135
  const inp = opts.input || process.stdin;
@@ -61,45 +148,7 @@ function createComposer(opts) {
61
148
 
62
149
  // Build the rendered block (array of lines) + the cursor target column on the input line.
63
150
  function frame(state, ctx) {
64
- const w = boxWidth();
65
- const inner = w - 2; // chars between │ … │
66
- const glyph = " " + (ctx.glyph || "›") + " "; // " › "
67
- const glyphW = visWidth(glyph);
68
- const fieldW = Math.max(8, inner - glyphW);
69
-
70
- // horizontal scroll by visual width — keep the cursor visible (CJK-safe)
71
- let start = Math.min(state.scroll, state.cur);
72
- while (start < state.cur && visWidth(state.buf.slice(start, state.cur)) > fieldW - 1) start++;
73
- state.scroll = start;
74
-
75
- let shown = "";
76
- let ww = 0;
77
- for (let i = start; i < state.buf.length; ) {
78
- const ch = state.buf.codePointAt(i) > 0xffff ? state.buf.slice(i, i + 2) : state.buf[i];
79
- const cw = charWidth(ch);
80
- if (ww + cw > fieldW) break;
81
- shown += ch;
82
- ww += cw;
83
- i += ch.length;
84
- }
85
- const pad = " ".repeat(Math.max(0, fieldW - ww));
86
- const top = c.faint("╭" + "─".repeat(inner) + "╮");
87
- const mid = c.faint("│") + c.emerald(glyph) + c.text(shown) + pad + c.faint("│");
88
- const bot = c.faint("╰" + "─".repeat(inner) + "╯");
89
- const lines = [top, mid, bot];
90
- if (ctx.status) lines.push(" " + c.faint(ctx.status));
91
-
92
- const rows = state.suggest || [];
93
- rows.slice(0, 8).forEach((r, i) => {
94
- const cmd = String(r.command || "").padEnd(16);
95
- const desc = String(r.description || "");
96
- const label = (" " + cmd + " " + desc).slice(0, w);
97
- lines.push(i === state.suggestSel ? c.inverse(label) : " " + c.blue(cmd) + c.dim(desc.slice(0, w - 20)));
98
- });
99
- if (rows.length) lines.push(c.faint(" ↑↓ move · Tab complete · Enter run · Esc close"));
100
-
101
- const curCol = 1 + glyphW + visWidth(state.buf.slice(start, state.cur)); // 0-based visual column
102
- return { lines, curCol };
151
+ return buildComposerFrame(state, ctx, c, boxWidth());
103
152
  }
104
153
 
105
154
  function render(state, ctx) {
@@ -172,6 +221,14 @@ function createComposer(opts) {
172
221
  function onKey(str, key) {
173
222
  key = key || {};
174
223
  const name = key.name;
224
+ const shiftTab = name === "tab" && key.shift;
225
+
226
+ if (!shiftTab && ctx.onPermissionCycleCancel) {
227
+ const hadConfirmation = Boolean(ctx.confirmation);
228
+ const next = ctx.onPermissionCycleCancel();
229
+ if (next && typeof next === "object") Object.assign(ctx, next);
230
+ if (hadConfirmation && !ctx.confirmation) draw();
231
+ }
175
232
 
176
233
  if (key.ctrl && name === "c") {
177
234
  if (state.buf.length) { ctrlc = 0; return setBuf("", 0); }
@@ -212,6 +269,14 @@ function createComposer(opts) {
212
269
  if (state.suggest.length) { state.suggestSel = (state.suggestSel + 1) % state.suggest.length; state.buf = state.suggest[state.suggestSel].command; state.cur = state.buf.length; return render(state, ctx); }
213
270
  return histNav(-1);
214
271
  }
272
+ if (shiftTab) {
273
+ if (ctx.onCyclePermission) {
274
+ const next = ctx.onCyclePermission(ctx.permission);
275
+ if (next && typeof next === "object") Object.assign(ctx, next);
276
+ draw();
277
+ }
278
+ return;
279
+ }
215
280
  if (name === "tab") {
216
281
  if (state.suggest.length) { const cmd = state.suggest[state.suggestSel].command; state.dismissed = cmd; return setBuf(cmd, cmd.length); }
217
282
  if (ctx.complete) {
@@ -253,4 +318,4 @@ function createComposer(opts) {
253
318
  return { read, setHistory: (h) => { history = (h || []).filter((x) => typeof x === "string"); } };
254
319
  }
255
320
 
256
- module.exports = { createComposer, visWidth };
321
+ module.exports = { createComposer, visWidth, buildComposerFrame, truncateWidth };