Run a polyglot task
Define a non-TypeScript script — a Python program, a shell script, a .NET tool, or any
executable — as a NetScript task, give it a permission sandbox, and run it through the
multi-runtime executor with its stdout/stderr captured and its result parsed.
alpha
This is the task-focused DO companion to the polyglot tasks
hub — read that first for the WHY (what a task is, how the subprocess seam works, the full
TaskResult shape). This page wires one up.
Prerequisites
| Name | Type | Description |
|---|---|---|
@netscript/plugin-workers-core |
package (alpha) |
Provides defineTask (./builders) and createDefaultTaskExecutor (./executor). |
The target toolchain |
host binary |
The interpreter the task spawns must exist on the worker HOST: python3 (or a venv / py on Windows), bash, pwsh/powershell, the .NET SDK, or your prebuilt binary. |
An entrypoint script |
file path |
The script or executable to run, e.g. ./scripts/score.py. The task passes it input as argv, env, or a bounded stdin payload. |
(deno tasks only) a permission set |
BuilderPermissions |
net/read/write/env/run/ffi/import. Enforced ONLY for the deno runtime — see the sandbox note below. |
Steps
1. Define the task
defineTask(id) returns a typestate builder; the default runtime is 'deno', so call
.runtime(type) to target another language. Input reaches the script as argv
(.args(...)) and environment variables (.env({...})). .build() is only callable
once an .entrypoint(path) (subprocess) or .handler(fn) (in-process) has been set.
// workers/tasks/score-batch.ts
import { defineTask } from '@netscript/plugin-workers-core/builders';
// python runtime, script entrypoint, explicit inputs.
export const scoreBatch = defineTask('score-batch')
.runtime('python')
.entrypoint('./scripts/score.py')
.env({ MODEL_PATH: './models/scorer.pkl' })
.args('--threshold', '0.8')
.timeout(120_000) // ms; defaults to 300_000
.build();
// Spawns: python3 -u ./scripts/score.py --threshold 0.8
// workers/tasks/rotate-logs.ts
import { defineTask } from '@netscript/plugin-workers-core/builders';
// shell runtime runs the entrypoint under bash (no -c).
export const rotateLogs = defineTask('rotate-logs')
.runtime('shell')
.entrypoint('./scripts/rotate-logs.sh')
.args('--keep', '7')
.timeout(30_000)
.build();
// Spawns: bash ./scripts/rotate-logs.sh --keep 7
// workers/tasks/score-batch.ts — prefer a pinned venv over $PATH discovery
import { defineTask } from '@netscript/plugin-workers-core/builders';
export const scoreBatch = defineTask('score-batch')
.runtime('python')
.entrypoint('./scripts/score.py')
// Resolution order: pythonConfig.pythonPath -> venvPath -> NETSCRIPT_PYTHON_PATH -> python3/py
.metadata({ pythonConfig: { venvPath: './.venv' } })
.build();
2. Write the script so its result crosses the process boundary
The subprocess returns structured data by printing one JSON object as the last line of
stdout. Everything else on stdout/stderr is captured as logs. Print diagnostics to
stderr; emit the JSON result last, with no trailing output.
# scripts/score.py
import json, os, sys
# Input arrives as argv + env in this example.
threshold = float(sys.argv[sys.argv.index('--threshold') + 1])
model_path = os.environ['MODEL_PATH']
# ... do the work ...
scored = {'kept': 42, 'dropped': 3, 'threshold': threshold}
# Diagnostics go to stderr; the result is the LAST stdout line and must be a
# single JSON OBJECT (not an array) to populate result.result.
print('scoring complete', file=sys.stderr)
print(json.dumps(scored))
#!/usr/bin/env bash
# scripts/rotate-logs.sh
set -euo pipefail
keep="${2:-7}"
# ... rotate ...
echo "rotated logs, keeping ${keep}" >&2 # diagnostics -> stderr
# Last stdout line = JSON object result. Non-python runtimes are not -u'd, so
# flush by emitting the JSON as the final write with nothing after it.
printf '{"rotated": true, "kept": %s}\n' "$keep"
2a. Send private input on stdin
Use .stdin(bytesOrJson) for a fixed task payload or options.stdin for input supplied at
execution time. The execution option replaces the builder payload. JSON is encoded as UTF-8;
Uint8Array values are sent unchanged. The runner writes the payload with backpressure and closes
stdin, so the script can read to EOF. Absent input uses null stdin and never inherits input from the
worker host. An explicitly supplied undefined payload fails with MissingStdinPayload.
import { defineTask } from '@netscript/plugin-workers-core/builders';
import { createDefaultTaskExecutor } from '@netscript/plugin-workers-core/executor';
const task = defineTask('private-input')
.runtime('python')
.entrypoint('./scripts/private-input.py')
.stdin({ uid: 'service-operator', roots: ['workspace-root'] })
.build();
const executor = createDefaultTaskExecutor();
const result = await executor.execute(task, {
stdin: { uid: 'another-operator', roots: ['another-root'] },
stdoutLimitBytes: 65_536,
stderrLimitBytes: 65_536,
});
console.log(result.success);
# scripts/private-input.py
import json, sys
payload = json.load(sys.stdin)
# Use private input without putting it in argv, env, or diagnostics.
print(json.dumps({'accepted': len(payload['roots'])}))
Stdin is capped at 1 MiB of encoded bytes, including JSON punctuation and escaping. Builder
input is validated and snapshotted when .stdin() is called; each .build() receives its own
byte buffer, which the adapter passes directly to the runner. Execution input is validated before
spawning. Oversized input produces StdinPayloadTooLarge. JSON accepts finite numbers, strings,
booleans, null, arrays, and plain objects with at most 64 levels of nesting; cycles, accessors, and
unsupported values produce InvalidStdinPayload. A subprocess that closes stdin before accepting
the payload produces StdinWriteFailed.
By default, stdout and stderr each retain only their last 1 MiB while continuing to read and
stream all output. Healthy tasks can print more than 1 MiB without failing; a final stdout JSON
object that fits in the retained tail remains available. Lines longer than 1 MiB of characters are delivered
to log callbacks in bounded fragments. Set positive integer stdoutLimitBytes / stderrLimitBytes
execution options, or configure them on
new ExecutableRuntimeAdapter({ stdoutLimitBytes, stderrLimitBytes }), to opt into a total output
cap. Exceeding an explicit cap terminates the process tree and returns status: 'failed',
success: false, and StdoutLimitExceeded or StderrLimitExceeded; explicit caps also apply with
streamLogs: false. Runtime validation and write errors return failed task results; builder
validation throws before building the task. Running aborts return cancelled, and timeouts return
timeout, both with exit code -1. Subprocesses inherit the worker's process group so terminal and supervisor
signals also reach active tasks.
Builder .stdin() applies only to direct executor calls. KV task registration validates through a
schema that strips this runtime field, and queue dispatch does not carry it. Supply private
per-execution input in options.stdin when calling the executor in a worker. Stdin is not a CLI
stdin flag. Scripts control their own stdout/stderr: do not print private payloads.
3. Run it through the workers CLI
ns-workers run-task resolves the generated task metadata and delegates to the same
MultiRuntimeTaskExecutor used by the worker runtime. Pass argv and environment as JSON; stdout and
stderr stream while the subprocess runs, followed by the complete TaskResult.
ns-workers run-task score-batch \
--args='["--threshold","0.8"]' \
--env='{"MODEL_PATH":"./models/scorer.pkl"}' \
--timeout=120000 \
--json
The programmatic createDefaultTaskExecutor() API remains available for runtime integrations and
custom adapters; ordinary task execution no longer needs a hand-written runner module.
4. Sandbox a deno task with explicit permissions
The deno runtime is the only one NetScript sandboxes: the .permissions({...}) set is
translated into --allow-* flags on the deno run command line. For python, shell,
powershell, cmd, dotnet, and executable, those keys are ignored — the subprocess
inherits the worker process's OS-level access. Gate those at the OS layer instead.
// workers/tasks/parse-feed.ts — a sandboxed deno task (least privilege)
import { defineTask } from '@netscript/plugin-workers-core/builders';
export const parseFeed = defineTask('parse-feed')
.runtime('deno') // the default; shown here for clarity
.entrypoint('./scripts/parse-feed.ts')
.permissions({
net: ['api.example.com'], // -> --allow-net=api.example.com
read: ['./feeds'], // -> --allow-read=./feeds
write: false,
env: ['FEED_TOKEN'], // -> --allow-env=FEED_TOKEN
})
.build();
// Spawns: deno run --allow-net=api.example.com --allow-read=./feeds --allow-env=FEED_TOKEN ./scripts/parse-feed.ts