local-serverless-stack 0.17.2 → 1.0.1

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.
Files changed (228) hide show
  1. package/CHANGELOG.md +327 -0
  2. package/README.md +1155 -221
  3. package/assets/icon.svg +37 -0
  4. package/assets/logo.svg +38 -0
  5. package/bin/cli.js +519 -228
  6. package/bin/i18n.js +525 -0
  7. package/dist/client/config-discovery.d.ts.map +1 -1
  8. package/dist/client/config-discovery.js +2 -1
  9. package/dist/client/config-discovery.js.map +1 -1
  10. package/dist/client/http.d.ts +7 -0
  11. package/dist/client/http.d.ts.map +1 -1
  12. package/dist/client/http.js +3 -2
  13. package/dist/client/http.js.map +1 -1
  14. package/dist/client/lifecycle.d.ts +1 -4
  15. package/dist/client/lifecycle.d.ts.map +1 -1
  16. package/dist/client/lifecycle.js +2 -8
  17. package/dist/client/lifecycle.js.map +1 -1
  18. package/dist/client/namespaces/health.d.ts +1 -1
  19. package/dist/client/namespaces/health.d.ts.map +1 -1
  20. package/dist/client/namespaces/queues.d.ts.map +1 -1
  21. package/dist/client/namespaces/queues.js +9 -0
  22. package/dist/client/namespaces/queues.js.map +1 -1
  23. package/dist/client/namespaces/seeds.d.ts +1 -1
  24. package/dist/client/namespaces/services.d.ts +34 -0
  25. package/dist/client/namespaces/services.d.ts.map +1 -1
  26. package/dist/client/namespaces/services.js +3 -0
  27. package/dist/client/namespaces/services.js.map +1 -1
  28. package/dist/client/types.d.ts +17 -5
  29. package/dist/client/types.d.ts.map +1 -1
  30. package/dist/mcp/http.d.ts +15 -0
  31. package/dist/mcp/http.d.ts.map +1 -0
  32. package/dist/mcp/http.js +86 -0
  33. package/dist/mcp/http.js.map +1 -0
  34. package/dist/mcp/protocol.d.ts +36 -0
  35. package/dist/mcp/protocol.d.ts.map +1 -0
  36. package/dist/mcp/protocol.js +96 -0
  37. package/dist/mcp/protocol.js.map +1 -0
  38. package/dist/mcp/server.d.ts +7 -0
  39. package/dist/mcp/server.d.ts.map +1 -0
  40. package/dist/mcp/server.js +41 -0
  41. package/dist/mcp/server.js.map +1 -0
  42. package/dist/mcp/tools.d.ts +18 -0
  43. package/dist/mcp/tools.d.ts.map +1 -0
  44. package/dist/mcp/tools.js +323 -0
  45. package/dist/mcp/tools.js.map +1 -0
  46. package/dist/server/dev/dynamo-proxy.d.ts.map +1 -1
  47. package/dist/server/dev/dynamo-proxy.js +16 -2
  48. package/dist/server/dev/dynamo-proxy.js.map +1 -1
  49. package/dist/server/engine/backends/self-backend.d.ts +11 -2
  50. package/dist/server/engine/backends/self-backend.d.ts.map +1 -1
  51. package/dist/server/engine/backends/self-backend.js +42 -1
  52. package/dist/server/engine/backends/self-backend.js.map +1 -1
  53. package/dist/server/engine/dispatch/dispatcher.js +2 -2
  54. package/dist/server/engine/dispatch/dispatcher.js.map +1 -1
  55. package/dist/server/engine/engine-manager.d.ts +6 -1
  56. package/dist/server/engine/engine-manager.d.ts.map +1 -1
  57. package/dist/server/engine/engine-manager.js +30 -27
  58. package/dist/server/engine/engine-manager.js.map +1 -1
  59. package/dist/server/engine/http/errors.js +1 -1
  60. package/dist/server/engine/http/errors.js.map +1 -1
  61. package/dist/server/engine/http/is-aws-request.d.ts +8 -0
  62. package/dist/server/engine/http/is-aws-request.d.ts.map +1 -0
  63. package/dist/server/engine/http/is-aws-request.js +53 -0
  64. package/dist/server/engine/http/is-aws-request.js.map +1 -0
  65. package/dist/server/engine/http/router.d.ts.map +1 -1
  66. package/dist/server/engine/http/router.js +7 -1
  67. package/dist/server/engine/http/router.js.map +1 -1
  68. package/dist/server/engine/store/engine-store.d.ts +1 -0
  69. package/dist/server/engine/store/engine-store.d.ts.map +1 -1
  70. package/dist/server/engine/store/engine-store.js +2 -1
  71. package/dist/server/engine/store/engine-store.js.map +1 -1
  72. package/dist/server/engine/store/memory-store.d.ts +16 -0
  73. package/dist/server/engine/store/memory-store.d.ts.map +1 -0
  74. package/dist/server/engine/store/memory-store.js +167 -0
  75. package/dist/server/engine/store/memory-store.js.map +1 -0
  76. package/dist/server/engine/store/wal.d.ts +3 -0
  77. package/dist/server/engine/store/wal.d.ts.map +1 -1
  78. package/dist/server/engine/store/wal.js +35 -1
  79. package/dist/server/engine/store/wal.js.map +1 -1
  80. package/dist/server/index.js +159 -44
  81. package/dist/server/index.js.map +1 -1
  82. package/dist/server/routes/config.d.ts.map +1 -1
  83. package/dist/server/routes/config.js +31 -52
  84. package/dist/server/routes/config.js.map +1 -1
  85. package/dist/server/routes/lambdas.d.ts.map +1 -1
  86. package/dist/server/routes/lambdas.js +77 -0
  87. package/dist/server/routes/lambdas.js.map +1 -1
  88. package/dist/server/routes/services.d.ts.map +1 -1
  89. package/dist/server/routes/services.js +288 -24
  90. package/dist/server/routes/services.js.map +1 -1
  91. package/dist/server/runtime/runtime-worker.js +7 -0
  92. package/dist/server/runtime/runtime-worker.js.map +1 -1
  93. package/dist/server/services/bind-host.d.ts +30 -0
  94. package/dist/server/services/bind-host.d.ts.map +1 -0
  95. package/dist/server/services/bind-host.js +182 -0
  96. package/dist/server/services/bind-host.js.map +1 -0
  97. package/dist/server/services/cache-manager.d.ts +7 -1
  98. package/dist/server/services/cache-manager.d.ts.map +1 -1
  99. package/dist/server/services/cache-manager.js +4 -14
  100. package/dist/server/services/cache-manager.js.map +1 -1
  101. package/dist/server/services/cloudformation-parser.js +3 -3
  102. package/dist/server/services/cloudformation-parser.js.map +1 -1
  103. package/dist/server/services/config-manager.d.ts +53 -35
  104. package/dist/server/services/config-manager.d.ts.map +1 -1
  105. package/dist/server/services/config-manager.js +600 -185
  106. package/dist/server/services/config-manager.js.map +1 -1
  107. package/dist/server/services/dynamo-explorer.d.ts +1 -0
  108. package/dist/server/services/dynamo-explorer.d.ts.map +1 -1
  109. package/dist/server/services/dynamo-explorer.js +28 -6
  110. package/dist/server/services/dynamo-explorer.js.map +1 -1
  111. package/dist/server/services/explorer-region.d.ts +2 -0
  112. package/dist/server/services/explorer-region.d.ts.map +1 -0
  113. package/dist/server/services/explorer-region.js +23 -0
  114. package/dist/server/services/explorer-region.js.map +1 -0
  115. package/dist/server/services/gateway-manager.d.ts.map +1 -1
  116. package/dist/server/services/gateway-manager.js +18 -5
  117. package/dist/server/services/gateway-manager.js.map +1 -1
  118. package/dist/server/services/invocation-activity.d.ts +45 -0
  119. package/dist/server/services/invocation-activity.d.ts.map +1 -0
  120. package/dist/server/services/invocation-activity.js +102 -0
  121. package/dist/server/services/invocation-activity.js.map +1 -0
  122. package/dist/server/services/lambda-runtime-manager.d.ts +8 -1
  123. package/dist/server/services/lambda-runtime-manager.d.ts.map +1 -1
  124. package/dist/server/services/lambda-runtime-manager.js +131 -11
  125. package/dist/server/services/lambda-runtime-manager.js.map +1 -1
  126. package/dist/server/services/opensearch-explorer.d.ts.map +1 -1
  127. package/dist/server/services/opensearch-explorer.js +14 -10
  128. package/dist/server/services/opensearch-explorer.js.map +1 -1
  129. package/dist/server/services/project-scope.d.ts +2 -0
  130. package/dist/server/services/project-scope.d.ts.map +1 -0
  131. package/dist/server/services/project-scope.js +23 -0
  132. package/dist/server/services/project-scope.js.map +1 -0
  133. package/dist/server/services/queue-inspector.d.ts.map +1 -1
  134. package/dist/server/services/queue-inspector.js +15 -6
  135. package/dist/server/services/queue-inspector.js.map +1 -1
  136. package/dist/server/services/resource-provisioner.d.ts +19 -3
  137. package/dist/server/services/resource-provisioner.d.ts.map +1 -1
  138. package/dist/server/services/resource-provisioner.js +153 -26
  139. package/dist/server/services/resource-provisioner.js.map +1 -1
  140. package/dist/server/services/s3-explorer.js +4 -4
  141. package/dist/server/services/s3-explorer.js.map +1 -1
  142. package/dist/server/services/secrets-explorer.d.ts.map +1 -1
  143. package/dist/server/services/secrets-explorer.js +10 -3
  144. package/dist/server/services/secrets-explorer.js.map +1 -1
  145. package/dist/server/services/seed-manager.d.ts.map +1 -1
  146. package/dist/server/services/seed-manager.js +28 -14
  147. package/dist/server/services/seed-manager.js.map +1 -1
  148. package/dist/server/services/serverless-state-parser.d.ts +10 -0
  149. package/dist/server/services/serverless-state-parser.d.ts.map +1 -1
  150. package/dist/server/services/serverless-state-parser.js +20 -1
  151. package/dist/server/services/serverless-state-parser.js.map +1 -1
  152. package/dist/server/services/service-registrar.d.ts +13 -1
  153. package/dist/server/services/service-registrar.d.ts.map +1 -1
  154. package/dist/server/services/service-registrar.js +80 -16
  155. package/dist/server/services/service-registrar.js.map +1 -1
  156. package/dist/server/services/service-scanner.d.ts +25 -0
  157. package/dist/server/services/service-scanner.d.ts.map +1 -0
  158. package/dist/server/services/service-scanner.js +219 -0
  159. package/dist/server/services/service-scanner.js.map +1 -0
  160. package/dist/server/services/source-watcher.d.ts.map +1 -1
  161. package/dist/server/services/source-watcher.js +31 -6
  162. package/dist/server/services/source-watcher.js.map +1 -1
  163. package/dist/ui/assets/ApisPage-CTOcNna-.js +1 -0
  164. package/dist/ui/assets/BucketDetailPage-BXW8-FrE.js +1 -0
  165. package/dist/ui/assets/BucketsPage-DY_ITepb.js +1 -0
  166. package/dist/ui/assets/DynamoPage-BnFFKnIU.js +1 -0
  167. package/dist/ui/assets/DynamoTablePage-BLCuyJHU.js +3 -0
  168. package/dist/ui/assets/LambdaDetailPage-D4rZNLRT.js +4 -0
  169. package/dist/ui/assets/LambdasPage-Bg-MWFBb.js +1 -0
  170. package/dist/ui/assets/OnboardingPage-nnl313Fm.js +1 -0
  171. package/dist/ui/assets/OpenSearchCollectionPage-Dn15lH_F.js +1 -0
  172. package/dist/ui/assets/OpenSearchPage-CgD5isok.js +1 -0
  173. package/dist/ui/assets/{OverviewPage-DS6XJ7kG.css → OverviewPage-CSWY71vZ.css} +1 -1
  174. package/dist/ui/assets/OverviewPage-m5sUr8IN.js +1 -0
  175. package/dist/ui/assets/QueueDetailPage-Ch_FDDoN.js +1 -0
  176. package/dist/ui/assets/QueuesPage-C_yDuGDu.js +1 -0
  177. package/dist/ui/assets/SecretsPage-BSGMSoqy.js +1 -0
  178. package/dist/ui/assets/ServiceDetailPage-6KyohyjI.js +2 -0
  179. package/dist/ui/assets/ServicesPage-BbSetOMo.js +2 -0
  180. package/dist/ui/assets/SettingsPage-BgF1jFr6.js +1 -0
  181. package/dist/ui/assets/engine-D0mFFNbL.js +1 -0
  182. package/dist/ui/assets/index-D627ZyYU.css +1 -0
  183. package/dist/ui/assets/index-DuSPnI19.js +37 -0
  184. package/dist/ui/assets/onboarding-DQmlvyWU.js +1 -0
  185. package/dist/ui/assets/resourceIcons-baecADXA.js +1 -0
  186. package/dist/ui/assets/triggerIcons-DIPWQkJ3.js +1 -0
  187. package/dist/ui/favicon.svg +37 -0
  188. package/dist/ui/index.html +5 -3
  189. package/package.json +11 -11
  190. package/dist/server/engine/aoss-sidecar.d.ts +0 -13
  191. package/dist/server/engine/aoss-sidecar.d.ts.map +0 -1
  192. package/dist/server/engine/aoss-sidecar.js +0 -93
  193. package/dist/server/engine/aoss-sidecar.js.map +0 -1
  194. package/dist/server/engine/backends/localstack-backend.d.ts +0 -25
  195. package/dist/server/engine/backends/localstack-backend.d.ts.map +0 -1
  196. package/dist/server/engine/backends/localstack-backend.js +0 -199
  197. package/dist/server/engine/backends/localstack-backend.js.map +0 -1
  198. package/dist/server/engine/engine-backend.d.ts +0 -17
  199. package/dist/server/engine/engine-backend.d.ts.map +0 -1
  200. package/dist/server/engine/engine-backend.js +0 -7
  201. package/dist/server/engine/engine-backend.js.map +0 -1
  202. package/dist/server/services/localstack-manager.d.ts +0 -17
  203. package/dist/server/services/localstack-manager.d.ts.map +0 -1
  204. package/dist/server/services/localstack-manager.js +0 -31
  205. package/dist/server/services/localstack-manager.js.map +0 -1
  206. package/dist/ui/assets/ApisPage-CXYcWDhm.js +0 -1
  207. package/dist/ui/assets/BucketDetailPage-BwS2--Pe.js +0 -1
  208. package/dist/ui/assets/BucketsPage-khuiYc9C.js +0 -1
  209. package/dist/ui/assets/DynamoPage-aYo0QC0N.js +0 -1
  210. package/dist/ui/assets/DynamoTablePage-rFuV2pgd.js +0 -3
  211. package/dist/ui/assets/LambdaDetailPage-CJmu8M_N.js +0 -4
  212. package/dist/ui/assets/LambdasPage-DniNyKMe.js +0 -1
  213. package/dist/ui/assets/OpenSearchCollectionPage-BBwjgbB9.js +0 -1
  214. package/dist/ui/assets/OpenSearchPage-D5RxJGRo.js +0 -1
  215. package/dist/ui/assets/OverviewPage-Cyt1Xdp8.js +0 -1
  216. package/dist/ui/assets/QueueDetailPage-3FPsowIp.js +0 -1
  217. package/dist/ui/assets/QueuesPage-CUoDDgHr.js +0 -1
  218. package/dist/ui/assets/SecretsPage-B_mTnUf3.js +0 -1
  219. package/dist/ui/assets/ServiceDetailPage-BcdSjA6e.js +0 -2
  220. package/dist/ui/assets/ServicesPage-CU6fb3f8.js +0 -2
  221. package/dist/ui/assets/SettingsPage-BG8cYxgC.js +0 -1
  222. package/dist/ui/assets/index-BxwA-UT0.css +0 -1
  223. package/dist/ui/assets/index-DGsXIPJ-.js +0 -37
  224. package/packages/serverless-plugin/README.md +0 -115
  225. package/packages/serverless-plugin/dist/index.d.ts +0 -2
  226. package/packages/serverless-plugin/dist/index.d.ts.map +0 -1
  227. package/packages/serverless-plugin/dist/index.js +0 -107
  228. package/packages/serverless-plugin/dist/index.js.map +0 -1
@@ -1,6 +1,7 @@
1
1
  import fs from 'fs';
2
2
  import os from 'os';
3
3
  import path from 'path';
4
+ import { projectCacheSegment } from './project-scope.js';
4
5
  // Symlink-stable spelling of a directory (e.g. macOS /tmp -> /private/tmp),
5
6
  // so the project identity matches the plugin's symlink-resolved process.cwd().
6
7
  function realpathOrSelf(dir) {
@@ -11,41 +12,225 @@ function realpathOrSelf(dir) {
11
12
  return dir;
12
13
  }
13
14
  }
14
- // Two config keys are deliberately absent (and rejected on write):
15
- // `localstackAuthToken` — the supported channel is the LOCALSTACK_AUTH_TOKEN
16
- // env var, the token must never transit the dashboard API — and `secrets`,
17
- // which holds seed material that belongs in the file/env only.
18
- const BLOCKED_CONFIG_KEYS = new Set(['localstackAuthToken', 'secrets']);
15
+ // One port for the whole stack: the REST API, the dashboard and the AWS wire
16
+ // all answer here by default. Deliberately outside 4566–4599, which a real
17
+ // LocalStack install intercepts on some hosts (Docker Desktop/WSL2).
18
+ //
19
+ // Setting `serverPort` and `selfEngine.port` to DIFFERENT values splits them
20
+ // back onto two listeners — see isSingleListener().
21
+ const DEFAULT_PORT = 14566;
22
+ // `secrets` is deliberately absent (and rejected on write): it holds seed
23
+ // material that belongs in the config file or the environment, never in a
24
+ // payload that crosses the dashboard API.
25
+ const BLOCKED_CONFIG_KEYS = new Set(['secrets']);
26
+ // ---------------------------------------------------------------------------
27
+ // What a package command written through PUT /api/config may become
28
+ // ---------------------------------------------------------------------------
29
+ //
30
+ // A package command is handed to serverless-packager.ts, which tokenizes it and
31
+ // calls spawn(firstToken, restTokens) with `packageArgs` appended to the argv.
32
+ // PUT /api/config is unauthenticated, so an unconstrained command — or an
33
+ // unconstrained arg list — is arbitrary code execution on this host:
34
+ // `PUT /api/config {"packageCommand":"/bin/sh","packageArgs":["-c","…"]}`
35
+ // followed by `POST /api/services/package` runs whatever the caller wrote. That
36
+ // is the same class the /start and /install runner allowlists already close;
37
+ // this is the third door into it.
38
+ //
39
+ // Fencing only the command's FIRST token does NOT close it, and that version of
40
+ // this check was walked straight through: every package manager on the list is
41
+ // itself an interpreter, one subcommand in.
42
+ //
43
+ // npm exec -c '<shell string>' spawn('npm', ['exec','-c',…]) runs the string
44
+ // npx -c '<shell string>' as a shell command — no shell:true required,
45
+ // the payload is read straight off the argv
46
+ // npx -y <pkg> Ā· npm exec -p <pkg> Ā· yarn dlx Ā· pnpm dlx fetch and run any package
47
+ // yarn node -e '<js>' Ā· yarn exec Ā· pnpm exec run an interpreter
48
+ // npm i <pkg> Ā· yarn add <pkg> run a fetched package's install scripts
49
+ //
50
+ // Each of those satisfies "the first token names a package manager". So the
51
+ // fence is a grammar over the WHOLE command instead of a check on one token —
52
+ // the shapes below and nothing else:
53
+ //
54
+ // npm | yarn | pnpm run | run-script [script] [args…]
55
+ // serverless | sls | osls package [args…]
56
+ // npx [-y|--yes]… serverless|sls|osls[@version] package [args…]
57
+ //
58
+ // plus BLOCKED_PACKAGE_FLAGS applied to every token of the command AND to every
59
+ // `packageArgs` element, since those reach the same argv without passing
60
+ // through the tokenizer at all.
61
+ //
62
+ // RESIDUAL, stated plainly rather than papered over. `npm run <script>` still
63
+ // runs whatever the service's own package.json declares, and `serverless
64
+ // package` still reads the service's own serverless.yml (plugins included).
65
+ // Both are the project's own code, which the operator already trusts by
66
+ // pointing LSS at the directory; and forbidding them would mean the dashboard
67
+ // could not set a package command at all, which is what the onboarding flow is
68
+ // built on. What is gone is the part that made the /start and /install
69
+ // allowlists worthless: through this API a caller can no longer choose the
70
+ // program, only ask the project's own build to run.
71
+ //
72
+ // The fence is on the API and deliberately NOT on the read path — a
73
+ // hand-edited lss.config.json and LSS_PACKAGE_COMMAND are the operator's own
74
+ // shell, and anyone who can write either already runs code on this host.
75
+ /**
76
+ * Runner names accepted as a command's first token, in the order the error
77
+ * message lists them. No entry contains a path separator, so membership alone
78
+ * rejects `/bin/sh`, `/usr/bin/env`, `./tool` and `..\x.exe`.
79
+ */
80
+ const ALLOWED_PACKAGE_RUNNERS = [
81
+ 'npm', 'npx', 'yarn', 'pnpm', 'serverless', 'sls', 'osls',
82
+ ];
83
+ /**
84
+ * The subcommands each runner may be followed by — the wall the first-token
85
+ * check was missing. `run`/`run-script` execute a script the project declared;
86
+ * every other verb these CLIs expose either takes a command off the argv
87
+ * (`exec`, `x`, `node`, `dlx`) or fetches a package and runs its code (`add`,
88
+ * `install`, `create`, `plugin install`). The Serverless CLIs get `package` for
89
+ * the same reason: `serverless plugin install <pkg>` is a fetch-and-run, and
90
+ * packaging is the only thing this setting exists to do.
91
+ *
92
+ * A bare `yarn package` (yarn 1's implicit `run`) is rejected on purpose: the
93
+ * implicit form is exactly what makes `yarn node -e '<js>'` and `yarn dlx <pkg>`
94
+ * indistinguishable from a script name, so the explicit `yarn run package` is
95
+ * required instead. `npx` is absent because it is a launcher rather than a
96
+ * program — validatePackageCommand() strips it and validates what it launches.
97
+ */
98
+ const PACKAGE_RUNNER_VERBS = {
99
+ npm: ['run', 'run-script'],
100
+ yarn: ['run'],
101
+ pnpm: ['run'],
102
+ serverless: ['package'],
103
+ sls: ['package'],
104
+ osls: ['package'],
105
+ };
106
+ /**
107
+ * The packages `npx` may fetch and run. Restricted to the Serverless CLIs
108
+ * because that is the entire reason npx appears here — LSS's own default is
109
+ * `npx serverless package`, i.e. "run the Serverless CLI without a global
110
+ * install". Anything else npx can reach is a package of the caller's choosing.
111
+ */
112
+ const NPX_PACKAGES = ['serverless', 'sls', 'osls'];
113
+ /**
114
+ * The only flags tolerated between `npx` and the package name. `-y` suppresses
115
+ * the "install it?" prompt and does nothing else; every other npx flag either
116
+ * names a different package to run (`-p`/`--package`) or takes a command string
117
+ * (`-c`/`--call`), which is the bypass itself.
118
+ */
119
+ const NPX_PREFIX_FLAGS = ['-y', '--yes'];
120
+ /**
121
+ * Flags that make an otherwise-allowed command run something other than the
122
+ * project's own build, whichever runner is in front of them. The grammar above
123
+ * pins the program; these pin what that program is allowed to be pointed at:
124
+ *
125
+ * --node-options npm/yarn/pnpm/npx set NODE_OPTIONS on the child, which
126
+ * is `--require <file>` under another name (the same hole
127
+ * BLOCKED_PACKAGE_ENV_KEYS closes on the env side)
128
+ * --script-shell npm chooses the binary that interprets the script;
129
+ * --shell/--shell-mode are the yarn/pnpm spellings
130
+ * --call npm exec's inline command string
131
+ * --userconfig/--globalconfig/--use-yarnrc
132
+ * point the manager at another rc file, which can itself
133
+ * set script-shell, node-options or yarn 1's `yarn-path`
134
+ * --registry `npx serverless package` fetches `serverless` when the
135
+ * service has no local copy, and npm's arg parser reads
136
+ * its own config flags from anywhere in the argv — so a
137
+ * registry named after the package name still decides
138
+ * which code that fetch runs
139
+ * --prefix/--cwd/--dir run the scripts of a package.json somewhere else
140
+ * --eval/--print/--require/--import/--loader/--experimental-loader/--input-type
141
+ * node's own code-loading flags, refused in case any
142
+ * runner forwards its argv to node
143
+ *
144
+ * Deliberately absent: `--package` (npm exec's package selector) and every
145
+ * short flag. Both are already unreachable — `npm exec` is not an allowed verb
146
+ * and npx accepts no flag but `-y` — while `-c`, `-p` and `-r` are `--config`,
147
+ * `--package` and `--region` to the Serverless CLI, so blocking them would
148
+ * break `sls package -c custom.yml` for nothing.
149
+ *
150
+ * Compared with dashes and underscores stripped, so `--nodeOptions` and
151
+ * `--node_options` cannot spell their way past the set.
152
+ */
153
+ const BLOCKED_PACKAGE_FLAGS = new Set([
154
+ 'nodeoptions',
155
+ 'scriptshell',
156
+ 'shell',
157
+ 'shellmode',
158
+ 'call',
159
+ 'userconfig',
160
+ 'globalconfig',
161
+ 'useyarnrc',
162
+ 'registry',
163
+ 'prefix',
164
+ 'cwd',
165
+ 'dir',
166
+ 'eval',
167
+ 'print',
168
+ 'require',
169
+ 'import',
170
+ 'loader',
171
+ 'experimentalloader',
172
+ 'inputtype',
173
+ ]);
174
+ // Env vars that make the spawned child load attacker-chosen code no matter
175
+ // WHICH binary runs, so they walk straight around the runner allowlist above:
176
+ // NODE_OPTIONS `--require /tmp/x.js` into every node process
177
+ // NODE_REPL_EXTERNAL_MODULE loads a module into node ahead of the program
178
+ // LD_PRELOAD / LD_AUDIT inject a shared object into the dynamic linker
179
+ // LD_LIBRARY_PATH re-points the linker at attacker libraries
180
+ // DYLD_INSERT_LIBRARIES / DYLD_LIBRARY_PATH the macOS spellings of both
181
+ // PATH decides WHICH `npm` runs at all — see below
182
+ // NODE_PATH prepends a module search root for every require
183
+ // Stored lowercase and compared case-insensitively: env lookup is
184
+ // case-insensitive on Windows, so `node_options` would otherwise be the same
185
+ // bypass one keystroke away.
186
+ //
187
+ // PATH is the one that looks harmless and is not. `runServerlessPackage` spawns
188
+ // WITHOUT a shell, but a shell was never what resolved the binary: libuv runs
189
+ // execvp(3) after installing the child's environment, so the child's PATH — not
190
+ // the orchestrator's — decides which file named `npm` executes. Verified by
191
+ // spawning `npm` with a prepended directory: the planted binary ran. That makes
192
+ // PATH equivalent to naming an arbitrary command, which is exactly what
193
+ // ALLOWED_PACKAGE_RUNNERS exists to prevent. The packaging child inherits the
194
+ // orchestrator's PATH and no caller may replace it.
195
+ const BLOCKED_PACKAGE_ENV_KEYS = new Set([
196
+ 'node_options',
197
+ 'node_repl_external_module',
198
+ 'ld_preload',
199
+ 'ld_audit',
200
+ 'ld_library_path',
201
+ 'dyld_insert_libraries',
202
+ 'dyld_library_path',
203
+ 'path',
204
+ 'node_path',
205
+ ]);
19
206
  const EDITABLE_CONFIG_KEYS = {
20
207
  serverPort: { kind: 'port' },
21
- localstackPort: { kind: 'port' },
22
- localstackEndpoint: { kind: 'string' },
23
- mode: { kind: 'string', enum: ['managed', 'external'] },
24
- localstackEdition: { kind: 'string', enum: ['community', 'pro'] },
25
- localstackVersion: { kind: 'string' },
26
- localstackImage: { kind: 'string' },
27
208
  enableDynamoProxy: { kind: 'boolean' },
28
209
  dynamoProxyPort: { kind: 'port' },
29
210
  region: { kind: 'string' },
30
- services: { kind: 'stringArray' },
31
211
  persistence: { kind: 'boolean' },
32
212
  debug: { kind: 'boolean' },
33
213
  seedsDir: { kind: 'string' },
34
214
  stateDir: { kind: 'string' },
35
215
  autoPackage: { kind: 'boolean' },
36
- packageCommand: { kind: 'string' },
37
- packageArgs: { kind: 'stringArray' },
38
- packageEnv: { kind: 'stringRecord' },
216
+ packageCommand: { kind: 'packageCommand' },
217
+ packageArgs: { kind: 'packageArgs' },
218
+ packageEnv: { kind: 'envRecord' },
39
219
  servicePackaging: {
40
220
  kind: 'object',
41
221
  entrySubKeys: {
42
- packageCommand: { kind: 'string' },
43
- packageArgs: { kind: 'stringArray' },
44
- packageEnv: { kind: 'stringRecord' },
222
+ // Same fences as the global keys above — a per-service override is spawned
223
+ // by the exact same code path, so validating only the global copy would
224
+ // leave the door open one nesting level down.
225
+ packageCommand: { kind: 'packageCommand' },
226
+ packageArgs: { kind: 'packageArgs' },
227
+ packageEnv: { kind: 'envRecord' },
45
228
  packageTimeoutMs: { kind: 'positiveInt' },
229
+ packageCwd: { kind: 'projectRelativePath' },
46
230
  },
47
231
  },
48
232
  packageTimeoutMs: { kind: 'positiveInt' },
233
+ scanIgnore: { kind: 'stringArray' },
49
234
  lambdaRuntime: {
50
235
  kind: 'object',
51
236
  subKeys: {
@@ -54,6 +239,9 @@ const EDITABLE_CONFIG_KEYS = {
54
239
  watch: { kind: 'boolean' },
55
240
  invokePortOffset: { kind: 'positiveInt' },
56
241
  invokeHost: { kind: 'string' },
242
+ lazy: { kind: 'boolean' },
243
+ idleTimeoutMs: { kind: 'nonNegativeInt' },
244
+ maxWarmWorkers: { kind: 'nonNegativeInt' },
57
245
  },
58
246
  },
59
247
  serviceRuntime: {
@@ -66,7 +254,6 @@ const EDITABLE_CONFIG_KEYS = {
66
254
  watch: { kind: 'boolean' },
67
255
  },
68
256
  },
69
- engine: { kind: 'string', enum: ['localstack', 'self'] },
70
257
  selfEngine: {
71
258
  kind: 'object',
72
259
  subKeys: {
@@ -79,13 +266,6 @@ const EDITABLE_CONFIG_KEYS = {
79
266
  fallbackEndpoint: { kind: 'string' },
80
267
  },
81
268
  },
82
- aossSidecar: {
83
- kind: 'object',
84
- subKeys: {
85
- enabled: { kind: 'boolean' },
86
- port: { kind: 'port' },
87
- },
88
- },
89
269
  branding: {
90
270
  kind: 'object',
91
271
  subKeys: {
@@ -111,13 +291,6 @@ const EDITABLE_CONFIG_KEYS = {
111
291
  // is unset.
112
292
  const CONFIG_ENV_OVERRIDES = {
113
293
  serverPort: ['LSS_DASHBOARD_PORT', 'PORT'],
114
- localstackPort: ['LSS_LOCALSTACK_PORT'],
115
- localstackEndpoint: ['LSS_LOCALSTACK_ENDPOINT'],
116
- mode: ['LSS_LOCALSTACK_MODE'],
117
- localstackEdition: ['LSS_LOCALSTACK_EDITION'],
118
- localstackVersion: ['LSS_LOCALSTACK_VERSION'],
119
- localstackImage: ['LSS_LOCALSTACK_IMAGE'],
120
- localstackAuthToken: ['LOCALSTACK_AUTH_TOKEN'],
121
294
  // Note: the deprecated unprefixed ENABLE_DYNAMO_PROXY is NOT listed — it is
122
295
  // only a getter-time fallback (isEnableDynamoProxy) that a file value always
123
296
  // beats, so it never masks a saved value.
@@ -132,12 +305,95 @@ const CONFIG_ENV_OVERRIDES = {
132
305
  packageCommand: ['LSS_PACKAGE_COMMAND'],
133
306
  packageTimeoutMs: ['LSS_PACKAGE_TIMEOUT_MS'],
134
307
  lambdaRuntime: ['LSS_LAMBDA_RUNTIME', 'LSS_LAMBDA_EXECUTION', 'LSS_LAMBDA_WATCH', 'LSS_INVOKE_HOST'],
135
- engine: ['LSS_ENGINE'],
136
- selfEngine: ['LSS_ENGINE_PORT'],
308
+ selfEngine: ['LSS_ENGINE_PORT', 'LSS_ENGINE_DATA_DIR'],
137
309
  };
138
310
  function isPlainObject(value) {
139
311
  return typeof value === 'object' && value !== null && !Array.isArray(value);
140
312
  }
313
+ /**
314
+ * The argv a package command would actually spawn, tokenized EXACTLY the way
315
+ * parseCommand() in serverless-packager.ts does (same regex, same
316
+ * per-quoted-segment stripping). Mirroring it is the whole point — the tokens
317
+ * validated here must be the tokens handed to spawn(), or the check and the
318
+ * execution disagree and the grammar means nothing. `"/bin/sh"` resolves to
319
+ * `/bin/sh` (rejected) and `'npm'` to `npm` (accepted), in both places.
320
+ * Keep the two in step if the packager's tokenizer ever changes.
321
+ */
322
+ function packageCommandTokens(raw) {
323
+ const tokens = raw.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) ?? [];
324
+ return tokens.map(token => token.replace(/"([^"]*)"|'([^']*)'/g, '$1$2'));
325
+ }
326
+ /**
327
+ * Comparable spelling of a flag token: the name before any `=value`, lowercased
328
+ * with dashes and underscores removed (`--Node-Options=--require x` →
329
+ * `nodeoptions`). Only the name is compared — the value is what the flag would
330
+ * point at, and every value is dangerous once the name is.
331
+ */
332
+ function normalizeFlagName(token) {
333
+ return token.split('=')[0].toLowerCase().replace(/[-_]/g, '');
334
+ }
335
+ /**
336
+ * A token that redirects the command at a program, shell or rc file of the
337
+ * caller's choosing. Only tokens that look like flags are screened, so a script
338
+ * or file name that happens to collide (`npm run print`) is untouched.
339
+ */
340
+ function isBlockedPackageFlag(token) {
341
+ return token.startsWith('-') && BLOCKED_PACKAGE_FLAGS.has(normalizeFlagName(token));
342
+ }
343
+ function blockedFlagError(label, token) {
344
+ return `"${label}" cannot contain "${token}" — that flag points the package command at a program, shell or config file of the caller's choosing`;
345
+ }
346
+ /** `"run" or "run-script"` — verbs quoted the way the error message reads best. */
347
+ function quotedList(values) {
348
+ const quoted = values.map(value => `"${value}"`);
349
+ return quoted.length === 1 ? quoted[0] : `${quoted.slice(0, -1).join(', ')} or ${quoted[quoted.length - 1]}`;
350
+ }
351
+ /**
352
+ * Validate a `packageCommand` against the grammar documented above:
353
+ * runner → subcommand → free arguments, with `npx` stripped down to the
354
+ * Serverless CLI it launches, and no blocked flag anywhere in the argv.
355
+ */
356
+ function validatePackageCommand(key, raw) {
357
+ const tokens = packageCommandTokens(raw);
358
+ // Two cursors because `npx serverless package` has two layers: `rest` is the
359
+ // slice the grammar is currently reading (it loses the `npx …` prefix), while
360
+ // `runner` names the program that will finally execute and therefore whose
361
+ // subcommand list applies.
362
+ let rest = tokens;
363
+ let runner = rest[0] ?? '';
364
+ if (runner === 'npx') {
365
+ rest = rest.slice(1);
366
+ while (NPX_PREFIX_FLAGS.includes(rest[0])) {
367
+ rest = rest.slice(1);
368
+ }
369
+ const spec = rest[0] ?? '';
370
+ if (spec.startsWith('-')) {
371
+ return `"${key}": npx may carry only ${quotedList(NPX_PREFIX_FLAGS)} before the package name — "${spec}" can name another package to run, or a command string to run as a shell`;
372
+ }
373
+ // `serverless@3.38.0` is the same program with a version pin. The `@` at
374
+ // index 0 belongs to a scope (`@scope/pkg`), which is never in the list.
375
+ const at = spec.lastIndexOf('@');
376
+ runner = at > 0 ? spec.slice(0, at) : spec;
377
+ if (!NPX_PACKAGES.includes(runner)) {
378
+ return `"${key}": npx may only run the Serverless CLI (${NPX_PACKAGES.join(', ')}) — "${spec}" is a package of the caller's choosing`;
379
+ }
380
+ }
381
+ else if (!ALLOWED_PACKAGE_RUNNERS.includes(runner)) {
382
+ return `"${key}" must start with one of: ${ALLOWED_PACKAGE_RUNNERS.join(', ')} — "${runner}" is not an allowed package runner`;
383
+ }
384
+ const verbs = PACKAGE_RUNNER_VERBS[runner];
385
+ const verb = rest[1] ?? '';
386
+ if (!verbs.includes(verb)) {
387
+ return `"${key}": "${runner}" may only be followed by ${quotedList(verbs)} — `
388
+ + (verb
389
+ ? `"${verb}" can run a program the caller chose instead of the project's build`
390
+ : 'the command names no subcommand at all');
391
+ }
392
+ // Screens the whole argv, not just the tail: a blocked flag is blocked
393
+ // wherever it sits, and the runner/verb tokens never start with a dash.
394
+ const blocked = tokens.find(isBlockedPackageFlag);
395
+ return blocked === undefined ? null : blockedFlagError(key, blocked);
396
+ }
141
397
  function validateValue(key, value, spec) {
142
398
  switch (spec.kind) {
143
399
  case 'port':
@@ -148,6 +404,11 @@ function validateValue(key, value, spec) {
148
404
  return Number.isInteger(value) && value > 0
149
405
  ? null
150
406
  : `"${key}" must be a positive integer`;
407
+ // 0 is meaningful for these (idleTimeoutMs: 0 = never unload).
408
+ case 'nonNegativeInt':
409
+ return Number.isInteger(value) && value >= 0
410
+ ? null
411
+ : `"${key}" must be an integer >= 0`;
151
412
  case 'boolean':
152
413
  return typeof value === 'boolean' ? null : `"${key}" must be a boolean`;
153
414
  case 'string':
@@ -157,14 +418,75 @@ function validateValue(key, value, spec) {
157
418
  return spec.enum && !spec.enum.includes(value)
158
419
  ? `"${key}" must be one of: ${spec.enum.join(', ')}`
159
420
  : null;
421
+ // A command that is spawned, not just stored: on top of the non-empty string
422
+ // check it must match the package-command grammar. See PACKAGE_RUNNER_VERBS
423
+ // for why an unfenced string — or a first-token-only check — is remote code
424
+ // execution here, and for the residual the grammar does not close.
425
+ case 'packageCommand': {
426
+ if (typeof value !== 'string' || value.length === 0) {
427
+ return `"${key}" must be a non-empty string`;
428
+ }
429
+ return validatePackageCommand(key, value);
430
+ }
431
+ // Appended to the spawned argv verbatim, with no tokenizer in between, so
432
+ // `packageCommand:"npm"` + `packageArgs:["exec","-c","<shell>"]` would be
433
+ // the identical bypass one key across. The grammar above already pins the
434
+ // program and its subcommand; the flag screen is what stops an arg from
435
+ // re-pointing that program somewhere else.
436
+ case 'packageArgs': {
437
+ if (!Array.isArray(value) || !value.every(v => typeof v === 'string')) {
438
+ return `"${key}" must be an array of strings`;
439
+ }
440
+ const index = value.findIndex(isBlockedPackageFlag);
441
+ return index === -1 ? null : blockedFlagError(`${key}[${index}]`, value[index]);
442
+ }
443
+ // A plain list of match patterns (scanIgnore) — never spawned, so the
444
+ // package-args screen above does not apply; it just has to be a list of
445
+ // non-empty strings so the matcher cannot be fed garbage.
160
446
  case 'stringArray':
161
- return Array.isArray(value) && value.every(v => typeof v === 'string')
447
+ return Array.isArray(value) && value.every(v => typeof v === 'string' && v.length > 0)
162
448
  ? null
163
- : `"${key}" must be an array of strings`;
449
+ : `"${key}" must be an array of non-empty strings`;
450
+ // A directory INSIDE the project, written the way the config file spells
451
+ // every other path: relative, forward slashes. Rejected lexically here
452
+ // (absolute, or any `..` segment) and again by realpath when it is resolved
453
+ // — this one becomes the cwd of a spawned child, so "inside the project"
454
+ // has to hold for the path that is actually used, symlinks included.
455
+ case 'projectRelativePath': {
456
+ if (typeof value !== 'string' || value.length === 0) {
457
+ return `"${key}" must be a non-empty string`;
458
+ }
459
+ if (path.isAbsolute(value) || value.split(/[\\/]/).includes('..')) {
460
+ return `"${key}" must be a path inside the project root, written relative to it (no leading "/" and no "..")`;
461
+ }
462
+ return null;
463
+ }
164
464
  case 'stringRecord':
165
465
  return isPlainObject(value) && Object.values(value).every(v => typeof v === 'string')
166
466
  ? null
167
467
  : `"${key}" must be an object of string values`;
468
+ // Written as a PATCH, one variable at a time (mergeEnvRecord), so `null` is
469
+ // a value with a meaning here — "remove this variable" — and not just the
470
+ // top-level "delete the block" spelling. Anything else that is not a string
471
+ // is still refused: the map ends up in a child process's environment.
472
+ case 'envRecord': {
473
+ if (!isPlainObject(value) || !Object.values(value).every(v => typeof v === 'string' || v === null)) {
474
+ return `"${key}" must be an object of string values (null removes a key)`;
475
+ }
476
+ // An env map is merged into the packaging child's environment, where a
477
+ // handful of names are loader/interpreter hooks: they run code in the
478
+ // child whatever program the command grammar let through, which would
479
+ // make that grammar decorative. Screened per variable being SET —
480
+ // REMOVING one of those names only ever takes capability away, and is the
481
+ // one way the dashboard can undo a hand-edited config file that carries
482
+ // it (the fence guards the API, not the read path).
483
+ for (const [envKey, envValue] of Object.entries(value)) {
484
+ if (envValue !== null && BLOCKED_PACKAGE_ENV_KEYS.has(envKey.toLowerCase())) {
485
+ return `"${key}.${envKey}" cannot be set — it injects code into the packaging process`;
486
+ }
487
+ }
488
+ return null;
489
+ }
168
490
  case 'object': {
169
491
  if (!isPlainObject(value)) {
170
492
  return `"${key}" must be an object`;
@@ -172,7 +494,12 @@ function validateValue(key, value, spec) {
172
494
  // null subvalues are always allowed — they delete the subkey on merge.
173
495
  if (spec.subKeys) {
174
496
  for (const [subKey, subValue] of Object.entries(value)) {
175
- const subSpec = spec.subKeys[subKey];
497
+ // hasOwnProperty, not a plain read: `constructor`/`toString` would
498
+ // otherwise resolve to an Object.prototype member, pass this guard
499
+ // and reach the file as unvalidated JSON.
500
+ const subSpec = Object.prototype.hasOwnProperty.call(spec.subKeys, subKey)
501
+ ? spec.subKeys[subKey]
502
+ : undefined;
176
503
  if (!subSpec)
177
504
  return `unknown config key "${key}.${subKey}"`;
178
505
  if (subValue === null)
@@ -189,7 +516,9 @@ function validateValue(key, value, spec) {
189
516
  if (!isPlainObject(entryValue))
190
517
  return `"${key}.${entryKey}" must be an object`;
191
518
  for (const [subKey, subValue] of Object.entries(entryValue)) {
192
- const subSpec = spec.entrySubKeys[subKey];
519
+ const subSpec = Object.prototype.hasOwnProperty.call(spec.entrySubKeys, subKey)
520
+ ? spec.entrySubKeys[subKey]
521
+ : undefined;
193
522
  if (!subSpec)
194
523
  return `unknown config key "${key}.${entryKey}.${subKey}"`;
195
524
  if (subValue === null)
@@ -204,6 +533,51 @@ function validateValue(key, value, spec) {
204
533
  }
205
534
  }
206
535
  }
536
+ /**
537
+ * Merge an `envRecord` patch over the map already in the file: a string sets or
538
+ * replaces one variable, `null` removes one, and every variable the patch does
539
+ * not name is left exactly as it was.
540
+ *
541
+ * Replacing the whole map — what every other non-`object` key does on write —
542
+ * cannot work for `packageEnv`, because its values are **write-only**:
543
+ * `GET /api/config` reports `packageEnvKeys`, the NAMES alone (see
544
+ * routes/config.ts), so the values never leave this process and a client
545
+ * editing one variable has nothing to resend for its siblings. Under a
546
+ * wholesale replace, saving `{"A":"v"}` would silently delete `B` and `C` —
547
+ * secrets the editor was never shown and cannot recover. So the write is a
548
+ * patch, for the same reason the one-level block merge above exists (a partial
549
+ * edit must not drop what it could not see), one level deeper.
550
+ *
551
+ * A file value that is not an object is replaced rather than merged into,
552
+ * mirroring how a malformed `object` block is treated.
553
+ */
554
+ function mergeEnvRecord(existing, patch) {
555
+ const merged = isPlainObject(existing) ? { ...existing } : {};
556
+ for (const [envKey, envValue] of Object.entries(patch)) {
557
+ if (envValue === null) {
558
+ delete merged[envKey];
559
+ }
560
+ else {
561
+ merged[envKey] = envValue;
562
+ }
563
+ }
564
+ return merged;
565
+ }
566
+ /**
567
+ * Write a merged block back, or drop the key when the merge emptied it — an
568
+ * empty block carries no setting and would only add noise to the file the human
569
+ * reviews. It is load-bearing for a map entry: an empty object is truthy, so a
570
+ * leftover `{}` would shadow a basename-keyed entry for the same service and
571
+ * silently disable that override.
572
+ */
573
+ function setOrDeleteBlock(target, key, merged) {
574
+ if (Object.keys(merged).length === 0) {
575
+ delete target[key];
576
+ }
577
+ else {
578
+ target[key] = merged;
579
+ }
580
+ }
207
581
  // Thrown by updateConfig so the route can answer 400 with every problem at
208
582
  // once instead of failing on the first.
209
583
  export class ConfigValidationError extends Error {
@@ -249,15 +623,16 @@ export class ConfigManager {
249
623
  this.config = JSON.parse(content);
250
624
  this.configPath = candidate;
251
625
  console.log(`āœ… Configuration loaded from ${candidate}`);
252
- break;
253
626
  }
254
627
  catch (error) {
255
628
  console.warn(`āš ļø Failed to parse config file ${candidate}:`, error instanceof Error ? error.message : 'Unknown error');
629
+ continue;
256
630
  }
631
+ break;
257
632
  }
258
633
  }
259
- // Environment variables override file values so secrets like LOCALSTACK_AUTH_TOKEN
260
- // can be injected without committing them to disk.
634
+ // Environment variables override file values, so an instance can be
635
+ // retargeted (ports, engine data dir, region) without touching the file.
261
636
  this.loadFromEnv();
262
637
  }
263
638
  loadFromEnv() {
@@ -266,33 +641,6 @@ export class ConfigManager {
266
641
  /* istanbul ignore next: the enclosing if guarantees one of these is truthy, so the `|| ''` fallback is unreachable */
267
642
  this.config.serverPort = parseInt(process.env.LSS_DASHBOARD_PORT || process.env.PORT || '', 10);
268
643
  }
269
- if (process.env.LSS_LOCALSTACK_PORT) {
270
- this.config.localstackPort = parseInt(process.env.LSS_LOCALSTACK_PORT, 10);
271
- }
272
- if (process.env.LSS_LOCALSTACK_ENDPOINT) {
273
- this.config.localstackEndpoint = process.env.LSS_LOCALSTACK_ENDPOINT;
274
- }
275
- if (process.env.LSS_LOCALSTACK_MODE) {
276
- const mode = process.env.LSS_LOCALSTACK_MODE.toLowerCase();
277
- if (mode === 'managed' || mode === 'external') {
278
- this.config.mode = mode;
279
- }
280
- }
281
- if (process.env.LSS_LOCALSTACK_EDITION) {
282
- const edition = process.env.LSS_LOCALSTACK_EDITION.toLowerCase();
283
- if (edition === 'community' || edition === 'pro') {
284
- this.config.localstackEdition = edition;
285
- }
286
- }
287
- if (process.env.LSS_LOCALSTACK_VERSION) {
288
- this.config.localstackVersion = process.env.LSS_LOCALSTACK_VERSION;
289
- }
290
- if (process.env.LSS_LOCALSTACK_IMAGE) {
291
- this.config.localstackImage = process.env.LSS_LOCALSTACK_IMAGE;
292
- }
293
- if (process.env.LOCALSTACK_AUTH_TOKEN) {
294
- this.config.localstackAuthToken = process.env.LOCALSTACK_AUTH_TOKEN;
295
- }
296
644
  if (process.env.LSS_ENABLE_DYNAMO_PROXY) {
297
645
  this.config.enableDynamoProxy = process.env.LSS_ENABLE_DYNAMO_PROXY === 'true' || process.env.LSS_ENABLE_DYNAMO_PROXY === '1';
298
646
  }
@@ -302,9 +650,6 @@ export class ConfigManager {
302
650
  if (process.env.AWS_REGION) {
303
651
  this.config.region = process.env.AWS_REGION;
304
652
  }
305
- if (process.env.LSS_SERVICES) {
306
- this.config.services = process.env.LSS_SERVICES.split(',');
307
- }
308
653
  if (process.env.LSS_PERSISTENCE) {
309
654
  this.config.persistence = process.env.LSS_PERSISTENCE === 'true' || process.env.LSS_PERSISTENCE === '1';
310
655
  }
@@ -350,61 +695,32 @@ export class ConfigManager {
350
695
  invokeHost: process.env.LSS_INVOKE_HOST,
351
696
  };
352
697
  }
353
- if (process.env.LSS_ENGINE) {
354
- const engine = process.env.LSS_ENGINE.toLowerCase();
355
- if (engine === 'localstack' || engine === 'self') {
356
- this.config.engine = engine;
357
- }
358
- }
359
698
  if (process.env.LSS_ENGINE_PORT) {
360
699
  this.config.selfEngine = {
361
700
  ...this.config.selfEngine,
362
701
  port: parseInt(process.env.LSS_ENGINE_PORT, 10),
363
702
  };
364
703
  }
704
+ // Give every instance its own engine state without editing a config file:
705
+ // `LSS_ENGINE_DATA_DIR=... LSS_ENGINE_PORT=... lss start` is enough to run a
706
+ // second stack (a test run, a second checkout) beside the dev one.
707
+ if (process.env.LSS_ENGINE_DATA_DIR) {
708
+ this.config.selfEngine = {
709
+ ...this.config.selfEngine,
710
+ dataDir: process.env.LSS_ENGINE_DATA_DIR,
711
+ };
712
+ }
365
713
  }
366
714
  getConfig() {
367
715
  return this.config;
368
716
  }
369
717
  getServerPort() {
370
- return this.config.serverPort ?? (parseInt(process.env.PORT || '', 10) || 3100);
718
+ return this.config.serverPort ?? (parseInt(process.env.PORT || '', 10) || DEFAULT_PORT);
371
719
  }
372
720
  // Alias for backward compatibility
373
721
  getDashboardPort() {
374
722
  return this.getServerPort();
375
723
  }
376
- getLocalStackPort() {
377
- return this.config.localstackPort ?? 4566;
378
- }
379
- getLocalStackEndpoint() {
380
- if (this.config.localstackEndpoint) {
381
- return this.config.localstackEndpoint;
382
- }
383
- const port = this.getLocalStackPort();
384
- return `http://localhost:${port}`;
385
- }
386
- getMode() {
387
- return this.config.mode ?? 'managed';
388
- }
389
- isExternal() {
390
- return this.getMode() === 'external';
391
- }
392
- getLocalStackEdition() {
393
- return this.config.localstackEdition ?? 'community';
394
- }
395
- getLocalStackVersion() {
396
- return this.config.localstackVersion ?? 'latest';
397
- }
398
- getLocalStackImage() {
399
- if (this.config.localstackImage) {
400
- return this.config.localstackImage;
401
- }
402
- const repo = this.getLocalStackEdition() === 'pro' ? 'localstack/localstack-pro' : 'localstack/localstack';
403
- return `${repo}:${this.getLocalStackVersion()}`;
404
- }
405
- getLocalStackAuthToken() {
406
- return this.config.localstackAuthToken;
407
- }
408
724
  isEnableDynamoProxy() {
409
725
  if (this.config.enableDynamoProxy !== undefined) {
410
726
  return this.config.enableDynamoProxy;
@@ -418,9 +734,6 @@ export class ConfigManager {
418
734
  getRegion() {
419
735
  return this.config.region ?? (process.env.AWS_REGION || 'us-east-1');
420
736
  }
421
- getServices() {
422
- return this.config.services ?? ['dynamodb', 'sqs', 'sns', 's3', 'lambda', 'events'];
423
- }
424
737
  isPersistence() {
425
738
  if (this.config.persistence !== undefined) {
426
739
  return this.config.persistence;
@@ -468,18 +781,91 @@ export class ConfigManager {
468
781
  * as the global baseline.
469
782
  */
470
783
  getPackageConfigForService(servicePath) {
471
- const map = this.config.servicePackaging ?? {};
472
- const baseName = path.basename(servicePath);
473
- const configDir = path.resolve(this.configPath ? path.dirname(this.configPath) : process.cwd());
474
- const relPath = path.relative(configDir, servicePath).split(path.sep).join('/');
475
- const perService = map[relPath] ?? map[baseName] ?? {};
784
+ const perService = this.lookupServiceEntry(this.config.servicePackaging, servicePath);
476
785
  return {
477
786
  command: perService.packageCommand ?? this.getPackageCommand(),
478
787
  args: [...(this.config.packageArgs ?? []), ...(perService.packageArgs ?? [])],
479
788
  env: { ...(this.config.packageEnv ?? {}), ...(perService.packageEnv ?? {}) },
480
789
  timeoutMs: perService.packageTimeoutMs ?? this.getPackageTimeoutMs(),
790
+ cwd: this.resolvePackageCwd(perService.packageCwd, servicePath),
481
791
  };
482
792
  }
793
+ /**
794
+ * Where a package command runs. `packageCwd` is resolved against the PROJECT
795
+ * ROOT (not the service dir — the point of the key is to reach a directory
796
+ * above the service) and fenced to it with the same realpath comparison the
797
+ * /install and /package endpoints apply to `servicePath`: this value is the
798
+ * cwd of a spawned child, so an unfenced `../..` would walk the packaging
799
+ * command out of the project entirely. A rejected value falls back to the
800
+ * service directory and says so, rather than silently packaging elsewhere.
801
+ */
802
+ resolvePackageCwd(packageCwd, servicePath) {
803
+ const serviceDir = path.resolve(servicePath);
804
+ if (!packageCwd)
805
+ return serviceDir;
806
+ const projectRoot = this.getProjectRoot();
807
+ const resolved = path.resolve(projectRoot, packageCwd);
808
+ const realRoot = realpathOrSelf(projectRoot);
809
+ const realTarget = realpathOrSelf(resolved);
810
+ const relative = path.relative(realRoot, realTarget);
811
+ if (relative.startsWith('..') || path.isAbsolute(relative)) {
812
+ console.warn(`āš ļø Ignoring packageCwd "${packageCwd}" for ${serviceDir}: it resolves outside the project root (${projectRoot}).`);
813
+ return serviceDir;
814
+ }
815
+ return resolved;
816
+ }
817
+ /**
818
+ * Stacks the scan must not offer. A monorepo always holds a few that are
819
+ * never local by decision — a bootstrap stack deployed once per AWS account,
820
+ * a us-east-1 DNS/certificate stack the engine does not emulate — and they
821
+ * came back as pending work, with warnings, on every single scan. Permanent
822
+ * noise is how a real warning goes unread.
823
+ *
824
+ * Matched with the same key spellings as `servicePackaging`/`serviceRuntime`
825
+ * (path relative to the config file's directory or to the project root, or
826
+ * the directory basename), so one convention covers all three.
827
+ */
828
+ isScanIgnored(servicePath) {
829
+ const ignore = this.config.scanIgnore;
830
+ if (!ignore || ignore.length === 0)
831
+ return false;
832
+ const patterns = new Set(ignore.map(entry => entry.replace(/^\.\//, '').replace(/\/+$/, '')));
833
+ return this.serviceEntryKeys(servicePath).some(key => patterns.has(key));
834
+ }
835
+ /**
836
+ * The keys a `servicePackaging`/`serviceRuntime` entry may be written under,
837
+ * most specific first: the path relative to the config file's directory, the
838
+ * path relative to the project root, then the directory basename.
839
+ *
840
+ * The two relative forms are usually the same string — but not always, and
841
+ * the difference used to silently drop overrides: `getProjectRoot()`
842
+ * realpath-resolves (and falls back to the cwd for a home-directory config),
843
+ * while a raw `dirname(configPath)` does not. A checkout reached through a
844
+ * symlink, or a config loaded from `~`, made the key the dashboard writes
845
+ * (project-root relative, from the scan) unreachable by the lookup. Both
846
+ * spellings resolve here so a written override always applies.
847
+ */
848
+ serviceEntryKeys(servicePath) {
849
+ const target = realpathOrSelf(path.resolve(servicePath));
850
+ const bases = [
851
+ this.configPath ? realpathOrSelf(path.resolve(path.dirname(this.configPath))) : null,
852
+ this.getProjectRoot(),
853
+ ];
854
+ const keys = bases
855
+ .filter((base) => base !== null)
856
+ .map(base => path.relative(base, target).split(path.sep).join('/'))
857
+ .filter(Boolean);
858
+ keys.push(path.basename(target));
859
+ return [...new Set(keys)];
860
+ }
861
+ lookupServiceEntry(map, servicePath) {
862
+ const entries = map ?? {};
863
+ for (const key of this.serviceEntryKeys(servicePath)) {
864
+ if (Object.prototype.hasOwnProperty.call(entries, key))
865
+ return entries[key];
866
+ }
867
+ return {};
868
+ }
483
869
  isLambdaRuntimeEnabled() {
484
870
  return this.config.lambdaRuntime?.enabled ?? true;
485
871
  }
@@ -489,13 +875,36 @@ export class ConfigManager {
489
875
  getInvokePortOffset() {
490
876
  return this.config.lambdaRuntime?.invokePortOffset ?? 10000;
491
877
  }
878
+ // Defer forking a service's worker to its first invocation. On by default:
879
+ // one Node process per registered service is ~48 MB, which a 40-service
880
+ // monorepo pays entirely up front for handlers it may never call.
881
+ isLambdaRuntimeLazy() {
882
+ return this.config.lambdaRuntime?.lazy ?? true;
883
+ }
884
+ // A worker only stays resident while it is being used. 0 disables unloading.
885
+ getLambdaIdleTimeoutMs() {
886
+ const raw = this.config.lambdaRuntime?.idleTimeoutMs;
887
+ if (raw === undefined)
888
+ return 60_000;
889
+ return typeof raw === 'number' && raw > 0 ? raw : 0;
890
+ }
891
+ // Ceiling on resident workers. Default: one per GB of system RAM, clamped to
892
+ // 2..12 — the point is that the host's memory is bounded by how many services
893
+ // are in flight, never by how many are registered. 0 disables the ceiling.
894
+ getLambdaMaxWarmWorkers() {
895
+ const raw = this.config.lambdaRuntime?.maxWarmWorkers;
896
+ if (typeof raw === 'number' && raw >= 0)
897
+ return Math.floor(raw);
898
+ const gib = Math.floor(os.totalmem() / (1024 ** 3));
899
+ return Math.max(2, Math.min(12, gib));
900
+ }
492
901
  getInvokeHost() {
493
902
  if (this.config.lambdaRuntime?.invokeHost) {
494
903
  return this.config.lambdaRuntime.invokeHost;
495
904
  }
496
- // Self engine: nothing runs inside Docker, so callbacks into the invoke
497
- // listeners resolve on the loopback directly.
498
- return this.isSelfEngine() ? '127.0.0.1' : 'host.docker.internal';
905
+ // Nothing runs inside a container, so callbacks into the invoke listeners
906
+ // resolve on the loopback directly.
907
+ return '127.0.0.1';
499
908
  }
500
909
  /**
501
910
  * Resolve the effective runtime settings for one service, merging the global
@@ -505,11 +914,7 @@ export class ConfigManager {
505
914
  * (true for source, false for artifact).
506
915
  */
507
916
  getRuntimeConfigForService(servicePath) {
508
- const map = this.config.serviceRuntime ?? {};
509
- const baseName = path.basename(servicePath);
510
- const configDir = path.resolve(this.configPath ? path.dirname(this.configPath) : process.cwd());
511
- const relPath = path.relative(configDir, servicePath).split(path.sep).join('/');
512
- const perService = map[relPath] ?? map[baseName] ?? {};
917
+ const perService = this.lookupServiceEntry(this.config.serviceRuntime, servicePath);
513
918
  return {
514
919
  enabled: perService.enabled ?? this.isLambdaRuntimeEnabled(),
515
920
  execution: perService.execution ?? this.getLambdaExecutionMode(),
@@ -518,19 +923,13 @@ export class ConfigManager {
518
923
  invokePort: perService.invokePort,
519
924
  };
520
925
  }
521
- getEngineKind() {
522
- return this.config.engine ?? 'localstack';
523
- }
524
- isSelfEngine() {
525
- return this.getEngineKind() === 'self';
526
- }
527
926
  getSelfEngineConfig() {
528
927
  const selfEngine = this.config.selfEngine ?? {};
529
928
  const stateDir = this.getStateDir();
530
929
  const rawDataDir = selfEngine.dataDir
531
- ?? (stateDir ? path.join(stateDir, 'engine') : path.join(os.homedir(), '.lss', 'engine'));
930
+ ?? (stateDir ? path.join(stateDir, 'engine') : this.homeStateDir('engine'));
532
931
  return {
533
- port: selfEngine.port ?? 14566,
932
+ port: selfEngine.port ?? DEFAULT_PORT,
534
933
  dataDir: path.isAbsolute(rawDataDir) ? rawDataDir : path.resolve(process.cwd(), rawDataDir),
535
934
  account: selfEngine.account ?? '000000000000',
536
935
  idleUnloadMs: selfEngine.idleUnloadMs ?? 300000,
@@ -541,27 +940,27 @@ export class ConfigManager {
541
940
  region: this.getRegion(),
542
941
  };
543
942
  }
544
- getAossSidecarConfig() {
545
- // The self engine serves aoss natively — the sidecar must never run there.
546
- const enabled = !this.isSelfEngine() && this.config.aossSidecar?.enabled !== false;
547
- const port = this.config.aossSidecar?.port ?? 14567;
548
- const stateDir = this.getStateDir();
549
- return {
550
- enabled,
551
- port,
552
- endpoint: `http://localhost:${port}`,
553
- // Mirrors the self-engine default so both persistence roots sit side by
554
- // side (<stateDir>/aoss for test isolation, else ~/.lss/aoss).
555
- dataDir: stateDir ? path.join(stateDir, 'aoss') : path.join(os.homedir(), '.lss', 'aoss'),
556
- };
943
+ // Fallback state root when the config declares no stateDir. Scoped to this
944
+ // project: a flat ~/.lss/<kind> meant every project on the machine shared one
945
+ // set of DynamoDB tables, queues and OpenSearch indices, so opening a second
946
+ // repo silently showed the first one's data.
947
+ homeStateDir(kind) {
948
+ return path.join(os.homedir(), '.lss', 'projects', projectCacheSegment(this.getProjectRoot()), kind);
557
949
  }
558
950
  // The AWS endpoint every SDK-based consumer (provisioner, explorers, seeds)
559
- // should target for the active engine.
951
+ // targets. Reads the port directly rather than through getSelfEngineConfig(),
952
+ // which would also resolve the data dir — needless work for a URL, and it
953
+ // drags the project-root lookup into every caller that only wants an endpoint.
560
954
  getEngineEndpoint() {
561
- if (this.isSelfEngine()) {
562
- return `http://localhost:${this.getSelfEngineConfig().port}`;
563
- }
564
- return this.getLocalStackEndpoint();
955
+ return `http://localhost:${this.config.selfEngine?.port ?? DEFAULT_PORT}`;
956
+ }
957
+ /**
958
+ * True when the orchestrator and the engine share one listener — the default,
959
+ * since both ports default to the same value. Give them different values to
960
+ * go back to two listeners.
961
+ */
962
+ isSingleListener() {
963
+ return this.getServerPort() === (this.config.selfEngine?.port ?? DEFAULT_PORT);
565
964
  }
566
965
  getConfigPath() {
567
966
  return this.configPath;
@@ -640,26 +1039,16 @@ export class ConfigManager {
640
1039
  // (per request/registration/seed run) and follows a reload immediately.
641
1040
  resolvedBootValues() {
642
1041
  const selfEngine = this.getSelfEngineConfig();
643
- const aossSidecar = this.getAossSidecarConfig();
644
1042
  return {
645
1043
  serverPort: this.getServerPort(),
646
- localstackPort: this.getLocalStackPort(),
647
- localstackEndpoint: this.getLocalStackEndpoint(),
648
- mode: this.getMode(),
649
- localstackEdition: this.getLocalStackEdition(),
650
- localstackVersion: this.getLocalStackVersion(),
651
- localstackImage: this.getLocalStackImage(),
652
1044
  enableDynamoProxy: this.isEnableDynamoProxy(),
653
1045
  dynamoProxyPort: this.getDynamoProxyPort(),
654
1046
  region: this.getRegion(),
655
- services: this.getServices(),
656
1047
  persistence: this.isPersistence(),
657
1048
  debug: this.isDebug(),
658
1049
  stateDir: this.getStateDir(),
659
- engine: this.getEngineKind(),
660
- // Only the block's OWN settings: region/persistence (self engine) and the
661
- // stateDir-derived dataDir (aoss) are tracked under their own keys above,
662
- // so one change never flags two entries.
1050
+ // Only the block's OWN settings: region/persistence are tracked under
1051
+ // their own keys above, so one change never flags two entries.
663
1052
  selfEngine: {
664
1053
  port: selfEngine.port,
665
1054
  dataDir: selfEngine.dataDir,
@@ -669,7 +1058,6 @@ export class ConfigManager {
669
1058
  fsync: selfEngine.fsync,
670
1059
  fallbackEndpoint: selfEngine.fallbackEndpoint,
671
1060
  },
672
- aossSidecar: { enabled: aossSidecar.enabled, port: aossSidecar.port },
673
1061
  };
674
1062
  }
675
1063
  static diffKeys(before, after) {
@@ -726,7 +1114,9 @@ export class ConfigManager {
726
1114
  errors.push(`"${key}" cannot be edited via the API — set it in the config file or environment directly`);
727
1115
  continue;
728
1116
  }
729
- const spec = EDITABLE_CONFIG_KEYS[key];
1117
+ const spec = Object.prototype.hasOwnProperty.call(EDITABLE_CONFIG_KEYS, key)
1118
+ ? EDITABLE_CONFIG_KEYS[key]
1119
+ : undefined;
730
1120
  if (!spec) {
731
1121
  errors.push(`unknown config key "${key}"`);
732
1122
  continue;
@@ -757,12 +1147,23 @@ export class ConfigManager {
757
1147
  }
758
1148
  }
759
1149
  for (const [key, value] of Object.entries(patch)) {
1150
+ const spec = EDITABLE_CONFIG_KEYS[key];
760
1151
  if (value === null) {
761
1152
  delete fileConfig[key];
762
1153
  }
763
- else if (EDITABLE_CONFIG_KEYS[key].kind === 'object') {
1154
+ else if (spec.kind === 'envRecord') {
1155
+ // Per-variable patch instead of a replace: these values are write-only,
1156
+ // so the client cannot resend the siblings it was never shown and a
1157
+ // replace would delete them. See mergeEnvRecord.
1158
+ setOrDeleteBlock(fileConfig, key, mergeEnvRecord(fileConfig[key], value));
1159
+ }
1160
+ else if (spec.kind === 'object') {
764
1161
  // One-level merge so a partial block edit (e.g. branding.title) never
765
1162
  // drops sibling settings the UI does not round-trip (branding.logo).
1163
+ // Map-shaped blocks (serviceRuntime/servicePackaging) merge one level
1164
+ // deeper for the same reason: onboarding sending { apiPort } for one
1165
+ // service must not drop that entry's execution/packageEnv siblings.
1166
+ const entrySubKeys = spec.entrySubKeys;
766
1167
  const existing = isPlainObject(fileConfig[key])
767
1168
  ? fileConfig[key]
768
1169
  : {};
@@ -771,6 +1172,32 @@ export class ConfigManager {
771
1172
  if (subValue === null) {
772
1173
  delete merged[subKey];
773
1174
  }
1175
+ else if (entrySubKeys && isPlainObject(subValue)) {
1176
+ const entryExisting = isPlainObject(merged[subKey])
1177
+ ? merged[subKey]
1178
+ : {};
1179
+ const entryMerged = { ...entryExisting };
1180
+ for (const [entryKey, entryValue] of Object.entries(subValue)) {
1181
+ if (entryValue === null) {
1182
+ delete entryMerged[entryKey];
1183
+ }
1184
+ else if (entrySubKeys[entryKey].kind === 'envRecord') {
1185
+ // servicePackaging[*].packageEnv is as write-only as the global
1186
+ // map (redactPackaging() in routes/config.ts collapses it the
1187
+ // same way), so it patches per variable too. Validation already
1188
+ // rejected any subkey that is not in entrySubKeys, so the spec
1189
+ // lookup here always lands on a declared one.
1190
+ setOrDeleteBlock(entryMerged, entryKey, mergeEnvRecord(entryMerged[entryKey], entryValue));
1191
+ }
1192
+ else {
1193
+ entryMerged[entryKey] = entryValue;
1194
+ }
1195
+ }
1196
+ // An entry left with no settings carries no meaning and would
1197
+ // shadow a basename-keyed entry for the same service (an empty
1198
+ // object is truthy), so it is dropped rather than written.
1199
+ setOrDeleteBlock(merged, subKey, entryMerged);
1200
+ }
774
1201
  else {
775
1202
  merged[subKey] = subValue;
776
1203
  }
@@ -799,29 +1226,17 @@ export class ConfigManager {
799
1226
  printSummary() {
800
1227
  console.log('\nšŸ“‹ Configuration Summary:');
801
1228
  console.log(` Server Port: ${this.getServerPort()} (http://localhost:${this.getServerPort()})`);
802
- console.log(` Engine: ${this.getEngineKind()}`);
803
- if (this.isSelfEngine()) {
804
- const selfEngine = this.getSelfEngineConfig();
805
- console.log(` Self Engine Port: ${selfEngine.port} (http://localhost:${selfEngine.port})`);
806
- console.log(` Self Engine Data Dir: ${selfEngine.dataDir}`);
807
- if (selfEngine.fallbackEndpoint) {
808
- console.log(` Self Engine Fallback: ${selfEngine.fallbackEndpoint}`);
809
- }
810
- }
811
- else {
812
- console.log(` LocalStack Mode: ${this.getMode()}`);
813
- console.log(` LocalStack Port: ${this.getLocalStackPort()} (${this.getLocalStackEndpoint()})`);
814
- if (!this.isExternal()) {
815
- console.log(` LocalStack Image: ${this.getLocalStackImage()} (${this.getLocalStackEdition()})`);
816
- console.log(` LocalStack Auth Token: ${this.getLocalStackAuthToken() ? 'set' : 'not set'}`);
817
- }
1229
+ const selfEngine = this.getSelfEngineConfig();
1230
+ console.log(` Self Engine Port: ${selfEngine.port} (http://localhost:${selfEngine.port})`);
1231
+ console.log(` Self Engine Data Dir: ${selfEngine.dataDir}`);
1232
+ if (selfEngine.fallbackEndpoint) {
1233
+ console.log(` Self Engine Fallback: ${selfEngine.fallbackEndpoint}`);
818
1234
  }
819
1235
  console.log(` DynamoDB Proxy Enabled: ${this.isEnableDynamoProxy()}`);
820
1236
  if (this.isEnableDynamoProxy()) {
821
1237
  console.log(` DynamoDB Proxy Port: ${this.getDynamoProxyPort()}`);
822
1238
  }
823
1239
  console.log(` AWS Region: ${this.getRegion()}`);
824
- console.log(` Services: ${this.getServices().join(', ')}`);
825
1240
  console.log(` Persistence: ${this.isPersistence()}`);
826
1241
  console.log(` Seeds Dir: ${this.getSeedsDir()}`);
827
1242
  const secretSeedCount = Object.keys(this.getSecretSeeds()).length;