diff --git a/docs/play/controls-and-commands.md b/docs/play/controls-and-commands.md
index 7471fb0..6455fba 100644
--- a/docs/play/controls-and-commands.md
+++ b/docs/play/controls-and-commands.md
@@ -46,7 +46,9 @@ Command lists join commands on one line, as in `sh`:
Output redirection with `>` empties its file before the command runs, so `sort notes.txt > notes.txt` leaves an empty file, just as on Linux. If the command fails, the file is put back. Wildcards skip hidden names unless the pattern starts with a dot: `*` ignores `.env`, while `.*` matches it.
-Background jobs (`&`), subshells, command substitution, special parameters such as `$?`, and file-descriptor redirection such as `2>` are rejected with an explanation before anything runs. Quote or escape these characters to use them as data.
+`$?` holds the exit status of the last command, as on Linux: `ls /missing; echo $?` prints `2`. The lab reports `0` for success, `1` for most failures (including `grep` without a match and `false`), `2` for shell syntax errors and `ls`, `grep`, or `sort` trouble, `126` for a script that exists but cannot run, and `127` for a command the lab does not provide. A blank line keeps the previous status, and each script starts at `0`.
+
+Background jobs (`&`), subshells, command substitution, other special parameters such as `$1` or `$$`, and file-descriptor redirection such as `2>` are rejected with an explanation before anything runs. Quote or escape these characters to use them as data.
## Mission navigation
diff --git a/docs/technical/diagrams/command-execution-pipeline.mmd b/docs/technical/diagrams/command-execution-pipeline.mmd
index 0380994..3a9edb7 100644
--- a/docs/technical/diagrams/command-execution-pipeline.mmd
+++ b/docs/technical/diagrams/command-execution-pipeline.mmd
@@ -3,7 +3,9 @@ flowchart LR
INPUT["Player command line"] --> LINE{"At most 64 KiB?"}
LINE -- No --> E1["Reject history unchanged"]
LINE -- Yes --> HISTORY["Record in bounded attempt history"]
- HISTORY --> LEX["Lex quotes · escapes · variables comments · pipes · redirection"]
+ HISTORY --> SPLIT{"Split ; && || lists every pipeline's syntax valid?"}
+ SPLIT -- No --> E4["Reject the whole line nothing runs · $? = 2"]
+ SPLIT -- Yes --> LEX["Lex one pipeline quotes · escapes · variables · $? comments · pipes · redirection"]
LEX --> PARSE["Parse pipeline stages and redirects"]
PARSE --> EXPAND["Expand virtual globs and arguments"]
EXPAND --> PREFLIGHT{"Composition and resource checks pass?"}
@@ -14,12 +16,15 @@ flowchart LR
STATE --> OUTPUT{"Output within limit?"}
OUTPUT -- No --> E3["Reject bounded output"]
OUTPUT -- Yes --> REDIRECT{"Virtual output redirection?"}
- REDIRECT -- Yes --> WRITE["Preflight and write virtual destination"]
+ REDIRECT -- Yes --> WRITE["Write virtual destination > empties it before the stage restored if the stage fails"]
REDIRECT -- No --> NEXT{"Another pipeline stage?"}
WRITE --> NEXT
NEXT -- Yes --> READ
- NEXT -- No --> RESULT["Execution result output · practiced commands pipeline width · editor request"]
- RESULT --> VALIDATE["Outcome evaluation text comparison + Environment.Observe"]
+ NEXT -- No --> RESULT["Pipeline result output · exit status for $? practiced commands"]
+ RESULT --> LIST{"Another pipeline the operators allow?"}
+ LIST -- "Yes: ; always · && after success · || after failure" --> LEX
+ LIST -- No --> FINAL["Execution result ordered output and errors pipeline width · editor request"]
+ FINAL --> VALIDATE["Outcome evaluation text comparison + Environment.Observe"]
VFS[("In-memory filesystem")]
ENV[("Virtual environment")]
@@ -41,8 +46,8 @@ flowchart LR
classDef invariant fill:#ede9fe,color:#4c1d95,stroke:#7c3aed,stroke-width:2px
class INPUT input
- class HISTORY,LEX,PARSE,EXPAND,READ,DISPATCH,RESULT,VALIDATE stage
+ class HISTORY,LEX,PARSE,EXPAND,READ,DISPATCH,RESULT,FINAL,VALIDATE stage
class STATE,WRITE,VFS,ENV,PROC,ARC state
- class LINE,PREFLIGHT,OUTPUT,REDIRECT,NEXT decision
- class E1,E2,E3 error
+ class LINE,SPLIT,PREFLIGHT,OUTPUT,REDIRECT,NEXT,LIST decision
+ class E1,E2,E3,E4 error
class HOST invariant
diff --git a/docs/technical/diagrams/command-execution-pipeline.svg b/docs/technical/diagrams/command-execution-pipeline.svg
index 0e78e56..20d166e 100644
--- a/docs/technical/diagrams/command-execution-pipeline.svg
+++ b/docs/technical/diagrams/command-execution-pipeline.svg
@@ -1,7 +1,7 @@
Teaching-shell command execution pipeline
- Flow from bounded player input through lexing, parsing, virtual expansion, preflight, Go command dispatch, virtual state, redirection, execution metadata, and mission outcome evaluation.
+ Flow from bounded player input through command-list splitting, per-pipeline lexing, parsing, virtual expansion, preflight, Go command dispatch, virtual state, redirection, exit status, a loop for the next pipeline in a list, execution metadata, and mission outcome evaluation.
@@ -17,8 +17,8 @@
FAILED GATE → bounded error · no external fallback
-
-
+
+
@@ -30,14 +30,17 @@
no
+
+ next pipeline in a list: ; always · && after success · || after failure
observe
- Player line untrusted text
- ≤ 64 KiB? before history
- Bounded history last 100 lines
- Lex quotes · variables pipes · redirection
- Parse stages + redirect shape checks
- Expand virtual globs token + argument budgets
+ Player line untrusted text
+ ≤ 64 KiB? before history
+ Bounded history last 100 lines
+ Split lists ; && || · valid?
+ Lex one pipeline quotes · variables · $? pipes · redirection
+ Parse stages + redirect shape checks
+ Expand virtual globs token + argument budgets
Preflight composition + limits
Read input prior output or VFS
@@ -46,9 +49,9 @@
Output within limit?
Redirect? virtual only
- Virtual write replace or append
+ Virtual write > empties before the stage restored if it fails
Next stage? bounded loop
- Execution result output + metadata
+ Pipeline result output + metadata exit status for $?
Validate text + observations
@@ -58,7 +61,7 @@
Processes virtual PID state
Archives logical payload
-
+
Invariant: no host shell, host path lookup, or external fallback
Unsupported names stop at the Go dispatcher.
diff --git a/docs/technical/sandbox-and-safety.md b/docs/technical/sandbox-and-safety.md
index 6260113..323a041 100644
--- a/docs/technical/sandbox-and-safety.md
+++ b/docs/technical/sandbox-and-safety.md
@@ -59,7 +59,7 @@ Editable source: [`command-execution-pipeline.mmd`](diagrams/command-execution-p
1. **Bound input:** reject a command line over 64 KiB before adding it to the attempt's 100-entry history.
2. **Split lists:** separate unquoted `;`, `&&`, and `||` into pipelines and check every pipeline's syntax before the first runs. Each pipeline then passes through the remaining stages only when its turn comes, so expansion sees earlier `cd` and `export` effects.
-3. **Lex:** reject unquoted shell syntax outside the teaching subset (background jobs, subshells, substitutions, special parameters, and file-descriptor redirection), then recognize words, quotes, escapes, comments, variables, pipes, and `<`, `>`, or `>>`. Interactive and script lines share these rules. Expansion reads only the sandbox environment.
+3. **Lex:** reject unquoted shell syntax outside the teaching subset (background jobs, subshells, substitutions, special parameters, and file-descriptor redirection), then recognize words, quotes, escapes, comments, variables, `$?`, pipes, and `<`, `>`, or `>>`. Interactive and script lines share these rules. Expansion reads only the sandbox environment and the attempt's last exit status.
4. **Parse:** build pipeline stages and attach at most one input and output redirection to each stage.
5. **Expand:** resolve eligible globs against the virtual filesystem and enforce expanded token and argument budgets.
6. **Preflight compositions:** reject unsupported interactive-editor or script placement before an earlier pipeline stage can mutate state.
@@ -80,7 +80,7 @@ One `sandbox.Sandbox` owns:
| --- | --- | --- |
| Files and directories | Normalized absolute paths to typed entries with content, mode, and owner; the virtual `HOME` directory always exists | Attempt only |
| Working directory | Virtual absolute path | Attempt only; child scripts restore caller scope |
-| Environment | String map initialized with virtual `HOME` and `USER` | Attempt only; exported child-script values restore on return |
+| Environment | String map initialized with virtual `HOME` and `USER`, plus the last exit status read by `$?` | Attempt only; exported child-script values restore on return, and a script's status becomes its caller's `$?` |
| Processes | Mission-provided PID map and running flags | Attempt only; no host PID is visible or signalable |
| Archives | Logical archive metadata and payload entries | Attempt only; kept synchronized with virtual path mutations |
| History | Last 100 accepted command lines | Attempt only |
diff --git a/internal/sandbox/command_list.go b/internal/sandbox/command_list.go
index 2add081..2ae3361 100644
--- a/internal/sandbox/command_list.go
+++ b/internal/sandbox/command_list.go
@@ -107,15 +107,17 @@ func splitCommandList(line string) ([]commandListItem, error) {
// returned error is the last pipeline's failure, if any.
func (s *Sandbox) executeLine(line string, context *executionContext, allowInteractive bool) (Result, error) {
items, err := splitCommandList(line)
+ if err == nil && len(items) > 1 {
+ err = s.preflightCommandList(items, allowInteractive)
+ }
if err != nil {
- return Result{}, err
+ // Like sh, a syntax error runs nothing and sets $? to 2.
+ s.lastStatus = statusTrouble
+ return Result{}, withExitStatus(statusTrouble, err)
}
if len(items) == 1 {
return s.executePipeline(items[0].line, context, allowInteractive)
}
- if err := s.preflightCommandList(items, allowInteractive); err != nil {
- return Result{}, err
- }
result := Result{list: true}
var output strings.Builder
@@ -179,7 +181,7 @@ func (s *Sandbox) preflightCommandList(items []commandListItem, allowInteractive
if err := validateShellSyntax(item.line); err != nil {
return err
}
- tokens, err := lex(item.line, s.Env)
+ tokens, err := lex(item.line, s.Env, s.lastStatus)
if err != nil {
return err
}
diff --git a/internal/sandbox/commands_script.go b/internal/sandbox/commands_script.go
index b1bd7d2..9212101 100644
--- a/internal/sandbox/commands_script.go
+++ b/internal/sandbox/commands_script.go
@@ -49,22 +49,27 @@ func (s *Sandbox) cmdExecutableScript(context *executionContext, args []string,
resolved := s.Resolve(args[0])
entry, exists := s.FS.Entry(resolved)
if !exists {
- return "", fmt.Errorf("%s: no such file or directory", args[0])
+ return "", withExitStatus(statusCommandNotFound, fmt.Errorf("%s: no such file or directory", args[0]))
}
if entry.Kind != Regular {
- return "", fmt.Errorf("%s: is a directory", args[0])
+ return "", withExitStatus(statusCannotExecute, fmt.Errorf("%s: is a directory", args[0]))
}
if entry.Mode&0o111 == 0 {
- return "", fmt.Errorf("%s: permission denied; set an executable mode with chmod", args[0])
+ return "", withExitStatus(statusCannotExecute, fmt.Errorf("%s: permission denied; set an executable mode with chmod", args[0]))
}
return s.executeScript(context, resolved, true)
}
func (s *Sandbox) executeScript(context *executionContext, scriptPath string, requireShebang bool) (string, error) {
- content, err := s.FS.ReadFile(scriptPath)
- if err != nil {
- return "", err
+ // As in sh, a missing script exits 127 and one that cannot run exits 126.
+ entry, exists := s.FS.Entry(scriptPath)
+ if !exists {
+ return "", withExitStatus(statusCommandNotFound, fmt.Errorf("%s: no such file", scriptPath))
+ }
+ if entry.Kind != Regular {
+ return "", withExitStatus(statusCannotExecute, fmt.Errorf("%s: is a directory", scriptPath))
}
+ content := entry.Content
if len(content) > maxScriptBytes {
return "", fmt.Errorf("%s: script exceeds the %d KiB limit", scriptPath, maxScriptBytes/1024)
}
@@ -75,7 +80,7 @@ func (s *Sandbox) executeScript(context *executionContext, scriptPath string, re
if requireShebang {
firstLine := strings.TrimSuffix(lines[0], "\r")
if firstLine != "#!/bin/sh" && firstLine != "#!/usr/bin/env sh" {
- return "", fmt.Errorf("%s: executable scripts require #!/bin/sh or #!/usr/bin/env sh", scriptPath)
+ return "", withExitStatus(statusCannotExecute, fmt.Errorf("%s: executable scripts require #!/bin/sh or #!/usr/bin/env sh", scriptPath))
}
}
if len(context.scriptStack) >= maxScriptDepth {
@@ -94,6 +99,8 @@ func (s *Sandbox) executeScript(context *executionContext, scriptPath string, re
s.Env = savedEnv
}()
+ // A script runs in a new shell, where $? starts at 0.
+ s.lastStatus = 0
var output strings.Builder
statusFailed := false
for index, rawLine := range lines {
diff --git a/internal/sandbox/commands_script_test.go b/internal/sandbox/commands_script_test.go
index af82f76..a6e79af 100644
--- a/internal/sandbox/commands_script_test.go
+++ b/internal/sandbox/commands_script_test.go
@@ -328,7 +328,7 @@ func TestShRejectsUnsupportedShellLanguage(t *testing.T) {
{name: "backticks", line: "echo `pwd`", want: "command substitution"},
{name: "substitution", line: "echo $(pwd)", want: "command substitution"},
{name: "quoted substitution", line: `echo "$(pwd)"`, want: "command substitution"},
- {name: "special parameter", line: "echo $?", want: "special parameters"},
+ {name: "special parameter", line: "echo $$", want: "special parameters"},
{name: "positional parameter", line: "echo ${1}", want: "special parameters"},
{name: "if keyword", line: "if echo yes", want: "keyword"},
{name: "for keyword", line: "for item", want: "keyword"},
diff --git a/internal/sandbox/dispatch.go b/internal/sandbox/dispatch.go
index b0bcfd5..4ec4100 100644
--- a/internal/sandbox/dispatch.go
+++ b/internal/sandbox/dispatch.go
@@ -40,7 +40,8 @@ func (s *Sandbox) run(context *executionContext, args []string, stdin string) (o
case "cd":
return s.cmdCD(args[1:])
case "ls":
- return s.cmdLS(args[1:])
+ output, err := s.cmdLS(args[1:])
+ return output, withExitStatus(statusTrouble, err)
case "mkdir":
return s.cmdMkdir(args[1:])
case "touch":
@@ -60,7 +61,8 @@ func (s *Sandbox) run(context *executionContext, args []string, stdin string) (o
case "history":
return s.cmdHistory(args[1:])
case "grep":
- return s.cmdGrep(args[1:], stdin)
+ output, err := s.cmdGrep(args[1:], stdin)
+ return output, withExitStatus(statusTrouble, err)
case "find":
return s.cmdFind(context, args[1:])
case "sh":
@@ -86,7 +88,8 @@ func (s *Sandbox) run(context *executionContext, args []string, stdin string) (o
case "env":
return s.cmdEnv(args[1:])
case "sort":
- return s.cmdSort(args[1:], stdin)
+ output, err := s.cmdSort(args[1:], stdin)
+ return output, withExitStatus(statusTrouble, err)
case "uniq":
return s.cmdUniq(args[1:], stdin)
case "wc":
@@ -114,6 +117,6 @@ func (s *Sandbox) run(context *executionContext, args []string, stdin string) (o
case "help", "man":
return shellHelp(args[1:])
default:
- return "", fmt.Errorf("command not available in this lab; type help to see supported commands")
+ return "", withExitStatus(statusCommandNotFound, fmt.Errorf("command not available in this lab; type help to see supported commands"))
}
}
diff --git a/internal/sandbox/exit_status.go b/internal/sandbox/exit_status.go
new file mode 100644
index 0000000..ab4930a
--- /dev/null
+++ b/internal/sandbox/exit_status.go
@@ -0,0 +1,47 @@
+package sandbox
+
+import "errors"
+
+// Exit statuses the lab models beyond 0 and 1. Any other failure exits with
+// 1, as most GNU tools do, and $? reports these values like a real shell.
+const (
+ // statusTrouble is sh's status for a syntax error and the documented
+ // status of ls, grep, and sort for errors such as a missing file.
+ statusTrouble = 2
+ // statusCannotExecute is for a script that exists but cannot run.
+ statusCannotExecute = 126
+ // statusCommandNotFound is for a name the lab does not provide.
+ statusCommandNotFound = 127
+)
+
+// exitStatusError gives a failure its shell exit status.
+type exitStatusError struct {
+ status int
+ err error
+}
+
+func (e exitStatusError) Error() string { return e.err.Error() }
+
+func (e exitStatusError) Unwrap() error { return e.err }
+
+// withExitStatus marks err with status. A silent failing status, such as grep
+// finding no match, keeps its status of 1.
+func withExitStatus(status int, err error) error {
+ if err == nil || errors.Is(err, errFailureStatus) {
+ return err
+ }
+ return exitStatusError{status: status, err: err}
+}
+
+// exitStatus is the shell status of a pipeline's outcome: 0 on success, the
+// outermost marked status of a failure, or 1.
+func exitStatus(err error, statusFailed bool) int {
+ var marked exitStatusError
+ switch {
+ case errors.As(err, &marked):
+ return marked.status
+ case err != nil || statusFailed:
+ return 1
+ }
+ return 0
+}
diff --git a/internal/sandbox/exit_status_test.go b/internal/sandbox/exit_status_test.go
new file mode 100644
index 0000000..052356b
--- /dev/null
+++ b/internal/sandbox/exit_status_test.go
@@ -0,0 +1,95 @@
+package sandbox
+
+import "testing"
+
+func TestExitStatusMatchesTheShell(t *testing.T) {
+ tests := []struct {
+ name string
+ line string
+ want string
+ }{
+ {name: "success", line: "true; echo $?", want: "0\n"},
+ {name: "false", line: "false; echo $?", want: "1\n"},
+ {name: "ordinary failure", line: "cat missing.log; echo $?", want: "1\n"},
+ {name: "ls trouble", line: "ls /missing; echo $?", want: "2\n"},
+ {name: "grep match", line: "grep -q ERROR events.log; echo $?", want: "0\n"},
+ {name: "grep no match", line: "grep PANIC events.log; echo $?", want: "1\n"},
+ {name: "grep trouble", line: "grep ERROR missing.log; echo $?", want: "2\n"},
+ {name: "sort trouble", line: "sort missing.log; echo $?", want: "2\n"},
+ {name: "unknown command", line: "deploy-everything; echo $?", want: "127\n"},
+ {name: "missing script", line: "sh missing.sh; echo $?", want: "127\n"},
+ {name: "missing executable", line: "./missing.sh; echo $?", want: "127\n"},
+ {name: "directory as script", line: "sh /work; echo $?", want: "126\n"},
+ {name: "pipeline is its last stage", line: "grep PANIC events.log | wc -l; echo $?", want: "0\n0\n"},
+ {name: "skipped command keeps the status", line: "false && echo never; echo $?", want: "1\n"},
+ {name: "status updates after each command", line: "false; true; echo $?", want: "0\n"},
+ {name: "braces", line: "false; echo ${?}", want: "1\n"},
+ {name: "double quotes", line: `false; echo "status=$?"`, want: "status=1\n"},
+ {name: "single quotes are literal", line: `false; echo '$?'`, want: "$?\n"},
+ {name: "escaped is literal", line: `false; echo \$?`, want: "$?\n"},
+ {name: "starts at zero", line: "echo $?", want: "0\n"},
+ }
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ box := testSandbox(t)
+ result, err := box.Execute(test.line)
+ if err != nil {
+ t.Fatalf("Execute(%q) error = %v", test.line, err)
+ }
+ if result.Output != test.want {
+ t.Errorf("Execute(%q) output = %q, want %q", test.line, result.Output, test.want)
+ }
+ })
+ }
+}
+
+func TestExitStatusPersistsAcrossLines(t *testing.T) {
+ box := testSandbox(t)
+ writeTestScript(t, box, "plain.sh", "echo hi\n", 0o644)
+ steps := []struct{ line, want string }{
+ {line: "ls /missing"},
+ {line: "echo $?", want: "2\n"},
+ {line: "echo $?", want: "0\n"},
+ {line: "false"},
+ {line: ""},
+ {line: " # a comment"},
+ {line: "echo $?", want: "1\n"},
+ {line: "echo 'unterminated"},
+ {line: "echo $?", want: "2\n"},
+ {line: "echo a &&"},
+ {line: "echo $?", want: "2\n"},
+ {line: "echo $(pwd)"},
+ {line: "echo $?", want: "2\n"},
+ {line: "./plain.sh"},
+ {line: "echo $?", want: "126\n"},
+ }
+ for _, step := range steps {
+ result, err := box.Execute(step.line)
+ if step.want == "" {
+ continue
+ }
+ if err != nil || result.Output != step.want {
+ t.Fatalf("Execute(%q) = %q, %v; want %q", step.line, result.Output, err, step.want)
+ }
+ }
+}
+
+func TestScriptsHaveTheirOwnExitStatus(t *testing.T) {
+ box := testSandbox(t)
+ writeTestScript(t, box, "inner-status.sh", "false\necho \"inside=$?\"\n", 0o644)
+ writeTestScript(t, box, "fresh.sh", "echo \"start=$?\"\n", 0o644)
+ writeTestScript(t, box, "fails.sh", "echo start\nls /missing\n", 0o644)
+ writeTestScript(t, box, "last.sh", "echo checking\ngrep -q PANIC events.log\n", 0o644)
+ steps := []struct{ line, want string }{
+ {line: "sh inner-status.sh", want: "inside=1\n"},
+ {line: "false; sh fresh.sh", want: "start=0\n"},
+ {line: "sh fails.sh; echo $?", want: "2\n"},
+ {line: "sh last.sh; echo $?", want: "checking\n1\n"},
+ }
+ for _, step := range steps {
+ result, err := box.Execute(step.line)
+ if err != nil || result.Output != step.want {
+ t.Fatalf("Execute(%q) = %q, %v; want %q", step.line, result.Output, err, step.want)
+ }
+ }
+}
diff --git a/internal/sandbox/lexer.go b/internal/sandbox/lexer.go
index 26564e6..7477e8b 100644
--- a/internal/sandbox/lexer.go
+++ b/internal/sandbox/lexer.go
@@ -2,6 +2,7 @@ package sandbox
import (
"fmt"
+ "strconv"
"strings"
"unicode"
"unicode/utf8"
@@ -15,7 +16,9 @@ type token struct {
glob bool
}
-func lex(line string, env map[string]string) ([]token, error) {
+// lex splits one pipeline into tokens, expanding variables from env and $?
+// from status.
+func lex(line string, env map[string]string, status int) ([]token, error) {
var tokens []token
var current strings.Builder
var quote rune
@@ -101,7 +104,7 @@ func lex(line string, env map[string]string) ([]token, error) {
}
continue
case char == '$':
- value, consumed := expandVariable(runes[i:], env)
+ value, consumed := expandVariable(runes[i:], env, status)
if err := writeString(value); err != nil {
return nil, err
}
@@ -143,7 +146,7 @@ func lex(line string, env map[string]string) ([]token, error) {
continue
}
if quote == '"' && char == '$' {
- value, consumed := expandVariable(runes[i:], env)
+ value, consumed := expandVariable(runes[i:], env, status)
if err := writeString(value); err != nil {
return nil, err
}
@@ -161,13 +164,19 @@ func lex(line string, env map[string]string) ([]token, error) {
return tokens, nil
}
-func expandVariable(input []rune, env map[string]string) (string, int) {
+func expandVariable(input []rune, env map[string]string, status int) (string, int) {
if len(input) < 2 {
return "$", 0
}
+ if input[1] == '?' {
+ return strconv.Itoa(status), 1
+ }
if input[1] == '{' {
for i := 2; i < len(input); i++ {
if input[i] == '}' {
+ if name := string(input[2:i]); name == "?" {
+ return strconv.Itoa(status), i
+ }
return env[string(input[2:i])], i
}
}
diff --git a/internal/sandbox/manuals.go b/internal/sandbox/manuals.go
index 2c7b013..a08eb31 100644
--- a/internal/sandbox/manuals.go
+++ b/internal/sandbox/manuals.go
@@ -20,6 +20,7 @@ func shellHelp(args []string) (string, error) {
return "Available lab commands:\n " + strings.Join(commands, " ") +
"\n\nShell features: pipelines (|), input (<), output (>), and append (>>) redirection." +
"\nCommand lists: a; b runs both, a && b runs b only if a succeeds, and a || b only if a fails." +
+ "\necho $? prints the last exit status: 0 success, 1 failure, 2 syntax or ls/grep/sort trouble, 126 cannot run, 127 not found." +
"\nNot supported: background jobs (&), 2> and other descriptor redirection, and $(...) substitution." +
fmt.Sprintf("\nSandbox limits: %d KiB per command line; %d KiB expanded tokens; %d expanded arguments; %d pipeline stages; %d command dispatches; %d MiB per file and command output; %d MiB filesystem content and %d MiB archive payload; %d filesystem entries and %d archive entries.",
maxCommandLineBytes/1024, maxExpandedTokenBytes/1024, maxExpandedArguments, maxPipelineStages, maxExecutionDispatchSteps,
diff --git a/internal/sandbox/sandbox.go b/internal/sandbox/sandbox.go
index 4e91fa2..a925ae1 100644
--- a/internal/sandbox/sandbox.go
+++ b/internal/sandbox/sandbox.go
@@ -34,6 +34,9 @@ type Sandbox struct {
Processes map[int]*Process
Archives map[string]Archive
History []string
+
+ // lastStatus is the exit status of the last pipeline that ran, read by $?.
+ lastStatus int
}
func New(setup mission.Setup, startDir string) (*Sandbox, error) {
diff --git a/internal/sandbox/shell.go b/internal/sandbox/shell.go
index 5421dd6..8eb0798 100644
--- a/internal/sandbox/shell.go
+++ b/internal/sandbox/shell.go
@@ -104,25 +104,33 @@ func (s *Sandbox) Execute(line string) (Result, error) {
// executePipeline runs one pipeline: validate, lex, parse, expand, open
// output redirections, and run each stage. statusFailed in the result reports
-// the last stage's silent failing status.
-func (s *Sandbox) executePipeline(line string, context *executionContext, allowInteractive bool) (Result, error) {
+// the last stage's silent failing status, and the pipeline's exit status
+// becomes the next $?. A blank or comment-only line leaves $? unchanged.
+func (s *Sandbox) executePipeline(line string, context *executionContext, allowInteractive bool) (result Result, err error) {
+ ran := true
+ defer func() {
+ if ran {
+ s.lastStatus = exitStatus(err, result.statusFailed)
+ }
+ }()
if err := validateShellSyntax(line); err != nil {
- return Result{}, err
+ return Result{}, withExitStatus(statusTrouble, err)
}
- tokens, err := lex(line, s.Env)
+ tokens, err := lex(line, s.Env, s.lastStatus)
if err != nil {
- return Result{}, err
+ return Result{}, withExitStatus(statusTrouble, err)
}
parsed, err := parseCommandLine(tokens)
if err != nil {
- return Result{}, err
+ return Result{}, withExitStatus(statusTrouble, err)
}
if len(parsed.stages) == 0 {
+ ran = false
return Result{}, nil
}
if !allowInteractive {
if err := validateScriptCommands(parsed); err != nil {
- return Result{}, err
+ return Result{}, withExitStatus(statusTrouble, err)
}
}
expanded, err := s.expandCommandLine(parsed)
diff --git a/internal/sandbox/syntax.go b/internal/sandbox/syntax.go
index 2fcbe81..e526d5d 100644
--- a/internal/sandbox/syntax.go
+++ b/internal/sandbox/syntax.go
@@ -117,10 +117,11 @@ func isUnsupportedShellParameter(input []rune) bool {
if len(input) == 0 {
return false
}
- if unicode.IsDigit(input[0]) || strings.ContainsRune("?$!#*@-", input[0]) {
+ // $? is supported; the lexer expands it to the last exit status.
+ if unicode.IsDigit(input[0]) || strings.ContainsRune("$!#*@-", input[0]) {
return true
}
- return len(input) > 1 && input[0] == '{' && (unicode.IsDigit(input[1]) || strings.ContainsRune("?$!#*@-", input[1]))
+ return len(input) > 1 && input[0] == '{' && (unicode.IsDigit(input[1]) || strings.ContainsRune("$!#*@-", input[1]))
}
func unsupportedParameterError(input []rune) error {
diff --git a/internal/sandbox/syntax_test.go b/internal/sandbox/syntax_test.go
index 8b28d2d..9a185fe 100644
--- a/internal/sandbox/syntax_test.go
+++ b/internal/sandbox/syntax_test.go
@@ -27,7 +27,7 @@ func TestInteractiveLinesRejectUnsupportedShellSyntaxBeforeRunning(t *testing.T)
{name: "numbered input", line: "touch /out/first.txt 0