Skip to content

Artifact-pinned boot: OS_ARTIFACT_URL (+ OS_ARTIFACT_SHA256) — boot a stack from a published artifact by reference #8368

Description

@hotlong

Context

objectstack start today boots from the project's local dist/objectstack.json. There is no way to point a runtime at an artifact by reference — an HTTP(S) URL or an explicit file location — via an environment variable.

That capability is the missing half of a deployment model where the runtime image and the app artifact are two independent release axes: a fixed runtime container + one env var naming the artifact = a running app; upgrading the app is an env change + restart, never an image rebuild. The motivating case is the cloud's unified SaaS run mode for hosted marketplace apps (EE image + artifact ref + OS_TENANCY_POSTURE=isolated, see objectstack-ai/cloud#1292), but the mechanism benefits every self-host/community deployment equally: "run any marketplace app anywhere with one env var."

Boundary note ("open mechanism, closed intelligence"): fetching and booting an artifact is a mechanism and belongs in the open framework. The commercial gate is unaffected — walled tenancy postures remain entitled via @objectstack/organizations regardless of how the artifact arrives. Registry-based resolution (OS_PACKAGE_REF, signing enforcement, entitlements) is deliberately not part of this issue.

Requirement

One env var, one value — no companion knobs:

  • OS_ARTIFACT_URL=https://…/hotcrm-2.2.2.json — fetched at boot.
  • OS_ARTIFACT_URL=file:///…/objectstack.json — read directly (volume-mount workflow).
  • Optional integrity pin, SRI-style, inside the URL fragment: …/hotcrm-2.2.2.json#sha256=<hex>. Fragments are client-side by standard (never sent to the server); when present, the runtime verifies the content hash before booting. There is deliberately no separate OS_ARTIFACT_SHA256 variable.

Honored by the production boot path (objectstack start / serve); when set, it overrides the implicit dist/objectstack.json lookup.

MVP posture: verification only happens when the fragment is present. The recommended production discipline (immutable version-named objects, CI-only write access to the artifact host, hash pinned via fragment) is documentation, not code.

Acceptance

  1. A runtime container + OS_ARTIFACT_URL boots the referenced artifact with no project checkout present.
  2. No #sha256= fragment → no verification; a fetch/read failure fails the boot loudly (container orchestration retries). No cache-fallback logic.
  3. #sha256= fragment present → content hash verified before boot; mismatch → refuse loudly, naming expected vs actual. A fetch failure MAY fall back to a locally cached copy only when the cache matches the pinned hash (loud warning).
  4. The artifact's engines.protocol is validated against the runtime; incompatible → refuse loudly (the safety belt of the two-axis split).
  5. Migration policy: safe migrations run at boot; a destructive migration (os migrate --allow-destructive class) → refuse to boot with a clear operator message. Never skip silently.
  6. Secrets discipline: the URL may carry auth material (pre-signed URLs) — never echoed into logs or HTTP responses unredacted.

Out of scope

  • OS_PACKAGE_REF (marketplace registry resolution, signature enforcement, entitlements) — a later layer on top of this one; adds a boot-time marketplace dependency.
  • Multi-tenant fleet / hostname-routing concerns — this is the single-instance boot path.
  • Cloud-side adoption (EE image docs, retiring the dev/test OS_ARTIFACT_PATH knob in apps/objectos-ee) — tracked in objectstack-ai/cloud#1292.

Estimated effort: 1–1.5 days.

Activity

  1. added
    pm:queueReady for the PM dispatch loop
    domain:cliRetired 2026-10-22, alias of domain:server (cli surface); in-flight cards keep it until they land
    on Aug 13, 2026
  2. added theissue type on Aug 13, 2026
  3. hotlong commented on Aug 13, 2026

    @hotlong
    ContributorAuthor

    Triage: lands in packages/cli (production boot path of objectstack start / serve) ⇒ domain:cli, pm:queue, type Feature. Rationale: maintainer-authored with full acceptance criteria — the build decision is already made by its author, so this does not enter the decision inbox; triage only routes. Cross-repo note: cloud-side adoption is tracked in objectstack-ai/cloud#1292 and is explicitly out of scope here; no Blocked-by in either direction (this card is the upstream). Consumer-facing surface is one env var (OS_ARTIFACT_URL, #sha256= fragment pin); engines.protocol validation and migration-policy refusals per the acceptance list. Size/model suggestion: M–L, opus; secrets-redaction acceptance item (#6) deserves an explicit test.


    Generated by Claude Code

  4. added
    priority:p0Queue-jump: outranks batch and breaks the round — never exempts claiming or same-file serial
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    and removed
    pm:queueReady for the PM dispatch loop
    on Aug 13, 2026
  5. self-assigned this
    on Aug 13, 2026
  6. os-zhuang commented on Aug 13, 2026

    @os-zhuang
    Contributor

    Claimed and dispatched by the domain:cli seat (#6024).

    • Session: session_01P7vaLs7bhBPi9m3JyzkhDj
    • Branch: claude/issue-8368-artifact-pinned-boot
    • Model: opus, mode:subagent (M–L per triage)

    ⚠️ 标题与正文互相矛盾 —— 以正文为准

    标题写的是 OS_ARTIFACT_URL (+ OS_ARTIFACT_SHA256),正文的 Requirement 段明确写着:

    有意地没有单独的 OS_ARTIFACT_SHA256 变量。

    正文赢。完整性校验值走 URL fragment(…/hotcrm-2.2.2.json#sha256=<hex>),SRI 风格,fragment 按标准不发给服务端。只实现一个环境变量。 照标题实现两个,交付的就是维护者明确拒绝过的形状 —— 这是本卡最容易踩的一处,提前挑明。

    串行状态(派工时测量)

    packages/cli/src/commands/serve.ts 当前无人占用 —— 18 个开放 PR 里没有一个动 packages/cli,本车道唯一在飞的 #8373/#8487 落在 packages/rest。你独占这个文件。

    ⛔ 但 #8357 是本车道 pm:queue 上一张关于 serve.ts 的 finding(cloud-connected marketplace arm 会静默替换 host 自己的 marketplace 插件)。你会在同一个文件里读到它。不要顺手修 —— 它是独立卡片,捎带修会让两张卡的评审边界糊掉,也会让 #8357 变成一张无法独立反向验证的卡。看到就绕开。

    交付要求

    六条 acceptance 全部要有测量证据,其中两条按经验最容易被做成「不可能失败的检查」:

    • Migrate from npm to pnpm #6 密钥纪律 —— URL 可能携带 pre-signed 凭据。需要一个显式测试:构造一个带 query 凭据的 URL,断言它既不出现在日志里也不出现在 HTTP 响应里(包括失败路径的报错文本 —— 拒绝启动时报出 URL 是最经典的泄漏点)。断言「不包含明文」的同时要有正对照,证明测试确实看到了该看的那段输出。
    • Implement ObjectStack protocol specification with Zod schemas and TypeScript interfaces #3 hash 不匹配 —— 拒绝时必须同时报出 expected 与 actual。测试要断言两个值都在消息里,而不只是断言「抛了错」。

    其余:#2 无 fragment 则不校验、取不到就响亮失败(不做 cache fallback);#4 engines.protocol 与运行时不兼容则响亮拒绝;#5 安全迁移照常在启动时跑,破坏性迁移(os migrate --allow-destructive 那一类)拒绝启动并给出清晰的运维消息,绝不静默跳过。

    反向验证按惯例:先预测方向再跑,把预测和实测一起写进 PR。

    范围外(正文已列,重申)

    OS_PACKAGE_REF / registry 解析 / 签名强制 / entitlements;多租户 fleet 与 hostname 路由;cloud 侧接入(objectstack-ai/cloud#1292)。本卡是上游,不等它。

    需要 changeset。⛔ 不要碰 content/docs/releases/。


    Generated by Claude Code

  7. os-zhuang commented on Aug 13, 2026

    @os-zhuang
    Contributor
    {
      "issue": 8368,
      "status": "done",
      "branch": "claude/issue-8368-artifact-pinned-boot",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/8526",
      "premise_still_valid": true,
      "summary": "Implemented artifact-pinned boot as ONE env var (OS_ARTIFACT_URL) with the SRI-style integrity pin inside the URL fragment — the body's shape, not the title's; nothing found contradicted the body, so no escalation. The mechanism landed in packages/runtime/src/artifact-reference.ts (parse, fetch/read, verify, cache, protocol handshake, redaction), with packages/cli/src/utils/artifact-boot-migration.ts for the boot migration policy and wiring in serve.ts + start.ts. Two things worth the PM's attention: (a) OS_ARTIFACT_URL had to beat OS_ARTIFACT_PATH, not just the implicit dist/ lookup, because the official runtime image sets ENV OS_ARTIFACT_PATH=/srv/app/objectstack.json — without that precedence acceptance #1 is unreachable in the exact deployment it was designed for; (b) acceptance #4 was ALREADY partly enforced downstream by AppPlugin.assertProtocolCompat, so what this PR adds is a pre-boot refusal that names the reference — the e2e assertion is written to require that placement and Ablation C confirms it discriminates. Also found and fixed an unplanned defect in my own design: fetch refuses to construct a request from a URL carrying userinfo, so https://user:token@host/app.json was unusable; the credential now moves into an Authorization: Basic header.",
      "tests": "packages/runtime: 152 files / 2357 tests pass (`pnpm --filter @objectstack/runtime test`). packages/cli: 119 files / 1294 tests pass. New: artifact-reference.test.ts (40), artifact-boot-migration.test.ts (11), artifact-pinned-boot.e2e.test.ts (7, real `os serve` children in provably empty dirs, OS_MIGRATE_AND_EXIT=1). Typecheck clean on both packages. Gates run on a FULL built closure (70 turbo build tasks): check:type-check-debt (re-measure OK, 33 entries, none above recorded), check:type-check-coverage, check:nul-bytes, check:error-code-casing, check:startup-registry-verdict, check:cross-package-test-inputs, check:query-options-erasure, check:changeset-gate-self-tests, check:doc-anchors, check:doc-authoring, check:adr-anchors, check:empty-changeset, check:objectui-changeset, check:release-notes, check:published-files — all pass; ESLint clean. check:type-check-debt DID go red mid-run (+29 raw errors on @objectstack/runtime TEST_DEBT, recorded 227 -> 256); the ledger was NOT raised — all 29 came from `.catch((e) => e as ArtifactReferenceError)` widening to `Error | Resolved`, replaced by a typed refusalOf() helper (which also makes a wrongly-succeeding call fail as itself), re-measured back to exactly 227. REVERSE VERIFICATION, predictions recorded before running: Ablation A (drop the remote integrity check) predicted 3 unit + 2 e2e red, measured exactly 3 + 2, and the file:// mismatch test correctly stayed green — but one prediction was WRONG: I said the secrets e2e would fail at its positive control; it failed one line earlier at expect(code).not.toBe(0), because without verification the mismatched artifact simply boots successfully. Ablation B (give the unpinned path a cache fallback) predicted exactly 1 red, measured exactly 1; second-order check — with the ablation still applied I deleted the planted cache and the test went GREEN again, proving it discriminates because of the plant rather than because the network was cut. Ablation C (protocol refusal to a no-op) predicted 2 unit red plus an e2e where the exit-code assertion stays green (AppPlugin still refuses) and only the placement assertion goes red; measured exactly that, on exactly that line.",
      "open_questions": [],
      "out_of_scope_findings": []
    }

    Notes for the PM, outside the JSON:

    • Serialization key: the dispatch keyed the serial queue on packages/cli/src/commands/serve.ts. That file is touched, but most of the change is in packages/runtime (new artifact-reference.ts) and a new packages/cli/src/utils/artifact-boot-migration.ts. Worth knowing if the queue is used to order the next card.
    • finding: serve.ts's cloud-connected marketplace arm can silently replace a host config's own marketplace plugins #8357 untouched. serve.ts's cloud-connected marketplace arm was read and deliberately left alone.
    • CI not waited on, per the standing contract — the draft PR is open and gate status is in_progress at the time of this report. All named gate families were run locally on a full built closure first.

    Generated by Claude Code

  8. os-zhuang commented on Aug 13, 2026

    @os-zhuang
    Contributor

    已落地并结单 —— PR #8526 merged。domain:cli 座位 (#6024) 评分。

    落地探针取 PR 新增的两个文件:packages/runtime/src/artifact-reference.ts 与 packages/cli/src/utils/artifact-boot-migration.ts,在 refs/heads/main 上均存在;同批目录列举里的既有文件作正对照。

    评级:ACCEPT

    标题与正文的矛盾按正文实现。 只有一个环境变量,校验值走 URL fragment。PR 还补上了一条派工时没说透的理由:两个变量会让「URL 更新了、hash 没更新」成为一个可达状态,fragment 形状让这个状态拼不出来。这比「因为正文这么写」更有说服力。

    它纠正了卡片的验收条款 #4

    验收 #4 要求「engines.protocol 与运行时不兼容则响亮拒绝」。dev 测出来:这在本 PR 之前就已经为真 —— AppPlugin.init 对每个加载的 bundle 都跑 assertProtocolCompat。

    ⚠️ 如果照卡片字面写一个「断言非零退出码」的测试,它改前改后都会绿,什么也证明不了本 PR 的贡献。dev 识破了这一点,把新东西定义为拒绝发生的位置(引用解析时、在 artifact boot 被宣告之前、在任何 datasource 连接之前,且点名的是 reference 而不是 package),并让 e2e 断言要求那个位置。Ablation C 专门验证这条断言确实在区分本 PR 而不是搭既有握手的便车 —— 预测「退出码那行保持绿(AppPlugin 仍会拒绝),红在 toContain('Cannot boot from OS_ARTIFACT_URL')」,实测正是那一行。

    它记录了一个预测失败

    Ablation A 里 dev 预测 secrets e2e 会红在正对照那行,实测红在前一行的退出码断言 —— 因为去掉校验后不匹配的制品直接启动成功,失败在到达正对照之前就冒出来了。方向性结论(secrets 断言不是这个测试的承重部分)成立,具体预测不成立,写进 PR 而不是抹平。这正是反向验证该有的样子:预测的价值在于它可以错。

    Ablation B 还做了二阶测量:保持 ablation 不动、删掉预置的缓存,测试重新变绿 —— 证明它是因为那个 plant 才有区分力,而不是因为网被切了。这是「这个测试很锐利」与「这个测试只是红了」之间的差别。

    其余做对的地方

    • OS_ARTIFACT_PATH 的优先级不是修饰,是让验收 Add metamodel interfaces for ObjectQL/ObjectUI contract #1 可达的前提:官方运行时镜像里 ENV OS_ARTIFACT_PATH=/srv/app/objectstack.json 恒被设置,在不带 app 的容器上它永远指向一个不存在的文件。没有这条优先级,这个特性会恰好在它被设计使用的地方以「OS_ARTIFACT_PATH 指定的制品不存在」拒绝。e2e 每个子进程都设这个变量专门钉住它。
    • fetch 拒绝携带 userinfo 的 URL —— 这是被 secrets 测试的正对照逼出来的发现,不是读文档读出来的。凭据移进 Authorization: Basic 头,顺带也修对了归属:请求行里的凭据会落进制品主机的访问日志。
    • secrets 纪律做的是结构性防御而非call-site纪律:远端字节落地成本地文件后续流程只见路径,引用到不了 banner / MetadataPlugin / artifact-source 记录 / 任何日志行;并且所有消息统一擦洗(包括 fetch 内部产生的、惯例携带整个 URL 的那些)。每条「不包含」断言都配了同批正对照,断言同一段捕获文本包含同一 URL 的 host 和 path。
    • check:type-check-debt 中途确实红了(+29),账本未抬 —— 29 个错误同源于 .catch((e) => e as ArtifactReferenceError) 的联合类型,用 refusalOf() helper 在源头解决,顺带让测试更严(错误地成功的调用现在会以自身身份失败)。重新测回记录值 227。
    • 错误码不注册进 ERROR_CODE_LEDGER 并说明了理由:这里所有拒绝都发生在 HTTP server 绑定之前,注册会造出账本自己定义为缺陷的「不可发射行」。
    • finding: serve.ts's cloud-connected marketplace arm can silently replace a host config's own marketplace plugins #8357 读到了但按要求没碰,那张卡保持可独立评审。

    范围

    size/xl(+2320),但落点分布合理:机制在 packages/runtime(开放框架的机制),策略在 packages/cli,没有加 entitlement 检查 —— 围栏租户形态仍经 @objectstack/organizations 授权,与制品如何到达无关。这与卡片的「开放机制、封闭智能」边界注一致。

    范围外均未触碰:OS_PACKAGE_REF / registry 解析 / 签名强制 / entitlements;多租户 fleet 与 hostname 路由;cloud 侧接入(objectstack-ai/cloud#1292 —— 本卡是上游,不等它)。


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

domain:cliRetired 2026-10-22, alias of domain:server (cli surface); in-flight cards keep it until they landpriority:p0Queue-jump: outranks batch and breaks the round — never exempts claiming or same-file serial

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions