arkaos 3.50.0 → 3.52.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 +1 -1
- package/dashboard/app/layouts/default.vue +7 -0
- package/dashboard/app/pages/agents/[id].vue +110 -0
- package/dashboard/app/pages/terminal.vue +224 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/scripts/__pycache__/dashboard-api.cpython-313.pyc +0 -0
- package/scripts/dashboard-api.py +159 -0
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
3.
|
|
1
|
+
3.52.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',
|
|
@@ -105,6 +105,58 @@ function markedHtml(src: string): string {
|
|
|
105
105
|
}
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
+
// PR95b v3.52.0 — edit YAML inline (modal).
|
|
109
|
+
const yamlEditorOpen = ref(false)
|
|
110
|
+
const yamlEditorDraft = ref('')
|
|
111
|
+
const yamlEditorSaving = ref(false)
|
|
112
|
+
|
|
113
|
+
async function openYamlEditor() {
|
|
114
|
+
if (!agent.value) return
|
|
115
|
+
// Fetch raw YAML once when opening (already on backend via PR89d).
|
|
116
|
+
try {
|
|
117
|
+
const blob = await $fetch<Blob>(
|
|
118
|
+
`${apiBase}/api/agents/${agentId}/yaml`,
|
|
119
|
+
{ responseType: 'blob' },
|
|
120
|
+
)
|
|
121
|
+
yamlEditorDraft.value = await blob.text()
|
|
122
|
+
yamlEditorOpen.value = true
|
|
123
|
+
} catch (err) {
|
|
124
|
+
toast.add({
|
|
125
|
+
title: 'Failed to load YAML',
|
|
126
|
+
description: err instanceof Error ? err.message : 'unknown error',
|
|
127
|
+
color: 'error',
|
|
128
|
+
})
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function saveYamlEditor() {
|
|
133
|
+
if (!agent.value) return
|
|
134
|
+
yamlEditorSaving.value = true
|
|
135
|
+
try {
|
|
136
|
+
const res = await $fetch<{ updated?: boolean, error?: string }>(
|
|
137
|
+
`${apiBase}/api/agents/${agentId}/yaml`,
|
|
138
|
+
{ method: 'PUT', body: { content: yamlEditorDraft.value } },
|
|
139
|
+
)
|
|
140
|
+
if (res.error) throw new Error(res.error)
|
|
141
|
+
toast.add({
|
|
142
|
+
title: 'YAML updated',
|
|
143
|
+
description: agentId,
|
|
144
|
+
color: 'success',
|
|
145
|
+
icon: 'i-lucide-check',
|
|
146
|
+
})
|
|
147
|
+
yamlEditorOpen.value = false
|
|
148
|
+
await refresh()
|
|
149
|
+
} catch (err) {
|
|
150
|
+
toast.add({
|
|
151
|
+
title: 'Save failed',
|
|
152
|
+
description: err instanceof Error ? err.message : 'unknown error',
|
|
153
|
+
color: 'error',
|
|
154
|
+
})
|
|
155
|
+
} finally {
|
|
156
|
+
yamlEditorSaving.value = false
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
108
160
|
// PR89d v3.30.0 — download YAML.
|
|
109
161
|
const downloadingYaml = ref(false)
|
|
110
162
|
async function downloadYaml() {
|
|
@@ -402,6 +454,13 @@ function formatTokens(n: number): string {
|
|
|
402
454
|
:loading="downloadingYaml"
|
|
403
455
|
@click="downloadYaml"
|
|
404
456
|
/>
|
|
457
|
+
<UButton
|
|
458
|
+
label="Edit YAML"
|
|
459
|
+
icon="i-lucide-file-code"
|
|
460
|
+
variant="ghost"
|
|
461
|
+
size="sm"
|
|
462
|
+
@click="openYamlEditor"
|
|
463
|
+
/>
|
|
405
464
|
<UButton
|
|
406
465
|
label="Edit"
|
|
407
466
|
icon="i-lucide-pencil"
|
|
@@ -552,6 +611,57 @@ function formatTokens(n: number): string {
|
|
|
552
611
|
</ol>
|
|
553
612
|
</section>
|
|
554
613
|
|
|
614
|
+
<!-- PR95b v3.52.0 — YAML editor modal -->
|
|
615
|
+
<UModal v-model:open="yamlEditorOpen" :ui="{ content: 'max-w-3xl' }">
|
|
616
|
+
<template #content>
|
|
617
|
+
<UCard>
|
|
618
|
+
<template #header>
|
|
619
|
+
<div class="flex items-center justify-between gap-3">
|
|
620
|
+
<div>
|
|
621
|
+
<h2 class="text-lg font-bold">Edit agent YAML</h2>
|
|
622
|
+
<p class="text-xs text-muted mt-0.5 font-mono">{{ agent?.id }}</p>
|
|
623
|
+
</div>
|
|
624
|
+
<UButton
|
|
625
|
+
icon="i-lucide-x"
|
|
626
|
+
variant="ghost"
|
|
627
|
+
size="sm"
|
|
628
|
+
:disabled="yamlEditorSaving"
|
|
629
|
+
@click="yamlEditorOpen = false"
|
|
630
|
+
/>
|
|
631
|
+
</div>
|
|
632
|
+
</template>
|
|
633
|
+
<UTextarea
|
|
634
|
+
v-model="yamlEditorDraft"
|
|
635
|
+
:rows="20"
|
|
636
|
+
class="w-full font-mono text-xs"
|
|
637
|
+
/>
|
|
638
|
+
<template #footer>
|
|
639
|
+
<div class="flex items-center justify-between gap-2 text-xs">
|
|
640
|
+
<p class="text-muted">
|
|
641
|
+
Edits go through the same validator as PUT /api/agents/{id}/yaml —
|
|
642
|
+
parse + dict root + matching id. Tier 0 agents are locked.
|
|
643
|
+
</p>
|
|
644
|
+
<div class="flex gap-2 shrink-0">
|
|
645
|
+
<UButton
|
|
646
|
+
label="Cancel"
|
|
647
|
+
variant="ghost"
|
|
648
|
+
:disabled="yamlEditorSaving"
|
|
649
|
+
@click="yamlEditorOpen = false"
|
|
650
|
+
/>
|
|
651
|
+
<UButton
|
|
652
|
+
label="Save"
|
|
653
|
+
icon="i-lucide-check"
|
|
654
|
+
color="primary"
|
|
655
|
+
:loading="yamlEditorSaving"
|
|
656
|
+
@click="saveYamlEditor"
|
|
657
|
+
/>
|
|
658
|
+
</div>
|
|
659
|
+
</div>
|
|
660
|
+
</template>
|
|
661
|
+
</UCard>
|
|
662
|
+
</template>
|
|
663
|
+
</UModal>
|
|
664
|
+
|
|
555
665
|
<AgentEditDrawer
|
|
556
666
|
v-model="editOpen"
|
|
557
667
|
:agent="agent"
|
|
@@ -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>
|
package/package.json
CHANGED
package/pyproject.toml
CHANGED
|
Binary file
|
package/scripts/dashboard-api.py
CHANGED
|
@@ -449,6 +449,51 @@ def persona_download_markdown(persona_id: str):
|
|
|
449
449
|
)
|
|
450
450
|
|
|
451
451
|
|
|
452
|
+
@app.put("/api/agents/{agent_id}/yaml")
|
|
453
|
+
def agent_update_yaml(agent_id: str, body: dict):
|
|
454
|
+
"""PR95b v3.52.0 — overwrite an agent's YAML file in place.
|
|
455
|
+
|
|
456
|
+
Body: ``{"content": "<full YAML>"}``. Mirrors the workflow YAML
|
|
457
|
+
editor (PR94d): parses the content, requires a dict root + an `id`
|
|
458
|
+
that matches the URL param. Atomic write via tmp + replace.
|
|
459
|
+
|
|
460
|
+
Refuses to mutate Tier 0 agents — those are governance fixtures.
|
|
461
|
+
"""
|
|
462
|
+
if not isinstance(body, dict):
|
|
463
|
+
return {"error": "body must be an object"}
|
|
464
|
+
content = str(body.get("content") or "")
|
|
465
|
+
if not content.strip():
|
|
466
|
+
return {"error": "content is required"}
|
|
467
|
+
yaml_file = _resolve_agent_yaml(agent_id)
|
|
468
|
+
if yaml_file is None:
|
|
469
|
+
return {"error": "Agent not found"}
|
|
470
|
+
if _agent_tier_from_yaml(yaml_file) == 0:
|
|
471
|
+
return {"error": "Cannot edit Tier 0 (C-Suite) YAML via this endpoint"}
|
|
472
|
+
try:
|
|
473
|
+
import yaml as _yaml
|
|
474
|
+
parsed = _yaml.safe_load(content)
|
|
475
|
+
except Exception as exc: # noqa: BLE001
|
|
476
|
+
return {"error": f"YAML parse failed: {exc}"}
|
|
477
|
+
if not isinstance(parsed, dict):
|
|
478
|
+
return {"error": "YAML root must be a mapping"}
|
|
479
|
+
if not parsed.get("id"):
|
|
480
|
+
return {"error": "YAML must include a non-empty 'id' field"}
|
|
481
|
+
if parsed.get("id") != agent_id:
|
|
482
|
+
return {
|
|
483
|
+
"error": (
|
|
484
|
+
"YAML 'id' must match the URL param "
|
|
485
|
+
f"({parsed.get('id')!r} vs {agent_id!r})"
|
|
486
|
+
),
|
|
487
|
+
}
|
|
488
|
+
try:
|
|
489
|
+
tmp = yaml_file.with_suffix(yaml_file.suffix + ".tmp")
|
|
490
|
+
tmp.write_text(content, encoding="utf-8")
|
|
491
|
+
tmp.replace(yaml_file)
|
|
492
|
+
except OSError as exc:
|
|
493
|
+
return {"error": f"write failed: {exc}"}
|
|
494
|
+
return {"updated": True, "id": agent_id, "yaml_path": str(yaml_file)}
|
|
495
|
+
|
|
496
|
+
|
|
452
497
|
@app.get("/api/agents/{agent_id}/yaml")
|
|
453
498
|
def agent_download_yaml(agent_id: str):
|
|
454
499
|
"""PR89d v3.30.0 — return the raw YAML for the agent.
|
|
@@ -1894,6 +1939,120 @@ def _iso_duration_s(start_iso: str, end_iso: str) -> Optional[int]:
|
|
|
1894
1939
|
return None
|
|
1895
1940
|
|
|
1896
1941
|
|
|
1942
|
+
# --- Terminal command runner (PR95a v3.51.0) ---
|
|
1943
|
+
|
|
1944
|
+
# Allowlist of commands the dashboard terminal can run. Each entry is
|
|
1945
|
+
# {id, label, cmd, description}. Server-side enforcement: any request
|
|
1946
|
+
# whose `command_id` isn't in this list is rejected. No shell expansion,
|
|
1947
|
+
# no globbing, no pipes — subprocess.run with explicit argv only.
|
|
1948
|
+
TERMINAL_ALLOWLIST: list[dict] = [
|
|
1949
|
+
{
|
|
1950
|
+
"id": "arka-status",
|
|
1951
|
+
"label": "ArkaOS status",
|
|
1952
|
+
"cmd": ["arkaos", "status"],
|
|
1953
|
+
"description": "System status (version, departments, agents, projects).",
|
|
1954
|
+
},
|
|
1955
|
+
{
|
|
1956
|
+
"id": "git-status",
|
|
1957
|
+
"label": "git status",
|
|
1958
|
+
"cmd": ["git", "status", "--short"],
|
|
1959
|
+
"description": "Working tree status in short form.",
|
|
1960
|
+
},
|
|
1961
|
+
{
|
|
1962
|
+
"id": "git-log",
|
|
1963
|
+
"label": "git log (10)",
|
|
1964
|
+
"cmd": ["git", "log", "-10", "--oneline"],
|
|
1965
|
+
"description": "Most recent 10 commits.",
|
|
1966
|
+
},
|
|
1967
|
+
{
|
|
1968
|
+
"id": "npm-version",
|
|
1969
|
+
"label": "npm view arkaos version",
|
|
1970
|
+
"cmd": ["npm", "view", "arkaos", "version"],
|
|
1971
|
+
"description": "Latest published ArkaOS version on npm.",
|
|
1972
|
+
},
|
|
1973
|
+
{
|
|
1974
|
+
"id": "pytest-collect",
|
|
1975
|
+
"label": "pytest --collect-only",
|
|
1976
|
+
"cmd": ["python3", "-m", "pytest", "--collect-only", "-q", "tests/python/"],
|
|
1977
|
+
"description": "List every Python test without running.",
|
|
1978
|
+
},
|
|
1979
|
+
{
|
|
1980
|
+
"id": "ls",
|
|
1981
|
+
"label": "ls",
|
|
1982
|
+
"cmd": ["ls", "-la"],
|
|
1983
|
+
"description": "List files in the project root.",
|
|
1984
|
+
},
|
|
1985
|
+
]
|
|
1986
|
+
|
|
1987
|
+
_TERMINAL_TIMEOUT_S = 15
|
|
1988
|
+
_TERMINAL_MAX_OUTPUT = 20_000 # chars — both stdout and stderr capped
|
|
1989
|
+
|
|
1990
|
+
|
|
1991
|
+
@app.get("/api/terminal/commands")
|
|
1992
|
+
def terminal_commands():
|
|
1993
|
+
"""List allowlisted commands the dashboard terminal may run."""
|
|
1994
|
+
return {
|
|
1995
|
+
"commands": [
|
|
1996
|
+
{"id": c["id"], "label": c["label"], "description": c["description"]}
|
|
1997
|
+
for c in TERMINAL_ALLOWLIST
|
|
1998
|
+
],
|
|
1999
|
+
"total": len(TERMINAL_ALLOWLIST),
|
|
2000
|
+
}
|
|
2001
|
+
|
|
2002
|
+
|
|
2003
|
+
@app.post("/api/terminal/exec")
|
|
2004
|
+
def terminal_exec(body: dict):
|
|
2005
|
+
"""PR95a v3.51.0 — run an allowlisted command and return the output.
|
|
2006
|
+
|
|
2007
|
+
Body: ``{"command_id": "<id>"}``. Unknown ids are rejected.
|
|
2008
|
+
Returns ``{stdout, stderr, exit_code, duration_ms, command}``.
|
|
2009
|
+
Output is capped at ``_TERMINAL_MAX_OUTPUT`` chars per stream.
|
|
2010
|
+
"""
|
|
2011
|
+
if not isinstance(body, dict):
|
|
2012
|
+
return {"error": "body must be an object"}
|
|
2013
|
+
cid = (body.get("command_id") or "").strip()
|
|
2014
|
+
if not cid:
|
|
2015
|
+
return {"error": "command_id is required"}
|
|
2016
|
+
entry = next((c for c in TERMINAL_ALLOWLIST if c["id"] == cid), None)
|
|
2017
|
+
if entry is None:
|
|
2018
|
+
return {"error": f"command '{cid}' is not on the allowlist"}
|
|
2019
|
+
import time
|
|
2020
|
+
started = time.monotonic()
|
|
2021
|
+
try:
|
|
2022
|
+
result = subprocess.run(
|
|
2023
|
+
entry["cmd"],
|
|
2024
|
+
cwd=str(ARKAOS_ROOT),
|
|
2025
|
+
capture_output=True,
|
|
2026
|
+
text=True,
|
|
2027
|
+
timeout=_TERMINAL_TIMEOUT_S,
|
|
2028
|
+
shell=False,
|
|
2029
|
+
)
|
|
2030
|
+
except subprocess.TimeoutExpired:
|
|
2031
|
+
return {
|
|
2032
|
+
"stdout": "",
|
|
2033
|
+
"stderr": f"command timed out after {_TERMINAL_TIMEOUT_S}s",
|
|
2034
|
+
"exit_code": -1,
|
|
2035
|
+
"duration_ms": int(_TERMINAL_TIMEOUT_S * 1000),
|
|
2036
|
+
"command": " ".join(entry["cmd"]),
|
|
2037
|
+
}
|
|
2038
|
+
except OSError as exc:
|
|
2039
|
+
return {
|
|
2040
|
+
"stdout": "",
|
|
2041
|
+
"stderr": f"command failed to launch: {exc}",
|
|
2042
|
+
"exit_code": -1,
|
|
2043
|
+
"duration_ms": int((time.monotonic() - started) * 1000),
|
|
2044
|
+
"command": " ".join(entry["cmd"]),
|
|
2045
|
+
}
|
|
2046
|
+
duration_ms = int((time.monotonic() - started) * 1000)
|
|
2047
|
+
return {
|
|
2048
|
+
"stdout": (result.stdout or "")[:_TERMINAL_MAX_OUTPUT],
|
|
2049
|
+
"stderr": (result.stderr or "")[:_TERMINAL_MAX_OUTPUT],
|
|
2050
|
+
"exit_code": result.returncode,
|
|
2051
|
+
"duration_ms": duration_ms,
|
|
2052
|
+
"command": " ".join(entry["cmd"]),
|
|
2053
|
+
}
|
|
2054
|
+
|
|
2055
|
+
|
|
1897
2056
|
@app.put("/api/workflows/{workflow_id}/yaml")
|
|
1898
2057
|
def workflow_update_yaml(workflow_id: str, body: dict):
|
|
1899
2058
|
"""PR94d v3.50.0 — overwrite a workflow's YAML file in place.
|