fix(builtins): unignore the env builtin package, gitignore's env/ hid it

This commit is contained in:
Zecheng Zhang
2026-08-18 10:05:16 -07:00
parent 1ea4428f32
commit 60f0c85ce2
8 changed files with 521 additions and 0 deletions
+2
View File
@@ -144,6 +144,8 @@ venv/
ENV/
env.bak/
venv.bak/
# the env shell builtin package, not a virtualenv
!**/builtins/env/
# Spyder project settings
.spyderproject
@@ -0,0 +1,19 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
from mirage.workspace.executor.builtins.env.env import handle_env
__all__ = [
"handle_env",
]
@@ -0,0 +1,15 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
ENV_HELP_HINT = "Try 'env --help' for more information.\n"
+161
View File
@@ -0,0 +1,161 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import shlex
from collections.abc import Callable
from typing import Any
from mirage.io import IOResult
from mirage.io.types import ByteSource
from mirage.workspace.executor.builtins.env.constants import ENV_HELP_HINT
from mirage.workspace.executor.builtins.shared import Result
from mirage.workspace.executor.builtins.types import BuiltinCall
from mirage.workspace.session import Session
from mirage.workspace.session.session import vars_from_env
from mirage.workspace.session.state import env_snapshot
from mirage.workspace.types import ExecutionNode
def _env_error(message: str) -> tuple[None, IOResult, ExecutionNode]:
err = (message + "\n" + ENV_HELP_HINT).encode()
return None, IOResult(exit_code=125,
stderr=err), ExecutionNode(command="env",
exit_code=125,
stderr=err)
async def handle_env(
execute_fn: Callable[..., Any],
args: list[str],
session: Session,
stdin: ByteSource | None = None,
) -> tuple[ByteSource | None, IOResult, ExecutionNode]:
"""Run the ``env`` builtin (print environment or run a command).
Usage: ``env [-i] [-u NAME]... [NAME=VALUE]... [command [arg]...]``.
With no command it prints the (optionally modified) environment in
``environ`` order, unsorted, terminated per entry by newline or NUL
(``-0``). With a command it runs it under the modified environment,
forwarding stdin, then restores the session environment. Missing
commands fail like GNU with the shell's own exit 127.
Args:
execute_fn (Callable): shell evaluator for the inner command.
args (list[str]): words after the ``env`` name.
session (Session): shell session state.
stdin (ByteSource | None): piped input forwarded to the command.
"""
ignore_env = False
null = False
unset: list[str] = []
i = 0
while i < len(args):
tok = args[i]
if tok == "--":
i += 1
break
if tok in ("-i", "--ignore-environment"):
ignore_env = True
i += 1
continue
if tok in ("-0", "--null"):
null = True
i += 1
continue
if tok == "-":
# GNU: "a mere - implies -i".
ignore_env = True
i += 1
continue
if tok == "--unset":
if i + 1 >= len(args):
return _env_error("env: option '--unset' requires an argument")
unset.append(args[i + 1])
i += 2
continue
if tok.startswith("--unset="):
unset.append(tok[len("--unset="):])
i += 1
continue
if tok.startswith("--"):
return _env_error(f"env: unrecognized option '{tok}'")
if tok.startswith("-") and len(tok) > 1:
j = 1
consumed_next = False
while j < len(tok):
ch = tok[j]
if ch == "i":
ignore_env = True
elif ch == "0":
null = True
elif ch == "u":
rest = tok[j + 1:]
if rest:
unset.append(rest)
elif i + 1 < len(args):
unset.append(args[i + 1])
consumed_next = True
else:
return _env_error(
"env: option requires an argument -- 'u'")
break
else:
return _env_error(f"env: invalid option -- '{ch}'")
j += 1
i += 2 if consumed_next else 1
continue
break
base = {} if ignore_env else env_snapshot(session)
for name in unset:
base.pop(name, None)
while i < len(args) and "=" in args[i] and not args[i].startswith("="):
key, _, value = args[i].partition("=")
base[key] = value
i += 1
command = args[i:]
if command and null:
return _env_error("env: cannot specify --null (-0) with command")
if not command:
sep = "\0" if null else "\n"
out = "".join(f"{k}={v}{sep}" for k, v in base.items()).encode()
return out, IOResult(), ExecutionNode(command="env", exit_code=0)
# `env NAME=v cmd` runs the command with a replaced environment.
# Only the scalars are replaced: arrays were never part of the env
# the old two-container store swapped, and bash does not put one in
# a child's environment either.
saved = session.vars
session.vars = {
name: var
for name, var in saved.items() if not isinstance(var.value, str)
} | vars_from_env(base)
try:
io = await execute_fn(shlex.join(command),
session_id=session.session_id,
stdin=stdin)
finally:
session.vars = saved
return io.stdout, io, ExecutionNode(command="env", exit_code=io.exit_code)
async def env_builtin(call: BuiltinCall) -> Result:
"""The ``env`` arm.
Args:
call (BuiltinCall): the invocation.
"""
return await handle_env(call.execute_fn, list(call.argv.args),
call.session, call.stdin)
+120
View File
@@ -0,0 +1,120 @@
from unittest.mock import AsyncMock
import pytest
from mirage.io import IOResult
from mirage.io.stream import materialize
from mirage.shell.variable import VarAttr
from mirage.workspace.executor.builtins.env import handle_env
from mirage.workspace.session.session import Session
from mirage.workspace.session.state import seed_var, set_attr
def make_session() -> Session:
return Session(session_id="s1")
def seed_exported(session: Session, name: str, value: str) -> None:
"""Seed a variable the process-view printers will actually list.
`env`, `printenv` and `export -p` show exported names only, so a
test whose subject is ordering or quoting has to mark what it seeds
or it renders nothing at all. `seed_var` alone makes a correct
plain shell variable, which those three rightly never print.
Args:
session (Session): the session being seeded.
name (str): variable name.
value (str): the value to store.
"""
seed_var(session, name, value)
set_attr(session, name, VarAttr.EXPORT)
def _unused_execute_fn():
raise AssertionError("execute_fn should not be called")
@pytest.mark.asyncio
async def test_env_prints_environment_in_insertion_order():
session = make_session()
seed_exported(session, "ZZZ", "1")
seed_exported(session, "AAA", "2")
out, io, _ = await handle_env(_unused_execute_fn, [], session)
assert io.exit_code == 0
# `$PWD` is seeded at construction, so it leads the insertion order.
assert await materialize(out) == b"PWD=/\nZZZ=1\nAAA=2\n"
@pytest.mark.asyncio
async def test_env_ignore_environment_and_null_terminator():
session = make_session()
seed_var(session, "KEEP", "x")
out, _, _ = await handle_env(_unused_execute_fn,
["-i", "-0", "A=1", "B=2"], session)
assert await materialize(out) == b"A=1\x00B=2\x00"
@pytest.mark.asyncio
async def test_env_unset_removes_variable():
session = make_session()
seed_exported(session, "DROP", "1")
seed_exported(session, "KEEP", "2")
out, _, _ = await handle_env(_unused_execute_fn, ["-u", "DROP"], session)
rendered = await materialize(out)
assert b"DROP=" not in rendered
assert b"KEEP=2" in rendered
@pytest.mark.asyncio
async def test_env_run_form_forwards_stdin_and_restores_env():
session = make_session()
seed_var(session, "FOO", "original")
execute_fn = AsyncMock(return_value=IOResult(exit_code=0))
await handle_env(execute_fn, ["-i", "FOO=temp", "printenv", "FOO"],
session,
stdin=b"piped\n")
execute_fn.assert_awaited_once()
args, kwargs = execute_fn.call_args
assert args[0] == "printenv FOO"
assert kwargs["stdin"] == b"piped\n"
# The session environment is restored after the inner command runs.
assert session.env == {"PWD": "/", "FOO": "original"}
@pytest.mark.asyncio
async def test_env_lone_dash_implies_ignore_environment():
session = make_session()
seed_var(session, "KEEP", "x")
out, io, _ = await handle_env(_unused_execute_fn, ["-", "A=1"], session)
assert io.exit_code == 0
assert await materialize(out) == b"A=1\n"
@pytest.mark.asyncio
async def test_env_null_with_command_rejected():
_, io, _ = await handle_env(_unused_execute_fn, ["-0", "echo", "hi"],
make_session())
assert io.exit_code == 125
assert await materialize(
io.stderr) == (b"env: cannot specify --null (-0) with command\n"
b"Try 'env --help' for more information.\n")
@pytest.mark.asyncio
async def test_env_invalid_option_exits_125():
_, io, _ = await handle_env(_unused_execute_fn, ["-Z"], make_session())
assert io.exit_code == 125
assert await materialize(io.stderr
) == (b"env: invalid option -- 'Z'\n"
b"Try 'env --help' for more information.\n")
@pytest.mark.asyncio
async def test_env_unrecognized_long_option_exits_125():
_, io, _ = await handle_env(_unused_execute_fn, ["--bogus"],
make_session())
assert io.exit_code == 125
assert await materialize(io.stderr
) == (b"env: unrecognized option '--bogus'\n"
b"Try 'env --help' for more information.\n")
@@ -0,0 +1,15 @@
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
export const ENV_HELP_HINT = "Try 'env --help' for more information.\n"
@@ -0,0 +1,174 @@
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import { IOResult } from '../../../../io/types.ts'
import type { ByteSource } from '../../../../io/types.ts'
import { shellJoin } from '../../../../shell/join.ts'
import { ownRecord, varsFromEnv } from '../../../session/session.ts'
import type { ShellVar } from '../../../../shell/variable.ts'
import type { Session } from '../../../session/session.ts'
import { envSnapshot } from '../../../session/state.ts'
import { ExecutionNode } from '../../../types.ts'
import type { ExecuteStringFn } from '../scope.ts'
import { type Result } from '../shared.ts'
import { ENV_HELP_HINT } from './constants.ts'
import type { BuiltinCall } from '../types.ts'
function envError(message: string): Result {
const err = new TextEncoder().encode(`${message}\n${ENV_HELP_HINT}`)
return [
null,
new IOResult({ exitCode: 125, stderr: err }),
new ExecutionNode({ command: 'env', exitCode: 125, stderr: err }),
]
}
export async function handleEnv(
executeFn: ExecuteStringFn,
args: string[],
session: Session,
stdin: ByteSource | null = null,
): Promise<Result> {
let ignoreEnv = false
let nullSep = false
const unset: string[] = []
let i = 0
while (i < args.length) {
const tok = args[i] ?? ''
if (tok === '--') {
i += 1
break
}
if (tok === '-i' || tok === '--ignore-environment') {
ignoreEnv = true
i += 1
continue
}
if (tok === '-0' || tok === '--null') {
nullSep = true
i += 1
continue
}
if (tok === '-') {
// GNU: "a mere - implies -i".
ignoreEnv = true
i += 1
continue
}
if (tok === '--unset') {
if (i + 1 >= args.length) {
return envError("env: option '--unset' requires an argument")
}
unset.push(args[i + 1] ?? '')
i += 2
continue
}
if (tok.startsWith('--unset=')) {
unset.push(tok.slice('--unset='.length))
i += 1
continue
}
if (tok.startsWith('--')) {
return envError(`env: unrecognized option '${tok}'`)
}
if (tok.startsWith('-') && tok.length > 1) {
let j = 1
let consumedNext = false
let errored: string | null = null
while (j < tok.length) {
const ch = tok[j]
if (ch === 'i') {
ignoreEnv = true
} else if (ch === '0') {
nullSep = true
} else if (ch === 'u') {
const rest = tok.slice(j + 1)
if (rest !== '') {
unset.push(rest)
} else if (i + 1 < args.length) {
unset.push(args[i + 1] ?? '')
consumedNext = true
} else {
errored = "env: option requires an argument -- 'u'"
}
break
} else {
errored = `env: invalid option -- '${ch ?? ''}'`
break
}
j += 1
}
if (errored !== null) return envError(errored)
i += consumedNext ? 2 : 1
continue
}
break
}
const dropSet = new Set(unset)
const source = ignoreEnv ? {} : envSnapshot(session)
const base: Record<string, string> = ownRecord()
for (const [k, v] of Object.entries(source)) {
if (!dropSet.has(k)) base[k] = v
}
while (i < args.length && (args[i] ?? '').includes('=') && !(args[i] ?? '').startsWith('=')) {
const tok = args[i] ?? ''
const eq = tok.indexOf('=')
base[tok.slice(0, eq)] = tok.slice(eq + 1)
i += 1
}
const command = args.slice(i)
if (command.length > 0 && nullSep) {
return envError('env: cannot specify --null (-0) with command')
}
if (command.length === 0) {
const sep = nullSep ? '\0' : '\n'
const out = new TextEncoder().encode(
Object.entries(base)
.map(([k, v]) => `${k}=${v}${sep}`)
.join(''),
)
return [out, new IOResult(), new ExecutionNode({ command: 'env', exitCode: 0 })]
}
// `env NAME=v cmd` runs the command with a replaced environment. Only
// the scalars are replaced: arrays were never part of the env the old
// two-container store swapped, and bash does not put one in a child's
// environment either.
//
// Built through `varsFromEnv`, the one conversion from an embedder's
// process environment to session records, so the export attribute is
// stamped in exactly one place rather than restated here. Seeded
// plain, `env -i FOO=bar printenv FOO` printed nothing, since the
// process view a command reads carries only exported names.
const saved = session.vars
const swapped = ownRecord<ShellVar>()
for (const [name, v] of Object.entries(saved)) {
if (typeof v.value !== 'string') swapped[name] = v
}
Object.assign(swapped, varsFromEnv(base))
session.vars = swapped
try {
const io = await executeFn(shellJoin(command), { sessionId: session.sessionId, stdin })
return [io.stdout, io, new ExecutionNode({ command: 'env', exitCode: io.exitCode })]
} finally {
session.vars = saved
}
}
/** The `env` arm. */
export async function envBuiltin(call: BuiltinCall): Promise<Result> {
return handleEnv(call.executeFn, [...call.argv.args], call.session, call.stdin)
}
@@ -0,0 +1,15 @@
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
export { handleEnv } from './env.ts'