diff --git a/components/execd/tests/init_container.sh b/components/execd/tests/init_container.sh
index 626fd2d54..3b83405d3 100755
--- a/components/execd/tests/init_container.sh
+++ b/components/execd/tests/init_container.sh
@@ -24,6 +24,8 @@
# (JUPYTER_TOKEN) while execd's credential (EXECD_ACCESS_TOKEN) is
# stripped by the launcher
# - on a non-PID-1 topology execd degrades to subreaper and says so
+# - while preStart is blocked, /ping stays ready and both the entrypoint
+# and periodic hooks wait; lifecycle transport is stripped from user code
#
# Exit-code propagation, runtime SIGTERM forwarding and the rest of the
# hardening floor are covered by the Python e2e suites
@@ -87,7 +89,7 @@ wait_file() {
dump_container_logs() {
local c="$1"
echo ">> Container logs for ${c}:"
- docker logs "$c" 2>&1 | grep -E "launcher|landlock|FAIL|init:|hardening|exited" | tail -30 || true
+ docker logs "$c" 2>&1 | grep -E "launcher|landlock|FAIL|init:|hardening|exited|lifecycle|error:" | tail -30 || true
}
echo "========================================="
@@ -258,6 +260,70 @@ grep -q "subreaper_ok=yes" "${TESTDIR}/subreaper.out" || fail "test 3: subreaper
docker rm -f "$C3" >/dev/null
echo "PASS: subreaper degradation reported"
+# -------------------------------------------------------------------
+# Test 4: init mode runs lifecycle hooks before and alongside workload.
+# -------------------------------------------------------------------
+echo ""
+echo ">> Test 4: init-mode lifecycle hooks"
+
+cat > "${TESTDIR}/lifecycle.sh" <<'SCRIPT'
+#!/bin/sh
+set -eu
+out=/mnt/test/lifecycle.out
+touch /mnt/test/entrypoint.started
+[ "$(cat /proc/1/comm)" = "execd" ] || { echo "FAIL: execd is not PID 1" > "$out"; exit 98; }
+[ -f /mnt/test/prestart.done ] || { echo "FAIL: preStart did not finish before entrypoint" > "$out"; exit 99; }
+[ -z "${OPENSANDBOX_LIFECYCLE:-}" ] || { echo "FAIL: lifecycle transport leaked" > "$out"; exit 100; }
+
+i=0
+while [ ! -f /mnt/test/periodic.twice ] && [ "$i" -lt 100 ]; do
+ sleep 0.2
+ i=$((i+1))
+done
+[ -f /mnt/test/periodic.twice ] \
+ || { echo "FAIL: periodic hook did not run twice" > "$out"; exit 101; }
+printf 'lifecycle_hooks_ok=yes\n' > "$out"
+exit 0
+SCRIPT
+chmod +x "${TESTDIR}/lifecycle.sh"
+
+C4="${PREFIX}-t4"
+RUNNERS+=("$C4")
+docker run -d --name "$C4" \
+ --entrypoint /bootstrap.sh \
+ -e EXECD=/execd \
+ -e EXECD_INIT=1 \
+ -e 'OPENSANDBOX_LIFECYCLE={"preStart":{"command":["/bin/sh","-c","touch /mnt/test/prestart.started; while [ ! -f /mnt/test/prestart.release ]; do sleep 0.1; done; touch /mnt/test/prestart.done"],"timeoutSeconds":300},"periodic":[{"name":"checkpoint","schedule":"@every 1s","command":["/bin/sh","-c","touch /mnt/test/periodic.started; echo periodic >> /mnt/test/periodic.sequence; test $(grep -c periodic /mnt/test/periodic.sequence) -ge 2 && touch /mnt/test/periodic.twice || true"]}]}' \
+ -v "${TESTDIR}:/mnt/test" \
+ "${IMAGE}" \
+ /mnt/test/lifecycle.sh >/dev/null
+if ! wait_file "${TESTDIR}/prestart.started"; then
+ dump_container_logs "$C4"
+ fail "test 4: preStart did not reach the startup barrier"
+fi
+if ! docker exec "$C4" /bin/sh -c \
+ 'i=0; while [ "$i" -lt 30 ]; do wget -qO- http://127.0.0.1:44772/ping >/dev/null && exit 0; sleep 0.5; i=$((i+1)); done; exit 1'; then
+ dump_container_logs "$C4"
+ fail "test 4: execd /ping was unavailable while preStart was blocked"
+fi
+# Keep the barrier closed long enough to observe an incorrectly started entrypoint or periodic hook.
+sleep 2
+[ ! -f "${TESTDIR}/entrypoint.started" ] \
+ || fail "test 4: entrypoint started before preStart was released"
+[ ! -f "${TESTDIR}/periodic.started" ] \
+ || fail "test 4: periodic hook started before preStart was released"
+touch "${TESTDIR}/prestart.release"
+if ! wait_file "${TESTDIR}/lifecycle.out"; then
+ dump_container_logs "$C4"
+ fail "test 4: container did not produce lifecycle.out"
+fi
+RC=$(docker wait "$C4")
+[ "$RC" = "0" ] || fail "test 4: container exited $RC: $(cat "${TESTDIR}/lifecycle.out")"
+grep -q "lifecycle_hooks_ok=yes" "${TESTDIR}/lifecycle.out" \
+ || fail "test 4: lifecycle assertions failed: $(cat "${TESTDIR}/lifecycle.out")"
+docker rm -f "$C4" >/dev/null
+echo "PASS: init-mode health availability, preStart ordering, periodic execution, and environment isolation"
+
# -------------------------------------------------------------------
echo ""
echo "========================================="
@@ -265,4 +331,4 @@ echo " Init-mode container regression PASSED"
echo "========================================="
echo " image: ${IMAGE}"
echo " cases: pid1 handoff / reaping / signal shield /"
-echo " env inheritance / subreaper"
+echo " env inheritance / subreaper / lifecycle hooks"
diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts
index 86095b8c9..6f89e6def 100644
--- a/docs/.vitepress/config.mts
+++ b/docs/.vitepress/config.mts
@@ -96,6 +96,7 @@ export default defineConfig({
{ text: "Multi-Tenancy", link: "/guides/multi-tenancy" },
{ text: "Isolation Sessions", link: "/guides/isolation-sessions" },
{ text: "Pause & Resume", link: "/guides/pause-resume" },
+ { text: "Lifecycle Hooks", link: "/guides/lifecycle-hooks" },
{ text: "Windows Sandbox", link: "/guides/windows-sandbox" },
{ text: "Client Pool", link: "/guides/client-pool" },
{ text: "SDK Telemetry", link: "/guides/sdk-telemetry" },
diff --git a/docs/guides/lifecycle-hooks.md b/docs/guides/lifecycle-hooks.md
new file mode 100644
index 000000000..f5614f0db
--- /dev/null
+++ b/docs/guides/lifecycle-hooks.md
@@ -0,0 +1,90 @@
+---
+title: Lifecycle Hooks
+description: Run setup work before a sandbox entrypoint and schedule recurring work while it is running.
+---
+
+# Lifecycle Hooks
+
+Lifecycle hooks let a sandbox run declarative commands at defined points in its runtime. OpenSandbox currently supports a blocking `preStart` hook and one or more non-blocking `periodic` hooks.
+
+## Supported hooks
+
+| Hook | Cardinality | When it runs | Failure behavior |
+|---|---:|---|---|
+| `preStart` | Zero or one | After execd starts listening, before the user entrypoint starts | A failure or timeout prevents the user entrypoint from starting |
+| `periodic` | Zero or more | The scheduler starts after `preStart` succeeds and runs each hook on its schedule | A failed or timed-out run is logged and later runs normally continue; if a timed-out process cannot be terminated, that hook is disabled |
+
+Both hooks run inside the sandbox with the sandbox environment. Commands are argument arrays and are executed directly. To use shell syntax such as pipes, redirects, or variable expansion, invoke a shell explicitly, for example `['sh', '-c', 'command > file']`.
+
+
+
+The ordering is the same in both startup modes:
+
+1. execd starts and its HTTP server begins listening.
+2. `preStart` runs to completion, if configured.
+3. The periodic scheduler starts, if configured.
+4. The user entrypoint starts.
+
+In execd-as-init mode, execd remains PID 1 and starts the entrypoint as its supervised child. In bootstrap mode, the bootstrap process starts the entrypoint after execd reports that lifecycle startup completed.
+
+## Configuration
+
+Lifecycle hooks are part of the sandbox creation request:
+
+```json
+{
+ "image": { "uri": "ubuntu:24.04" },
+ "entrypoint": ["tail", "-f", "/dev/null"],
+ "resourceLimits": {
+ "cpu": "1",
+ "memory": "1Gi"
+ },
+ "lifecycle": {
+ "preStart": {
+ "command": ["sh", "-c", "echo ready > /tmp/prestart.done"],
+ "timeoutSeconds": 120
+ },
+ "periodic": [
+ {
+ "name": "checkpoint",
+ "schedule": "@every 5m",
+ "command": ["sh", "-c", "date -u >> /tmp/checkpoints.log"],
+ "timeoutSeconds": 120
+ }
+ ]
+ }
+}
+```
+
+### `preStart`
+
+| Field | Required | Description |
+|---|---:|---|
+| `command` | Yes | Non-empty command and argument array |
+| `timeoutSeconds` | No | When omitted, defaults to 60 seconds; explicit values must be from 1 through 300 seconds |
+
+`preStart` runs on each container start. Make the command idempotent so retrying or restarting a sandbox does not corrupt its state.
+
+### `periodic`
+
+| Field | Required | Description |
+|---|---:|---|
+| `name` | Yes | Non-blank name, unique within the sandbox |
+| `schedule` | Yes | Standard five-field cron expression or descriptor such as `@hourly` or `@every 30s` |
+| `command` | Yes | Non-empty command and argument array |
+| `timeoutSeconds` | No | When omitted, defaults to 60 seconds; explicit values must be from 1 through 300 seconds |
+
+An `@every` interval must be a whole number of seconds and at least one second. Runs of the same named hook never overlap: if the previous run is still active, the next scheduled run is skipped.
+
+## Current availability
+
+Lifecycle hooks currently require the Kubernetes provider. They are rejected by the Docker provider and Fleets backend. A request cannot combine `lifecycle` with `poolRef`.
+
+The SDKs expose lifecycle fields on their sandbox creation APIs but do not enforce the timeout range. The Server is the authority for request validation. See the language-specific examples in the [SDK documentation](/sdks/).
+
+## Operational guidance
+
+- Keep `preStart` bounded and deterministic because the entrypoint waits for it.
+- Make periodic work idempotent and safe to retry.
+- Store credentials through the platform's supported secret mechanism rather than embedding them in commands.
+- Write data that must survive node replacement to durable storage. Lifecycle hooks schedule recovery and synchronization work; they do not make the sandbox filesystem durable by themselves.
diff --git a/docs/public/images/lifecycle-hooks-startup.drawio b/docs/public/images/lifecycle-hooks-startup.drawio
new file mode 100644
index 000000000..4cd6c6f15
--- /dev/null
+++ b/docs/public/images/lifecycle-hooks-startup.drawio
@@ -0,0 +1,121 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/public/images/lifecycle-hooks-startup.png b/docs/public/images/lifecycle-hooks-startup.png
new file mode 100644
index 000000000..bc07b4e84
Binary files /dev/null and b/docs/public/images/lifecycle-hooks-startup.png differ
diff --git a/docs/sdks/csharp.md b/docs/sdks/csharp.md
index e02b49407..e54c75e60 100644
--- a/docs/sdks/csharp.md
+++ b/docs/sdks/csharp.md
@@ -64,6 +64,40 @@ catch (SandboxException ex)
}
```
+## Lifecycle Hooks
+
+Set `Lifecycle` in `SandboxCreateOptions`. `PreStart` completes before the entrypoint starts, while `Periodic` hooks run on their schedules after startup.
+
+```csharp
+using OpenSandbox.Models;
+
+await using var sandbox = await Sandbox.CreateAsync(new SandboxCreateOptions
+{
+ ConnectionConfig = config,
+ Image = "ubuntu:24.04",
+ Lifecycle = new SandboxLifecycle
+ {
+ PreStart = new LifecycleHook
+ {
+ Command = new[] { "sh", "-c", "echo ready > /tmp/prestart.done" },
+ TimeoutSeconds = 120,
+ },
+ Periodic = new[]
+ {
+ new PeriodicLifecycleHook
+ {
+ Name = "checkpoint",
+ Schedule = "@every 5m",
+ Command = new[] { "sh", "-c", "date -u >> /tmp/checkpoints.log" },
+ TimeoutSeconds = 120,
+ },
+ },
+ },
+});
+```
+
+The Server validates `TimeoutSeconds`; when omitted it defaults to 60 seconds. See [Lifecycle Hooks](/guides/lifecycle-hooks) for timing, failure behavior, and provider limitations.
+
## Usage Examples
### 1. Lifecycle Management
diff --git a/docs/sdks/go.md b/docs/sdks/go.md
index 884b0d8c3..bcf91cca3 100644
--- a/docs/sdks/go.md
+++ b/docs/sdks/go.md
@@ -303,6 +303,33 @@ pool, err := opensandbox.NewSandboxPoolBuilder().
- Configure `PrimaryLockTTL` greater than `WarmupReadyTimeout` plus expected warmup preparer time.
:::
+## Lifecycle Hooks
+
+Set `Lifecycle` in `SandboxCreateOptions`. `PreStart` completes before the entrypoint starts, while `Periodic` hooks run on their schedules after startup.
+
+```go
+hookTimeout := 120
+sandbox, err := opensandbox.CreateSandbox(ctx, config, opensandbox.SandboxCreateOptions{
+ Image: "ubuntu:24.04",
+ Lifecycle: &opensandbox.SandboxLifecycle{
+ PreStart: &opensandbox.LifecycleHook{
+ Command: []string{"sh", "-c", "echo ready > /tmp/prestart.done"},
+ TimeoutSeconds: &hookTimeout,
+ },
+ Periodic: []opensandbox.PeriodicLifecycleHook{
+ {
+ Name: "checkpoint",
+ Schedule: "@every 5m",
+ Command: []string{"sh", "-c", "date -u >> /tmp/checkpoints.log"},
+ TimeoutSeconds: &hookTimeout,
+ },
+ },
+ },
+})
+```
+
+The Server validates `TimeoutSeconds`; when omitted it defaults to 60 seconds. See [Lifecycle Hooks](/guides/lifecycle-hooks) for timing, failure behavior, and provider limitations.
+
## API Reference
### LifecycleClient
diff --git a/docs/sdks/javascript.md b/docs/sdks/javascript.md
index 567278326..bdcdd824e 100644
--- a/docs/sdks/javascript.md
+++ b/docs/sdks/javascript.md
@@ -70,6 +70,33 @@ try {
}
```
+## Lifecycle Hooks
+
+Set `lifecycle` in `Sandbox.create`. `preStart` completes before the entrypoint starts, while `periodic` hooks run on their schedules after startup.
+
+```ts
+const sandbox = await Sandbox.create({
+ connectionConfig: config,
+ image: "ubuntu:24.04",
+ lifecycle: {
+ preStart: {
+ command: ["sh", "-c", "echo ready > /tmp/prestart.done"],
+ timeoutSeconds: 120,
+ },
+ periodic: [
+ {
+ name: "checkpoint",
+ schedule: "@every 5m",
+ command: ["sh", "-c", "date -u >> /tmp/checkpoints.log"],
+ timeoutSeconds: 120,
+ },
+ ],
+ },
+});
+```
+
+The Server validates `timeoutSeconds`; when omitted it defaults to 60 seconds. See [Lifecycle Hooks](/guides/lifecycle-hooks) for timing, failure behavior, and provider limitations.
+
## Usage Examples
### 1. Lifecycle Management
diff --git a/docs/sdks/kotlin.md b/docs/sdks/kotlin.md
index 1e8cb6349..1faea4a8e 100644
--- a/docs/sdks/kotlin.md
+++ b/docs/sdks/kotlin.md
@@ -77,6 +77,37 @@ public class QuickStart {
}
```
+## Lifecycle Hooks
+
+Configure lifecycle hooks on `Sandbox.Builder`. `preStart` completes before the entrypoint starts, while `periodic` hooks run on their schedules after startup.
+
+```java
+import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.LifecycleHook;
+import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.PeriodicLifecycleHook;
+import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxLifecycle;
+
+SandboxLifecycle lifecycle = SandboxLifecycle.builder()
+ .preStart(LifecycleHook.builder()
+ .command("sh", "-c", "echo ready > /tmp/prestart.done")
+ .timeoutSeconds(120)
+ .build())
+ .periodic(PeriodicLifecycleHook.builder()
+ .name("checkpoint")
+ .schedule("@every 5m")
+ .command("sh", "-c", "date -u >> /tmp/checkpoints.log")
+ .timeoutSeconds(120)
+ .build())
+ .build();
+
+Sandbox sandbox = Sandbox.builder()
+ .connectionConfig(config)
+ .image("ubuntu:24.04")
+ .lifecycle(lifecycle)
+ .build();
+```
+
+The Server validates `timeoutSeconds`; when omitted it defaults to 60 seconds. See [Lifecycle Hooks](/guides/lifecycle-hooks) for timing, failure behavior, and provider limitations.
+
## Usage Examples
### 1. Lifecycle Management
diff --git a/docs/sdks/python.md b/docs/sdks/python.md
index 73b45e352..64d328e33 100644
--- a/docs/sdks/python.md
+++ b/docs/sdks/python.md
@@ -257,6 +257,39 @@ For async pools, pass a `redis.asyncio` client to `AsyncRedisPoolStateStore`.
does not bypass shared state.
:::
+## Lifecycle Hooks
+
+Pass a `SandboxLifecycle` when creating a sandbox. `pre_start` completes before the entrypoint starts, while `periodic` hooks run on their schedules after startup.
+
+```python
+from opensandbox.models.sandboxes import (
+ LifecycleHook,
+ PeriodicLifecycleHook,
+ SandboxLifecycle,
+)
+
+sandbox = await Sandbox.create(
+ "ubuntu:24.04",
+ connection_config=config,
+ lifecycle=SandboxLifecycle(
+ pre_start=LifecycleHook(
+ command=["sh", "-c", "echo ready > /tmp/prestart.done"],
+ timeout_seconds=120,
+ ),
+ periodic=[
+ PeriodicLifecycleHook(
+ name="checkpoint",
+ schedule="@every 5m",
+ command=["sh", "-c", "date -u >> /tmp/checkpoints.log"],
+ timeout_seconds=120,
+ )
+ ],
+ ),
+)
+```
+
+The Server validates `timeout_seconds`; when omitted it defaults to 60 seconds. See [Lifecycle Hooks](/guides/lifecycle-hooks) for timing, failure behavior, and provider limitations.
+
## Usage Examples
### 1. Lifecycle Management
diff --git a/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/domain/models/sandboxes/LifecycleModels.kt b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/domain/models/sandboxes/LifecycleModels.kt
index 04d89f9d9..80dfae71b 100644
--- a/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/domain/models/sandboxes/LifecycleModels.kt
+++ b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/domain/models/sandboxes/LifecycleModels.kt
@@ -16,8 +16,6 @@
package com.alibaba.opensandbox.sandbox.domain.models.sandboxes
-private const val MAX_LIFECYCLE_HOOK_TIMEOUT_SECONDS = 300
-
/** Command executed by execd before the user entrypoint starts. */
class LifecycleHook private constructor(
val command: List,
@@ -43,9 +41,6 @@ class LifecycleHook private constructor(
fun command(vararg command: String): Builder = command(command.toList())
fun timeoutSeconds(timeoutSeconds: Int): Builder {
- require(timeoutSeconds in 1..MAX_LIFECYCLE_HOOK_TIMEOUT_SECONDS) {
- "Lifecycle hook timeoutSeconds must be between 1 and $MAX_LIFECYCLE_HOOK_TIMEOUT_SECONDS"
- }
this.timeoutSeconds = timeoutSeconds
return this
}
@@ -98,9 +93,6 @@ class PeriodicLifecycleHook private constructor(
fun command(vararg command: String): Builder = command(command.toList())
fun timeoutSeconds(timeoutSeconds: Int): Builder {
- require(timeoutSeconds in 1..MAX_LIFECYCLE_HOOK_TIMEOUT_SECONDS) {
- "Periodic lifecycle hook timeoutSeconds must be between 1 and $MAX_LIFECYCLE_HOOK_TIMEOUT_SECONDS"
- }
this.timeoutSeconds = timeoutSeconds
return this
}
diff --git a/sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/domain/models/SandboxLifecycleModelsTest.kt b/sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/domain/models/SandboxLifecycleModelsTest.kt
index 7d8a0e72b..037185f81 100644
--- a/sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/domain/models/SandboxLifecycleModelsTest.kt
+++ b/sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/domain/models/SandboxLifecycleModelsTest.kt
@@ -33,19 +33,18 @@ import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxLifecycle
class SandboxLifecycleModelsTest {
@Test
- fun `stable lifecycle builders reject timeout above maximum`() {
- assertEquals(300, DomainLifecycleHook.builder().command("true").timeoutSeconds(300).build().timeoutSeconds)
-
- assertThrows(IllegalArgumentException::class.java) {
- DomainLifecycleHook.builder().command("true").timeoutSeconds(301)
- }
- assertThrows(IllegalArgumentException::class.java) {
+ fun `stable lifecycle builders preserve timeout for server validation`() {
+ assertEquals(0, DomainLifecycleHook.builder().command("true").timeoutSeconds(0).build().timeoutSeconds)
+ assertEquals(
+ 301,
DomainPeriodicLifecycleHook.builder()
.name("sync")
.schedule("@hourly")
.command("true")
.timeoutSeconds(301)
- }
+ .build()
+ .timeoutSeconds,
+ )
}
@Test
diff --git a/sdks/sandbox/python/src/opensandbox/models/sandboxes.py b/sdks/sandbox/python/src/opensandbox/models/sandboxes.py
index 801d1bf5c..975d457c6 100644
--- a/sdks/sandbox/python/src/opensandbox/models/sandboxes.py
+++ b/sdks/sandbox/python/src/opensandbox/models/sandboxes.py
@@ -25,8 +25,6 @@
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
-_MAX_LIFECYCLE_HOOK_TIMEOUT_SECONDS = 300
-
class SandboxImageAuth(BaseModel):
"""
@@ -168,9 +166,7 @@ class LifecycleHook(BaseModel):
timeout_seconds: int | None = Field(
default=None,
alias="timeoutSeconds",
- ge=1,
- le=_MAX_LIFECYCLE_HOOK_TIMEOUT_SECONDS,
- description="Maximum execution time in seconds, up to 300. The server defaults to 60.",
+ description="Maximum execution time in seconds. The server validates the value and defaults to 60.",
)
@field_validator("command")
@@ -192,9 +188,7 @@ class PeriodicLifecycleHook(BaseModel):
timeout_seconds: int | None = Field(
default=None,
alias="timeoutSeconds",
- ge=1,
- le=_MAX_LIFECYCLE_HOOK_TIMEOUT_SECONDS,
- description="Maximum execution time in seconds, up to 300. The server defaults to 60.",
+ description="Maximum execution time in seconds. The server validates the value and defaults to 60.",
)
@field_validator("name", "schedule")
diff --git a/sdks/sandbox/python/tests/test_models_stability.py b/sdks/sandbox/python/tests/test_models_stability.py
index 73c0d2b78..f9ba2114c 100644
--- a/sdks/sandbox/python/tests/test_models_stability.py
+++ b/sdks/sandbox/python/tests/test_models_stability.py
@@ -69,17 +69,21 @@
)
-@pytest.mark.parametrize("hook_type", [DomainLifecycleHook, DomainPeriodicLifecycleHook])
-def test_lifecycle_hooks_reject_timeout_above_maximum(hook_type: type) -> None:
- kwargs: dict[str, object] = {"command": ["true"], "timeoutSeconds": 300}
+@pytest.mark.parametrize(
+ "hook_type", [DomainLifecycleHook, DomainPeriodicLifecycleHook]
+)
+@pytest.mark.parametrize("timeout_seconds", [0, 301])
+def test_lifecycle_hooks_preserve_timeout_for_server_validation(
+ hook_type: type, timeout_seconds: int
+) -> None:
+ kwargs: dict[str, object] = {
+ "command": ["true"],
+ "timeoutSeconds": timeout_seconds,
+ }
if hook_type is DomainPeriodicLifecycleHook:
kwargs.update(name="sync", schedule="@hourly")
- assert hook_type.model_validate(kwargs).timeout_seconds == 300
-
- kwargs["timeoutSeconds"] = 301
- with pytest.raises(ValueError, match="less than or equal to 300"):
- hook_type.model_validate(kwargs)
+ assert hook_type.model_validate(kwargs).timeout_seconds == timeout_seconds
def test_lifecycle_hooks_reject_blank_commands_and_normalize_periodic_text() -> None: