fix(builtins): unignore the env builtin package, gitignore's env/ hid it
This commit is contained in:
@@ -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"
|
||||
@@ -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)
|
||||
@@ -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'
|
||||
Reference in New Issue
Block a user