arkaos 3.68.0 → 3.70.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/core/terminal/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/terminal/__pycache__/audit.cpython-313.pyc +0 -0
- package/core/terminal/__pycache__/session.cpython-313.pyc +0 -0
- package/core/terminal/__pycache__/token.cpython-313.pyc +0 -0
- package/dashboard/app/components/Terminal.vue +42 -30
- package/dashboard/app/composables/useTerminalSession.ts +7 -2
- package/dashboard/app/composables/useTerminalTabs.ts +130 -0
- package/dashboard/app/composables/useTerminalThemes.ts +190 -0
- package/dashboard/app/pages/terminal.vue +282 -18
- 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 +2 -210
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
3.
|
|
1
|
+
3.70.0
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -1,47 +1,35 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
// PR99b v3.68.0 — xterm.js terminal mount.
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// will spawn N of these.
|
|
3
|
+
// PR99c v3.69.0 — accepts an external session via prop so the tab
|
|
4
|
+
// manager in /terminal can mount N instances, each bound to its own
|
|
5
|
+
// PTY session.
|
|
7
6
|
|
|
8
7
|
import { Terminal as XTerm } from '@xterm/xterm'
|
|
9
8
|
import { FitAddon } from '@xterm/addon-fit'
|
|
10
9
|
import { WebLinksAddon } from '@xterm/addon-web-links'
|
|
11
10
|
import { SearchAddon } from '@xterm/addon-search'
|
|
12
11
|
import '@xterm/xterm/css/xterm.css'
|
|
12
|
+
import type { XtermTheme } from '~/composables/useTerminalThemes'
|
|
13
|
+
|
|
14
|
+
interface Props {
|
|
15
|
+
session?: ReturnType<typeof useTerminalSession>
|
|
16
|
+
onInputLine?: (line: string) => void
|
|
17
|
+
theme?: XtermTheme
|
|
18
|
+
}
|
|
19
|
+
const props = defineProps<Props>()
|
|
13
20
|
|
|
14
21
|
const container = ref<HTMLDivElement | null>(null)
|
|
15
|
-
const session = useTerminalSession()
|
|
22
|
+
const session = props.session ?? useTerminalSession()
|
|
16
23
|
const term = shallowRef<XTerm | null>(null)
|
|
17
24
|
const fit = shallowRef<FitAddon | null>(null)
|
|
18
25
|
const search = shallowRef<SearchAddon | null>(null)
|
|
19
26
|
|
|
20
27
|
const decoder = new TextDecoder('utf-8', { fatal: false })
|
|
21
28
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
cursorAccent: '#0a0a0f',
|
|
27
|
-
selectionBackground: '#1e3a5f',
|
|
28
|
-
black: '#0a0a0f',
|
|
29
|
-
red: '#f87171',
|
|
30
|
-
green: '#86efac',
|
|
31
|
-
yellow: '#fde68a',
|
|
32
|
-
blue: '#7dd3fc',
|
|
33
|
-
magenta: '#f0abfc',
|
|
34
|
-
cyan: '#67e8f9',
|
|
35
|
-
white: '#e6e6f0',
|
|
36
|
-
brightBlack: '#3f3f46',
|
|
37
|
-
brightRed: '#fca5a5',
|
|
38
|
-
brightGreen: '#bbf7d0',
|
|
39
|
-
brightYellow: '#fef3c7',
|
|
40
|
-
brightBlue: '#bae6fd',
|
|
41
|
-
brightMagenta: '#f5d0fe',
|
|
42
|
-
brightCyan: '#a5f3fc',
|
|
43
|
-
brightWhite: '#fafafa',
|
|
44
|
-
}
|
|
29
|
+
// PR99d v3.70.0 — theme comes from prop or from the composable
|
|
30
|
+
// default (operator's choice stored in localStorage).
|
|
31
|
+
const { activeTheme } = useTerminalThemes()
|
|
32
|
+
const effectiveTheme = computed(() => props.theme ?? activeTheme.value)
|
|
45
33
|
|
|
46
34
|
let unsubscribeOutput: (() => void) | null = null
|
|
47
35
|
let resizeObserver: ResizeObserver | null = null
|
|
@@ -55,9 +43,14 @@ onMounted(async () => {
|
|
|
55
43
|
fontSize: 13,
|
|
56
44
|
lineHeight: 1.2,
|
|
57
45
|
scrollback: 5000,
|
|
58
|
-
theme:
|
|
46
|
+
theme: effectiveTheme.value,
|
|
59
47
|
allowProposedApi: true,
|
|
60
48
|
})
|
|
49
|
+
|
|
50
|
+
// React to theme switches without remounting.
|
|
51
|
+
watch(effectiveTheme, (next) => {
|
|
52
|
+
if (term.value) term.value.options.theme = next
|
|
53
|
+
}, { deep: true })
|
|
61
54
|
const fitAddon = new FitAddon()
|
|
62
55
|
const searchAddon = new SearchAddon()
|
|
63
56
|
t.loadAddon(fitAddon)
|
|
@@ -77,7 +70,22 @@ onMounted(async () => {
|
|
|
77
70
|
t.write(text)
|
|
78
71
|
})
|
|
79
72
|
|
|
73
|
+
// PR99c v3.69.0 — line-buffer for command history without server-
|
|
74
|
+
// side audit. Captures only printable chars up to Enter; ignores
|
|
75
|
+
// arrow keys, ctrl combos, escape sequences.
|
|
76
|
+
let lineBuf = ''
|
|
80
77
|
t.onData((data) => {
|
|
78
|
+
for (const ch of data) {
|
|
79
|
+
if (ch === '\r' || ch === '\n') {
|
|
80
|
+
const cmd = lineBuf.trim()
|
|
81
|
+
if (cmd) props.onInputLine?.(cmd)
|
|
82
|
+
lineBuf = ''
|
|
83
|
+
} else if (ch === '\x7f' || ch === '\b') {
|
|
84
|
+
lineBuf = lineBuf.slice(0, -1)
|
|
85
|
+
} else if (ch >= ' ' && ch < '\x7f') {
|
|
86
|
+
lineBuf += ch
|
|
87
|
+
}
|
|
88
|
+
}
|
|
81
89
|
session.sendInput(data)
|
|
82
90
|
})
|
|
83
91
|
|
|
@@ -103,7 +111,11 @@ onMounted(async () => {
|
|
|
103
111
|
onBeforeUnmount(async () => {
|
|
104
112
|
unsubscribeOutput?.()
|
|
105
113
|
resizeObserver?.disconnect()
|
|
106
|
-
|
|
114
|
+
// PR99c: only close the session if we created it. When the parent
|
|
115
|
+
// owns the session (props.session), parent is responsible for close.
|
|
116
|
+
if (!props.session) {
|
|
117
|
+
await session.close()
|
|
118
|
+
}
|
|
107
119
|
term.value?.dispose()
|
|
108
120
|
})
|
|
109
121
|
|
|
@@ -26,8 +26,13 @@ export interface TerminalSessionHandle {
|
|
|
26
26
|
onOutput: (cb: (chunk: Uint8Array) => void) => () => void
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
export function useTerminalSession(
|
|
30
|
-
|
|
29
|
+
export function useTerminalSession(
|
|
30
|
+
apiBaseOverride?: string,
|
|
31
|
+
): TerminalSessionHandle {
|
|
32
|
+
// PR99c v3.69.0 — apiBaseOverride lets useTerminalTabs construct
|
|
33
|
+
// sessions from user-event handlers without re-entering Nuxt's
|
|
34
|
+
// composable context.
|
|
35
|
+
const apiBase = apiBaseOverride ?? useApi().apiBase
|
|
31
36
|
const meta = ref<TerminalSessionMeta | null>(null)
|
|
32
37
|
const status = ref<'idle' | 'connecting' | 'open' | 'closed' | 'error'>('idle')
|
|
33
38
|
const error = ref<string | null>(null)
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// PR99c v3.69.0 — multi-session tab manager for /terminal.
|
|
2
|
+
//
|
|
3
|
+
// Owns an array of `useTerminalSession()` handles plus the active tab
|
|
4
|
+
// id. Persists tab titles (not PTYs — those are re-created fresh on
|
|
5
|
+
// reload) to localStorage. Enforces an 8-tab cap (matching the backend
|
|
6
|
+
// default).
|
|
7
|
+
|
|
8
|
+
export interface TerminalTab {
|
|
9
|
+
id: string
|
|
10
|
+
title: string
|
|
11
|
+
session: ReturnType<typeof useTerminalSession>
|
|
12
|
+
createdAt: number
|
|
13
|
+
hasActivity: boolean
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const STORAGE_KEY = 'arka-terminal-tab-titles'
|
|
17
|
+
const MAX_TABS = 8
|
|
18
|
+
|
|
19
|
+
interface PersistedTitle {
|
|
20
|
+
id: string
|
|
21
|
+
title: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function loadPersistedTitles(): Record<string, string> {
|
|
25
|
+
if (typeof localStorage === 'undefined') return {}
|
|
26
|
+
try {
|
|
27
|
+
const raw = localStorage.getItem(STORAGE_KEY)
|
|
28
|
+
if (!raw) return {}
|
|
29
|
+
const parsed = JSON.parse(raw) as PersistedTitle[]
|
|
30
|
+
return Object.fromEntries(parsed.map(p => [p.id, p.title]))
|
|
31
|
+
} catch {
|
|
32
|
+
return {}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function persistTitles(tabs: TerminalTab[]) {
|
|
37
|
+
if (typeof localStorage === 'undefined') return
|
|
38
|
+
try {
|
|
39
|
+
const payload: PersistedTitle[] = tabs.map(t => ({ id: t.id, title: t.title }))
|
|
40
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(payload))
|
|
41
|
+
} catch {
|
|
42
|
+
// quota / private mode — swallow
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function useTerminalTabs() {
|
|
47
|
+
const { apiBase } = useApi()
|
|
48
|
+
const tabs = ref<TerminalTab[]>([])
|
|
49
|
+
const activeId = ref<string | null>(null)
|
|
50
|
+
const capReached = computed(() => tabs.value.length >= MAX_TABS)
|
|
51
|
+
|
|
52
|
+
function makeId(): string {
|
|
53
|
+
return Math.random().toString(36).slice(2, 10)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function newTab(): TerminalTab | null {
|
|
57
|
+
if (tabs.value.length >= MAX_TABS) return null
|
|
58
|
+
const id = makeId()
|
|
59
|
+
const persistedTitles = loadPersistedTitles()
|
|
60
|
+
const titleFromStorage = persistedTitles[id]
|
|
61
|
+
const tab: TerminalTab = {
|
|
62
|
+
id,
|
|
63
|
+
title: titleFromStorage || `Session ${tabs.value.length + 1}`,
|
|
64
|
+
session: useTerminalSession(apiBase),
|
|
65
|
+
createdAt: Date.now(),
|
|
66
|
+
hasActivity: false,
|
|
67
|
+
}
|
|
68
|
+
tabs.value.push(tab)
|
|
69
|
+
activeId.value = id
|
|
70
|
+
persistTitles(tabs.value)
|
|
71
|
+
return tab
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function closeTab(id: string) {
|
|
75
|
+
const idx = tabs.value.findIndex(t => t.id === id)
|
|
76
|
+
if (idx < 0) return
|
|
77
|
+
const tab = tabs.value[idx]
|
|
78
|
+
if (!tab) return
|
|
79
|
+
await tab.session.close()
|
|
80
|
+
tabs.value.splice(idx, 1)
|
|
81
|
+
if (activeId.value === id) {
|
|
82
|
+
const next = tabs.value[Math.min(idx, tabs.value.length - 1)]
|
|
83
|
+
activeId.value = next ? next.id : null
|
|
84
|
+
}
|
|
85
|
+
persistTitles(tabs.value)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function switchTab(id: string) {
|
|
89
|
+
const t = tabs.value.find(t => t.id === id)
|
|
90
|
+
if (!t) return
|
|
91
|
+
activeId.value = id
|
|
92
|
+
t.hasActivity = false
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function renameTab(id: string, title: string) {
|
|
96
|
+
const t = tabs.value.find(t => t.id === id)
|
|
97
|
+
if (!t) return
|
|
98
|
+
t.title = title.trim() || t.title
|
|
99
|
+
persistTitles(tabs.value)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function markActivity(id: string) {
|
|
103
|
+
if (activeId.value === id) return
|
|
104
|
+
const t = tabs.value.find(t => t.id === id)
|
|
105
|
+
if (t) t.hasActivity = true
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function closeAll() {
|
|
109
|
+
await Promise.all(tabs.value.map(t => t.session.close()))
|
|
110
|
+
tabs.value = []
|
|
111
|
+
activeId.value = null
|
|
112
|
+
persistTitles([])
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const activeTab = computed(() => tabs.value.find(t => t.id === activeId.value) ?? null)
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
tabs,
|
|
119
|
+
activeId,
|
|
120
|
+
activeTab,
|
|
121
|
+
capReached,
|
|
122
|
+
maxTabs: MAX_TABS,
|
|
123
|
+
newTab,
|
|
124
|
+
closeTab,
|
|
125
|
+
switchTab,
|
|
126
|
+
renameTab,
|
|
127
|
+
markActivity,
|
|
128
|
+
closeAll,
|
|
129
|
+
}
|
|
130
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// PR99d v3.70.0 — theme presets for xterm.js.
|
|
2
|
+
//
|
|
3
|
+
// Stored as plain xterm.js ITheme objects. The active theme name lives
|
|
4
|
+
// in localStorage so it survives reloads. ArkaOS Dark is the default
|
|
5
|
+
// and is tuned to the dashboard's primary color.
|
|
6
|
+
|
|
7
|
+
export interface XtermTheme {
|
|
8
|
+
background: string
|
|
9
|
+
foreground: string
|
|
10
|
+
cursor: string
|
|
11
|
+
cursorAccent: string
|
|
12
|
+
selectionBackground: string
|
|
13
|
+
black: string
|
|
14
|
+
red: string
|
|
15
|
+
green: string
|
|
16
|
+
yellow: string
|
|
17
|
+
blue: string
|
|
18
|
+
magenta: string
|
|
19
|
+
cyan: string
|
|
20
|
+
white: string
|
|
21
|
+
brightBlack: string
|
|
22
|
+
brightRed: string
|
|
23
|
+
brightGreen: string
|
|
24
|
+
brightYellow: string
|
|
25
|
+
brightBlue: string
|
|
26
|
+
brightMagenta: string
|
|
27
|
+
brightCyan: string
|
|
28
|
+
brightWhite: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const TERMINAL_THEMES: Record<string, XtermTheme> = {
|
|
32
|
+
'arkaos-dark': {
|
|
33
|
+
background: '#0a0a0f',
|
|
34
|
+
foreground: '#e6e6f0',
|
|
35
|
+
cursor: '#7dd3fc',
|
|
36
|
+
cursorAccent: '#0a0a0f',
|
|
37
|
+
selectionBackground: '#1e3a5f',
|
|
38
|
+
black: '#0a0a0f',
|
|
39
|
+
red: '#f87171',
|
|
40
|
+
green: '#86efac',
|
|
41
|
+
yellow: '#fde68a',
|
|
42
|
+
blue: '#7dd3fc',
|
|
43
|
+
magenta: '#f0abfc',
|
|
44
|
+
cyan: '#67e8f9',
|
|
45
|
+
white: '#e6e6f0',
|
|
46
|
+
brightBlack: '#3f3f46',
|
|
47
|
+
brightRed: '#fca5a5',
|
|
48
|
+
brightGreen: '#bbf7d0',
|
|
49
|
+
brightYellow: '#fef3c7',
|
|
50
|
+
brightBlue: '#bae6fd',
|
|
51
|
+
brightMagenta: '#f5d0fe',
|
|
52
|
+
brightCyan: '#a5f3fc',
|
|
53
|
+
brightWhite: '#fafafa',
|
|
54
|
+
},
|
|
55
|
+
dracula: {
|
|
56
|
+
background: '#282a36',
|
|
57
|
+
foreground: '#f8f8f2',
|
|
58
|
+
cursor: '#f8f8f2',
|
|
59
|
+
cursorAccent: '#282a36',
|
|
60
|
+
selectionBackground: '#44475a',
|
|
61
|
+
black: '#21222c',
|
|
62
|
+
red: '#ff5555',
|
|
63
|
+
green: '#50fa7b',
|
|
64
|
+
yellow: '#f1fa8c',
|
|
65
|
+
blue: '#bd93f9',
|
|
66
|
+
magenta: '#ff79c6',
|
|
67
|
+
cyan: '#8be9fd',
|
|
68
|
+
white: '#f8f8f2',
|
|
69
|
+
brightBlack: '#6272a4',
|
|
70
|
+
brightRed: '#ff6e6e',
|
|
71
|
+
brightGreen: '#69ff94',
|
|
72
|
+
brightYellow: '#ffffa5',
|
|
73
|
+
brightBlue: '#d6acff',
|
|
74
|
+
brightMagenta: '#ff92df',
|
|
75
|
+
brightCyan: '#a4ffff',
|
|
76
|
+
brightWhite: '#ffffff',
|
|
77
|
+
},
|
|
78
|
+
'solarized-dark': {
|
|
79
|
+
background: '#002b36',
|
|
80
|
+
foreground: '#839496',
|
|
81
|
+
cursor: '#93a1a1',
|
|
82
|
+
cursorAccent: '#002b36',
|
|
83
|
+
selectionBackground: '#073642',
|
|
84
|
+
black: '#073642',
|
|
85
|
+
red: '#dc322f',
|
|
86
|
+
green: '#859900',
|
|
87
|
+
yellow: '#b58900',
|
|
88
|
+
blue: '#268bd2',
|
|
89
|
+
magenta: '#d33682',
|
|
90
|
+
cyan: '#2aa198',
|
|
91
|
+
white: '#eee8d5',
|
|
92
|
+
brightBlack: '#586e75',
|
|
93
|
+
brightRed: '#cb4b16',
|
|
94
|
+
brightGreen: '#586e75',
|
|
95
|
+
brightYellow: '#657b83',
|
|
96
|
+
brightBlue: '#839496',
|
|
97
|
+
brightMagenta: '#6c71c4',
|
|
98
|
+
brightCyan: '#93a1a1',
|
|
99
|
+
brightWhite: '#fdf6e3',
|
|
100
|
+
},
|
|
101
|
+
'solarized-light': {
|
|
102
|
+
background: '#fdf6e3',
|
|
103
|
+
foreground: '#657b83',
|
|
104
|
+
cursor: '#586e75',
|
|
105
|
+
cursorAccent: '#fdf6e3',
|
|
106
|
+
selectionBackground: '#eee8d5',
|
|
107
|
+
black: '#073642',
|
|
108
|
+
red: '#dc322f',
|
|
109
|
+
green: '#859900',
|
|
110
|
+
yellow: '#b58900',
|
|
111
|
+
blue: '#268bd2',
|
|
112
|
+
magenta: '#d33682',
|
|
113
|
+
cyan: '#2aa198',
|
|
114
|
+
white: '#eee8d5',
|
|
115
|
+
brightBlack: '#002b36',
|
|
116
|
+
brightRed: '#cb4b16',
|
|
117
|
+
brightGreen: '#586e75',
|
|
118
|
+
brightYellow: '#657b83',
|
|
119
|
+
brightBlue: '#839496',
|
|
120
|
+
brightMagenta: '#6c71c4',
|
|
121
|
+
brightCyan: '#93a1a1',
|
|
122
|
+
brightWhite: '#fdf6e3',
|
|
123
|
+
},
|
|
124
|
+
nord: {
|
|
125
|
+
background: '#2e3440',
|
|
126
|
+
foreground: '#d8dee9',
|
|
127
|
+
cursor: '#d8dee9',
|
|
128
|
+
cursorAccent: '#2e3440',
|
|
129
|
+
selectionBackground: '#434c5e',
|
|
130
|
+
black: '#3b4252',
|
|
131
|
+
red: '#bf616a',
|
|
132
|
+
green: '#a3be8c',
|
|
133
|
+
yellow: '#ebcb8b',
|
|
134
|
+
blue: '#81a1c1',
|
|
135
|
+
magenta: '#b48ead',
|
|
136
|
+
cyan: '#88c0d0',
|
|
137
|
+
white: '#e5e9f0',
|
|
138
|
+
brightBlack: '#4c566a',
|
|
139
|
+
brightRed: '#bf616a',
|
|
140
|
+
brightGreen: '#a3be8c',
|
|
141
|
+
brightYellow: '#ebcb8b',
|
|
142
|
+
brightBlue: '#81a1c1',
|
|
143
|
+
brightMagenta: '#b48ead',
|
|
144
|
+
brightCyan: '#8fbcbb',
|
|
145
|
+
brightWhite: '#eceff4',
|
|
146
|
+
},
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export const THEME_LABELS: Record<string, string> = {
|
|
150
|
+
'arkaos-dark': 'ArkaOS Dark',
|
|
151
|
+
dracula: 'Dracula',
|
|
152
|
+
'solarized-dark': 'Solarized Dark',
|
|
153
|
+
'solarized-light': 'Solarized Light',
|
|
154
|
+
nord: 'Nord',
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const STORAGE_KEY = 'arka-terminal-theme'
|
|
158
|
+
const DEFAULT_THEME = 'arkaos-dark'
|
|
159
|
+
|
|
160
|
+
export function useTerminalThemes() {
|
|
161
|
+
const themeName = useState<string>('terminal-theme', () => {
|
|
162
|
+
if (typeof localStorage === 'undefined') return DEFAULT_THEME
|
|
163
|
+
return localStorage.getItem(STORAGE_KEY) || DEFAULT_THEME
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
function setTheme(name: string) {
|
|
167
|
+
if (!TERMINAL_THEMES[name]) return
|
|
168
|
+
themeName.value = name
|
|
169
|
+
try {
|
|
170
|
+
localStorage.setItem(STORAGE_KEY, name)
|
|
171
|
+
} catch {
|
|
172
|
+
// ignore quota
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const activeTheme = computed<XtermTheme>(
|
|
177
|
+
() => TERMINAL_THEMES[themeName.value] ?? TERMINAL_THEMES[DEFAULT_THEME]!,
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
const options = computed(() =>
|
|
181
|
+
Object.entries(THEME_LABELS).map(([value, label]) => ({ value, label })),
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
themeName,
|
|
186
|
+
activeTheme,
|
|
187
|
+
setTheme,
|
|
188
|
+
options,
|
|
189
|
+
}
|
|
190
|
+
}
|
|
@@ -1,25 +1,159 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
// PR99b v3.68.0 — Real-shell terminal (single session).
|
|
3
|
+
// PR99c v3.69.0 — Multi-session tabs + browser-local command history.
|
|
3
4
|
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// until PR99d, but the UI is gone as of this release.
|
|
5
|
+
// Each tab owns its own PTY session. PTYs are NOT persisted across
|
|
6
|
+
// reloads (they're per-process); only tab titles are. The 8-tab cap
|
|
7
|
+
// matches the backend default.
|
|
8
8
|
|
|
9
9
|
definePageMeta({ layout: 'default' })
|
|
10
10
|
|
|
11
|
-
const
|
|
12
|
-
|
|
11
|
+
const {
|
|
12
|
+
tabs,
|
|
13
|
+
activeId,
|
|
14
|
+
activeTab,
|
|
15
|
+
capReached,
|
|
16
|
+
maxTabs,
|
|
17
|
+
newTab,
|
|
18
|
+
closeTab,
|
|
19
|
+
switchTab,
|
|
20
|
+
renameTab,
|
|
21
|
+
} = useTerminalTabs()
|
|
22
|
+
|
|
23
|
+
const HISTORY_KEY = 'arka-terminal-command-history'
|
|
24
|
+
const HISTORY_MAX = 500
|
|
25
|
+
|
|
26
|
+
interface HistoryEntry {
|
|
27
|
+
ts: number
|
|
28
|
+
cmd: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function loadHistory(): HistoryEntry[] {
|
|
32
|
+
if (typeof localStorage === 'undefined') return []
|
|
33
|
+
try {
|
|
34
|
+
const raw = localStorage.getItem(HISTORY_KEY)
|
|
35
|
+
return raw ? (JSON.parse(raw) as HistoryEntry[]) : []
|
|
36
|
+
} catch {
|
|
37
|
+
return []
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const history = ref<HistoryEntry[]>(loadHistory())
|
|
42
|
+
|
|
43
|
+
function recordCommand(cmd: string) {
|
|
44
|
+
const trimmed = cmd.trim()
|
|
45
|
+
if (!trimmed || trimmed.length < 2) return
|
|
46
|
+
history.value.unshift({ ts: Date.now(), cmd: trimmed })
|
|
47
|
+
if (history.value.length > HISTORY_MAX) {
|
|
48
|
+
history.value = history.value.slice(0, HISTORY_MAX)
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
localStorage.setItem(HISTORY_KEY, JSON.stringify(history.value))
|
|
52
|
+
} catch {
|
|
53
|
+
// quota — silently truncate further
|
|
54
|
+
history.value = history.value.slice(0, 200)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// PR99d v3.70.0 — theme picker + Ctrl+R history search.
|
|
59
|
+
const { themeName, setTheme, options: themeOptions } = useTerminalThemes()
|
|
60
|
+
const searchOpen = ref(false)
|
|
61
|
+
const searchQuery = ref('')
|
|
62
|
+
|
|
63
|
+
const searchResults = computed(() => {
|
|
64
|
+
const q = searchQuery.value.trim().toLowerCase()
|
|
65
|
+
if (!q) return history.value.slice(0, 30)
|
|
66
|
+
return history.value
|
|
67
|
+
.filter((h) => h.cmd.toLowerCase().includes(q))
|
|
68
|
+
.slice(0, 30)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
function openSearch() {
|
|
72
|
+
searchOpen.value = true
|
|
73
|
+
searchQuery.value = ''
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function pickFromSearch(cmd: string) {
|
|
77
|
+
activeTab.value?.session.sendInput(cmd)
|
|
78
|
+
searchOpen.value = false
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const editingTabId = ref<string | null>(null)
|
|
82
|
+
const renameDraft = ref('')
|
|
83
|
+
|
|
84
|
+
function startRename(tabId: string, currentTitle: string) {
|
|
85
|
+
editingTabId.value = tabId
|
|
86
|
+
renameDraft.value = currentTitle
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function commitRename() {
|
|
90
|
+
if (editingTabId.value) {
|
|
91
|
+
renameTab(editingTabId.value, renameDraft.value)
|
|
92
|
+
}
|
|
93
|
+
editingTabId.value = null
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const toast = useToast()
|
|
97
|
+
|
|
98
|
+
function tryNewTab() {
|
|
99
|
+
if (capReached.value) {
|
|
100
|
+
toast.add({
|
|
101
|
+
title: 'Maximum sessions reached',
|
|
102
|
+
description: `You can have up to ${maxTabs} sessions open at once. Close one to open a new one.`,
|
|
103
|
+
color: 'warning',
|
|
104
|
+
icon: 'i-lucide-alert-triangle',
|
|
105
|
+
})
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
newTab()
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Keyboard shortcuts.
|
|
112
|
+
defineShortcuts({
|
|
113
|
+
meta_t: { handler: tryNewTab, usingInput: false },
|
|
114
|
+
meta_w: {
|
|
115
|
+
handler: () => {
|
|
116
|
+
if (activeId.value) closeTab(activeId.value)
|
|
117
|
+
},
|
|
118
|
+
usingInput: false,
|
|
119
|
+
},
|
|
120
|
+
ctrl_r: { handler: openSearch, usingInput: false },
|
|
121
|
+
meta_1: { handler: () => switchByIndex(0), usingInput: false },
|
|
122
|
+
meta_2: { handler: () => switchByIndex(1), usingInput: false },
|
|
123
|
+
meta_3: { handler: () => switchByIndex(2), usingInput: false },
|
|
124
|
+
meta_4: { handler: () => switchByIndex(3), usingInput: false },
|
|
125
|
+
meta_5: { handler: () => switchByIndex(4), usingInput: false },
|
|
126
|
+
meta_6: { handler: () => switchByIndex(5), usingInput: false },
|
|
127
|
+
meta_7: { handler: () => switchByIndex(6), usingInput: false },
|
|
128
|
+
meta_8: { handler: () => switchByIndex(7), usingInput: false },
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
function switchByIndex(idx: number) {
|
|
132
|
+
const t = tabs.value[idx]
|
|
133
|
+
if (t) switchTab(t.id)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// First-visit: open one tab automatically.
|
|
137
|
+
onMounted(() => {
|
|
138
|
+
if (tabs.value.length === 0) newTab()
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
onBeforeUnmount(async () => {
|
|
142
|
+
// Don't proactively close tabs — operator may navigate back. Backend
|
|
143
|
+
// reaper will GC after the idle timeout (30 min).
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
const showHistory = ref(false)
|
|
13
147
|
</script>
|
|
14
148
|
|
|
15
149
|
<template>
|
|
16
150
|
<div class="flex flex-col gap-3 h-full">
|
|
17
|
-
<header class="flex items-center justify-between">
|
|
151
|
+
<header class="flex items-center justify-between gap-3 flex-wrap">
|
|
18
152
|
<div>
|
|
19
153
|
<h1 class="text-2xl font-semibold">Terminal</h1>
|
|
20
154
|
<p class="text-sm text-muted">
|
|
21
|
-
Real shell
|
|
22
|
-
|
|
155
|
+
Real PTY shell — run claude, codex, git, anything. ⌘T new ·
|
|
156
|
+
⌘W close · ⌘1–8 switch.
|
|
23
157
|
</p>
|
|
24
158
|
</div>
|
|
25
159
|
<div class="flex items-center gap-2">
|
|
@@ -27,25 +161,155 @@ const expanded = ref(false)
|
|
|
27
161
|
<UIcon name="i-lucide-shield" class="size-3 mr-1" />
|
|
28
162
|
localhost only
|
|
29
163
|
</UBadge>
|
|
164
|
+
<USelect
|
|
165
|
+
:model-value="themeName"
|
|
166
|
+
:items="themeOptions"
|
|
167
|
+
size="xs"
|
|
168
|
+
class="w-44"
|
|
169
|
+
@update:model-value="setTheme($event as string)"
|
|
170
|
+
/>
|
|
171
|
+
<UButton
|
|
172
|
+
size="xs"
|
|
173
|
+
variant="ghost"
|
|
174
|
+
icon="i-lucide-search"
|
|
175
|
+
title="Ctrl+R — search history"
|
|
176
|
+
@click="openSearch"
|
|
177
|
+
>
|
|
178
|
+
⌃R
|
|
179
|
+
</UButton>
|
|
30
180
|
<UButton
|
|
31
181
|
size="xs"
|
|
32
182
|
variant="ghost"
|
|
33
|
-
:icon="
|
|
34
|
-
@click="
|
|
183
|
+
:icon="showHistory ? 'i-lucide-x' : 'i-lucide-history'"
|
|
184
|
+
@click="showHistory = !showHistory"
|
|
35
185
|
>
|
|
36
|
-
{{
|
|
186
|
+
History ({{ history.length }})
|
|
37
187
|
</UButton>
|
|
38
188
|
</div>
|
|
39
189
|
</header>
|
|
40
190
|
|
|
41
|
-
<
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
191
|
+
<div class="flex items-center gap-1 border-b border-default pb-2 overflow-x-auto">
|
|
192
|
+
<div
|
|
193
|
+
v-for="(tab, idx) in tabs"
|
|
194
|
+
:key="tab.id"
|
|
195
|
+
:class="[
|
|
196
|
+
'group flex items-center gap-1 px-3 py-1.5 rounded-t-md cursor-pointer text-sm shrink-0 border-b-2 transition-colors',
|
|
197
|
+
activeId === tab.id
|
|
198
|
+
? 'bg-elevated/60 border-primary text-default'
|
|
199
|
+
: 'border-transparent text-muted hover:text-default hover:bg-elevated/30',
|
|
200
|
+
]"
|
|
201
|
+
@click="switchTab(tab.id)"
|
|
202
|
+
@dblclick="startRename(tab.id, tab.title)"
|
|
203
|
+
>
|
|
204
|
+
<span class="text-xs text-muted">{{ idx + 1 }}</span>
|
|
205
|
+
<UInput
|
|
206
|
+
v-if="editingTabId === tab.id"
|
|
207
|
+
v-model="renameDraft"
|
|
208
|
+
size="xs"
|
|
209
|
+
autofocus
|
|
210
|
+
@keydown.enter="commitRename"
|
|
211
|
+
@keydown.esc="editingTabId = null"
|
|
212
|
+
@blur="commitRename"
|
|
213
|
+
/>
|
|
214
|
+
<span v-else>{{ tab.title }}</span>
|
|
215
|
+
<UIcon
|
|
216
|
+
v-if="tab.hasActivity && activeId !== tab.id"
|
|
217
|
+
name="i-lucide-circle"
|
|
218
|
+
class="size-2 text-amber-400 fill-current"
|
|
219
|
+
/>
|
|
220
|
+
<button
|
|
221
|
+
class="ml-1 size-4 grid place-items-center rounded text-muted hover:bg-default/50 hover:text-default opacity-0 group-hover:opacity-100"
|
|
222
|
+
@click.stop="closeTab(tab.id)"
|
|
223
|
+
>
|
|
224
|
+
<UIcon name="i-lucide-x" class="size-3" />
|
|
225
|
+
</button>
|
|
226
|
+
</div>
|
|
227
|
+
<UButton
|
|
228
|
+
size="xs"
|
|
229
|
+
variant="ghost"
|
|
230
|
+
icon="i-lucide-plus"
|
|
231
|
+
:disabled="capReached"
|
|
232
|
+
:title="capReached ? `Max ${maxTabs} sessions` : 'New session'"
|
|
233
|
+
@click="tryNewTab"
|
|
234
|
+
>
|
|
235
|
+
New
|
|
236
|
+
</UButton>
|
|
237
|
+
</div>
|
|
238
|
+
|
|
239
|
+
<div class="flex-1 min-h-[480px] flex gap-3">
|
|
240
|
+
<div class="flex-1 relative">
|
|
241
|
+
<template v-for="tab in tabs" :key="tab.id">
|
|
242
|
+
<Terminal
|
|
243
|
+
v-show="activeId === tab.id"
|
|
244
|
+
:session="tab.session"
|
|
245
|
+
:on-input-line="recordCommand"
|
|
246
|
+
class="absolute inset-0"
|
|
247
|
+
/>
|
|
248
|
+
</template>
|
|
249
|
+
<div
|
|
250
|
+
v-if="tabs.length === 0"
|
|
251
|
+
class="absolute inset-0 grid place-items-center text-muted text-sm"
|
|
252
|
+
>
|
|
253
|
+
No active sessions. Press ⌘T or click "+ New" to open one.
|
|
254
|
+
</div>
|
|
255
|
+
</div>
|
|
256
|
+
<aside
|
|
257
|
+
v-if="showHistory"
|
|
258
|
+
class="w-72 shrink-0 rounded-lg border border-default bg-elevated/10 overflow-hidden flex flex-col"
|
|
259
|
+
>
|
|
260
|
+
<div class="px-3 py-2 border-b border-default text-xs uppercase tracking-wide text-muted">
|
|
261
|
+
Command history
|
|
262
|
+
</div>
|
|
263
|
+
<div class="flex-1 overflow-auto text-xs font-mono">
|
|
264
|
+
<button
|
|
265
|
+
v-for="(entry, i) in history"
|
|
266
|
+
:key="i"
|
|
267
|
+
class="w-full text-left px-3 py-1.5 hover:bg-default/40 truncate"
|
|
268
|
+
:title="entry.cmd"
|
|
269
|
+
@click="activeTab?.session.sendInput(entry.cmd)"
|
|
270
|
+
>
|
|
271
|
+
{{ entry.cmd }}
|
|
272
|
+
</button>
|
|
273
|
+
<div v-if="history.length === 0" class="px-3 py-4 text-muted text-center">
|
|
274
|
+
No commands yet
|
|
275
|
+
</div>
|
|
276
|
+
</div>
|
|
277
|
+
</aside>
|
|
278
|
+
</div>
|
|
45
279
|
|
|
46
280
|
<footer class="text-xs text-muted">
|
|
47
|
-
|
|
48
|
-
|
|
281
|
+
Sessions live on the backend until you close them or 30 min idle.
|
|
282
|
+
History stays in this browser only. Ctrl+R to search history.
|
|
49
283
|
</footer>
|
|
284
|
+
|
|
285
|
+
<UModal v-model:open="searchOpen" :title="`Search history (${history.length})`">
|
|
286
|
+
<template #body>
|
|
287
|
+
<div class="space-y-2">
|
|
288
|
+
<UInput
|
|
289
|
+
v-model="searchQuery"
|
|
290
|
+
placeholder="type to filter…"
|
|
291
|
+
autofocus
|
|
292
|
+
icon="i-lucide-search"
|
|
293
|
+
@keydown.enter="searchResults[0] && pickFromSearch(searchResults[0].cmd)"
|
|
294
|
+
/>
|
|
295
|
+
<div class="max-h-80 overflow-y-auto rounded-md border border-default divide-y divide-default">
|
|
296
|
+
<button
|
|
297
|
+
v-for="(entry, i) in searchResults"
|
|
298
|
+
:key="i"
|
|
299
|
+
class="w-full text-left px-3 py-2 text-sm font-mono hover:bg-elevated/40 truncate"
|
|
300
|
+
@click="pickFromSearch(entry.cmd)"
|
|
301
|
+
>
|
|
302
|
+
{{ entry.cmd }}
|
|
303
|
+
</button>
|
|
304
|
+
<div v-if="searchResults.length === 0" class="px-3 py-4 text-muted text-center text-sm">
|
|
305
|
+
No matches
|
|
306
|
+
</div>
|
|
307
|
+
</div>
|
|
308
|
+
<p class="text-xs text-muted">
|
|
309
|
+
Enter sends the top match to the active session.
|
|
310
|
+
</p>
|
|
311
|
+
</div>
|
|
312
|
+
</template>
|
|
313
|
+
</UModal>
|
|
50
314
|
</div>
|
|
51
315
|
</template>
|
package/package.json
CHANGED
package/pyproject.toml
CHANGED
|
Binary file
|
package/scripts/dashboard-api.py
CHANGED
|
@@ -2221,216 +2221,8 @@ def _iso_duration_s(start_iso: str, end_iso: str) -> Optional[int]:
|
|
|
2221
2221
|
return None
|
|
2222
2222
|
|
|
2223
2223
|
|
|
2224
|
-
#
|
|
2225
|
-
|
|
2226
|
-
# Allowlist of commands the dashboard terminal can run. Each entry is
|
|
2227
|
-
# {id, label, cmd, description}. Server-side enforcement: any request
|
|
2228
|
-
# whose `command_id` isn't in this list is rejected. No shell expansion,
|
|
2229
|
-
# no globbing, no pipes — subprocess.run with explicit argv only.
|
|
2230
|
-
TERMINAL_ALLOWLIST: list[dict] = [
|
|
2231
|
-
{
|
|
2232
|
-
"id": "arka-status",
|
|
2233
|
-
"label": "ArkaOS status",
|
|
2234
|
-
"cmd": ["arkaos", "status"],
|
|
2235
|
-
"description": "System status (version, departments, agents, projects).",
|
|
2236
|
-
},
|
|
2237
|
-
{
|
|
2238
|
-
"id": "git-status",
|
|
2239
|
-
"label": "git status",
|
|
2240
|
-
"cmd": ["git", "status", "--short"],
|
|
2241
|
-
"description": "Working tree status in short form.",
|
|
2242
|
-
},
|
|
2243
|
-
{
|
|
2244
|
-
"id": "git-log",
|
|
2245
|
-
"label": "git log",
|
|
2246
|
-
"cmd_template": ["git", "log", "-{count}", "--oneline"],
|
|
2247
|
-
"args": [
|
|
2248
|
-
{
|
|
2249
|
-
"name": "count",
|
|
2250
|
-
"label": "Commits to show",
|
|
2251
|
-
"choices": ["5", "10", "20", "50"],
|
|
2252
|
-
"default": "10",
|
|
2253
|
-
},
|
|
2254
|
-
],
|
|
2255
|
-
"description": "Most recent N commits (operator picks N).",
|
|
2256
|
-
},
|
|
2257
|
-
{
|
|
2258
|
-
"id": "npm-version",
|
|
2259
|
-
"label": "npm view arkaos version",
|
|
2260
|
-
"cmd": ["npm", "view", "arkaos", "version"],
|
|
2261
|
-
"description": "Latest published ArkaOS version on npm.",
|
|
2262
|
-
},
|
|
2263
|
-
{
|
|
2264
|
-
"id": "pytest-collect",
|
|
2265
|
-
"label": "pytest --collect-only",
|
|
2266
|
-
"cmd": ["python3", "-m", "pytest", "--collect-only", "-q", "tests/python/"],
|
|
2267
|
-
"description": "List every Python test without running.",
|
|
2268
|
-
},
|
|
2269
|
-
{
|
|
2270
|
-
"id": "ls",
|
|
2271
|
-
"label": "ls",
|
|
2272
|
-
"cmd": ["ls", "-la"],
|
|
2273
|
-
"description": "List files in the project root.",
|
|
2274
|
-
},
|
|
2275
|
-
{
|
|
2276
|
-
"id": "arka-costs",
|
|
2277
|
-
"label": "ArkaOS costs",
|
|
2278
|
-
"cmd_template": ["python3", "-m", "core.runtime.llm_cost_telemetry_cli", "{period}"],
|
|
2279
|
-
"args": [
|
|
2280
|
-
{
|
|
2281
|
-
"name": "period",
|
|
2282
|
-
"label": "Period",
|
|
2283
|
-
"choices": ["today", "week", "month", "all"],
|
|
2284
|
-
"default": "today",
|
|
2285
|
-
},
|
|
2286
|
-
],
|
|
2287
|
-
"description": "LLM cost summary for the selected period.",
|
|
2288
|
-
},
|
|
2289
|
-
]
|
|
2290
|
-
|
|
2291
|
-
_TERMINAL_TIMEOUT_S = 15
|
|
2292
|
-
_TERMINAL_MAX_OUTPUT = 20_000 # chars — both stdout and stderr capped
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
@app.get("/api/terminal/commands")
|
|
2296
|
-
def terminal_commands():
|
|
2297
|
-
"""List allowlisted commands the dashboard terminal may run.
|
|
2298
|
-
|
|
2299
|
-
PR96b v3.56.0 — includes the `args` schema (label / choices /
|
|
2300
|
-
default) when an entry has parameters. The raw cmd / cmd_template
|
|
2301
|
-
is NEVER returned — defence in depth.
|
|
2302
|
-
"""
|
|
2303
|
-
out: list[dict] = []
|
|
2304
|
-
for c in TERMINAL_ALLOWLIST:
|
|
2305
|
-
out.append({
|
|
2306
|
-
"id": c["id"],
|
|
2307
|
-
"label": c["label"],
|
|
2308
|
-
"description": c["description"],
|
|
2309
|
-
"args": _safe_args_schema(c.get("args")),
|
|
2310
|
-
})
|
|
2311
|
-
return {"commands": out, "total": len(out)}
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
def _safe_args_schema(args) -> list[dict]:
|
|
2315
|
-
"""Sanitised arg schema for the public API — never leaks cmd/template."""
|
|
2316
|
-
if not isinstance(args, list):
|
|
2317
|
-
return []
|
|
2318
|
-
out: list[dict] = []
|
|
2319
|
-
for a in args:
|
|
2320
|
-
if not isinstance(a, dict):
|
|
2321
|
-
continue
|
|
2322
|
-
choices = a.get("choices")
|
|
2323
|
-
if not isinstance(choices, list) or not choices:
|
|
2324
|
-
continue
|
|
2325
|
-
out.append({
|
|
2326
|
-
"name": str(a.get("name") or ""),
|
|
2327
|
-
"label": str(a.get("label") or a.get("name") or ""),
|
|
2328
|
-
"choices": [str(x) for x in choices],
|
|
2329
|
-
"default": str(a.get("default") or choices[0]),
|
|
2330
|
-
})
|
|
2331
|
-
return out
|
|
2332
|
-
|
|
2333
|
-
|
|
2334
|
-
@app.post("/api/terminal/exec")
|
|
2335
|
-
def terminal_exec(body: dict):
|
|
2336
|
-
"""PR95a v3.51.0 — run an allowlisted command and return the output.
|
|
2337
|
-
|
|
2338
|
-
Body: ``{"command_id": "<id>"}``. Unknown ids are rejected.
|
|
2339
|
-
Returns ``{stdout, stderr, exit_code, duration_ms, command}``.
|
|
2340
|
-
Output is capped at ``_TERMINAL_MAX_OUTPUT`` chars per stream.
|
|
2341
|
-
"""
|
|
2342
|
-
if not isinstance(body, dict):
|
|
2343
|
-
return {"error": "body must be an object"}
|
|
2344
|
-
cid = (body.get("command_id") or "").strip()
|
|
2345
|
-
if not cid:
|
|
2346
|
-
return {"error": "command_id is required"}
|
|
2347
|
-
entry = next((c for c in TERMINAL_ALLOWLIST if c["id"] == cid), None)
|
|
2348
|
-
if entry is None:
|
|
2349
|
-
return {"error": f"command '{cid}' is not on the allowlist"}
|
|
2350
|
-
|
|
2351
|
-
# PR96b v3.56.0 — resolve template + args, or fall back to fixed cmd.
|
|
2352
|
-
if "cmd_template" in entry:
|
|
2353
|
-
argv, err = _resolve_cmd_template(entry, body.get("args") or {})
|
|
2354
|
-
if err:
|
|
2355
|
-
return {"error": err}
|
|
2356
|
-
else:
|
|
2357
|
-
argv = entry["cmd"]
|
|
2358
|
-
|
|
2359
|
-
import time
|
|
2360
|
-
started = time.monotonic()
|
|
2361
|
-
try:
|
|
2362
|
-
result = subprocess.run(
|
|
2363
|
-
argv,
|
|
2364
|
-
cwd=str(ARKAOS_ROOT),
|
|
2365
|
-
capture_output=True,
|
|
2366
|
-
text=True,
|
|
2367
|
-
timeout=_TERMINAL_TIMEOUT_S,
|
|
2368
|
-
shell=False,
|
|
2369
|
-
)
|
|
2370
|
-
except subprocess.TimeoutExpired:
|
|
2371
|
-
return {
|
|
2372
|
-
"stdout": "",
|
|
2373
|
-
"stderr": f"command timed out after {_TERMINAL_TIMEOUT_S}s",
|
|
2374
|
-
"exit_code": -1,
|
|
2375
|
-
"duration_ms": int(_TERMINAL_TIMEOUT_S * 1000),
|
|
2376
|
-
"command": " ".join(argv),
|
|
2377
|
-
}
|
|
2378
|
-
except OSError as exc:
|
|
2379
|
-
return {
|
|
2380
|
-
"stdout": "",
|
|
2381
|
-
"stderr": f"command failed to launch: {exc}",
|
|
2382
|
-
"exit_code": -1,
|
|
2383
|
-
"duration_ms": int((time.monotonic() - started) * 1000),
|
|
2384
|
-
"command": " ".join(argv),
|
|
2385
|
-
}
|
|
2386
|
-
duration_ms = int((time.monotonic() - started) * 1000)
|
|
2387
|
-
return {
|
|
2388
|
-
"stdout": (result.stdout or "")[:_TERMINAL_MAX_OUTPUT],
|
|
2389
|
-
"stderr": (result.stderr or "")[:_TERMINAL_MAX_OUTPUT],
|
|
2390
|
-
"exit_code": result.returncode,
|
|
2391
|
-
"duration_ms": duration_ms,
|
|
2392
|
-
"command": " ".join(argv),
|
|
2393
|
-
}
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
def _resolve_cmd_template(entry: dict, supplied: dict) -> tuple[list[str], "str | None"]:
|
|
2397
|
-
"""Substitute `{name}` placeholders in cmd_template with validated args.
|
|
2398
|
-
|
|
2399
|
-
Returns (argv, None) on success, ([], error_msg) on validation failure.
|
|
2400
|
-
Anything supplied that isn't in the schema's choices is rejected.
|
|
2401
|
-
Unknown arg names are also rejected — no silent passthrough.
|
|
2402
|
-
"""
|
|
2403
|
-
schema = entry.get("args") or []
|
|
2404
|
-
if not isinstance(schema, list):
|
|
2405
|
-
return [], "command has invalid args schema"
|
|
2406
|
-
by_name: dict[str, dict] = {a["name"]: a for a in schema if isinstance(a, dict)}
|
|
2407
|
-
chosen: dict[str, str] = {}
|
|
2408
|
-
for arg_def in schema:
|
|
2409
|
-
name = arg_def["name"]
|
|
2410
|
-
s = supplied.get(name)
|
|
2411
|
-
if s is None:
|
|
2412
|
-
chosen[name] = str(arg_def.get("default") or arg_def["choices"][0])
|
|
2413
|
-
continue
|
|
2414
|
-
s_str = str(s)
|
|
2415
|
-
if s_str not in arg_def["choices"]:
|
|
2416
|
-
return [], (
|
|
2417
|
-
f"arg '{name}'='{s_str}' is not in the allowed choices "
|
|
2418
|
-
f"({', '.join(arg_def['choices'])})"
|
|
2419
|
-
)
|
|
2420
|
-
chosen[name] = s_str
|
|
2421
|
-
for k in supplied:
|
|
2422
|
-
if k not in by_name:
|
|
2423
|
-
return [], f"unknown arg '{k}'"
|
|
2424
|
-
argv: list[str] = []
|
|
2425
|
-
for tok in entry["cmd_template"]:
|
|
2426
|
-
if not isinstance(tok, str):
|
|
2427
|
-
continue
|
|
2428
|
-
out_tok = tok
|
|
2429
|
-
for name, val in chosen.items():
|
|
2430
|
-
out_tok = out_tok.replace(f"{{{name}}}", val)
|
|
2431
|
-
argv.append(out_tok)
|
|
2432
|
-
return argv, None
|
|
2433
|
-
|
|
2224
|
+
# Allowlist runner (PR95a v3.51.0) removed in PR99d v3.70.0 —
|
|
2225
|
+
# replaced by PTY WebSocket from PR99a/b/c.
|
|
2434
2226
|
|
|
2435
2227
|
@app.put("/api/workflows/{workflow_id}/yaml")
|
|
2436
2228
|
def workflow_update_yaml(workflow_id: str, body: dict):
|