arkaos 3.49.0 → 3.51.0

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/VERSION CHANGED
@@ -1 +1 @@
1
- 3.49.0
1
+ 3.51.0
@@ -59,6 +59,13 @@ const links = [[{
59
59
  onSelect: () => {
60
60
  open.value = false
61
61
  }
62
+ }, {
63
+ label: 'Terminal',
64
+ icon: 'i-lucide-terminal',
65
+ to: '/terminal',
66
+ onSelect: () => {
67
+ open.value = false
68
+ }
62
69
  }, {
63
70
  label: 'Workflows',
64
71
  icon: 'i-lucide-workflow',
@@ -0,0 +1,224 @@
1
+ <script setup lang="ts">
2
+ // PR95a v3.51.0 — Dashboard terminal (allowlist mode).
3
+ //
4
+ // Operator picks one of the allowlisted commands; backend runs it via
5
+ // subprocess.run (no shell). Output streams into the history block.
6
+ //
7
+ // Note: vue-termui is for building Vue TUI apps that RUN in a terminal,
8
+ // not for embedding an arbitrary shell in a browser. The dashboard
9
+ // instead ships a controlled command runner with allowlist + capped
10
+ // output. xterm.js-style PTY can be a later upgrade if needed.
11
+
12
+ interface CommandEntry {
13
+ id: string
14
+ label: string
15
+ description: string
16
+ }
17
+
18
+ interface ExecResult {
19
+ stdout: string
20
+ stderr: string
21
+ exit_code: number
22
+ duration_ms: number
23
+ command: string
24
+ }
25
+
26
+ interface HistoryEntry {
27
+ id: string
28
+ label: string
29
+ result: ExecResult
30
+ ts: string
31
+ }
32
+
33
+ const { fetchApi, apiBase } = useApi()
34
+ const toast = useToast()
35
+
36
+ const { data: cmdData, status } = await fetchApi<{ commands: CommandEntry[] }>(
37
+ '/api/terminal/commands',
38
+ )
39
+ const commands = computed<CommandEntry[]>(() => cmdData.value?.commands ?? [])
40
+
41
+ const running = ref<string | null>(null)
42
+ const history = ref<HistoryEntry[]>([])
43
+
44
+ async function run(cmd: CommandEntry) {
45
+ running.value = cmd.id
46
+ try {
47
+ const res = await $fetch<ExecResult & { error?: string }>(
48
+ `${apiBase}/api/terminal/exec`,
49
+ { method: 'POST', body: { command_id: cmd.id } },
50
+ )
51
+ if (res.error) throw new Error(res.error)
52
+ history.value = [
53
+ {
54
+ id: cmd.id,
55
+ label: cmd.label,
56
+ result: res,
57
+ ts: new Date().toISOString(),
58
+ },
59
+ ...history.value,
60
+ ].slice(0, 20)
61
+ if (res.exit_code === 0) {
62
+ toast.add({
63
+ title: `${cmd.label} · ok`,
64
+ description: `${res.duration_ms}ms`,
65
+ color: 'success',
66
+ icon: 'i-lucide-check',
67
+ })
68
+ } else {
69
+ toast.add({
70
+ title: `${cmd.label} · exit ${res.exit_code}`,
71
+ description: `${res.duration_ms}ms · ${res.stderr.slice(0, 80) || 'no stderr'}`,
72
+ color: 'warning',
73
+ })
74
+ }
75
+ } catch (err) {
76
+ toast.add({
77
+ title: 'Run failed',
78
+ description: err instanceof Error ? err.message : 'unknown error',
79
+ color: 'error',
80
+ })
81
+ } finally {
82
+ running.value = null
83
+ }
84
+ }
85
+
86
+ function copyOutput(entry: HistoryEntry) {
87
+ if (typeof navigator === 'undefined' || !navigator.clipboard) return
88
+ const body = entry.result.stdout || entry.result.stderr
89
+ void navigator.clipboard.writeText(body)
90
+ toast.add({ title: 'Copied to clipboard', color: 'success', icon: 'i-lucide-clipboard-check' })
91
+ }
92
+
93
+ function clearHistory() {
94
+ history.value = []
95
+ }
96
+
97
+ function relative(iso: string): string {
98
+ const ts = Date.parse(iso)
99
+ if (Number.isNaN(ts)) return iso
100
+ const diff = Date.now() - ts
101
+ const s = Math.floor(diff / 1000)
102
+ if (s < 60) return `${s}s ago`
103
+ const m = Math.floor(s / 60)
104
+ if (m < 60) return `${m}m ago`
105
+ return `${Math.floor(m / 60)}h ago`
106
+ }
107
+ </script>
108
+
109
+ <template>
110
+ <UDashboardPanel id="terminal">
111
+ <template #header>
112
+ <UDashboardNavbar title="Terminal">
113
+ <template #leading>
114
+ <UDashboardSidebarCollapse />
115
+ </template>
116
+ <template #trailing>
117
+ <UBadge label="Allowlist mode" variant="subtle" color="primary" size="sm" />
118
+ </template>
119
+ </UDashboardNavbar>
120
+ </template>
121
+
122
+ <template #body>
123
+ <DashboardState
124
+ :status="status"
125
+ :empty="commands.length === 0"
126
+ empty-title="No allowlisted commands"
127
+ empty-description="The backend exposes no terminal commands. Add to TERMINAL_ALLOWLIST."
128
+ empty-icon="i-lucide-terminal"
129
+ >
130
+ <div class="space-y-5 max-w-4xl">
131
+ <UCard>
132
+ <template #header>
133
+ <div>
134
+ <h3 class="text-lg font-bold">Commands</h3>
135
+ <p class="text-xs text-muted mt-0.5">
136
+ Server-enforced allowlist. Each command runs via
137
+ <code class="font-mono">subprocess.run</code> with explicit argv —
138
+ no shell, no globbing, no pipes. Cap: 15s timeout, 20K chars per stream.
139
+ </p>
140
+ </div>
141
+ </template>
142
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-2">
143
+ <UButton
144
+ v-for="cmd in commands"
145
+ :key="cmd.id"
146
+ :label="cmd.label"
147
+ :description="cmd.description"
148
+ icon="i-lucide-terminal"
149
+ variant="soft"
150
+ color="primary"
151
+ size="sm"
152
+ block
153
+ class="justify-start"
154
+ :loading="running === cmd.id"
155
+ :disabled="running !== null && running !== cmd.id"
156
+ @click="run(cmd)"
157
+ />
158
+ </div>
159
+ </UCard>
160
+
161
+ <UCard v-if="history.length > 0">
162
+ <template #header>
163
+ <div class="flex items-center justify-between gap-3">
164
+ <div>
165
+ <h3 class="text-lg font-bold">Recent runs</h3>
166
+ <p class="text-xs text-muted mt-0.5">
167
+ Last {{ history.length }} commands · most recent first
168
+ </p>
169
+ </div>
170
+ <UButton label="Clear" variant="ghost" size="xs" @click="clearHistory" />
171
+ </div>
172
+ </template>
173
+ <ul class="space-y-4">
174
+ <li
175
+ v-for="entry in history"
176
+ :key="`${entry.ts}-${entry.id}`"
177
+ class="rounded-lg border border-default overflow-hidden"
178
+ >
179
+ <div class="px-3 py-2 bg-elevated/30 flex items-center justify-between gap-3 text-xs">
180
+ <div class="min-w-0 flex items-center gap-2">
181
+ <UBadge
182
+ :label="entry.result.exit_code === 0 ? 'ok' : `exit ${entry.result.exit_code}`"
183
+ :color="entry.result.exit_code === 0 ? 'success' : 'warning'"
184
+ variant="subtle"
185
+ size="xs"
186
+ />
187
+ <span class="font-mono truncate">{{ entry.result.command }}</span>
188
+ </div>
189
+ <div class="flex items-center gap-2 shrink-0">
190
+ <span class="text-muted font-mono">{{ entry.result.duration_ms }}ms</span>
191
+ <span class="text-muted">{{ relative(entry.ts) }}</span>
192
+ <UButton
193
+ icon="i-lucide-clipboard-copy"
194
+ variant="ghost"
195
+ size="xs"
196
+ aria-label="Copy output"
197
+ @click="copyOutput(entry)"
198
+ />
199
+ </div>
200
+ </div>
201
+ <pre
202
+ v-if="entry.result.stdout"
203
+ class="p-3 text-xs font-mono whitespace-pre overflow-x-auto"
204
+ >{{ entry.result.stdout }}</pre>
205
+ <pre
206
+ v-if="entry.result.stderr"
207
+ class="p-3 text-xs font-mono whitespace-pre overflow-x-auto text-rose-500 border-t border-default"
208
+ >{{ entry.result.stderr }}</pre>
209
+ </li>
210
+ </ul>
211
+ </UCard>
212
+
213
+ <p class="text-xs text-muted">
214
+ Want a different command? Add it to
215
+ <code class="font-mono">TERMINAL_ALLOWLIST</code> in
216
+ <code class="font-mono">scripts/dashboard-api.py</code> and
217
+ restart the backend. Arbitrary shell execution from the
218
+ dashboard is intentionally not supported.
219
+ </p>
220
+ </div>
221
+ </DashboardState>
222
+ </template>
223
+ </UDashboardPanel>
224
+ </template>
@@ -30,8 +30,55 @@ interface Workflow {
30
30
  }
31
31
 
32
32
  const { fetchApi, apiBase } = useApi()
33
+ const toast = useToast()
33
34
  const { data, status, error, refresh } = await fetchApi<{ workflows: Workflow[] }>('/api/workflows')
34
35
 
36
+ // PR94d v3.50.0 — inline YAML editor state.
37
+ const editingYaml = ref(false)
38
+ const yamlDraft = ref('')
39
+ const savingYaml = ref(false)
40
+
41
+ function startEditYaml() {
42
+ if (!selected.value) return
43
+ yamlDraft.value = selected.value.content
44
+ editingYaml.value = true
45
+ }
46
+
47
+ function cancelEditYaml() {
48
+ yamlDraft.value = ''
49
+ editingYaml.value = false
50
+ }
51
+
52
+ async function saveYaml() {
53
+ if (!selected.value) return
54
+ savingYaml.value = true
55
+ try {
56
+ const res = await $fetch<{ updated?: boolean, error?: string }>(
57
+ `${apiBase}/api/workflows/${selected.value.id}/yaml`,
58
+ { method: 'PUT', body: { content: yamlDraft.value } },
59
+ )
60
+ if (res.error) throw new Error(res.error)
61
+ toast.add({
62
+ title: 'YAML updated',
63
+ description: selected.value.file,
64
+ color: 'success',
65
+ icon: 'i-lucide-check',
66
+ })
67
+ // Patch local content so the preview shows the new YAML immediately.
68
+ selected.value = { ...selected.value, content: yamlDraft.value }
69
+ editingYaml.value = false
70
+ await refresh()
71
+ } catch (err) {
72
+ toast.add({
73
+ title: 'Save failed',
74
+ description: err instanceof Error ? err.message : 'unknown error',
75
+ color: 'error',
76
+ })
77
+ } finally {
78
+ savingYaml.value = false
79
+ }
80
+ }
81
+
35
82
  // PR89b v3.28.0 — recent runs for the selected workflow.
36
83
  interface WorkflowRun {
37
84
  session_id: string
@@ -171,7 +218,7 @@ const columns: TableColumn<Workflow>[] = [
171
218
  th: 'py-2 first:rounded-l-lg last:rounded-r-lg border-y border-default first:border-l last:border-r',
172
219
  td: 'border-b border-default',
173
220
  }"
174
- @select="(row: { original: Workflow }) => { selected = row.original; sidePanelTab = 'flow'; runs = []; loadRuns(row.original.id) }"
221
+ @select="(row: { original: Workflow }) => { selected = row.original; sidePanelTab = 'flow'; runs = []; editingYaml = false; loadRuns(row.original.id) }"
175
222
  >
176
223
  <template #name-cell="{ row }">
177
224
  <div class="min-w-0">
@@ -292,8 +339,47 @@ const columns: TableColumn<Workflow>[] = [
292
339
  No phases defined.
293
340
  </div>
294
341
  </div>
295
- <div v-else-if="sidePanelTab === 'yaml'" class="overflow-x-auto">
296
- <pre class="p-4 text-xs font-mono whitespace-pre">{{ selected.content }}</pre>
342
+ <div v-else-if="sidePanelTab === 'yaml'" class="flex flex-col">
343
+ <div class="px-4 py-2 border-b border-default flex items-center justify-between text-xs">
344
+ <span class="text-muted font-mono">{{ selected.file }}</span>
345
+ <div class="flex items-center gap-2">
346
+ <template v-if="!editingYaml">
347
+ <UButton
348
+ label="Edit"
349
+ icon="i-lucide-pencil"
350
+ size="xs"
351
+ variant="soft"
352
+ @click="startEditYaml"
353
+ />
354
+ </template>
355
+ <template v-else>
356
+ <UButton
357
+ label="Cancel"
358
+ variant="ghost"
359
+ size="xs"
360
+ :disabled="savingYaml"
361
+ @click="cancelEditYaml"
362
+ />
363
+ <UButton
364
+ label="Save"
365
+ icon="i-lucide-check"
366
+ color="primary"
367
+ size="xs"
368
+ :loading="savingYaml"
369
+ @click="saveYaml"
370
+ />
371
+ </template>
372
+ </div>
373
+ </div>
374
+ <div v-if="!editingYaml" class="overflow-x-auto">
375
+ <pre class="p-4 text-xs font-mono whitespace-pre">{{ selected.content }}</pre>
376
+ </div>
377
+ <UTextarea
378
+ v-else
379
+ v-model="yamlDraft"
380
+ :rows="20"
381
+ class="m-2 font-mono text-xs"
382
+ />
297
383
  </div>
298
384
  <div v-else class="p-4">
299
385
  <div v-if="runsLoading" class="py-6 text-center text-sm text-muted">
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arkaos",
3
- "version": "3.49.0",
3
+ "version": "3.51.0",
4
4
  "description": "The Operating System for AI Agent Teams",
5
5
  "type": "module",
6
6
  "bin": {
package/pyproject.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "arkaos-core"
3
- version = "3.49.0"
3
+ version = "3.51.0"
4
4
  description = "Core engine for ArkaOS — The Operating System for AI Agent Teams"
5
5
  readme = "README.md"
6
6
  license = {text = "MIT"}
@@ -1894,6 +1894,176 @@ def _iso_duration_s(start_iso: str, end_iso: str) -> Optional[int]:
1894
1894
  return None
1895
1895
 
1896
1896
 
1897
+ # --- Terminal command runner (PR95a v3.51.0) ---
1898
+
1899
+ # Allowlist of commands the dashboard terminal can run. Each entry is
1900
+ # {id, label, cmd, description}. Server-side enforcement: any request
1901
+ # whose `command_id` isn't in this list is rejected. No shell expansion,
1902
+ # no globbing, no pipes — subprocess.run with explicit argv only.
1903
+ TERMINAL_ALLOWLIST: list[dict] = [
1904
+ {
1905
+ "id": "arka-status",
1906
+ "label": "ArkaOS status",
1907
+ "cmd": ["arkaos", "status"],
1908
+ "description": "System status (version, departments, agents, projects).",
1909
+ },
1910
+ {
1911
+ "id": "git-status",
1912
+ "label": "git status",
1913
+ "cmd": ["git", "status", "--short"],
1914
+ "description": "Working tree status in short form.",
1915
+ },
1916
+ {
1917
+ "id": "git-log",
1918
+ "label": "git log (10)",
1919
+ "cmd": ["git", "log", "-10", "--oneline"],
1920
+ "description": "Most recent 10 commits.",
1921
+ },
1922
+ {
1923
+ "id": "npm-version",
1924
+ "label": "npm view arkaos version",
1925
+ "cmd": ["npm", "view", "arkaos", "version"],
1926
+ "description": "Latest published ArkaOS version on npm.",
1927
+ },
1928
+ {
1929
+ "id": "pytest-collect",
1930
+ "label": "pytest --collect-only",
1931
+ "cmd": ["python3", "-m", "pytest", "--collect-only", "-q", "tests/python/"],
1932
+ "description": "List every Python test without running.",
1933
+ },
1934
+ {
1935
+ "id": "ls",
1936
+ "label": "ls",
1937
+ "cmd": ["ls", "-la"],
1938
+ "description": "List files in the project root.",
1939
+ },
1940
+ ]
1941
+
1942
+ _TERMINAL_TIMEOUT_S = 15
1943
+ _TERMINAL_MAX_OUTPUT = 20_000 # chars — both stdout and stderr capped
1944
+
1945
+
1946
+ @app.get("/api/terminal/commands")
1947
+ def terminal_commands():
1948
+ """List allowlisted commands the dashboard terminal may run."""
1949
+ return {
1950
+ "commands": [
1951
+ {"id": c["id"], "label": c["label"], "description": c["description"]}
1952
+ for c in TERMINAL_ALLOWLIST
1953
+ ],
1954
+ "total": len(TERMINAL_ALLOWLIST),
1955
+ }
1956
+
1957
+
1958
+ @app.post("/api/terminal/exec")
1959
+ def terminal_exec(body: dict):
1960
+ """PR95a v3.51.0 — run an allowlisted command and return the output.
1961
+
1962
+ Body: ``{"command_id": "<id>"}``. Unknown ids are rejected.
1963
+ Returns ``{stdout, stderr, exit_code, duration_ms, command}``.
1964
+ Output is capped at ``_TERMINAL_MAX_OUTPUT`` chars per stream.
1965
+ """
1966
+ if not isinstance(body, dict):
1967
+ return {"error": "body must be an object"}
1968
+ cid = (body.get("command_id") or "").strip()
1969
+ if not cid:
1970
+ return {"error": "command_id is required"}
1971
+ entry = next((c for c in TERMINAL_ALLOWLIST if c["id"] == cid), None)
1972
+ if entry is None:
1973
+ return {"error": f"command '{cid}' is not on the allowlist"}
1974
+ import time
1975
+ started = time.monotonic()
1976
+ try:
1977
+ result = subprocess.run(
1978
+ entry["cmd"],
1979
+ cwd=str(ARKAOS_ROOT),
1980
+ capture_output=True,
1981
+ text=True,
1982
+ timeout=_TERMINAL_TIMEOUT_S,
1983
+ shell=False,
1984
+ )
1985
+ except subprocess.TimeoutExpired:
1986
+ return {
1987
+ "stdout": "",
1988
+ "stderr": f"command timed out after {_TERMINAL_TIMEOUT_S}s",
1989
+ "exit_code": -1,
1990
+ "duration_ms": int(_TERMINAL_TIMEOUT_S * 1000),
1991
+ "command": " ".join(entry["cmd"]),
1992
+ }
1993
+ except OSError as exc:
1994
+ return {
1995
+ "stdout": "",
1996
+ "stderr": f"command failed to launch: {exc}",
1997
+ "exit_code": -1,
1998
+ "duration_ms": int((time.monotonic() - started) * 1000),
1999
+ "command": " ".join(entry["cmd"]),
2000
+ }
2001
+ duration_ms = int((time.monotonic() - started) * 1000)
2002
+ return {
2003
+ "stdout": (result.stdout or "")[:_TERMINAL_MAX_OUTPUT],
2004
+ "stderr": (result.stderr or "")[:_TERMINAL_MAX_OUTPUT],
2005
+ "exit_code": result.returncode,
2006
+ "duration_ms": duration_ms,
2007
+ "command": " ".join(entry["cmd"]),
2008
+ }
2009
+
2010
+
2011
+ @app.put("/api/workflows/{workflow_id}/yaml")
2012
+ def workflow_update_yaml(workflow_id: str, body: dict):
2013
+ """PR94d v3.50.0 — overwrite a workflow's YAML file in place.
2014
+
2015
+ Body: ``{"content": "<full YAML body>"}``. Validates that the
2016
+ content parses to a dict with a non-empty ``id`` key. Refuses to
2017
+ move the file (operator can't rename via this endpoint — that
2018
+ would invalidate cached references).
2019
+ """
2020
+ if not isinstance(body, dict):
2021
+ return {"error": "body must be an object"}
2022
+ content = str(body.get("content") or "")
2023
+ if not content.strip():
2024
+ return {"error": "content is required"}
2025
+ target = _resolve_workflow_yaml(workflow_id)
2026
+ if target is None:
2027
+ return {"error": "workflow not found"}
2028
+ try:
2029
+ import yaml as _yaml
2030
+ parsed = _yaml.safe_load(content)
2031
+ except Exception as exc: # noqa: BLE001
2032
+ return {"error": f"YAML parse failed: {exc}"}
2033
+ if not isinstance(parsed, dict):
2034
+ return {"error": "YAML root must be a mapping"}
2035
+ if not parsed.get("id"):
2036
+ return {"error": "YAML must include a non-empty 'id' field"}
2037
+ try:
2038
+ tmp = target.with_suffix(target.suffix + ".tmp")
2039
+ tmp.write_text(content, encoding="utf-8")
2040
+ tmp.replace(target)
2041
+ except OSError as exc:
2042
+ return {"error": f"write failed: {exc}"}
2043
+ return {"updated": True, "id": workflow_id, "file": str(target)}
2044
+
2045
+
2046
+ def _resolve_workflow_yaml(workflow_id: str):
2047
+ """Return the YAML path for a workflow id, or None when missing."""
2048
+ try:
2049
+ import yaml as _yaml
2050
+ except ImportError:
2051
+ return None
2052
+ dept_root = ARKAOS_ROOT / "departments"
2053
+ if not dept_root.exists():
2054
+ return None
2055
+ for path in dept_root.glob("*/workflows/*.yaml"):
2056
+ try:
2057
+ raw = _yaml.safe_load(path.read_text(encoding="utf-8")) or {}
2058
+ except Exception: # noqa: BLE001
2059
+ continue
2060
+ if not isinstance(raw, dict):
2061
+ continue
2062
+ if str(raw.get("id") or path.stem) == workflow_id:
2063
+ return path
2064
+ return None
2065
+
2066
+
1897
2067
  @app.get("/api/workflows")
1898
2068
  def workflows_list():
1899
2069
  """Scan departments/*/workflows/*.yaml and return metadata + content."""