@caupulican/pi-adaptative 0.85.0 → 0.85.3
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/CHANGELOG.md +25 -0
- package/dist/bundled-resources/runtimes/pi-shell-engine/arithmetic.py +364 -0
- package/dist/bundled-resources/runtimes/pi-shell-engine/context.py +2 -1
- package/dist/bundled-resources/runtimes/pi-shell-engine/errors.py +16 -0
- package/dist/bundled-resources/runtimes/pi-shell-engine/exec.py +137 -14
- package/dist/bundled-resources/runtimes/pi-shell-engine/nodes.py +23 -2
- package/dist/bundled-resources/runtimes/pi-shell-engine/parser.py +134 -6
- package/dist/bundled-resources/runtimes/pi-shell-engine/tokens.py +25 -2
- package/dist/core/agent-session.d.ts.map +1 -1
- package/dist/core/agent-session.js +8 -0
- package/dist/core/agent-session.js.map +1 -1
- package/dist/core/compaction-controller.d.ts +2 -0
- package/dist/core/compaction-controller.d.ts.map +1 -1
- package/dist/core/compaction-controller.js +35 -13
- package/dist/core/compaction-controller.js.map +1 -1
- package/dist/core/failure-corpus.d.ts +26 -1
- package/dist/core/failure-corpus.d.ts.map +1 -1
- package/dist/core/failure-corpus.js +17 -0
- package/dist/core/failure-corpus.js.map +1 -1
- package/dist/core/model-tool-protocol.d.ts +3 -2
- package/dist/core/model-tool-protocol.d.ts.map +1 -1
- package/dist/core/model-tool-protocol.js +5 -1
- package/dist/core/model-tool-protocol.js.map +1 -1
- package/dist/core/models/runtime-process.d.ts +7 -1
- package/dist/core/models/runtime-process.d.ts.map +1 -1
- package/dist/core/models/runtime-process.js +4 -2
- package/dist/core/models/runtime-process.js.map +1 -1
- package/dist/core/process-matrix/runtime.js +43 -11
- package/dist/core/process-matrix/runtime.js.map +1 -1
- package/dist/core/resource-loader.d.ts +2 -0
- package/dist/core/resource-loader.d.ts.map +1 -1
- package/dist/core/resource-loader.js +18 -6
- package/dist/core/resource-loader.js.map +1 -1
- package/dist/core/tool-recovery-log-records.d.ts +33 -1
- package/dist/core/tool-recovery-log-records.d.ts.map +1 -1
- package/dist/core/tool-recovery-log-records.js +59 -12
- package/dist/core/tool-recovery-log-records.js.map +1 -1
- package/dist/core/tool-recovery-logger.d.ts +12 -1
- package/dist/core/tool-recovery-logger.d.ts.map +1 -1
- package/dist/core/tool-recovery-logger.js +24 -1
- package/dist/core/tool-recovery-logger.js.map +1 -1
- package/dist/core/tool-repair-health.d.ts.map +1 -1
- package/dist/core/tool-repair-health.js +6 -0
- package/dist/core/tool-repair-health.js.map +1 -1
- package/dist/core/tools/bash.d.ts +2 -2
- package/dist/core/tools/bash.d.ts.map +1 -1
- package/dist/core/tools/bash.js +2 -2
- package/dist/core/tools/bash.js.map +1 -1
- package/dist/core/tools/find.d.ts +2 -1
- package/dist/core/tools/find.d.ts.map +1 -1
- package/dist/core/tools/find.js +2 -6
- package/dist/core/tools/find.js.map +1 -1
- package/dist/core/tools/grep.d.ts +2 -1
- package/dist/core/tools/grep.d.ts.map +1 -1
- package/dist/core/tools/grep.js +2 -6
- package/dist/core/tools/grep.js.map +1 -1
- package/dist/core/tools/managed-search-tool.d.ts +8 -0
- package/dist/core/tools/managed-search-tool.d.ts.map +1 -0
- package/dist/core/tools/managed-search-tool.js +10 -0
- package/dist/core/tools/managed-search-tool.js.map +1 -0
- package/dist/core/tools/shell-command-parser.d.ts +4 -3
- package/dist/core/tools/shell-command-parser.d.ts.map +1 -1
- package/dist/core/tools/shell-command-parser.js +32 -7
- package/dist/core/tools/shell-command-parser.js.map +1 -1
- package/dist/core/tools/shell-contract-router.d.ts.map +1 -1
- package/dist/core/tools/shell-contract-router.js +23 -1
- package/dist/core/tools/shell-contract-router.js.map +1 -1
- package/dist/core/tools/windows-shell-engine.js +1 -1
- package/dist/core/tools/windows-shell-engine.js.map +1 -1
- package/dist/utils/paths.js +1 -1
- package/dist/utils/paths.js.map +1 -1
- package/dist/utils/tools-manager.d.ts +18 -1
- package/dist/utils/tools-manager.d.ts.map +1 -1
- package/dist/utils/tools-manager.js +73 -43
- package/dist/utils/tools-manager.js.map +1 -1
- package/docs/models.md +1 -1
- package/docs/settings.md +1 -1
- package/docs/tool-repair.md +3 -3
- package/docs/windows.md +7 -5
- package/examples/extensions/custom-provider-anthropic/package-lock.json +2 -2
- package/examples/extensions/custom-provider-anthropic/package.json +1 -1
- package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
- package/examples/extensions/sandbox/package-lock.json +2 -2
- package/examples/extensions/sandbox/package.json +1 -1
- package/examples/extensions/with-deps/package-lock.json +2 -2
- package/examples/extensions/with-deps/package.json +1 -1
- package/npm-shrinkwrap.json +12 -12
- package/package.json +4 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,28 @@
|
|
|
1
|
+
## [0.85.3] - 2026-08-03
|
|
2
|
+
|
|
3
|
+
### Fixed
|
|
4
|
+
|
|
5
|
+
- Fixed the bundled Windows incident collector on PowerShell 5.1 hosts where the module-provided `Get-FileHash` command is unavailable.
|
|
6
|
+
- Fixed native Windows path canonicalization so 8.3 short paths and their long-path spellings resolve to one resource identity.
|
|
7
|
+
- Fixed cooperative worker cleanup under Windows file-lock contention so terminal state is durably persisted, including cleanup requested during the adoption grace window.
|
|
8
|
+
- Fixed system Python detection incorrectly accepting the Windows Store execution alias or another non-Python executable as an installed interpreter.
|
|
9
|
+
|
|
10
|
+
## [0.85.2] - 2026-08-03
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- Fixed fresh Windows search-tool provisioning by pinning checksum-verified `fd` release assets instead of relying on GitHub's rate-limited latest-release API, and hardened Windows release-gate portability without enabling unsupported native tmux.
|
|
15
|
+
|
|
16
|
+
## [0.85.1] - 2026-08-03
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
|
|
20
|
+
- Prevented concurrent and ineffective repeated auto-compaction runs in image-heavy sessions and contexts with protected recent messages.
|
|
21
|
+
- Fixed Windows runtime probes and case-insensitive context-file identity, plus Bash-like path parsing, portable `printf`, word-list and arithmetic `for` loops, and nested `break`/`continue` in the managed shell.
|
|
22
|
+
- Preserved managed-tool provisioning causes and bounded execution-failure guidance in recovery logs, failure corpora, and `/toolhealth`.
|
|
23
|
+
- Kept proven native tool-call models off the phone/text protocol and routed phone validation, repair teaching, and failure guidance through the shared tool pipeline.
|
|
24
|
+
- Required the exact release ref to pass Linux and Windows CI before publishing binaries.
|
|
25
|
+
|
|
1
26
|
## [0.85.0] - 2026-08-02
|
|
2
27
|
|
|
3
28
|
### Breaking Changes
|
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
"""Safe integer-expression parser/evaluator for arithmetic ``for ((...))`` loops.
|
|
2
|
+
|
|
3
|
+
The evaluator deliberately does not call Python ``eval``. It accepts the integer,
|
|
4
|
+
variable, update, assignment, comparison, logical, bitwise, and conditional operators
|
|
5
|
+
used by Bash loop headers, then applies signed 64-bit wrapping after arithmetic writes.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
from state import ShellState
|
|
14
|
+
|
|
15
|
+
_MASK = (1 << 64) - 1
|
|
16
|
+
_SIGN_BIT = 1 << 63
|
|
17
|
+
_ASSIGNMENT_OPERATORS = frozenset(
|
|
18
|
+
{"=", "+=", "-=", "*=", "/=", "%=", "<<=", ">>=", "&=", "^=", "|="}
|
|
19
|
+
)
|
|
20
|
+
_TOKEN_RE = re.compile(
|
|
21
|
+
r"\s*(?:"
|
|
22
|
+
r"(?P<number>0[xX][0-9A-Fa-f]+|0[bB][01]+|0[0-7]+|[0-9]+)"
|
|
23
|
+
r"|(?P<variable>\$?[A-Za-z_][A-Za-z0-9_]*)"
|
|
24
|
+
r"|(?P<operator>\*\*|\+\+|--|<<=|>>=|\+=|-=|\*=|/=|%=|&=|\^=|\|="
|
|
25
|
+
r"|\|\||&&|==|!=|<=|>=|<<|>>|[=+\-*/%<>()!~&^|?:,])"
|
|
26
|
+
r")"
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ArithmeticError(ValueError):
|
|
31
|
+
"""A bounded syntax or runtime failure in a loop arithmetic expression."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class _Token:
|
|
36
|
+
kind: str
|
|
37
|
+
text: str
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class _Number:
|
|
42
|
+
value: int
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class _Variable:
|
|
47
|
+
name: str
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class _Unary:
|
|
52
|
+
operator: str
|
|
53
|
+
operand: "_Expression"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class _Binary:
|
|
58
|
+
operator: str
|
|
59
|
+
left: "_Expression"
|
|
60
|
+
right: "_Expression"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True)
|
|
64
|
+
class _Conditional:
|
|
65
|
+
condition: "_Expression"
|
|
66
|
+
when_true: "_Expression"
|
|
67
|
+
when_false: "_Expression"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True)
|
|
71
|
+
class _Assignment:
|
|
72
|
+
operator: str
|
|
73
|
+
target: _Variable
|
|
74
|
+
value: "_Expression"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(frozen=True)
|
|
78
|
+
class _Update:
|
|
79
|
+
operator: str
|
|
80
|
+
target: _Variable
|
|
81
|
+
prefix: bool
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass(frozen=True)
|
|
85
|
+
class _Sequence:
|
|
86
|
+
expressions: tuple["_Expression", ...]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
_Expression = _Number | _Variable | _Unary | _Binary | _Conditional | _Assignment | _Update | _Sequence
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _wrap(value: int) -> int:
|
|
93
|
+
unsigned = value & _MASK
|
|
94
|
+
return unsigned - (1 << 64) if unsigned & _SIGN_BIT else unsigned
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _parse_integer(text: str) -> int:
|
|
98
|
+
if text.lower().startswith(("0x", "0b")):
|
|
99
|
+
return _wrap(int(text, 0))
|
|
100
|
+
if len(text) > 1 and text.startswith("0"):
|
|
101
|
+
return _wrap(int(text, 8))
|
|
102
|
+
return _wrap(int(text, 10))
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _tokenize(source: str) -> list[_Token]:
|
|
106
|
+
tokens: list[_Token] = []
|
|
107
|
+
position = 0
|
|
108
|
+
while position < len(source):
|
|
109
|
+
match = _TOKEN_RE.match(source, position)
|
|
110
|
+
if match is None:
|
|
111
|
+
excerpt = source[position : position + 16]
|
|
112
|
+
raise ArithmeticError(f"unexpected arithmetic token near {excerpt!r}")
|
|
113
|
+
kind = match.lastgroup
|
|
114
|
+
assert kind is not None
|
|
115
|
+
text = match.group(kind)
|
|
116
|
+
if kind == "variable" and text.startswith("$"):
|
|
117
|
+
text = text[1:]
|
|
118
|
+
tokens.append(_Token(kind, text))
|
|
119
|
+
position = match.end()
|
|
120
|
+
tokens.append(_Token("end", ""))
|
|
121
|
+
return tokens
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class _Parser:
|
|
125
|
+
def __init__(self, source: str) -> None:
|
|
126
|
+
self.tokens = _tokenize(source)
|
|
127
|
+
self.index = 0
|
|
128
|
+
|
|
129
|
+
def current(self) -> _Token:
|
|
130
|
+
return self.tokens[self.index]
|
|
131
|
+
|
|
132
|
+
def accept(self, operator: str) -> bool:
|
|
133
|
+
if self.current().kind == "operator" and self.current().text == operator:
|
|
134
|
+
self.index += 1
|
|
135
|
+
return True
|
|
136
|
+
return False
|
|
137
|
+
|
|
138
|
+
def require(self, operator: str) -> None:
|
|
139
|
+
if not self.accept(operator):
|
|
140
|
+
raise ArithmeticError(f"expected {operator!r} in arithmetic expression")
|
|
141
|
+
|
|
142
|
+
def parse(self) -> _Expression:
|
|
143
|
+
expression = self.parse_sequence()
|
|
144
|
+
if self.current().kind != "end":
|
|
145
|
+
raise ArithmeticError(f"unexpected arithmetic token {self.current().text!r}")
|
|
146
|
+
return expression
|
|
147
|
+
|
|
148
|
+
def parse_sequence(self) -> _Expression:
|
|
149
|
+
expressions = [self.parse_assignment()]
|
|
150
|
+
while self.accept(","):
|
|
151
|
+
expressions.append(self.parse_assignment())
|
|
152
|
+
return expressions[0] if len(expressions) == 1 else _Sequence(tuple(expressions))
|
|
153
|
+
|
|
154
|
+
def parse_assignment(self) -> _Expression:
|
|
155
|
+
target = self.parse_conditional()
|
|
156
|
+
token = self.current()
|
|
157
|
+
if token.kind != "operator" or token.text not in _ASSIGNMENT_OPERATORS:
|
|
158
|
+
return target
|
|
159
|
+
self.index += 1
|
|
160
|
+
if not isinstance(target, _Variable):
|
|
161
|
+
raise ArithmeticError("the left side of an arithmetic assignment must be a variable")
|
|
162
|
+
return _Assignment(token.text, target, self.parse_assignment())
|
|
163
|
+
|
|
164
|
+
def parse_conditional(self) -> _Expression:
|
|
165
|
+
condition = self.parse_logical_or()
|
|
166
|
+
if not self.accept("?"):
|
|
167
|
+
return condition
|
|
168
|
+
when_true = self.parse_sequence()
|
|
169
|
+
self.require(":")
|
|
170
|
+
return _Conditional(condition, when_true, self.parse_assignment())
|
|
171
|
+
|
|
172
|
+
def parse_logical_or(self) -> _Expression:
|
|
173
|
+
return self._parse_left_associative(self.parse_logical_and, frozenset({"||"}))
|
|
174
|
+
|
|
175
|
+
def parse_logical_and(self) -> _Expression:
|
|
176
|
+
return self._parse_left_associative(self.parse_bitwise_or, frozenset({"&&"}))
|
|
177
|
+
|
|
178
|
+
def parse_bitwise_or(self) -> _Expression:
|
|
179
|
+
return self._parse_left_associative(self.parse_bitwise_xor, frozenset({"|"}))
|
|
180
|
+
|
|
181
|
+
def parse_bitwise_xor(self) -> _Expression:
|
|
182
|
+
return self._parse_left_associative(self.parse_bitwise_and, frozenset({"^"}))
|
|
183
|
+
|
|
184
|
+
def parse_bitwise_and(self) -> _Expression:
|
|
185
|
+
return self._parse_left_associative(self.parse_equality, frozenset({"&"}))
|
|
186
|
+
|
|
187
|
+
def parse_equality(self) -> _Expression:
|
|
188
|
+
return self._parse_left_associative(self.parse_relational, frozenset({"==", "!="}))
|
|
189
|
+
|
|
190
|
+
def parse_relational(self) -> _Expression:
|
|
191
|
+
return self._parse_left_associative(self.parse_shift, frozenset({"<", "<=", ">", ">="}))
|
|
192
|
+
|
|
193
|
+
def parse_shift(self) -> _Expression:
|
|
194
|
+
return self._parse_left_associative(self.parse_additive, frozenset({"<<", ">>"}))
|
|
195
|
+
|
|
196
|
+
def parse_additive(self) -> _Expression:
|
|
197
|
+
return self._parse_left_associative(self.parse_multiplicative, frozenset({"+", "-"}))
|
|
198
|
+
|
|
199
|
+
def parse_multiplicative(self) -> _Expression:
|
|
200
|
+
return self._parse_left_associative(self.parse_power, frozenset({"*", "/", "%"}))
|
|
201
|
+
|
|
202
|
+
def parse_power(self) -> _Expression:
|
|
203
|
+
left = self.parse_unary()
|
|
204
|
+
if self.accept("**"):
|
|
205
|
+
return _Binary("**", left, self.parse_power())
|
|
206
|
+
return left
|
|
207
|
+
|
|
208
|
+
def parse_unary(self) -> _Expression:
|
|
209
|
+
token = self.current()
|
|
210
|
+
if token.kind == "operator" and token.text in ("+", "-", "!", "~"):
|
|
211
|
+
self.index += 1
|
|
212
|
+
return _Unary(token.text, self.parse_unary())
|
|
213
|
+
if token.kind == "operator" and token.text in ("++", "--"):
|
|
214
|
+
self.index += 1
|
|
215
|
+
target = self.parse_unary()
|
|
216
|
+
if not isinstance(target, _Variable):
|
|
217
|
+
raise ArithmeticError(f"{token.text} requires a variable")
|
|
218
|
+
return _Update(token.text, target, True)
|
|
219
|
+
return self.parse_postfix()
|
|
220
|
+
|
|
221
|
+
def parse_postfix(self) -> _Expression:
|
|
222
|
+
expression = self.parse_primary()
|
|
223
|
+
token = self.current()
|
|
224
|
+
if token.kind == "operator" and token.text in ("++", "--"):
|
|
225
|
+
self.index += 1
|
|
226
|
+
if not isinstance(expression, _Variable):
|
|
227
|
+
raise ArithmeticError(f"{token.text} requires a variable")
|
|
228
|
+
return _Update(token.text, expression, False)
|
|
229
|
+
return expression
|
|
230
|
+
|
|
231
|
+
def parse_primary(self) -> _Expression:
|
|
232
|
+
token = self.current()
|
|
233
|
+
if token.kind == "number":
|
|
234
|
+
self.index += 1
|
|
235
|
+
return _Number(_parse_integer(token.text))
|
|
236
|
+
if token.kind == "variable":
|
|
237
|
+
self.index += 1
|
|
238
|
+
return _Variable(token.text)
|
|
239
|
+
if self.accept("("):
|
|
240
|
+
expression = self.parse_sequence()
|
|
241
|
+
self.require(")")
|
|
242
|
+
return expression
|
|
243
|
+
raise ArithmeticError(f"expected an integer, variable, or '('; found {token.text!r}")
|
|
244
|
+
|
|
245
|
+
def _parse_left_associative(self, operand_parser, operators: frozenset[str]) -> _Expression:
|
|
246
|
+
expression = operand_parser()
|
|
247
|
+
while self.current().kind == "operator" and self.current().text in operators:
|
|
248
|
+
operator = self.current().text
|
|
249
|
+
self.index += 1
|
|
250
|
+
expression = _Binary(operator, expression, operand_parser())
|
|
251
|
+
return expression
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def compile_arithmetic(source: str) -> _Expression | None:
|
|
255
|
+
stripped = source.strip()
|
|
256
|
+
return None if not stripped else _Parser(stripped).parse()
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _variable_value(variable: _Variable, state: ShellState) -> int:
|
|
260
|
+
text = state.env.get(variable.name, "").strip()
|
|
261
|
+
if not text:
|
|
262
|
+
return 0
|
|
263
|
+
try:
|
|
264
|
+
return _parse_integer(text)
|
|
265
|
+
except ValueError:
|
|
266
|
+
return 0
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _set_variable(variable: _Variable, value: int, state: ShellState) -> int:
|
|
270
|
+
wrapped = _wrap(value)
|
|
271
|
+
state.setenv(variable.name, str(wrapped))
|
|
272
|
+
return wrapped
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _divide(left: int, right: int) -> int:
|
|
276
|
+
if right == 0:
|
|
277
|
+
raise ArithmeticError("division by zero")
|
|
278
|
+
quotient = abs(left) // abs(right)
|
|
279
|
+
return -quotient if (left < 0) != (right < 0) else quotient
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _binary(operator: str, left: int, right: int) -> int:
|
|
283
|
+
if operator == "+":
|
|
284
|
+
return _wrap(left + right)
|
|
285
|
+
if operator == "-":
|
|
286
|
+
return _wrap(left - right)
|
|
287
|
+
if operator == "*":
|
|
288
|
+
return _wrap(left * right)
|
|
289
|
+
if operator == "/":
|
|
290
|
+
return _wrap(_divide(left, right))
|
|
291
|
+
if operator == "%":
|
|
292
|
+
return _wrap(left - _divide(left, right) * right)
|
|
293
|
+
if operator == "**":
|
|
294
|
+
if right < 0:
|
|
295
|
+
raise ArithmeticError("negative exponent")
|
|
296
|
+
return _wrap(pow(left & _MASK, right, 1 << 64))
|
|
297
|
+
if operator in ("<<", ">>"):
|
|
298
|
+
if right < 0 or right >= 64:
|
|
299
|
+
raise ArithmeticError("shift count must be between 0 and 63")
|
|
300
|
+
return _wrap(left << right) if operator == "<<" else _wrap(left >> right)
|
|
301
|
+
if operator == "&":
|
|
302
|
+
return _wrap(left & right)
|
|
303
|
+
if operator == "^":
|
|
304
|
+
return _wrap(left ^ right)
|
|
305
|
+
if operator == "|":
|
|
306
|
+
return _wrap(left | right)
|
|
307
|
+
if operator == "==":
|
|
308
|
+
return int(left == right)
|
|
309
|
+
if operator == "!=":
|
|
310
|
+
return int(left != right)
|
|
311
|
+
if operator == "<":
|
|
312
|
+
return int(left < right)
|
|
313
|
+
if operator == "<=":
|
|
314
|
+
return int(left <= right)
|
|
315
|
+
if operator == ">":
|
|
316
|
+
return int(left > right)
|
|
317
|
+
if operator == ">=":
|
|
318
|
+
return int(left >= right)
|
|
319
|
+
raise ArithmeticError(f"unsupported arithmetic operator {operator!r}")
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def evaluate_arithmetic(expression: _Expression | None, state: ShellState) -> int:
|
|
323
|
+
if expression is None:
|
|
324
|
+
return 0
|
|
325
|
+
if isinstance(expression, _Number):
|
|
326
|
+
return expression.value
|
|
327
|
+
if isinstance(expression, _Variable):
|
|
328
|
+
return _variable_value(expression, state)
|
|
329
|
+
if isinstance(expression, _Sequence):
|
|
330
|
+
result = 0
|
|
331
|
+
for item in expression.expressions:
|
|
332
|
+
result = evaluate_arithmetic(item, state)
|
|
333
|
+
return result
|
|
334
|
+
if isinstance(expression, _Conditional):
|
|
335
|
+
branch = expression.when_true if evaluate_arithmetic(expression.condition, state) != 0 else expression.when_false
|
|
336
|
+
return evaluate_arithmetic(branch, state)
|
|
337
|
+
if isinstance(expression, _Unary):
|
|
338
|
+
value = evaluate_arithmetic(expression.operand, state)
|
|
339
|
+
if expression.operator == "+":
|
|
340
|
+
return value
|
|
341
|
+
if expression.operator == "-":
|
|
342
|
+
return _wrap(-value)
|
|
343
|
+
if expression.operator == "!":
|
|
344
|
+
return int(value == 0)
|
|
345
|
+
if expression.operator == "~":
|
|
346
|
+
return _wrap(~value)
|
|
347
|
+
if isinstance(expression, _Binary):
|
|
348
|
+
left = evaluate_arithmetic(expression.left, state)
|
|
349
|
+
if expression.operator == "&&":
|
|
350
|
+
return int(left != 0 and evaluate_arithmetic(expression.right, state) != 0)
|
|
351
|
+
if expression.operator == "||":
|
|
352
|
+
return int(left != 0 or evaluate_arithmetic(expression.right, state) != 0)
|
|
353
|
+
return _binary(expression.operator, left, evaluate_arithmetic(expression.right, state))
|
|
354
|
+
if isinstance(expression, _Assignment):
|
|
355
|
+
right = evaluate_arithmetic(expression.value, state)
|
|
356
|
+
if expression.operator == "=":
|
|
357
|
+
return _set_variable(expression.target, right, state)
|
|
358
|
+
left = _variable_value(expression.target, state)
|
|
359
|
+
return _set_variable(expression.target, _binary(expression.operator[:-1], left, right), state)
|
|
360
|
+
if isinstance(expression, _Update):
|
|
361
|
+
old = _variable_value(expression.target, state)
|
|
362
|
+
new = _set_variable(expression.target, old + (1 if expression.operator == "++" else -1), state)
|
|
363
|
+
return new if expression.prefix else old
|
|
364
|
+
raise ArithmeticError("unrecognized arithmetic expression")
|
|
@@ -44,9 +44,10 @@ class ExecContext:
|
|
|
44
44
|
builtins: Mapping[str, BuiltinFn]
|
|
45
45
|
deadline: float | None
|
|
46
46
|
stderr: BinaryIO | int | None = None
|
|
47
|
+
loop_depth: int = 0
|
|
47
48
|
|
|
48
49
|
|
|
49
50
|
# Executor-owned builtins are NOT in commands/REGISTRY: state mutators and `exit`
|
|
50
51
|
# need ShellState/control-flow access, while the runner needs the executor.
|
|
51
|
-
STATE_BUILTINS = {"cd", "export", "unset", "exit"}
|
|
52
|
+
STATE_BUILTINS = {"cd", "export", "unset", "exit", "break", "continue"}
|
|
52
53
|
RUNNER_BUILTINS = {"xargs"}
|
|
@@ -55,3 +55,19 @@ class ShellExit(Exception):
|
|
|
55
55
|
def __init__(self, exit_code: int) -> None:
|
|
56
56
|
super().__init__(exit_code)
|
|
57
57
|
self.exit_code = exit_code
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class LoopControl(Exception):
|
|
61
|
+
"""Internal non-local control transfer consumed by enclosing loop nodes."""
|
|
62
|
+
|
|
63
|
+
def __init__(self, levels: int) -> None:
|
|
64
|
+
super().__init__(levels)
|
|
65
|
+
self.levels = levels
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class LoopBreak(LoopControl):
|
|
69
|
+
"""Leave one or more enclosing loops."""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class LoopContinue(LoopControl):
|
|
73
|
+
"""Continue one or more enclosing loops."""
|
|
@@ -10,17 +10,20 @@ import io
|
|
|
10
10
|
import os
|
|
11
11
|
import subprocess
|
|
12
12
|
import threading
|
|
13
|
+
import time
|
|
13
14
|
from typing import BinaryIO
|
|
14
15
|
|
|
16
|
+
from arithmetic import ArithmeticError, compile_arithmetic, evaluate_arithmetic
|
|
15
17
|
import nodes
|
|
16
18
|
import parser as parser_module
|
|
17
19
|
import proc
|
|
18
20
|
import tokens as tokens_module
|
|
19
21
|
from context import RUNNER_BUILTINS, STATE_BUILTINS, BuiltinContext, ExecContext
|
|
20
|
-
from errors import ShellExit, UnsupportedConstruct
|
|
22
|
+
from errors import LoopBreak, LoopContinue, LoopControl, ShellExit, UnsupportedConstruct
|
|
21
23
|
from state import ShellState
|
|
22
24
|
|
|
23
25
|
DEVNULL = os.devnull
|
|
26
|
+
MAX_LOOP_ITERATIONS = 1_000_000
|
|
24
27
|
|
|
25
28
|
|
|
26
29
|
class _Redirected:
|
|
@@ -344,7 +347,7 @@ def _run_pipeline_elements(elements: list, ctx: ExecContext) -> int:
|
|
|
344
347
|
try:
|
|
345
348
|
# Pipeline stages are subshell-like: state mutations belong to the stage,
|
|
346
349
|
# never to the parent command context or a sibling stage.
|
|
347
|
-
stage_ctx = _sub_ctx(ctx, ctx.state.copy(), sin, sout, stderr_base)
|
|
350
|
+
stage_ctx = _sub_ctx(ctx, ctx.state.copy(), sin, sout, stderr_base, loop_depth=0)
|
|
348
351
|
results[idx] = _dispatch_element(elements[idx], stage_ctx, sin, sout, stderr_base, is_pipeline=True)
|
|
349
352
|
except UnsupportedConstruct as exc:
|
|
350
353
|
# A structured refusal from an inline stage means the WHOLE command
|
|
@@ -356,6 +359,10 @@ def _run_pipeline_elements(elements: list, ctx: ExecContext) -> int:
|
|
|
356
359
|
results[idx] = 1
|
|
357
360
|
except ShellExit as exc:
|
|
358
361
|
results[idx] = exc.exit_code
|
|
362
|
+
except LoopControl:
|
|
363
|
+
# A pipeline stage is a subshell boundary. Loop control in that
|
|
364
|
+
# stage cannot alter an enclosing loop in the parent context.
|
|
365
|
+
results[idx] = 0
|
|
359
366
|
except BaseException as exc: # noqa: BLE001 - any other stage failure must not crash the engine
|
|
360
367
|
# Any OTHER exception is a real per-stage failure, not a refusal: it
|
|
361
368
|
# gets a named, actionable one-line message on the merged sink (never
|
|
@@ -487,6 +494,8 @@ def _dispatch_element(
|
|
|
487
494
|
return _execute_subshell(element, ctx, stdin_stream, stdout_stream, stderr_stream)
|
|
488
495
|
if isinstance(element, nodes.BraceGroup):
|
|
489
496
|
return _execute_brace_group(element, ctx, stdin_stream, stdout_stream, stderr_stream)
|
|
497
|
+
if isinstance(element, (nodes.ForCommand, nodes.ArithmeticForCommand)):
|
|
498
|
+
return _execute_for_command(element, ctx, stdin_stream, stdout_stream, stderr_stream)
|
|
490
499
|
if isinstance(element, nodes.SimpleCommand):
|
|
491
500
|
return _dispatch_simple_command(element, ctx, stdin_stream, stdout_stream, stderr_stream)
|
|
492
501
|
raise UnsupportedConstruct("malformed-syntax", f"unrecognized pipeline element {type(element)!r}")
|
|
@@ -498,6 +507,8 @@ def _sub_ctx(
|
|
|
498
507
|
stdin_stream: BinaryIO | int,
|
|
499
508
|
stdout_stream: BinaryIO | int,
|
|
500
509
|
stderr_stream: BinaryIO | int,
|
|
510
|
+
*,
|
|
511
|
+
loop_depth: int | None = None,
|
|
501
512
|
) -> ExecContext:
|
|
502
513
|
return ExecContext(
|
|
503
514
|
state=state,
|
|
@@ -508,11 +519,12 @@ def _sub_ctx(
|
|
|
508
519
|
builtins=ctx.builtins,
|
|
509
520
|
deadline=ctx.deadline,
|
|
510
521
|
stderr=stderr_stream,
|
|
522
|
+
loop_depth=ctx.loop_depth if loop_depth is None else loop_depth,
|
|
511
523
|
)
|
|
512
524
|
|
|
513
525
|
|
|
514
|
-
def
|
|
515
|
-
node: nodes.Subshell | nodes.BraceGroup,
|
|
526
|
+
def _execute_redirected_compound(
|
|
527
|
+
node: nodes.Subshell | nodes.BraceGroup | nodes.ForCommand | nodes.ArithmeticForCommand,
|
|
516
528
|
ctx: ExecContext,
|
|
517
529
|
stdin_stream,
|
|
518
530
|
stdout_stream,
|
|
@@ -526,8 +538,18 @@ def _execute_redirected_group(
|
|
|
526
538
|
node.redirects, ctx, stdin_stream, stdout_stream, stderr_stream, tracker
|
|
527
539
|
)
|
|
528
540
|
state = ctx.state.copy() if isolated else ctx.state
|
|
529
|
-
inner_ctx = _sub_ctx(
|
|
541
|
+
inner_ctx = _sub_ctx(
|
|
542
|
+
ctx,
|
|
543
|
+
state,
|
|
544
|
+
r_in,
|
|
545
|
+
r_out,
|
|
546
|
+
r_err,
|
|
547
|
+
loop_depth=0 if isolated else ctx.loop_depth,
|
|
548
|
+
)
|
|
530
549
|
try:
|
|
550
|
+
if isinstance(node, (nodes.ForCommand, nodes.ArithmeticForCommand)):
|
|
551
|
+
inner_ctx.loop_depth += 1
|
|
552
|
+
return _execute_loop(node, inner_ctx)
|
|
531
553
|
return execute(node.body, inner_ctx)
|
|
532
554
|
except ShellExit as exc:
|
|
533
555
|
if isolated:
|
|
@@ -538,17 +560,89 @@ def _execute_redirected_group(
|
|
|
538
560
|
|
|
539
561
|
|
|
540
562
|
def _execute_subshell(node: nodes.Subshell, ctx: ExecContext, stdin_stream, stdout_stream, stderr_stream) -> int:
|
|
541
|
-
return
|
|
563
|
+
return _execute_redirected_compound(
|
|
542
564
|
node, ctx, stdin_stream, stdout_stream, stderr_stream, isolated=True
|
|
543
565
|
)
|
|
544
566
|
|
|
545
567
|
|
|
546
568
|
def _execute_brace_group(node: nodes.BraceGroup, ctx: ExecContext, stdin_stream, stdout_stream, stderr_stream) -> int:
|
|
547
|
-
return
|
|
569
|
+
return _execute_redirected_compound(
|
|
570
|
+
node, ctx, stdin_stream, stdout_stream, stderr_stream, isolated=False
|
|
571
|
+
)
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def _execute_for_command(
|
|
575
|
+
node: nodes.ForCommand | nodes.ArithmeticForCommand,
|
|
576
|
+
ctx: ExecContext,
|
|
577
|
+
stdin_stream,
|
|
578
|
+
stdout_stream,
|
|
579
|
+
stderr_stream,
|
|
580
|
+
) -> int:
|
|
581
|
+
return _execute_redirected_compound(
|
|
548
582
|
node, ctx, stdin_stream, stdout_stream, stderr_stream, isolated=False
|
|
549
583
|
)
|
|
550
584
|
|
|
551
585
|
|
|
586
|
+
def _check_loop_budget(ctx: ExecContext, iteration: int) -> None:
|
|
587
|
+
if ctx.deadline is not None and time.monotonic() >= ctx.deadline:
|
|
588
|
+
raise ShellExit(124)
|
|
589
|
+
if iteration >= MAX_LOOP_ITERATIONS:
|
|
590
|
+
_write_merged(
|
|
591
|
+
_merged_sink(ctx),
|
|
592
|
+
f"for: iteration limit ({MAX_LOOP_ITERATIONS}) exceeded\n".encode("utf-8"),
|
|
593
|
+
ctx,
|
|
594
|
+
)
|
|
595
|
+
raise ShellExit(124)
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def _execute_loop_body(body: nodes.CommandList, ctx: ExecContext) -> tuple[int, str | None]:
|
|
599
|
+
try:
|
|
600
|
+
return execute(body, ctx), None
|
|
601
|
+
except LoopBreak as exc:
|
|
602
|
+
if exc.levels > 1:
|
|
603
|
+
raise LoopBreak(exc.levels - 1) from None
|
|
604
|
+
return 0, "break"
|
|
605
|
+
except LoopContinue as exc:
|
|
606
|
+
if exc.levels > 1:
|
|
607
|
+
raise LoopContinue(exc.levels - 1) from None
|
|
608
|
+
return 0, "continue"
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def _execute_loop(node: nodes.ForCommand | nodes.ArithmeticForCommand, ctx: ExecContext) -> int:
|
|
612
|
+
if isinstance(node, nodes.ForCommand):
|
|
613
|
+
values: list[str] = []
|
|
614
|
+
for item in node.items:
|
|
615
|
+
values.extend(ctx.expand_word(item, ctx))
|
|
616
|
+
|
|
617
|
+
exit_code = 0
|
|
618
|
+
for iteration, value in enumerate(values):
|
|
619
|
+
_check_loop_budget(ctx, iteration)
|
|
620
|
+
ctx.state.setenv(node.name, value)
|
|
621
|
+
exit_code, action = _execute_loop_body(node.body, ctx)
|
|
622
|
+
if action == "break":
|
|
623
|
+
return exit_code
|
|
624
|
+
return exit_code
|
|
625
|
+
|
|
626
|
+
try:
|
|
627
|
+
initializer = compile_arithmetic(node.initializer)
|
|
628
|
+
condition = compile_arithmetic(node.condition)
|
|
629
|
+
update = compile_arithmetic(node.update)
|
|
630
|
+
evaluate_arithmetic(initializer, ctx.state)
|
|
631
|
+
exit_code = 0
|
|
632
|
+
iteration = 0
|
|
633
|
+
while condition is None or evaluate_arithmetic(condition, ctx.state) != 0:
|
|
634
|
+
_check_loop_budget(ctx, iteration)
|
|
635
|
+
iteration += 1
|
|
636
|
+
exit_code, action = _execute_loop_body(node.body, ctx)
|
|
637
|
+
if action == "break":
|
|
638
|
+
return exit_code
|
|
639
|
+
evaluate_arithmetic(update, ctx.state)
|
|
640
|
+
return exit_code
|
|
641
|
+
except ArithmeticError as exc:
|
|
642
|
+
_write_merged(_merged_sink(ctx), f"bash: arithmetic: {exc}\n".encode("utf-8"), ctx)
|
|
643
|
+
return 1
|
|
644
|
+
|
|
645
|
+
|
|
552
646
|
def _dispatch_simple_command(
|
|
553
647
|
command: nodes.SimpleCommand,
|
|
554
648
|
ctx: ExecContext,
|
|
@@ -577,7 +671,7 @@ def _dispatch_simple_command(
|
|
|
577
671
|
name = argv[0]
|
|
578
672
|
|
|
579
673
|
if name in STATE_BUILTINS:
|
|
580
|
-
return _run_state_builtin(name, argv, ctx, r_out)
|
|
674
|
+
return _run_state_builtin(name, argv, ctx, r_out, r_err)
|
|
581
675
|
|
|
582
676
|
if name in RUNNER_BUILTINS:
|
|
583
677
|
return _run_xargs(argv, ctx, r_in, r_out)
|
|
@@ -621,17 +715,46 @@ def _as_stream(fd_or_stream: BinaryIO | int, mode: str) -> BinaryIO:
|
|
|
621
715
|
return fd_or_stream
|
|
622
716
|
|
|
623
717
|
|
|
624
|
-
def _run_state_builtin(
|
|
718
|
+
def _run_state_builtin(
|
|
719
|
+
name: str,
|
|
720
|
+
argv: list[str],
|
|
721
|
+
ctx: ExecContext,
|
|
722
|
+
out_stream: BinaryIO | int,
|
|
723
|
+
error_stream: BinaryIO | int | None = None,
|
|
724
|
+
) -> int:
|
|
725
|
+
diagnostic_stream = error_stream if error_stream is not None else out_stream
|
|
726
|
+
if name in ("break", "continue"):
|
|
727
|
+
if len(argv) > 2:
|
|
728
|
+
_write_merged(diagnostic_stream, f"{name}: too many arguments\n".encode("utf-8"), ctx)
|
|
729
|
+
return 1
|
|
730
|
+
try:
|
|
731
|
+
levels = int(argv[1], 10) if len(argv) == 2 else 1
|
|
732
|
+
except ValueError:
|
|
733
|
+
_write_merged(diagnostic_stream, f"{name}: {argv[1]}: numeric argument required\n".encode("utf-8"), ctx)
|
|
734
|
+
return 1
|
|
735
|
+
if levels <= 0:
|
|
736
|
+
_write_merged(diagnostic_stream, f"{name}: loop count must be positive\n".encode("utf-8"), ctx)
|
|
737
|
+
return 1
|
|
738
|
+
if ctx.loop_depth == 0:
|
|
739
|
+
_write_merged(
|
|
740
|
+
diagnostic_stream,
|
|
741
|
+
f"bash: {name}: only meaningful in a 'for', 'while', or 'until' loop\n".encode("utf-8"),
|
|
742
|
+
ctx,
|
|
743
|
+
)
|
|
744
|
+
return 0
|
|
745
|
+
control = LoopBreak if name == "break" else LoopContinue
|
|
746
|
+
raise control(min(levels, ctx.loop_depth))
|
|
747
|
+
|
|
625
748
|
if name == "exit":
|
|
626
749
|
if len(argv) > 2:
|
|
627
|
-
_write_merged(
|
|
750
|
+
_write_merged(diagnostic_stream, b"exit: too many arguments\n", ctx)
|
|
628
751
|
raise ShellExit(1)
|
|
629
752
|
if len(argv) == 1:
|
|
630
753
|
raise ShellExit(ctx.state.last_exit_code)
|
|
631
754
|
try:
|
|
632
755
|
exit_code = int(argv[1], 10)
|
|
633
756
|
except ValueError:
|
|
634
|
-
_write_merged(
|
|
757
|
+
_write_merged(diagnostic_stream, f"exit: {argv[1]}: numeric argument required\n".encode("utf-8"), ctx)
|
|
635
758
|
raise ShellExit(2)
|
|
636
759
|
raise ShellExit(exit_code & 0xFF)
|
|
637
760
|
|
|
@@ -641,16 +764,16 @@ def _run_state_builtin(name: str, argv: list[str], ctx: ExecContext, out_stream:
|
|
|
641
764
|
if print_new_cwd:
|
|
642
765
|
oldpwd = ctx.state.env.get("OLDPWD")
|
|
643
766
|
if not oldpwd:
|
|
644
|
-
_write_merged(
|
|
767
|
+
_write_merged(diagnostic_stream, b"cd: OLDPWD not set\n", ctx)
|
|
645
768
|
return 1
|
|
646
769
|
target = oldpwd
|
|
647
770
|
if target is None:
|
|
648
|
-
_write_merged(
|
|
771
|
+
_write_merged(diagnostic_stream, b"cd: HOME not set\n", ctx)
|
|
649
772
|
return 1
|
|
650
773
|
try:
|
|
651
774
|
ctx.state.chdir(target)
|
|
652
775
|
except FileNotFoundError:
|
|
653
|
-
_write_merged(
|
|
776
|
+
_write_merged(diagnostic_stream, f"cd: {target}: No such file or directory\n".encode("utf-8"), ctx)
|
|
654
777
|
return 1
|
|
655
778
|
if print_new_cwd:
|
|
656
779
|
_write_merged(out_stream, f"{ctx.state.cwd}\n".encode("utf-8"), ctx)
|