Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,9 @@ object ExecutionCoordinator {
suspend fun execute(
sessionId: String,
command: String,
// Ignored. Armed timeout is BudgetClassifier.classify(command).wallMs.
// [T-timeout-honored] May SHORTEN the armed wall clock, never
// extend it: armed = min(timeout, class wall). The class ceiling
// stays the authority; the caller gets to bound its own wait.
timeout: Long = 600_000L,
lineCallback: ((String) -> Unit)? = null,
resourceClass: SandboxResourceGate.ResourceClass = SandboxResourceGate.ResourceClass.AUTO,
Expand Down Expand Up @@ -174,11 +176,17 @@ object ExecutionCoordinator {
onWaiting = { lineCallback?.invoke(it) },
) {
val budget = BudgetClassifier.classify(command, resourceClass)
val armed = budget.wallMs
if (timeout != armed) {
Log.w(
// [T-timeout-honored] The caller may shorten the class wall but
// never extend it, and the setup floor always survives (a killed
// minis-dev-setup-full leaves a broken rootfs). The old code
// logged "caller timeout ignored" — a 60s request then rode the
// full 1200s BATCH wall while the user watched the shell "hang".
val armed = BudgetClassifier.armMs(budget, command, timeout)
if (timeout > 0L && armed < budget.wallMs) {
Log.i(
TAG,
"[$sessionId] caller timeout ${timeout}ms ignored; armed ${armed}ms class=${budget.workClass}",
"[$sessionId] caller timeout ${timeout}ms shortens class " +
"${budget.workClass} wall ${budget.wallMs}ms → ${armed}ms",
)
}
val startTime = System.currentTimeMillis()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ internal object GuestWorkloadPolicy {
*/
fun isDiskRelief(command: String): Boolean {
if (hasWriteSyntax(command)) return false
val segments = command.split(Regex("""&&|\|\||[;|]""")).map { it.trim() }.filter { it.isNotEmpty() }
val segments = splitCommandSegments(command)
if (segments.isEmpty()) return false
return segments.all { segment ->
val token = commandToken(segment) ?: return@all false
Expand Down Expand Up @@ -192,7 +192,7 @@ internal object GuestWorkloadPolicy {
fun isObviouslyReadOnly(command: String): Boolean {
if (command.isBlank()) return true
if (hasWriteSyntax(command)) return false
val segments = command.split(Regex("""&&|\|\||[;|]""")).map { it.trim() }.filter { it.isNotEmpty() }
val segments = splitCommandSegments(command)
if (segments.isEmpty()) return true
return segments.all { segment ->
val token = commandToken(segment) ?: return@all false
Expand All @@ -203,6 +203,23 @@ internal object GuestWorkloadPolicy {
}
}

/**
* [T-quote-aware-split] Split a compound command into segments WITHOUT
* letting quoted spans create phantom segments. `grep -n "a\|b" file`
* contains a `|` inside quotes: the old raw split cut it into `grep -n "a\`
* and `b" file`, whose garbage leading token failed the read-only
* whitelist — so a plainly read-only pipeline classified as heavy work,
* escalated to BATCH and armed a 20-minute wall clock (the field report:
* every `cd repo && grep …` hung the full 1200s). Quoted spans are
* blanked first, exactly like [hasWriteSyntax] already does; only the
* leading token of each segment is inspected, so losing the quoted text
* costs nothing.
*/
internal fun splitCommandSegments(command: String): List<String> {
val stripped = command.replace(Regex(""""[^"]*"|'[^']*'"""), " ")
return stripped.split(Regex("""&&|\|\||[;|]""")).map { it.trim() }.filter { it.isNotEmpty() }
}

/**
* The ceiling may reduce log sampling. It must not drop the counter:
* a gap that is only "too long to be a hang" is exactly the freeze the
Expand Down Expand Up @@ -319,6 +336,10 @@ internal object GuestWorkloadPolicy {
// when the disk IS the constraint — it was missing while `df` was
// present. The rest are pure reporters with no write mode, added for
// the same reason: they diagnose a stressed system without growing it.
// [T-cd-readonly] `cd`/`pushd`/`popd` are pure navigation builtins: they
// write nothing to disk, yet their absence made every
// `cd repo && grep …` fail the read-only check → isHeavy → forced BATCH
// → the 1200s wall clock the field report hit on every repo command.
private val READ_ONLY_TOKENS = setOf(
"ls", "cat", "head", "tail", "pwd", "echo", "printf", "df", "du", "stat", "file",
"wc", "true", "false", "whoami", "id", "uname", "date", "env", "printenv",
Expand All @@ -327,6 +348,7 @@ internal object GuestWorkloadPolicy {
"nl", "od", "hexdump", "fold", "grep", "egrep", "fgrep", "tree",
"free", "lsblk", "findmnt", "vmstat", "iostat", "uptime", "ps", "nproc",
"arch", "lscpu", "lsof", "groups", "hostname", "tty",
"cd", "pushd", "popd",
)

fun clampHostTimeout(requestedMs: Long): Long =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -544,8 +544,9 @@ class PersistentShell(

suspend fun executeCommand(
command: String,
// Ignored. Armed timeout comes from [callerBudget] (or, when absent,
// from BudgetClassifier.classify(command)).
// [T-timeout-honored] May SHORTEN the armed wall clock, never
// extend it: armed = min(timeout, class wall). The class ceiling
// stays the authority; the caller gets to bound its own wait.
timeout: Long = 600_000L,
lineCallback: ((String) -> Unit)? = null,
// [T-resource-class-threaded] The budget the CALLER already computed,
Expand Down Expand Up @@ -580,9 +581,13 @@ class PersistentShell(
}

val budget = callerBudget ?: BudgetClassifier.classify(command)
val armed = budget.wallMs
if (timeout != armed) {
Log.w(TAG, "caller timeout ${timeout}ms ignored; armed ${armed}ms class=${budget.workClass}")
// [T-timeout-honored] The caller may shorten the class wall but never
// extend it, and the setup floor survives. The guest watchdog below
// gets the SAME effective wall so the in-guest `sleep` and the host
// withTimeoutOrNull agree on when to fire.
val armed = BudgetClassifier.armMs(budget, command, timeout)
if (timeout > 0L && armed < budget.wallMs) {
Log.i(TAG, "caller timeout ${timeout}ms shortens class ${budget.workClass} wall ${budget.wallMs}ms → ${armed}ms")
}
val marker = UUID.randomUUID().toString().take(8)
// [T-android-ghost-cwd] Heal the shell's cwd before handing control back
Expand Down Expand Up @@ -610,7 +615,7 @@ class PersistentShell(
append("echo \"__MINIS_GO_${marker}__\"\n")
// Supervisor re-arm. Not a subshell around the command, not a trap,
// not a second ulimit. On expiry `kill -TERM -$$` kills the group.
append(GuardianScript.persistentCommand(command, budget.wallSeconds))
append(GuardianScript.persistentCommand(command, (armed / 1000L).toInt().coerceAtLeast(1)))
append("__minis_rc=\$?\n")
append("kill -KILL \${__minis_cmd_wd:-} 2>/dev/null\n")
append("if [ ! -d \"\$PWD\" ]; then cd /var/minis/workspace 2>/dev/null || cd / ; fi\n")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,11 @@ object ShellExecutor {
*
* @param context Android context for resolving PROOT_TMP_DIR
* @param command Shell command string to execute
* @param timeout Timeout in milliseconds (default 10 minutes). IGNORED —
* the armed wall clock comes from [BudgetClassifier].
* @param timeout Timeout in milliseconds (default 10 minutes). May
* SHORTEN the armed wall clock, never extend it: armed =
* min(timeout, class wall). [T-timeout-honored] The old code ignored
* this parameter outright and logged "caller timeout ignored" — a
* 60s request then rode the full 1200s BATCH wall.
* @param environment Additional environment variables
* @param lineCallback Called for each line of output (on IO dispatcher)
* @param resourceClass Caller's `resource_class`. [T-resource-class-threaded]
Expand All @@ -62,9 +65,11 @@ object ShellExecutor {
check(PRootKernel.isBooted) { "PRootKernel must be booted before executing commands" }

val budget = BudgetClassifier.classify(command, resourceClass)
val armed = budget.wallMs
if (timeout != armed) {
Log.w(TAG, "caller timeout ${timeout}ms ignored; armed ${armed}ms class=${budget.workClass}")
// [T-timeout-honored] The caller may shorten the class wall but never
// extend it, and the setup floor survives.
val armed = BudgetClassifier.armMs(budget, command, timeout)
if (timeout > 0L && armed < budget.wallMs) {
Log.i(TAG, "caller timeout ${timeout}ms shortens class ${budget.workClass} wall ${budget.wallMs}ms → ${armed}ms")
}
val first = runOnce(
context, command, armed, environment, lineCallback, noSeccomp = false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,10 +65,15 @@ object ShellTimeoutPolicy {
}

/**
* Class wall clock. [requestedMs] is ignored. The live callers are
* [com.openminis.app.sandbox.ExecutionCoordinator.execute] and
* [com.openminis.app.sandbox.ShellExecutor.execute], which call
* [com.openminis.app.sandbox.kernel.BudgetClassifier.classify] themselves.
* Armed wall clock. [requestedMs] may SHORTEN the class wall, never
* extend it ([T-timeout-honored]): the class ceiling stays the authority
* — the old `maxOf(requested, minimumMs)` path is how a 10-minute
* default outlived the table — while a caller that asks for 60s gets
* 60s instead of riding the full 20-minute BATCH wall. The live callers
* ([com.openminis.app.sandbox.ExecutionCoordinator.execute],
* [com.openminis.app.sandbox.ShellExecutor.execute],
* [com.openminis.app.sandbox.PersistentShell.executeCommand]) apply the
* same min() themselves.
*/
fun effectiveMs(command: String, requestedMs: Long): Long =
com.openminis.app.sandbox.kernel.BudgetClassifier.armMs(command, requestedMs)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,13 @@ object BudgetClassifier {
// command. Otherwise `sleep 100 &` becomes NORMAL and inherits a
// CPU limit a daemon must not have.
val background = isBackground(text)
val segments = text.split(Regex("""&&|\|\||[;&|]|\$\(|`"""))
// [T-quote-aware-split] Blank quoted spans BEFORE splitting: a `|`
// inside quotes (`grep -n "a\|b" file`) used to cut a phantom
// segment whose garbage token classified as NORMAL, inflating the
// wall clock for a quick utility. Only each segment's leading token
// is inspected, so losing the quoted text costs nothing.
val stripped = text.replace(Regex(""""[^"]*"|'[^']*'"""), " ")
val segments = stripped.split(Regex("""&&|\|\||[;&|]|\$\(|`"""))
.map { it.trim() }
.filter { it.isNotEmpty() }
val detected = if (segments.isEmpty()) interactive()
Expand All @@ -54,10 +60,34 @@ object BudgetClassifier {
return Regex("""(?<![&])&(?![&])""").containsMatchIn(stripped)
}

/** Armed wall clock. [requestedMs] is ignored on purpose. */
fun armMs(command: String, requestedMs: Long = 0L): Long {
if (requestedMs < 0L) return classify(command).wallMs
return classify(command).wallMs
/**
* Armed wall clock. [requestedMs] may SHORTEN the class wall but never
* extend it: the class ceiling stays the authority (the old
* `maxOf(requested, minimumMs)` path is how a 10-minute default
* outlived the table), while a caller that knows it wants 60s gets 60s
* instead of riding the full 20-minute BATCH wall — the field report
* was a `grep` hanging 1200s with a 60s request logged as "ignored".
* [T-timeout-honored]
*/
fun armMs(command: String, requestedMs: Long = 0L): Long =
armMs(classify(command), command, requestedMs)

/**
* [T-timeout-honored] The effective wall for an ALREADY-classified
* budget: the class ceiling capped by the caller's request, but never
* below the setup floor — a `minis-dev-setup-full` killed by a short
* request leaves a broken rootfs, which is exactly what
* `setupFloorSurvivesAShortRequest` pins. The live execution paths
* ([com.openminis.app.sandbox.ExecutionCoordinator],
* [com.openminis.app.sandbox.PersistentShell],
* [com.openminis.app.sandbox.ShellExecutor]) call this with the budget
* they classified (including `resource_class`), so HEAVY escalation
* and the floor compose in one place.
*/
fun armMs(budget: ProcessBudget, command: String, requestedMs: Long): Long {
val floor = com.openminis.app.sandbox.ShellTimeoutPolicy.minimumMs(command)
if (requestedMs <= 0L) return maxOf(floor, budget.wallMs)
return maxOf(floor, minOf(requestedMs, budget.wallMs))
}

private fun classifySegment(segment: String): ProcessBudget {
Expand Down Expand Up @@ -162,5 +192,8 @@ object BudgetClassifier {
"ls", "cat", "head", "tail", "wc", "grep", "rg", "egrep", "fgrep",
"echo", "printf", "pwd", "which", "whoami", "id", "true", "false",
"basename", "dirname", "stat", "df",
// [T-cd-readonly] Pure navigation builtins: `cd repo && grep …` must
// classify exactly like `grep …` alone — INTERACTIVE, fail fast.
"cd", "pushd", "popd",
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,7 @@ background=true detaches the wave: this call returns a dispatch_id at once inste
parameters = mapOf(
"tool_title" to AgentToolParam("string", "A concise 5-10 word summary of what this tool call does, shown to the user (e.g. 'Install Python data analysis packages', 'List files in home directory'). Use the same language as the user."),
"command" to AgentToolParam("string", "The shell command to execute. Supports multi-line commands directly — no special escaping needed. Keep under 1000 chars; for longer scripts, write to a file with file_write first, then run it."),
"timeout" to AgentToolParam("integer", "Timeout in seconds (default: 900). Use a larger value for long-running commands like package installs."),
"timeout" to AgentToolParam("integer", "Timeout in seconds (default: 900). Caps the wall clock: a slow command is killed at this deadline or the command-class ceiling, whichever comes FIRST. A larger value cannot extend past the class ceiling (installs/builds get up to 10/20 min regardless); a smaller value bounds slow commands early instead of letting them hang."),
"resource_class" to AgentToolParam("string", "Use heavy for memory-intensive work, including scripts that run decompilers, compilers, JVMs or large data processing. Heavy tasks share one process-wide memory budget. Omit or use auto for other commands."),
"delay" to AgentToolParam("integer", "Delay in seconds before execution begins. The tool blocks the agent flow during this wait WITHOUT occupying the shell, so other concurrent tasks can use it. Use this instead of sleep commands to avoid resource contention."),
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,9 +67,15 @@ class GuestWorkloadPolicyTest {
}

@Test
fun findIsNormalAndACallerTimeoutDoesNotChangeIt() {
assertEquals(600_000L, ShellTimeoutPolicy.effectiveMs("find /var/minis/workspace -name '*.kt'", 60_000L))
assertEquals(600_000L, ShellTimeoutPolicy.effectiveMs("find /data -name '*.db'", 30_000L))
fun findIsNormalAndACallerMayShortenIt() {
// Class ceiling: a broad find walk is NORMAL (600s), not INTERACTIVE
// — slowness is handled by the wall clock, not by refusing reads.
assertEquals(600_000L, ShellTimeoutPolicy.effectiveMs("find /var/minis/workspace -name '*.kt'", 0L))
// [T-timeout-honored] a caller may now SHORTEN that wall…
assertEquals(60_000L, ShellTimeoutPolicy.effectiveMs("find /var/minis/workspace -name '*.kt'", 60_000L))
assertEquals(30_000L, ShellTimeoutPolicy.effectiveMs("find /data -name '*.db'", 30_000L))
// …but never extend past it.
assertEquals(600_000L, ShellTimeoutPolicy.effectiveMs("find /data -name '*.db'", 10_000_000L))
// A broad walk is slow, not irreversible. It is no longer refused
// outright — refusing a read-only command in every mode made it
// unavailable. The wall clock, output rate and resident window brake
Expand Down Expand Up @@ -126,6 +132,38 @@ class GuestWorkloadPolicyTest {
assertTrue(GuestWorkloadPolicy.isObviouslyReadOnly("echo \"2>/dev/null\""))
}

/**
* [T-cd-readonly] `cd` is pure navigation — it writes nothing. Its
* absence from READ_ONLY_TOKENS made every `cd repo && grep …` classify
* as heavy work and arm the 1200s BATCH wall (the field report).
* [T-quote-aware-split] A `|` inside quotes must not create a phantom
* segment either: `grep -n "a\|b"` stayed read-only in truth but failed
* the whitelist on its garbage split half.
*/
@Test
fun cdPrefixedAndQuotedPipePipelinesAreReadOnly() {
assertTrue(GuestWorkloadPolicy.isObviouslyReadOnly("cd /var/minis/workspace/repo && grep -rn foo src/ | head"))
assertTrue(GuestWorkloadPolicy.isObviouslyReadOnly("cd /tmp && ls -la"))
assertTrue(GuestWorkloadPolicy.isObviouslyReadOnly("grep -n \"a\\|b\" file"))
assertTrue(GuestWorkloadPolicy.isObviouslyReadOnly("echo \"a ; b\""))
// Real writes inside quotes stay writes (hasWriteSyntax already
// stripped quotes); real pipes still segment.
assertFalse(GuestWorkloadPolicy.isObviouslyReadOnly("cd /tmp && rm -rf x"))
assertFalse(GuestWorkloadPolicy.isObviouslyReadOnly("echo \"x\" > /tmp/out"))
// The split helper itself: quoted spans blanked, no phantom halves.
// Whitespace is collapsed for the assertion — the replacement leaves
// the surrounding spaces, which is irrelevant to token extraction.
assertEquals(
listOf("grep -n file"),
GuestWorkloadPolicy.splitCommandSegments("grep -n \"a\\|b\" file")
.map { it.replace(Regex("\\s+"), " ") },
)
assertEquals(
listOf("cd /x", "ls"),
GuestWorkloadPolicy.splitCommandSegments("cd /x && ls"),
)
}

/**
* [T-disk-percent-arm] The refusal message says "先清理空间再重试", but
* the gate used to refuse `rm` and `apt-get clean` under the same
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ class HeavyTaskAdmissionTest {
assertFalse(SandboxResourceGate.isHeavy("cat report.txt"))
assertFalse(SandboxResourceGate.isHeavy("./gradlew --stop"))
assertFalse(SandboxResourceGate.isHeavy("pkill java"))
// [T-cd-readonly] the field-report shape: a cd-prefixed read pipeline
// must not consume the heavy budget — it used to, arming the 1200s
// BATCH wall for a plain grep.
assertFalse(SandboxResourceGate.isHeavy("cd /var/minis/workspace/repo && grep -rn foo src/ | head -5"))
assertFalse(SandboxResourceGate.isHeavy("grep -n \"a\\|b\" file"))
}

@Test fun differentHeavyToolsShareOneBudgetAndCancellationReleasesIt() = runTest {
Expand Down
Loading
Loading