maf-sandbox-codeact 0.21.4__tar.gz → 0.22.0__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: maf-sandbox-codeact
3
- Version: 0.21.4
3
+ Version: 0.22.0
4
4
  Summary: CodeAct as a Microsoft Agent Framework tool — the model writes a short Python program and it runs inside a sandbox — written against maf-sandbox so it runs on any sandbox backend.
5
5
  Keywords: codeact,code-execution,sandbox,agent-framework,microsoft-agent-framework
6
6
  Author: SOKOLAI BV
@@ -15,7 +15,7 @@ Classifier: Programming Language :: Python :: 3.12
15
15
  Classifier: Programming Language :: Python :: 3.13
16
16
  Classifier: Programming Language :: Python :: 3.14
17
17
  Classifier: Topic :: Software Development :: Interpreters
18
- Requires-Dist: maf-sandbox>=0.46.0,<0.47
18
+ Requires-Dist: maf-sandbox>=0.47.0,<0.48
19
19
  Requires-Dist: agent-framework-core>=1.19.0,<1.20
20
20
  Requires-Python: >=3.12, <3.15
21
21
  Project-URL: Homepage, https://www.sokol.ai
@@ -52,7 +52,7 @@ tools = make_codeact_tools(
52
52
 
53
53
  The host supplies the router and `CallerContext`. With no configured backend the factory returns `[]`. Unsupported requirements are refused before attachment.
54
54
 
55
- The default path writes `program.py` and runs it with `python3`. It requires `EXEC` and `FILES_IN`. Source text is file content, never part of the command line.
55
+ The default path requests a backend-owned `python-portable-v1` channel, preferring exec. This profile promises Python 3.11+ with `json`, `math`, `re`, `sys` and `types`; it promises no other imports or retained state. The exec channel verifies CPython and owns `program.py`, the interpreter and any host-tool transport. Source text is file content, never part of the command line. See [channel selection and migration](https://github.com/sokolaidev/maf-extensions/blob/main/docs/sandbox/program-channels.md).
56
56
 
57
57
  Use the [Docker sample](https://github.com/sokolaidev/maf-extensions/tree/main/samples/06_docker_codeact), [ACAS sample](https://github.com/sokolaidev/maf-extensions/tree/main/samples/03_acas_codeact) or [WSLC sample](https://github.com/sokolaidev/maf-extensions/tree/main/samples/04_wslc_codeact) for complete applications.
58
58
 
@@ -64,7 +64,7 @@ All four channels below are off by default. The host enables them when building
64
64
  |---|---|
65
65
  | `file_store=store` | A `files` argument selecting caller-visible input files. |
66
66
  | `output_sink=sink`, `outputs=...` | Collection and delivery of named output files. |
67
- | `host_tools=registry` | Calls from guest Python to registered host functions, in exec mode. |
67
+ | `host_tools=registry` | Calls from guest Python to registered host functions through a supporting program channel. |
68
68
  | `egress_allow=(...)` | Network requests to named destinations. |
69
69
 
70
70
  These channels operate during `execute_code`. Hiding its final report does not undo host calls, network requests or artifact delivery.
@@ -101,10 +101,12 @@ tools = make_codeact_tools(
101
101
  )
102
102
  ```
103
103
 
104
- `files_in` and `files_out` bound file count, individual bytes and total bytes. A missing declared output is reported. Artifacts carry no guest-selected media type; the host decides how to handle them.
104
+ `files_in` and `files_out` bound shared-file count, individual bytes and total bytes. Shared inputs default to 8 MiB per file, 24 MiB total and 63 files, leaving room for the default 8 MiB program and its staging file within a 32 MiB/64-file backend ceiling. Explicit limits are preserved and the complete channel demand is checked without clamping; larger programs or host-tool transport traffic can require a backend with larger ceilings. On the automatic channel path, `program=ProgramRequirements(max_program_bytes=8 * 1024 * 1024, host_tool_timeout_seconds=30)` from `maf_sandbox` configures source bytes and callback time separately. A missing declared output is reported. Artifacts carry no guest-selected media type; the host decides how to handle them.
105
105
 
106
106
  Keep the output sink separate from the agent's writable input store. Otherwise guest code could overwrite files through a channel that bypasses the host's file-write approval. See the [files sample](https://github.com/sokolaidev/maf-extensions/tree/main/samples/08_docker_codeact_files).
107
107
 
108
+ With `withhold_guest_output=True`, a sink can supply `locate=lambda name: f".tool-results/execute_code/{name}"` to report a fixed landing folder in the Saved and not-written lists. Have `deliver` write to the same location and return it in `LandedArtifact.name` or `handle`. The pure synchronous locator receives only validated declared names, normalized as the sink requests; it must never depend on guest content or delivery state. A mismatched receipt falls back to the declared name and logs a warning. The default locator preserves existing results, and `per_call` sinks retain their folder-only reporting. See the [withholding contract](https://github.com/sokolaidev/maf-extensions/blob/main/docs/sandbox/kinds/codeact.md#withholding-guest-output) for the information this mode still reveals.
109
+
108
110
  ## Host functions and network access
109
111
 
110
112
  Register host functions before passing the registry to the factory. Reading its combined policy seals it against later registration.
@@ -125,7 +127,7 @@ registry.register(exchange_rate)
125
127
 
126
128
  `exchange_rate` is a host-defined function. Its body runs with host authority and bypasses ordinary agent middleware. Configure declarations, permitted identities and approval policy before exposing it. Registering a user-authority function makes the enclosing tool approval-gated.
127
129
 
128
- A nonempty registry requires `HOST_TOOLS` and `FILES_OUT` as well as `EXEC` and `FILES_IN`. Docker and ACAS support this transport; WSLC does not. The image needs the POSIX launcher utilities, including `sh` and `nohup`.
130
+ A nonempty registry requires `HOST_TOOLS` and an explicit supporting program channel. Docker, ACAS and Docker Sandboxes supply the exec channel, which adds `EXEC`, `FILES_IN` and `FILES_OUT` internally. WSLC supports programs without host tools. The exec image needs the POSIX launcher utilities, including `sh` and `nohup`. Hyperlight native host tools remain pending.
129
131
 
130
132
  Transport traffic counts toward backend transfer limits. Set response limits to fit your functions; broad defaults can make the tool fail attachment. Model-named files live separately from transport files. Without a registry, `program.py` is reserved.
131
133
 
@@ -27,7 +27,7 @@ tools = make_codeact_tools(
27
27
 
28
28
  The host supplies the router and `CallerContext`. With no configured backend the factory returns `[]`. Unsupported requirements are refused before attachment.
29
29
 
30
- The default path writes `program.py` and runs it with `python3`. It requires `EXEC` and `FILES_IN`. Source text is file content, never part of the command line.
30
+ The default path requests a backend-owned `python-portable-v1` channel, preferring exec. This profile promises Python 3.11+ with `json`, `math`, `re`, `sys` and `types`; it promises no other imports or retained state. The exec channel verifies CPython and owns `program.py`, the interpreter and any host-tool transport. Source text is file content, never part of the command line. See [channel selection and migration](https://github.com/sokolaidev/maf-extensions/blob/main/docs/sandbox/program-channels.md).
31
31
 
32
32
  Use the [Docker sample](https://github.com/sokolaidev/maf-extensions/tree/main/samples/06_docker_codeact), [ACAS sample](https://github.com/sokolaidev/maf-extensions/tree/main/samples/03_acas_codeact) or [WSLC sample](https://github.com/sokolaidev/maf-extensions/tree/main/samples/04_wslc_codeact) for complete applications.
33
33
 
@@ -39,7 +39,7 @@ All four channels below are off by default. The host enables them when building
39
39
  |---|---|
40
40
  | `file_store=store` | A `files` argument selecting caller-visible input files. |
41
41
  | `output_sink=sink`, `outputs=...` | Collection and delivery of named output files. |
42
- | `host_tools=registry` | Calls from guest Python to registered host functions, in exec mode. |
42
+ | `host_tools=registry` | Calls from guest Python to registered host functions through a supporting program channel. |
43
43
  | `egress_allow=(...)` | Network requests to named destinations. |
44
44
 
45
45
  These channels operate during `execute_code`. Hiding its final report does not undo host calls, network requests or artifact delivery.
@@ -76,10 +76,12 @@ tools = make_codeact_tools(
76
76
  )
77
77
  ```
78
78
 
79
- `files_in` and `files_out` bound file count, individual bytes and total bytes. A missing declared output is reported. Artifacts carry no guest-selected media type; the host decides how to handle them.
79
+ `files_in` and `files_out` bound shared-file count, individual bytes and total bytes. Shared inputs default to 8 MiB per file, 24 MiB total and 63 files, leaving room for the default 8 MiB program and its staging file within a 32 MiB/64-file backend ceiling. Explicit limits are preserved and the complete channel demand is checked without clamping; larger programs or host-tool transport traffic can require a backend with larger ceilings. On the automatic channel path, `program=ProgramRequirements(max_program_bytes=8 * 1024 * 1024, host_tool_timeout_seconds=30)` from `maf_sandbox` configures source bytes and callback time separately. A missing declared output is reported. Artifacts carry no guest-selected media type; the host decides how to handle them.
80
80
 
81
81
  Keep the output sink separate from the agent's writable input store. Otherwise guest code could overwrite files through a channel that bypasses the host's file-write approval. See the [files sample](https://github.com/sokolaidev/maf-extensions/tree/main/samples/08_docker_codeact_files).
82
82
 
83
+ With `withhold_guest_output=True`, a sink can supply `locate=lambda name: f".tool-results/execute_code/{name}"` to report a fixed landing folder in the Saved and not-written lists. Have `deliver` write to the same location and return it in `LandedArtifact.name` or `handle`. The pure synchronous locator receives only validated declared names, normalized as the sink requests; it must never depend on guest content or delivery state. A mismatched receipt falls back to the declared name and logs a warning. The default locator preserves existing results, and `per_call` sinks retain their folder-only reporting. See the [withholding contract](https://github.com/sokolaidev/maf-extensions/blob/main/docs/sandbox/kinds/codeact.md#withholding-guest-output) for the information this mode still reveals.
84
+
83
85
  ## Host functions and network access
84
86
 
85
87
  Register host functions before passing the registry to the factory. Reading its combined policy seals it against later registration.
@@ -100,7 +102,7 @@ registry.register(exchange_rate)
100
102
 
101
103
  `exchange_rate` is a host-defined function. Its body runs with host authority and bypasses ordinary agent middleware. Configure declarations, permitted identities and approval policy before exposing it. Registering a user-authority function makes the enclosing tool approval-gated.
102
104
 
103
- A nonempty registry requires `HOST_TOOLS` and `FILES_OUT` as well as `EXEC` and `FILES_IN`. Docker and ACAS support this transport; WSLC does not. The image needs the POSIX launcher utilities, including `sh` and `nohup`.
105
+ A nonempty registry requires `HOST_TOOLS` and an explicit supporting program channel. Docker, ACAS and Docker Sandboxes supply the exec channel, which adds `EXEC`, `FILES_IN` and `FILES_OUT` internally. WSLC supports programs without host tools. The exec image needs the POSIX launcher utilities, including `sh` and `nohup`. Hyperlight native host tools remain pending.
104
106
 
105
107
  Transport traffic counts toward backend transfer limits. Set response limits to fit your functions; broad defaults can make the tool fail attachment. Model-named files live separately from transport files. Without a registry, `program.py` is reserved.
106
108
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "maf-sandbox-codeact"
3
- version = "0.21.4"
3
+ version = "0.22.0"
4
4
  description = "CodeAct as a Microsoft Agent Framework tool — the model writes a short Python program and it runs inside a sandbox — written against maf-sandbox so it runs on any sandbox backend."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12,<3.15"
@@ -24,7 +24,7 @@ classifiers = [
24
24
  "Topic :: Software Development :: Interpreters",
25
25
  ]
26
26
  dependencies = [
27
- "maf-sandbox>=0.46.0,<0.47",
27
+ "maf-sandbox>=0.47.0,<0.48",
28
28
  "agent-framework-core>=1.19.0,<1.20",
29
29
  ]
30
30
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "maf-sandbox-codeact"
3
- version = "0.21.4"
3
+ version = "0.22.0"
4
4
  description = "CodeAct as a Microsoft Agent Framework tool — the model writes a short Python program and it runs inside a sandbox — written against maf-sandbox so it runs on any sandbox backend."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12,<3.15"
@@ -35,7 +35,7 @@ dependencies = [
35
35
  # import, and that one fails at the call, where no import check is looking. The number
36
36
  # itself is below, and `scripts/set_dependents_range.py` moves it — so this says which
37
37
  # release the floor is for, never which release that is.
38
- "maf-sandbox>=0.46.0,<0.47",
38
+ "maf-sandbox>=0.47.0,<0.48",
39
39
  # The `@tool` decorator. MAF is a genuine dependency of the tool itself, not of the host
40
40
  # application. Both ends of the range are measured: 1.19 made an item's embedded label
41
41
  # restrict-only, which the result wrapper now works with rather than against.
@@ -35,8 +35,6 @@ from typing import TYPE_CHECKING, Any, cast
35
35
 
36
36
  from maf_sandbox import (
37
37
  DEFAULT_TRANSFER_LIMITS,
38
- SHIM_MODULE,
39
- WORK_DIRECTORY,
40
38
  CallerContext,
41
39
  Capability,
42
40
  Cleanup,
@@ -50,6 +48,7 @@ from maf_sandbox import (
50
48
  ListedFile,
51
49
  NameNormalization,
52
50
  OutputSink,
51
+ ProgramRequirements,
53
52
  SandboxArtifactNameInvalid,
54
53
  SandboxOutputError,
55
54
  SandboxProgramTimeout,
@@ -61,9 +60,6 @@ from maf_sandbox import (
61
60
  collect_outputs,
62
61
  echoed_name,
63
62
  error_detail,
64
- guest_run_layout,
65
- host_tool_calls_over_exec,
66
- host_tool_shim,
67
63
  validate_artifact_name,
68
64
  )
69
65
  from maf_sandbox.maf import (
@@ -81,10 +77,12 @@ if TYPE_CHECKING:
81
77
 
82
78
  from agent_framework import AgentFileStore
83
79
  from maf_sandbox import (
80
+ Artifact,
84
81
  FileStoreProvenance,
85
82
  HostToolAggregate,
86
83
  HostToolRegistry,
87
84
  LandedArtifact,
85
+ ProgramRequirements,
88
86
  Sandbox,
89
87
  SandboxKey,
90
88
  )
@@ -136,10 +134,10 @@ _SMALLEST_MANIFEST = len(
136
134
  #: time and cannot un-deliver, so "could not be saved" is not the same as "nothing was saved".
137
135
  _MAY_HAVE_LANDED = "Some of them may already have been saved; do not assume none were."
138
136
 
139
- _INTERPRETER = "python3"
140
-
141
137
  #: Eight artifacts is a generous single call and a cap that actually bounds something; the
142
138
  #: byte ceilings are the shared defaults, so only the count is this workload's own opinion.
139
+ # Shared inputs leave room for the default program within a 32 MiB / 64-file backend.
140
+ _DEFAULT_FILES_IN = replace(DEFAULT_TRANSFER_LIMITS, max_total_bytes=24 * 1024 * 1024, max_files=63)
143
141
  _DEFAULT_FILES_OUT = replace(DEFAULT_TRANSFER_LIMITS, max_files=8)
144
142
 
145
143
  #: Writing an expression and expecting a REPL to echo it is the commonest way a first CodeAct
@@ -201,11 +199,12 @@ def codeact_sandbox_spec(
201
199
  image_id: str | None = None,
202
200
  *,
203
201
  outputs: CodeactOutputs = CodeactOutputs.NONE,
204
- files_in: TransferLimits = DEFAULT_TRANSFER_LIMITS,
202
+ files_in: TransferLimits = _DEFAULT_FILES_IN,
205
203
  files_out: TransferLimits = _DEFAULT_FILES_OUT,
206
204
  host_tools: HostToolRegistry | None = None,
207
205
  egress_allow: Sequence[str | EgressRule] = (),
208
206
  runtime: CodeactRuntime | None = None,
207
+ program: ProgramRequirements = ProgramRequirements(),
209
208
  takes_files: bool = False,
210
209
  credential_retention_seconds: int | None = None,
211
210
  ) -> SandboxSpec:
@@ -269,6 +268,7 @@ def codeact_sandbox_spec(
269
268
  surface=_host_tools(host_tools),
270
269
  egress_allow=egress_allow,
271
270
  runtime=runtime,
271
+ program=program,
272
272
  takes_files=takes_files,
273
273
  credential_retention_seconds=credential_retention_seconds,
274
274
  )
@@ -288,10 +288,11 @@ def make_codeact_tools(
288
288
  image: str | None = None,
289
289
  image_id: str | None = None,
290
290
  exec_timeout_seconds: int = 120,
291
- files_in: TransferLimits = DEFAULT_TRANSFER_LIMITS,
291
+ files_in: TransferLimits = _DEFAULT_FILES_IN,
292
292
  files_out: TransferLimits = _DEFAULT_FILES_OUT,
293
293
  egress_allow: Sequence[str | EgressRule] = (),
294
294
  runtime: CodeactRuntime | None = None,
295
+ program: ProgramRequirements = ProgramRequirements(),
295
296
  credential_retention_seconds: int | None = None,
296
297
  file_store_provenance: FileStoreProvenance | None = None,
297
298
  requires_file_integrity: SourceIntegrity | None = None,
@@ -323,7 +324,7 @@ def make_codeact_tools(
323
324
  :data:`CodeactOutputs.NONE`, and refused at attach without one.
324
325
  outputs: How a program's output files are named. See :class:`CodeactOutputs`.
325
326
  withhold_guest_output: Keep what the program printed out of the tool result, and answer
326
- with whether it exited cleanly and the model's own declared names instead. No
327
+ with whether it exited cleanly and the declared names or their sink locations. No
327
328
  guest-authored text survives into the result — but the values that replace it were
328
329
  still chosen by a program the model wrote. The workload therefore claims
329
330
  ``SourceIntegrity.UNTRUSTED`` for its derived report in both modes. The result
@@ -358,8 +359,9 @@ def make_codeact_tools(
358
359
  ``docs/sandbox/information-flow.md``.
359
360
 
360
361
  **What withholding gets you, exactly.** The prose and the shape are this package's,
361
- and the artifact names are the model's own — but what fills them is the program's
362
- to choose, and it is a channel rather than a leak-free boundary. Whether the program
362
+ and artifact locations depend only on declared names and host configuration — but
363
+ what fills them is the program's to choose, and it is a channel rather than a
364
+ leak-free boundary. Whether the program
363
365
  exited cleanly is one bit and whether it finished in time is another; **each
364
366
  declared output is one further bit**, since the program decides whether to write it
365
367
  and the result says of every declared name whether it landed — up to
@@ -453,36 +455,8 @@ def make_codeact_tools(
453
455
  f"a non-empty host_tools registry makes it the guest's patience as well as the "
454
456
  f"run's bound, so it must be a finite positive number of seconds."
455
457
  )
456
- # Generated here, so the module the checks below measure is the one every call writes.
457
- # Its patience is the run's own bound: give up first and a program is told the host
458
- # never answered while the host-tool call it asked for goes on to act.
459
- host_tool_call = _HostToolCall(
460
- host_tools, host_tool_shim(host_tools.names(), call_timeout=exec_timeout_seconds)
461
- )
462
- if configured and runtime is None and files_in.max_files < 1:
463
- # `program.py` is one inbound file on every call, so a cap below one refuses all of
464
- # them. Every impossible pairing below is caught here rather than per call: a tool the
465
- # model can see and can never use successfully is worse than one that never attached.
466
- raise ValueError(
467
- f"{EXECUTE_CODE_TOOL_NAME}: files_in.max_files is {files_in.max_files}, and the "
468
- f"program itself is one file written into the sandbox on every call, so no call "
469
- f"could succeed."
470
- )
471
- if host_tool_call is not None:
472
- if files_in.max_files < 2:
473
- raise ValueError(
474
- f"{EXECUTE_CODE_TOOL_NAME}: files_in.max_files is {files_in.max_files}, and a "
475
- f"non-empty host_tools registry puts the guest's host-tool module beside the "
476
- f"program on every call, so no call could succeed."
477
- )
478
- crossing = len(host_tool_call.shim.encode())
479
- room = min(files_in.max_bytes_per_file, files_in.max_total_bytes)
480
- if crossing > room:
481
- raise ValueError(
482
- f"{EXECUTE_CODE_TOOL_NAME}: the guest's host-tool module is {crossing} bytes and "
483
- f"crosses beside the program on every call, and this host's files_in allows "
484
- f"{room}, so no call could succeed."
485
- )
458
+ host_tool_call = _HostToolCall(host_tools)
459
+
486
460
  if configured and outputs is CodeactOutputs.DECLARED and files_out.max_files < 1:
487
461
  raise ValueError(
488
462
  f"{EXECUTE_CODE_TOOL_NAME}: outputs={str(CodeactOutputs.DECLARED)!r} shows the "
@@ -544,6 +518,7 @@ def make_codeact_tools(
544
518
  surface=surface,
545
519
  egress_allow=egress_allow if configured else (),
546
520
  runtime=runtime if configured else None,
521
+ program=program if configured else ProgramRequirements(),
547
522
  takes_files=file_store is not None,
548
523
  credential_retention_seconds=credential_retention_seconds if configured else None,
549
524
  )
@@ -694,24 +669,24 @@ def _codeact_spec(
694
669
  surface: HostToolAggregate | None,
695
670
  egress_allow: Sequence[str | EgressRule] = (),
696
671
  runtime: CodeactRuntime | None = None,
672
+ program: ProgramRequirements = ProgramRequirements(),
697
673
  takes_files: bool = False,
698
674
  credential_retention_seconds: int | None = None,
699
675
  ) -> SandboxSpec:
700
676
  """:func:`codeact_sandbox_spec`, over a host-tool surface the caller has already derived."""
701
677
  _validate_runtime(runtime, takes_files=takes_files, outputs=outputs)
678
+ if runtime is None and program.profile != "python-portable-v1":
679
+ raise ValueError("CodeAct requires the python-portable-v1 execution profile")
702
680
  if runtime is not None and surface is not None:
703
681
  raise ValueError("CodeAct runtime host tools require a native channel; not supported")
704
682
  collects = outputs is not CodeactOutputs.NONE
705
- requires = {Capability.EXEC, Capability.FILES_IN} if runtime is None else {Capability.RUN_CODE}
683
+ requires: set[Capability] = set() if runtime is None else {Capability.RUN_CODE}
706
684
  if takes_files:
707
685
  requires.add(Capability.FILES_IN)
708
686
  if collects:
709
687
  requires.add(Capability.FILES_OUT)
710
688
  if surface is not None:
711
- # FILES_OUT for the transport rather than for this kind's outputs: a host-tool call stats
712
- # and reads its request files and the exit marker back over the pull surface, so even a
713
- # stdout-only program that can call a host function needs one.
714
- requires |= {Capability.HOST_TOOLS, Capability.FILES_OUT}
689
+ requires.add(Capability.HOST_TOOLS)
715
690
  # CodeAct runs model-written code, so it accepts only two postures and never UNRESTRICTED:
716
691
  # CLOSED to compute offline, ALLOWLIST to reach the deployment's named sources. The mode is
717
692
  # derived from whether any host was named — an allowlist with hosts is ALLOWLIST, an empty
@@ -738,7 +713,10 @@ def _codeact_spec(
738
713
  max_identity_scope=IdentityScope.PER_SANDBOX if authority else None,
739
714
  max_identity_retention_seconds=credential_retention_seconds,
740
715
  work_dir=runtime.guest_work_dir if runtime is not None else None,
741
- execution_contract=runtime_contract(runtime),
716
+ execution_contract=runtime_contract(runtime)
717
+ if runtime is not None
718
+ else f"codeact:{program.profile}",
719
+ program=program if runtime is None else None,
742
720
  # Model-written code can write outside the call path and leave processes running.
743
721
  confined_to_guest_call_path=False,
744
722
  # And read whatever a sibling call put in the sandbox, so calls run one at a time.
@@ -873,8 +851,7 @@ _DESCRIPTION_HOST_TOOLS = """**To call a host tool, ``import maf_host_tools`` an
873
851
  The tools you may call this way are: {names}. A refusal raises
874
852
  ``maf_host_tools.HostToolError``, whose message says why."""
875
853
 
876
- _DESCRIPTION_ARG_CODE = """code: The Python source to run. The standard library, plus
877
- whatever the sandbox image ships."""
854
+ _DESCRIPTION_ARG_CODE = """code: The Python source to run using the profile described above."""
878
855
 
879
856
  _DESCRIPTION_ARG_FILES = """files: Store-relative paths to share into the sandbox, or
880
857
  omit for none. Only files in your file store listing can be shared."""
@@ -935,6 +912,19 @@ _DESCRIPTION_IN_PLACE_WITHHELD = """Naming a file in both ``files`` and ``output
935
912
 
936
913
  _DESCRIPTION_RETURNS_SAVED_WITHHELD = """ A call that saved files also names each one."""
937
914
 
915
+ _DESCRIPTION_DECLARED_WITHHELD_LOCATED = """**To produce files, name them in ``outputs`` and
916
+ write them into the working directory.** They are saved to host storage after the
917
+ program exits. The result names their host-configured landing paths when delivery
918
+ confirms them, otherwise their declared names. A name you declare and do not write
919
+ is reported at its configured landing path. A file you write without declaring is
920
+ not saved. **A program that fails still saves what it wrote.** Saved files have not
921
+ been read back; do not claim to know their contents from this result."""
922
+
923
+ _DESCRIPTION_IN_PLACE_WITHHELD_LOCATED = """Naming a file in both ``files`` and ``outputs``
924
+ edits the supplied copy; the saved copy goes to the sink's configured location.
925
+ The supplied copy is already present, so it cannot be reported as not written.
926
+ A failed program still saves whatever it had written by then."""
927
+
938
928
  #: The withholding pair again, for a host whose sink lands each call under a folder of its own.
939
929
  #: Two promises above stop being true: nothing names which files landed, so nothing reports a
940
930
  #: name declared and not written either. It names the folder without promising a reader for it,
@@ -966,6 +956,7 @@ def _tool_description(
966
956
  egress_allow: Sequence[str | EgressRule] = (),
967
957
  withhold: bool,
968
958
  lands_per_call: bool = False,
959
+ locates_outputs: bool = False,
969
960
  runtime: CodeactRuntime | None = None,
970
961
  ) -> str:
971
962
  """The description the model reads, for the channels this host actually wired.
@@ -992,7 +983,19 @@ def _tool_description(
992
983
  "*this* call is in it.",
993
984
  "Use only the runtime facilities described below.",
994
985
  )
986
+ else:
987
+ head = head.replace("as ``python3 program.py``", "with the ``python-portable-v1`` profile")
988
+ head = head.replace(
989
+ "Each call gets a fresh working\n directory: nothing you did not pass in to "
990
+ "*this* call is in it.",
991
+ "Do not rely on state from earlier calls.",
992
+ )
995
993
  body = [head.format(network=network)]
994
+ if runtime is None:
995
+ body.append(
996
+ "Python 3.11 or newer is available with ``json``, ``math``, ``re``, ``sys`` and "
997
+ "``types``. Other standard-library modules and third-party packages are not promised."
998
+ )
996
999
  if runtime is not None:
997
1000
  if runtime.guest_work_dir is not None:
998
1001
  body.append(
@@ -1027,8 +1030,18 @@ def _tool_description(
1027
1030
  body.append(_DESCRIPTION_DECLARED_WITHHELD_PER_CALL)
1028
1031
  in_place = _DESCRIPTION_IN_PLACE_WITHHELD_PER_CALL
1029
1032
  else:
1030
- body.append(_DESCRIPTION_DECLARED_WITHHELD)
1031
- in_place = _DESCRIPTION_IN_PLACE_WITHHELD
1033
+ body.append(
1034
+ _DESCRIPTION_DECLARED_WITHHELD_LOCATED
1035
+ if locates_outputs
1036
+ else _DESCRIPTION_DECLARED_WITHHELD
1037
+ )
1038
+ in_place = (
1039
+ _DESCRIPTION_IN_PLACE_WITHHELD_LOCATED
1040
+ if locates_outputs
1041
+ else _DESCRIPTION_IN_PLACE_WITHHELD
1042
+ )
1043
+ if withhold and lands_per_call and locates_outputs:
1044
+ in_place = _DESCRIPTION_IN_PLACE_WITHHELD_LOCATED
1032
1045
  if takes_files:
1033
1046
  body.append(in_place)
1034
1047
  arguments.append(_DESCRIPTION_ARG_OUTPUTS)
@@ -1083,14 +1096,9 @@ def _tool_description(
1083
1096
 
1084
1097
  @dataclass(frozen=True)
1085
1098
  class _HostToolCall:
1086
- """What one attached tool serves host-tool calls with: the registry, and the guest module.
1087
-
1088
- Generated once and written into every run, which the registry's sealing is what makes
1089
- honest: the names it spells cannot change after the factory has read them.
1090
- """
1099
+ """The sealed registry from which each program receives a fresh policy run."""
1091
1100
 
1092
1101
  registry: HostToolRegistry
1093
- shim: str
1094
1102
 
1095
1103
 
1096
1104
  def _execute_code_tool(
@@ -1167,6 +1175,9 @@ def _execute_code_tool(
1167
1175
  egress_allow=session.spec.egress_allow,
1168
1176
  withhold=withhold,
1169
1177
  lands_per_call=lands_per_call,
1178
+ locates_outputs=(
1179
+ session.output_sink is not None and session.output_sink.locate is not OutputSink.locate
1180
+ ),
1170
1181
  runtime=runtime,
1171
1182
  )
1172
1183
  return body
@@ -1227,9 +1238,7 @@ async def _execute(
1227
1238
  # writes. One sentence for both would be false about one of them.
1228
1239
  reserved: dict[str, str] = {}
1229
1240
  if host_tool_call is None and runtime is None:
1230
- reserved[_PROGRAM_FILENAME] = (
1231
- "this tool writes a file of that name into every call's directory"
1232
- )
1241
+ reserved[_PROGRAM_FILENAME] = "this name is reserved for program execution"
1233
1242
  if outputs is CodeactOutputs.MANIFEST:
1234
1243
  reserved[_MANIFEST_FILENAME] = (
1235
1244
  "this tool reads a file of that name from every call's directory as its manifest"
@@ -1248,12 +1257,21 @@ async def _execute(
1248
1257
  # from, and what is collected out — so the three cannot disagree about one call's layout.
1249
1258
  # It is longer when calling a host tool, which is why the name checks below take it rather than
1250
1259
  # `call_id`: five more bytes of the 255 a guest path gets, spent before the name is.
1251
- guest_prefix = f"{call_id}/{WORK_DIRECTORY}" if host_tool_call is not None else call_id
1260
+ guest_prefix = call_id
1252
1261
  if runtime is not None and not runtime.use_call_directory:
1253
1262
  guest_prefix = ""
1254
1263
 
1255
1264
  names: list[str] = []
1256
1265
  if outputs is CodeactOutputs.DECLARED:
1266
+ if runtime is None:
1267
+ prospective = session.prospective_program_channel(key)
1268
+ if prospective is not None:
1269
+ guest_prefix = _channel_output_prefix(
1270
+ call_directory,
1271
+ prospective.guest_working_directory(
1272
+ call_directory, host_tools=host_tool_call is not None
1273
+ ),
1274
+ )
1257
1275
  checked = _validated_output_names(
1258
1276
  declared,
1259
1277
  max_files=session.spec.files_out.max_files,
@@ -1268,35 +1286,26 @@ async def _execute(
1268
1286
  return _stopped("Error: the outputs argument is invalid.", detail=checked)
1269
1287
  names = checked
1270
1288
 
1271
- # Cap before acquiring anything, and cap *as we go*: a bound that answers only once
1272
- # everything is in memory has already spent what it exists to bound. Every check below
1273
- # therefore happens before the read it would have prevented — the count before the listing,
1274
- # the program's own bytes before the store is touched at all, and each file's as it arrives.
1275
- # The tally covers the submitted program, an exec shim where wired, and each shared file.
1276
- # The transport's launcher and responses have their own limits.
1277
1289
  limits = session.spec.files_in
1278
1290
  tally = _InboundTally(limits)
1279
1291
  shared: list[tuple[str, str, str]] = []
1280
- inbound = len(files) + (0 if runtime is not None else 2 if host_tool_call is not None else 1)
1281
1292
  program = code
1282
1293
  if runtime is not None:
1283
1294
  refusal = _InboundTally(limits).add("", code, named="the program", program=True)
1284
1295
  if refusal is not None:
1285
1296
  return _stopped("Error: the program could not be staged.", detail=refusal)
1286
1297
  program = runtime_program(runtime, code, call_directory)
1287
- over_cap = _over_file_count(
1288
- inbound,
1289
- limits,
1290
- calls_host_tool=host_tool_call is not None,
1291
- program_is_file=runtime is None,
1292
- ) or tally.add(
1293
- _PROGRAM_FILENAME,
1294
- program,
1295
- named="the program" if runtime is not None else None,
1296
- program=runtime is not None,
1297
- )
1298
- if over_cap is None and host_tool_call is not None:
1299
- over_cap = tally.add(SHIM_MODULE, host_tool_call.shim)
1298
+ refusal = tally.add("", program, named="the program", program=True)
1299
+ if refusal is not None:
1300
+ return _stopped("Error: the program could not be staged.", detail=refusal)
1301
+ else:
1302
+ try:
1303
+ program_bytes = len(code.encode("utf-8"))
1304
+ except UnicodeEncodeError:
1305
+ return _stopped("Error: the program is not valid UTF-8.")
1306
+ if session.spec.program is None or program_bytes > session.spec.program.max_program_bytes:
1307
+ return _stopped("Error: the program exceeds its declared byte limit.")
1308
+ over_cap = _over_file_count(len(files), limits, calls_host_tool=False, program_is_file=False)
1300
1309
  if over_cap is not None:
1301
1310
  return _stopped("Error: the call's inputs could not be staged.", detail=over_cap)
1302
1311
  if store is not None:
@@ -1317,18 +1326,28 @@ async def _execute(
1317
1326
  if isinstance(sandbox, str):
1318
1327
  return _stopped("Error: the sandbox could not be acquired.", detail=sandbox)
1319
1328
 
1320
- # The session owns this path, and `sandboxed_tool` cleans the sandbox when the call returns.
1321
- # Built before anything is written, because it decides where everything goes. A call that
1322
- # calls a host tool is two directories — the model's files in `work`, the program and the shim
1323
- # in the transport's — and one that does not is the call directory flat, which is what a kind
1324
- # writing no shim has always been. The program name and the interpreter are passed rather
1325
- # than defaulted, so this kind's constants and the transport's cannot drift apart.
1326
- layout = (
1327
- guest_run_layout(call_directory, program=_PROGRAM_FILENAME)
1328
- if host_tool_call is not None
1329
- else None
1329
+ channel = session.program_channel(key, sandbox) if runtime is None else None
1330
+ shared_dir = (
1331
+ channel.guest_working_directory(call_directory, host_tools=host_tool_call is not None)
1332
+ if channel is not None
1333
+ else call_directory
1330
1334
  )
1331
- shared_dir = layout.work if layout is not None else call_directory
1335
+ if channel is not None:
1336
+ guest_prefix = _channel_output_prefix(call_directory, shared_dir)
1337
+ if runtime is None and outputs is CodeactOutputs.DECLARED:
1338
+ checked = _validated_output_names(
1339
+ declared,
1340
+ max_files=session.spec.files_out.max_files,
1341
+ reserved=reserved,
1342
+ guest_prefix=guest_prefix,
1343
+ normalization=_normalization(session),
1344
+ named_by=_OUTPUTS_ARGUMENT,
1345
+ argument=_OUTPUTS_ARGUMENT,
1346
+ candidates=rewritten,
1347
+ )
1348
+ if isinstance(checked, str):
1349
+ return _stopped("Error: the outputs argument is invalid.", detail=checked)
1350
+ names = checked
1332
1351
 
1333
1352
  for name, named, content in shared:
1334
1353
  refusal = await _write_shared(
@@ -1339,45 +1358,20 @@ async def _execute(
1339
1358
  "Error: an input file could not be written into the sandbox.", detail=refusal
1340
1359
  )
1341
1360
 
1342
- program_path = layout.program if layout is not None else f"{call_directory}/{_PROGRAM_FILENAME}"
1343
1361
  try:
1344
- if runtime is None:
1345
- await sandbox.write_file(
1346
- posixpath.relpath(
1347
- program_path, layout.directory if layout is not None else call_directory
1348
- ),
1349
- code,
1350
- working_directory=layout.directory if layout is not None else call_directory,
1351
- )
1352
- except Exception as exc: # noqa: BLE001
1353
- logger.warning(
1354
- "execute_code: could not write the program into the sandbox: %s", error_detail(exc)
1355
- )
1356
- return _stopped("Error: could not write the program into the sandbox")
1357
-
1358
- try:
1359
- # The two are built together above and are never one without the other; both are named
1360
- # here so neither has to be narrowed from the other.
1361
1362
  if runtime is not None:
1362
1363
  result = await sandbox.run_code(program, timeout=timeout)
1363
- elif host_tool_call is not None and layout is not None:
1364
- await sandbox.write_file(
1365
- posixpath.relpath(layout.shim, layout.directory),
1366
- host_tool_call.shim,
1367
- working_directory=layout.directory,
1368
- )
1369
- # A fresh run per call: the host-tool-call cap and the ledger bound one program.
1370
- result = await host_tool_calls_over_exec(
1364
+ else:
1365
+ assert channel is not None and session.spec.program is not None
1366
+ result = await channel.run(
1371
1367
  sandbox,
1372
- HostToolRun(host_tool_call.registry, logger=logger, key=key),
1373
- layout,
1368
+ code,
1369
+ requirements=session.spec.program,
1370
+ guest_call_path=call_directory,
1374
1371
  timeout=timeout,
1375
- interpreter=_INTERPRETER,
1376
- )
1377
- else:
1378
- # An argv sequence, never a command line: the model's source never reaches a shell.
1379
- result = await sandbox.exec(
1380
- [_INTERPRETER, _PROGRAM_FILENAME], working_directory=call_directory, timeout=timeout
1372
+ policy=HostToolRun(host_tool_call.registry, logger=logger, key=key)
1373
+ if host_tool_call
1374
+ else None,
1381
1375
  )
1382
1376
  except SandboxQueuedTimeout:
1383
1377
  logger.warning("execute_code: the deadline expired before the queued program started")
@@ -1740,6 +1734,13 @@ async def _write_shared(
1740
1734
  # --- Files out -----------------------------------------------------------------------------
1741
1735
 
1742
1736
 
1737
+ def _channel_output_prefix(guest_call_path: str, guest_working_directory: str) -> str:
1738
+ call_id = guest_call_path.rsplit("/", 1)[-1]
1739
+ return posixpath.normpath(
1740
+ f"{call_id}/{posixpath.relpath(guest_working_directory, guest_call_path)}"
1741
+ )
1742
+
1743
+
1743
1744
  def _normalization(session: SandboxToolSession) -> NameNormalization:
1744
1745
  """What the attached sink does to a name, which decides the spelling to judge."""
1745
1746
  sink = session.output_sink
@@ -1877,7 +1878,11 @@ async def _collect(
1877
1878
  DeclaredOutput(path=posixpath.join(guest_prefix, name), name=name, required=False)
1878
1879
  for name in declared
1879
1880
  )
1881
+ locations: dict[str, str] = {}
1882
+ delivered_names: list[str] | None = None
1880
1883
  try:
1884
+ if withhold and not sink.per_call and sink.locate is not OutputSink.locate:
1885
+ sink, locations, delivered_names = _located_sink(sink, declared)
1881
1886
  landed = await collect_outputs(
1882
1887
  sandbox,
1883
1888
  spec,
@@ -1911,9 +1916,40 @@ async def _collect(
1911
1916
  named_by=_MANIFEST_FILENAME if outputs is CodeactOutputs.MANIFEST else _OUTPUTS_ARGUMENT,
1912
1917
  argument=None if outputs is CodeactOutputs.MANIFEST else _OUTPUTS_ARGUMENT,
1913
1918
  candidates=candidates,
1919
+ locations=locations,
1920
+ delivered_names=delivered_names,
1914
1921
  )
1915
1922
 
1916
1923
 
1924
+ def _located_sink(
1925
+ sink: OutputSink, declared: Sequence[str]
1926
+ ) -> tuple[OutputSink, dict[str, str], list[str]]:
1927
+ """Calculate locations before reading guest bytes, then check each delivery receipt."""
1928
+ locations = {
1929
+ name: sink.locate(
1930
+ unicodedata.normalize("NFC", name)
1931
+ if sink.normalization is NameNormalization.NFC
1932
+ else name
1933
+ )
1934
+ for name in declared
1935
+ }
1936
+ originals = {unicodedata.normalize("NFC", name): name for name in declared}
1937
+ delivered_names: list[str] = []
1938
+
1939
+ async def deliver(artifact: Artifact) -> LandedArtifact:
1940
+ accepted = await sink.deliver(artifact)
1941
+ name = originals[unicodedata.normalize("NFC", artifact.name)]
1942
+ if locations[name] not in (accepted.name, accepted.handle):
1943
+ logger.warning(
1944
+ "execute_code: output sink locate disagrees with delivery; using declared name"
1945
+ )
1946
+ locations[name] = name
1947
+ delivered_names.append(artifact.name)
1948
+ return accepted
1949
+
1950
+ return replace(sink, deliver=deliver), locations, delivered_names
1951
+
1952
+
1917
1953
  async def _read_manifest(
1918
1954
  session: SandboxToolSession, sandbox: Sandbox, guest_prefix: str
1919
1955
  ) -> tuple[list[str], int] | str:
@@ -2000,6 +2036,8 @@ def _format_landed(
2000
2036
  named_by: str = _OUTPUTS_ARGUMENT,
2001
2037
  argument: str | None = None,
2002
2038
  candidates: frozenset[str] | None = None,
2039
+ locations: Mapping[str, str] | None = None,
2040
+ delivered_names: Sequence[str] | None = None,
2003
2041
  ) -> str:
2004
2042
  """What the model is told about the files: what landed, and what is absent.
2005
2043
 
@@ -2015,14 +2053,20 @@ def _format_landed(
2015
2053
  before it: without it a declared name equal to hidden content renders as a position, and a
2016
2054
  caller watching which way its own spelling comes back learns that the guess was right.
2017
2055
 
2018
- ``withhold`` drops ``display`` in favour of the name the model itself declared. The sink
2056
+ ``withhold`` drops ``display`` in favour of the declared name or its checked location. The sink
2019
2057
  composes ``display`` from an :class:`~maf_sandbox.Artifact` whose ``content`` is the guest's
2020
2058
  bytes, and nothing in the protocol requires the two to be independent — so a sink that puts
2021
2059
  any of that content in the string would be putting guest-authored text back into a result
2022
- rendered to hold none. Naming the declared spelling costs the sink's own detail and needs no
2023
- promise from the host to stay honest.
2060
+ rendered to hold none. Locations are calculated from validated declarations before delivery;
2061
+ substituted declarations still render only as argument positions.
2024
2062
  """
2025
- delivered = {unicodedata.normalize("NFC", item.name) for item in landed}
2063
+ delivered = {
2064
+ unicodedata.normalize("NFC", name)
2065
+ for name in (
2066
+ delivered_names if delivered_names is not None else [item.name for item in landed]
2067
+ )
2068
+ }
2069
+ locations = locations or {}
2026
2070
  lines: list[str] = []
2027
2071
  # Answered once, above the two renderings that need it: a name that landed is reported as
2028
2072
  # surely as one that did not, and a name the framework substituted must not be repeated
@@ -2039,10 +2083,11 @@ def _format_landed(
2039
2083
  if withhold:
2040
2084
  for position, name in enumerate(declared):
2041
2085
  if unicodedata.normalize("NFC", name) in delivered:
2042
- # The name itself unless the framework put it there: this is a list of
2043
- # files, not a refusal, so a name of the model's own is what it wants back.
2086
+ # Only model-authored declarations may reveal their location.
2044
2087
  lines.append(
2045
- f"- {named_by}[{position}]" if position in rewritten else f"- {name}"
2088
+ f"- {named_by}[{position}]"
2089
+ if position in rewritten
2090
+ else f"- {locations.get(name, name)}"
2046
2091
  )
2047
2092
  else:
2048
2093
  for item in landed:
@@ -2058,7 +2103,11 @@ def _format_landed(
2058
2103
  # spelling where it is the caller's, and the position where the framework put something
2059
2104
  # else there. It is the one line here that names a file which does not exist.
2060
2105
  missing = [
2061
- echoed_name(name, at=f"{named_by}[{position}]", hidden=position in rewritten)
2106
+ echoed_name(
2107
+ locations.get(name, name),
2108
+ at=f"{named_by}[{position}]",
2109
+ hidden=position in rewritten,
2110
+ )
2062
2111
  for position, name in enumerate(declared)
2063
2112
  if unicodedata.normalize("NFC", name) not in delivered
2064
2113
  ]