fix(sst): use bundle for the prebuilt Rust Lambda, not handler - #12
Merged
Conversation
Every route of a SmoothAgentApi deploy failed with:
Error: Runtime not found: provided.al2023
`args.handler` is a PREBUILT artifact directory — the cargo-lambda output
holding `bootstrap`. Passing it as `handler` makes SST try to BUILD it,
and SST dispatches its builder on the runtime string. There is no builder
registered for the literal `provided.al2023`: SST expects `rust` or `go`
as the BUILD input and maps those to that Lambda runtime itself. So the
value is a valid Lambda runtime and an invalid SST build runtime, and the
two are easy to conflate.
`bundle` is the documented way to say "I built this myself, skip
bundling" — with `handler` then naming the entrypoint inside it
(`bootstrap`, which is also the crate's [[bin]] name).
This failed in a way that pointed at the wrong layer: each route created
its log group and IAM role successfully FIRST, so the run looked like a
permissions problem right up until the build step.
Found deploying smooth-operator's serverless flavor for the first time
(SmooAI/smooth-operator#559) — the path had never actually been run.
Typecheck is unchanged: 93 pre-existing errors on main (the `sst` ambient
globals a library checkout cannot resolve), 93 with this change.
|
brentrager
added a commit
that referenced
this pull request
Aug 31, 2026
Patch: the Rust Lambda bundle fix (#12). SmoothAgentApi could not deploy at all before it — every route failed with 'Runtime not found: provided.al2023' because the prebuilt artifact dir was passed as `handler` instead of `bundle`.
brentrager
added a commit
to SmooAI/smooth-operator
that referenced
this pull request
Aug 31, 2026
0.2.0's SmoothAgentApi passed the prebuilt cargo-lambda artifact dir as `handler`, so SST tried to BUILD it and failed every route with 'Runtime not found: provided.al2023' — there is no SST builder for that string (it expects `rust`/`go` as the build input and maps them to it). 0.2.1 uses `bundle` + `handler: 'bootstrap'`, which is SST's documented 'I built this myself' path. SmooAI/deploy#12. Lockfile regenerated with --config.inject-workspace-packages=false so it carries no trace of my local pnpm setting.
brentrager
added a commit
to SmooAI/smooth-operator
that referenced
this pull request
Sep 8, 2026
* deploy/sst: make the documented verify steps actually pass, add a CI deploy
The serverless deploy path did not survive its own README. Both verify
steps failed on a clean checkout:
1. `pnpm install` in deploy/sst installs NOTHING. The directory is
deliberately not a member of the root pnpm-workspace.yaml, so pnpm
resolves against the repo root, reports "Done", and leaves no
node_modules — surfacing later as `sst: command not found`. It needs
--ignore-workspace.
2. `pnpm sst install` then hard-fails on sst.config.ts itself:
"Your sst.config.ts has top level imports - this is not allowed."
The `import { SmoothAgentApi } from '@smooai/deploy'` at the top is
exactly what SST 4.13 rejects. Because that step is what GENERATES
.sst/platform/config.d.ts, the documented `pnpm typecheck` could
never pass either — it failed on a missing config.d.ts plus every
$-global in the file.
Both are one-line fixes (dynamic import; a flag), and both now pass:
`sst install` reports "Installed providers" and `tsc --noEmit` is clean.
Adds .github/workflows/deploy-sst.yml: workflow_dispatch only, defaulting
to the dev stage. No push trigger — merging should deploy an application
image, not a stack that owns a DynamoDB table. It refuses the production
stage outright, takes a per-stage concurrency lock (SST holds a state
lock; a second concurrent run fails and can strand it), and asserts the
bootstrap artifact exists before deploying, since SST is pointed at a
DIRECTORY and would otherwise happily ship an empty function.
Two prerequisites it cannot create for itself, now written down: an IAM
role trusting GitHub OIDC for this repo, and the AWS_DEPLOY_ROLE_ARN
repository variable. This repo has no AWS credentials in CI today.
Local verification deliberately stops at host-target compile + typecheck.
The arm64 bootstrap is built in CI; SST v4 has no creds-free synth.
* ci: e2e for the local and serverless flavors, so all three are gated
k8s already had a live gate (pr-kind-deploy-smoke.yml — Helm chart in an
ephemeral kind cluster inside the runner, real WebSocket probes, zero
cloud spend). The other two flavors had none. This closes that.
LOCAL (local-flavor-smoke.yml). examples/web-chat/e2e/smoke.mjs already
existed and NOTHING ran it — before this, no file under .github/workflows
referenced examples/ at all. The project's front door had a test suite
and no gate, which is how it rotted: running it needs
`pnpm --filter @smooai/smooth-operator build` first, because the probe
imports the SDK's dist/ and install alone doesn't build it. Undocumented
and unenforced, so it failed with ERR_MODULE_NOT_FOUND — which reads as a
broken example rather than a missing step. The workflow does that build
explicitly.
SERVERLESS (serverless-flavor-smoke.yml). Deploys an ephemeral pr-<n>
stage, drives the same probe against API Gateway, tears it down. The
probe is transport-agnostic (SMOOTH_WS_URL), so all three flavors are now
verified by the same protocol exercise rather than three bespoke checks.
Cost is designed, not hoped for. The RUN is cents — Lambda, DynamoDB
PAY_PER_REQUEST and API Gateway are all per-request. The RISK is a leaked
stage, so teardown is `if: always()`. That is necessary and NOT
sufficient: always() means "regardless of earlier step outcome", not
"regardless of whether this job keeps running", so a cancelled job or a
dead runner still leaks a live stack. sst-sweep-stale-stages.yml is the
actual backstop — daily, removes pr-* stages untouched for 12h, and only
ever the pr- prefix so dev/production are untouchable by construction.
All three keyless: the probe treats "no gateway key" as a pass, verifying
transport + session + protocol without LLM spend or a secret beyond the
deploy role. The two AWS jobs no-op via `if: vars.AWS_DEPLOY_ROLE_ARN !=
''` so they skip cleanly on forks and until that role exists, instead of
failing every PR with a credentials error.
Verified locally: server built, SDK built, probe run against the local
flavor — exit 0, session created, keyless turn handled cleanly.
* ci: match the repo's toolchain convention in the new workflows
Three drift bugs in what I just added, all the same shape — a second
source of truth for a version this repo already pins:
- `pnpm/action-setup` with `version: 10`. package.json pins
`packageManager: pnpm@10.34.5`, and mise.toml explicitly says pnpm is
NOT pinned there BECAUSE that field is canonical. Passing a version
can override the pin and yield a pnpm whose lockfile format differs
from the committed one — surfacing as a --frozen-lockfile failure that
looks like a bad lockfile rather than a bad runner. Dropped, matching
typescript.yml.
- `actions/setup-node` with node 22, while mise.toml pins node 24.14.0
and typescript.yml gets it from jdx/mise-action. Two pins, one of them
silently wrong. Now mise-action everywhere.
- The path filters didn't include mise.toml, so a toolchain bump could
not re-run the jobs it can break.
Also commits the root lockfile update. It is not churn: it is the
lockfile catching up to the ALREADY-PINNED pnpm 10.34.5, and it is
load-bearing — the new workflows install with --frozen-lockfile, which
failed against the stale committed lockfile ("Update your lockfile
using --no-frozen-lockfile"). Verified `pnpm install --frozen-lockfile`
now exits 0 from a clean node_modules.
* ci: revert the lockfile change — it was my machine, not the repo
The previous commit claimed the root pnpm-lock.yaml update was
load-bearing because `pnpm install --frozen-lockfile` failed against the
committed one. That was wrong, and CI caught it:
ERR_PNPM_LOCKFILE_CONFIG_MISMATCH
The current "settings.injectWorkspacePackages" configuration doesn't
match the value found in the lockfile
`inject-workspace-packages=true` is set in my GLOBAL pnpm config — not in
~/.npmrc, not in the repo. So my local install wrote that setting into
the lockfile, and CI (which has no such setting) then refused it. The
committed lockfile was correct the whole time; the local failure that
"proved" otherwise was my own environment leaking into a shared file.
Verified the right way round: with the original lockfile restored,
`pnpm install --frozen-lockfile --config.inject-workspace-packages=false`
exits 0 from a clean node_modules — which is the configuration CI
actually runs in, and is why typescript.yml's identical install has been
passing all along.
Worth noting because the same reasoning error nearly shipped twice: I
reverted this churn once already, correctly, then reinstated it when a
local command failed. A failing local command is evidence about the
local machine until it is reproduced somewhere else.
* serverless: make the Lambda's protocol path actually testable, and test it
The serverless flavor had no test of any kind. k8s is gated by a kind
cluster and local by the web-chat smoke; the Lambda could only be
exercised by deploying it — so its protocol handling, the one thing that
differs from the reference server, was unverified on every PR. The
deploy-and-teardown smoke added earlier does cover it, but only once an
AWS OIDC role exists, which means it SKIPS today. A gate that skips is
not a gate.
Two things made it untestable, both small:
- The crate was bin-only. No [lib], so no integration test could import
it. Added one; main.rs still owns the bootstrap binary.
- ConnectionPoster was a concrete struct with an API Gateway client
welded in. It is now an enum with a Capturing variant that collects
events in memory. An enum rather than a trait because every caller
already takes &ConnectionPoster, so the seam opens without changing a
single signature — and because that is how this codebase selects
backends elsewhere (StorageBackend, KnowledgeBackend).
tests/protocol_smoke.rs then drives real frames through
dispatch::handle_frame and asserts on what came back: ping -> exactly one
pong; create_conversation_session -> a sessionId (nested under `data`,
which is the detail that cost a debugging round when driving this
protocol by hand); and — the two that matter most for this transport —
malformed JSON and an unknown action must come back as protocol `error`
events and NOT as Lambda errors, because a hard error is what API Gateway
turns into a dropped connection.
Storage is the in-memory adapter, not dynamodb-local, on purpose:
handle_frame takes &Arc<dyn StorageAdapter>, DynamoDB already has its own
conformance suite against dynamodb-local, and a test that needs Docker is
a test that gets skipped.
This runs under the existing `cargo test --workspace` in rust.yml, so it
is active on every PR touching rust/** with no new workflow, no
credentials and no cost. It complements rather than replaces
serverless-flavor-smoke.yml: this covers the protocol path, that covers
the transport (API Gateway invoking the function, PostToConnection
delivering a frame) once the role exists.
Side benefit: the poster seam is the same shape the Postgres-on-Lambda
work needs for its connection registry.
* serverless: rustfmt the protocol smoke
Format check is part of rust.yml; the new test file had not been run
through cargo fmt.
* serverless: make the bin a thin shim over the lib
clippy -D warnings failed: the capturing poster variant and its two
accessors are dead code in the BIN target, because main.rs re-declared
`mod adapter; mod config; ...` and so compiled every module a second time
into the binary — where only the lib's tests use that variant.
The fix is the shape a crate with both targets should have anyway: the
modules live in the lib and main.rs uses them. One compilation, no
duplicate module tree, and the test seam is not dead code in some other
target.
Verified: cargo clippy --all-targets -- -D warnings clean, cargo fmt
--check clean, 3 unit + 4 protocol tests pass.
* serverless: fix the artifact path — the deploy was pointed at an empty dir
The first real run of the serverless smoke failed on the artifact
assertion, which is exactly what that step was for.
cargo-lambda names its output directory after the BINARY, and this
crate's [[bin]] is literally named `bootstrap` — so the artifact is at
target/lambda/bootstrap/bootstrap. Both sst.config.ts's ARTIFACT_DIR and
the README pointed at target/lambda/smooai-smooth-operator-lambda, the
PACKAGE name, which does not exist.
That is not a loud failure. SST points at a DIRECTORY, so a path that
isn't there deploys an empty function and reports success. This bug was
committed, documented, and would have shipped a broken Lambda the first
time anyone ran the deploy — the assertion is the only reason it surfaced
as a red step instead of a mystery 500 later.
Fixed three ways, because a hard-coded path is what caused it:
- CI DISCOVERS the bootstrap (`find … -name bootstrap`) and fails loudly,
listing the tree, if there isn't one.
- It passes that directory to SST via SMOOTH_LAMBDA_ARTIFACT_DIR, so a
future rename of the crate or the bin cannot silently move it again.
- The committed default is corrected for local use, and the README says
why the directory is named what it is.
Also: teardown ran unconditionally on `always()` and died with
`pnpm: command not found`, because the early failure happened before pnpm
was installed — reporting a second, misleading failure on a run that had
created nothing. It is now gated on the deploy step having actually been
attempted, which keeps the guarantee (a failed probe still tears down)
without the false alarm.
* serverless: regenerate deploy/sst's lockfile without my local pnpm setting
Second instance of the same mistake, caught by CI again:
ERR_PNPM_LOCKFILE_CONFIG_MISMATCH
"settings.injectWorkspacePackages" doesn't match the value in the lockfile
`inject-workspace-packages=true` is in my GLOBAL pnpm config, so every
lockfile I regenerate picks it up and then fails --frozen-lockfile
everywhere else. I caught and reverted this for the ROOT lockfile and
missed that deploy/sst/pnpm-lock.yaml had the same contamination — that
one I had a legitimate reason to regenerate (the @smooai/deploy path dep
became the published ^0.2.0), so reverting it was not an option and it
needed regenerating CLEANLY instead.
Regenerated with --config.inject-workspace-packages=false and verified
the way CI actually runs it: a frozen install from clean node_modules
exits 0, and the lockfile no longer carries the setting at all.
* serverless: deploy into the region the account is actually bootstrapped in
The workflows pinned us-east-1. The account's SST bootstrap lives in
us-east-2 — /sst/bootstrap does not exist in us-east-1 at all. Deploying
there would not have failed cleanly; it would have tried to BOOTSTRAP a
second SST environment, which the deploy role deliberately cannot do
(bootstrap is account-level and read-only for it).
Also fixes the sweeper, which derived the state bucket as
`sst-state-<account>` — the same wrong guess that broke the deploy role's
S3 scope. The real name is `sst-state-<random>` and it is recorded in the
bootstrap parameter, so the sweeper now READS it and fails loudly if it
cannot, rather than quietly listing an empty bucket that does not exist
and reporting "no stale stages".
That last one matters more than it looks: a sweeper that silently finds
nothing is indistinguishable from a sweeper with nothing to find, and it
is the backstop for leaked infrastructure.
* serverless: surface the actual SST error
The deploy failed with 'Unexpected error occurred. Please run with
--print-logs', which on a runner means the cause is in a file nobody will
ever see. Deploy and teardown now both pass --print-logs, and a failure
tails .sst/log/sst.log.
Teardown is continue-on-error so it cannot MASK the real failure above it
— it still shows red, and the daily sweeper remains the actual guarantee
against a leaked stage.
* serverless: seed a placeholder gateway key for the ephemeral stage
All the IAM errors are gone; the deploy now gets far enough to fail on
SecretMissingError for SmoothAgentGatewayKey. sst.Secret has no default,
so a fresh stage cannot deploy without SOME value.
Sets an obvious non-credential placeholder rather than wiring a real key.
The probe is keyless by design — it verifies transport, session and
protocol, and treats 'no gateway key' as a pass — so a real key would buy
nothing except LLM spend and one more secret to manage.
* deploy/sst: take @smooai/deploy 0.2.1 (Rust Lambda bundle fix)
0.2.0's SmoothAgentApi passed the prebuilt cargo-lambda artifact dir as
`handler`, so SST tried to BUILD it and failed every route with
'Runtime not found: provided.al2023' — there is no SST builder for that
string (it expects `rust`/`go` as the build input and maps them to it).
0.2.1 uses `bundle` + `handler: 'bootstrap'`, which is SST's documented
'I built this myself' path. SmooAI/deploy#12.
Lockfile regenerated with --config.inject-workspace-packages=false so it
carries no trace of my local pnpm setting.
* serverless: read the WebSocket URL from the deploy's own output
The deploy SUCCEEDED — full stack up on AWS, teardown clean — and then the
separate 'Resolve the WebSocket URL' step failed: `sst outputs` returned
nothing my awk could match.
It was redundant anyway. SST prints the stack outputs on completion
('api: wss://…'), and the URL cannot exist before the deploy succeeds, so
one step is the honest shape. Dropped the second command and parse the
deploy's own output.
`set -o pipefail` is load-bearing there: piping through `tee` otherwise
lets tee decide the step's exit status, so a FAILED deploy would report
success and the probe would run against a stale or empty URL. Same trap as
'check | tail && merge'.
* serverless: dump Lambda logs before teardown on failure
The probe reached the deployed API and got a hard 'WebSocket connection
error'. That is indistinguishable from the outside between a rejected
$connect and a handler crashing on cold start — and the stack is torn
down moments later, taking the evidence with it.
Dumps each route's CloudWatch group and the API Gateway listing before
teardown, on failure only. Best-effort throughout: a missing log group
must not fail a run that already has a real failure to report.
* serverless: fix the log-group prefix and dump API Gateway routing
The first diagnostic found no log groups and I nearly read that as 'the
Lambda was never invoked'. It was the prefix: SST names functions
smooth-operator-<stage>-…, so the group is /aws/lambda/smooth-operator-<stage>…,
not /aws/lambda/<stage>-.
Also dumps routes, integrations and stages. The probe gets a hard
WebSocket reject while the API itself exists, so the question is whether
$connect is wired to an integration at all — which none of the previous
output could answer.
* serverless: stop guessing the Lambda log-group prefix
Third attempt at this, and the first two both silently found nothing —
which reads as 'the Lambda never ran' and is a materially wrong conclusion
to hand someone.
SST truncates the app name to fit Lambda's 64-char name limit, and does it
INCONSISTENTLY per route: one deploy produced smooth-pr-559-,
smo-pr-559- and smooth--pr-559- for different handlers. No prefix guess
can be right.
Lists all log groups and filters on the stage, which is the one component
of the name that is actually stable.
* serverless: check the Lambda invoke permission
Every log group for the stage exists and every one is EMPTY, while the
routes and integrations are correct — so the handler is never reached.
The remaining explanation is that API Gateway is not permitted to invoke
the function: a missing resource-based policy refuses the connection
before any handler runs, and leaves exactly this signature.
Dumps each function's resource policy so the next run distinguishes
'not wired' from 'wired but not permitted' instead of inferring it.
* serverless: probe the raw WebSocket handshake before the SDK
The SDK reports every failure as the same opaque 'WebSocket connection
error'. With all five Lambda log groups empty, routes wired and the
invoke permission present, the open question is whether API Gateway
REFUSES the path or the integration fails — and only the HTTP status of
the upgrade request answers it.
Also probes the same host without the stage path, so a 403 on
/$default versus / immediately implicates the stage name ($default is
really an HTTP-API idiom; SST uses it for websocket stages too).
* serverless: dump route auth + DisableExecuteApiEndpoint
The handshake returns 403 Forbidden on BOTH /$default and /, so the
stage path is not the differentiator — API Gateway refuses at the edge,
before routing, which matches the empty Lambda logs.
Two things produce that and neither is visible in what we dumped so far:
a route AuthorizationType other than NONE, or DisableExecuteApiEndpoint
on the API (which turns off the execute-api.amazonaws.com hostname
entirely — and that hostname is exactly what SST hands back as the URL).
* serverless: dump the stage's deployment status, and read the 403 correctly
Every earlier diagnostic asked API Gateway about routes, integrations,
permissions and the stage's three most obvious fields — all of which came
back correct — while the $connect handshake kept returning 403 with every
Lambda log group empty.
Two things were missing.
1. `LastDeploymentStatusMessage`. `AutoDeploy: true` plus a DeploymentId
reads as healthy and is not: auto-deploy can FAIL and leave the stage
pinned to an empty deployment, and API Gateway then matches no route and
answers 403 before any integration runs — which is precisely the
signature. The dump hand-picked StageName/AutoDeploy/DeploymentId and
omitted the one field that would say so. Dump the whole stage, plus the
deployment's own status.
2. The API's own access log, the only record of a request refused before
routing. Resolve it from the stage's DestinationArn instead of the
stage-substring filter used for the Lambda groups: SST names it from a
truncated physical name that need not contain the stage at all
(`/aws/vendedlogs/apis/smooai-brentrager-RealtimeApi-rtfkfumb`).
Also records how to READ the raw handshake, established against a known-good
SST WebSocket API in the same account: on /$default a healthy API HANGS
(upgrade accepted), while the bare host answers 403 there too. The
bare-host 403 was being read as a second symptom; it is normal and means
nothing. The failure signal is specifically an instant 403 on the stage path.
`keep` input added so a dispatch can leave the stage up for live probing;
inert on PR runs, and the daily sweeper remains the backstop.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PB8DtC21Zkccqk7BHSX9h
* serverless: assert the inspected API is the one the deploy returned
The diagnostic finds the API by matching the stage string in its NAME, so a
leftover API from an earlier failed run of the same stage matches too — and
then every route, integration and permission it prints describes the wrong
API while reading perfectly healthy. Compare the id against the host in the
deployed wss:// URL and say so out loud.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PB8DtC21Zkccqk7BHSX9h
* serverless: wait for the stage to serve instead of probing it once
Root cause of the $connect 403, from the leaked pr-559 stage itself:
stage created 14:05:42
deployment uk103t 14:05:52 DEPLOYED
probe 14:06:01 403 Forbidden
access log stream 0 bytes
Nine seconds. Routes, integrations, invoke permission, route
AuthorizationType and DisableExecuteApiEndpoint were all verified correct
over several rounds — the deployment was DEPLOYED, and the stage's own
access log recorded nothing at all, because API Gateway had not started
serving the brand-new stage yet. The probe was simply asking too early, and
every "the edge is refusing us" hypothesis was chasing that.
So poll until the handshake is accepted, up to 3 minutes, and only then run
the SDK smoke. Past the deadline it fails loudly and says the difference out
loud, so a genuine misconfiguration still reads as one.
How to interpret the probe is now written down, because it is deeply
counter-intuitive and was verified against a known-good SST WebSocket API in
the same account (smooai-brentrager-RealtimeApi):
- accepted -> curl TIMES OUT (exit 28); API Gateway takes the upgrade and
holds the socket, so curl never sees a response.
- refused -> 403 {"message":"Forbidden"}.
- the BARE HOST answers 403 on a healthy API too, so it is not a useful
probe. It had been read as a second symptom; it never was one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PB8DtC21Zkccqk7BHSX9h
* serverless: delegate the unimplemented protocol actions to the server's handler
With the connect 403 gone, the smoke got further and hit the next wall:
ProtocolError: action 'list_conversations' is not supported
The lambda transport implemented four actions (ping,
create_conversation_session, get_session, send_message) and refused the rest,
so a client that had connected, created a session and was about to send a
message fell over on the sidebar query. The serverless flavor was a strict
subset of the protocol and nothing said so.
Reimplementing `list_conversations` here was the obvious fix and the wrong
one. It is gated by `may_read_conversation` — the org + per-user predicate
deciding who may see which conversation — and a second copy of that predicate
is exactly the defect class behind this repo's cross-tenant P0s: two guards
that drift until one checks the wrong thing. So the frame is handed to the
reference server's own `handler::handle_frame` instead, over the same
sender->poster bridge `send_message` already uses for streaming. One
predicate, both transports; every action the server gains, this transport
gains with it.
DELEGATED_ACTIONS is an allowlist, not a catch-all, and stays narrow on
purpose. Every member is connection-independent and spawns no turn, which is
what makes it safe where there is no socket and no state between invocations.
`confirm_tool_action` resumes a turn parked in connection-local state, and
verify_otp/submit_interaction resolve against the in-process interaction
registry — none of which survive an invocation. Those keep answering
UNSUPPORTED_ACTION, an honest "this transport cannot" rather than a handler
that looks wired and never completes. `connection_stateful_actions_stay_unsupported`
is the test that fails if someone widens the set to everything.
One seam needed care: the org passed to the delegated handler is the frame's
principal org else the lambda's CONFIGURED org — the same fallback
create_session stamps rows with. Passing None would be quietly wrong twice
over: the server's own fallback is its SEED_ORG_ID, so an unauthenticated
frame would enumerate a different org than this deployment writes to and
always come back empty, and None also disables the tenant half of
may_read_conversation entirely.
UserScope is derived from the frame by the server's `scope_for` rule
verbatim — copied as a derivation, not as policy, since the lambda has no
persistent connection to hang a principal off. Fails closed on multi-user
deployments; unchanged for the single-user/keyless flavors.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PB8DtC21Zkccqk7BHSX9h
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Every route of a
SmoothAgentApideploy fails with:The bug
args.handleris a prebuilt artifact directory — thecargo lambdaoutput holdingbootstrap. Passing it ashandlermakes SST try to build it, and SST dispatches its builder on the runtime string.There is no builder registered for the literal
provided.al2023. SST expectsrustorgoas the build input and maps those to that Lambda runtime itself:So
provided.al2023is a valid Lambda runtime and an invalid SST build runtime — easy to conflate, and the arg is namedruntimefor both.The fix
bundleis SST's documented way to say "I built this myself, skip bundling", withhandlernaming the entrypoint inside it:Why it was slow to spot
It failed at the wrong-looking layer. Each route successfully created its CloudWatch log group and IAM role first, so the run read as a permissions problem right up until the build step — and the deploy had genuinely hit four real IAM denials before this one, which made a fifth entirely plausible.
Provenance
Found deploying smooth-operator's serverless flavor for the first time (SmooAI/smooth-operator#559). That path had never actually been run end to end, which is also how it shipped with a wrong
ARTIFACT_DIRand a config thatsst installrejected outright.Verification
Typecheck is unchanged: 93 pre-existing errors on main (the
sstambient globals a library checkout can't resolve), 93 with this change — verified by stashing and re-running, not assumed.🤖 Generated with Claude Code