Merge master and reconcile translation records
This commit is contained in:
commit
00a2e76b87
127 files changed
+4167
-557
No files matched your search
+12
-12
@@ -27,17 +27,17 @@
|
||||
en: a01ac57fc71a84f0
|
||||
zh: 3e488ef3da5e4b8c
|
||||
/agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control:
|
||||
en: a55516339b0d49dd
|
||||
zh: 3281df9ea7878fc0
|
||||
en: 725a2d4145c0a75e
|
||||
zh: b98a64532cadeaba
|
||||
/agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control/correspondence-with-rbac:
|
||||
en: a78a2410eb52f777
|
||||
zh: a868bf1b0e690c9f
|
||||
/agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control/resources-and-resource-readers-deepseek-ai-dsh-typert-protocol-deepseek-ai-dsh-typert-registry:
|
||||
en: 625b20552962e268
|
||||
zh: 1136c0fd7cdc6901
|
||||
en: d50ae22978165c57
|
||||
zh: 8c1fcfb7dc2d3367
|
||||
/agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control/endpoint-resources:
|
||||
en: ec1677d8e338e115
|
||||
zh: 6bedd163f208eaa9
|
||||
en: 056a141b31270cba
|
||||
zh: 3f6caafb3a13c192
|
||||
/agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control/roles-bindings-and-policies-deepseek-ai-dsh-api-remote-access:
|
||||
en: e57f396239ea9f56
|
||||
zh: ddb26278b1521ceb
|
||||
@@ -45,8 +45,8 @@
|
||||
en: 3c911f57f0f6ca4b
|
||||
zh: 1509781eeda4c922
|
||||
/agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control/host-access-check-order:
|
||||
en: 6c58dc8777e107ef
|
||||
zh: bc14605c96e4d8f3
|
||||
en: 2d97a251cc4c9db0
|
||||
zh: df072d5a96a9e82f
|
||||
/agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control/forwarded-events:
|
||||
en: 5fe34ee5ec31942a
|
||||
zh: ef39b020b0539b8f
|
||||
@@ -75,14 +75,14 @@
|
||||
en: 305ec45a7cd85ed7
|
||||
zh: d2cd4209a56b1d0c
|
||||
/agent-note-peer-principals-and-method-level-remote-access-control/alternatives-considered:
|
||||
en: 130df2981ba550df
|
||||
zh: e9a0a89af6fb50a3
|
||||
en: 60da38bca6e9c3d6
|
||||
zh: 61ea578fe8185b61
|
||||
/agent-note-peer-principals-and-method-level-remote-access-control/consequences:
|
||||
en: 89452c3c3f042e75
|
||||
zh: 9bcad00a60c65303
|
||||
/agent-note-peer-principals-and-method-level-remote-access-control/consequences/verification:
|
||||
en: 2279a8c153cc5b89
|
||||
zh: 63c0b6462722f0a3
|
||||
en: d7d0909974609247
|
||||
zh: 6ebf20714f8d545b
|
||||
/agent-note-peer-principals-and-method-level-remote-access-control/consequences/costs-and-limits:
|
||||
en: 1e254cec680b1a37
|
||||
zh: 06a31e057ba965aa
|
||||
|
||||
+19
-19
@@ -131,7 +131,7 @@ The single read point is `this.ctx.invocation.peer.principal`; an owner test is
|
||||
|
||||
### Method-level access control
|
||||
|
||||
Resource types and the `resources` readers of `@Remote` live in `@deepseek-ai/dsh-typert-protocol`; resource kinds live in typert beside lookups and Contexts: `TypertResourceMap` declares the kinds, `typert.resources` holds each kind's default wire field, and lookups and Host Context adapters declare the resource kind their wire identity names. The Gateway resolves one call or forwarded event to the resources it touches. Decisions, list filtering, and the access snapshot live in the new package `@deepseek-ai/dsh-api-remote-access` (`packages/api/remote-access`), which reads no argument field. The Gateway and domain packages depend on remote-access only for types and inject `remoteAccess`; their only value dependency is `dsh-typert-protocol`, so `package-dependency-policy` gains no remote-access entry. Business packages define and register roles, bindings, and policies; liveshare is the first user. Apart from the built-in `global`, no resource-kind name appears in remote-access or the Gateway, which follows the [`packages/api` carries generic orchestration only](../../../../docs/defensive-patterns/remote-and-api.md) review rule.
|
||||
Resource types and the `resources` readers of `@Remote` live in `@deepseek-ai/dsh-typert-protocol`; resource kinds live in typert beside lookups and Contexts: `TypertResourceMap` declares the kinds, `typert.resources` holds each kind's default wire field or resolver, and lookups and Host Context adapters declare the resource kind their wire identity names. The Gateway resolves one call or forwarded event to the resources it touches. Decisions, list filtering, and the access snapshot live in the new package `@deepseek-ai/dsh-api-remote-access` (`packages/api/remote-access`), which reads no argument field. The Gateway and domain packages depend on remote-access only for types and inject `remoteAccess`; their only value dependency is `dsh-typert-protocol`, so `package-dependency-policy` gains no remote-access entry. Business packages define and register roles, bindings, and policies; liveshare is the first user. Apart from the built-in `global`, no resource-kind name appears in remote-access or the Gateway, which follows the [`packages/api` carries generic orchestration only](../../../../docs/defensive-patterns/remote-and-api.md) review rule.
|
||||
|
||||
#### Correspondence with RBAC
|
||||
|
||||
@@ -165,7 +165,7 @@ export interface TypertResourceMap {}
|
||||
// @deepseek-ai/dsh-workspace/types: workspace: TypertResource<WorkspaceId>
|
||||
|
||||
// Runtime registries in typert
|
||||
ctx.typert.resources.register(kind, { field }): TypertDisposer
|
||||
ctx.typert.resources.register(kind, { field } | { resolve }): TypertDisposer
|
||||
ctx.typert.lookups.register(key, { parameter, wire, resource?, … }) // resource: the kind its wire id names
|
||||
ctx.typert.contexts.registerHost(key, { wire, resource?, … }) // a TypertHostContextAdapter
|
||||
|
||||
@@ -213,11 +213,11 @@ export function RemoteScope<This extends object, Args extends unknown[], Result>
|
||||
|
||||
| Rule | Content |
|
||||
|---|---|
|
||||
| Syntax | `@Remote({ name: 'page', resources: { session: (request) => request.address.kind === 'session' ? request.address.sessionId : request.address.childSessionId } })`; a Context method writes `@RemoteScope('agent')` and gets its Session resource from the Agent Context's `resource: 'session'`, and writes `@RemoteScope('agent', { resources: { workspace: (request) => request.workspaceId } })` when it needs another resource. Most methods write no `resources` and get their resources from automatic resolution |
|
||||
| Syntax | `@Remote({ name: 'prompt', resources: { session: (request) => request.sessionId } })` when a method must name its resource by hand; `session/page` and `session/follow` instead rely on the registered `session` default resolver; a Context method writes `@RemoteScope('agent')` and gets its Session resource from the Agent Context's `resource: 'session'`, and writes `@RemoteScope('agent', { resources: { workspace: (request) => request.workspaceId } })` when it needs another resource. Most methods write no `resources` and get their resources from automatic resolution |
|
||||
| Resource kinds | The lower-level packages that declare the identity types merge the resource kinds: `session: TypertResource<SessionId>` in `@deepseek-ai/dsh-session/types` and `workspace: TypertResource<WorkspaceId>` in `@deepseek-ai/dsh-workspace/types`. session-controller references the commands package, so a merge in session-controller would make commands reference session-controller and form a project-reference cycle. remote-access builds in `global`, which has no id and stays out of `TypertResourceMap`; typert reserves the name, and `typert.resources.register('global', …)` throws |
|
||||
| Default fields and declarations | The domain that owns a kind registers its default wire field when its controller is constructed: the `SessionController` constructor calls `typert.resources.register('session', { field: 'sessionId' })` and the `WorkspaceController` constructor registers `workspace` → `workspaceId`; registering a kind twice throws. The Agent, Session, and `workspaceFileScope` lookups declare `resource: 'session'`, and the agent package's Agent Context declares `resource: 'session'` |
|
||||
| Resolution precedence | Each resource kind takes, in order: ① that kind's reader in the method's `resources`; ② every lookup parameter that declares that kind as its `resource` contributes its wire identity, and an omitted optional lookup parameter contributes none; when the Context of a Context method declares that kind as its `resource`, the Context's wire identity contributes too; ③ that kind's default field: a top-level named argument of that name when one exists, otherwise that field of the first object argument. A default field names one identity, so an array there is an invalid value |
|
||||
| Argument values | Resource resolution and the `args` of `decide()` read the named arguments validated against their Typert-compiled types: each JSON argument is decoded once by its codec, which drops object fields its type does not declare, and dispatch reuses that value, so a caller cannot change the resources with an undeclared field; lookup parameters and the Context identity keep their wire identities, and no lookup or Agent is resolved at that point |
|
||||
| Default extraction declarations | The owning domain registers either `{ field }` or `{ resolve }`, never both. SessionController registers `resolveSessionResource`; WorkspaceController registers `workspaceId`. Duplicate kinds are rejected. Lookup and Host Context resource declarations remain independent of these defaults |
|
||||
| Resolution precedence | Per kind: explicit method reader, then lookup and Host Context identities, then the registered default field or resolver. A field reads the named top-level parameter or the first object argument. A resolver receives validated named `args` and ordered parameter descriptions once before lookup resolution. Defaults return one non-empty string or undefined; arrays are invalid |
|
||||
| Argument values | Compiled codecs strip undeclared object fields; SRC fallback checks JSON safety without stripping them. Resolution and dispatch use the same validated values. The Session resolver rejects a request object containing both sessionId and address, even when equal. Lookup and Context identities remain wire values until after authorization |
|
||||
| Resolution result | The resources of every kind together are the resources the call touches; with none, the call touches `[global]`; when resources resolve, only those are used and `global` is not added |
|
||||
| Readers | Called with the method's positional arguments: the named arguments mapped by the descriptor back to the method's parameter order (the validated value in a JSON position, the wire identity in a lookup position, and the call's signal in the cancellation position); return one id, an id array (every one must be allowed), or `undefined` (no resource of that kind) |
|
||||
| Invalid values | A reader that throws or returns an empty array, or a read value (including a Context's wire identity) that is not a non-empty string, makes the resources invalid: the Gateway refuses the call with `remote-access/denied` (reason `resource-invalid`) without asking `decide()` |
|
||||
@@ -228,7 +228,7 @@ export function RemoteScope<This extends object, Args extends unknown[], Result>
|
||||
|
||||
#### Endpoint resources
|
||||
|
||||
The table lists the endpoints #3854 viewers use and where their resources come from. Only `session/page` and `session/follow` need explicit `resources`; the rest rely on a lookup's `resource` or the default field:
|
||||
The table lists the endpoints #3854 viewers use and where their resources come from. Session history endpoints use the registered Session resolver; special method readers can override defaults:
|
||||
|
||||
| Endpoint | Resource | Resolution source | Filtering inside the method |
|
||||
|---|---|---|---|
|
||||
@@ -241,14 +241,14 @@ The table lists the endpoints #3854 viewers use and where their resources come f
|
||||
| `llm/listProviders` | `global` | no resource | no |
|
||||
| `commands/list` | session: `args.agentId` (the Agent lookup's wire field) | Agent lookup `resource: 'session'` | no |
|
||||
| `goals/get` | session: `args.agentId` | Agent lookup `resource: 'session'` | no |
|
||||
| `session/attachment` | session: `args.request.sessionId` | default field | no |
|
||||
| `skills/list` | session: `args.request.sessionId` | default field | no |
|
||||
| `session/fork` | session: `args.request.sessionId` | default field | no |
|
||||
| `session/prompt` | session: `args.request.sessionId` | default field | no |
|
||||
| `session/cancel` | session: `args.request.sessionId` | default field | no |
|
||||
| `session/projections` | session: `args.request.sessionId` | default field | no |
|
||||
| `session/page` | session: `args.request.address.sessionId` for a Session address; `args.request.address.childSessionId` for a subagent address | `resources` | no |
|
||||
| `session/follow` | same as above | `resources` | no |
|
||||
| `session/attachment` | session: `args.request.sessionId` | default resolver | no |
|
||||
| `skills/list` | session: `args.request.sessionId` | default resolver | no |
|
||||
| `session/fork` | session: `args.request.sessionId` | default resolver | no |
|
||||
| `session/prompt` | session: `args.request.sessionId` | default resolver | no |
|
||||
| `session/cancel` | session: `args.request.sessionId` | default resolver | no |
|
||||
| `session/projections` | session: `args.request.sessionId` | default resolver | no |
|
||||
| `session/page` | session: `args.request.address.sessionId` for a Session address; `args.request.address.childSessionId` for a subagent address | default resolver | no |
|
||||
| `session/follow` | same as above | default resolver | no |
|
||||
| `remoteAccess/follow` | `global` | no resource; remote-access allows it for every principal | no |
|
||||
|
||||
A subagent address reads the child transcript, so its resource is the child Session. A Client rereads the projections of known Sessions after a reconnect, including the parent of a subagent Session; a reader without a grant on that parent has its projection read refused. Whether a grant on a parent Session covers its subagent children is the policy's decision, that is, whether it gives a binding for the child Session; the generic layer derives no parent resource.
|
||||
@@ -392,7 +392,7 @@ Every call path (HTTP `/api`, WebSocket streams, in-process `fetch(request, peer
|
||||
| 1 | Admission yields the Peer: network paths from Connection `admit()` (`browserPeer`), in-process paths from the caller. The Gateway checks `peer.signal` before it reads the wire payload | Failed network admission answers HTTP 401 / 403 without entering the Gateway; a disposed Peer: `gateway/peer-disposed` |
|
||||
| 2 | Resolve the endpoint and validate the argument field set (`resolveDescriptor`, `assertExactArguments`), then validate the JSON arguments against their Typert-compiled types, decoding each once | `gateway/invocation-unavailable`, `gateway/ambiguous-endpoint`, `gateway/definition-unavailable`; extra or missing fields: `gateway/arguments-invalid`; a JSON argument failing validation: `gateway/input-invalid` (for owners and non-owners alike, before the access check) |
|
||||
| 3 | Owner pass-through: for `principal.kind === 'owner'` go straight to step 5 | — |
|
||||
| 4 | Non-owner: the Gateway resolves resources from the arguments validated in step 2 (`resources` readers, then lookup parameters and the `resource` a Context declares, then default fields; lookup and Context identities are wire identities, and neither a lookup nor the Context is resolved), then asks `remoteAccess.decide({ principal, action: endpoint, resources, args })` with the validated arguments as `args`; invalid resources are refused with `resource-invalid` without asking `decide()`; a `deny` decision becomes the thrown error | `remote-access/denied` with details `{ action, reason }` |
|
||||
| 4 | Non-owner: the Gateway resolves resources from the arguments validated in step 2 (`resources` readers, then lookup parameters and the `resource` a Context declares, then the registered default field or resolver; lookup and Context identities are wire identities, and neither a lookup nor the Context is resolved), then asks `remoteAccess.decide({ principal, action: endpoint, resources, args })` with the validated arguments as `args`; invalid resources are refused with `resource-invalid` without asking `decide()`; a `deny` decision becomes the thrown error | `remote-access/denied` with details `{ action, reason }` |
|
||||
| 5 | Dispatch: `resolveReceiverContext` (which can activate an Agent), lookup resolution (JSON arguments reuse the step 2 values), and the method call | Existing codes unchanged, such as `gateway/lookup-not-found`, `gateway/context-not-found`, `gateway/input-invalid`, `gateway/service-unavailable`, `gateway/method-unavailable`, and business errors |
|
||||
|
||||
The access check (steps 3–4) precedes lookup and Context resolution, so a refused call activates no Session and causes no other side effect. The only new Gateway error code is `gateway/peer-disposed`; remote-access declares the refusal code `remote-access/denied`.
|
||||
@@ -600,7 +600,7 @@ The review rule's home is the "`@Remote` marks capability methods" section of [R
|
||||
|
||||
**Refusal reasons from conditions only.** Without a binding there is no condition to give a reason, so a viewer page could not tell "share expired" from "share not writable"; `explain` gives a reason only after the decision is already a refusal and never changes it.
|
||||
|
||||
**A global resource-resolver registry (domain packages register recognizers by field name).** Field recognition separates from the method, nothing flags a method signature change, and same-named fields in other packages are misread; resource resolution uses a default convention on the method plus per-method `resources`, and a domain package registers only its own kind's default field.
|
||||
**A global resource-resolver registry (domain packages register recognizers by field name).** Field recognition separates from the method, nothing flags a method signature change, and same-named fields in other packages are misread; resource resolution uses a default convention on the method plus per-method `resources`, and a domain package registers only its own kind's default field or resolver.
|
||||
|
||||
**Dot-path resource declarations (`'request.address.sessionId'`).** Types cannot constrain a path; the parameter types of `resources` readers are inferred from the decorated method, so a typo fails compilation.
|
||||
|
||||
@@ -635,8 +635,8 @@ The review rule's home is the "`@Remote` marks capability methods" section of [R
|
||||
| Check order | The owner calls any method normally without reading a policy; a method no role lists refuses non-owners with `remote-access/denied` (`no-allow`); the access check precedes lookups, so a refused call activates no Session | Gateway host spec |
|
||||
| Policy registration | One policy per non-owner principal kind, and a second registration throws; once the disposer withdraws it, that kind is refused with `no-allow`; it is withdrawn with its fiber | remote-access host spec |
|
||||
| Decisions | Every resource needs a matching binding (AND); a role without the action, an unequal resource, or a refusing condition does not allow; the refusal reason follows condition > `explain` > `no-allow`; `explain` never changes the decision; conditions receive args for calls but not for snapshots and list filtering; a throwing `bindings()`, condition, or `explain()` refuses with `policy-failed`, each policy is logged once between `invalidate()` calls, and on the event path the owner stream and the shared event source continue; `remoteAccess/follow` is allowed for every principal | remote-access host spec, Gateway host spec |
|
||||
| Resource resolution | A default field (top-level or first object argument) yields its resource; a lookup parameter that declares that kind as its `resource` yields its wire identity; a Context method gets its Session resource from the Agent Context identity in args without activating the Agent, and may declare other resources through `resources`; a `resources` reader overrides its kind, an array yields several resources, and `undefined` none; with nothing the call is `[global]`; a throw, an empty array, or a value that is not a non-empty string (including a Context identity) is invalid, refused by the Gateway with `resource-invalid` without asking `decide()`; a Context that declares no `resource` resolves by the ordinary rules; resources and `args` read the validated arguments, so an undeclared nested field changes no resource and no condition sees it, and a JSON argument failing validation returns `gateway/input-invalid` before the access check | Gateway host spec (gateway-access) |
|
||||
| Resource registry | `typert.resources` registers one default field per kind, rejects a second registration and `global`, and withdraws the registration with its fiber; a configured lookup resolver keeps the provider's `resource`; a Host Context adapter keeps its `resource` | typert-registry spec |
|
||||
| Resource resolution | A default field or resolver yields one resource; a default resolver returning an array is invalid; a lookup parameter that declares that kind as its `resource` yields its wire identity; a Context method gets its Session resource from the Agent Context identity in args without activating the Agent, and may declare other resources through `resources`; a `resources` reader overrides its kind, an array yields several resources, and `undefined` none; with nothing the call is `[global]`; a throw, an empty array, or a value that is not a non-empty string (including a Context identity) is invalid, refused by the Gateway with `resource-invalid` without asking `decide()`; a Context that declares no `resource` resolves by the ordinary rules; resources and `args` read the validated arguments, with undeclared fields stripped only by compiled codecs, and a JSON argument failing validation returns `gateway/input-invalid` before the access check | Gateway host spec (gateway-access) |
|
||||
| Resource registry | `typert.resources` registers one default field or resolver per kind, rejects a second registration and `global`, and withdraws the registration with its fiber; a configured lookup resolver keeps the provider's `resource`; a Host Context adapter keeps its `resource` | typert-registry spec |
|
||||
| Forwarded events | A non-owner `$events` stream receives an event, whole, only when `event:<name>` is granted on its resources; an event that declares no resources is decided on `global`; an event whose reader returns `undefined` does not reach non-owners, and `decide()` is not asked; the owner receives all | Gateway host spec (gateway-access) |
|
||||
| First snapshot | `snapshotFor()` returns the owner baseline or the principal's grants; remote-access injects the snapshot of the Peer each index speaks for; the Client starts from the injected baseline and rejects a malformed one | remote-access host and client specs |
|
||||
| Type inference | The parameter types of `resources` readers are inferred from the method; lookup positions have wire types; a missing property, more parameters than the method has, and a `global` key fail compilation; a binding whose resource kind differs from its role's fails compilation | typert-protocol spec, remote-access spec (type tests) |
|
||||
|
||||
+19
-19
@@ -131,7 +131,7 @@ export function principalOf(invocation: RemoteInvocation | undefined): Principal
|
||||
|
||||
### 方法级访问控制
|
||||
|
||||
资源类型与 `@Remote` 的 `resources` 读取函数在 `@deepseek-ai/dsh-typert-protocol`;资源种类在 typert 中与 lookups、Contexts 并列:`TypertResourceMap` 声明种类,`typert.resources` 保存每个种类的默认 wire 字段,lookup 与 Host Context adapter 声明其 wire 身份所属的资源种类。Gateway 把一次调用或一个转发事件解析为它访问的资源。判定、列表过滤与访问快照在新包 `@deepseek-ai/dsh-api-remote-access`(`packages/api/remote-access`),该包不读取任何参数字段。Gateway 与领域包对 remote-access 只做 type-only 依赖并 inject `remoteAccess`,值依赖只有 `dsh-typert-protocol`,不为 remote-access 新增 `package-dependency-policy` 条目。role、binding 与 policy 由业务包定义和注册,liveshare 为首个使用方。除内置的 `global` 外,remote-access 与 Gateway 中不出现任何资源种类名,符合 [`packages/api` 只承载通用编排](../../../../docs/defensive-patterns/remote-and-api.zh.md) 的评审规则。
|
||||
资源类型与 `@Remote` 的 `resources` 读取函数在 `@deepseek-ai/dsh-typert-protocol`;资源种类在 typert 中与 lookups、Contexts 并列:`TypertResourceMap` 声明种类,`typert.resources` 保存每个种类的默认 wire 字段或 resolver,lookup 与 Host Context adapter 声明其 wire 身份所属的资源种类。Gateway 把一次调用或一个转发事件解析为它访问的资源。判定、列表过滤与访问快照在新包 `@deepseek-ai/dsh-api-remote-access`(`packages/api/remote-access`),该包不读取任何参数字段。Gateway 与领域包对 remote-access 只做 type-only 依赖并 inject `remoteAccess`,值依赖只有 `dsh-typert-protocol`,不为 remote-access 新增 `package-dependency-policy` 条目。role、binding 与 policy 由业务包定义和注册,liveshare 为首个使用方。除内置的 `global` 外,remote-access 与 Gateway 中不出现任何资源种类名,符合 [`packages/api` 只承载通用编排](../../../../docs/defensive-patterns/remote-and-api.zh.md) 的评审规则。
|
||||
|
||||
#### 与 RBAC 的对应
|
||||
|
||||
@@ -165,7 +165,7 @@ export interface TypertResourceMap {}
|
||||
// @deepseek-ai/dsh-workspace/types: workspace: TypertResource<WorkspaceId>
|
||||
|
||||
// Runtime registries in typert
|
||||
ctx.typert.resources.register(kind, { field }): TypertDisposer
|
||||
ctx.typert.resources.register(kind, { field } | { resolve }): TypertDisposer
|
||||
ctx.typert.lookups.register(key, { parameter, wire, resource?, … }) // resource: the kind its wire id names
|
||||
ctx.typert.contexts.registerHost(key, { wire, resource?, … }) // a TypertHostContextAdapter
|
||||
|
||||
@@ -213,11 +213,11 @@ export function RemoteScope<This extends object, Args extends unknown[], Result>
|
||||
|
||||
| 规则 | 内容 |
|
||||
|---|---|
|
||||
| 写法 | `@Remote({ name: 'page', resources: { session: (request) => request.address.kind === 'session' ? request.address.sessionId : request.address.childSessionId } })`;Context 方法写 `@RemoteScope('agent')`,由 Agent Context 的 `resource: 'session'` 得到会话资源,需要其他资源时写 `@RemoteScope('agent', { resources: { workspace: (request) => request.workspaceId } })`。多数方法不写 `resources`,由自动解析得到资源 |
|
||||
| 写法 | 方法必须手工点名资源时写 `@Remote({ name: 'prompt', resources: { session: (request) => request.sessionId } })`;`session/page` 与 `session/follow` 则依赖已注册的 `session` 默认 resolver;Context 方法写 `@RemoteScope('agent')`,由 Agent Context 的 `resource: 'session'` 得到会话资源,需要其他资源时写 `@RemoteScope('agent', { resources: { workspace: (request) => request.workspaceId } })`。多数方法不写 `resources`,由自动解析得到资源 |
|
||||
| 资源种类 | 资源种类由声明 id 类型的底层包合并:`session: TypertResource<SessionId>` 在 `@deepseek-ai/dsh-session/types`,`workspace: TypertResource<WorkspaceId>` 在 `@deepseek-ai/dsh-workspace/types`。session-controller 引用 commands 包,若在 session-controller 合并,commands 要引用 session-controller,形成 project reference 环。`global` 由 remote-access 内置,没有 id,不进 `TypertResourceMap`;typert 保留该名,`typert.resources.register('global', …)` 抛错 |
|
||||
| 默认字段与声明 | 拥有该种类的领域在控制器构造时登记默认 wire 字段:`SessionController` 构造函数调用 `typert.resources.register('session', { field: 'sessionId' })`,`WorkspaceController` 构造函数登记 `workspace` → `workspaceId`;同一种类重复登记即抛错。Agent、Session 与 `workspaceFileScope` lookup 声明 `resource: 'session'`,agent 包的 Agent Context 声明 `resource: 'session'` |
|
||||
| 解析优先级 | 对每个资源种类依次取:① 该方法 `resources` 中该种类的读取函数;② 声明了该种类 `resource` 的每个 lookup 参数贡献其 wire 身份,省略的可选 lookup 参数不贡献;Context 方法的 Context 声明了该种类 `resource` 时,其 wire 身份同样贡献;③ 该种类的默认字段:命名参数中有同名顶层参数就读它,否则读第一个对象参数的同名字段。默认字段只表示一个身份,读到数组即为非法值 |
|
||||
| 参数来源 | 资源解析与 `decide()` 的 `args` 读取经 Typert 编译类型校验后的命名参数:每个 JSON 参数由其 codec 解码一次,类型未声明的对象字段被移除,分派复用该值,因此调用方无法用未声明字段改变资源;lookup 参数与 Context 身份取 wire 身份,此时不解析 lookup、不激活 Agent |
|
||||
| 默认提取声明 | 资源所属领域登记 `{ field }` 或 `{ resolve }`,两者互斥。SessionController 登记 `resolveSessionResource`;WorkspaceController 登记 `workspaceId`。重复种类被拒绝;lookup 和 Host Context 的资源声明独立于默认规则 |
|
||||
| 解析优先级 | 每个种类依次采用方法显式 reader、lookup 与 Host Context 身份、注册的默认字段或 resolver。字段读取同名顶层参数或首个对象参数。resolver 在 lookup 解析前调用一次,接收已校验的命名 `args` 及有序参数描述。默认规则返回一个非空字符串或 undefined,数组非法 |
|
||||
| 参数来源 | 编译 codec 移除未声明的对象字段;SRC fallback 只检查 JSON 安全性,不移除字段。解析和分派使用相同的已校验值。Session resolver 拒绝同时包含 sessionId 与 address 的请求对象,即使两者相同。lookup 和 Context 身份在授权完成前保持 wire 值 |
|
||||
| 解析结果 | 各种类的资源合在一起即本次调用访问的资源;一个都没有即 `[global]`;解析出资源时只用这些资源,不附加 `global` |
|
||||
| 读取函数 | 以方法的位置参数调用:按描述符把命名参数映射回方法参数顺序(JSON 位为校验后的值,lookup 位为 wire 身份,取消参数位为调用的 signal);返回一个 id、id 数组(每个都须被允许)或 `undefined`(该种类无资源) |
|
||||
| 非法值 | 读取函数抛错或返回空数组,或读到的值(含 Context 的 wire 身份)不是非空字符串即资源非法:Gateway 不询问 `decide()`,直接以 `remote-access/denied`(reason `resource-invalid`)拒绝调用 |
|
||||
@@ -228,7 +228,7 @@ export function RemoteScope<This extends object, Args extends unknown[], Result>
|
||||
|
||||
#### 端点的资源
|
||||
|
||||
下表列出 #3854 读者用到的端点及其资源来源。只有 `session/page` 与 `session/follow` 需要显式 `resources`,其余靠 lookup 的 `resource` 或默认字段:
|
||||
下表列出 #3854 读者用到的端点及其资源来源。Session 历史端点使用注册的 Session resolver;特殊方法可用显式 reader 覆盖默认规则:
|
||||
|
||||
| 端点 | 资源 | 解析来源 | 方法内过滤 |
|
||||
|---|---|---|---|
|
||||
@@ -241,14 +241,14 @@ export function RemoteScope<This extends object, Args extends unknown[], Result>
|
||||
| `llm/listProviders` | `global` | 无资源 | 否 |
|
||||
| `commands/list` | session:`args.agentId`(Agent lookup 的 wire 字段) | Agent lookup `resource: 'session'` | 否 |
|
||||
| `goals/get` | session:`args.agentId` | Agent lookup `resource: 'session'` | 否 |
|
||||
| `session/attachment` | session:`args.request.sessionId` | 默认字段 | 否 |
|
||||
| `skills/list` | session:`args.request.sessionId` | 默认字段 | 否 |
|
||||
| `session/fork` | session:`args.request.sessionId` | 默认字段 | 否 |
|
||||
| `session/prompt` | session:`args.request.sessionId` | 默认字段 | 否 |
|
||||
| `session/cancel` | session:`args.request.sessionId` | 默认字段 | 否 |
|
||||
| `session/projections` | session:`args.request.sessionId` | 默认字段 | 否 |
|
||||
| `session/page` | session:Session 地址读 `args.request.address.sessionId`;subagent 地址读 `args.request.address.childSessionId` | `resources` | 否 |
|
||||
| `session/follow` | 同上 | `resources` | 否 |
|
||||
| `session/attachment` | session:`args.request.sessionId` | 默认 resolver | 否 |
|
||||
| `skills/list` | session:`args.request.sessionId` | 默认 resolver | 否 |
|
||||
| `session/fork` | session:`args.request.sessionId` | 默认 resolver | 否 |
|
||||
| `session/prompt` | session:`args.request.sessionId` | 默认 resolver | 否 |
|
||||
| `session/cancel` | session:`args.request.sessionId` | 默认 resolver | 否 |
|
||||
| `session/projections` | session:`args.request.sessionId` | 默认 resolver | 否 |
|
||||
| `session/page` | session:Session 地址读 `args.request.address.sessionId`;subagent 地址读 `args.request.address.childSessionId` | 默认 resolver | 否 |
|
||||
| `session/follow` | 同上 | 默认 resolver | 否 |
|
||||
| `remoteAccess/follow` | `global` | 无资源;remote-access 对任何 principal 放行 | 否 |
|
||||
|
||||
subagent 地址读取的是子会话转录,因此其资源是子会话。Client 在重连后会重新读取已知会话的 projections,其中包括 subagent 会话的父会话;没有该父会话授权的读者,其 projection 读取被拒。父会话的授权是否覆盖其 subagent 子会话由 policy 决定,即是否为子会话给出 binding;通用层不推导父资源。
|
||||
@@ -392,7 +392,7 @@ const childPrompt = child => ({ action, args }) =>
|
||||
| 1 | 准入得到 Peer:网络路径由 Connection `admit()` 给出(`browserPeer`);进程内路径由调用方传入。Gateway 在读取 wire payload 之前检查 `peer.signal` | 网络准入失败返回 HTTP 401 / 403(不进入 Gateway);Peer 已 dispose:`gateway/peer-disposed` |
|
||||
| 2 | 解析端点并做参数字段集校验(`resolveDescriptor`、`assertExactArguments`),再按 Typert 编译类型校验 JSON 参数,每个参数解码一次 | `gateway/invocation-unavailable`、`gateway/ambiguous-endpoint`、`gateway/definition-unavailable`;字段多余或缺失:`gateway/arguments-invalid`;JSON 参数校验失败:`gateway/input-invalid`(owner 与非 owner 相同,先于访问检查) |
|
||||
| 3 | owner 直通:`principal.kind === 'owner'` 时直接进入第 5 步 | — |
|
||||
| 4 | 非 owner:Gateway 从第 2 步校验后的参数解析资源(`resources` 读取函数、其次 lookup 参数与 Context 声明的 `resource`、再次默认字段;lookup 与 Context 身份取 wire 身份,不解析 lookup 与 Context),再以校验后的参数为 `args` 询问 `remoteAccess.decide({ principal, action: endpoint, resources, args })`;资源非法时不询问 `decide()`,直接以 `resource-invalid` 拒绝;`deny` 结论变为抛出的错误 | `remote-access/denied`,details `{ action, reason }` |
|
||||
| 4 | 非 owner:Gateway 从第 2 步校验后的参数解析资源(`resources` 读取函数、其次 lookup 参数与 Context 声明的 `resource`、再次注册的默认字段或 resolver;lookup 与 Context 身份取 wire 身份,不解析 lookup 与 Context),再以校验后的参数为 `args` 询问 `remoteAccess.decide({ principal, action: endpoint, resources, args })`;资源非法时不询问 `decide()`,直接以 `resource-invalid` 拒绝;`deny` 结论变为抛出的错误 | `remote-access/denied`,details `{ action, reason }` |
|
||||
| 5 | 分派:`resolveReceiverContext`(可能激活 Agent)、lookup 解析(JSON 参数复用第 2 步的值)、调用方法 | 既有错误码不变,如 `gateway/lookup-not-found`、`gateway/context-not-found`、`gateway/input-invalid`、`gateway/service-unavailable`、`gateway/method-unavailable` 与业务错误 |
|
||||
|
||||
鉴权(第 3–4 步)位于 lookup 与 Context 解析之前,未授权调用不会激活会话或产生其他副作用。Gateway 新增的错误码只有 `gateway/peer-disposed`;拒绝码 `remote-access/denied` 由 remote-access 声明。
|
||||
@@ -600,7 +600,7 @@ GlobalStandardProps.useWorkspaceAccess: (workspaceId: WorkspaceId, action: Remot
|
||||
|
||||
**拒绝原因只由 condition 给出。** 没有 binding 时没有 condition 可以给出原因,读者页面无法区分「分享已过期」「分享不允许写入」等情况;`explain` 只在结论已为拒绝后给出原因,不改变结论。
|
||||
|
||||
**全局资源 resolver 注册表(领域包按字段名注册识别函数)。** 字段识别与方法分离,方法改签名时无人提醒,跨包同名字段也会被误认;资源解析采用方法上的默认约定加 per-method `resources`,领域包只登记自己种类的默认字段。
|
||||
**全局资源 resolver 注册表(领域包按字段名注册识别函数)。** 字段识别与方法分离,方法改签名时无人提醒,跨包同名字段也会被误认;资源解析采用方法上的默认约定加 per-method `resources`,领域包只登记自己种类的默认字段或 resolver。
|
||||
|
||||
**按点路径声明资源(`'request.address.sessionId'`)。** 路径无法由类型推导约束;`resources` 读取函数的参数类型从被装饰方法推断,拼错即编译失败。
|
||||
|
||||
@@ -635,8 +635,8 @@ GlobalStandardProps.useWorkspaceAccess: (workspaceId: WorkspaceId, action: Remot
|
||||
| 鉴权顺序 | owner 调用任何方法照常分派且不读取 policy;没有 role 列出的方法对非 owner 以 `remote-access/denied`(`no-allow`)拒绝;访问检查早于 lookup,被拒调用不激活会话 | Gateway host spec |
|
||||
| policy 注册 | 每个非 owner principal kind 一个 policy,重复注册抛错;disposer 撤回后该 kind 以 `no-allow` 拒绝;随 fiber 撤回 | remote-access host spec |
|
||||
| 判定 | 每个资源都需要匹配的 binding(AND);role 不含该 action、资源不相等或 condition 拒绝都不放行;拒绝原因按 condition > `explain` > `no-allow` 取;`explain` 不改变结论;condition 在调用时收到 args,在快照与列表过滤时不收;`bindings()`、condition 或 `explain()` 抛错以 `policy-failed` 拒绝,每个 policy 在两次 `invalidate()` 之间只记录一次日志,事件路径上 owner 流与共享事件源照常;`remoteAccess/follow` 对任何 principal 放行 | remote-access host spec、Gateway host spec |
|
||||
| 资源解析 | 默认字段(顶层或首个对象参数)得到对应资源;声明了该种类 `resource` 的 lookup 参数得到其 wire 身份;Context 方法从 args 中的 Agent Context 身份得到其 session 资源且不激活 Agent,另可经 `resources` 声明其他资源;`resources` 读取函数覆盖该种类,数组为多个资源,`undefined` 为无;都没有为 `[global]`;抛错、空数组或非非空字符串(含 Context 身份)为非法,Gateway 以 `resource-invalid` 拒绝且不询问 `decide()`;未声明 `resource` 的 Context 按普通规则解析;资源与 `args` 读取校验后的参数,未声明的嵌套字段不改变资源、condition 也读不到它,JSON 参数校验失败在访问检查前返回 `gateway/input-invalid` | Gateway host spec(gateway-access) |
|
||||
| 资源注册表 | `typert.resources` 每个种类登记一个默认字段,拒绝重复登记与 `global`,并随 fiber 撤回;配置的 lookup resolver 保留 provider 的 `resource`;Host Context adapter 保留其 `resource` | typert-registry spec |
|
||||
| 资源解析 | 默认字段或 resolver 得到一个资源,默认 resolver 返回数组为非法;声明了该种类 `resource` 的 lookup 参数得到其 wire 身份;Context 方法从 args 中的 Agent Context 身份得到其 session 资源且不激活 Agent,另可经 `resources` 声明其他资源;`resources` 读取函数覆盖该种类,数组为多个资源,`undefined` 为无;都没有为 `[global]`;抛错、空数组或非非空字符串(含 Context 身份)为非法,Gateway 以 `resource-invalid` 拒绝且不询问 `decide()`;未声明 `resource` 的 Context 按普通规则解析;资源与 `args` 读取校验后的参数,只有编译 codec 才移除未声明字段,JSON 参数校验失败在访问检查前返回 `gateway/input-invalid` | Gateway host spec(gateway-access) |
|
||||
| 资源注册表 | `typert.resources` 每个种类登记一个默认字段或 resolver,拒绝重复登记与 `global`,并随 fiber 撤回;配置的 lookup resolver 保留 provider 的 `resource`;Host Context adapter 保留其 `resource` | typert-registry spec |
|
||||
| 转发事件 | 非 owner 的 `$events` 流只在 `event:<name>` 于其资源上被授权时整条收到事件;未声明资源的事件按 `global` 判定;读取函数返回 `undefined` 的事件不送达非 owner,且不询问 `decide()`;owner 全部收到 | Gateway host spec(gateway-access) |
|
||||
| 首份快照 | `snapshotFor()` 返回 owner baseline 或该 principal 的 grant;remote-access 为每个 index 注入其所代表 Peer 的快照;Client 从注入的 baseline 起步并拒绝格式错误的快照 | remote-access host 与 client spec |
|
||||
| 类型推断 | `resources` 读取函数的参数类型从方法推断;lookup 位为 wire 类型;访问不存在的属性、参数多于方法参数与 `global` 键编译失败;binding 的资源种类与 role 不符编译失败 | typert-protocol spec、remote-access spec(类型测试) |
|
||||
|
||||
@@ -76,7 +76,7 @@ jobs:
|
||||
- name: Parse Windows runner supervision scripts
|
||||
shell: pwsh
|
||||
run: |
|
||||
foreach ($path in @('apps/desktop/scripts/windows-runner-task.ps1', 'apps/desktop/scripts/run-windows-runner-task.ps1', 'apps/desktop/tests/windows-runner-task.ps1', 'apps/desktop/tests/windows-runner-launcher.ps1')) {
|
||||
foreach ($path in @('apps/desktop/scripts/windows-runner-task.ps1', 'apps/desktop/scripts/run-windows-runner-task.ps1', 'apps/desktop/scripts/start-windows-packaging-runner.ps1', 'apps/desktop/scripts/windows-runner-network.ps1', 'apps/desktop/scripts/run-windows-runner-network-process.ps1', 'apps/desktop/tests/windows-runner-task.ps1', 'apps/desktop/tests/windows-runner-launcher.ps1', 'apps/desktop/tests/windows-runner-network.ps1', 'apps/desktop/tests/windows-runner-network-process.ps1')) {
|
||||
$tokens = $null
|
||||
$parseErrors = $null
|
||||
$null = [Management.Automation.Language.Parser]::ParseFile((Join-Path $env:GITHUB_WORKSPACE $path), [ref]$tokens, [ref]$parseErrors)
|
||||
@@ -85,6 +85,15 @@ jobs:
|
||||
- name: Verify windowless Windows runner launcher
|
||||
shell: pwsh
|
||||
run: ./apps/desktop/tests/windows-runner-launcher.ps1
|
||||
- name: Verify Windows packaging priority inheritance
|
||||
shell: pwsh
|
||||
run: ./apps/desktop/tests/windows-packaging-priority.ps1
|
||||
- name: Verify Windows runner network supervision
|
||||
shell: pwsh
|
||||
run: |
|
||||
./apps/desktop/tests/windows-runner-network.ps1
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
./apps/desktop/tests/windows-runner-network-process.ps1
|
||||
- name: Run Windows observational gates on built artifacts
|
||||
id: observational
|
||||
continue-on-error: true
|
||||
|
||||
@@ -9,6 +9,12 @@ on:
|
||||
options: [test, production]
|
||||
default: test
|
||||
required: true
|
||||
zip_compression_level:
|
||||
description: 'ZIP 压缩等级:只影响 ZIP 和自动升级下载,不影响 DMG;3 打包较快,7 压缩更强'
|
||||
type: choice
|
||||
options: ['3', '7']
|
||||
default: '3'
|
||||
required: true
|
||||
skip_notarization:
|
||||
description: '跳过公证和 stapling:仅限内部调试,禁止发布到 test / production 或用于自动更新;需要发布请勿勾选'
|
||||
type: boolean
|
||||
@@ -57,6 +63,7 @@ jobs:
|
||||
run:
|
||||
shell: bash
|
||||
env:
|
||||
PACKAGE_MACOS_ZIP_COMPRESSION_LEVEL: ${{ inputs.zip_compression_level }}
|
||||
PACKAGE_SKIP_NOTARIZATION: ${{ inputs.skip_notarization }}
|
||||
PACKAGE_SOURCE_REF: ${{ inputs.source_ref }}
|
||||
PACKAGE_VERSION_MODE: ${{ inputs.version_mode }}
|
||||
@@ -95,6 +102,12 @@ jobs:
|
||||
- uses: actions/[email protected]
|
||||
with:
|
||||
node-version: '24'
|
||||
- name: Configure persistent package caches
|
||||
env:
|
||||
DSH_DESKTOP_PACKAGE_CACHE_DIR: ${{ runner.tool_cache }}/dsh-desktop-package-cache
|
||||
run: |
|
||||
echo "DSH_DESKTOP_PACKAGE_CACHE_DIR=$DSH_DESKTOP_PACKAGE_CACHE_DIR" >> "$GITHUB_ENV"
|
||||
echo "PNPM_CONFIG_STORE_DIR=$DSH_DESKTOP_PACKAGE_CACHE_DIR/workspace-pnpm-store" >> "$GITHUB_ENV"
|
||||
- name: Install locked dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Validate preset and resolve build version
|
||||
@@ -138,5 +151,6 @@ jobs:
|
||||
name: macos-arm64-${{ steps.settings.outputs.mode }}-${{ steps.settings.outputs.version }}-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
include-hidden-files: true
|
||||
path: ${{ steps.retained.outputs.directory }}/*
|
||||
compression-level: 0
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
@@ -42,6 +42,22 @@ on:
|
||||
type: string
|
||||
default: ''
|
||||
required: false
|
||||
cache_namespace:
|
||||
description: '缓存测量命名空间:留空使用共享缓存;新名称用于冷缓存,再次使用同名测热缓存,不清除现有缓存'
|
||||
type: string
|
||||
default: ''
|
||||
required: false
|
||||
build_root:
|
||||
description: '独立构建根目录:填写绝对路径,每次任务创建独立源码副本;留空使用 runner 工作区'
|
||||
type: string
|
||||
default: ''
|
||||
required: false
|
||||
compression_level:
|
||||
description: '安装器压缩等级:3 打包较快,7 压缩更强;覆盖本轮机器配置'
|
||||
type: choice
|
||||
options: ['3', '7']
|
||||
default: '3'
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -65,6 +81,9 @@ jobs:
|
||||
DSH_PACKAGING_PRESET: ${{ inputs.preset }}
|
||||
PACKAGE_TEST_RELEASE_ID: ${{ inputs.test_release_id }}
|
||||
PACKAGE_SIGNING: ${{ inputs.signing }}
|
||||
PACKAGE_CACHE_NAMESPACE: ${{ inputs.cache_namespace }}
|
||||
PACKAGE_BUILD_ROOT: ${{ inputs.build_root }}
|
||||
PACKAGE_WINDOWS_COMPRESSION_LEVEL: ${{ inputs.compression_level }}
|
||||
steps:
|
||||
- name: Reset packaging checkout
|
||||
working-directory: ${{ runner.temp }}
|
||||
@@ -77,6 +96,20 @@ jobs:
|
||||
}
|
||||
node -e "const fs = require('node:fs'); fs.rmSync(process.argv[1], { recursive: true, force: true, maxRetries: 3 }); fs.mkdirSync(process.argv[1])" $workspace
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Checkout cleanup failed' }
|
||||
- name: Checkout workflow helpers
|
||||
uses: actions/[email protected]
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: ${{ github.workflow_sha }}
|
||||
sparse-checkout: apps/desktop/scripts
|
||||
- name: Stage workflow helpers
|
||||
run: |
|
||||
$helpers = Join-Path $env:RUNNER_TEMP "packaging-helpers-$env:GITHUB_RUN_ID-$env:GITHUB_RUN_ATTEMPT"
|
||||
New-Item -ItemType Directory -Path $helpers -ErrorAction Stop | Out-Null
|
||||
foreach ($name in @('windows-packaging-priority.ps1', 'windows-build-workspace.mjs')) {
|
||||
Copy-Item -LiteralPath (Join-Path 'apps/desktop/scripts' $name) -Destination $helpers -ErrorAction Stop
|
||||
}
|
||||
"PACKAGE_CI_HELPERS=$helpers" >> $env:GITHUB_ENV
|
||||
- uses: actions/[email protected]
|
||||
id: checkout
|
||||
with:
|
||||
@@ -107,16 +140,50 @@ jobs:
|
||||
- uses: actions/[email protected]
|
||||
with:
|
||||
node-version: '24'
|
||||
- name: Install locked dependencies
|
||||
- name: Select packaging workspace
|
||||
id: build_workspace
|
||||
env:
|
||||
PACKAGE_SOURCE_SHA: ${{ steps.checkout.outputs.commit }}
|
||||
run: |
|
||||
if (!$env:PACKAGE_BUILD_ROOT) {
|
||||
"directory=$env:GITHUB_WORKSPACE" >> $env:GITHUB_OUTPUT
|
||||
} else {
|
||||
node "$env:PACKAGE_CI_HELPERS/windows-build-workspace.mjs"
|
||||
exit $LASTEXITCODE
|
||||
}
|
||||
- name: Configure persistent pnpm store
|
||||
# PNPM_HOME belongs to this run; keep downloaded packages outside its temporary directory.
|
||||
env:
|
||||
DSH_DESKTOP_PACKAGE_CACHE_DIR: ${{ runner.tool_cache }}/dsh-desktop-package-cache
|
||||
run: |
|
||||
$storeRoot = Join-Path ([IO.Path]::GetPathRoot($env:GITHUB_WORKSPACE)) '.pnpm-store'
|
||||
$namespace = $env:PACKAGE_CACHE_NAMESPACE
|
||||
if ($namespace) {
|
||||
if ($namespace -cnotmatch '^[a-z0-9][a-z0-9-]{0,63}\z') { throw 'Cache namespace must contain 1-64 lowercase letters, digits or hyphens' }
|
||||
if ($namespace -match '^(con|prn|aux|nul|com[1-9]|lpt[1-9])$') { throw 'Cache namespace cannot be a Windows device name' }
|
||||
$storeRoot = Join-Path $storeRoot $namespace
|
||||
$env:DSH_DESKTOP_PACKAGE_CACHE_DIR = Join-Path $env:DSH_DESKTOP_PACKAGE_CACHE_DIR $namespace
|
||||
}
|
||||
"DSH_DESKTOP_PACKAGE_CACHE_DIR=$env:DSH_DESKTOP_PACKAGE_CACHE_DIR" >> $env:GITHUB_ENV
|
||||
"PNPM_CONFIG_STORE_DIR=$storeRoot" >> $env:GITHUB_ENV
|
||||
- name: Install locked dependencies
|
||||
working-directory: ${{ steps.build_workspace.outputs.directory }}
|
||||
run: |
|
||||
& "$env:PACKAGE_CI_HELPERS/windows-packaging-priority.ps1" -BestEffort
|
||||
node -e "const os=require('node:os'); console.log(JSON.stringify({node:process.version,availableParallelism:os.availableParallelism(),logicalCpus:os.cpus().length,totalMemoryBytes:os.totalmem(),freeMemoryBytes:os.freemem()}))"
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
pnpm store path
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
pnpm install --frozen-lockfile
|
||||
exit $LASTEXITCODE
|
||||
- name: Validate preset and resolve build version
|
||||
id: settings
|
||||
working-directory: ${{ steps.build_workspace.outputs.directory }}
|
||||
env:
|
||||
PACKAGE_BUILD_VERSION: ${{ inputs.build_version }}
|
||||
run: node apps/desktop/scripts/prepare-windows-ci.mjs
|
||||
- name: Check packaging configuration and toolchain
|
||||
working-directory: ${{ steps.build_workspace.outputs.directory }}
|
||||
env:
|
||||
PACKAGE_VERSION: ${{ steps.settings.outputs.version }}
|
||||
run: |
|
||||
@@ -125,15 +192,23 @@ jobs:
|
||||
pnpm @packageArgs
|
||||
exit $LASTEXITCODE
|
||||
- name: Build installer and verify packaged runtime
|
||||
working-directory: ${{ steps.build_workspace.outputs.directory }}
|
||||
env:
|
||||
PACKAGE_VERSION: ${{ steps.settings.outputs.version }}
|
||||
PACKAGE_BUILD_TEMP: ${{ steps.build_workspace.outputs.temporaryDirectory }}
|
||||
run: |
|
||||
& "$env:PACKAGE_CI_HELPERS/windows-packaging-priority.ps1" -BestEffort
|
||||
if ($env:PACKAGE_BUILD_TEMP) {
|
||||
$env:TEMP = $env:PACKAGE_BUILD_TEMP
|
||||
$env:TMP = $env:PACKAGE_BUILD_TEMP
|
||||
}
|
||||
$packageArgs = @('run', 'package:desktop:win:x64', '--build-version', $env:PACKAGE_VERSION)
|
||||
if ($env:PACKAGE_SIGNING -eq 'unsigned') { $packageArgs += '--unsigned' }
|
||||
pnpm @packageArgs
|
||||
exit $LASTEXITCODE
|
||||
- name: Retain complete package outside the checkout
|
||||
id: retained
|
||||
working-directory: ${{ steps.build_workspace.outputs.directory }}
|
||||
env:
|
||||
PACKAGE_SOURCE_SHA: ${{ steps.checkout.outputs.commit }}
|
||||
PACKAGE_VERSION: ${{ steps.settings.outputs.version }}
|
||||
@@ -144,7 +219,7 @@ jobs:
|
||||
with:
|
||||
name: windows-package-logs-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
include-hidden-files: true
|
||||
path: apps/desktop/.desktop-build/packaging-runs/
|
||||
path: ${{ steps.build_workspace.outputs.directory || github.workspace }}/apps/desktop/.desktop-build/packaging-runs/
|
||||
if-no-files-found: warn
|
||||
retention-days: 14
|
||||
- name: Retain verified unsigned installer
|
||||
@@ -152,6 +227,7 @@ jobs:
|
||||
uses: actions/[email protected]
|
||||
with:
|
||||
name: windows-x64-unsigned-${{ steps.settings.outputs.version }}-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
compression-level: 0
|
||||
include-hidden-files: true
|
||||
path: ${{ steps.retained.outputs.directory }}/*
|
||||
if-no-files-found: error
|
||||
@@ -161,7 +237,13 @@ jobs:
|
||||
uses: actions/[email protected]
|
||||
with:
|
||||
name: windows-x64-signed-${{ steps.settings.outputs.version }}-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
compression-level: 0
|
||||
include-hidden-files: true
|
||||
path: ${{ steps.retained.outputs.directory }}/*
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
- name: Remove isolated packaging workspace
|
||||
if: success() && inputs.build_root != ''
|
||||
env:
|
||||
PACKAGE_SOURCE_SHA: ${{ steps.checkout.outputs.commit }}
|
||||
run: node "$env:PACKAGE_CI_HELPERS/windows-build-workspace.mjs" --remove
|
||||
@@ -1,7 +1,7 @@
|
||||
# Release configuration only; local --unsigned builds ignore this file.
|
||||
# Copy to .env.macos (Git ignored). Use UTF-8 and quote secrets containing # or spaces.
|
||||
# Paths may be absolute or relative to apps/desktop. Values are not shell-expanded.
|
||||
DSH_DESKTOP_APP_ID=com.deepseek.harness
|
||||
DSH_DESKTOP_APP_ID=com.deepseek.dsh
|
||||
DSH_DESKTOP_AUTO_UPDATE_ENV=test
|
||||
DOWNLOAD_TEST_ORIGIN=
|
||||
# Required for test releases: 32 lowercase hexadecimal characters from a secure random generator.
|
||||
@@ -14,6 +14,7 @@ DOWNLOAD_TEST_RELEASE_ID=
|
||||
|
||||
# Workspace tarball workers; omit to use 4. Must be a positive integer.
|
||||
DSH_DESKTOP_MACOS_PACK_CONCURRENCY=4
|
||||
DSH_DESKTOP_MACOS_ZIP_COMPRESSION_LEVEL=3
|
||||
# Optional independent proxy origins; empty values preserve inherited networking.
|
||||
# Downloads: HTTP/HTTPS proxy for Electron, runtime assets, pnpm and builder downloads.
|
||||
DSH_DESKTOP_MACOS_DOWNLOAD_PROXY=
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Copy to .env.windows (Git ignored). Use UTF-8 and quote secrets containing # or spaces.
|
||||
# Credential file paths may be absolute or relative to apps/desktop. Values are not shell-expanded.
|
||||
DSH_DESKTOP_APP_ID=com.deepseek.harness
|
||||
DSH_DESKTOP_APP_ID=com.deepseek.dsh
|
||||
DSH_DESKTOP_AUTO_UPDATE_ENV=test
|
||||
DOWNLOAD_TEST_ORIGIN=
|
||||
# Required for test releases: 32 lowercase hexadecimal characters from a secure random generator.
|
||||
@@ -31,6 +31,9 @@ DSH_DESKTOP_WINDOWS_TOKEN_PIN=
|
||||
# Cache-hit restore and verification workers (1-8). Hardware signing remains serial.
|
||||
DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY=4
|
||||
|
||||
# Installer archive compression (0-9); lower values trade size for packaging time.
|
||||
DSH_DESKTOP_WINDOWS_COMPRESSION_LEVEL=3
|
||||
|
||||
# Optional upload configuration; packaging does not require these credentials.
|
||||
# DOWNLOAD_TEST_COS_BUCKET=
|
||||
# DOWNLOAD_TEST_COS_SECRET_ID=
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
# Copy to production.env and configure the production service before selecting this preset.
|
||||
DSH_DESKTOP_APP_ID=com.deepseek.harness
|
||||
DSH_DESKTOP_APP_ID=com.deepseek.dsh
|
||||
DSH_DESKTOP_AUTO_UPDATE_ENV=production
|
||||
DSH_DESKTOP_MANDATORY_UPDATE_PROD_ORIGIN=
|
||||
DSH_DESKTOP_MACOS_PACK_CONCURRENCY=4
|
||||
DSH_DESKTOP_MACOS_ZIP_COMPRESSION_LEVEL=3
|
||||
DSH_DESKTOP_MACOS_DOWNLOAD_PROXY=
|
||||
DSH_DESKTOP_MACOS_NOTARIZATION_PROXY=
|
||||
# Optional absolute private directory; setup fills this in for machine presets. Omit to use checkout-local storage.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Copy to test.env; paths in secret configuration resolve beside macos-signing.env.
|
||||
DSH_DESKTOP_APP_ID=com.deepseek.harness
|
||||
DSH_DESKTOP_APP_ID=com.deepseek.dsh
|
||||
DSH_DESKTOP_AUTO_UPDATE_ENV=test
|
||||
DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN=
|
||||
DSH_DESKTOP_MANDATORY_UPDATE_CONFIG='{"allowedAuthOrigins":["https://login.example.com"]}'
|
||||
@@ -7,6 +7,7 @@ DOWNLOAD_TEST_ORIGIN=
|
||||
# Generate a 32-character lowercase hexadecimal release ID; retain it for upgrade testing.
|
||||
DOWNLOAD_TEST_RELEASE_ID=
|
||||
DSH_DESKTOP_MACOS_PACK_CONCURRENCY=4
|
||||
DSH_DESKTOP_MACOS_ZIP_COMPRESSION_LEVEL=3
|
||||
DSH_DESKTOP_MACOS_DOWNLOAD_PROXY=
|
||||
# Temporarily changes the active network service's system HTTP/HTTPS proxies.
|
||||
DSH_DESKTOP_MACOS_NOTARIZATION_PROXY=
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
# Copy to production.env in the same machine configuration directory and fill in the values.
|
||||
DSH_DESKTOP_APP_ID=com.deepseek.harness
|
||||
DSH_DESKTOP_APP_ID=com.deepseek.dsh
|
||||
DSH_DESKTOP_AUTO_UPDATE_ENV=production
|
||||
DSH_DESKTOP_MANDATORY_UPDATE_PROD_ORIGIN=
|
||||
DSH_DESKTOP_MANDATORY_UPDATE_CONFIG='{}'
|
||||
# DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_DIR=
|
||||
DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY=4
|
||||
# Installer archive compression (0-9).
|
||||
DSH_DESKTOP_WINDOWS_COMPRESSION_LEVEL=3
|
||||
@@ -1,6 +1,6 @@
|
||||
# Copy to test.env in the same machine configuration directory and fill in the values.
|
||||
# Public test configuration. No PIN or upload credentials belong here.
|
||||
DSH_DESKTOP_APP_ID=com.deepseek.harness
|
||||
DSH_DESKTOP_APP_ID=com.deepseek.dsh
|
||||
DSH_DESKTOP_AUTO_UPDATE_ENV=test
|
||||
DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN=
|
||||
# Replace login.example.com with the real login origin before packaging.
|
||||
@@ -11,3 +11,5 @@ DOWNLOAD_TEST_RELEASE_ID=
|
||||
# Optional absolute private cache directory. Omit to retain the account default cache.
|
||||
# DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_DIR=
|
||||
DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY=4
|
||||
# Installer archive compression (0-9).
|
||||
DSH_DESKTOP_WINDOWS_COMPRESSION_LEVEL=3
|
||||
@@ -15,8 +15,8 @@
|
||||
en: a6203dc2e690c7e1
|
||||
zh: 07b516549b8e3328
|
||||
/desktop-packaging/choose-a-build-path:
|
||||
en: 17c6bcdefa05a8f8
|
||||
zh: 4ee18f832276144d
|
||||
en: 0b35c7471c09b27a
|
||||
zh: cc4a382b99fc3210
|
||||
/desktop-packaging/release-versions:
|
||||
en: 3f38ab26ee195b39
|
||||
zh: f00eddafa8f3b9ca
|
||||
@@ -24,20 +24,20 @@
|
||||
en: 5b4f0f80a117f202
|
||||
zh: ff05be196c181087
|
||||
/desktop-packaging/windows-actions-packaging:
|
||||
en: e1b101593f6f755e
|
||||
zh: 121f225de399de1e
|
||||
en: 364223725374d9ac
|
||||
zh: c4d4cc58d7cbafbd
|
||||
/desktop-packaging/macos-self-hosted-packaging:
|
||||
en: a3af9523fdc60036
|
||||
zh: 5e18d4740e9b7f57
|
||||
en: bb1dcdfef3f3a113
|
||||
zh: 9c151800a61e2505
|
||||
/desktop-packaging/unsigned-windows-test-installer:
|
||||
en: 6843a0fdc03ead58
|
||||
zh: 957f92fc8126e23e
|
||||
en: f429bc0c280e2dfa
|
||||
zh: 4adbcc9e906bdf80
|
||||
/desktop-packaging/local-macos-dmg-without-release-signing:
|
||||
en: 2feaa0412247d81f
|
||||
zh: 461dde8e13fd4734
|
||||
/desktop-packaging/macos-release-signing-and-diagnostics:
|
||||
en: f4a2b14da34ba883
|
||||
zh: 6292f3a7b4038213
|
||||
en: 29fa6bd8919f3ec0
|
||||
zh: 9e31e037fa3d9378
|
||||
/desktop-packaging/directory-builds-and-preparation:
|
||||
en: 5860c306375ddd63
|
||||
zh: a76e3cdbbe25c79e
|
||||
@@ -45,14 +45,14 @@
|
||||
en: b42f103358d0dfd7
|
||||
zh: a534164ad9eb00da
|
||||
/desktop-packaging/runtime-file-selection-and-installation-checks:
|
||||
en: bfd89e1d139c5729
|
||||
zh: 5d6cced5462ba13e
|
||||
en: 83e1075417039410
|
||||
zh: 3088a7ae0a2cc3c5
|
||||
/desktop-packaging/desktop-runtime-preparation:
|
||||
en: a7aa3066e649ffe8
|
||||
zh: 4334637910058c09
|
||||
en: ab73b67d899e611d
|
||||
zh: d77b0525c6b81e91
|
||||
/desktop-packaging/installation-and-release-verification:
|
||||
en: eb35eb973b796643
|
||||
zh: c1c52311632b7296
|
||||
/desktop-packaging/dev-note:
|
||||
en: b75c8458928b72b9
|
||||
zh: 003ba4e17b4b5269
|
||||
en: 691194744993dfaa
|
||||
zh: e5798faf99546ff8
|
||||
@@ -32,6 +32,8 @@ Build Desktop installers locally or through Windows or macOS Actions, choose dep
|
||||
|
||||
Run local commands from the repository root after installing locked dependencies. Use platform-specific commands for a local build, or platform Actions for the registered self-hosted runner. Choose test or production deployment independently from signed or unsigned mode. Read [Windows signing and runner operations](../windows-signing/README.md#trust-and-isolation) before any Windows signed build or shared-runner use. Installation is separate from packaging; [publication](../publishing/README.md) is a further explicit operation.
|
||||
|
||||
Set `DSH_DESKTOP_PACKAGE_CACHE_DIR` to an absolute build-cache directory for reuse across checkouts; see [cache configuration and lifecycle](#desktop-runtime-preparation).
|
||||
|
||||
<a id="release-versions"></a>
|
||||
|
||||
## Release versions
|
||||
@@ -113,18 +115,26 @@ Select the workflow in Actions, choose the source branch, preset, signing mode a
|
||||
|
||||
Open the completed run in Actions and download its Artifacts. `windows-x64-signed-<version>-<run_id>-<run_attempt>` and `windows-x64-unsigned-<version>-<run_id>-<run_attempt>` contain the corresponding EXE; `windows-package-logs-<run_id>-<run_attempt>` contains packaging diagnostics. The upload steps are `Retain verified signed installer`, `Retain verified unsigned installer`, and `Retain packaging diagnostics`; their step durations and timestamped logs show upload time. These artifacts are not a Release or download-site publication.
|
||||
|
||||
For path-sensitive Windows build measurements, `build_root` optionally selects an absolute directory outside existing Git checkouts. Each run clones the validated source commit into a new `windows-package-<run-id>-<attempt>/source` directory; ignored files and previous build outputs are not copied. Only the packaging process uses a sibling run-owned `temp` directory through `TEMP` and `TMP`; Actions steps and post-job hooks retain their original temporary directories. Dependency and signing caches retain their existing locations. Successful runs remove only their owned checkout after artifact uploads, retrying transient filesystem failures up to three times with 100 ms increasing delays; persistent cleanup errors fail the Job and may leave a partially removed directory. Failed builds retain their checkout for inspection. Leave the input empty to build in the original Actions checkout. Include checkout creation and cleanup in the complete Job timing.
|
||||
|
||||
For cache measurements, `cache_namespace` selects a retained subdirectory for both dependency stores and upstream downloads; leave it empty for normal shared caches. Use a new name for a cold sample and repeat that name for warm samples. Names contain 1–64 lowercase letters, digits or hyphens, start with a letter or digit, and cannot be Windows device names. This does not reset the separate signature cache or OS file cache; report those states and Job start-to-completion time alongside phase timings and artifact size. Before dependency installation, the workflow records the Node version, available CPU parallelism, logical CPU count, and total/free memory.
|
||||
|
||||
The dependency-install and packaging steps normalize their own shell to normal CPU, I/O and memory priority before launching children. Priority and isolated-workspace helpers are copied from the exact workflow commit into a run-specific temporary directory, independently of the selected product source. Missing helpers stop CI setup; unavailable priority adjustment emits a warning and marks scheduling unsuitable for performance comparison, but does not stop packaging. This does not modify the runner task or other processes. Version, preset and retained-artifact adapters remain tied to product source and its formats.
|
||||
|
||||
Installer uploads store the already-compressed release files without ZIP recompression; this preserves installer bytes and signatures. Diagnostic uploads retain their ordinary compression.
|
||||
|
||||
<a id="macos-actions"></a>
|
||||
|
||||
## macOS self-hosted packaging
|
||||
|
||||
The `Package Desktop · macOS` [manual workflow](../../../.github/workflows/macos-package.yml) targets an Apple Silicon runner labeled `dsh-mac-package-trial`. It selects `test` or `production`, resolves `build_version=auto` from the UTC date, run number and attempt, and uses the existing signing, notarization and packaged-runtime checks. Successful DMG/ZIP files and diagnostic logs are retained for 14 days; this workflow does not publish a release. The operator must register the runner and verify a complete Actions run before relying on it.
|
||||
The `Package Desktop · macOS` [manual workflow](../../../.github/workflows/macos-package.yml) targets an Apple Silicon runner labeled `dsh-mac-package-trial`. It selects `test` or `production`, resolves `build_version=auto` from the UTC date, run number and attempt, and uses the existing signing, notarization and packaged-runtime checks. Installer upload disables redundant ZIP compression; diagnostic logs retain normal compression. Successful DMG/ZIP files and diagnostic logs are retained for 14 days; this workflow does not publish a release. The operator must register the runner and verify a complete Actions run before relying on it. The `zip_compression_level` dropdown defaults to `3` and also offers `7`; it overrides the machine preset for this run and affects only the ZIP and automatic-update downloads, not the DMG.
|
||||
|
||||
Run `node apps/desktop/scripts/setup-macos-packaging.mjs "$HOME/dsh-ci"` to prepare private `runner/`, `packaging/{presets,secrets,certificates}` and `cache/macos-signatures` directories without overwriting existing files; append `--check` to inspect them. Fill the [preset](../config/macos-packaging/test.env.example) and [signing](../config/macos-packaging/macos-signing.env.example) templates outside the checkout. Set secret and certificate files to mode `0600`. `DSH_PACKAGING_CONFIG_DIR` selects the absolute `packaging` directory and `DSH_PACKAGING_PRESET` selects its preset; missing files fail without falling back to `.env.macos`. Signed and preparation-only Mac builds read `secrets/macos-signing.env`; relative credential paths resolve beside that file. Public presets reject signing credentials, and signing files cannot override public settings. Local unsigned Mac builds retain their configuration-free behavior.
|
||||
|
||||
Run `bash apps/desktop/scripts/start-macos-packaging-runner.sh "$HOME/dsh-ci/runner" "$HOME/dsh-ci/packaging" <absolute-node-24-bin-directory> --check` to check the host tools and configuration files. The script selects the active Xcode and its macOS SDK together and compiles a small linker probe. After registration, omit `--check` to run in the foreground with a cleared inherited environment and prevent idle sleep while the runner is alive. Stop it with Ctrl-C before installing the service below. The runner owns its `_work` checkout, separate from daily development. The account can read its signing files, so run only trusted code. The existing notarization proxy restoration rules apply; after forced termination, inspect retained recovery records before another build. New setup templates set `DSH_DESKTOP_MACOS_SIGNATURE_CACHE_DIR` to the absolute `cache/macos-signatures` directory outside checkout. For existing installations, rerun setup and add that field to each preset; existing files are preserved. This retains signatures across clean checkouts; cache verification and limits are described under [macOS signing](#macos-release-signing).
|
||||
|
||||
|
||||
The `skip_notarization` workflow option defaults to `false`; the equivalent CLI flag is `--skip-notarization`. It retains Developer ID signing, DMG signature verification and packaged-runtime verification, but omits Apple notarization and stapling and does not require Apple notarization credentials. DMG/ZIP filenames carry `-unnotarized`, are stored under `.desktop-build/targets/<target>/unnotarized-artifacts/`, and are uploaded with an `unnotarized-debug` label. These artifacts are for internal debugging only: Gatekeeper may block downloaded copies, and they are not qualified for distribution or update publishing. No release completion record is written. The flag rejects unsigned, Windows and preparation-only builds.
|
||||
ZIP compression uses the same configured level with or without notarization; the App is assembled once, then DMG and ZIP are built separately so the ZIP setting does not change DMG compression. The `skip_notarization` workflow option defaults to `false`; the equivalent CLI flag is `--skip-notarization`. It retains Developer ID signing, DMG signature verification and packaged-runtime verification, but omits Apple notarization and stapling and does not require Apple notarization credentials. DMG/ZIP filenames carry `-unnotarized`, are stored under `.desktop-build/targets/<target>/unnotarized-artifacts/`, and are uploaded with an `unnotarized-debug` label. These artifacts are for internal debugging only: Gatekeeper may block downloaded copies, and they are not qualified for distribution or update publishing. No release completion record is written. The flag rejects unsigned, Windows and preparation-only builds.
|
||||
|
||||
Install the user service from the checkout as the same account that registered the runner, without `sudo`:
|
||||
|
||||
@@ -146,7 +156,7 @@ On Windows x64, use the complete unsigned packaging command for local installati
|
||||
pnpm run package:desktop:win:x64:unsigned
|
||||
```
|
||||
|
||||
The command reads `.env.windows` and requires `DSH_DESKTOP_APP_ID` and the [mandatory-update policy settings](../README.md#mandatory-update-policy), including a real HTTPS service origin. The local run and CI use Node 24; the remaining tools are the root `packageManager` version of pnpm, Git, PowerShell, tar, Python, Visual C++ Build Tools and a Windows SDK. Set `PYTHON` when Python is absent from `PATH`. It writes the installer to `.desktop-build/targets/win-x64/unsigned-artifacts/`, omits ordinary automatic-update configuration, strips signing credentials, and creates no release completion record. EV and upload credentials are unnecessary. Signed packaging and upload retain their release requirements.
|
||||
The command reads `.env.windows` and requires `DSH_DESKTOP_APP_ID` and the [mandatory-update policy settings](../README.md#mandatory-update-policy), including a real HTTPS service origin. The local run and CI use Node 24; the remaining tools are the root `packageManager` version of pnpm, Git, PowerShell 7 (`pwsh`), tar, Python, Visual C++ Build Tools and a Windows SDK. Set `PYTHON` when Python is absent from `PATH`. It writes the installer to `.desktop-build/targets/win-x64/unsigned-artifacts/`, omits ordinary automatic-update configuration, strips signing credentials, and creates no release completion record. EV and upload credentials are unnecessary. Signed packaging and upload retain their release requirements.
|
||||
|
||||
Run `pnpm install --frozen-lockfile`, then `pnpm run package:desktop:win:x64:unsigned --check` to validate configuration and tooling before the full command above. Packaging performs its own complete build and isolated runtime smoke without an online model. Stage logs remain under `.desktop-build/packaging-runs/`; a successful build alone does not qualify the installer. Dependency installation and runtime preparation need access to npm, GitHub release assets and nodejs.org; use the existing registry and proxy configuration when the network requires it. Caches and tool locations belong to the build machine, not tracked personal paths.
|
||||
|
||||
@@ -179,11 +189,12 @@ Mac package commands print `DESKTOP_PACKAGING_RECORD` with a unique directory un
|
||||
|
||||
App and DMG notarization each record a submission ID and separate `notarytool:upload:*` and `notarytool:wait:*` durations. Upload uses `submit --no-wait` and includes authentication, local validation, transfer and server acceptance; it is not pure transfer time. Waiting starts after that command returns and includes polling and remaining Apple processing; Apple can start processing before upload submission returns. Apple status and diagnostic logs are retained, including rejection; signature checks, acceptance checks and stapling remain owned by `@electron/notarize`. Directory-only package commands use this same measured App notarization path. Individual subprocess output remains live in the terminal, while phase failures and nested errors are retained in the journal. Both platforms record parent-process packaging failures and print redacted diagnostics, including proxy recovery instructions, to the terminal. The configuration-only `check:package` command creates no run log.
|
||||
|
||||
Mac packaging reads three tuning fields from `.env.macos`:
|
||||
Mac packaging reads these tuning fields from `.env.macos`:
|
||||
|
||||
| Setting | Default | Scope |
|
||||
|---|---|---|
|
||||
| `DSH_DESKTOP_MACOS_PACK_CONCURRENCY` | `4` | Workers for first-party and vendor workspace tarballs; requires a positive integer. |
|
||||
| `DSH_DESKTOP_MACOS_ZIP_COMPRESSION_LEVEL` | `3` | Final update ZIP compression, integer `0`–`9`; lower levels trade larger downloads for shorter builds. App notarization archives and DMG compression are unchanged. |
|
||||
| `DSH_DESKTOP_MACOS_DOWNLOAD_PROXY` | Empty | HTTP/HTTPS proxy origin for Electron, runtime assets, pnpm installation and builder downloads. |
|
||||
| `DSH_DESKTOP_MACOS_NOTARIZATION_PROXY` | Empty | HTTP proxy origin for Apple tools through temporary system proxy settings. |
|
||||
|
||||
@@ -254,18 +265,26 @@ When extraction fails, the installer writes a report with the 7-Zip result and i
|
||||
|
||||
Windows packaging fixes the 7-Zip filter to `BCJ` for compatibility with the bundled NSIS decoder. This preserves ARM64 binaries carried by dependencies in x64 installers; automatic ARM64 filtering produces entries that this decoder cannot extract.
|
||||
|
||||
`DSH_DESKTOP_WINDOWS_COMPRESSION_LEVEL` in the Windows dotenv or machine preset selects installer archive compression from `0` to `9`. If omitted, the existing `ELECTRON_BUILDER_COMPRESSION_LEVEL` environment override remains effective, otherwise the default is `3` for both test and production. The manual Windows workflow's `compression_level` dropdown defaults to `3` and also offers `7`, matching the Mac ZIP choices; it overrides the file setting for that run. Direct local builds retain explicit machine settings. Mac ZIP workflow overrides must be `3` or `7`; an empty value is invalid even on macOS. Any defined Mac ZIP workflow override, including an empty value, rejects Windows; nonempty Windows workflow overrides reject macOS. Lower levels trade installer size for packaging time; qualify the complete signed workflow and artifact size before changing a release preset.
|
||||
|
||||
NSIS removes its temporary extraction tree during installation, before the completion page or an automatic launch. The installed production packages remain ordinary files; startup does not extract them again. Installation still writes the complete application tree.
|
||||
|
||||
<a id="desktop-runtime-preparation"></a>
|
||||
|
||||
## Desktop runtime preparation
|
||||
|
||||
Windows prepares Electron and primary-runtime concurrently; macOS prepares them serially. CLI preparation and `versions.json` publication require both payloads to succeed. If either Windows payload fails, preparation waits for the other to settle before reporting failure and publishes neither CLI resources nor versions. Nested preparation timings overlap and must not be added together.
|
||||
|
||||
`DSH_DESKTOP_PACKAGE_CACHE_DIR` in the packaging process environment selects a nonempty absolute directory for shared `downloads` and `pnpm-store` caches; omission uses `.desktop-build`. Temporary installation projects, lockfiles and configuration remain separate per run. Runtime installation verifies store integrity and copies package files so later signing cannot modify cached bytes; cleanup retains these caches. Both packaging workflows use `runner.tool_cache/dsh-desktop-package-cache`; Windows repository dependency installation uses a separate `.pnpm-store` at the workspace drive root. Both installation logs identify their store and report package reuse. A fresh runtime lockfile still resolves dependencies on every run. Share writable caches only among trusted build jobs; integrity checks do not isolate untrusted writers. Clean caches only when no job is using them. Operators manage cache disk usage; no automatic eviction runs.
|
||||
|
||||
Desktop [patch policy](../scripts/runtime-patch-policy.ts) explicitly classifies every workspace patch as shared, workspace-only, or runtime-only. Shared entries reuse the root patch bytes and verify their hash against the root lockfile; runtime-only entries use separate repository files without changing workspace installation. Workspace-only patches belong to build tools or dependencies already embedded in client bundles. Unclassified or stale entries, missing files, changed hashes, incompatible resolved versions, and workspace-only packages entering the runtime stop packaging. The [preparation script](../scripts/prepare-runtime-patches.ts) writes only to the temporary project; pnpm applies the selected patches and rejects application failures.
|
||||
|
||||
Desktop dependency overrides are declared separately in the patch-policy module, not inferred from patch versions. The pi-ai constraint keeps its runtime version at the version qualified by the shared patch. Desktop still resolves a fresh runtime lockfile for each build, then installs with `--frozen-lockfile`; dependency resolution across separate builds is not yet reproducible. CLI packed-installation tests and other distribution flows retain their own configuration.
|
||||
|
||||
Every package command builds the repository, packs the first-party production closures rooted at dsh and the private Desktop Host, and prepares the target Electron distribution plus primary-runtime with its shared pnpm CLI. `prepare:dsh` installs the production graph once at build time, prepares materialized packages for electron-builder to archive under `app.asar/dsh`, removes package-manager metadata, and writes `desktop-runtime.json` with shared package versions and final file hashes. Signed macOS builds sign and verify native files before inventory generation; electron-builder excludes this already-signed tree from nested re-signing. Resource mappings explicitly include `dsh/node_modules`, which the default root-directory filter omits; the prepared runtime inventory is checked after native signing. Native executables and libraries are unpacked beside ASAR; Python, standalone Node and pnpm remain in external runtime resources. Windows packaging checks every prepared PE against its ASAR unpacked entry and byte-identical disk copy, including unsigned builds. Builder glob rules match braces in PE filenames as single-character wildcards, so a matching neighboring file may also be unpacked. Prepared runtime smoke uses the verified target descriptor rather than the build host architecture. Signed installer, notarization, installed upgrade, and target-specific native-module qualification require the release environment.
|
||||
|
||||
Windows runtime sealing reads at most eight files concurrently, before and after native signing. A read failure drains outstanding reads without replacing the descriptor; successful sealing still requires the subsequent complete integrity and smoke checks.
|
||||
|
||||
Signed macOS packaging writes `Contents/Resources/app-update.yml` while assembling the App and before code signing, including the directory build that feeds the parallel ZIP and DMG lanes. The signing hook verifies the exact feed and updater cache directory. Both lane copies and the promoted App are checked again before the release completion record is written; a missing or mismatched configuration prevents artifact promotion and therefore prevents upload.
|
||||
|
||||
An unpacked artifact contains Electron, the materialized dsh production tree, pnpm, and the shell application. Installer size and filesystem size differ; release qualification measures both, plus the profile’s plugin storage and first-launch latency. The runtime trades more application files for eliminating core package installation on the user’s machine.
|
||||
@@ -280,4 +299,4 @@ Keep the generated installer, its version and packaging records together. Review
|
||||
|
||||
## Dev Note
|
||||
|
||||
None.
|
||||
XXX: Evaluate disk-space monitoring, a maintenance entry point and automatic eviction if disk shortages, sustained cache growth or frequent manual cleanup occur. Any automated cleanup would need coordination with every cache user.
|
||||
@@ -32,6 +32,8 @@ description: "在本地或 Windows/macOS Actions 中生成 Desktop 安装包
|
||||
|
||||
安装锁定依赖后,从仓库根目录运行本地命令。本地构建使用平台命令,已注册的自托管 runner 使用对应平台的 Actions。test/production 部署与 signed/unsigned 模式分别选择。任何 Windows 签名构建或共享 runner 使用前,先阅读 [Windows 签名与 runner 运维](../windows-signing/README.zh.md#trust-and-isolation)。安装与打包分开进行;[发布](../publishing/README.zh.md)还需要单独的显式操作。
|
||||
|
||||
将 `DSH_DESKTOP_PACKAGE_CACHE_DIR` 设为绝对构建缓存目录以跨 checkout 复用;详见[缓存配置与生命周期](#desktop-runtime-preparation)。
|
||||
|
||||
<a id="release-versions"></a>
|
||||
|
||||
## 发布版本
|
||||
@@ -113,18 +115,26 @@ runner 的受监督启动和恢复见 [Windows runner 机器准备](../windows-s
|
||||
|
||||
在 Actions 打开完成的运行并下载 Artifacts。`windows-x64-signed-<version>-<run_id>-<run_attempt>` 与 `windows-x64-unsigned-<version>-<run_id>-<run_attempt>` 包含对应 EXE;`windows-package-logs-<run_id>-<run_attempt>` 包含打包诊断。上传步骤名为 `Retain verified signed installer`、`Retain verified unsigned installer` 和 `Retain packaging diagnostics`;步骤耗时与带时间戳的日志显示上传用时。这些产物不等于发布 Release 或下载站内容。
|
||||
|
||||
测量 Windows 构建路径对性能的影响时,可用 `build_root` 指定现有 Git checkout 之外的绝对目录。每轮任务将已验证的源码提交克隆到新的 `windows-package-<run-id>-<attempt>/source` 目录;不会复制忽略文件或此前的构建输出。仅打包进程通过 `TEMP` 和 `TMP` 使用同轮所属的同级 `temp` 目录;Actions 步骤及 Job 后置钩子保留原临时目录。依赖缓存和签名缓存沿用原有位置。成功任务在产物上传后仅删除本轮所属的 checkout,临时文件系统错误最多重试三次,等待时间按 100 ms 递增;持续清理失败会使 Job 失败,并可能留下部分删除的目录。构建失败时保留 checkout 供检查。输入留空时使用原 Actions checkout。完整 Job 耗时须包含 checkout 创建和清理。
|
||||
|
||||
测量缓存时,`cache_namespace` 为两个依赖 store 和上游下载选择保留的子目录;留空使用正常共享缓存。新名称用于冷缓存采样,再次使用同名用于热缓存采样。名称由 1–64 个小写字母、数字或连字符组成,以字母或数字开头,且不能为 Windows 设备名。这不会重置独立的签名缓存或操作系统文件缓存;应将其状态、Job 开始至完成的总耗时与阶段耗时和产物体积一并报告。workflow 在依赖安装前记录 Node 版本、可用 CPU 并行数、逻辑 CPU 数及总内存和空闲内存。
|
||||
|
||||
依赖安装和打包步骤在启动子进程前,将自身 shell 的 CPU、I/O 和内存优先级调整为普通值。优先级及独立构建目录辅助脚本从 workflow 的精确提交复制到本轮临时目录,与选中的产品源码分离。缺失辅助脚本会终止 CI 准备;无法调整优先级时明确告警并标记调度条件不适合性能对比,但继续打包。这不会修改 runner 任务或其他进程。版本、preset 和产物留存适配器仍跟随产品源码及其格式。
|
||||
|
||||
安装器上传直接存储已经压缩的发布文件,不再进行 ZIP 二次压缩;安装器字节和签名保持不变。诊断日志上传保留通常的压缩方式。
|
||||
|
||||
<a id="macos-actions"></a>
|
||||
|
||||
## macOS 自托管打包
|
||||
|
||||
`Package Desktop · macOS` [手动工作流](../../../.github/workflows/macos-package.yml) 使用带有 `dsh-mac-package-trial` 标签的 Apple Silicon runner。它选择 `test` 或 `production`,以 UTC 日期、运行编号和重试次数解析 `build_version=auto`,并复用现有签名、公证和打包运行时检查。成功的 DMG/ZIP 与诊断日志保留 14 天;工作流不发布 release。操作人员须注册 runner,并验收完整 Actions 运行后再依赖该流程。
|
||||
`Package Desktop · macOS` [手动工作流](../../../.github/workflows/macos-package.yml) 使用带有 `dsh-mac-package-trial` 标签的 Apple Silicon runner。它选择 `test` 或 `production`,以 UTC 日期、运行编号和重试次数解析 `build_version=auto`,并复用现有签名、公证和打包运行时检查。安装包上传关闭重复 ZIP 压缩;诊断日志保留普通压缩。成功的 DMG/ZIP 与诊断日志保留 14 天;工作流不发布 release。操作人员须注册 runner,并验收完整 Actions 运行后再依赖该流程。 `zip_compression_level` 下拉选项默认为 `3`,也可选择 `7`;本次运行以该选项覆盖机器预设,只影响 ZIP 和自动升级下载,不影响 DMG。
|
||||
|
||||
运行 `node apps/desktop/scripts/setup-macos-packaging.mjs "$HOME/dsh-ci"` 创建私有 `runner/`、`packaging/{presets,secrets,certificates}` 和 `cache/macos-signatures` 目录,不覆盖已有文件;追加 `--check` 仅检查。在 checkout 外填写 [preset](../config/macos-packaging/test.env.example) 和[签名](../config/macos-packaging/macos-signing.env.example)模板,将秘密和证书文件权限设为 `0600`。`DSH_PACKAGING_CONFIG_DIR` 指定 `packaging` 的绝对目录,`DSH_PACKAGING_PRESET` 选择其中的 preset;缺少文件立即失败,不回退到 `.env.macos`。Mac 签名和仅准备构建读取 `secrets/macos-signing.env`,相对凭据路径以该文件所在目录为基准。公开 preset 拒绝签名凭据,签名文件不能覆盖公开配置。本地未签名 Mac 构建保留无需配置的行为。
|
||||
|
||||
运行 `bash apps/desktop/scripts/start-macos-packaging-runner.sh "$HOME/dsh-ci/runner" "$HOME/dsh-ci/packaging" <absolute-node-24-bin-directory> --check` 检查宿主工具和配置文件。脚本同时选择当前 Xcode 及其 macOS SDK,并编译小型链接探针。注册后省略 `--check`,清理继承环境并在前台启动 runner,存活期间阻止空闲睡眠。安装下方服务前,先用 Ctrl-C 停止。runner 拥有 `_work` 下的源码目录,与日常开发分开。该账户可读取签名文件,因此只执行可信代码。沿用现有公证代理恢复规则;强制终止后,先检查保留的恢复记录再构建。新建模板将 `DSH_DESKTOP_MACOS_SIGNATURE_CACHE_DIR` 设为 checkout 外 `cache/macos-signatures` 的绝对路径。已有部署重新运行 setup 后,在各 preset 中补上该字段;已有文件保持不变。这样可跨干净检出保留签名;缓存验证和容量限制见 [macOS 签名](#macos-release-signing)。
|
||||
|
||||
|
||||
工作流选项 `skip_notarization` 默认为 `false`;对应 CLI 参数是 `--skip-notarization`。该模式保留 Developer ID 签名、DMG 签名验证和打包运行时验证,跳过 Apple 公证和 stapling,不要求 Apple 公证凭据。DMG/ZIP 文件名带 `-unnotarized`,存放于 `.desktop-build/targets/<target>/unnotarized-artifacts/`,上传产物标为 `unnotarized-debug`。这些产物仅供内部调试:Gatekeeper 可能阻止下载副本运行,不能视为可分发包或用于更新发布。该模式不写入发布完成记录,不能与未签名、Windows 或仅准备构建组合。
|
||||
无论是否公证,ZIP 都使用同一压缩配置;App 只组装一次,再分别生成 DMG 和 ZIP,确保 ZIP 设置不改变 DMG 压缩。工作流选项 `skip_notarization` 默认为 `false`;对应 CLI 参数是 `--skip-notarization`。该模式保留 Developer ID 签名、DMG 签名验证和打包运行时验证,跳过 Apple 公证和 stapling,不要求 Apple 公证凭据。DMG/ZIP 文件名带 `-unnotarized`,存放于 `.desktop-build/targets/<target>/unnotarized-artifacts/`,上传产物标为 `unnotarized-debug`。这些产物仅供内部调试:Gatekeeper 可能阻止下载副本运行,不能视为可分发包或用于更新发布。该模式不写入发布完成记录,不能与未签名、Windows 或仅准备构建组合。
|
||||
|
||||
在 checkout 中,以注册 runner 的同一账户安装用户服务,不使用 `sudo`:
|
||||
|
||||
@@ -146,7 +156,7 @@ bash apps/desktop/scripts/macos-runner-service.sh status "$HOME/dsh-ci/runner"
|
||||
pnpm run package:desktop:win:x64:unsigned
|
||||
```
|
||||
|
||||
该命令读取 `.env.windows`,要求设置 `DSH_DESKTOP_APP_ID` 和[强制更新策略配置](../README.zh.md#mandatory-update-policy),包括真实的 HTTPS 服务 origin。本机验收和 CI 使用 Node 24;其他工具为根 `packageManager` 固定版本的 pnpm、Git、PowerShell、tar、Python、Visual C++ Build Tools 和 Windows SDK。Python 不在 `PATH` 中时设置 `PYTHON`。命令将安装包写入 `.desktop-build/targets/win-x64/unsigned-artifacts/`,省略普通自动更新配置,清除签名凭据,且不生成发布完成记录。无需 EV 或上传凭据。签名打包和上传仍遵循正式发布要求。
|
||||
该命令读取 `.env.windows`,要求设置 `DSH_DESKTOP_APP_ID` 和[强制更新策略配置](../README.zh.md#mandatory-update-policy),包括真实的 HTTPS 服务 origin。本机验收和 CI 使用 Node 24;其他工具为根 `packageManager` 固定版本的 pnpm、Git、PowerShell 7(`pwsh`)、tar、Python、Visual C++ Build Tools 和 Windows SDK。Python 不在 `PATH` 中时设置 `PYTHON`。命令将安装包写入 `.desktop-build/targets/win-x64/unsigned-artifacts/`,省略普通自动更新配置,清除签名凭据,且不生成发布完成记录。无需 EV 或上传凭据。签名打包和上传仍遵循正式发布要求。
|
||||
|
||||
先运行 `pnpm install --frozen-lockfile`,再运行 `pnpm run package:desktop:win:x64:unsigned --check` 校验配置和工具,最后执行上面的完整命令。打包自行执行完整构建和隔离的运行时 smoke,不调用在线模型。阶段日志保留在 `.desktop-build/packaging-runs/`;仅构建成功不代表安装包验证通过。依赖安装和运行时准备需要访问 npm、GitHub release 资源和 nodejs.org;网络需要时使用现有 registry 和代理配置。缓存与工具位置属于构建机器配置,不在跟踪文件中固定个人路径。
|
||||
|
||||
@@ -179,11 +189,12 @@ Mac 打包命令通过 `DESKTOP_PACKAGING_RECORD` 输出 `apps/desktop/.desktop-
|
||||
|
||||
App 和 DMG 公证分别记录 submission ID,以及独立的 `notarytool:upload:*` 和 `notarytool:wait:*` 耗时。上传使用 `submit --no-wait`,包含认证、本地校验、传输和服务端受理,并非纯传输时间。等待从该命令返回后开始,包含轮询及剩余的 Apple 处理时间;Apple 可能在上传提交命令返回前已开始处理。日志保留 Apple 状态与诊断信息,包括拒绝结果;签名检查、接受状态校验和 stapling 仍由 `@electron/notarize` 负责。仅生成目录的打包命令也使用同一条可计时的 App 公证路径。各子进程输出仍实时显示在终端,阶段失败及嵌套错误保留在事件日志中。两个平台均记录父进程打包失败,并在终端显示脱敏诊断,包括代理恢复操作指引。仅检查配置的 `check:package` 命令不创建运行日志。
|
||||
|
||||
Mac 打包从 `.env.macos` 读取三个调优字段:
|
||||
Mac 打包从 `.env.macos` 读取以下调优字段:
|
||||
|
||||
| 设置 | 默认值 | 作用范围 |
|
||||
|---|---|---|
|
||||
| `DSH_DESKTOP_MACOS_PACK_CONCURRENCY` | `4` | 第一方与 vendor workspace tarball 的打包 worker 数;必须是正整数。 |
|
||||
| `DSH_DESKTOP_MACOS_ZIP_COMPRESSION_LEVEL` | `3` | 最终更新 ZIP 的压缩等级,整数 `0`–`9`;较低等级以更大的下载体积换取更短的构建时间。App 公证归档和 DMG 压缩不变。 |
|
||||
| `DSH_DESKTOP_MACOS_DOWNLOAD_PROXY` | 空 | Electron、运行时资源、pnpm 安装及 builder 下载使用的 HTTP/HTTPS 代理 origin。 |
|
||||
| `DSH_DESKTOP_MACOS_NOTARIZATION_PROXY` | 空 | 通过临时系统代理设置供 Apple 工具使用的 HTTP 代理 origin。 |
|
||||
|
||||
@@ -254,18 +265,26 @@ Windows 安装器在启动时和选定目标目录后检查应用是否正在运
|
||||
|
||||
Windows 打包将 7-Zip 过滤器固定为 `BCJ`,以兼容内置的 NSIS 解码器。这样可以保留 x64 安装包中由依赖携带的 ARM64 二进制文件;自动 ARM64 过滤会生成该解码器无法解压的条目。
|
||||
|
||||
Windows dotenv 或机器 preset 中的 `DSH_DESKTOP_WINDOWS_COMPRESSION_LEVEL` 选择 `0` 至 `9` 的安装包归档压缩等级。省略时,现有 `ELECTRON_BUILDER_COMPRESSION_LEVEL` 环境覆盖仍然生效,否则 test 和 production 均默认为 `3`。手动 Windows workflow 的 `compression_level` 下拉默认选 `3`,也提供 `7`,与 Mac ZIP 选项一致;它对本轮覆盖文件中的设置。直接本地打包保留显式机器配置。Mac ZIP workflow 覆盖值必须为 `3` 或 `7`;空值在 macOS 上也非法。任何已定义的 Mac ZIP workflow 覆盖值(包括空值)均拒绝用于 Windows;非空的 Windows workflow 覆盖值拒绝用于 macOS。较低等级以安装包体积换取打包时间;修改发布 preset 前应验证完整签名 workflow 和产物体积。
|
||||
|
||||
NSIS 在安装阶段清理临时解压目录,完成后才显示完成页或自动启动应用。已安装的生产依赖保持为普通文件;启动时不会再次解压。安装仍会写入完整的应用目录树。
|
||||
|
||||
<a id="desktop-runtime-preparation"></a>
|
||||
|
||||
## Desktop 运行时准备
|
||||
|
||||
Windows 并行准备 Electron 和 primary-runtime;macOS 串行准备二者。只有两份运行时资源均成功,才会准备 CLI 并写入 `versions.json`。任一 Windows 运行时准备失败时,流程会等待另一路结束后再报告失败,且不写入 CLI 资源或版本记录。嵌套准备阶段的计时存在重叠,不能直接相加。
|
||||
|
||||
打包进程环境中的 `DSH_DESKTOP_PACKAGE_CACHE_DIR` 为共享的 `downloads` 和 `pnpm-store` 缓存指定非空绝对目录;省略时使用 `.desktop-build`。临时安装项目、lockfile 和配置仍逐次独立创建。运行时安装校验 store 完整性并复制包文件,防止后续签名修改缓存字节;清理时保留这些缓存。两个打包工作流使用 `runner.tool_cache/dsh-desktop-package-cache`;Windows 仓库依赖安装使用工作区盘根目录下独立的 `.pnpm-store`。两次安装日志均标明各自的 store 并报告包复用情况。每次运行仍通过新的运行时 lockfile 解析依赖。可写缓存仅供可信构建任务共享;完整性校验不能隔离不可信的写入者。仅在没有任务使用缓存时清理。缓存磁盘用量由操作者管理,不自动淘汰。
|
||||
|
||||
Desktop [补丁策略](../scripts/runtime-patch-policy.ts) 将每份工作区补丁明确归为共享、仅工作区或仅运行时。共享项复用根目录补丁字节,并对照根锁文件校验 hash;仅运行时项使用仓库内的独立文件,不改变工作区安装。仅工作区补丁用于构建工具或已嵌入客户端 bundle 的依赖。未分类或过期的条目、文件缺失、hash 改变、解析版本不兼容,以及仅工作区包进入运行时,都会阻止打包。[准备脚本](../scripts/prepare-runtime-patches.ts) 只写入临时项目;pnpm 负责应用选中的补丁,应用失败时会报错。
|
||||
|
||||
Desktop 依赖 overrides 在补丁策略模块中单独声明,不从补丁版本推导。pi-ai 约束使运行时版本保持在共享补丁已验证的版本。Desktop 仍会在每次构建时重新解析运行时锁文件,再通过 `--frozen-lockfile` 安装;不同构建之间的依赖解析尚不保证可复现。CLI 打包安装测试及其他交付流程保留各自的配置。
|
||||
|
||||
每条打包命令都会构建仓库,打包以 dsh 和私有 Desktop Host 为根的第一方生产依赖闭包,并准备目标专用的 Electron 分发包和包含共享 pnpm CLI 的 primary-runtime。`prepare:dsh` 在构建时安装一次生产依赖图,准备物化包供 electron-builder 归档到 `app.asar/dsh`,移除包管理器元数据,并生成包含共享包版本和最终文件哈希的 `desktop-runtime.json`。macOS 签名构建先签名并验证原生文件,再生成清单;electron-builder 不对已签名的此目录重复进行嵌套签名。资源映射明确包含默认根目录过滤器会忽略的 `dsh/node_modules`;准备完成的运行时清单在原生签名后检查。原生可执行文件及库解包到 ASAR 旁;Python、独立 Node 和 pnpm 保留在外部 runtime 资源中。Windows 打包逐项检查准备好的 PE,确认其 ASAR 条目已标记为解包,且磁盘副本字节一致;未签名构建也执行此检查。Builder glob 规则用单字符通配符匹配 PE 文件名中的花括号,因此同目录中名称匹配的文件也可能被解包。准备好的运行时 smoke 沿用已验证的目标描述符,不使用构建宿主的架构。签名安装包、公证、已安装应用升级和各目标原生模块的验收需要发布环境。
|
||||
|
||||
Windows 运行时在原生签名前后封存文件清单,每次最多并发读取八个文件。读取失败时会等待未完成的读取结束,不替换描述符;封存成功后仍须完成后续的完整性与 smoke 检查。
|
||||
|
||||
macOS 签名打包在组装 App 时、代码签名前写入 `Contents/Resources/app-update.yml`,供并行 ZIP 与 DMG 路线使用的目录构建也执行此操作。签名钩子验证准确的更新源和 updater 缓存目录。写入发布完成记录前,流程会再次检查两条路线的副本和最终移入的 App;配置缺失或不匹配会阻止移入产物,因而也会阻止上传。
|
||||
|
||||
未压缩产物包含 Electron、物化后的 dsh 生产依赖树、pnpm,以及壳应用。安装包大小与文件系统占用不同;发布验收需要测量两者,以及 profile 插件存储和首次启动耗时。此布局用更多应用内文件换取消除用户机器上的核心包安装过程。
|
||||
@@ -280,4 +299,4 @@ Windows 以不带版本后缀的产品名称显示已安装应用,版本保留
|
||||
|
||||
## 开发备注
|
||||
|
||||
无。
|
||||
XXX:出现磁盘空间不足、缓存持续增长或频繁人工清理时,评估空间监测、维护入口和自动淘汰。任何自动清理方案都需要与所有使用缓存的任务协调。
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { DesktopAutoUpdateTarget } from './desktop-auto-update-environment.mjs'
|
||||
|
||||
/** Mutable target directories plus the shared immutable download cache. */
|
||||
/** Mutable target directories plus shared download and package caches. */
|
||||
export interface DesktopTargetBuildPaths {
|
||||
readonly root: string
|
||||
readonly artifacts: string
|
||||
@@ -15,6 +15,7 @@ export interface DesktopTargetBuildPaths {
|
||||
readonly packedVendor: string
|
||||
readonly packedLandlock: string
|
||||
readonly downloads: string
|
||||
readonly pnpmStore: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -33,9 +34,10 @@ export function resolveDesktopBuildTarget(
|
||||
/**
|
||||
* Return the mutable preparation and artifact directories owned by one release target.
|
||||
* @param target - Supported Desktop target name.
|
||||
* @returns Target paths plus the shared immutable download cache.
|
||||
* @param environment - Packaging environment with an optional absolute cache directory.
|
||||
* @returns Target paths plus shared download and package caches.
|
||||
*/
|
||||
export function desktopTargetBuildPaths(target: DesktopAutoUpdateTarget): DesktopTargetBuildPaths
|
||||
export function desktopTargetBuildPaths(target: DesktopAutoUpdateTarget, environment?: NodeJS.ProcessEnv): DesktopTargetBuildPaths
|
||||
|
||||
/**
|
||||
* Return the platform and architecture of the payload one release target prepares.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** Resolve build-owned Desktop paths without sharing mutable state across release targets. */
|
||||
/** Resolve target-owned Desktop outputs and shared package caches. */
|
||||
|
||||
import { join, resolve } from 'node:path'
|
||||
import { isAbsolute, join, resolve } from 'node:path'
|
||||
|
||||
const APP_ROOT = resolve(import.meta.dirname, '..')
|
||||
const BUILD_ROOT = join(APP_ROOT, '.desktop-build')
|
||||
@@ -38,10 +38,15 @@ function assertSupportedTarget(target) {
|
||||
/**
|
||||
* Return the mutable preparation and artifact directories owned by one release target.
|
||||
* @param {'mac-arm64' | 'mac-x64' | 'win-x64'} target - Supported Desktop target name.
|
||||
* @returns {{ root: string, artifacts: string, unsignedArtifacts: string, unnotarizedArtifacts: string, runtime: string, packageSet: string, dsh: string, dshPnpm: string, electron: string, packedDsh: string, packedVendor: string, packedLandlock: string, downloads: string }} Target paths plus the shared immutable download cache.
|
||||
* @param {NodeJS.ProcessEnv} environment - Packaging environment with an optional absolute cache directory.
|
||||
* @returns {{ root: string, artifacts: string, unsignedArtifacts: string, unnotarizedArtifacts: string, runtime: string, packageSet: string, dsh: string, dshPnpm: string, electron: string, packedDsh: string, packedVendor: string, packedLandlock: string, downloads: string, pnpmStore: string }} Target paths plus shared download and package caches.
|
||||
*/
|
||||
export function desktopTargetBuildPaths(target) {
|
||||
export function desktopTargetBuildPaths(target, environment = process.env) {
|
||||
assertSupportedTarget(target)
|
||||
const cacheRoot = environment.DSH_DESKTOP_PACKAGE_CACHE_DIR ?? BUILD_ROOT
|
||||
if (!cacheRoot.trim() || !isAbsolute(cacheRoot) || cacheRoot.includes('\0')) {
|
||||
throw new Error('desktop package: DSH_DESKTOP_PACKAGE_CACHE_DIR must be a nonempty absolute directory')
|
||||
}
|
||||
const root = join(BUILD_ROOT, 'targets', target)
|
||||
const packed = join(root, 'packed')
|
||||
return {
|
||||
@@ -57,7 +62,8 @@ export function desktopTargetBuildPaths(target) {
|
||||
packedDsh: join(packed, 'dsh'),
|
||||
packedVendor: join(packed, 'vendor'),
|
||||
packedLandlock: join(packed, 'landlock'),
|
||||
downloads: join(BUILD_ROOT, 'downloads'),
|
||||
downloads: join(cacheRoot, 'downloads'),
|
||||
pnpmStore: join(cacheRoot, 'pnpm-store'),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,7 +93,7 @@ export function resolveDesktopTargetBuildPaths(
|
||||
hostPlatform = process.platform,
|
||||
hostArch = process.arch,
|
||||
) {
|
||||
return desktopTargetBuildPaths(resolveDesktopBuildTarget(env, hostPlatform, hostArch))
|
||||
return desktopTargetBuildPaths(resolveDesktopBuildTarget(env, hostPlatform, hostArch), env)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -10,6 +10,9 @@ export function localMacOSPackageEnvironment(environment?: NodeJS.ProcessEnv): N
|
||||
/**
|
||||
* Read a machine preset when selected, otherwise the target's required dotenv; no ambient release fallback.
|
||||
* PACKAGE_TEST_RELEASE_ID overrides the test preset only; blank retains its configured ID.
|
||||
* PACKAGE_MACOS_ZIP_COMPRESSION_LEVEL overrides the Mac file setting with 3 or 7; an empty value is invalid.
|
||||
* Any defined Mac ZIP override rejects Windows, including an empty value; nonempty Windows overrides reject macOS.
|
||||
* PACKAGE_WINDOWS_COMPRESSION_LEVEL selects 3 or 7 for Windows workflow builds; blank retains the file setting.
|
||||
* @param platform Target platform.
|
||||
* @param environment Parent environment, retained only for unrelated build tools.
|
||||
* @param appRoot Desktop application directory; relative credential paths resolve here.
|
||||
|
||||
@@ -12,11 +12,12 @@ import { validateMacOSSignatureCacheDirectory } from './macos-signature-cache-di
|
||||
import { resolveMacOSPackageSettings } from './macos-package-settings.mjs'
|
||||
import { resolveWindowsSignatureCacheDirectory } from './windows-signature-cache-directory.mjs'
|
||||
import { resolveWindowsPackageSettings } from './windows-package-settings.mjs'
|
||||
import { resolveDesktopTargetBuildPaths } from './desktop-build-paths.mjs'
|
||||
|
||||
const APP_ROOT = fileURLToPath(new URL('..', import.meta.url))
|
||||
const SHARED_SETTING = /^(?:DSH_DESKTOP_(?:APP_ID|AUTO_UPDATE_ENV|NPM_REGISTRY|MANDATORY_UPDATE_(?:CONFIG|(?:TEST|PROD)_ORIGIN))|DOWNLOAD_TEST_RELEASE_ID|DOWNLOAD_(?:TEST|PROD)_(?:ORIGIN|COS_BUCKET|COS_SECRET_ID|COS_SECRET_KEY))$/u
|
||||
const WINDOWS_SETTING = /^DSH_DESKTOP_WINDOWS_(?:CER_FILE|SIGNTOOL|KEY_CONTAINER|TOKEN_PIN|SIGNATURE_CACHE_DIR|SIGNATURE_CACHE_CONCURRENCY|PACK_CONCURRENCY)$/u
|
||||
const MACOS_SETTING = /^(?:DSH_DESKTOP_MACOS_(?:SIGNING_IDENTITY|TEAM_ID|SIGNATURE_CACHE_DIR|PACK_CONCURRENCY|DOWNLOAD_PROXY|NOTARIZATION_PROXY)|APPLE_(?:API_KEY|API_KEY_ID|API_ISSUER|ID|APP_SPECIFIC_PASSWORD|TEAM_ID|KEYCHAIN|KEYCHAIN_PROFILE)|CSC_(?:LINK|KEY_PASSWORD))$/u
|
||||
const WINDOWS_SETTING = /^DSH_DESKTOP_WINDOWS_(?:CER_FILE|SIGNTOOL|KEY_CONTAINER|TOKEN_PIN|SIGNATURE_CACHE_DIR|SIGNATURE_CACHE_CONCURRENCY|PACK_CONCURRENCY|COMPRESSION_LEVEL)$/u
|
||||
const MACOS_SETTING = /^(?:DSH_DESKTOP_MACOS_(?:SIGNING_IDENTITY|TEAM_ID|SIGNATURE_CACHE_DIR|PACK_CONCURRENCY|ZIP_COMPRESSION_LEVEL|DOWNLOAD_PROXY|NOTARIZATION_PROXY)|APPLE_(?:API_KEY|API_KEY_ID|API_ISSUER|ID|APP_SPECIFIC_PASSWORD|TEAM_ID|KEYCHAIN|KEYCHAIN_PROFILE)|CSC_(?:LINK|KEY_PASSWORD))$/u
|
||||
const AMBIENT_RELEASE_SETTING = /^(?:DSH_DESKTOP_(?:APP_ID|AUTO_UPDATE_ENV|MANDATORY_UPDATE_.*|WINDOWS_.*|MACOS_.*)|APPLE_.*|(?:WIN_)?CSC_.*|DOWNLOAD_(?:TEST|PROD)_.*)$/iu
|
||||
const FILE_SETTINGS = ['DSH_DESKTOP_WINDOWS_CER_FILE', 'DSH_DESKTOP_WINDOWS_SIGNTOOL', 'APPLE_API_KEY', 'APPLE_KEYCHAIN', 'CSC_LINK']
|
||||
|
||||
@@ -35,7 +36,10 @@ export function localMacOSPackageEnvironment(environment = process.env) {
|
||||
|
||||
/**
|
||||
* Read a machine preset when selected, otherwise the target's required dotenv; no ambient release fallback.
|
||||
* PACKAGE_MACOS_ZIP_COMPRESSION_LEVEL overrides the Mac file setting with 3 or 7; an empty value is invalid.
|
||||
* Any defined Mac ZIP override rejects Windows, including an empty value; nonempty Windows overrides reject macOS.
|
||||
* PACKAGE_TEST_RELEASE_ID overrides the test preset only; blank retains its configured ID.
|
||||
* PACKAGE_WINDOWS_COMPRESSION_LEVEL selects 3 or 7 for Windows workflow builds; blank retains the file setting.
|
||||
* @param {'win32' | 'darwin'} platform Target platform.
|
||||
* @param {NodeJS.ProcessEnv} environment Parent environment, retained only for unrelated build tools.
|
||||
* @param {string} appRoot Desktop application directory; relative credential paths resolve here.
|
||||
@@ -43,6 +47,17 @@ export function localMacOSPackageEnvironment(environment = process.env) {
|
||||
* @returns {NodeJS.ProcessEnv} Isolated environment with file-owned release settings.
|
||||
*/
|
||||
export function loadDesktopPackageEnvironment(platform, environment = process.env, appRoot = APP_ROOT, options = {}) {
|
||||
const compression = environment.PACKAGE_WINDOWS_COMPRESSION_LEVEL
|
||||
if (compression !== undefined && compression !== '') {
|
||||
if (platform !== 'win32' || !['3', '7'].includes(compression)) {
|
||||
throw new Error('desktop package: PACKAGE_WINDOWS_COMPRESSION_LEVEL requires Windows and a choice of 3 or 7')
|
||||
}
|
||||
}
|
||||
const zipCompression = environment.PACKAGE_MACOS_ZIP_COMPRESSION_LEVEL
|
||||
if (zipCompression !== undefined) {
|
||||
if (platform !== 'darwin') throw new Error('desktop package: PACKAGE_MACOS_ZIP_COMPRESSION_LEVEL requires macOS')
|
||||
if (!['3', '7'].includes(zipCompression)) throw new Error('desktop package: workflow ZIP compression must be 3 or 7')
|
||||
}
|
||||
const preset = environment.DSH_PACKAGING_PRESET
|
||||
let path = join(appRoot, platform === 'win32' ? '.env.windows' : '.env.macos')
|
||||
if (preset !== undefined) {
|
||||
@@ -53,8 +68,8 @@ export function loadDesktopPackageEnvironment(platform, environment = process.en
|
||||
}
|
||||
const platformSetting = platform === 'win32' ? WINDOWS_SETTING : MACOS_SETTING
|
||||
const publicPlatformSetting = platform === 'win32'
|
||||
? /^DSH_DESKTOP_WINDOWS_(?:SIGNATURE_CACHE_(?:DIR|CONCURRENCY)|PACK_CONCURRENCY)$/u
|
||||
: /^DSH_DESKTOP_MACOS_(?:SIGNATURE_CACHE_DIR|PACK_CONCURRENCY|DOWNLOAD_PROXY|NOTARIZATION_PROXY)$/u
|
||||
? /^DSH_DESKTOP_WINDOWS_(?:SIGNATURE_CACHE_(?:DIR|CONCURRENCY)|PACK_CONCURRENCY|COMPRESSION_LEVEL)$/u
|
||||
: /^DSH_DESKTOP_MACOS_(?:SIGNATURE_CACHE_DIR|PACK_CONCURRENCY|ZIP_COMPRESSION_LEVEL|DOWNLOAD_PROXY|NOTARIZATION_PROXY)$/u
|
||||
const settings = readSettings(path, name => preset === undefined
|
||||
? SHARED_SETTING.test(name) || platformSetting.test(name)
|
||||
: /^(?:DSH_DESKTOP_(?:APP_ID|AUTO_UPDATE_ENV|NPM_REGISTRY|MANDATORY_UPDATE_(?:CONFIG|(?:TEST|PROD)_ORIGIN))|DOWNLOAD_TEST_(?:ORIGIN|RELEASE_ID))$/u.test(name) || publicPlatformSetting.test(name))
|
||||
@@ -68,6 +83,10 @@ export function loadDesktopPackageEnvironment(platform, environment = process.en
|
||||
: MACOS_SETTING.test(name) && !publicPlatformSetting.test(name)))
|
||||
}
|
||||
}
|
||||
if (compression !== undefined && compression !== '') settings.DSH_DESKTOP_WINDOWS_COMPRESSION_LEVEL = compression
|
||||
if (zipCompression !== undefined) {
|
||||
settings.DSH_DESKTOP_MACOS_ZIP_COMPRESSION_LEVEL = zipCompression
|
||||
}
|
||||
const releaseId = environment.PACKAGE_TEST_RELEASE_ID?.trim()
|
||||
if (releaseId) {
|
||||
if (preset !== 'test') throw new Error('desktop package: test release ID override requires the test preset')
|
||||
@@ -134,6 +153,7 @@ function requireReadableFile(environment, name) {
|
||||
* @returns {void}
|
||||
*/
|
||||
export function validateDesktopPackageEnvironment(environment, target, options = {}) {
|
||||
resolveDesktopTargetBuildPaths(environment, target.platform, target.arch)
|
||||
resolveDesktopAppId(environment)
|
||||
resolveNpmRegistry(environment)
|
||||
if (target.platform !== 'darwin' || !options.unsigned) resolveDesktopPolicyEnvironment(environment)
|
||||
|
||||
@@ -70,7 +70,7 @@ export function createElectronBuilderConfig(
|
||||
if (resolvedPlatform === 'win32') installWindowsDirectoryInstaller()
|
||||
const macOSSigning = packagesMacOS && !unsigned ? resolveMacOSSigningEnvironment(env) : undefined
|
||||
if (packagesMacOS && !unsigned && !skipNotarization) resolveMacOSNotarizationEnvironment(env)
|
||||
const buildPaths = desktopTargetBuildPaths(resolveDesktopBuildTarget(env, hostPlatform, hostArch))
|
||||
const buildPaths = desktopTargetBuildPaths(resolveDesktopBuildTarget(env, hostPlatform, hostArch), env)
|
||||
const output = desktopArtifactOutput(buildPaths, { unsigned, skipNotarization })
|
||||
let primaryRuntimeDestination
|
||||
let dshDestination
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
/** Validated settings shared by preflight and stage execution. */
|
||||
export interface MacOSPackageSettings {
|
||||
readonly packConcurrency: number
|
||||
readonly zipCompressionLevel: number
|
||||
readonly downloadProxy?: string
|
||||
readonly notarizationProxy?: string
|
||||
}
|
||||
|
||||
@@ -15,14 +15,19 @@ function proxy(environment, name, protocols) {
|
||||
/**
|
||||
* Validate tuning before any build or network operation.
|
||||
* @param {NodeJS.ProcessEnv} environment File-owned packaging settings.
|
||||
* @returns {{packConcurrency: number, downloadProxy?: string, notarizationProxy?: string}} Resolved settings; empty proxy fields preserve inherited networking.
|
||||
* @returns {{packConcurrency: number, zipCompressionLevel: number, downloadProxy?: string, notarizationProxy?: string}} Resolved settings; empty proxy fields preserve inherited networking.
|
||||
*/
|
||||
export function resolveMacOSPackageSettings(environment) {
|
||||
const concurrency = environment.DSH_DESKTOP_MACOS_PACK_CONCURRENCY ?? '4'
|
||||
if (!/^[1-9]\d*$/u.test(concurrency) || !Number.isSafeInteger(Number(concurrency))) {
|
||||
throw new Error('desktop package: DSH_DESKTOP_MACOS_PACK_CONCURRENCY must be a positive integer')
|
||||
}
|
||||
const compression = environment.DSH_DESKTOP_MACOS_ZIP_COMPRESSION_LEVEL ?? '3'
|
||||
if (!/^[0-9]$/u.test(compression)) {
|
||||
throw new Error('desktop package: DSH_DESKTOP_MACOS_ZIP_COMPRESSION_LEVEL must be an integer from 0 to 9')
|
||||
}
|
||||
return {
|
||||
zipCompressionLevel: Number(compression),
|
||||
packConcurrency: Number(concurrency),
|
||||
downloadProxy: proxy(environment, 'DSH_DESKTOP_MACOS_DOWNLOAD_PROXY', ['http:', 'https:']),
|
||||
notarizationProxy: proxy(environment, 'DSH_DESKTOP_MACOS_NOTARIZATION_PROXY', ['http:']),
|
||||
|
||||
@@ -328,7 +328,7 @@ async function resolveRequestedBuildVersion(
|
||||
const requested = invocation.requestedBuildVersion
|
||||
if (requested === undefined) return productVersion
|
||||
if (requested !== AUTOMATIC_BUILD_VERSION) return validateDesktopBuildVersion(requested, productVersion)
|
||||
const paths = desktopTargetBuildPaths(invocation.target.name)
|
||||
const paths = desktopTargetBuildPaths(invocation.target.name, environment)
|
||||
return suggestDesktopBuildVersion({
|
||||
productVersion, target: invocation.target.name,
|
||||
// Machine presets own packaging, not upload credentials; local auto numbering uses existing artifacts.
|
||||
@@ -370,7 +370,7 @@ async function main(): Promise<void> {
|
||||
version: buildVersion, productVersion, node: process.version,
|
||||
commit: packaged.commit,
|
||||
dirty: packaged.dirty,
|
||||
}, { parallel: target.platform === 'darwin', secrets })
|
||||
}, { parallel: true, secrets })
|
||||
console.log(`DESKTOP_PACKAGING_RECORD ${run.directory}`)
|
||||
const previousDirectory = process.env.DSH_DESKTOP_PACKAGING_RUN_DIR
|
||||
process.env.DSH_DESKTOP_PACKAGING_RUN_DIR = run.directory
|
||||
@@ -380,7 +380,7 @@ async function main(): Promise<void> {
|
||||
await packagingStep(run.directory, 'toolchain', () => requireDesktopToolchain(target.platform, environment), secrets)
|
||||
if (target.platform === 'darwin') {
|
||||
const settings = resolveMacOSPackageSettings(environment)
|
||||
recordPackagingEvent(run.directory, { type: 'macos-settings', packConcurrency: settings.packConcurrency,
|
||||
recordPackagingEvent(run.directory, { type: 'macos-settings', packConcurrency: settings.packConcurrency, zipCompressionLevel: settings.zipCompressionLevel,
|
||||
downloadProxyConfigured: settings.downloadProxy !== undefined,
|
||||
notarizationProxyConfigured: settings.notarizationProxy !== undefined })
|
||||
await packagingStep(run.directory, 'macos-package', () => invocation.unsigned
|
||||
@@ -400,7 +400,7 @@ async function main(): Promise<void> {
|
||||
run.finish(success)
|
||||
}
|
||||
if (success && target.platform === 'darwin' && !invocation.directory && !invocation.prepareOnly) {
|
||||
const paths = desktopTargetBuildPaths(target.name)
|
||||
const paths = desktopTargetBuildPaths(target.name, environment)
|
||||
const output = desktopArtifactOutput(paths, invocation)
|
||||
const filename = `deepseek-harness-${buildVersion}-mac-${target.arch}${output.suffix}.dmg`
|
||||
console.log(`DMG: ${join(output.directory, filename)}`)
|
||||
@@ -426,7 +426,7 @@ export async function packageTarget(
|
||||
const mac = target.platform === 'darwin' ? resolveMacOSPackageSettings(environment) : undefined
|
||||
const packConcurrency = mac?.packConcurrency ?? resolveWindowsPackageSettings(environment).packConcurrency
|
||||
const packArguments = ['--concurrency', String(packConcurrency)]
|
||||
const buildPaths = desktopTargetBuildPaths(target.name)
|
||||
const buildPaths = desktopTargetBuildPaths(target.name, environment)
|
||||
const releaseRecordPath = join(buildPaths.artifacts, desktopBuildRecordFilename(target.name))
|
||||
if (!invocation.prepareOnly && !invocation.unsigned && !invocation.skipNotarization) {
|
||||
rmSync(releaseRecordPath, { force: true })
|
||||
@@ -443,6 +443,9 @@ export async function packageTarget(
|
||||
}
|
||||
const downloadEnv = macOSDownloadEnvironment(targetEnv, mac?.downloadProxy)
|
||||
const electronBuilderEnv = desktopElectronBuilderEnvironment(downloadEnv, invocation.unsigned)
|
||||
if (target.platform === 'win32') {
|
||||
electronBuilderEnv.ELECTRON_BUILDER_COMPRESSION_LEVEL = String(resolveWindowsPackageSettings(environment).compressionLevel)
|
||||
}
|
||||
for (const name of WINDOWS_SIGNING_ENV_NAMES) {
|
||||
if (!invocation.unsigned && environment[name] !== undefined) electronBuilderEnv[name] = environment[name]
|
||||
}
|
||||
@@ -476,36 +479,56 @@ export async function packageTarget(
|
||||
})
|
||||
}
|
||||
await execute(invocation.unsigned ? ['run', 'build', '--profile', 'local'] : ['run', 'build:official'], buildEnv, REPOSITORY_ROOT)
|
||||
await execute([
|
||||
...(invocation.unsigned
|
||||
? ['exec', 'tsx', 'apps/desktop/scripts/pack-local-packages.ts']
|
||||
: ['run', 'release:pack', '--family', 'dsh']),
|
||||
'--out', buildPaths.packedDsh, ...packArguments,
|
||||
], buildEnv, REPOSITORY_ROOT)
|
||||
await execute([
|
||||
'--dir',
|
||||
'apps/desktop-host',
|
||||
'pack',
|
||||
'--pack-destination',
|
||||
buildPaths.packedDsh,
|
||||
], buildEnv, REPOSITORY_ROOT)
|
||||
await execute(['run', 'release:pack', '--family', 'vendor', '--out', buildPaths.packedVendor, ...packArguments], buildEnv, REPOSITORY_ROOT)
|
||||
rmSync(buildPaths.packedLandlock, { recursive: true, force: true })
|
||||
mkdirSync(buildPaths.packedLandlock, { recursive: true })
|
||||
await execute(['--dir', 'native/system', 'run', 'build:ts'], buildEnv, REPOSITORY_ROOT)
|
||||
await execute([
|
||||
'--dir',
|
||||
'native/system/packages/entry',
|
||||
'pack',
|
||||
'--pack-destination',
|
||||
buildPaths.packedLandlock,
|
||||
], buildEnv, REPOSITORY_ROOT)
|
||||
await execute(['run', 'prepare:runtime', ...(signPrimaryRuntime ? ['--defer-primary-runtime-smoke'] : [])], downloadEnv)
|
||||
let preparationFailure: { error: unknown } | undefined
|
||||
const executePreparation = async (args: readonly string[], env: NodeJS.ProcessEnv, cwd?: string): Promise<void> => {
|
||||
if (preparationFailure !== undefined) throw preparationFailure.error
|
||||
try { await execute(args, env, cwd) }
|
||||
catch (error) { preparationFailure ??= { error }; throw error }
|
||||
}
|
||||
const pack = async (): Promise<void> => {
|
||||
await executePreparation([
|
||||
...(invocation.unsigned
|
||||
? ['exec', 'tsx', 'apps/desktop/scripts/pack-local-packages.ts']
|
||||
: ['run', 'release:pack', '--family', 'dsh']),
|
||||
'--out', buildPaths.packedDsh, ...packArguments,
|
||||
], buildEnv, REPOSITORY_ROOT)
|
||||
await executePreparation([
|
||||
'--dir',
|
||||
'apps/desktop-host',
|
||||
'pack',
|
||||
'--pack-destination',
|
||||
buildPaths.packedDsh,
|
||||
], buildEnv, REPOSITORY_ROOT)
|
||||
await executePreparation(['run', 'release:pack', '--family', 'vendor', '--out', buildPaths.packedVendor, ...packArguments], buildEnv, REPOSITORY_ROOT)
|
||||
rmSync(buildPaths.packedLandlock, { recursive: true, force: true })
|
||||
mkdirSync(buildPaths.packedLandlock, { recursive: true })
|
||||
await executePreparation(['--dir', 'native/system', 'run', 'build:ts'], buildEnv, REPOSITORY_ROOT)
|
||||
await executePreparation([
|
||||
'--dir',
|
||||
'native/system/packages/entry',
|
||||
'pack',
|
||||
'--pack-destination',
|
||||
buildPaths.packedLandlock,
|
||||
], buildEnv, REPOSITORY_ROOT)
|
||||
}
|
||||
const prepareRuntime = () => executePreparation(
|
||||
['run', 'prepare:runtime', ...(signPrimaryRuntime ? ['--defer-primary-runtime-smoke'] : [])], downloadEnv)
|
||||
if (target.platform === 'win32') {
|
||||
const results = await Promise.allSettled([pack(), prepareRuntime()])
|
||||
for (const result of results) if (result.status === 'rejected') throw result.reason
|
||||
} else {
|
||||
await pack()
|
||||
await prepareRuntime()
|
||||
}
|
||||
if (signPrimaryRuntime) await execute(['run', 'sign:primary-runtime'], electronBuilderEnv)
|
||||
await execute(['run', 'prepare:packages'], targetEnv)
|
||||
await execute(['run', 'prepare:dsh', ...(signPrimaryRuntime ? ['--defer-runtime-smoke'] : [])], downloadEnv)
|
||||
if (signPrimaryRuntime) await execute(['run', 'sign:primary-runtime', '--dsh'], electronBuilderEnv)
|
||||
if (invocation.prepareOnly) return
|
||||
const buildMacArtifact = (artifact: DesktopPrepackagedArtifact) => execute(
|
||||
desktopElectronBuilderArguments(target, false, artifact), artifact.format === 'zip'
|
||||
? { ...electronBuilderEnv, ELECTRON_BUILDER_COMPRESSION_LEVEL: String(mac?.zipCompressionLevel) }
|
||||
: electronBuilderEnv)
|
||||
if (target.platform === 'darwin' && !invocation.unsigned && !invocation.skipNotarization && !invocation.directory) {
|
||||
await execute([
|
||||
...desktopElectronBuilderArguments(target, true),
|
||||
@@ -518,7 +541,7 @@ export async function packageTarget(
|
||||
version: resolveDesktopBuildVersion(environment, packageVersion(join(APP_ROOT, 'package.json'), 'desktop package')),
|
||||
artifactsRoot: buildPaths.artifacts,
|
||||
environment: electronBuilderEnv,
|
||||
}, artifact => execute(desktopElectronBuilderArguments(target, false, artifact), electronBuilderEnv)), undefined, undefined, proxyEvent)
|
||||
}, buildMacArtifact), undefined, undefined, proxyEvent)
|
||||
} else if (target.platform === 'darwin' && !invocation.unsigned && !invocation.skipNotarization) {
|
||||
await execute([...desktopElectronBuilderArguments(target, true), '--config.mac.notarize=false'], electronBuilderEnv)
|
||||
await execute(['exec', 'tsx', 'scripts/smoke-packaged-runtime.ts'], targetEnv)
|
||||
@@ -526,7 +549,15 @@ export async function packageTarget(
|
||||
await withMacOSNotarizationProxy(mac?.notarizationProxy,
|
||||
() => notarizeMacOS({ appPath, ...resolveMacOSNotarizationEnvironment(environment) }), undefined, undefined, proxyEvent)
|
||||
} else {
|
||||
await signedStage('artifacts', () => execute(desktopElectronBuilderArguments(target, invocation.directory), electronBuilderEnv))
|
||||
if (target.platform === 'darwin' && invocation.skipNotarization && !invocation.directory) {
|
||||
await execute(desktopElectronBuilderArguments(target, true), electronBuilderEnv)
|
||||
const appPath = join(artifactsRoot, target.arch === 'arm64' ? 'mac-arm64' : 'mac', 'DeepSeek Harness.app')
|
||||
for (const format of ['dmg', 'zip'] as const) {
|
||||
await buildMacArtifact({ format, appPath, output: artifactsRoot })
|
||||
}
|
||||
} else {
|
||||
await signedStage('artifacts', () => execute(desktopElectronBuilderArguments(target, invocation.directory), electronBuilderEnv))
|
||||
}
|
||||
await execute(['exec', 'tsx', 'scripts/smoke-packaged-runtime.ts',
|
||||
...(invocation.unsigned ? ['--unsigned'] : []), ...(invocation.skipNotarization ? ['--skip-notarization'] : [])], targetEnv)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { resolveMacOSSignatureCacheDirectory } from './macos-signature-cache-directory.mjs'
|
||||
import { packagingStep } from './packaging-step.mjs'
|
||||
import { spawn } from 'node:child_process'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { copyFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { delimiter, join, relative, resolve } from 'node:path'
|
||||
@@ -22,7 +23,7 @@ import { smokePrimaryRuntime } from './prepare-primary-runtime.ts'
|
||||
import { smokePreparedRuntime } from './smoke-prepared-runtime.ts'
|
||||
import { prepareRuntimeManifests } from './prepare-runtime-manifests.ts'
|
||||
import { prepareRuntimePatches, verifyRuntimePatches } from './prepare-runtime-patches.ts'
|
||||
import { writeDesktopRuntime, verifyDesktopRuntime } from '../src/runtime-tree.ts'
|
||||
import { writeDesktopRuntime, writeDesktopRuntimeAsync, verifyDesktopRuntime } from '../src/runtime-tree.ts'
|
||||
import {
|
||||
resolveDesktopAppId,
|
||||
resolveMacOSSigningEnvironment,
|
||||
@@ -39,7 +40,7 @@ const APP_ROOT = resolve(import.meta.dirname, '..')
|
||||
const BUILD_PATHS = resolveDesktopTargetBuildPaths()
|
||||
const DSH_OUTPUT_ROOT = BUILD_PATHS.dsh
|
||||
const BUILD_ROOT = mkdtempSync(join(tmpdir(), 'dsh-desktop-runtime-'))
|
||||
const STORE_ROOT = join(BUILD_ROOT, 'store')
|
||||
const STORE_ROOT = BUILD_PATHS.pnpmStore
|
||||
const RUNTIME_ROOT = BUILD_PATHS.runtime
|
||||
const PNPM_BUILD_STATE = BUILD_PATHS.dshPnpm
|
||||
const PACKAGE_SET_ROOT = BUILD_PATHS.packageSet
|
||||
@@ -82,6 +83,8 @@ function runPnpm(args: readonly string[]): Promise<void> {
|
||||
PNPM,
|
||||
`--config.registry=${registry}`,
|
||||
`--config.store-dir=${STORE_ROOT}`,
|
||||
'--config.verify-store-integrity=true',
|
||||
'--config.package-import-method=copy',
|
||||
'--config.enable-global-virtual-store=false',
|
||||
`--config.userconfig=${userConfig}`,
|
||||
command,
|
||||
@@ -113,6 +116,7 @@ function runPnpm(args: readonly string[]): Promise<void> {
|
||||
|
||||
async function main(): Promise<void> {
|
||||
try {
|
||||
console.log(`desktop runtime: pnpm store ${STORE_ROOT}`)
|
||||
await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'runtime:reset', async () => {
|
||||
rmSync(DSH_OUTPUT_ROOT, { recursive: true, force: true })
|
||||
rmSync(PNPM_BUILD_STATE, { recursive: true, force: true })
|
||||
@@ -127,8 +131,10 @@ async function main(): Promise<void> {
|
||||
})
|
||||
await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'runtime:lockfile', () => runPnpm(['install', '--lockfile-only']))
|
||||
verifyRuntimePatches(BUILD_ROOT, patches)
|
||||
const lockfile = readFileSync(join(BUILD_ROOT, 'pnpm-lock.yaml'), 'utf8')
|
||||
console.log(`desktop runtime: lockfile sha256 ${createHash('sha256').update(lockfile).digest('hex')}`)
|
||||
verifyDesktopCoreLockfile(
|
||||
readFileSync(join(BUILD_ROOT, 'pnpm-lock.yaml'), 'utf8'),
|
||||
lockfile,
|
||||
readDesktopCorePackageSet(BUILD_ROOT, release.version),
|
||||
)
|
||||
await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'runtime:install', () => runPnpm(['install', '--prod', '--frozen-lockfile', '--trust-lockfile']))
|
||||
@@ -164,7 +170,10 @@ async function main(): Promise<void> {
|
||||
}
|
||||
await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'runtime:manifests', () => prepareRuntimeManifests(DSH_OUTPUT_ROOT))
|
||||
await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'runtime:primary-smoke', async () => smokePrimaryRuntime(join(RUNTIME_ROOT, 'primary-runtime')))
|
||||
await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'runtime:write-descriptor', async () => writeDesktopRuntime(DSH_OUTPUT_ROOT, release, packageSet.packages.map(entry => entry.name), target))
|
||||
await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'runtime:write-descriptor', async () => {
|
||||
const writeRuntime = target.platform === 'win32' ? writeDesktopRuntimeAsync : writeDesktopRuntime
|
||||
return writeRuntime(DSH_OUTPUT_ROOT, release, packageSet.packages.map(entry => entry.name), target)
|
||||
})
|
||||
const descriptor = await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'runtime:verify-before-smoke', () => verifyDesktopRuntime(DSH_OUTPUT_ROOT, release.version, target))
|
||||
if (!process.argv.includes('--defer-runtime-smoke')) {
|
||||
await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'runtime:smoke', () => smokePreparedRuntime(DSH_OUTPUT_ROOT, NODE, RUNTIME_ROOT, descriptor))
|
||||
|
||||
@@ -53,9 +53,18 @@ async function main(): Promise<void> {
|
||||
const target = desktopTargetPlatform(resolveDesktopBuildTarget())
|
||||
rmSync(RUNTIME_ROOT, { recursive: true, force: true })
|
||||
mkdirSync(RUNTIME_ROOT, { recursive: true })
|
||||
const nodeVersion = await prepareElectron(target)
|
||||
await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'prepare:primary-runtime',
|
||||
const preparePrimary = () => packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'prepare:primary-runtime',
|
||||
() => preparePrimaryRuntime({ deferSmoke: values['defer-primary-runtime-smoke'] }))
|
||||
let nodeVersion: string
|
||||
if (target.platform === 'win32') {
|
||||
const [electron, primary] = await Promise.allSettled([prepareElectron(target), preparePrimary()])
|
||||
if (electron.status === 'rejected') throw electron.reason
|
||||
if (primary.status === 'rejected') throw primary.reason
|
||||
nodeVersion = electron.value
|
||||
} else {
|
||||
nodeVersion = await prepareElectron(target)
|
||||
await preparePrimary()
|
||||
}
|
||||
const { pnpm } = await readPrimaryRuntime(join(RUNTIME_ROOT, 'primary-runtime'))
|
||||
if (pnpm === undefined) throw new Error('desktop runtime: primary-runtime manifest has no pnpm version')
|
||||
await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'prepare:cli', async () => { prepareCli(target) })
|
||||
|
||||
@@ -5,6 +5,7 @@ import { execFileSync } from 'node:child_process'
|
||||
import { desktopCiVersion as windowsCiVersion } from './desktop-ci-version.mjs'
|
||||
export { desktopCiVersion as windowsCiVersion } from './desktop-ci-version.mjs'
|
||||
import { loadDesktopPackageEnvironment, validateDesktopPackageEnvironment } from './desktop-package-environment.mjs'
|
||||
import { resolveWindowsPackageSettings } from './windows-package-settings.mjs'
|
||||
|
||||
if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.filename) {
|
||||
const appRoot = resolve(import.meta.dirname, '..')
|
||||
@@ -22,6 +23,7 @@ if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.fi
|
||||
}
|
||||
}
|
||||
validateDesktopPackageEnvironment(environment, { platform: 'win32', arch: 'x64' }, { unsigned: mode === 'unsigned' })
|
||||
console.log(`Windows installer compression level: ${resolveWindowsPackageSettings(environment).compressionLevel}`)
|
||||
if (!process.env.GITHUB_OUTPUT || !process.env.GITHUB_STEP_SUMMARY) throw new Error('Run this adapter from GitHub Actions')
|
||||
appendFileSync(process.env.GITHUB_OUTPUT, `version=${version}\n`)
|
||||
appendFileSync(process.env.GITHUB_STEP_SUMMARY, `Windows x64 ${mode} installer\n\nVersion: ${version}\n\nCommit: ${execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim()}\n\nChannel: ${process.env.DSH_DESKTOP_UPDATE_CHANNEL}\n\nSource: ${process.env.PACKAGE_SOURCE_REF}\n\nPreset: ${process.env.DSH_PACKAGING_PRESET}\n`)
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
# Own one operator-configured network process until the registered runner exits.
|
||||
param([Parameter(Mandatory=$true)][string]$RunnerRoot, [Parameter(Mandatory=$true)][string]$ConfigRoot, [Parameter(Mandatory=$true)][string]$NetworkRoot, [Parameter(Mandatory=$true)][string]$ReadinessScript, [Parameter(Mandatory=$true)][string]$StartScript, [switch]$CheckOnly)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$network = $NetworkRoot
|
||||
$lock = $null
|
||||
$process = $null
|
||||
$started = $false
|
||||
$runner = $null
|
||||
$runnerStarted = $false
|
||||
$restarts = 0
|
||||
$recordPath = Join-Path $network 'process.json'
|
||||
$code = 1
|
||||
try {
|
||||
foreach ($directory in @($RunnerRoot,$ConfigRoot,$NetworkRoot)) {
|
||||
if ($directory -notmatch '^[A-Za-z]:[\\/]' -or -not (Test-Path -LiteralPath $directory -PathType Container)) { throw 'An existing absolute local directory is required.' }
|
||||
}
|
||||
foreach ($script in @($ReadinessScript,$StartScript)) {
|
||||
if ($script -notmatch '^[A-Za-z]:[\\/]' -or -not (Test-Path -LiteralPath $script -PathType Leaf)) { throw 'An existing absolute local script is required.' }
|
||||
}
|
||||
$lock = [IO.File]::Open((Join-Path $network 'supervisor.lock'), 'OpenOrCreate', 'ReadWrite', 'None')
|
||||
$launch = Get-Content (Join-Path $network 'launch.json') -Raw | ConvertFrom-Json -AsHashtable
|
||||
if ($launch -isnot [Collections.IDictionary] -or $launch.Count -ne 3 -or @($launch.Keys | Where-Object { $_ -cnotin @('executable','arguments','restartDelaySeconds') }).Count) { throw 'Invalid network launch configuration.' }
|
||||
if (($launch.restartDelaySeconds -isnot [long] -and $launch.restartDelaySeconds -isnot [int]) -or $launch.restartDelaySeconds -lt 1 -or $launch.restartDelaySeconds -gt 3600) { throw 'Invalid network restart interval.' }
|
||||
if ($launch.executable -isnot [string] -or $launch.arguments -isnot [array] -or @($launch.arguments | Where-Object { $_ -isnot [string] -or $_.Contains([char]0) }).Count) { throw 'Invalid network executable or arguments.' }
|
||||
if (-not [IO.Path]::IsPathFullyQualified($launch.executable) -or -not (Test-Path -LiteralPath $launch.executable -PathType Leaf)) { throw 'Network executable unavailable.' }
|
||||
$launch.executable = [IO.Path]::GetFullPath($launch.executable)
|
||||
if (Test-Path -LiteralPath $recordPath) {
|
||||
$record = Get-Content -LiteralPath $recordPath -Raw | ConvertFrom-Json
|
||||
if ($record.pid -isnot [long] -and $record.pid -isnot [int]) { throw 'Invalid network process record.' }
|
||||
if ($record.pid -le 0 -or $record.started -isnot [long] -or $record.started -le 0) { throw 'Invalid network process record.' }
|
||||
$stale = Get-Process -Id $record.pid -ErrorAction SilentlyContinue
|
||||
if ($stale) {
|
||||
try {
|
||||
if (-not $stale.StartTime) { throw 'Owned network process identity unavailable.' }
|
||||
if ($stale.StartTime.ToUniversalTime().Ticks -eq $record.started) {
|
||||
if ($stale.Path -ne $launch.executable) { throw 'Owned network process identity unavailable.' }
|
||||
$stale.Kill($true)
|
||||
if (-not $stale.WaitForExit(10000)) { throw 'Owned network process did not stop.' }
|
||||
}
|
||||
} finally { $stale.Dispose() }
|
||||
}
|
||||
Remove-Item -LiteralPath $recordPath
|
||||
}
|
||||
$info = [Diagnostics.ProcessStartInfo]::new($launch.executable)
|
||||
$info.UseShellExecute = $false
|
||||
$info.CreateNoWindow = $true
|
||||
$info.WorkingDirectory = $network
|
||||
foreach ($name in @('HTTP_PROXY','HTTPS_PROXY','ALL_PROXY','http_proxy','https_proxy','all_proxy')) { $null = $info.Environment.Remove($name) }
|
||||
# Provider output can include private routes; it must never enter runner logs.
|
||||
$info.RedirectStandardOutput = $true
|
||||
$info.RedirectStandardError = $true
|
||||
foreach ($argument in $launch.arguments) { $info.ArgumentList.Add($argument) }
|
||||
$process = [Diagnostics.Process]::new()
|
||||
$process.StartInfo = $info
|
||||
if (-not $process.Start()) { throw 'Network process did not start.' }
|
||||
$started = $true
|
||||
$stdout = $process.StandardOutput.BaseStream.CopyToAsync([IO.Stream]::Null)
|
||||
$stderr = $process.StandardError.BaseStream.CopyToAsync([IO.Stream]::Null)
|
||||
@{pid=$process.Id;started=$process.StartTime.ToUniversalTime().Ticks} | ConvertTo-Json | Set-Content -LiteralPath $recordPath
|
||||
& (Join-Path $PSHOME 'pwsh.exe') -NoProfile -NonInteractive -File $ReadinessScript -RunnerRoot $RunnerRoot -ConfigRoot $ConfigRoot -CheckOnly
|
||||
if ($LASTEXITCODE -ne 0 -or $process.HasExited) { throw 'Network readiness failed; listener was not started.' }
|
||||
if ($CheckOnly) { $code = 0 } else {
|
||||
$runnerInfo = [Diagnostics.ProcessStartInfo]::new((Join-Path $PSHOME 'pwsh.exe'))
|
||||
$runnerInfo.UseShellExecute = $false
|
||||
$runnerInfo.CreateNoWindow = $true
|
||||
$runnerInfo.RedirectStandardOutput = $true
|
||||
$runnerInfo.RedirectStandardError = $true
|
||||
foreach ($argument in @('-NoProfile','-NonInteractive','-File',$StartScript,'-RunnerRoot',$RunnerRoot,'-ConfigRoot',$ConfigRoot)) { $runnerInfo.ArgumentList.Add($argument) }
|
||||
$runner = [Diagnostics.Process]::new()
|
||||
$runner.StartInfo = $runnerInfo
|
||||
if (-not $runner.Start()) { throw 'Runner process did not start.' }
|
||||
$runnerStarted = $true
|
||||
$runnerOut = $runner.StandardOutput.BaseStream.CopyToAsync([Console]::OpenStandardOutput())
|
||||
$runnerErr = $runner.StandardError.BaseStream.CopyToAsync([Console]::OpenStandardError())
|
||||
while (-not $runner.WaitForExit(1000)) {
|
||||
if ($started -and -not $process.HasExited) { continue }
|
||||
if ($started) {
|
||||
Write-Output "Network process exited (code $($process.ExitCode)); runner retained. Inspect local network configuration if recovery repeats."
|
||||
$started = $false
|
||||
Remove-Item -LiteralPath $recordPath
|
||||
$process.Dispose()
|
||||
$process = $null
|
||||
}
|
||||
# The runner's lifetime owns recovery; waiting on it also cancels the retry delay.
|
||||
if ($runner.WaitForExit($launch.restartDelaySeconds * 1000)) { break }
|
||||
$process = [Diagnostics.Process]::new()
|
||||
$process.StartInfo = $info
|
||||
try { $started = $process.Start() }
|
||||
catch {
|
||||
# Launch errors can contain private arguments or paths.
|
||||
$started = $false
|
||||
}
|
||||
if (-not $started) {
|
||||
$process.Dispose()
|
||||
$process = $null
|
||||
Write-Output 'Network process restart failed; runner retained. Inspect the local executable and configuration; recovery will retry.'
|
||||
continue
|
||||
}
|
||||
$stdout = $process.StandardOutput.BaseStream.CopyToAsync([IO.Stream]::Null)
|
||||
$stderr = $process.StandardError.BaseStream.CopyToAsync([IO.Stream]::Null)
|
||||
@{pid=$process.Id;started=$process.StartTime.ToUniversalTime().Ticks} | ConvertTo-Json | Set-Content -LiteralPath $recordPath
|
||||
$restarts++
|
||||
Write-Output "Network process restarted (attempt $restarts); runner retained. Connectivity recovery is handled by the runner."
|
||||
}
|
||||
$code = $runner.ExitCode
|
||||
}
|
||||
} catch {
|
||||
# Do not expose provider paths, arguments, configuration or raw error messages.
|
||||
[Console]::Error.WriteLine('Private network supervision failed; inspect local state. No job was retried.')
|
||||
if ($runnerStarted -and -not $runner.HasExited) {
|
||||
[Console]::Error.WriteLine('Network recovery stopped; the existing runner remains running until it exits. Inspect local supervision state before maintenance.')
|
||||
$runner.WaitForExit()
|
||||
}
|
||||
} finally {
|
||||
if ($runnerStarted) {
|
||||
try {
|
||||
if (-not [Threading.Tasks.Task]::WaitAll([Threading.Tasks.Task[]]@($runnerOut,$runnerErr), 5000)) { throw 'Output drain timed out.' }
|
||||
} catch {
|
||||
$code = 1
|
||||
[Console]::Error.WriteLine('Runner output drain failed or timed out; output may be incomplete.')
|
||||
}
|
||||
}
|
||||
if ($runner) { $runner.Dispose() }
|
||||
try {
|
||||
if ($started) {
|
||||
if (-not $process.HasExited) { $process.Kill($true) }
|
||||
if (-not $process.WaitForExit(10000)) { throw 'Network cleanup did not complete.' }
|
||||
if (Test-Path -LiteralPath $recordPath) { Remove-Item -LiteralPath $recordPath }
|
||||
}
|
||||
} catch {
|
||||
$code = 1
|
||||
[Console]::Error.WriteLine('Owned network cleanup failed; inspect local process state before restarting.')
|
||||
} finally {
|
||||
if ($process) { $process.Dispose() }
|
||||
if ($lock) { $lock.Dispose() }
|
||||
}
|
||||
}
|
||||
exit $code
|
||||
@@ -15,7 +15,13 @@ try {
|
||||
exit 1
|
||||
}
|
||||
Set-Content -LiteralPath $attemptFile -Value ($attempts + 1)
|
||||
& (Join-Path $PSHOME 'pwsh.exe') -NoProfile -NonInteractive -File (Join-Path $PSScriptRoot 'start.ps1') -RunnerRoot $config.RunnerRoot -ConfigRoot $config.ConfigRoot *> $log
|
||||
$startup = Join-Path $PSScriptRoot 'start.ps1'
|
||||
if ($config.NetworkRoot) {
|
||||
if (-not (Test-Path -LiteralPath (Join-Path $config.ConfigRoot 'runner-network.json') -PathType Leaf)) { throw 'Owned network supervision requires runner-network.json.' }
|
||||
& (Join-Path $PSHOME 'pwsh.exe') -NoProfile -NonInteractive -File (Join-Path $PSScriptRoot 'run-windows-runner-network-process.ps1') -RunnerRoot $config.RunnerRoot -ConfigRoot $config.ConfigRoot -NetworkRoot $config.NetworkRoot -ReadinessScript $startup -StartScript $startup *> $log
|
||||
} else {
|
||||
& (Join-Path $PSHOME 'pwsh.exe') -NoProfile -NonInteractive -File $startup -RunnerRoot $config.RunnerRoot -ConfigRoot $config.ConfigRoot *> $log
|
||||
}
|
||||
$code = $LASTEXITCODE
|
||||
Add-Content -LiteralPath $log -Value "Runner launcher exited with code $code."
|
||||
# An unexpected exit is a task failure even when run.cmd hides the listener's nonzero status; the log retains its code.
|
||||
|
||||
@@ -11,7 +11,7 @@ import { withWindowsSigningStage } from './windows-signing-stage.mjs'
|
||||
import { failPackagingRun, recordPackagingEvent } from './packaging-run.mjs'
|
||||
import { resolveDesktopBuildTarget, resolveDesktopTargetBuildPaths } from './desktop-build-paths.mjs'
|
||||
import { smokePrimaryRuntime } from './prepare-primary-runtime.ts'
|
||||
import { verifyDesktopRuntime, writeDesktopRuntime, type DesktopRuntimeDescriptor } from '../src/runtime-tree.ts'
|
||||
import { verifyDesktopRuntime, writeDesktopRuntimeAsync, type DesktopRuntimeDescriptor } from '../src/runtime-tree.ts'
|
||||
import { smokePreparedRuntime } from './smoke-prepared-runtime.ts'
|
||||
|
||||
/**
|
||||
@@ -39,7 +39,7 @@ export async function signWindowsDesktopRuntime(root: string, version: string,
|
||||
options: WindowsCodeSigningOptions & { smoke: (descriptor: DesktopRuntimeDescriptor) => Promise<void> }): Promise<void> {
|
||||
const descriptor = await verifyDesktopRuntime(root, version)
|
||||
await signWindowsCode(root, options)
|
||||
writeDesktopRuntime(root, descriptor.release, descriptor.sharedPackages.map(entry => entry.name))
|
||||
await writeDesktopRuntimeAsync(root, descriptor.release, descriptor.sharedPackages.map(entry => entry.name))
|
||||
const signed = await verifyDesktopRuntime(root, version)
|
||||
await options.smoke(signed)
|
||||
await verifyDesktopRuntime(root, version)
|
||||
|
||||
@@ -16,6 +16,9 @@ if ($drive.DriveType -ne [IO.DriveType]::Fixed) { throw 'ConfigRoot requires a f
|
||||
$env:DSH_PACKAGING_CONFIG_DIR=[IO.Path]::GetFullPath($ConfigRoot)
|
||||
$env:DSH_TELEMETRY_DISABLED='1'
|
||||
$env:POWERSHELL_TELEMETRY_OPTOUT='1'
|
||||
. (Join-Path $PSScriptRoot 'windows-runner-network.ps1')
|
||||
$network = Read-RunnerNetwork $ConfigRoot
|
||||
Set-RunnerNetwork $network
|
||||
foreach($tool in @('pwsh','node','git','tar','python')) {
|
||||
$resolved=Get-Command $tool -CommandType Application -ErrorAction Stop | Select-Object -First 1
|
||||
Write-Output ('Tool: '+$tool+' -> '+$resolved.Source)
|
||||
@@ -31,6 +34,7 @@ $vswhere=Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio/Installer/v
|
||||
$visualStudio=& $vswhere -latest -products '*' -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath
|
||||
if($LASTEXITCODE -ne 0 -or -not $visualStudio){throw 'Visual C++ tools unavailable'}
|
||||
Write-Output 'Visual C++ tools located.'
|
||||
Wait-RunnerNetwork $network
|
||||
if($CheckOnly){Write-Output 'Runner environment check passed.'; exit 0}
|
||||
if(Get-Process -Name Runner.Listener -ErrorAction SilentlyContinue){throw 'A runner listener is already running; inspect before starting another.'}
|
||||
Set-Location $RunnerRoot
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
/** Identity of a Windows packaging run and its independent build root. */
|
||||
export interface WindowsBuildWorkspaceOptions {
|
||||
sourceDirectory: string
|
||||
buildRoot: string
|
||||
runId: string
|
||||
runAttempt: string
|
||||
sourceSha: string
|
||||
}
|
||||
/**
|
||||
* Clone the clean selected commit into a new run-owned directory.
|
||||
* @param options - Original checkout and Actions run identity.
|
||||
* @returns Checkout and temporary directory owned by this run.
|
||||
*/
|
||||
export function prepareWindowsBuildWorkspace(options: WindowsBuildWorkspaceOptions): { directory: string; temporaryDirectory: string }
|
||||
/**
|
||||
* Remove the directory only when its ownership record matches the run.
|
||||
* @param options - Original checkout and Actions run identity.
|
||||
*/
|
||||
export function removeWindowsBuildWorkspace(options: WindowsBuildWorkspaceOptions): void
|
||||
@@ -0,0 +1,96 @@
|
||||
/** Create and remove a run-owned checkout for Windows packaging. */
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { appendFileSync, existsSync, lstatSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { dirname, isAbsolute, join, parse, resolve } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
function assertUnlinkedAncestors(directory) {
|
||||
for (let current = directory; ; current = dirname(current)) {
|
||||
if (existsSync(current) && lstatSync(current).isSymbolicLink()) {
|
||||
throw new Error('Packaging workspace ancestors must not be links')
|
||||
}
|
||||
if (dirname(current) === current) break
|
||||
}
|
||||
}
|
||||
|
||||
function locations(options) {
|
||||
const { buildRoot, runId, runAttempt, sourceSha } = options
|
||||
if (!buildRoot || !isAbsolute(buildRoot) || /[\r\n\0]/.test(buildRoot)) {
|
||||
throw new Error('Packaging build root must be a nonempty absolute directory')
|
||||
}
|
||||
const root = resolve(buildRoot)
|
||||
if (root === parse(root).root) throw new Error('Packaging build root must not be a volume root')
|
||||
if (!/^[1-9]\d*$/.test(runId) || !/^[1-9]\d*$/.test(runAttempt) || !/^[a-f0-9]{40}$/.test(sourceSha)) {
|
||||
throw new Error('Packaging workspace requires a run identity and exact commit')
|
||||
}
|
||||
assertUnlinkedAncestors(root)
|
||||
for (let current = root; ; current = dirname(current)) {
|
||||
if (existsSync(join(current, '.git'))) throw new Error('Packaging build root must be outside existing Git checkouts')
|
||||
if (dirname(current) === current) break
|
||||
}
|
||||
const owner = join(root, `windows-package-${runId}-${runAttempt}`)
|
||||
assertUnlinkedAncestors(owner)
|
||||
return { root, owner, directory: join(owner, 'source'), temporaryDirectory: join(owner, 'temp') }
|
||||
}
|
||||
|
||||
function git(directory, args) {
|
||||
return execFileSync('git', args, { cwd: directory, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim()
|
||||
}
|
||||
|
||||
function ownership(options, paths) {
|
||||
return JSON.stringify({ version: 1, root: paths.root, source: resolve(options.sourceDirectory), runId: options.runId, runAttempt: options.runAttempt, sourceSha: options.sourceSha })
|
||||
}
|
||||
|
||||
/**
|
||||
* Clone the validated commit into a new run-owned checkout; failures retain diagnostics.
|
||||
* @param {{sourceDirectory: string, buildRoot: string, runId: string, runAttempt: string, sourceSha: string}} options - Source and run identity.
|
||||
* @returns {{directory: string, temporaryDirectory: string}} Checkout and temporary directory owned by this run.
|
||||
*/
|
||||
export function prepareWindowsBuildWorkspace(options) {
|
||||
const paths = locations(options)
|
||||
if (git(options.sourceDirectory, ['rev-parse', 'HEAD']) !== options.sourceSha || git(options.sourceDirectory, ['status', '--porcelain'])) {
|
||||
throw new Error('Packaging source must be clean and match the selected commit')
|
||||
}
|
||||
mkdirSync(paths.root, { recursive: true })
|
||||
mkdirSync(paths.owner)
|
||||
writeFileSync(join(paths.owner, 'owner.json'), ownership(options, paths), { flag: 'wx' })
|
||||
mkdirSync(paths.temporaryDirectory)
|
||||
git(paths.owner, ['clone', '--no-hardlinks', '--no-checkout', resolve(options.sourceDirectory), paths.directory])
|
||||
git(paths.directory, ['checkout', '--detach', options.sourceSha])
|
||||
if (git(paths.directory, ['rev-parse', 'HEAD']) !== options.sourceSha || git(paths.directory, ['status', '--porcelain'])) {
|
||||
throw new Error('Isolated packaging checkout does not match the selected commit')
|
||||
}
|
||||
return { directory: paths.directory, temporaryDirectory: paths.temporaryDirectory }
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove only the matching run-owned directory after successful artifact upload.
|
||||
* Transient filesystem failures retry up to three times; persistent failures propagate.
|
||||
* @param {{sourceDirectory: string, buildRoot: string, runId: string, runAttempt: string, sourceSha: string}} options - Original source and run identity.
|
||||
* @returns {void}
|
||||
*/
|
||||
export function removeWindowsBuildWorkspace(options) {
|
||||
const paths = locations(options)
|
||||
const marker = join(paths.owner, 'owner.json')
|
||||
const stat = lstatSync(marker)
|
||||
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 8192 || readFileSync(marker, 'utf8') !== ownership(options, paths)) {
|
||||
throw new Error('Packaging workspace ownership does not match this run')
|
||||
}
|
||||
rmSync(paths.owner, { recursive: true, maxRetries: 3, retryDelay: 100 })
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
|
||||
const options = {
|
||||
sourceDirectory: process.env.GITHUB_WORKSPACE ?? '',
|
||||
buildRoot: process.env.PACKAGE_BUILD_ROOT ?? '',
|
||||
runId: process.env.GITHUB_RUN_ID ?? '',
|
||||
runAttempt: process.env.GITHUB_RUN_ATTEMPT ?? '',
|
||||
sourceSha: process.env.PACKAGE_SOURCE_SHA ?? '',
|
||||
}
|
||||
if (process.argv[2] === '--remove') removeWindowsBuildWorkspace(options)
|
||||
else {
|
||||
if (process.argv.length !== 2 || !process.env.GITHUB_OUTPUT) throw new Error('Expected Actions output file and no arguments')
|
||||
const result = prepareWindowsBuildWorkspace(options)
|
||||
appendFileSync(process.env.GITHUB_OUTPUT, `directory=${result.directory}\ntemporaryDirectory=${result.temporaryDirectory}\n`)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* Validate file-owned Windows packaging and cache worker limits before packaging starts.
|
||||
* @param environment Windows packaging settings loaded from .env.windows.
|
||||
* @returns Cache workers from one to eight and a positive integer pack limit; both default to four. Invalid values throw.
|
||||
* Validate worker limits and compression before packaging starts.
|
||||
* File-owned compression takes precedence over the existing electron-builder environment override.
|
||||
* @param environment Loaded packaging settings and inherited build environment.
|
||||
* @returns Cache workers from one to eight and a positive integer pack limit, both defaulting to four; archive compression from zero to nine, defaulting to three. Invalid values throw.
|
||||
*/
|
||||
export function resolveWindowsPackageSettings(environment: NodeJS.ProcessEnv): { signatureCacheConcurrency: number, packConcurrency: number }
|
||||
export function resolveWindowsPackageSettings(environment: NodeJS.ProcessEnv): { signatureCacheConcurrency: number, packConcurrency: number, compressionLevel: number }
|
||||
@@ -1,9 +1,10 @@
|
||||
/** Resolve file-owned Windows packaging and signature-cache concurrency. */
|
||||
/** Resolve Windows packaging concurrency and archive compression. */
|
||||
|
||||
/**
|
||||
* Validate packaging and cache worker limits without accessing signing hardware.
|
||||
* @param {NodeJS.ProcessEnv} environment File-owned Windows packaging settings.
|
||||
* @returns {{signatureCacheConcurrency: number, packConcurrency: number}} Worker limits, each defaulting to four when omitted.
|
||||
* Validate worker limits and compression without accessing signing hardware.
|
||||
* File-owned compression takes precedence over the existing electron-builder environment override.
|
||||
* @param {NodeJS.ProcessEnv} environment Loaded packaging settings and inherited build environment.
|
||||
* @returns {{signatureCacheConcurrency: number, packConcurrency: number, compressionLevel: number}} Worker limits defaulting to four and archive compression defaulting to three.
|
||||
*/
|
||||
export function resolveWindowsPackageSettings(environment) {
|
||||
const value = environment.DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY ?? '4'
|
||||
@@ -14,5 +15,9 @@ export function resolveWindowsPackageSettings(environment) {
|
||||
if (!/^[1-9]\d*$/u.test(pack) || !Number.isSafeInteger(Number(pack))) {
|
||||
throw new Error('desktop package: DSH_DESKTOP_WINDOWS_PACK_CONCURRENCY must be a positive integer')
|
||||
}
|
||||
return { signatureCacheConcurrency: Number(value), packConcurrency: Number(pack) }
|
||||
const compression = environment.DSH_DESKTOP_WINDOWS_COMPRESSION_LEVEL ?? environment.ELECTRON_BUILDER_COMPRESSION_LEVEL ?? '3'
|
||||
if (!/^[0-9]$/u.test(compression)) {
|
||||
throw new Error('desktop package: DSH_DESKTOP_WINDOWS_COMPRESSION_LEVEL must be an integer from 0 to 9')
|
||||
}
|
||||
return { signatureCacheConcurrency: Number(value), packConcurrency: Number(pack), compressionLevel: Number(compression) }
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
# Normalize only this workflow shell and the children it subsequently launches.
|
||||
[CmdletBinding()]
|
||||
param([switch]$InspectOnly, [switch]$BestEffort)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
if (-not $IsWindows) { throw 'Packaging process priority requires Windows.' }
|
||||
|
||||
if (-not ('DshPackagingPriority' -as [type])) {
|
||||
Add-Type -TypeDefinition @'
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
public static class DshPackagingPriority {
|
||||
[DllImport("kernel32.dll")] static extern IntPtr GetCurrentProcess();
|
||||
[DllImport("ntdll.dll")] static extern int NtSetInformationProcess(IntPtr process, int kind, ref int value, int size);
|
||||
[DllImport("ntdll.dll")] static extern int NtQueryInformationProcess(IntPtr process, int kind, out int value, int size, out int length);
|
||||
public static int Read(int kind) {
|
||||
int value, length;
|
||||
int status = NtQueryInformationProcess(GetCurrentProcess(), kind, out value, 4, out length);
|
||||
if (status != 0 || length != 4) throw new InvalidOperationException("Cannot read packaging priority: " + status);
|
||||
return value;
|
||||
}
|
||||
public static void Set(int kind, int value) {
|
||||
int status = NtSetInformationProcess(GetCurrentProcess(), kind, ref value, 4);
|
||||
if (status != 0) throw new InvalidOperationException("Cannot set packaging priority: " + status);
|
||||
}
|
||||
}
|
||||
'@
|
||||
}
|
||||
|
||||
$process = [Diagnostics.Process]::GetCurrentProcess()
|
||||
try {
|
||||
$before = [ordered]@{
|
||||
cpu = $process.PriorityClass.ToString()
|
||||
io = [DshPackagingPriority]::Read(33)
|
||||
memory = [DshPackagingPriority]::Read(39)
|
||||
}
|
||||
if (-not $InspectOnly) {
|
||||
$process.PriorityClass = [Diagnostics.ProcessPriorityClass]::Normal
|
||||
# PROCESSINFOCLASS: ProcessIoPriority (33), ProcessPagePriority (39).
|
||||
[DshPackagingPriority]::Set(33, 2)
|
||||
[DshPackagingPriority]::Set(39, 5)
|
||||
}
|
||||
$process.Refresh()
|
||||
$after = [ordered]@{
|
||||
cpu = $process.PriorityClass.ToString()
|
||||
io = [DshPackagingPriority]::Read(33)
|
||||
memory = [DshPackagingPriority]::Read(39)
|
||||
}
|
||||
[ordered]@{ event = 'packaging-process-priority'; before = $before; after = $after } | ConvertTo-Json -Compress
|
||||
if (-not $InspectOnly -and ($after.cpu -ne 'Normal' -or $after.io -ne 2 -or $after.memory -ne 5)) {
|
||||
throw 'Packaging process did not reach normal CPU, I/O and memory priority.'
|
||||
}
|
||||
} catch {
|
||||
if (-not $BestEffort) { throw }
|
||||
Write-Warning "Packaging priority adjustment unavailable; performance comparison is invalid: $($_.Exception.Message)"
|
||||
[ordered]@{ event = 'packaging-process-priority'; comparable = $false } | ConvertTo-Json -Compress
|
||||
} finally {
|
||||
$process.Dispose()
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
# Load operator-owned network settings without evaluating code or logging their values.
|
||||
function Read-RunnerNetwork([string]$ConfigRoot) {
|
||||
$path = Join-Path $ConfigRoot 'runner-network.json'
|
||||
if (-not (Test-Path -LiteralPath $path)) { return $null }
|
||||
try { $config = Get-Content -LiteralPath $path -Raw | ConvertFrom-Json -AsHashtable -ErrorAction Stop }
|
||||
catch { throw 'Cannot read runner-network.json; inspect its JSON locally.' }
|
||||
$keys = @('proxyUrl','noProxy','serviceName','probeUrl','attempts','timeoutSeconds','retrySeconds')
|
||||
if ($config -isnot [Collections.IDictionary] -or $config.Count -ne $keys.Count -or @($config.Keys | Where-Object { $_ -cnotin $keys }).Count) {
|
||||
throw 'runner-network.json requires exactly proxyUrl, noProxy, serviceName, probeUrl, attempts, timeoutSeconds and retrySeconds.'
|
||||
}
|
||||
foreach ($key in @('proxyUrl','noProxy','serviceName','probeUrl')) {
|
||||
if ($config[$key] -isnot [string] -or $config[$key] -match '[\r\n\x00]') { throw 'Invalid network string setting; inspect runner-network.json locally.' }
|
||||
}
|
||||
if ([Management.Automation.WildcardPattern]::ContainsWildcardCharacters($config.serviceName)) { throw 'Network service must be an exact name.' }
|
||||
foreach ($key in @('attempts','timeoutSeconds','retrySeconds')) {
|
||||
if ($config[$key] -isnot [long] -and $config[$key] -isnot [int]) { throw 'Network budgets must be integers.' }
|
||||
if ($config[$key] -lt 1 -or $config[$key] -gt 60) { throw 'Network budgets must be between 1 and 60.' }
|
||||
}
|
||||
foreach ($key in @('proxyUrl','probeUrl')) {
|
||||
if ($key -eq 'proxyUrl' -and $config[$key] -eq '') { continue }
|
||||
$uri = $null
|
||||
if (-not [Uri]::TryCreate($config[$key], [UriKind]::Absolute, [ref]$uri) -or $uri.Scheme -notin @('http','https') -or $uri.UserInfo -or $uri.Query -or $uri.Fragment) {
|
||||
throw 'Network URLs must be absolute HTTP(S) URLs without credentials, queries or fragments.'
|
||||
}
|
||||
if ($key -eq 'proxyUrl' -and $uri.AbsolutePath -ne '/') { throw 'Proxy URL must not contain a path.' }
|
||||
}
|
||||
return $config
|
||||
}
|
||||
|
||||
# Called after the runner environment is scrubbed; only declared network values reach child jobs.
|
||||
function Set-RunnerNetwork($Config) {
|
||||
if ($null -eq $Config) { return }
|
||||
[Environment]::SetEnvironmentVariable('HTTP_PROXY', $Config.proxyUrl, 'Process')
|
||||
[Environment]::SetEnvironmentVariable('HTTPS_PROXY', $Config.proxyUrl, 'Process')
|
||||
[Environment]::SetEnvironmentVariable('NO_PROXY', $Config.noProxy, 'Process')
|
||||
[Environment]::SetEnvironmentVariable('NODE_USE_ENV_PROXY', '1', 'Process')
|
||||
}
|
||||
|
||||
# Only probe retries are allowed here. Service recovery belongs to its installed Windows service.
|
||||
function Wait-RunnerNetwork($Config) {
|
||||
if ($null -eq $Config) { return }
|
||||
$info = [Diagnostics.ProcessStartInfo]::new((Get-Command node -CommandType Application -ErrorAction Stop | Select-Object -First 1).Source)
|
||||
$info.UseShellExecute = $false
|
||||
$info.CreateNoWindow = $true
|
||||
$info.RedirectStandardOutput = $true
|
||||
$info.RedirectStandardError = $true
|
||||
$info.Environment['DSH_NETWORK_PROBE_URL'] = $Config.probeUrl
|
||||
$info.Environment['DSH_NETWORK_PROBE_TIMEOUT'] = [string]($Config.timeoutSeconds * 1000)
|
||||
$info.ArgumentList.Add('--input-type=module')
|
||||
$info.ArgumentList.Add('-e')
|
||||
$info.ArgumentList.Add('try { const r = await fetch(process.env.DSH_NETWORK_PROBE_URL, { method: "HEAD", redirect: "error", signal: AbortSignal.timeout(Number(process.env.DSH_NETWORK_PROBE_TIMEOUT)) }); await r.body?.cancel(); process.exit(r.ok ? 0 : 1); } catch { process.exit(1); }')
|
||||
for ($attempt = 1; $attempt -le $Config.attempts; $attempt++) {
|
||||
$ready = $true
|
||||
if ($Config.serviceName) {
|
||||
try { $service = Get-Service -Name $Config.serviceName -ErrorAction Stop }
|
||||
catch { throw 'Configured network service is unavailable; inspect its installation locally.' }
|
||||
$ready = $service.Status -eq 'Running'
|
||||
}
|
||||
if ($ready) {
|
||||
$process = [Diagnostics.Process]::new()
|
||||
$process.StartInfo = $info
|
||||
try {
|
||||
if (-not $process.Start()) { throw 'Cannot launch network probe.' }
|
||||
$out = $process.StandardOutput.ReadToEndAsync()
|
||||
$err = $process.StandardError.ReadToEndAsync()
|
||||
if (-not $process.WaitForExit(($Config.timeoutSeconds + 5) * 1000)) {
|
||||
$process.Kill($true)
|
||||
$process.WaitForExit()
|
||||
$ready = $false
|
||||
} else { $ready = $process.ExitCode -eq 0 }
|
||||
$null = $out.GetAwaiter().GetResult()
|
||||
$null = $err.GetAwaiter().GetResult()
|
||||
} finally { $process.Dispose() }
|
||||
}
|
||||
if ($ready) { Write-Output 'Runner network dependency ready (Node HEAD probe).'; return }
|
||||
Write-Output "Runner network dependency not ready (probe $attempt/$($Config.attempts))."
|
||||
if ($attempt -lt $Config.attempts) { Start-Sleep -Seconds $Config.retrySeconds }
|
||||
}
|
||||
throw 'Runner network dependency unavailable; runner was not started. Inspect local service and network configuration. No job or signing retry was requested.'
|
||||
}
|
||||
@@ -4,13 +4,14 @@ param(
|
||||
[Parameter(Mandatory=$true)][ValidateSet('install','start','stop','status','uninstall')][string]$Action,
|
||||
[Parameter(Mandatory=$true)][string]$RunnerRoot,
|
||||
[string]$ConfigRoot,
|
||||
[string]$NetworkRoot,
|
||||
[ValidateRange(1,1440)][int]$RestartMinutes = 1,
|
||||
[ValidateRange(1,999)][int]$RestartCount = 10
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
if (-not $IsWindows) { throw 'This command requires PowerShell 7 on Windows.' }
|
||||
if ($Action -ne 'install' -and @('ConfigRoot','RestartMinutes','RestartCount').Where({ $PSBoundParameters.ContainsKey($_) }).Count) {
|
||||
throw 'ConfigRoot, RestartMinutes and RestartCount apply only to install; reinstall to change them.'
|
||||
if ($Action -ne 'install' -and @('ConfigRoot','NetworkRoot','RestartMinutes','RestartCount').Where({ $PSBoundParameters.ContainsKey($_) }).Count) {
|
||||
throw 'ConfigRoot, NetworkRoot, RestartMinutes and RestartCount apply only to install; reinstall to change them.'
|
||||
}
|
||||
if ($RunnerRoot -notmatch '^[A-Za-z]:[\\/]') { throw 'RunnerRoot must be an absolute local directory.' }
|
||||
$RunnerRoot = [IO.Path]::GetFullPath($RunnerRoot).TrimEnd('\','/')
|
||||
@@ -30,7 +31,15 @@ if ($Action -eq 'install') {
|
||||
if (-not $ConfigRoot -or $ConfigRoot -notmatch '^[A-Za-z]:[\\/]') { throw 'Install requires an absolute ConfigRoot.' }
|
||||
$ConfigRoot = [IO.Path]::GetFullPath($ConfigRoot)
|
||||
$pwsh = Join-Path $PSHOME 'pwsh.exe'
|
||||
& $pwsh -NoProfile -File (Join-Path $PSScriptRoot 'start-windows-packaging-runner.ps1') -RunnerRoot $RunnerRoot -ConfigRoot $ConfigRoot -CheckOnly
|
||||
$startup = Join-Path $PSScriptRoot 'start-windows-packaging-runner.ps1'
|
||||
if ($PSBoundParameters.ContainsKey('NetworkRoot')) {
|
||||
if ($NetworkRoot -notmatch '^[A-Za-z]:[\\/]') { throw 'NetworkRoot must identify an existing absolute local directory.' }
|
||||
if (-not (Test-Path -LiteralPath (Join-Path $ConfigRoot 'runner-network.json') -PathType Leaf)) { throw 'Owned network supervision requires runner-network.json.' }
|
||||
$NetworkRoot = [IO.Path]::GetFullPath($NetworkRoot)
|
||||
& $pwsh -NoProfile -File (Join-Path $PSScriptRoot 'run-windows-runner-network-process.ps1') -RunnerRoot $RunnerRoot -ConfigRoot $ConfigRoot -NetworkRoot $NetworkRoot -ReadinessScript $startup -StartScript $startup -CheckOnly
|
||||
} else {
|
||||
& $pwsh -NoProfile -File $startup -RunnerRoot $RunnerRoot -ConfigRoot $ConfigRoot -CheckOnly
|
||||
}
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Runner preflight failed; no task was installed.' }
|
||||
if (Test-Path -LiteralPath $serviceDir) {
|
||||
if ((Get-Item -LiteralPath $serviceDir).Attributes -band [IO.FileAttributes]::ReparsePoint) { throw 'Task directory cannot be a link.' }
|
||||
@@ -44,7 +53,10 @@ if ($Action -eq 'install') {
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Runner launcher compilation failed; verify .NET Framework 4.5+ is installed. No task was installed.' }
|
||||
Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'start-windows-packaging-runner.ps1') -Destination (Join-Path $serviceDir 'start.ps1')
|
||||
Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'run-windows-runner-task.ps1') -Destination (Join-Path $serviceDir 'run.ps1')
|
||||
@{ RunnerRoot = $RunnerRoot; ConfigRoot = $ConfigRoot; TaskName = $taskName; RestartCount = $RestartCount } | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $serviceDir 'config.json') -Encoding utf8
|
||||
foreach ($helper in @('windows-runner-network.ps1','run-windows-runner-network-process.ps1')) {
|
||||
Copy-Item -LiteralPath (Join-Path $PSScriptRoot $helper) -Destination (Join-Path $serviceDir $helper)
|
||||
}
|
||||
@{ RunnerRoot = $RunnerRoot; ConfigRoot = $ConfigRoot; NetworkRoot = $NetworkRoot; TaskName = $taskName; RestartCount = $RestartCount } | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $serviceDir 'config.json') -Encoding utf8
|
||||
Set-Content -LiteralPath (Join-Path $serviceDir 'attempts.txt') -Value '0'
|
||||
$execution = New-ScheduledTaskAction -Execute $launcher -Argument ('"' + $pwsh + '"') -WorkingDirectory $RunnerRoot
|
||||
$principal = New-ScheduledTaskPrincipal -UserId $sid -LogonType Interactive -RunLevel Limited
|
||||
@@ -52,7 +64,7 @@ if ($Action -eq 'install') {
|
||||
(New-ScheduledTaskTrigger -AtLogOn -User $sid),
|
||||
(New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes($RestartMinutes) -RepetitionInterval (New-TimeSpan -Minutes $RestartMinutes))
|
||||
)
|
||||
$settings = New-ScheduledTaskSettingsSet -MultipleInstances IgnoreNew -ExecutionTimeLimit ([TimeSpan]::Zero) -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries
|
||||
$settings = New-ScheduledTaskSettingsSet -Priority 4 -MultipleInstances IgnoreNew -ExecutionTimeLimit ([TimeSpan]::Zero) -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries
|
||||
$settings.Enabled = $false
|
||||
$null = Register-ScheduledTask -TaskName $taskName -TaskPath '\' -Action $execution -Principal $principal -Trigger $trigger -Settings $settings -Description "DSH packaging runner: $RunnerRoot; owner: $sid"
|
||||
Write-Output "Installed disabled task $taskName. Run start while logged in; reboot requires desktop login. Logs: $serviceDir"
|
||||
|
||||
@@ -41,7 +41,7 @@ export interface WindowsCodeSigningOptions {
|
||||
/**
|
||||
* Enumerate PE files by content without following links.
|
||||
* @param root Materialized directory to scan.
|
||||
* @returns Sorted PE paths; rejects malformed Windows executables and links.
|
||||
* @returns Sorted PE paths; eight bounded readers reject links or malformed executables and drain open handles before failure.
|
||||
*/
|
||||
export function windowsRuntimeCode(root: string): Promise<string[]>
|
||||
|
||||
@@ -49,7 +49,7 @@ export function windowsRuntimeCode(root: string): Promise<string[]>
|
||||
* Preserve valid signatures and sign unsigned PE files with a supervised signer.
|
||||
* @param root Materialized directory to sign.
|
||||
* @param options Signer, certificate identity and audit sink.
|
||||
* @returns Resolves after verified parallel cache restores and sequential signing; stops dispatch and drains active restores on failure, without retries.
|
||||
* @returns Resolves after parallel cache restores, batched target verification and sequential signing; failures drain active restores or inspections without retries.
|
||||
*/
|
||||
export function signWindowsCode(root: string, options: WindowsCodeSigningOptions): Promise<void>
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { promisify } from 'node:util'
|
||||
import { scrubWindowsSigningEnvironment } from './windows-sign.mjs'
|
||||
import { recordPackagingEvent } from './packaging-run.mjs'
|
||||
import { inspectSignaturesBatched } from './windows-signature-batch.mjs'
|
||||
import { packagingStep } from './packaging-step.mjs'
|
||||
|
||||
/**
|
||||
* Read Windows trust, timestamp and signer identity using the engine's bundled modules, without accessing the private key.
|
||||
@@ -52,17 +53,32 @@ export async function preserveWindowsRuntimeSignature(path, options) {
|
||||
/**
|
||||
* Enumerate Windows code without following links or treating foreign .node files as PE binaries.
|
||||
* @param {string} root - Owned, materialized runtime directory.
|
||||
* @returns {Promise<string[]>} Sorted real PE files; rejects links and malformed Windows executable files.
|
||||
* @returns {Promise<string[]>} Sorted real PE files; eight bounded readers reject links or malformed executables and drain open handles before failure.
|
||||
*/
|
||||
export async function windowsRuntimeCode(root) {
|
||||
const rootStat = await lstat(root)
|
||||
if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) throw new Error('Windows code: expected a real directory')
|
||||
return packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'windows:enumerate-code', async () => {
|
||||
const candidates = []
|
||||
async function collect(directory) {
|
||||
const rootStat = await lstat(directory)
|
||||
if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) throw new Error('Windows code: expected a real directory')
|
||||
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
||||
const path = join(directory, entry.name)
|
||||
if (entry.isSymbolicLink()) throw new Error(`Windows code: directory links are not signable: ${path}`)
|
||||
if (entry.isDirectory()) await collect(path)
|
||||
else if (entry.isFile()) candidates.push(path)
|
||||
}
|
||||
}
|
||||
await collect(root)
|
||||
return inspectPortableExecutables(candidates)
|
||||
})
|
||||
}
|
||||
|
||||
/** Close every active file before returning a sorted inventory or the first inspection failure. */
|
||||
async function inspectPortableExecutables(candidates) {
|
||||
const files = []
|
||||
for (const entry of await readdir(root, { withFileTypes: true })) {
|
||||
const path = join(root, entry.name)
|
||||
if (entry.isSymbolicLink()) throw new Error(`Windows code: directory links are not signable: ${path}`)
|
||||
if (entry.isDirectory()) { files.push(...await windowsRuntimeCode(path)); continue }
|
||||
if (!entry.isFile()) continue
|
||||
let next = 0
|
||||
let failure
|
||||
async function inspect(path) {
|
||||
const file = await open(path, 'r')
|
||||
let portableExecutable = false
|
||||
let windowsCandidate = false
|
||||
@@ -80,11 +96,26 @@ export async function windowsRuntimeCode(root) {
|
||||
if (portableExecutable) files.push(path)
|
||||
else if (windowsCandidate || ['.exe', '.dll', '.pyd'].includes(extname(path).toLowerCase())) throw new Error(`Windows code: invalid PE file: ${path}`)
|
||||
}
|
||||
async function worker() {
|
||||
while (failure === undefined && next < candidates.length) {
|
||||
const path = candidates[next++]
|
||||
try { await inspect(path) }
|
||||
catch (error) { failure ??= { error } }
|
||||
}
|
||||
}
|
||||
await Promise.all(Array.from({ length: Math.min(8, candidates.length) }, worker))
|
||||
if (failure !== undefined) throw failure.error
|
||||
return files.sort()
|
||||
}
|
||||
|
||||
/** Drain each public-key verification batch before signing or reporting a failure. */
|
||||
async function inspectWindowsCode(files, inspect) {
|
||||
return packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'windows:inspect-signatures',
|
||||
() => inspectWindowsCodeBatch(files, inspect))
|
||||
}
|
||||
|
||||
/** Complete bounded inspections without allowing callers to proceed past an active failed batch. */
|
||||
async function inspectWindowsCodeBatch(files, inspect) {
|
||||
if (inspect === inspectWindowsRuntimeSignature) return inspectSignaturesBatched(files)
|
||||
const signatures = []
|
||||
for (let offset = 0; offset < files.length; offset += 4) {
|
||||
@@ -101,7 +132,7 @@ async function inspectWindowsCode(files, inspect) {
|
||||
* Preserve valid signatures and sign unsigned PE files with a supervised signer.
|
||||
* @param {string} root - Owned final runtime directory.
|
||||
* @param {import('./windows-runtime-signature.mjs').WindowsCodeSigningOptions} options - Supervised signer, certificate identity, audit sink.
|
||||
* @returns {Promise<void>} Resolves after bounded cache restores and sequential hardware signing, each with verification; failures drain active restores before rejecting.
|
||||
* @returns {Promise<void>} Resolves after bounded cache restores, batched target verification and sequential hardware signing; failures drain active work before rejecting.
|
||||
*/
|
||||
export async function signWindowsCode(root, options) {
|
||||
const inspect = options.inspect ?? inspectWindowsRuntimeSignature
|
||||
@@ -116,12 +147,15 @@ export async function signWindowsCode(root, options) {
|
||||
else if (signature.status !== 'Valid') throw new Error(`Windows code: refusing ${signature.status} signature: ${path}`)
|
||||
}
|
||||
options.record({ type: 'windows-code-signing-plan', files: files.length, unsigned: unsigned.length })
|
||||
async function verifySigned(path) {
|
||||
const signature = await inspect(path)
|
||||
if (signature.status !== 'Valid' || !signature.timestamped || signature.thumbprint?.toUpperCase() !== options.thumbprint.toUpperCase()) {
|
||||
throw new Error(`Windows code: signing verification failed: ${path}`)
|
||||
async function verifySigned(paths) {
|
||||
const verified = await inspectWindowsCode(paths, inspect)
|
||||
for (const [index, path] of paths.entries()) {
|
||||
const signature = verified[index]
|
||||
if (signature.status !== 'Valid' || !signature.timestamped || signature.thumbprint?.toUpperCase() !== options.thumbprint.toUpperCase()) {
|
||||
throw new Error(`Windows code: signing verification failed: ${path}`)
|
||||
}
|
||||
options.record({ type: 'windows-code-signature-verified', path, ...signature })
|
||||
}
|
||||
options.record({ type: 'windows-code-signature-verified', path, ...signature })
|
||||
}
|
||||
const restored = new Set()
|
||||
if (options.cache) {
|
||||
@@ -132,7 +166,6 @@ export async function signWindowsCode(root, options) {
|
||||
const path = unsigned[next++]
|
||||
try {
|
||||
if (await options.cache.restore({ path, hash: 'sha256', isNest: false })) {
|
||||
await verifySigned(path)
|
||||
restored.add(path)
|
||||
}
|
||||
} catch (error) { failure ??= { error } }
|
||||
@@ -140,11 +173,12 @@ export async function signWindowsCode(root, options) {
|
||||
}
|
||||
await Promise.all(Array.from({ length: Math.min(options.cache.concurrency, unsigned.length) }, worker))
|
||||
if (failure !== undefined) throw failure.error
|
||||
await verifySigned(unsigned.filter(path => restored.has(path)))
|
||||
}
|
||||
for (const path of unsigned) {
|
||||
if (restored.has(path)) continue
|
||||
await options.sign({ path, hash: 'sha256', isNest: false })
|
||||
await verifySigned(path)
|
||||
await verifySigned([path])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -135,7 +135,9 @@ export function createRedactedWindowsSigningError(error, path, secrets) {
|
||||
const stderr = record !== undefined && 'stderr' in record
|
||||
? redactedSigningOutput(record.stderr, secrets).trim()
|
||||
: ''
|
||||
return new Error(`Windows release signing failed for ${path}${code}${stderr === '' ? '' : `: ${stderr}`}`)
|
||||
return new Error(`Windows release signing failed for ${path}${code}${stderr === '' ? '' : `: ${stderr}`}. `
|
||||
+ 'Signing stopped. After replacing the UKey, check the certificate, container, SafeNet provider and user PIN. '
|
||||
+ 'This error does not establish device lock status or whether PIN retries were consumed; inspect the retained interlock before operator recovery.')
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,6 +8,7 @@ import { promisify } from 'node:util'
|
||||
import { createWindowsTokenSigner, scrubWindowsSigningEnvironment } from './windows-sign.mjs'
|
||||
import { inspectWindowsRuntimeSignature, type WindowsRuntimeSignature } from './windows-runtime-signature.mjs'
|
||||
import { failPackagingRun, recordPackagingEvent } from './packaging-run.mjs'
|
||||
import { inspectWindowsTokenIdentity, windowsTokenIdentityFailure } from './windows-token-identity.ts'
|
||||
|
||||
const PROBE_SOURCE = 'internal static class SigningProbe { private static int Main() { return 0; } }\n'
|
||||
|
||||
@@ -18,6 +19,7 @@ interface SigningPreflightOptions {
|
||||
compile?: (compiler: string, source: string, output: string, environment: NodeJS.ProcessEnv) => Promise<void>
|
||||
sign?: ReturnType<typeof createWindowsTokenSigner>
|
||||
inspect?: (path: string) => Promise<WindowsRuntimeSignature>
|
||||
inspectIdentity?: typeof inspectWindowsTokenIdentity
|
||||
}
|
||||
|
||||
async function compileProbe(compiler: string, source: string, output: string, environment: NodeJS.ProcessEnv): Promise<void> {
|
||||
@@ -28,7 +30,7 @@ async function compileProbe(compiler: string, source: string, output: string, en
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign one newly compiled, never-executed probe and verify its timestamp and configured certificate.
|
||||
* Match public container keys before signing one never-executed probe and verifying its certificate and timestamp.
|
||||
* @param options Supervised evidence directory, file-owned settings and isolated test adapters.
|
||||
* @returns Resolves after one signature and verification; failures retain evidence; timestamp attempts never repeat hardware signing.
|
||||
*/
|
||||
@@ -71,6 +73,12 @@ export async function preflightWindowsSigning(options: SigningPreflightOptions):
|
||||
compilerSha256: createHash('sha256').update(await readFile(compiler)).digest('hex'),
|
||||
signToolSha256: createHash('sha256').update(await readFile(environment.DSH_DESKTOP_WINDOWS_SIGNTOOL!)).digest('hex'),
|
||||
certificateSha256: createHash('sha256').update(certificate).digest('hex') })
|
||||
const identity = await (options.inspectIdentity ?? inspectWindowsTokenIdentity)({
|
||||
certificate, container: environment.DSH_DESKTOP_WINDOWS_KEY_CONTAINER!.trim(),
|
||||
environment: scrubWindowsSigningEnvironment(environment),
|
||||
})
|
||||
record({ type: 'signing-preflight-public-identity', result: identity, pinSupplied: false })
|
||||
if (identity !== 'matched') throw new Error(windowsTokenIdentityFailure(identity))
|
||||
await (options.compile ?? compileProbe)(compiler, source, output, environment)
|
||||
const inspect = options.inspect ?? inspectWindowsRuntimeSignature
|
||||
if ((await inspect(output)).status !== 'NotSigned') throw new Error('Windows signing preflight requires a new unsigned probe')
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
/** Compare CSP public material without supplying a PIN or invoking a signing operation. */
|
||||
import { execFile } from 'node:child_process'
|
||||
import { createPublicKey, X509Certificate } from 'node:crypto'
|
||||
import { promisify } from 'node:util'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { scrubWindowsSigningEnvironment } from './windows-sign.mjs'
|
||||
|
||||
/** Public-data results do not attest physical device presence, authentication or remaining PIN attempts. */
|
||||
export type WindowsTokenIdentity = 'matched' | 'certificate-mismatch' | 'provider-unavailable' | 'container-unavailable' | 'unknown'
|
||||
|
||||
interface PublicKeyQuery {
|
||||
container: string
|
||||
environment: NodeJS.ProcessEnv
|
||||
}
|
||||
|
||||
async function queryPublicKeys({ container, environment }: PublicKeyQuery): Promise<string> {
|
||||
const { NODE_OPTIONS: _options, NODE_PATH: _path, ...safeEnvironment } = scrubWindowsSigningEnvironment(environment)
|
||||
const { stdout, stderr } = await promisify(execFile)('pwsh', ['-NoProfile', '-NonInteractive', '-File',
|
||||
fileURLToPath(new URL('./windows-token-public-key.ps1', import.meta.url))], {
|
||||
env: { ...safeEnvironment, DSH_TOKEN_PUBLIC_CONTAINER: container },
|
||||
encoding: 'utf8', windowsHide: true, timeout: 15_000, maxBuffer: 64 * 1024,
|
||||
})
|
||||
if (stderr) throw new Error('Public-key query returned diagnostics')
|
||||
return stdout
|
||||
}
|
||||
|
||||
function publicBlobKey(value: string): Buffer {
|
||||
const bytes = Buffer.from(value, 'base64')
|
||||
if (bytes.toString('base64') !== value || bytes.length < 20 || bytes[0] !== 6 || bytes[1] !== 2
|
||||
|| bytes.readUInt16LE(2) !== 0 || ![0x2400, 0xa400].includes(bytes.readUInt32LE(4))
|
||||
|| bytes.readUInt32LE(8) !== 0x31415352) throw new Error('Invalid RSA public blob')
|
||||
const bits = bytes.readUInt32LE(12)
|
||||
const exponent = bytes.readUInt32LE(16)
|
||||
if (bits < 512 || bits % 8 !== 0 || bytes.length !== 20 + bits / 8 || exponent < 3 || exponent % 2 === 0) {
|
||||
throw new Error('Invalid RSA public parameters')
|
||||
}
|
||||
const exponentBytes = Buffer.alloc(4)
|
||||
exponentBytes.writeUInt32BE(exponent)
|
||||
const first = exponentBytes.findIndex(byte => byte !== 0)
|
||||
return createPublicKey({ format: 'jwk', key: { kty: 'RSA',
|
||||
n: Buffer.from(bytes.subarray(20)).reverse().toString('base64url'),
|
||||
e: exponentBytes.subarray(first).toString('base64url') } }).export({ format: 'der', type: 'spki' })
|
||||
}
|
||||
|
||||
/**
|
||||
* Read both CSP public key slots and compare them with the configured certificate; never sign or retry.
|
||||
* @param options Certificate bytes, container, process environment and optional isolated public-query adapter.
|
||||
* @returns Matched public information, a specific unavailable/mismatch result, or unknown on incomplete/invalid output.
|
||||
*/
|
||||
export async function inspectWindowsTokenIdentity(options: PublicKeyQuery & {
|
||||
certificate: Uint8Array
|
||||
query?: (input: PublicKeyQuery) => Promise<string>
|
||||
}): Promise<WindowsTokenIdentity> {
|
||||
try {
|
||||
const value: unknown = JSON.parse(await (options.query ?? queryPublicKeys)({
|
||||
container: options.container, environment: options.environment,
|
||||
}))
|
||||
if (!value || typeof value !== 'object' || !('status' in value)) return 'unknown'
|
||||
if (value.status === 'provider-unavailable' || value.status === 'container-unavailable') return value.status
|
||||
if (value.status !== 'public-keys' || !('publicKeys' in value) || !Array.isArray(value.publicKeys)
|
||||
|| value.publicKeys.length < 1 || value.publicKeys.length > 2) return 'unknown'
|
||||
const keys = value.publicKeys.map((key: unknown) => {
|
||||
if (typeof key !== 'string') throw new Error('Invalid public key')
|
||||
return publicBlobKey(key)
|
||||
})
|
||||
const expected = new X509Certificate(options.certificate).publicKey.export({ format: 'der', type: 'spki' })
|
||||
return keys.some(key => key.equals(expected)) ? 'matched' : 'certificate-mismatch'
|
||||
} catch {
|
||||
// Native failures may include container names or inherited diagnostics; expose only the incomplete result.
|
||||
return 'unknown'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Explain why public-data validation stopped signing without asserting device lock or authentication status.
|
||||
* @param result Non-matching public-data result.
|
||||
* @returns A credential-free operator diagnostic.
|
||||
*/
|
||||
export function windowsTokenIdentityFailure(result: Exclude<WindowsTokenIdentity, 'matched'>): string {
|
||||
const reasons = {
|
||||
'provider-unavailable': 'The configured eToken provider is unavailable; check the SafeNet driver.',
|
||||
'container-unavailable': 'The configured container could not be opened; after replacing the UKey, check its container name and visibility.',
|
||||
'certificate-mismatch': 'The returned container public keys do not match the configured certificate; check the certificate and container after replacing the UKey.',
|
||||
unknown: 'The container public identity could not be confirmed; check that PowerShell 7 (pwsh) can start, then check device visibility and SafeNet public-information support.',
|
||||
}
|
||||
return `${reasons[result]} No PIN was supplied and no signing was attempted by this check. Signing stopped; device lock and PIN retry counts are unknown.`
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
// Read only public material through the existing SafeNet CSP. No authentication or key mutation APIs.
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
public static class DshTokenPublicKey
|
||||
{
|
||||
public const string Provider = "eToken Base Cryptographic Provider";
|
||||
public const uint PublicContextFlags = 0xF0000040; // CRYPT_VERIFYCONTEXT | CRYPT_SILENT
|
||||
public const int NoKey = unchecked((int)0x8009000D);
|
||||
|
||||
public interface IApi
|
||||
{
|
||||
int Acquire(string container, out IntPtr context);
|
||||
int GetKey(IntPtr context, uint spec, out IntPtr key);
|
||||
int Export(IntPtr key, byte[] bytes, ref uint length);
|
||||
bool Destroy(IntPtr key);
|
||||
bool Release(IntPtr context);
|
||||
}
|
||||
|
||||
public sealed class NativeApi : IApi
|
||||
{
|
||||
[DllImport("advapi32.dll", CharSet = CharSet.Unicode, ExactSpelling = true, SetLastError = true)]
|
||||
static extern bool CryptAcquireContextW(out IntPtr context, string container, string provider, uint type, uint flags);
|
||||
[DllImport("advapi32.dll", SetLastError = true)]
|
||||
static extern bool CryptGetUserKey(IntPtr context, uint spec, out IntPtr key);
|
||||
[DllImport("advapi32.dll", SetLastError = true)]
|
||||
static extern bool CryptExportKey(IntPtr key, IntPtr exportKey, uint type, uint flags, byte[] bytes, ref uint length);
|
||||
[DllImport("advapi32.dll", SetLastError = true)]
|
||||
static extern bool CryptDestroyKey(IntPtr key);
|
||||
[DllImport("advapi32.dll", SetLastError = true)]
|
||||
static extern bool CryptReleaseContext(IntPtr context, uint flags);
|
||||
public int Acquire(string container, out IntPtr context) {
|
||||
return CryptAcquireContextW(out context, container, Provider, 1, PublicContextFlags) ? 0 : Marshal.GetLastWin32Error();
|
||||
}
|
||||
public int GetKey(IntPtr context, uint spec, out IntPtr key) {
|
||||
return CryptGetUserKey(context, spec, out key) ? 0 : Marshal.GetLastWin32Error();
|
||||
}
|
||||
public int Export(IntPtr key, byte[] bytes, ref uint length) {
|
||||
return CryptExportKey(key, IntPtr.Zero, 6, 0, bytes, ref length) ? 0 : Marshal.GetLastWin32Error(); // PUBLICKEYBLOB
|
||||
}
|
||||
public bool Destroy(IntPtr key) { return CryptDestroyKey(key); }
|
||||
public bool Release(IntPtr context) { return CryptReleaseContext(context, 0); }
|
||||
}
|
||||
|
||||
public sealed class Result
|
||||
{
|
||||
public string status = "unknown";
|
||||
public string[] publicKeys = new string[0];
|
||||
}
|
||||
|
||||
// The adapter seam allows all failure paths to be exercised without loading a hardware provider.
|
||||
public static Result Read(IApi api, string container)
|
||||
{
|
||||
var result = new Result();
|
||||
IntPtr context;
|
||||
int error = api.Acquire(container, out context);
|
||||
if (error != 0) {
|
||||
if (error == unchecked((int)0x80090013) || error == unchecked((int)0x80090017)
|
||||
|| error == unchecked((int)0x8009001E)) result.status = "provider-unavailable";
|
||||
else if (error == unchecked((int)0x80090016)) result.status = "container-unavailable";
|
||||
return result;
|
||||
}
|
||||
try {
|
||||
var keys = new List<string>();
|
||||
foreach (uint spec in new uint[] { 2, 1 }) {
|
||||
IntPtr key;
|
||||
error = api.GetKey(context, spec, out key);
|
||||
if (error == NoKey) continue;
|
||||
if (error != 0) return result;
|
||||
bool destroyed;
|
||||
try {
|
||||
uint length = 0;
|
||||
if (api.Export(key, null, ref length) != 0 || length < 20 || length > 16384) return result;
|
||||
var bytes = new byte[length];
|
||||
uint capacity = length;
|
||||
if (api.Export(key, bytes, ref length) != 0 || length < 20 || length > capacity) return result;
|
||||
keys.Add(Convert.ToBase64String(bytes, 0, (int)length));
|
||||
} finally { destroyed = api.Destroy(key); }
|
||||
if (!destroyed) return result;
|
||||
}
|
||||
if (keys.Count > 0) {
|
||||
result.publicKeys = keys.ToArray();
|
||||
result.status = "public-keys";
|
||||
}
|
||||
} finally {
|
||||
if (!api.Release(context)) {
|
||||
result.status = "unknown";
|
||||
result.publicKeys = new string[0];
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
# Only the container name is needed; the caller removes inherited signing credentials.
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Add-Type -Path (Join-Path $PSScriptRoot 'windows-token-public-key.cs')
|
||||
[DshTokenPublicKey]::Read([DshTokenPublicKey+NativeApi]::new(), $env:DSH_TOKEN_PUBLIC_CONTAINER) | ConvertTo-Json -Compress
|
||||
@@ -89,7 +89,7 @@ export function inventoryDesktopRuntime(root: string): DesktopRuntimeFile[] {
|
||||
.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0)
|
||||
}
|
||||
|
||||
async function inventoryRuntimeForVerification(root: string): Promise<DesktopRuntimeFile[]> {
|
||||
async function inventoryRuntimeConcurrently(root: string): Promise<DesktopRuntimeFile[]> {
|
||||
const entries = runtimeFiles(root)
|
||||
const files: DesktopRuntimeFile[] = []
|
||||
const remaining = entries.values()
|
||||
@@ -125,6 +125,31 @@ export function writeDesktopRuntime(
|
||||
root: string, release: DesktopRelease, sharedNames: readonly string[],
|
||||
target: { platform: NodeJS.Platform; arch: string } = process,
|
||||
): DesktopRuntimeDescriptor {
|
||||
return writeRuntimeDescriptor(root, {
|
||||
...runtimeMetadata(root, release, sharedNames, target), files: inventoryDesktopRuntime(root),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Seal a materialized runtime with bounded concurrent reads, draining readers before failure returns.
|
||||
* @param root - Runtime output directory.
|
||||
* @param release - Matching shell, dsh, Host, and executable versions.
|
||||
* @param sharedNames - Release-owned packages supplied to plugins.
|
||||
* @param target - Platform and architecture selected by runtime preparation.
|
||||
* @returns Descriptor written after every final file has been inventoried.
|
||||
*/
|
||||
export async function writeDesktopRuntimeAsync(
|
||||
root: string, release: DesktopRelease, sharedNames: readonly string[],
|
||||
target: { platform: NodeJS.Platform; arch: string } = process,
|
||||
): Promise<DesktopRuntimeDescriptor> {
|
||||
const metadata = runtimeMetadata(root, release, sharedNames, target)
|
||||
return writeRuntimeDescriptor(root, { ...metadata, files: await inventoryRuntimeConcurrently(root) })
|
||||
}
|
||||
|
||||
function runtimeMetadata(
|
||||
root: string, release: DesktopRelease, sharedNames: readonly string[],
|
||||
target: { platform: NodeJS.Platform; arch: string },
|
||||
): Omit<DesktopRuntimeDescriptor, 'files'> {
|
||||
const sharedPackages = [...new Set(sharedNames)].sort().map((name) => {
|
||||
if (!PACKAGE_NAME.test(name)) throw new Error(`desktop runtime: invalid shared package ${name}`)
|
||||
const path = `node_modules/${name}`
|
||||
@@ -134,10 +159,10 @@ export function writeDesktopRuntime(
|
||||
}
|
||||
return { name, version: manifest.version, path }
|
||||
})
|
||||
const descriptor: DesktopRuntimeDescriptor = {
|
||||
schemaVersion: 1, release, platform: target.platform, arch: target.arch,
|
||||
sharedPackages, files: inventoryDesktopRuntime(root),
|
||||
}
|
||||
return { schemaVersion: 1, release, platform: target.platform, arch: target.arch, sharedPackages }
|
||||
}
|
||||
|
||||
function writeRuntimeDescriptor(root: string, descriptor: DesktopRuntimeDescriptor): DesktopRuntimeDescriptor {
|
||||
writeFileSync(join(root, DESKTOP_RUNTIME_FILE), `${JSON.stringify(descriptor, undefined, 2)}\n`)
|
||||
return descriptor
|
||||
}
|
||||
@@ -203,7 +228,7 @@ export async function verifyDesktopRuntime(
|
||||
throw new Error(`desktop runtime: shared package metadata mismatch for ${entry.name}`)
|
||||
}
|
||||
}
|
||||
const actual = await inventoryRuntimeForVerification(root)
|
||||
const actual = await inventoryRuntimeConcurrently(root)
|
||||
// Windows has no portable Unix executable permission bits.
|
||||
const comparable = (items: readonly DesktopRuntimeFile[]): unknown => process.platform === 'win32'
|
||||
? items.map(({ executable: _executable, ...item }) => item) : items
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { join, sep } from 'node:path'
|
||||
import { join, resolve, sep } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
desktopTargetBuildPaths,
|
||||
desktopTargetPlatform,
|
||||
developmentRuntimeDirectory,
|
||||
resolveDesktopBuildTarget,
|
||||
resolveDesktopTargetBuildPaths,
|
||||
} from '../scripts/desktop-build-paths.mjs'
|
||||
|
||||
describe('desktop build paths', () => {
|
||||
@@ -35,11 +36,34 @@ describe('desktop build paths', () => {
|
||||
expect(windows.runtime).toContain(join('targets', 'win-x64', 'runtime'))
|
||||
})
|
||||
|
||||
it('shares only the immutable upstream download cache', () => {
|
||||
it('shares download and package caches outside target output directories', () => {
|
||||
const arm64 = desktopTargetBuildPaths('mac-arm64')
|
||||
const x64 = desktopTargetBuildPaths('mac-x64')
|
||||
expect(arm64.downloads).toBe(x64.downloads)
|
||||
expect(arm64.downloads).not.toContain(`${sep}targets${sep}`)
|
||||
expect(arm64.pnpmStore).toBe(x64.pnpmStore)
|
||||
expect(arm64.pnpmStore).not.toContain(`${sep}targets${sep}`)
|
||||
})
|
||||
|
||||
it('retains target outputs while relocating caches through the supplied environment', () => {
|
||||
const cache = resolve('external-package-cache')
|
||||
const environment = { DSH_DESKTOP_PACKAGE_CACHE_DIR: cache }
|
||||
const defaults = desktopTargetBuildPaths('win-x64', {})
|
||||
const paths = resolveDesktopTargetBuildPaths(environment, 'win32', 'x64')
|
||||
expect(paths).toEqual({ ...defaults, downloads: join(cache, 'downloads'), pnpmStore: join(cache, 'pnpm-store') })
|
||||
expect(desktopTargetBuildPaths('mac-arm64', environment).pnpmStore).toBe(paths.pnpmStore)
|
||||
for (const target of ['mac-arm64', 'mac-x64', 'win-x64'] as const) {
|
||||
const configured = desktopTargetBuildPaths(target, environment)
|
||||
expect(configured.downloads).toBe(join(cache, 'downloads'))
|
||||
expect(configured.pnpmStore).toBe(join(cache, 'pnpm-store'))
|
||||
expect(configured.root).not.toContain(cache)
|
||||
}
|
||||
expect(defaults.pnpmStore).toBe(resolve(defaults.root, '../..', 'pnpm-store'))
|
||||
})
|
||||
|
||||
it.each(['', ' ', 'relative/cache', './cache', 'cache\0invalid'])('rejects invalid cache directory %j', (directory) => {
|
||||
expect(() => desktopTargetBuildPaths('win-x64', { DSH_DESKTOP_PACKAGE_CACHE_DIR: directory }))
|
||||
.toThrow('DSH_DESKTOP_PACKAGE_CACHE_DIR')
|
||||
})
|
||||
|
||||
it('resolves the development primary runtime from the build target rather than the host architecture', () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
@@ -23,6 +23,85 @@ async function withDirectory(action: (directory: string) => Promise<void>): Prom
|
||||
}
|
||||
|
||||
describe('Desktop local packaging configuration', () => {
|
||||
it('loads file-owned compression and defaults to fast Windows archive compression', async () => {
|
||||
expect(resolveWindowsPackageSettings({}).compressionLevel).toBe(3)
|
||||
await withDirectory(async (directory) => {
|
||||
await writeFile(join(directory, '.env.windows'), 'DSH_DESKTOP_WINDOWS_COMPRESSION_LEVEL=3\n')
|
||||
const selected = loadDesktopPackageEnvironment('win32', { DSH_DESKTOP_WINDOWS_COMPRESSION_LEVEL: '0' }, directory)
|
||||
expect(resolveWindowsPackageSettings(selected).compressionLevel).toBe(3)
|
||||
})
|
||||
})
|
||||
|
||||
it.each(['3', '7'])('applies an explicit Windows workflow compression override %s', async (value) => {
|
||||
await withDirectory(async (directory) => {
|
||||
await writeFile(join(directory, '.env.windows'), 'DSH_DESKTOP_WINDOWS_COMPRESSION_LEVEL=5\n')
|
||||
const selected = loadDesktopPackageEnvironment('win32', { PACKAGE_WINDOWS_COMPRESSION_LEVEL: value }, directory)
|
||||
expect(resolveWindowsPackageSettings(selected).compressionLevel).toBe(Number(value))
|
||||
const unchanged = loadDesktopPackageEnvironment('win32', { PACKAGE_WINDOWS_COMPRESSION_LEVEL: '' }, directory)
|
||||
expect(resolveWindowsPackageSettings(unchanged).compressionLevel).toBe(5)
|
||||
})
|
||||
})
|
||||
|
||||
it.each(['0', '5', '7', '9'])('retains explicit Windows file compression %s outside the workflow', async (value) => {
|
||||
await withDirectory(async (directory) => {
|
||||
await writeFile(join(directory, '.env.windows'), `DSH_DESKTOP_WINDOWS_COMPRESSION_LEVEL=${value}\n`)
|
||||
expect(resolveWindowsPackageSettings(loadDesktopPackageEnvironment('win32', {}, directory)).compressionLevel).toBe(Number(value))
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the legacy builder override below the file-owned Windows setting', () => {
|
||||
expect(resolveWindowsPackageSettings({ ELECTRON_BUILDER_COMPRESSION_LEVEL: '7' }).compressionLevel).toBe(7)
|
||||
expect(resolveWindowsPackageSettings({ ELECTRON_BUILDER_COMPRESSION_LEVEL: '7', DSH_DESKTOP_WINDOWS_COMPRESSION_LEVEL: '3' }).compressionLevel).toBe(3)
|
||||
})
|
||||
|
||||
it.each(['test', 'production'])('uses the same Windows compression defaults and overrides for %s', async (preset) => {
|
||||
await withDirectory(async (directory) => {
|
||||
await mkdir(join(directory, 'presets'))
|
||||
const path = join(directory, 'presets', `${preset}.env`)
|
||||
const parent = { DSH_PACKAGING_CONFIG_DIR: directory, DSH_PACKAGING_PRESET: preset }
|
||||
await writeFile(path, `DSH_DESKTOP_AUTO_UPDATE_ENV=${preset}\n`)
|
||||
const load = (environment: NodeJS.ProcessEnv) => resolveWindowsPackageSettings(
|
||||
loadDesktopPackageEnvironment('win32', environment, directory, { unsigned: true }),
|
||||
).compressionLevel
|
||||
expect(load(parent)).toBe(3)
|
||||
await writeFile(path, `DSH_DESKTOP_AUTO_UPDATE_ENV=${preset}\nDSH_DESKTOP_WINDOWS_COMPRESSION_LEVEL=9\n`)
|
||||
expect(load(parent)).toBe(9)
|
||||
expect(load({ ...parent, PACKAGE_WINDOWS_COMPRESSION_LEVEL: '3' })).toBe(3)
|
||||
expect(load({ ...parent, PACKAGE_WINDOWS_COMPRESSION_LEVEL: '7' })).toBe(7)
|
||||
})
|
||||
})
|
||||
|
||||
it.each([' ', '0', '5', '9', '-1', '10', '3.5', '03', 'fast'])('rejects invalid workflow compression %j before reading settings', (value) => {
|
||||
expect(() => loadDesktopPackageEnvironment('win32', { PACKAGE_WINDOWS_COMPRESSION_LEVEL: value }, 'missing-settings'))
|
||||
.toThrow('PACKAGE_WINDOWS_COMPRESSION_LEVEL')
|
||||
})
|
||||
|
||||
it('rejects a Windows compression override for macOS before reading settings', () => {
|
||||
expect(() => loadDesktopPackageEnvironment('darwin', { PACKAGE_WINDOWS_COMPRESSION_LEVEL: '3' }, 'missing-settings'))
|
||||
.toThrow('PACKAGE_WINDOWS_COMPRESSION_LEVEL')
|
||||
})
|
||||
it.each(['', '-1', '10', '3.5', '03', 'fast'])('rejects invalid compression %j before preparation', (value) => {
|
||||
const selected = { ...RELEASE, DSH_DESKTOP_WINDOWS_COMPRESSION_LEVEL: value }
|
||||
expect(() => { validateDesktopPackageEnvironment(selected, WINDOWS, { unsigned: true }) })
|
||||
.toThrow('COMPRESSION_LEVEL')
|
||||
})
|
||||
|
||||
it.each(['', ' ', 'relative/cache'])('rejects invalid package caches before any signing mode %j', (directory) => {
|
||||
for (const target of [WINDOWS, MACOS]) {
|
||||
for (const options of [{ unsigned: true }, { prepareOnly: true }, {}]) {
|
||||
expect(() => { validateDesktopPackageEnvironment({ DSH_DESKTOP_PACKAGE_CACHE_DIR: directory }, target, options) })
|
||||
.toThrow('DSH_DESKTOP_PACKAGE_CACHE_DIR')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves the machine package cache when loading a release file', async () => {
|
||||
await withDirectory(async (directory) => {
|
||||
await writeFile(join(directory, '.env.windows'), '')
|
||||
expect(loadDesktopPackageEnvironment('win32', { DSH_DESKTOP_PACKAGE_CACHE_DIR: directory }, directory))
|
||||
.toEqual({ DSH_DESKTOP_PACKAGE_CACHE_DIR: directory })
|
||||
})
|
||||
})
|
||||
it('selects macOS local defaults while excluding inherited release settings and retaining build networking', () => {
|
||||
const parent = {
|
||||
PATH: 'build-tools', HTTPS_PROXY: 'http://proxy.example:8080', DSH_DESKTOP_NPM_REGISTRY: 'https://registry.npmmirror.com',
|
||||
@@ -272,3 +351,40 @@ it('skips Apple credentials but still requires signing credentials for unnotariz
|
||||
}).toThrow(/CSC_KEY_PASSWORD/u)
|
||||
})
|
||||
})
|
||||
|
||||
it('takes ZIP compression from the Mac file instead of ambient release settings', async () => {
|
||||
await withDirectory(async (directory) => {
|
||||
await writeFile(join(directory, '.env.macos'), 'DSH_DESKTOP_MACOS_ZIP_COMPRESSION_LEVEL=5\n')
|
||||
const loaded = loadDesktopPackageEnvironment('darwin', { DSH_DESKTOP_MACOS_ZIP_COMPRESSION_LEVEL: '9' }, directory)
|
||||
expect(loaded.DSH_DESKTOP_MACOS_ZIP_COMPRESSION_LEVEL).toBe('5')
|
||||
await writeFile(join(directory, '.env.macos'), '')
|
||||
expect(loadDesktopPackageEnvironment('darwin', { DSH_DESKTOP_MACOS_ZIP_COMPRESSION_LEVEL: '9' }, directory))
|
||||
.not.toHaveProperty('DSH_DESKTOP_MACOS_ZIP_COMPRESSION_LEVEL')
|
||||
})
|
||||
})
|
||||
|
||||
it.each(['', 'relative-cache'])('rejects invalid build cache paths during preflight (%s)', (cache) => {
|
||||
expect(() => { validateDesktopPackageEnvironment({ ...RELEASE, DSH_DESKTOP_PACKAGE_CACHE_DIR: cache }, MACOS, { unsigned: true }) })
|
||||
.toThrow('DSH_DESKTOP_PACKAGE_CACHE_DIR must be a nonempty absolute directory')
|
||||
})
|
||||
|
||||
it.each(['3', '7'])('applies the workflow ZIP choice %s over the Mac file setting', async (level) => {
|
||||
await withDirectory(async (directory) => {
|
||||
await writeFile(join(directory, '.env.macos'), 'DSH_DESKTOP_MACOS_ZIP_COMPRESSION_LEVEL=5\n')
|
||||
const loaded = loadDesktopPackageEnvironment('darwin', { PACKAGE_MACOS_ZIP_COMPRESSION_LEVEL: level }, directory)
|
||||
expect(loaded.DSH_DESKTOP_MACOS_ZIP_COMPRESSION_LEVEL).toBe(level)
|
||||
})
|
||||
})
|
||||
|
||||
it.each(['', '3', '7'])('rejects Mac workflow ZIP choice %j on Windows before reading settings', (level) => {
|
||||
expect(() => loadDesktopPackageEnvironment('win32', { PACKAGE_MACOS_ZIP_COMPRESSION_LEVEL: level }, 'missing-settings'))
|
||||
.toThrow('PACKAGE_MACOS_ZIP_COMPRESSION_LEVEL requires macOS')
|
||||
})
|
||||
|
||||
it.each(['', '0', '9', 'invalid'])('rejects unsupported workflow ZIP choice %j', async (level) => {
|
||||
await withDirectory(async (directory) => {
|
||||
await writeFile(join(directory, '.env.macos'), '')
|
||||
expect(() => loadDesktopPackageEnvironment('darwin', { PACKAGE_MACOS_ZIP_COMPRESSION_LEVEL: level }, directory))
|
||||
.toThrow('workflow ZIP compression must be 3 or 7')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,49 @@
|
||||
|
||||
public sealed class FakeTokenPublicKey : DshTokenPublicKey.IApi
|
||||
{
|
||||
string scenario;
|
||||
int releases, destroys, exports, keys;
|
||||
public FakeTokenPublicKey(string value) { scenario = value; }
|
||||
public int Acquire(string container, out System.IntPtr context) {
|
||||
if (container != "fake-container") throw new System.Exception("unexpected container");
|
||||
context = new System.IntPtr(1);
|
||||
if (scenario == "provider") return unchecked((int)0x80090013);
|
||||
if (scenario == "container") return unchecked((int)0x80090016);
|
||||
if (scenario == "auth") return unchecked((int)0x80090022);
|
||||
return 0;
|
||||
}
|
||||
public int GetKey(System.IntPtr context, uint spec, out System.IntPtr key) {
|
||||
keys++;
|
||||
key = new System.IntPtr(2);
|
||||
if (scenario == "no-key" || spec == 1) return DshTokenPublicKey.NoKey;
|
||||
if (scenario == "get-key") return 5;
|
||||
return 0;
|
||||
}
|
||||
public int Export(System.IntPtr key, byte[] bytes, ref uint length) {
|
||||
exports++;
|
||||
if (scenario == "export") return 5;
|
||||
if (scenario == "size") { length = 1000000; return 0; }
|
||||
length = 24;
|
||||
return 0;
|
||||
}
|
||||
public bool Destroy(System.IntPtr key) { destroys++; return scenario != "destroy"; }
|
||||
public bool Release(System.IntPtr context) { releases++; return scenario != "release"; }
|
||||
public static string Test() {
|
||||
if (DshTokenPublicKey.PublicContextFlags != 0xF0000040) throw new System.Exception("unsafe flags");
|
||||
foreach (string scenario in new [] { "ok", "provider", "container", "auth", "no-key", "get-key", "export", "size", "destroy", "release" }) {
|
||||
var fake = new FakeTokenPublicKey(scenario);
|
||||
var result = DshTokenPublicKey.Read(fake, "fake-container");
|
||||
string expected = scenario == "ok" ? "public-keys" : scenario == "provider" ? "provider-unavailable"
|
||||
: scenario == "container" ? "container-unavailable" : "unknown";
|
||||
if (result.status != expected) throw new System.Exception("wrong outcome: " + scenario);
|
||||
bool acquired = scenario != "provider" && scenario != "container" && scenario != "auth";
|
||||
if (fake.releases != (acquired ? 1 : 0)) throw new System.Exception("context leak: " + scenario);
|
||||
bool hasKey = acquired && scenario != "no-key" && scenario != "get-key";
|
||||
if (fake.destroys != (hasKey ? 1 : 0)) throw new System.Exception("key leak: " + scenario);
|
||||
if (!acquired && fake.keys != 0) throw new System.Exception("read after failed acquire");
|
||||
if (scenario == "ok" && (fake.exports != 2 || fake.keys != 2)) throw new System.Exception("incomplete read");
|
||||
if (result.status != "public-keys" && result.publicKeys.Length != 0) throw new System.Exception("partial result escaped");
|
||||
}
|
||||
return "fake CSP cases passed";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/** Snapshot operator-selected release files into unsigned, hash-pinned experiment inputs. */
|
||||
import { createHash } from 'node:crypto'
|
||||
import { copyFile, lstat, mkdir, readFile } from 'node:fs/promises'
|
||||
import { extname, isAbsolute, join } from 'node:path'
|
||||
import { inspectWindowsRuntimeSignature } from '../../scripts/windows-runtime-signature.mjs'
|
||||
|
||||
/** One immutable unsigned input; its source is never modified or executed. */
|
||||
export interface SigningCorpusFile {
|
||||
readonly path: string
|
||||
readonly sha256: string
|
||||
readonly bytes: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy a nonempty JSON array of absolute paths and SHA-256 digests, then strip signatures only on copies.
|
||||
* @param manifestPath Operator-owned JSON manifest; every source must be a regular file with the pinned digest.
|
||||
* @param directory New private directory for retained unsigned templates.
|
||||
* @param removeSignature Public SignTool removal operation; never receives an original source path.
|
||||
* @param signal Stops preparation before the next file when aborted.
|
||||
* @returns Unsigned template digests and sizes in manifest order; rejects invalid signatures or changed inputs.
|
||||
*/
|
||||
export async function prepareSigningCorpus(
|
||||
manifestPath: string, directory: string, removeSignature: (path: string) => Promise<void>, signal: AbortSignal,
|
||||
): Promise<SigningCorpusFile[]> {
|
||||
const manifest: unknown = JSON.parse(await readFile(manifestPath, 'utf8'))
|
||||
if (!Array.isArray(manifest) || manifest.length === 0) throw new Error('Signing corpus must be a nonempty JSON array')
|
||||
const inputs = manifest.map((entry: unknown) => {
|
||||
if (typeof entry !== 'object' || entry === null || !('path' in entry) || typeof entry.path !== 'string'
|
||||
|| !isAbsolute(entry.path) || !('sha256' in entry) || typeof entry.sha256 !== 'string'
|
||||
|| !/^[a-f0-9]{64}$/u.test(entry.sha256)) throw new Error('Signing corpus requires absolute paths and lowercase SHA-256 digests')
|
||||
return { path: entry.path, sha256: entry.sha256 }
|
||||
})
|
||||
await mkdir(directory)
|
||||
const result: SigningCorpusFile[] = []
|
||||
for (const [index, input] of inputs.entries()) {
|
||||
signal.throwIfAborted()
|
||||
const stat = await lstat(input.path)
|
||||
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error('Signing corpus source must be a regular file')
|
||||
const path = join(directory, `${index}${extname(input.path) || '.exe'}`)
|
||||
await copyFile(input.path, path)
|
||||
if (createHash('sha256').update(await readFile(path)).digest('hex') !== input.sha256) {
|
||||
throw new Error('Signing corpus source differs from its pinned digest')
|
||||
}
|
||||
const before = await inspectWindowsRuntimeSignature(path)
|
||||
if (before.status === 'Valid') await removeSignature(path)
|
||||
else if (before.status !== 'NotSigned') throw new Error('Signing corpus source has an invalid signature')
|
||||
if ((await inspectWindowsRuntimeSignature(path)).status !== 'NotSigned') throw new Error('Signing corpus copy must be unsigned')
|
||||
const content = await readFile(path)
|
||||
result.push({ path, sha256: createHash('sha256').update(content).digest('hex'), bytes: content.length })
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/** Bounded scheduling for the opt-in signing experiment; production signing does not use this helper. */
|
||||
|
||||
/** Operations owned by one experiment, with independently bounded completion and hardware work. */
|
||||
export interface SigningExperimentOptions<T> {
|
||||
readonly jobs: 1 | 2 | 4
|
||||
readonly hardwareConcurrency: 1 | 2 | 4
|
||||
readonly signal?: AbortSignal
|
||||
/** Complete one item, awaiting the supplied signing operation and all subsequent work before returning. */
|
||||
readonly complete: (item: T, sign: (path: string) => Promise<void>) => Promise<void>
|
||||
/** Perform one hardware request without retrying it. */
|
||||
readonly sign: (item: T, path: string) => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule independent experiment items and stop dispatch on the first failure or cancellation.
|
||||
* @param items Ordered inputs owned by this batch.
|
||||
* @param options Completion workers, hardware limit, cancellation and experiment operations.
|
||||
* @returns Resolves after every item completes; otherwise drains issued work before rejecting with the first reason.
|
||||
* Cancellation prevents new items and hardware requests but does not terminate an issued native operation.
|
||||
*/
|
||||
export async function runSigningExperimentBatch<T>(items: readonly T[], options: SigningExperimentOptions<T>): Promise<void> {
|
||||
let failure: { reason: unknown } | undefined
|
||||
let next = 0
|
||||
let signing = 0
|
||||
const requests = new Set<Promise<void>>()
|
||||
const waiting = new Set<() => void>()
|
||||
const wake = (): void => {
|
||||
for (const resume of waiting) resume()
|
||||
waiting.clear()
|
||||
}
|
||||
const stop = (reason: unknown): void => {
|
||||
failure ??= { reason }
|
||||
wake()
|
||||
}
|
||||
const aborted = (): void => { stop(options.signal?.reason) }
|
||||
const signItem = async (item: T, path: string): Promise<void> => {
|
||||
while (failure === undefined && signing >= options.hardwareConcurrency) {
|
||||
await new Promise<void>((resume) => { waiting.add(resume) })
|
||||
}
|
||||
if (failure !== undefined) throw failure.reason
|
||||
signing++
|
||||
try { await options.sign(item, path) }
|
||||
catch (error) { stop(error); throw error }
|
||||
finally { signing--; wake() }
|
||||
}
|
||||
const worker = async (): Promise<void> => {
|
||||
while (failure === undefined && next < items.length) {
|
||||
const item = items[next++]!
|
||||
try {
|
||||
await options.complete(item, (path) => {
|
||||
const request = signItem(item, path)
|
||||
requests.add(request)
|
||||
void request.then(() => requests.delete(request), () => requests.delete(request))
|
||||
return request
|
||||
})
|
||||
}
|
||||
catch (error) { stop(error) }
|
||||
}
|
||||
}
|
||||
options.signal?.addEventListener('abort', aborted, { once: true })
|
||||
try {
|
||||
if (options.signal?.aborted) aborted()
|
||||
await Promise.all(Array.from({ length: Math.min(options.jobs, items.length) }, worker))
|
||||
// A completion failure can leave a previously issued request awaiting its native result.
|
||||
await Promise.allSettled(requests)
|
||||
if (failure !== undefined) throw failure.reason
|
||||
} finally {
|
||||
options.signal?.removeEventListener('abort', aborted)
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,12 @@ import { expect, it } from 'vitest'
|
||||
import { macOSDownloadEnvironment, resolveMacOSPackageSettings } from '../scripts/macos-package-settings.mjs'
|
||||
|
||||
it('defaults to four workers and keeps download and Apple proxies independent', () => {
|
||||
expect(resolveMacOSPackageSettings({})).toEqual({ packConcurrency: 4 })
|
||||
expect(resolveMacOSPackageSettings({})).toEqual({ packConcurrency: 4, zipCompressionLevel: 3 })
|
||||
expect(resolveMacOSPackageSettings({ DSH_DESKTOP_MACOS_PACK_CONCURRENCY: '2',
|
||||
DSH_DESKTOP_MACOS_DOWNLOAD_PROXY: 'https://download.example:8080',
|
||||
DSH_DESKTOP_MACOS_NOTARIZATION_PROXY: 'http://apple.example:8081',
|
||||
})).toEqual({ packConcurrency: 2, downloadProxy: 'https://download.example:8080', notarizationProxy: 'http://apple.example:8081' })
|
||||
expect(resolveMacOSPackageSettings({ DSH_DESKTOP_MACOS_DOWNLOAD_PROXY: ' ', DSH_DESKTOP_MACOS_NOTARIZATION_PROXY: '' })).toEqual({ packConcurrency: 4 })
|
||||
})).toEqual({ packConcurrency: 2, zipCompressionLevel: 3, downloadProxy: 'https://download.example:8080', notarizationProxy: 'http://apple.example:8081' })
|
||||
expect(resolveMacOSPackageSettings({ DSH_DESKTOP_MACOS_DOWNLOAD_PROXY: ' ', DSH_DESKTOP_MACOS_NOTARIZATION_PROXY: '' })).toEqual({ packConcurrency: 4, zipCompressionLevel: 3 })
|
||||
})
|
||||
|
||||
it.each(['', '0', '-1', '1.5', '1e2', 'Infinity', '9007199254740992'])('rejects invalid concurrency %j', (value) => {
|
||||
@@ -32,3 +32,11 @@ it('overrides conflicting child proxy variables while preserving the parent and
|
||||
expect(parent.HTTPS_PROXY).toBe('http://old')
|
||||
expect(parent.NO_PROXY).toBe('*')
|
||||
})
|
||||
|
||||
it.each(['', '-1', '10', '3.5', '03', 'NaN'])('rejects invalid ZIP compression %j', (value) => {
|
||||
expect(() => resolveMacOSPackageSettings({ DSH_DESKTOP_MACOS_ZIP_COMPRESSION_LEVEL: value })).toThrow('ZIP_COMPRESSION_LEVEL')
|
||||
})
|
||||
|
||||
it.each(['0', '3', '7', '9'])('accepts ZIP compression %s', (value) => {
|
||||
expect(resolveMacOSPackageSettings({ DSH_DESKTOP_MACOS_ZIP_COMPRESSION_LEVEL: value }).zipCompressionLevel).toBe(Number(value))
|
||||
})
|
||||
@@ -1,4 +1,6 @@
|
||||
import { writeFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { setImmediate } from 'node:timers/promises'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { packageTarget, parseDesktopPackageInvocation } from '../scripts/package-target.ts'
|
||||
import { withMacOSNotarizationProxy } from '../scripts/macos-notarization-proxy.ts'
|
||||
@@ -43,6 +45,97 @@ function supervisor(failure?: string) {
|
||||
return { run, stages }
|
||||
}
|
||||
|
||||
it.each(['pack', 'runtime'] as const)('overlaps Windows preparation and waits for both before signing when %s finishes first', async (first) => {
|
||||
const { run, stages } = supervisor()
|
||||
const pack = Promise.withResolvers<undefined>()
|
||||
const runtime = Promise.withResolvers<undefined>()
|
||||
const finished = new Set<string>()
|
||||
run.run.mockImplementation(async (stage) => {
|
||||
stages.push(stage)
|
||||
if (stage.startsWith('run release:pack --family dsh ')) await pack.promise
|
||||
if (stage === 'run prepare:runtime --defer-primary-runtime-smoke') {
|
||||
await runtime.promise
|
||||
finished.add('runtime')
|
||||
}
|
||||
if (stage.startsWith('--dir native/system/packages/entry pack ')) finished.add('pack')
|
||||
if (stage === 'run sign:primary-runtime') expect([...finished].sort()).toEqual(['pack', 'runtime'])
|
||||
})
|
||||
const operation = packageTarget(parseDesktopPackageInvocation(['win-x64'], 'win32', 'x64'), environment, run)
|
||||
const outcome = operation.then(() => undefined, (error: unknown) => error)
|
||||
try {
|
||||
await vi.waitFor(() => {
|
||||
expect(stages.some(stage => stage.startsWith('run release:pack --family dsh '))).toBe(true)
|
||||
expect(stages).toContain('run prepare:runtime --defer-primary-runtime-smoke')
|
||||
})
|
||||
expect(stages).not.toContain('run sign:primary-runtime')
|
||||
;(first === 'pack' ? pack : runtime).resolve(undefined)
|
||||
await vi.waitFor(() => { expect(finished.has(first)).toBe(true) })
|
||||
expect(stages).not.toContain('run sign:primary-runtime')
|
||||
expect(writeFileSync).not.toHaveBeenCalled()
|
||||
;(first === 'pack' ? runtime : pack).resolve(undefined)
|
||||
expect(await outcome).toBeUndefined()
|
||||
expect(stages).toContain('run sign:primary-runtime')
|
||||
expect(writeFileSync).toHaveBeenCalledOnce()
|
||||
} finally {
|
||||
pack.resolve(undefined)
|
||||
runtime.resolve(undefined)
|
||||
await outcome
|
||||
}
|
||||
})
|
||||
|
||||
it.each(['pack', 'runtime'] as const)('drains the other Windows preparation after %s fails without starting later packing or signing', async (failed) => {
|
||||
const { run, stages } = supervisor()
|
||||
const pack = Promise.withResolvers<undefined>()
|
||||
const runtime = Promise.withResolvers<undefined>()
|
||||
const failure = new Error(`${failed} preparation failed`)
|
||||
let settled = false
|
||||
run.run.mockImplementation(async (stage) => {
|
||||
stages.push(stage)
|
||||
if (stage.startsWith('run release:pack --family dsh ')) await pack.promise
|
||||
if (stage === 'run prepare:runtime --defer-primary-runtime-smoke') await runtime.promise
|
||||
})
|
||||
const outcome = packageTarget(parseDesktopPackageInvocation(['win-x64'], 'win32', 'x64'), environment, run).then(
|
||||
() => { settled = true; return undefined },
|
||||
(error: unknown) => { settled = true; return error },
|
||||
)
|
||||
try {
|
||||
await vi.waitFor(() => {
|
||||
expect(stages.some(stage => stage.startsWith('run release:pack --family dsh '))).toBe(true)
|
||||
expect(stages).toContain('run prepare:runtime --defer-primary-runtime-smoke')
|
||||
})
|
||||
;(failed === 'pack' ? pack : runtime).reject(failure)
|
||||
await setImmediate()
|
||||
expect(settled).toBe(false)
|
||||
expect(writeFileSync).not.toHaveBeenCalled()
|
||||
;(failed === 'pack' ? runtime : pack).resolve(undefined)
|
||||
expect(await outcome).toBe(failure)
|
||||
expect(stages.filter(stage => stage.startsWith('run release:pack'))).toHaveLength(1)
|
||||
expect(stages.some(stage => stage.startsWith('--dir apps/desktop-host pack'))).toBe(false)
|
||||
expect(stages.some(stage => stage.startsWith('--dir native/system'))).toBe(false)
|
||||
expect(stages.some(stage => stage.startsWith('run sign:primary-runtime'))).toBe(false)
|
||||
expect(stages.some(stage => stage.startsWith('run prepare:packages') || stage.startsWith('run prepare:dsh'))).toBe(false)
|
||||
expect(writeFileSync).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
pack.resolve(undefined)
|
||||
runtime.resolve(undefined)
|
||||
await outcome
|
||||
}
|
||||
})
|
||||
|
||||
it.each([undefined, '3'])('passes the package cache and selected compression to their subprocesses (%s)', async (level) => {
|
||||
const { run } = supervisor()
|
||||
const cache = resolve('fixture-package-cache')
|
||||
await packageTarget(parseDesktopPackageInvocation(['win-x64'], 'win32', 'x64'), {
|
||||
...environment, DSH_DESKTOP_PACKAGE_CACHE_DIR: cache,
|
||||
...(level === undefined ? {} : { DSH_DESKTOP_WINDOWS_COMPRESSION_LEVEL: level }),
|
||||
ELECTRON_BUILDER_COMPRESSION_LEVEL: '0',
|
||||
}, run)
|
||||
const prepare = run.run.mock.calls.find(([stage]) => stage.startsWith('run prepare:dsh'))!
|
||||
expect(prepare[3].env.DSH_DESKTOP_PACKAGE_CACHE_DIR).toBe(cache)
|
||||
const builder = run.run.mock.calls.find(([stage]) => stage.startsWith('exec electron-builder'))!
|
||||
expect(builder[3].env.ELECTRON_BUILDER_COMPRESSION_LEVEL).toBe(level ?? '0')
|
||||
})
|
||||
|
||||
it.each([undefined, '2'])('uses bounded Windows packing while retaining signing and smoke (workers=%s)', async (workers) => {
|
||||
const { run, stages } = supervisor()
|
||||
const settings = workers === undefined ? environment : { ...environment, DSH_DESKTOP_WINDOWS_PACK_CONCURRENCY: workers }
|
||||
@@ -147,7 +240,10 @@ it.each([
|
||||
const invocation = parseDesktopPackageInvocation([target, '--unsigned'],
|
||||
target === 'win-x64' ? 'win32' : 'darwin', target === 'win-x64' ? 'x64' : 'arm64')
|
||||
await expect(packageTarget(invocation, environment, run)).rejects.toThrow('stage refused')
|
||||
expect(stages.at(-1)?.startsWith(failure)).toBe(true)
|
||||
const failedAt = stages.findIndex(stage => stage.startsWith(failure))
|
||||
expect(failedAt).toBeGreaterThanOrEqual(0)
|
||||
expect(stages.slice(failedAt + 1)).toEqual(
|
||||
target === 'win-x64' && failure.startsWith('exec tsx') ? ['run prepare:runtime'] : [])
|
||||
expect(stages.some(stage => stage.startsWith('--dir apps/desktop-host pack'))).toBe(false)
|
||||
expect(writeFileSync).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -253,7 +349,8 @@ it.each([false, true])('keeps signing and runtime verification when notarization
|
||||
await packageTarget(parseDesktopPackageInvocation(['mac-arm64', '--skip-notarization', ...(directory ? ['--dir'] : [])], 'darwin', 'arm64'), {
|
||||
...environment, CSC_LINK: 'signing.p12', CSC_KEY_PASSWORD: 'fixture-password',
|
||||
}, run)
|
||||
expect(stages.at(-2)).toBe(`exec electron-builder --config electron-builder.config.mjs --mac --arm64 --publish never${directory ? ' --dir' : ''}`)
|
||||
expect(stages).toContain('exec electron-builder --config electron-builder.config.mjs --mac --arm64 --publish never --dir')
|
||||
if (!directory) expect(stages.at(-2)).toContain('--mac zip')
|
||||
expect(stages.at(-1)).toBe('exec tsx scripts/smoke-packaged-runtime.ts --skip-notarization')
|
||||
expect(run.run.mock.calls.at(-2)![3].env).toMatchObject({
|
||||
DSH_DESKTOP_UNSIGNED: '0', DSH_DESKTOP_MACOS_SKIP_NOTARIZATION: '1', CSC_LINK: 'signing.p12',
|
||||
@@ -263,6 +360,34 @@ it.each([false, true])('keeps signing and runtime verification when notarization
|
||||
expect(writeFileSync).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['mac-arm64', 'mac-x64'] as const)('applies ZIP compression without changing DMG settings when skipping notarization for %s', async (target) => {
|
||||
for (const level of [undefined, '3', '7']) {
|
||||
const { run } = supervisor()
|
||||
await packageTarget(parseDesktopPackageInvocation([target, '--skip-notarization'], 'darwin', 'arm64'), {
|
||||
...environment, ELECTRON_BUILDER_COMPRESSION_LEVEL: '9',
|
||||
...(level === undefined ? {} : { DSH_DESKTOP_MACOS_ZIP_COMPRESSION_LEVEL: level }),
|
||||
}, run)
|
||||
const builders = run.run.mock.calls.filter(call => call[0].startsWith('exec electron-builder'))
|
||||
expect(builders).toHaveLength(3)
|
||||
const [app, dmg, zip] = builders
|
||||
expect(app![0]).toContain('--dir')
|
||||
expect(dmg![0]).toContain('--mac dmg')
|
||||
expect(zip![0]).toContain('--mac zip')
|
||||
expect(app![3].env.ELECTRON_BUILDER_COMPRESSION_LEVEL).toBe('9')
|
||||
expect(dmg![3].env.ELECTRON_BUILDER_COMPRESSION_LEVEL).toBe('9')
|
||||
expect(zip![3].env.ELECTRON_BUILDER_COMPRESSION_LEVEL).toBe(level ?? '3')
|
||||
for (const call of [dmg!, zip!]) {
|
||||
expect(call[0]).toContain('--prepackaged')
|
||||
expect(call[0]).toContain('unnotarized-artifacts')
|
||||
expect(call[3].env.DSH_DESKTOP_MACOS_SKIP_NOTARIZATION).toBe('1')
|
||||
expect(call[3].env.DSH_DESKTOP_UNSIGNED).toBe('0')
|
||||
}
|
||||
}
|
||||
expect(packageMacOSArtifacts).not.toHaveBeenCalled()
|
||||
expect(withMacOSNotarizationProxy).not.toHaveBeenCalled()
|
||||
expect(writeFileSync).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fails debugging packages when runtime verification fails', async () => {
|
||||
const failure = 'exec tsx scripts/smoke-packaged-runtime.ts --skip-notarization'
|
||||
const { run, stages } = supervisor(failure)
|
||||
@@ -271,3 +396,29 @@ it('fails debugging packages when runtime verification fails', async () => {
|
||||
expect(stages.at(-1)).toBe(failure)
|
||||
expect(writeFileSync).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stops unnotarized ZIP packaging when the DMG stage fails', async () => {
|
||||
const failure = 'exec electron-builder --config electron-builder.config.mjs --mac dmg'
|
||||
const { run, stages } = supervisor(failure)
|
||||
await expect(packageTarget(parseDesktopPackageInvocation(['mac-arm64', '--skip-notarization'], 'darwin', 'arm64'), environment, run))
|
||||
.rejects.toThrow('stage refused')
|
||||
expect(stages.some(stage => stage.includes('--mac zip'))).toBe(false)
|
||||
expect(stages.some(stage => stage.includes('smoke-packaged-runtime'))).toBe(false)
|
||||
expect(writeFileSync).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([undefined, '7'])('applies file-owned compression only to the final notarized ZIP (%s)', async (level) => {
|
||||
const { run } = supervisor()
|
||||
vi.mocked(packageMacOSArtifacts).mockImplementationOnce(async (_request, build) => {
|
||||
await build({ format: 'dmg', appPath: '/fixture/app', output: '/fixture/dmg' })
|
||||
await build({ format: 'zip', appPath: '/fixture/app', output: '/fixture/zip' })
|
||||
})
|
||||
const settings = { ...environment, ELECTRON_BUILDER_COMPRESSION_LEVEL: '9',
|
||||
...(level === undefined ? {} : { DSH_DESKTOP_MACOS_ZIP_COMPRESSION_LEVEL: level }) }
|
||||
await packageTarget(parseDesktopPackageInvocation(['mac-arm64'], 'darwin', 'arm64'), settings, run)
|
||||
const zip = run.run.mock.calls.find(call => call[0].includes('--mac zip'))
|
||||
const dmg = run.run.mock.calls.find(call => call[0].includes('--mac dmg'))
|
||||
expect(zip?.[3].env.ELECTRON_BUILDER_COMPRESSION_LEVEL).toBe(level ?? '3')
|
||||
expect(dmg?.[3].env.ELECTRON_BUILDER_COMPRESSION_LEVEL).toBe('9')
|
||||
expect(writeFileSync).toHaveBeenCalledOnce()
|
||||
})
|
||||
@@ -65,32 +65,84 @@ it('bounds overlapping cache restores and verifies all hits before serial hardwa
|
||||
expect(sign.mock.calls.map(([request]) => request.path)).toEqual(['c.exe', 'd.exe'].map(name => join(root, name)))
|
||||
})
|
||||
|
||||
it.each(['restore', 'verification', 'audit'])('drains active cache restores after %s failure without dispatching more work', async (failure) => {
|
||||
it('drains active cache restores after failure without dispatching more work', async () => {
|
||||
const root = await fixture(['a.exe', 'b.exe', 'c.exe'])
|
||||
const entered = Promise.withResolvers<undefined>()
|
||||
const release = Promise.withResolvers<undefined>()
|
||||
const failed = Promise.withResolvers<undefined>()
|
||||
const restored = new Set<string>()
|
||||
const calls: string[] = []
|
||||
const sign = vi.fn(async () => {})
|
||||
let settled = false
|
||||
const operation = signWindowsCode(root, { thumbprint, sign,
|
||||
record: (event) => {
|
||||
if (failure === 'audit' && 'type' in event && event.type === 'windows-code-signature-verified') {
|
||||
failed.resolve(undefined)
|
||||
throw new Error('audit failed')
|
||||
}
|
||||
},
|
||||
inspect: async (path) => {
|
||||
if (!restored.has(path)) return unsigned
|
||||
if (failure === 'verification' && path.endsWith('a.exe')) { failed.resolve(undefined); throw new Error('verification failed') }
|
||||
return valid
|
||||
},
|
||||
record: () => {},
|
||||
inspect: async () => unsigned,
|
||||
cache: { concurrency: 2, restore: async ({ path }) => {
|
||||
calls.push(path)
|
||||
if (path.endsWith('b.exe')) { entered.resolve(undefined); await release.promise; return false }
|
||||
await entered.promise
|
||||
if (failure === 'restore') { failed.resolve(undefined); throw new Error('restore failed') }
|
||||
failed.resolve(undefined)
|
||||
throw new Error('restore failed')
|
||||
} },
|
||||
}).then(() => { settled = true; return undefined }, (error: unknown) => { settled = true; return error })
|
||||
try {
|
||||
await Promise.race([failed.promise, operation])
|
||||
await new Promise<void>(resolve => setImmediate(resolve))
|
||||
expect(settled).toBe(false)
|
||||
expect(sign).not.toHaveBeenCalled()
|
||||
} finally { release.resolve(undefined); await operation }
|
||||
expect(await operation).toEqual(new Error('restore failed'))
|
||||
expect(calls).toEqual(['a.exe', 'b.exe'].map(name => join(root, name)))
|
||||
expect(sign).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['trust', 'timestamp', 'certificate', 'audit'])('rejects restored target %s failures before hardware signing', async (failure) => {
|
||||
const root = await fixture(['a.exe', 'b.exe', 'miss.exe'])
|
||||
const restored = new Set<string>()
|
||||
let restores = 0
|
||||
const sign = vi.fn(async () => {})
|
||||
await expect(signWindowsCode(root, { thumbprint, sign,
|
||||
record: (event) => {
|
||||
if (failure === 'audit' && 'type' in event && event.type === 'windows-code-signature-verified') throw new Error('audit failed')
|
||||
},
|
||||
inspect: async (path) => {
|
||||
if (!restored.has(path)) return unsigned
|
||||
expect(restores).toBe(3)
|
||||
return {
|
||||
...valid,
|
||||
...(failure === 'trust' ? { status: 'HashMismatch' } : {}),
|
||||
...(failure === 'timestamp' ? { timestamped: false } : {}),
|
||||
...(failure === 'certificate' ? { thumbprint: 'B'.repeat(40) } : {}),
|
||||
}
|
||||
},
|
||||
cache: { concurrency: 2, restore: async ({ path }) => {
|
||||
restores++
|
||||
if (path.endsWith('miss.exe')) return false
|
||||
restored.add(path)
|
||||
return true
|
||||
} },
|
||||
})).rejects.toThrow(failure === 'audit' ? 'audit failed' : 'signing verification failed')
|
||||
expect(restores).toBe(3)
|
||||
expect(sign).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('drains failed restored-target inspection before returning and never signs misses', async () => {
|
||||
const root = await fixture(['a.exe', 'b.exe', 'miss.exe'])
|
||||
const entered = Promise.withResolvers<undefined>()
|
||||
const failed = Promise.withResolvers<undefined>()
|
||||
const release = Promise.withResolvers<undefined>()
|
||||
const restored = new Set<string>()
|
||||
const sign = vi.fn(async () => {})
|
||||
let settled = false
|
||||
const operation = signWindowsCode(root, { thumbprint, sign, record: () => {},
|
||||
inspect: async (path) => {
|
||||
if (!restored.has(path)) return unsigned
|
||||
if (path.endsWith('b.exe')) { entered.resolve(undefined); await release.promise; return valid }
|
||||
await entered.promise
|
||||
failed.resolve(undefined)
|
||||
throw new Error('verification failed')
|
||||
},
|
||||
cache: { concurrency: 2, restore: async ({ path }) => {
|
||||
if (path.endsWith('miss.exe')) return false
|
||||
restored.add(path)
|
||||
return true
|
||||
} },
|
||||
@@ -101,8 +153,7 @@ it.each(['restore', 'verification', 'audit'])('drains active cache restores afte
|
||||
expect(settled).toBe(false)
|
||||
expect(sign).not.toHaveBeenCalled()
|
||||
} finally { release.resolve(undefined); await operation }
|
||||
expect(await operation).toEqual(new Error(`${failure} failed`))
|
||||
expect(calls).toEqual(['a.exe', 'b.exe'].map(name => join(root, name)))
|
||||
expect(await operation).toEqual(new Error('verification failed'))
|
||||
expect(sign).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ const state = vi.hoisted(() => ({
|
||||
pnpm: '11.7.0' as string | undefined,
|
||||
failure: undefined as 'electron' | 'primary' | 'cli' | undefined,
|
||||
preparePrimary: vi.fn(),
|
||||
extractElectron: vi.fn(),
|
||||
commandLink: vi.fn(),
|
||||
}))
|
||||
vi.mock('../scripts/desktop-build-paths.mjs', async importOriginal => ({
|
||||
@@ -22,9 +23,7 @@ vi.mock('../scripts/desktop-build-paths.mjs', async importOriginal => ({
|
||||
}),
|
||||
}))
|
||||
vi.mock('@electron/get', () => ({ downloadArtifact: async () => 'electron.zip' }))
|
||||
vi.mock('extract-zip', () => ({ default: async () => {
|
||||
if (state.failure === 'electron') throw new Error('Electron extraction failed')
|
||||
} }))
|
||||
vi.mock('extract-zip', () => ({ default: state.extractElectron }))
|
||||
vi.mock('node:child_process', async importOriginal => ({
|
||||
...await importOriginal<typeof import('node:child_process')>(),
|
||||
execFileSync: (file: string) => file === '/usr/libexec/PlistBuddy' ? '12.0\n' : '24.18.1\n',
|
||||
@@ -54,6 +53,9 @@ beforeEach(() => {
|
||||
state.failure = undefined
|
||||
process.argv = [process.execPath, 'prepare-runtime.ts']
|
||||
vi.stubEnv('DSH_DESKTOP_PACKAGING_RUN_DIR', undefined)
|
||||
state.extractElectron.mockImplementation(async () => {
|
||||
if (state.failure === 'electron') throw new Error('Electron extraction failed')
|
||||
})
|
||||
state.preparePrimary.mockImplementation(async () => {
|
||||
if (state.failure === 'primary') throw new Error('primary preparation failed')
|
||||
const root = join(state.root, 'runtime', 'primary-runtime')
|
||||
@@ -118,3 +120,94 @@ it.each([
|
||||
await expect(import('../scripts/prepare-runtime.ts')).rejects.toThrow(error)
|
||||
expect(existsSync(join(state.root, 'runtime', 'versions.json'))).toBe(false)
|
||||
})
|
||||
|
||||
it.each(['electron', 'primary'] as const)('overlaps Windows payloads and waits for %s to finish last before publishing CLI and versions', async (last) => {
|
||||
state.target = 'win-x64'
|
||||
const electron = Promise.withResolvers<undefined>()
|
||||
const primary = Promise.withResolvers<undefined>()
|
||||
const started = new Set<string>()
|
||||
const finished = new Set<string>()
|
||||
const preparePrimary = state.preparePrimary.getMockImplementation()!
|
||||
state.extractElectron.mockImplementation(async () => {
|
||||
started.add('electron')
|
||||
await electron.promise
|
||||
finished.add('electron')
|
||||
})
|
||||
state.preparePrimary.mockImplementation(async () => {
|
||||
started.add('primary')
|
||||
await primary.promise
|
||||
await preparePrimary()
|
||||
finished.add('primary')
|
||||
})
|
||||
const outcome = import('../scripts/prepare-runtime.ts').then(() => undefined, (error: unknown) => error)
|
||||
try {
|
||||
await vi.waitFor(() => { expect([...started].sort()).toEqual(['electron', 'primary']) }, { timeout: 5000 })
|
||||
;(last === 'electron' ? primary : electron).resolve(undefined)
|
||||
await vi.waitFor(() => { expect(finished.size).toBe(1) }, { timeout: 5000 })
|
||||
expect(existsSync(join(state.root, 'runtime', 'cli'))).toBe(false)
|
||||
expect(existsSync(join(state.root, 'runtime', 'versions.json'))).toBe(false)
|
||||
;(last === 'electron' ? electron : primary).resolve(undefined)
|
||||
expect(await outcome).toBeUndefined()
|
||||
expect(existsSync(join(state.root, 'runtime', 'cli', 'command-manager.js'))).toBe(true)
|
||||
expect(existsSync(join(state.root, 'runtime', 'versions.json'))).toBe(true)
|
||||
} finally {
|
||||
electron.resolve(undefined)
|
||||
primary.resolve(undefined)
|
||||
await outcome
|
||||
}
|
||||
})
|
||||
|
||||
it.each(['electron', 'primary'] as const)('drains the sibling after Windows %s failure without publishing CLI or versions', async (failed) => {
|
||||
state.target = 'win-x64'
|
||||
const electron = Promise.withResolvers<undefined>()
|
||||
const primary = Promise.withResolvers<undefined>()
|
||||
const started = new Set<string>()
|
||||
const completed = new Set<string>()
|
||||
const failure = new Error(`${failed} payload failed`)
|
||||
const prepare = async (name: 'electron' | 'primary', barrier: Promise<undefined>) => {
|
||||
started.add(name)
|
||||
try { await barrier } finally { completed.add(name) }
|
||||
}
|
||||
state.extractElectron.mockImplementation(() => prepare('electron', electron.promise))
|
||||
state.preparePrimary.mockImplementation(() => prepare('primary', primary.promise))
|
||||
const outcome = import('../scripts/prepare-runtime.ts').then(
|
||||
() => ({ error: undefined, completed: [...completed].sort() }),
|
||||
(error: unknown) => ({ error, completed: [...completed].sort() }),
|
||||
)
|
||||
try {
|
||||
await vi.waitFor(() => { expect([...started].sort()).toEqual(['electron', 'primary']) }, { timeout: 5000 })
|
||||
;(failed === 'electron' ? electron : primary).reject(failure)
|
||||
await vi.waitFor(() => { expect(completed.has(failed)).toBe(true) }, { timeout: 5000 })
|
||||
expect(existsSync(join(state.root, 'runtime', 'cli'))).toBe(false)
|
||||
expect(existsSync(join(state.root, 'runtime', 'versions.json'))).toBe(false)
|
||||
;(failed === 'electron' ? primary : electron).resolve(undefined)
|
||||
expect(await outcome).toEqual({ error: failure, completed: ['electron', 'primary'] })
|
||||
expect(existsSync(join(state.root, 'runtime', 'cli'))).toBe(false)
|
||||
expect(existsSync(join(state.root, 'runtime', 'versions.json'))).toBe(false)
|
||||
} finally {
|
||||
electron.resolve(undefined)
|
||||
primary.resolve(undefined)
|
||||
await outcome
|
||||
}
|
||||
})
|
||||
|
||||
it.each(['mac-arm64', 'mac-x64'] as const)('keeps %s primary preparation after Electron extraction', async (target) => {
|
||||
state.target = target
|
||||
const electron = Promise.withResolvers<undefined>()
|
||||
let extracting = false
|
||||
state.extractElectron.mockImplementation(async () => {
|
||||
extracting = true
|
||||
await electron.promise
|
||||
})
|
||||
const outcome = import('../scripts/prepare-runtime.ts').then(() => undefined, (error: unknown) => error)
|
||||
try {
|
||||
await vi.waitFor(() => { expect(extracting).toBe(true) }, { timeout: 5000 })
|
||||
expect(state.preparePrimary).not.toHaveBeenCalled()
|
||||
electron.resolve(undefined)
|
||||
expect(await outcome).toBeUndefined()
|
||||
expect(state.preparePrimary).toHaveBeenCalledOnce()
|
||||
} finally {
|
||||
electron.resolve(undefined)
|
||||
await outcome
|
||||
}
|
||||
})
|
||||
@@ -3,7 +3,7 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, expect, it } from 'vitest'
|
||||
import { DESKTOP_HOST_PACKAGE, DESKTOP_HOST_RUNTIME_FILES } from '../src/core-package-set.ts'
|
||||
import { DESKTOP_RUNTIME_FILE, readDesktopRuntime, runtimePath, verifyDesktopRuntime } from '../src/runtime-tree.ts'
|
||||
import { DESKTOP_RUNTIME_FILE, readDesktopRuntime, runtimePath, verifyDesktopRuntime, writeDesktopRuntime, writeDesktopRuntimeAsync } from '../src/runtime-tree.ts'
|
||||
import { runtimeFixture } from './runtime-fixture.ts'
|
||||
|
||||
const roots: string[] = []
|
||||
@@ -15,6 +15,29 @@ function fixture(): string {
|
||||
}
|
||||
afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) })
|
||||
|
||||
it('seals final file bytes identically with concurrent and synchronous inventory', async () => {
|
||||
const dsh = join(fixture(), 'dsh')
|
||||
const original = readDesktopRuntime(dsh)
|
||||
const names = original.sharedPackages.map(entry => entry.name)
|
||||
writeFileSync(join(dsh, 'extra.bin'), Buffer.from([0, 1, 255]))
|
||||
writeFileSync(join(dsh, 'package.json'), '{"type":"module","private":true}\n')
|
||||
const expected = writeDesktopRuntime(dsh, original.release, names)
|
||||
const expectedBytes = readFileSync(join(dsh, DESKTOP_RUNTIME_FILE))
|
||||
expect(await writeDesktopRuntimeAsync(dsh, original.release, names)).toEqual(expected)
|
||||
expect(readFileSync(join(dsh, DESKTOP_RUNTIME_FILE))).toEqual(expectedBytes)
|
||||
expect(await verifyDesktopRuntime(dsh, original.release.version)).toEqual(expected)
|
||||
})
|
||||
|
||||
it.each(['manifest', 'link'])('leaves the descriptor unchanged when concurrent sealing rejects a %s', async (failure) => {
|
||||
const dsh = join(fixture(), 'dsh')
|
||||
const original = readDesktopRuntime(dsh)
|
||||
const before = readFileSync(join(dsh, DESKTOP_RUNTIME_FILE))
|
||||
if (failure === 'manifest') writeFileSync(join(dsh, 'node_modules', DESKTOP_HOST_PACKAGE, 'package.json'), '{}')
|
||||
else symlinkSync(join(dsh, 'node_modules'), join(dsh, 'outside'), process.platform === 'win32' ? 'junction' : 'dir')
|
||||
await expect(writeDesktopRuntimeAsync(dsh, original.release, original.sharedPackages.map(entry => entry.name))).rejects.toThrow()
|
||||
expect(readFileSync(join(dsh, DESKTOP_RUNTIME_FILE))).toEqual(before)
|
||||
})
|
||||
|
||||
it('verifies a runtime after relocation without depending on build paths', async () => {
|
||||
const root = fixture()
|
||||
const before = await verifyDesktopRuntime(join(root, 'dsh'), '1.0.0')
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# Bilingual-pair consistency record for README.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write apps/desktop/tests/signing-concurrency/README.md
|
||||
/:
|
||||
en: cde5fda2a2f4b53f
|
||||
zh: 19dbeb4af20f26fa
|
||||
/windows-signing-concurrency-qualification:
|
||||
en: bd245a9d93c44d46
|
||||
zh: eb74f3edc9bb1f97
|
||||
/windows-signing-concurrency-qualification/summary:
|
||||
en: 0d910efcaf9434da
|
||||
zh: 0e6e179620e82713
|
||||
/windows-signing-concurrency-qualification/table-of-contents:
|
||||
en: 498f69e40952088d
|
||||
zh: 506c81b0f6879e9a
|
||||
/windows-signing-concurrency-qualification/retest-on-another-machine:
|
||||
en: b90878b9b5756cc9
|
||||
zh: e69d017153c10660
|
||||
/windows-signing-concurrency-qualification/measured-reference:
|
||||
en: c2d5b47db6ce1e20
|
||||
zh: ca1182b064d63081
|
||||
/windows-signing-concurrency-qualification/dev-note:
|
||||
en: b75c8458928b72b9
|
||||
zh: 003ba4e17b4b5269
|
||||
@@ -0,0 +1,47 @@
|
||||
---
|
||||
description: "Windows signing concurrency measurements and retesting on replacement runners."
|
||||
---
|
||||
|
||||
# Windows signing concurrency qualification
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
## Summary
|
||||
|
||||
Use this record when choosing signing concurrency on a replacement Windows runner. Results apply to the measured host, token and software combination; repeat qualification after changing them. Production signing remains serial.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Retest on another machine](#retest-on-another-machine)
|
||||
- [Measured reference](#measured-reference)
|
||||
|
||||
<a id="retest-on-another-machine"></a>
|
||||
|
||||
## Retest on another machine
|
||||
|
||||
1. Follow the [operator-approved experiment procedure](../../windows-signing/README.md#verification), using a hash-pinned corpus of real runtime, application and installer files. Compare the same inputs across modes; preserve originals and use isolated copies without signature-cache reuse.
|
||||
2. Run `--corpus` with `--rounds 2` to compare 1/1, 2/2 and 4/4 completion workers/hardware requests, reversing the second round. Run the same corpus with `--pipeline-control --rounds 2` for the 4/1 control. Retain the procedure's first-failure stop and interlock handling.
|
||||
3. Record the date, test and artifact versions, corpus count/bytes/largest file, CPU/RAM/disk, token/client/provider/SignTool versions, timestamp service, competing jobs and resource pressure. Keep both batch wall times and failure/retry counts; retain detailed output outside Git without credentials. Select a candidate only from completely verified runs, then validate it in the complete signed release workflow. Similar results favor fewer hardware requests; unexpected variation needs more rounds.
|
||||
|
||||
<a id="measured-reference"></a>
|
||||
|
||||
## Measured reference
|
||||
|
||||
On 2026-10-22, the experiment in PR #6675 used retained files from release run 39725804188, version `0.2.1-rc.2.20261022.50.2`. Each batch contained 148 runtime and 8 application/installer targets: 156 files, 849.2 MiB unsigned, largest 277.5 MiB. Deleted transient targets and repeated intermediate signing passes were excluded.
|
||||
|
||||
The host had a Ryzen AI 9 HX 470 (12 cores/24 threads), 32 GiB RAM and a 1 TB UMIS RPJYJ1T24RLS1QWY disk. The operator identified the token as SafeNet 5110; the precise variant/firmware was not verified. Signing used SafeNet Authentication Client 10.9.4482.0, `eToken Base Cryptographic Provider` and the unchanged machine test preset's SignTool/timestamp service. Public signature removal alone used SDK 10.0.26100.0 SignTool. Mode-average host CPU was 7.6–8.9%, minimum sampled free RAM was 9.9 GiB, and no Runner.Worker was sampled; other Node processes shared the host.
|
||||
|
||||
Times below are batch wall seconds, including signing, timestamp completion and all file verification. They exclude preparation, lock waiting, build, cache lookup, package assembly and upload. File-system caches were uncontrolled; these are not cold/hot full-release measurements.
|
||||
|
||||
| Completion workers / hardware requests | Round 1 (s) | Round 2 (s) | Mean (s) | Reduction vs serial |
|
||||
|---|---:|---:|---:|---:|
|
||||
| 1 / 1 | 732.3 | 718.5 | 725.4 | — |
|
||||
| 2 / 2 | 398.3 | 394.6 | 396.5 | 45.3% |
|
||||
| 4 / 4 | 459.4 | 465.0 | 462.2 | 36.3% |
|
||||
| 4 / 1, separate control | 427.7 | 428.3 | 428.0 | 41.0% |
|
||||
|
||||
All 1,248 signatures, timestamps and final validations passed, with zero hardware failures/timeouts or timestamp retries. Original files and signature-cache payloads remained unchanged. The 2/2 mode was fastest; 4/4 was slower, while pipeline overlap alone delivered most of the observed benefit. This selects candidates for further release testing, not a production default or evidence of parallel operations inside the token. It does not establish a ten-minute complete release.
|
||||
|
||||
## Dev Note
|
||||
|
||||
None.
|
||||
@@ -0,0 +1,47 @@
|
||||
---
|
||||
description: "Windows 签名并发实测结果与更换 runner 后的复测方法。"
|
||||
---
|
||||
|
||||
# Windows 签名并发验收
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
## 摘要
|
||||
|
||||
更换 Windows runner 后,使用本记录选择签名并发参数。结果仅适用于实测的主机、UKey 和软件组合;更换后需重新验收。生产签名仍为串行。
|
||||
|
||||
## 目录
|
||||
|
||||
- [在另一台机器上复测](#retest-on-another-machine)
|
||||
- [实测参考](#measured-reference)
|
||||
|
||||
<a id="retest-on-another-machine"></a>
|
||||
|
||||
## 在另一台机器上复测
|
||||
|
||||
1. 按[经操作人员授权的实验流程](../../windows-signing/README.zh.md#verification),使用哈希固定的真实 runtime、应用和安装器文件集。各模式使用相同输入,保留原件,使用隔离副本且不复用签名缓存。
|
||||
2. 使用 `--corpus` 和 `--rounds 2` 比较完成任务数/硬件请求数为 1/1、2/2、4/4 的模式,第二轮反转顺序。对相同文件集使用 `--pipeline-control --rounds 2` 测量 4/1 对照组。保留流程中的首错停止和 interlock 处理。
|
||||
3. 记录日期、测试及产物版本、文件数/总大小/最大文件、CPU/内存/磁盘、UKey/客户端/provider/SignTool 版本、时间戳服务、并行任务和资源压力。保留两轮整批墙钟时间及失败/重试次数;详细输出保存在 Git 外且不含凭据。仅从全部验证通过的结果中选择候选参数,再用完整签名发布流程验收。结果接近时优先减少硬件请求;波动异常时增加轮次。
|
||||
|
||||
<a id="measured-reference"></a>
|
||||
|
||||
## 实测参考
|
||||
|
||||
2026-10-22,PR #6675 中的实验使用发布 run 39725804188 保留的文件,版本为 `0.2.1-rc.2.20261022.50.2`。每批包含 148 个 runtime 和 8 个应用/安装器签名目标:共 156 个文件,无签名总大小 849.2 MiB,最大文件 277.5 MiB。不含已删除的临时目标及中间产物的重复签名调用。
|
||||
|
||||
主机使用 Ryzen AI 9 HX 470(12 核/24 线程)、32 GiB 内存和 1 TB UMIS RPJYJ1T24RLS1QWY 磁盘。操作人员确认 UKey 为 SafeNet 5110,未核实具体变体/固件。签名使用 SafeNet Authentication Client 10.9.4482.0、`eToken Base Cryptographic Provider`,以及未变更的机器 test preset 中的 SignTool/时间戳服务。仅公开移除签名使用 SDK 10.0.26100.0 SignTool。各模式主机平均 CPU 为 7.6–8.9%,采样可用内存最低 9.9 GiB,未采样到 Runner.Worker;另有 Node 进程共享主机。
|
||||
|
||||
下表为整批墙钟秒数,包含签名、时间戳完成及全部文件验证。不含准备、等锁、构建、缓存查询、包组装和上传。文件系统缓存未受控,因此不是完整发布的冷/热缓存测量。
|
||||
|
||||
| 完成任务数 / 硬件请求数 | 第 1 轮(秒) | 第 2 轮(秒) | 平均(秒) | 相对串行减少 |
|
||||
|---|---:|---:|---:|---:|
|
||||
| 1 / 1 | 732.3 | 718.5 | 725.4 | — |
|
||||
| 2 / 2 | 398.3 | 394.6 | 396.5 | 45.3% |
|
||||
| 4 / 4 | 459.4 | 465.0 | 462.2 | 36.3% |
|
||||
| 4 / 1,单独对照组 | 427.7 | 428.3 | 428.0 | 41.0% |
|
||||
|
||||
共 1,248 次签名、时间戳及最终验证全部通过,硬件失败/超时及时间戳重试均为零。原文件和签名缓存内容保持不变。2/2 模式最快,4/4 更慢;仅流水线重叠已获得大部分实测收益。这些结果用于选择后续发布测试的候选参数,不是生产默认值,也不能证明 UKey 内部并行执行。它们不能证明完整发布达到十分钟。
|
||||
|
||||
## 开发备注
|
||||
|
||||
无。
|
||||
@@ -0,0 +1,66 @@
|
||||
# Exercise priority inheritance in disposable children without changing the test host or runner.
|
||||
[CmdletBinding()]
|
||||
param([switch]$Child, [switch]$Failure)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
if (-not $IsWindows) { throw 'Requires PowerShell 7 on Windows.' }
|
||||
$helper = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '../scripts/windows-packaging-priority.ps1'))
|
||||
if ($Failure) {
|
||||
Add-Type 'public static class DshPackagingPriority { public static int Read(int kind) { throw new System.InvalidOperationException("fixture priority access denied"); } }'
|
||||
$warnings = @()
|
||||
$report = & $helper -BestEffort -WarningVariable warnings 3>$null | ConvertFrom-Json
|
||||
if ($report.comparable -ne $false -or $warnings.Count -ne 1) { throw 'Missing degraded scheduling report.' }
|
||||
$rejected = $false
|
||||
try { & $helper } catch { $rejected = $true }
|
||||
if (-not $rejected) { throw 'Strict priority inspection swallowed failure.' }
|
||||
exit 0
|
||||
}
|
||||
if ($Child) {
|
||||
$null = & $helper -InspectOnly
|
||||
$self = [Diagnostics.Process]::GetCurrentProcess()
|
||||
try {
|
||||
$self.PriorityClass = [Diagnostics.ProcessPriorityClass]::BelowNormal
|
||||
[DshPackagingPriority]::Set(33, 1)
|
||||
[DshPackagingPriority]::Set(39, 2)
|
||||
$report = (& $helper | ConvertFrom-Json)
|
||||
if ($report.before.cpu -ne 'BelowNormal' -or $report.before.io -ne 1 -or $report.before.memory -ne 2) {
|
||||
throw 'Fixture did not establish background priority.'
|
||||
}
|
||||
if ($report.after.cpu -ne 'Normal' -or $report.after.io -ne 2 -or $report.after.memory -ne 5) {
|
||||
throw 'Packaging shell retained background priority.'
|
||||
}
|
||||
$inherited = (& (Join-Path $PSHOME 'pwsh.exe') -NoProfile -File $helper -InspectOnly | ConvertFrom-Json)
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Priority inspection child failed.' }
|
||||
if ($inherited.before.cpu -ne 'Normal' -or $inherited.before.io -ne 2 -or $inherited.before.memory -ne 5) {
|
||||
throw 'Packaging child did not inherit normal priority.'
|
||||
}
|
||||
} finally {
|
||||
$self.Dispose()
|
||||
}
|
||||
exit 0
|
||||
}
|
||||
$process = [Diagnostics.Process]::new()
|
||||
$process.StartInfo.FileName = Join-Path $PSHOME 'pwsh.exe'
|
||||
$process.StartInfo.UseShellExecute = $false
|
||||
$process.StartInfo.CreateNoWindow = $true
|
||||
$process.StartInfo.RedirectStandardError = $true
|
||||
foreach ($argument in @('-NoProfile', '-File', $PSCommandPath, '-Child')) {
|
||||
$process.StartInfo.ArgumentList.Add($argument)
|
||||
}
|
||||
$started = $false
|
||||
try {
|
||||
$started = $process.Start()
|
||||
if (-not $started) { throw 'Could not start priority test.' }
|
||||
$errorOutput = $process.StandardError.ReadToEndAsync()
|
||||
if (-not $process.WaitForExit(30000)) { throw 'Priority test exceeded 30 seconds.' }
|
||||
if (-not $errorOutput.Wait(5000)) { throw 'Priority test error stream did not close.' }
|
||||
if ($process.ExitCode -ne 0) { throw "Priority test failed: $($process.ExitCode): $($errorOutput.Result)" }
|
||||
} finally {
|
||||
if ($started -and -not $process.HasExited) {
|
||||
$process.Kill($true)
|
||||
$process.WaitForExit()
|
||||
}
|
||||
$process.Dispose()
|
||||
}
|
||||
& (Join-Path $PSHOME 'pwsh.exe') -NoProfile -File $PSCommandPath -Failure
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Priority failure policy test failed.' }
|
||||
Write-Output 'Packaging priority normalization, child inheritance and failure policy passed.'
|
||||
@@ -0,0 +1,140 @@
|
||||
# Fake local processes only; no network or signing hardware.
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$root = Join-Path ([IO.Path]::GetTempPath()) ('demo-network-' + [guid]::NewGuid().ToString('N'))
|
||||
$null = New-Item -ItemType Directory $root
|
||||
try {
|
||||
foreach ($case in @('ready','not-ready','locked','missing-executable','stale','reused','invalid-config','invalid-delay','bad-record','provider-exit','runner-failure','check-only','recovery','task-route')) {
|
||||
$base = Join-Path $root $case
|
||||
$runner = Join-Path $base 'runner'
|
||||
$network = Join-Path $base 'network'
|
||||
$null = New-Item -ItemType Directory -Path $network,(Join-Path $runner 'dsh-task') -Force
|
||||
@'
|
||||
Set-Content (Join-Path $PSScriptRoot 'child-pid') $PID
|
||||
Write-Output 'PRIVATE-OUTPUT-MUST-NOT-LEAK'
|
||||
Start-Sleep -Seconds 60
|
||||
'@ | Set-Content (Join-Path $network 'fake.ps1')
|
||||
@'
|
||||
param([string]$RunnerRoot,[string]$ConfigRoot)
|
||||
$network = Join-Path (Split-Path $PSScriptRoot) 'network'
|
||||
$deadline = [datetime]::UtcNow.AddSeconds(10)
|
||||
while (-not (Test-Path (Join-Path $network 'child-pid'))) {
|
||||
if ([datetime]::UtcNow -ge $deadline) { exit 9 }
|
||||
Start-Sleep -Milliseconds 20
|
||||
}
|
||||
if (Test-Path (Join-Path $network 'fail')) { exit 1 }
|
||||
exit 0
|
||||
'@ | Set-Content (Join-Path $runner 'cold-boot-smoke.ps1')
|
||||
"Set-Content (Join-Path `$PSScriptRoot 'listened') yes; exit 0" | Set-Content (Join-Path $runner 'dsh-task/start-runner.ps1')
|
||||
if ($case -eq 'runner-failure') { Set-Content (Join-Path $runner 'dsh-task/start-runner.ps1') "Set-Content (Join-Path `$PSScriptRoot 'listened') yes; exit 7" }
|
||||
if ($case -in @('recovery','task-route')) {
|
||||
@'
|
||||
param([string]$RunnerRoot,[string]$ConfigRoot,[switch]$CheckOnly)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$network = Join-Path $ConfigRoot 'network'
|
||||
if ($CheckOnly) {
|
||||
$deadline = [datetime]::UtcNow.AddSeconds(30)
|
||||
while (-not (Test-Path (Join-Path $network 'child-pid'))) {
|
||||
if ([datetime]::UtcNow -ge $deadline) { exit 9 }
|
||||
Start-Sleep -Milliseconds 20
|
||||
}
|
||||
exit 0
|
||||
}
|
||||
Set-Content (Join-Path $PSScriptRoot 'listened') $PID
|
||||
Write-Output 'RUNNER-STDOUT'
|
||||
[Console]::Error.WriteLine('RUNNER-STDERR')
|
||||
for ($iteration = 0; $iteration -lt 2; $iteration++) {
|
||||
$previous = [int](Get-Content (Join-Path $network 'child-pid'))
|
||||
$child = Get-Process -Id $previous
|
||||
try { $child.Kill(); $child.WaitForExit() } finally { $child.Dispose() }
|
||||
$deadline = [datetime]::UtcNow.AddSeconds(30)
|
||||
do {
|
||||
if ([datetime]::UtcNow -ge $deadline) { throw 'Network process was not recovered.' }
|
||||
Start-Sleep -Milliseconds 50
|
||||
$next = Get-Content (Join-Path $network 'child-pid') -Raw
|
||||
} while (-not $next -or [int]$next -eq $previous)
|
||||
if ([int](Get-Content (Join-Path $PSScriptRoot 'listened')) -ne $PID) { throw 'Runner was replaced during recovery.' }
|
||||
}
|
||||
Write-Output 'RUNNER-SURVIVED-RECOVERY'
|
||||
exit 0
|
||||
'@ | Set-Content (Join-Path $runner 'dsh-task/start-runner.ps1')
|
||||
}
|
||||
if ($case -eq 'provider-exit') {
|
||||
Set-Content (Join-Path $network 'fake.ps1') 'Set-Content (Join-Path $PSScriptRoot "child-pid") $PID; exit 0'
|
||||
@'
|
||||
$network = Join-Path (Split-Path $PSScriptRoot) 'network'
|
||||
$deadline = [datetime]::UtcNow.AddSeconds(10)
|
||||
do {
|
||||
if ([datetime]::UtcNow -ge $deadline) { throw 'Provider exit barrier timed out.' }
|
||||
Start-Sleep -Milliseconds 20
|
||||
$record = Join-Path $network 'child-pid'
|
||||
} while (-not (Test-Path $record) -or (Get-Process -Id ([int](Get-Content $record)) -ErrorAction SilentlyContinue))
|
||||
exit 0
|
||||
'@ | Set-Content (Join-Path $runner 'cold-boot-smoke.ps1')
|
||||
}
|
||||
$exe = if ($case -eq 'missing-executable') { Join-Path $network 'missing.exe' } else { Join-Path $PSHOME 'pwsh.exe' }
|
||||
@{executable=$exe;arguments=@('-NoProfile','-File',(Join-Path $network 'fake.ps1'));restartDelaySeconds=1} | ConvertTo-Json | Set-Content (Join-Path $network 'launch.json')
|
||||
if ($case -eq 'invalid-config') { Set-Content (Join-Path $network 'launch.json') '{"executable":"PRIVATE-OUTPUT","arguments":[],"unknown":true}' }
|
||||
if ($case -eq 'invalid-delay') { @{executable=$exe;arguments=@();restartDelaySeconds=0} | ConvertTo-Json | Set-Content (Join-Path $network 'launch.json') }
|
||||
if ($case -eq 'bad-record') { Set-Content (Join-Path $network 'process.json') '{"pid":0,"started":1}' }
|
||||
if ($case -eq 'not-ready') { Set-Content (Join-Path $network 'fail') yes }
|
||||
$held = if ($case -eq 'locked') { [IO.File]::Open((Join-Path $network 'supervisor.lock'),'OpenOrCreate','ReadWrite','None') } else { $null }
|
||||
$stale = $null
|
||||
try {
|
||||
if ($case -in @('stale','reused')) {
|
||||
$info = [Diagnostics.ProcessStartInfo]::new($exe)
|
||||
$info.UseShellExecute = $false
|
||||
$info.CreateNoWindow = $true
|
||||
foreach ($argument in @('-NoProfile','-Command','Start-Sleep -Seconds 60')) { $info.ArgumentList.Add($argument) }
|
||||
$stale = [Diagnostics.Process]::Start($info)
|
||||
@{pid=$stale.Id;started=($stale.StartTime.ToUniversalTime().Ticks + $(if($case -eq 'reused'){1}else{0}))} | ConvertTo-Json | Set-Content (Join-Path $network 'process.json')
|
||||
}
|
||||
[string[]]$options = @(if ($case -eq 'check-only') { '-CheckOnly' })
|
||||
if ($case -eq 'task-route') {
|
||||
$taskRoot = Join-Path $runner 'dsh-task'
|
||||
Copy-Item (Join-Path $PSScriptRoot '../scripts/run-windows-runner-task.ps1') (Join-Path $taskRoot 'run.ps1')
|
||||
Copy-Item (Join-Path $PSScriptRoot '../scripts/run-windows-runner-network-process.ps1') $taskRoot
|
||||
Copy-Item (Join-Path $taskRoot 'start-runner.ps1') (Join-Path $taskRoot 'start.ps1')
|
||||
@{RunnerRoot=$runner;ConfigRoot=$base;NetworkRoot=$network;RestartCount=1} | ConvertTo-Json | Set-Content (Join-Path $taskRoot 'config.json')
|
||||
Set-Content (Join-Path $taskRoot 'attempts.txt') 0
|
||||
Set-Content (Join-Path $base 'runner-network.json') '{}'
|
||||
& (Join-Path $PSHOME 'pwsh.exe') -NoProfile -File (Join-Path $taskRoot 'run.ps1')
|
||||
if ($LASTEXITCODE -ne 1) { throw 'Task wrapper must retain its unexpected-exit result.' }
|
||||
$output = Get-Content (Get-ChildItem $taskRoot -Filter 'run-*.log').FullName -Raw
|
||||
if ([int](Get-Content (Join-Path $taskRoot 'attempts.txt')) -ne 1) { throw 'Network recovery consumed task launch budget.' }
|
||||
} else {
|
||||
$output = & (Join-Path $PSHOME 'pwsh.exe') -NoProfile -File (Join-Path $PSScriptRoot '../scripts/run-windows-runner-network-process.ps1') -RunnerRoot $runner -ConfigRoot $base -NetworkRoot $network -ReadinessScript (Join-Path $runner 'cold-boot-smoke.ps1') -StartScript (Join-Path $runner 'dsh-task/start-runner.ps1') @options 2>&1 | Out-String
|
||||
}
|
||||
if (($LASTEXITCODE -eq 0) -ne ($case -in @('ready','stale','reused','check-only','recovery'))) { throw "Unexpected exit: ${case}: $output" }
|
||||
if ((Test-Path (Join-Path $runner 'dsh-task/listened')) -ne ($case -in @('ready','stale','reused','runner-failure','recovery','task-route'))) { throw "Listener admission failed: $case" }
|
||||
if ($case -in @('recovery','task-route')) {
|
||||
foreach ($expected in @('RUNNER-STDOUT','RUNNER-STDERR','RUNNER-SURVIVED-RECOVERY','Network process exited','Network process restarted (attempt 2)')) {
|
||||
if (-not $output.Contains($expected)) { throw "Missing recovery evidence: $expected" }
|
||||
}
|
||||
}
|
||||
if ($case -eq 'stale' -and -not $stale.HasExited) { throw 'Owned stale provider was not reaped' }
|
||||
if ($case -eq 'reused' -and $stale.HasExited) { throw 'A reused PID was killed' }
|
||||
if ($output.Contains('PRIVATE-OUTPUT')) { throw 'Provider output leaked' }
|
||||
if (Test-Path (Join-Path $network 'child-pid')) {
|
||||
$childId = [int](Get-Content (Join-Path $network 'child-pid'))
|
||||
if (Get-Process -Id $childId -ErrorAction SilentlyContinue) { throw 'Child leaked' }
|
||||
}
|
||||
if ($case -ne 'bad-record' -and (Test-Path (Join-Path $network 'process.json'))) { throw 'Ownership record leaked' }
|
||||
if ($case -eq 'bad-record' -and -not (Test-Path (Join-Path $network 'process.json'))) { throw 'Invalid identity evidence was removed' }
|
||||
if (-not $held) {
|
||||
$released = [IO.File]::Open((Join-Path $network 'supervisor.lock'),'OpenOrCreate','ReadWrite','None')
|
||||
$released.Dispose()
|
||||
}
|
||||
Write-Output "PASS: $case"
|
||||
} finally {
|
||||
if ($stale) {
|
||||
if (-not $stale.HasExited) { $stale.Kill(); $stale.WaitForExit() }
|
||||
$stale.Dispose()
|
||||
}
|
||||
if ($held) { $held.Dispose() }
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if ([IO.Path]::GetDirectoryName($root).TrimEnd('\') -ne [IO.Path]::GetTempPath().TrimEnd('\')) { throw 'Unsafe cleanup path' }
|
||||
Remove-Item -LiteralPath $root -Recurse -Force
|
||||
}
|
||||
exit 0
|
||||
@@ -0,0 +1,117 @@
|
||||
# Isolated proxy fixture; no external requests, runner registration or signing hardware.
|
||||
param([switch]$CheckStartup)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
. (Join-Path $PSScriptRoot '../scripts/windows-runner-network.ps1')
|
||||
$root = Join-Path ([IO.Path]::GetTempPath()) ('dsh-network-' + [guid]::NewGuid().ToString('N'))
|
||||
$null = New-Item -ItemType Directory -Path $root
|
||||
$null = New-Item -ItemType Directory -Path (Join-Path $root 'tools')
|
||||
$pwshLink = Join-Path $root 'tools/pwsh-7.6.6'
|
||||
if ($CheckStartup) { $null = New-Item -ItemType Junction -Path $pwshLink -Target $PSHOME }
|
||||
$server = $null
|
||||
$saved = @{}
|
||||
foreach ($name in @('HTTP_PROXY','HTTPS_PROXY','NO_PROXY','NODE_USE_ENV_PROXY')) { $saved[$name] = [Environment]::GetEnvironmentVariable($name,'Process') }
|
||||
try {
|
||||
if ($null -ne (Read-RunnerNetwork $root)) { throw 'Absent configuration changed behavior.' }
|
||||
$fixture = Join-Path $root 'proxy.cjs'
|
||||
@'
|
||||
const http = require('node:http');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const root = process.argv[2];
|
||||
const server = http.createServer((req, res) => {
|
||||
fs.appendFileSync(path.join(root, 'requests'), req.method + ' ' + req.url + '\n');
|
||||
if (req.url.includes('/hang')) return;
|
||||
res.writeHead(req.url.includes('/fail') ? 503 : 200);
|
||||
res.end();
|
||||
});
|
||||
server.on('connect', (req, socket) => {
|
||||
socket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
|
||||
server.emit('connection', socket);
|
||||
});
|
||||
server.listen(0, '127.0.0.1', () => fs.writeFileSync(path.join(root, 'port'), String(server.address().port)));
|
||||
'@ | Set-Content -LiteralPath $fixture
|
||||
$info = [Diagnostics.ProcessStartInfo]::new((Get-Command node).Source)
|
||||
$info.UseShellExecute = $false
|
||||
$info.CreateNoWindow = $true
|
||||
$info.ArgumentList.Add($fixture)
|
||||
$info.ArgumentList.Add($root)
|
||||
$server = [Diagnostics.Process]::Start($info)
|
||||
$deadline = [DateTime]::UtcNow.AddSeconds(30)
|
||||
while (-not (Test-Path (Join-Path $root 'port'))) {
|
||||
if ($server.HasExited -or [DateTime]::UtcNow -gt $deadline) { throw 'Proxy fixture did not become ready.' }
|
||||
Start-Sleep -Milliseconds 50
|
||||
}
|
||||
$port = Get-Content (Join-Path $root 'port') -Raw
|
||||
$config = @{proxyUrl="http://127.0.0.1:$port";noProxy='';serviceName='';probeUrl='http://runner-probe.invalid/ready';attempts=2;timeoutSeconds=2;retrySeconds=1}
|
||||
function Write-Config { $config | ConvertTo-Json | Set-Content (Join-Path $root 'runner-network.json') }
|
||||
Write-Config
|
||||
$parsed = Read-RunnerNetwork $root
|
||||
Set-RunnerNetwork $parsed
|
||||
Wait-RunnerNetwork $parsed
|
||||
if ($env:NODE_USE_ENV_PROXY -ne '1' -or $env:HTTP_PROXY -ne $config.proxyUrl -or $env:HTTPS_PROXY -ne $config.proxyUrl) { throw 'Proxy environment was not applied.' }
|
||||
# Full startup requires the operator's installed packaging toolchain and persistent PATH.
|
||||
if ($CheckStartup) {
|
||||
$output = & (Join-Path $PSHOME 'pwsh.exe') -NoProfile -File (Join-Path $PSScriptRoot '../scripts/start-windows-packaging-runner.ps1') -RunnerRoot $root -ConfigRoot $root -CheckOnly 2>&1 | Out-String
|
||||
if ($LASTEXITCODE -ne 0 -or -not $output.Contains('Runner network dependency ready')) { throw "Startup integration failed: $output" }
|
||||
if ((Get-Content (Join-Path $root 'requests')).Count -ne 2) { throw 'Startup did not reach the configured proxy.' }
|
||||
Clear-Content (Join-Path $root 'requests')
|
||||
Wait-RunnerNetwork $parsed
|
||||
}
|
||||
$config.probeUrl = 'http://runner-probe.invalid/fail'
|
||||
Write-Config
|
||||
$failed = $false
|
||||
try { Wait-RunnerNetwork (Read-RunnerNetwork $root) } catch { $failed = $_.Exception.Message.StartsWith('Runner network dependency unavailable;') }
|
||||
if (-not $failed -or (Get-Content (Join-Path $root 'requests')).Count -ne 3) { throw 'Failed readiness did not stop at its budget.' }
|
||||
$config.serviceName = 'fixture-service'
|
||||
$config.probeUrl = 'http://runner-probe.invalid/ready'
|
||||
$script:serviceChecks = 0
|
||||
function Get-Service {
|
||||
param($Name)
|
||||
$script:serviceChecks++
|
||||
[pscustomobject]@{ Status = if ($script:serviceChecks -eq 1) { 'Stopped' } else { 'Running' } }
|
||||
}
|
||||
try {
|
||||
Wait-RunnerNetwork $config
|
||||
if ($script:serviceChecks -ne 2 -or (Get-Content (Join-Path $root 'requests')).Count -ne 4) { throw 'Service readiness did not contain probes.' }
|
||||
function Get-Service { throw 'fake-secret' }
|
||||
$failure = $null
|
||||
try { Wait-RunnerNetwork $config } catch { $failure = $_ }
|
||||
if (-not $failure -or $failure.ToString().Contains('fake-secret') -or (Get-Content (Join-Path $root 'requests')).Count -ne 4) { throw 'Missing service was not contained.' }
|
||||
} finally { Remove-Item Function:Get-Service }
|
||||
$config.serviceName = ''
|
||||
$config.proxyUrl = 'http://127.0.0.1:1'
|
||||
$config.noProxy = '127.0.0.1'
|
||||
$config.probeUrl = "http://127.0.0.1:$port/ready"
|
||||
Set-RunnerNetwork $config
|
||||
Wait-RunnerNetwork $config
|
||||
if ((Get-Content (Join-Path $root 'requests')).Count -ne 5) { throw 'NO_PROXY did not bypass the unavailable proxy.' }
|
||||
$config.probeUrl = "http://127.0.0.1:$port/hang"
|
||||
$config.attempts = 1
|
||||
$config.timeoutSeconds = 1
|
||||
$failure = $null
|
||||
try { Wait-RunnerNetwork $config } catch { $failure = $_ }
|
||||
if (-not $failure -or (Get-Content (Join-Path $root 'requests')).Count -ne 6) { throw 'Unresponsive endpoint did not terminate its probe.' }
|
||||
foreach ($invalid in @('extra','credential','budget','type','wildcard','malformed')) {
|
||||
$bad = $config.Clone()
|
||||
switch ($invalid) {
|
||||
'extra' { $bad.unknown = 'invalid' }
|
||||
'credential' { $bad.proxyUrl = 'http://fake-secret:fake-secret@localhost:1' }
|
||||
'budget' { $bad.attempts = 0 }
|
||||
'type' { $bad.retrySeconds = '2' }
|
||||
'wildcard' { $bad.serviceName = '*' }
|
||||
}
|
||||
$json = if ($invalid -eq 'malformed') { '{fake-secret' } else { $bad | ConvertTo-Json }
|
||||
$json | Set-Content (Join-Path $root 'runner-network.json')
|
||||
$failure = $null
|
||||
try { $null = Read-RunnerNetwork $root } catch { $failure = $_ }
|
||||
if (-not $failure -or $failure.ToString().Contains('fake-secret')) { throw 'Invalid configuration accepted or disclosed.' }
|
||||
}
|
||||
Write-Output "PASS: absent config, proxy routing, Node proxy opt-in, service readiness, bounded failure and redacted invalid config; startup integration=$CheckStartup."
|
||||
} finally {
|
||||
foreach ($name in $saved.Keys) { [Environment]::SetEnvironmentVariable($name,$saved[$name],'Process') }
|
||||
if ($server) { if (-not $server.HasExited) { $server.Kill($true); $server.WaitForExit() }; $server.Dispose() }
|
||||
if ($CheckStartup) { [IO.Directory]::Delete($pwshLink) }
|
||||
if ([IO.Path]::GetDirectoryName([IO.Path]::GetFullPath($root)).TrimEnd('\') -ne [IO.Path]::GetTempPath().TrimEnd('\')) { throw 'Fixture cleanup escaped the temporary directory.' }
|
||||
Remove-Item -LiteralPath $root -Recurse -Force
|
||||
}
|
||||
exit 0
|
||||
@@ -7,13 +7,18 @@ $source = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '../scripts'))
|
||||
$runner = Join-Path $root 'runner with spaces'
|
||||
$null = New-Item -ItemType Directory -Path $runner
|
||||
Set-Content -LiteralPath (Join-Path $runner '.runner') -Value '{}'
|
||||
foreach ($file in @('windows-runner-task.ps1','run-windows-runner-task.ps1','windows-runner-launcher.cs')) { Copy-Item -LiteralPath (Join-Path $source $file) -Destination $root }
|
||||
foreach ($file in @('windows-runner-task.ps1','run-windows-runner-task.ps1','windows-runner-launcher.cs','windows-runner-network.ps1','run-windows-runner-network-process.ps1')) { Copy-Item -LiteralPath (Join-Path $source $file) -Destination $root }
|
||||
$manager = Join-Path $root 'windows-runner-task.ps1'
|
||||
@'
|
||||
param([string]$RunnerRoot, [string]$ConfigRoot, [switch]$CheckOnly)
|
||||
if ($CheckOnly) { exit 0 }
|
||||
Add-Type -TypeDefinition 'using System; using System.Runtime.InteropServices; public static class ConsoleProbe { [DllImport("kernel32.dll")] public static extern IntPtr GetConsoleWindow(); }'
|
||||
Add-Content -LiteralPath (Join-Path $RunnerRoot 'consoles.txt') -Value ([ConsoleProbe]::GetConsoleWindow().ToInt64())
|
||||
Add-Type -TypeDefinition 'using System; using System.Runtime.InteropServices; public static class SchedulingProbe { [DllImport("ntdll.dll")] static extern int NtQueryInformationProcess(IntPtr handle, int kind, out int value, int size, out int length); public static int Read(IntPtr handle, int kind) { int value, length; int status = NtQueryInformationProcess(handle, kind, out value, 4, out length); if (status != 0) throw new InvalidOperationException("Priority query failed: " + status); return value; } }'
|
||||
$process = [Diagnostics.Process]::GetCurrentProcess()
|
||||
try {
|
||||
@{ Cpu = $process.PriorityClass.ToString(); Io = [SchedulingProbe]::Read($process.Handle, 33); Memory = [SchedulingProbe]::Read($process.Handle, 39) } | ConvertTo-Json -Compress | Add-Content -LiteralPath (Join-Path $RunnerRoot 'priorities.jsonl')
|
||||
} finally { $process.Dispose() }
|
||||
Add-Content -LiteralPath (Join-Path $RunnerRoot 'starts.txt') -Value $PID
|
||||
if (-not (Test-Path -LiteralPath (Join-Path $RunnerRoot 'failed-once'))) {
|
||||
Set-Content -LiteralPath (Join-Path $RunnerRoot 'failed-once') -Value 'yes'
|
||||
@@ -50,6 +55,7 @@ try {
|
||||
$peOffset = [BitConverter]::ToInt32($image, 0x3c)
|
||||
if ([BitConverter]::ToUInt16($image, $peOffset + 24 + 68) -ne 2) { throw 'Launcher must use the Windows GUI subsystem.' }
|
||||
if ($task.State -ne 'Disabled' -or $task.Settings.ExecutionTimeLimit -ne 'PT0S' -or $task.Settings.MultipleInstances -ne 'IgnoreNew') { throw 'Unexpected task scheduling settings.' }
|
||||
if ($task.Settings.Priority -ne 4) { throw 'Runner task must use normal CPU, I/O and memory priority.' }
|
||||
Invoke-Manager install -ExpectFailure
|
||||
$runScript = Join-Path $runner 'dsh-task/run.ps1'
|
||||
$originalRun = Get-Content -LiteralPath $runScript -Raw
|
||||
@@ -75,6 +81,8 @@ if (-not (Test-Path -LiteralPath (Join-Path $PSScriptRoot 'inherited-writer-star
|
||||
if ([int](Get-Content -LiteralPath (Join-Path $runner 'dsh-task/attempts.txt') -Raw) -ne 2) { throw 'Drain recovery did not consume the normal restart budget.' }
|
||||
$consoles = @(Get-Content -LiteralPath (Join-Path $runner 'consoles.txt'))
|
||||
if ($consoles.Count -ne 2 -or @($consoles | Where-Object { $_ -ne '0' }).Count) { throw 'Initial or recovered runner allocated a console.' }
|
||||
$priorities = @(Get-Content -LiteralPath (Join-Path $runner 'priorities.jsonl') | ForEach-Object { $_ | ConvertFrom-Json })
|
||||
if ($priorities.Count -ne 2 -or @($priorities | Where-Object { $_.Cpu -ne 'Normal' -or $_.Io -ne 2 -or $_.Memory -ne 5 }).Count) { throw 'Initial or recovered runner inherited background scheduling priority.' }
|
||||
$childId = [int]@(Get-Content -LiteralPath $starts)[-1]
|
||||
$child = Get-Process -Id $childId
|
||||
Invoke-Manager start
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
/** PE discovery keeps content checks and drains bounded file reads before failure. */
|
||||
import { mkdir, mkdtemp, rm, writeFile, type FileHandle } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { setImmediate } from 'node:timers/promises'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { windowsRuntimeCode } from '../scripts/windows-runtime-signature.mjs'
|
||||
|
||||
const hooks = vi.hoisted(() => ({
|
||||
opened: undefined as ((handle: FileHandle) => void) | undefined,
|
||||
}))
|
||||
|
||||
vi.mock('node:fs/promises', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs/promises')>()
|
||||
return {
|
||||
...actual,
|
||||
open: async (...args: Parameters<typeof actual.open>) => {
|
||||
const handle = await actual.open(...args)
|
||||
hooks.opened?.(handle)
|
||||
return handle
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const roots: string[] = []
|
||||
|
||||
async function fixture(entries: Record<string, string | Buffer>): Promise<string> {
|
||||
const root = await mkdtemp(join(tmpdir(), 'windows-runtime-code-'))
|
||||
roots.push(root)
|
||||
for (const [name, contents] of Object.entries(entries)) {
|
||||
const path = join(root, name)
|
||||
await mkdir(dirname(path), { recursive: true })
|
||||
await writeFile(path, contents)
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
function executable(): Buffer {
|
||||
const bytes = Buffer.alloc(128)
|
||||
bytes.writeUInt16LE(0x5a4d, 0)
|
||||
bytes.writeUInt32LE(64, 0x3c)
|
||||
bytes.writeUInt32LE(0x4550, 64)
|
||||
return bytes
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
hooks.opened = undefined
|
||||
vi.restoreAllMocks()
|
||||
for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('discovers extensionless PE files in nested directories and excludes foreign native modules', async () => {
|
||||
const root = await fixture({
|
||||
'nested/deeper/extensionless': executable(),
|
||||
'nested/runtime.node': executable(),
|
||||
'a.exe': executable(),
|
||||
'nested/deeper/foreign.node': Buffer.from([0x7f, 0x45, 0x4c, 0x46]),
|
||||
'readme.txt': 'ordinary data',
|
||||
})
|
||||
expect(await windowsRuntimeCode(root)).toEqual([
|
||||
'nested/deeper/extensionless', 'nested/runtime.node', 'a.exe',
|
||||
].map(name => join(root, name)).sort())
|
||||
})
|
||||
|
||||
it.each([
|
||||
['broken.exe', Buffer.from('not PE')],
|
||||
['nested/extensionless', Buffer.from('MZ')],
|
||||
['nested/broken.node', Buffer.from('MZ')],
|
||||
['nested/deeper/broken.dll', Buffer.alloc(64)],
|
||||
])('rejects malformed Windows code at %s', async (name, contents) => {
|
||||
const root = await fixture({ [name]: contents })
|
||||
await expect(windowsRuntimeCode(root)).rejects.toThrow(`Windows code: invalid PE file: ${join(root, name)}`)
|
||||
})
|
||||
|
||||
it('limits open files to eight and drains pending closes before rejecting a read failure', async () => {
|
||||
const root = await fixture(Object.fromEntries(Array.from({ length: 24 }, (_, index) => [
|
||||
`${String(index).padStart(2, '0')}.txt`, 'ordinary data',
|
||||
])))
|
||||
const releaseReads = Promise.withResolvers<undefined>()
|
||||
const releaseCloses = Promise.withResolvers<undefined>()
|
||||
const failedClosed = Promise.withResolvers<undefined>()
|
||||
const failure = new Error('controlled PE header read failure')
|
||||
let opened = 0
|
||||
let active = 0
|
||||
let peak = 0
|
||||
let settled = false
|
||||
hooks.opened = (handle) => {
|
||||
const index = opened++
|
||||
active++
|
||||
peak = Math.max(peak, active)
|
||||
const close = handle.close.bind(handle)
|
||||
vi.spyOn(handle, 'read').mockImplementationOnce(async () => {
|
||||
await releaseReads.promise
|
||||
if (index === 0) throw failure
|
||||
return { bytesRead: 0, buffer: Buffer.alloc(64) }
|
||||
})
|
||||
vi.spyOn(handle, 'close').mockImplementationOnce(async () => {
|
||||
if (index !== 0) await releaseCloses.promise
|
||||
await close()
|
||||
active--
|
||||
if (index === 0) failedClosed.resolve(undefined)
|
||||
})
|
||||
}
|
||||
const operation = windowsRuntimeCode(root).then(
|
||||
(result) => { settled = true; return result },
|
||||
(error: unknown) => { settled = true; return error },
|
||||
)
|
||||
try {
|
||||
await vi.waitFor(() => { expect(opened).toBe(8) })
|
||||
expect(active).toBe(8)
|
||||
releaseReads.resolve(undefined)
|
||||
await failedClosed.promise
|
||||
await setImmediate()
|
||||
expect(settled).toBe(false)
|
||||
expect(opened).toBe(8)
|
||||
expect(active).toBe(7)
|
||||
releaseCloses.resolve(undefined)
|
||||
expect(await operation).toBe(failure)
|
||||
expect(opened).toBe(8)
|
||||
expect(peak).toBe(8)
|
||||
expect(active).toBe(0)
|
||||
} finally {
|
||||
hooks.opened = undefined
|
||||
releaseReads.resolve(undefined)
|
||||
releaseCloses.resolve(undefined)
|
||||
await operation
|
||||
}
|
||||
})
|
||||
@@ -317,7 +317,9 @@ describe('Windows token signing', () => {
|
||||
'C:\\release\\setup.exe',
|
||||
['token-secret'],
|
||||
)
|
||||
expect(failure.message).toBe('Windows release signing failed for C:\\release\\setup.exe (exit 1): provider rejected <redacted>')
|
||||
expect(failure.message).toContain('Windows release signing failed for C:\\release\\setup.exe (exit 1): provider rejected <redacted>')
|
||||
expect(failure.message).toContain('After replacing the UKey')
|
||||
expect(failure.message).toContain('does not establish device lock status')
|
||||
expect(failure.message).not.toContain('token-secret')
|
||||
expect(failure).not.toHaveProperty('cause')
|
||||
expect(failure).not.toHaveProperty('cmd')
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
/** Operator-only comparison of serial signing and bounded concurrent signature completion. */
|
||||
import { execFile } from 'node:child_process'
|
||||
import { createHash, X509Certificate } from 'node:crypto'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { copyFile, mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { homedir, cpus, totalmem, freemem } from 'node:os'
|
||||
import { extname, isAbsolute, join, resolve } from 'node:path'
|
||||
import { parseArgs, promisify } from 'node:util'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { loadDesktopPackageEnvironment } from '../scripts/desktop-package-environment.mjs'
|
||||
import { createPackagingRun, recordPackagingEvent } from '../scripts/packaging-run.mjs'
|
||||
import { beginWindowsSigningAttempt } from '../scripts/windows-signing-state.mjs'
|
||||
import { withWindowsSigningStage } from '../scripts/windows-signing-stage.mjs'
|
||||
import { inspectWindowsTokenIdentity, windowsTokenIdentityFailure } from '../scripts/windows-token-identity.ts'
|
||||
import { buildWindowsSigningEnvironment, createWindowsTokenSigner, scrubWindowsSigningEnvironment } from '../scripts/windows-sign.mjs'
|
||||
import { inspectWindowsRuntimeSignature } from '../scripts/windows-runtime-signature.mjs'
|
||||
import { completeWindowsSignature, normalizeWindowsSignature } from '../scripts/windows-timestamp.mjs'
|
||||
import { runSigningExperimentBatch } from './helpers/windows-signing-experiment.ts'
|
||||
import { prepareSigningCorpus } from './helpers/windows-signing-corpus.ts'
|
||||
|
||||
const APP_ROOT = fileURLToPath(new URL('..', import.meta.url))
|
||||
const MODES = [
|
||||
{ name: 'serial', jobs: 1, hardwareConcurrency: 1 },
|
||||
{ name: 'pipeline', jobs: 2, hardwareConcurrency: 1 },
|
||||
{ name: 'hardware2', jobs: 2, hardwareConcurrency: 2 },
|
||||
] as const
|
||||
const CORPUS_MODES = [
|
||||
{ name: 'serial', jobs: 1, hardwareConcurrency: 1 },
|
||||
{ name: 'hardware2', jobs: 2, hardwareConcurrency: 2 },
|
||||
{ name: 'hardware4', jobs: 4, hardwareConcurrency: 4 },
|
||||
] as const
|
||||
|
||||
/**
|
||||
* Run an explicitly opted-in hardware experiment, retaining unsigned inputs, signed copies and audit records.
|
||||
* @param args CLI arguments; omission uses the current process arguments.
|
||||
* @returns Resolves after all requested batches verify; a failure during a batch retains its interlock and stops later modes.
|
||||
*/
|
||||
export async function runWindowsSigningConcurrencyExperiment(args = process.argv.slice(2)): Promise<void> {
|
||||
const { values } = parseArgs({ args, options: {
|
||||
execute: { type: 'boolean' }, 'config-root': { type: 'string' }, rounds: { type: 'string', default: '2' },
|
||||
corpus: { type: 'string' }, 'pipeline-control': { type: 'boolean' }, 'strip-tool': { type: 'string' },
|
||||
} })
|
||||
if (!values.execute) {
|
||||
console.log('Operator hardware experiment: node apps/desktop/tests/windows-signing-concurrency.perf.ts --execute --config-root <absolute-machine-packaging-directory> [--rounds 1|2] [--corpus <absolute-manifest.json>] [--strip-tool <absolute-signtool.exe>] [--pipeline-control]')
|
||||
return
|
||||
}
|
||||
if (process.platform !== 'win32' || !values['config-root'] || !isAbsolute(values['config-root'])
|
||||
|| !['1', '2'].includes(values.rounds)) throw new Error('Requires Windows, an absolute config-root and rounds 1 or 2')
|
||||
if (values.corpus !== undefined && !isAbsolute(values.corpus)) throw new Error('Signing corpus manifest must be an absolute path')
|
||||
if (values['pipeline-control'] && !values.corpus) throw new Error('Pipeline control requires a real-file corpus')
|
||||
if (values['strip-tool'] !== undefined && (!values.corpus || !isAbsolute(values['strip-tool']))) throw new Error('Signature stripping override requires a corpus and an absolute SignTool path')
|
||||
const modes = values['pipeline-control'] ? [{ name: 'pipeline4', jobs: 4, hardwareConcurrency: 1 } as const]
|
||||
: values.corpus ? CORPUS_MODES : MODES
|
||||
const environment = loadDesktopPackageEnvironment('win32', {
|
||||
...process.env, DSH_PACKAGING_PRESET: 'test', DSH_PACKAGING_CONFIG_DIR: values['config-root'],
|
||||
})
|
||||
const certificateFile = environment.DSH_DESKTOP_WINDOWS_CER_FILE!
|
||||
const signTool = environment.DSH_DESKTOP_WINDOWS_SIGNTOOL!
|
||||
const stripTool = values['strip-tool'] ?? signTool
|
||||
const tokenPin = environment.DSH_DESKTOP_WINDOWS_TOKEN_PIN!
|
||||
const keyContainer = environment.DSH_DESKTOP_WINDOWS_KEY_CONTAINER!.trim()
|
||||
// Construction validates the same signing inputs; the production serial hook is never invoked here.
|
||||
createWindowsTokenSigner({ certificateFile, signTool, tokenPin, keyContainer })
|
||||
const certificate = await readFile(certificateFile)
|
||||
const leaf = new X509Certificate(certificate)
|
||||
if (Date.now() < Date.parse(leaf.validFrom) || Date.now() > Date.parse(leaf.validTo)) {
|
||||
throw new Error('Signing experiment requires a currently valid public certificate')
|
||||
}
|
||||
const thumbprint = leaf.fingerprint.replaceAll(':', '')
|
||||
const safeEnvironment = scrubWindowsSigningEnvironment(environment)
|
||||
delete safeEnvironment.NODE_OPTIONS
|
||||
delete safeEnvironment.NODE_PATH
|
||||
const run = createPackagingRun(join(APP_ROOT, '.desktop-build', 'qualification', 'signing-concurrency'), {
|
||||
kind: 'operator-signing-concurrency', certificateSha256: createHash('sha256').update(certificate).digest('hex'),
|
||||
node: process.version, cpu: cpus()[0]?.model, logicalCpus: cpus().length, memoryBytes: totalmem(),
|
||||
rounds: Number(values.rounds), corpus: values.corpus ?? null, stripTool, modes,
|
||||
cache: 'disabled', probeExecuted: false,
|
||||
}, { parallel: true, secrets: [tokenPin, keyContainer] })
|
||||
console.log(`SIGNING_EXPERIMENT_RECORD ${run.directory}`)
|
||||
const controller = new AbortController()
|
||||
const abort = (): void => { controller.abort(new Error('Operator interrupted the signing experiment')) }
|
||||
process.once('SIGINT', abort)
|
||||
process.once('SIGTERM', abort)
|
||||
const record = (event: object): void => { recordPackagingEvent(run.directory, event) }
|
||||
let success = false
|
||||
try {
|
||||
await withWindowsSigningStage({ stage: 'operator-signing-concurrency', signal: controller.signal, record }, async () => {
|
||||
if (existsSync(join(homedir(), '.dsh-desktop-signing', 'attempt.json'))) {
|
||||
throw new Error('Retained signing interlock requires operator inspection; experiment refused')
|
||||
}
|
||||
const identity = await inspectWindowsTokenIdentity({ certificate, container: keyContainer, environment: safeEnvironment })
|
||||
record({ type: 'experiment-public-identity', result: identity, pinSupplied: false })
|
||||
if (identity !== 'matched') throw new Error(windowsTokenIdentityFailure(identity))
|
||||
let templates: string[] = []
|
||||
if (values.corpus) {
|
||||
let removalIndex = 0
|
||||
const corpus = await prepareSigningCorpus(values.corpus, join(run.directory, 'unsigned'), async (path) => {
|
||||
await run.run(`remove-signature-${removalIndex++}`, stripTool, ['remove', '/s', path],
|
||||
{ cwd: run.directory, env: safeEnvironment, timeoutMs: 60_000 })
|
||||
}, controller.signal)
|
||||
await writeFile(join(run.directory, 'corpus.json'), `${JSON.stringify(corpus, null, 2)}\n`, { flag: 'wx' })
|
||||
templates = corpus.map(file => file.path)
|
||||
record({ type: 'experiment-corpus', files: corpus.length, bytes: corpus.reduce((sum, file) => sum + file.bytes, 0) })
|
||||
} else {
|
||||
const source = join(run.directory, 'probe.cs')
|
||||
await writeFile(source, 'internal static class SigningProbe { private static int Main() { return 0; } }\n', { flag: 'wx' })
|
||||
for (const size of [0, 1024 * 1024, 16 * 1024 * 1024]) {
|
||||
controller.signal.throwIfAborted()
|
||||
const resource = join(run.directory, `resource-${size}.bin`)
|
||||
if (size) await writeFile(resource, Buffer.alloc(size, 0x5a), { flag: 'wx' })
|
||||
const output = join(run.directory, `unsigned-${size}.exe`)
|
||||
await run.run(`compile-probe-${size}`, join(environment.SystemRoot ?? 'C:\\Windows', 'Microsoft.NET/Framework64/v4.0.30319/csc.exe'),
|
||||
['/nologo', '/target:exe', '/platform:x64', `/out:${output}`, ...(size ? [`/resource:${resource}`] : []), source],
|
||||
{ cwd: run.directory, env: safeEnvironment, timeoutMs: 30_000 })
|
||||
if ((await inspectWindowsRuntimeSignature(output)).status !== 'NotSigned') throw new Error('Probe must be unsigned')
|
||||
templates.push(output, output)
|
||||
}
|
||||
}
|
||||
const results: { round: number; mode: string; elapsedMs: number; files: number }[] = []
|
||||
for (let round = 1; round <= Number(values.rounds); round++) {
|
||||
const order = round % 2 ? modes : [...modes].reverse()
|
||||
for (const mode of order) {
|
||||
controller.signal.throwIfAborted()
|
||||
const directory = join(run.directory, `round-${round}-${mode.name}`)
|
||||
await mkdir(directory)
|
||||
const files: string[] = []
|
||||
for (const [index, template] of templates.entries()) {
|
||||
const file = join(directory, `probe-${index}${extname(template)}`)
|
||||
await copyFile(template, file)
|
||||
if (Buffer.compare(await readFile(template), await readFile(file)) !== 0) throw new Error('Signing experiment batch copy differs from its template')
|
||||
files.push(file)
|
||||
}
|
||||
record({ type: 'experiment-batch-plan', round, ...mode, files })
|
||||
const attempt = beginWindowsSigningAttempt({ runDirectory: run.directory, target: directory })
|
||||
let commandIndex = 0
|
||||
const started = performance.now()
|
||||
try {
|
||||
record({ type: 'experiment-resources', round, mode: mode.name, point: 'start', freeMemoryBytes: freemem(), cpuTimes: cpus().map(cpu => cpu.times), processMemory: process.memoryUsage() })
|
||||
await runSigningExperimentBatch(files, {
|
||||
...mode, signal: controller.signal,
|
||||
sign: async (_file, path) => {
|
||||
controller.signal.throwIfAborted()
|
||||
const stage = `experiment-sign-${round}-${mode.name}-${commandIndex++}`
|
||||
record({ type: 'experiment-hardware-start', stage, path })
|
||||
const interpreter = environment.ComSpec ?? join(environment.SystemRoot ?? 'C:\\Windows', 'System32', 'cmd.exe')
|
||||
await run.run(stage, interpreter,
|
||||
['/d', '/v:off', '/c', 'windows-sign.cmd'], {
|
||||
cwd: join(APP_ROOT, 'scripts'), timeoutMs: 60_000,
|
||||
env: buildWindowsSigningEnvironment(safeEnvironment, {
|
||||
certificateFile, signTool, tokenPin, keyContainer, path, isNest: false,
|
||||
}),
|
||||
})
|
||||
record({ type: 'experiment-hardware-exited', stage, path })
|
||||
const signature = await inspectWindowsRuntimeSignature(path)
|
||||
if (signature.status !== 'Valid' || signature.timestamped || signature.thumbprint?.toUpperCase() !== thumbprint.toUpperCase()) {
|
||||
throw new Error('Primary signature verification failed')
|
||||
}
|
||||
record({ type: 'experiment-hardware-verified', stage, path })
|
||||
},
|
||||
complete: async (path, sign) => {
|
||||
const fileStarted = performance.now()
|
||||
await completeWindowsSignature(path, {
|
||||
thumbprint, sign, inspect: inspectWindowsRuntimeSignature, evidenceDirectory: run.directory, record,
|
||||
normalize: file => normalizeWindowsSignature(file, signTool, safeEnvironment),
|
||||
timestamp: async (file) => {
|
||||
await promisify(execFile)(signTool, ['timestamp', '/v', '/tr', 'http://timestamp.digicert.com', '/td', 'sha256', file], {
|
||||
env: safeEnvironment, windowsHide: true, timeout: 60_000,
|
||||
})
|
||||
},
|
||||
})
|
||||
const signature = await inspectWindowsRuntimeSignature(path)
|
||||
if (signature.status !== 'Valid' || !signature.timestamped || signature.thumbprint?.toUpperCase() !== thumbprint.toUpperCase()) {
|
||||
throw new Error('Published probe signature verification failed')
|
||||
}
|
||||
record({ type: 'experiment-file-verified', path, ...signature, elapsedMs: performance.now() - fileStarted,
|
||||
freeMemoryBytes: freemem(), processMemory: process.memoryUsage(),
|
||||
sha256: createHash('sha256').update(await readFile(path)).digest('hex') })
|
||||
},
|
||||
})
|
||||
controller.signal.throwIfAborted()
|
||||
const result = { round, mode: mode.name, elapsedMs: performance.now() - started, files: files.length }
|
||||
record({ type: 'experiment-resources', round, mode: mode.name, point: 'end', freeMemoryBytes: freemem(), cpuTimes: cpus().map(cpu => cpu.times), processMemory: process.memoryUsage() })
|
||||
results.push(result)
|
||||
record({ type: 'experiment-batch-complete', ...result })
|
||||
await writeFile(join(run.directory, 'measurements.json'), `${JSON.stringify(results, null, 2)}\n`, { flush: true })
|
||||
attempt.success()
|
||||
console.log(`SIGNING_EXPERIMENT_SAMPLE ${JSON.stringify(result)}`)
|
||||
} catch (error) {
|
||||
attempt.failure(null, 'Signing concurrency experiment failed; inspect retained records before recovery')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
success = true
|
||||
} finally {
|
||||
process.removeListener('SIGINT', abort)
|
||||
process.removeListener('SIGTERM', abort)
|
||||
run.finish(success)
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] !== undefined && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
runWindowsSigningConcurrencyExperiment().catch(() => {
|
||||
// Child-process errors can embed arguments; the retained supervisor output is already redacted.
|
||||
console.error('Signing experiment stopped; inspect its retained record. No automatic hardware retry or interlock recovery.')
|
||||
process.exitCode = 1
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
/** Driver ownership tests replace every native signing and device boundary. */
|
||||
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { runWindowsSigningConcurrencyExperiment } from './windows-signing-concurrency.perf.ts'
|
||||
import { loadDesktopPackageEnvironment } from '../scripts/desktop-package-environment.mjs'
|
||||
import { createPackagingRun } from '../scripts/packaging-run.mjs'
|
||||
import { beginWindowsSigningAttempt } from '../scripts/windows-signing-state.mjs'
|
||||
import { inspectWindowsTokenIdentity } from '../scripts/windows-token-identity.ts'
|
||||
import { prepareSigningCorpus } from './helpers/windows-signing-corpus.ts'
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
root: '', environment: {} as NodeJS.ProcessEnv,
|
||||
identity: 'matched', failure: '' as '' | 'primary' | 'final' | 'audit',
|
||||
signed: new Set<string>(), completed: new Set<string>(), stages: [] as string[],
|
||||
events: [] as object[], successes: 0, failures: 0,
|
||||
removals: [] as { executable: string; args: readonly string[]; environment: NodeJS.ProcessEnv }[],
|
||||
}))
|
||||
|
||||
vi.mock('node:child_process', () => ({
|
||||
execFile: vi.fn(() => { throw new Error('Native subprocesses are forbidden in driver tests') }),
|
||||
}))
|
||||
vi.mock('node:os', async original => ({ ...await original<typeof import('node:os')>(), homedir: () => state.root }))
|
||||
vi.mock('node:crypto', async original => ({
|
||||
...await original<typeof import('node:crypto')>(),
|
||||
X509Certificate: class {
|
||||
fingerprint = 'A'.repeat(40)
|
||||
validFrom = 'Jan 1 2000 GMT'
|
||||
validTo = 'Jan 1 2100 GMT'
|
||||
},
|
||||
}))
|
||||
vi.mock('../scripts/desktop-package-environment.mjs', () => ({
|
||||
loadDesktopPackageEnvironment: vi.fn(() => state.environment),
|
||||
}))
|
||||
vi.mock('../scripts/windows-sign.mjs', async original => ({
|
||||
...await original<typeof import('../scripts/windows-sign.mjs')>(),
|
||||
createWindowsTokenSigner: vi.fn(() => { throw new Error('unconfigured constructor') }),
|
||||
}))
|
||||
vi.mock('../scripts/windows-token-identity.ts', () => ({
|
||||
inspectWindowsTokenIdentity: vi.fn(async () => state.identity),
|
||||
windowsTokenIdentityFailure: (result: string) => `Identity refused: ${result}`,
|
||||
}))
|
||||
vi.mock('./helpers/windows-signing-corpus.ts', () => ({
|
||||
prepareSigningCorpus: vi.fn(async () => {
|
||||
const files = []
|
||||
for (let index = 0; index < 6; index++) {
|
||||
const path = join(state.root, `real-${index}.dll`)
|
||||
await writeFile(path, 'inert real file')
|
||||
files.push({ path, bytes: 15, sha256: 'a'.repeat(64) })
|
||||
}
|
||||
return files
|
||||
}),
|
||||
}))
|
||||
vi.mock('../scripts/windows-signing-stage.mjs', () => ({
|
||||
withWindowsSigningStage: vi.fn(async (_options: object, operation: () => Promise<void>) => operation()),
|
||||
}))
|
||||
vi.mock('../scripts/packaging-run.mjs', () => ({
|
||||
createPackagingRun: vi.fn(() => ({
|
||||
directory: join(state.root, 'run'),
|
||||
finish: vi.fn(),
|
||||
run: vi.fn(async (stage: string, executable: string, args: readonly string[], options: { env: NodeJS.ProcessEnv }) => {
|
||||
state.stages.push(stage)
|
||||
if (stage.startsWith('compile-')) {
|
||||
await writeFile(args.find(arg => arg.startsWith('/out:'))!.slice(5), 'inert probe')
|
||||
} else if (stage.startsWith('remove-signature-')) {
|
||||
state.removals.push({ executable, args, environment: options.env })
|
||||
} else {
|
||||
state.signed.add(options.env.DSH_DESKTOP_WINDOWS_SIGN_TARGET!)
|
||||
}
|
||||
}),
|
||||
})),
|
||||
recordPackagingEvent: (_directory: string, event: object) => {
|
||||
if (state.failure === 'audit' && 'type' in event && event.type === 'experiment-file-verified') throw new Error('Audit failed')
|
||||
state.events.push(event)
|
||||
},
|
||||
}))
|
||||
vi.mock('../scripts/windows-signing-state.mjs', () => ({
|
||||
beginWindowsSigningAttempt: vi.fn((options: { runDirectory: string; target: string; stateDirectory?: string }) => {
|
||||
expect(options.stateDirectory).toBeUndefined()
|
||||
const lock = join(state.root, '.dsh-desktop-signing', 'attempt.json')
|
||||
mkdirSync(dirname(lock), { recursive: true })
|
||||
writeFileSync(lock, 'batch attempt', { flag: 'wx' })
|
||||
return {
|
||||
success: () => {
|
||||
const verified = state.events.filter(event => 'type' in event && event.type === 'experiment-file-verified'
|
||||
&& 'path' in event && typeof event.path === 'string' && dirname(event.path) === options.target)
|
||||
expect(verified).toHaveLength(6)
|
||||
expect(readFileSync(join(options.runDirectory, 'measurements.json'), 'utf8')).toContain('"files": 6')
|
||||
unlinkSync(lock)
|
||||
state.successes++
|
||||
},
|
||||
failure: () => { state.failures++ },
|
||||
}
|
||||
}),
|
||||
}))
|
||||
vi.mock('../scripts/windows-runtime-signature.mjs', () => ({
|
||||
inspectWindowsRuntimeSignature: vi.fn(async (path: string) => {
|
||||
if (!state.signed.has(path)) return { status: 'NotSigned', timestamped: false, thumbprint: null }
|
||||
const timestamped = state.completed.has(path)
|
||||
const failed = state.failure === (timestamped ? 'final' : 'primary')
|
||||
return { status: failed ? 'HashMismatch' : 'Valid', timestamped, thumbprint: 'A'.repeat(40) }
|
||||
}),
|
||||
}))
|
||||
vi.mock('../scripts/windows-timestamp.mjs', () => ({
|
||||
completeWindowsSignature: vi.fn(async (path: string, options: { sign: (path: string) => Promise<void> }) => {
|
||||
await options.sign(path)
|
||||
state.completed.add(path)
|
||||
}),
|
||||
normalizeWindowsSignature: vi.fn(() => { throw new Error('Native normalization is forbidden in driver tests') }),
|
||||
}))
|
||||
|
||||
const platform = Object.getOwnPropertyDescriptor(process, 'platform')!
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks()
|
||||
state.root = await mkdtemp(join(tmpdir(), 'signing-driver-'))
|
||||
mkdirSync(join(state.root, 'run'))
|
||||
state.identity = 'matched'; state.failure = ''; state.successes = 0; state.failures = 0
|
||||
state.signed.clear(); state.completed.clear(); state.stages = []; state.events = []; state.removals = []
|
||||
state.environment = {
|
||||
DSH_DESKTOP_WINDOWS_CER_FILE: join(state.root, 'certificate.cer'),
|
||||
DSH_DESKTOP_WINDOWS_SIGNTOOL: 'forbidden-signer', DSH_DESKTOP_WINDOWS_TOKEN_PIN: 'fixture-pin',
|
||||
DSH_DESKTOP_WINDOWS_KEY_CONTAINER: ' fixture-container ', API_KEY: 'fixture-secret', NODE_OPTIONS: 'forbidden-option',
|
||||
}
|
||||
await writeFile(state.environment.DSH_DESKTOP_WINDOWS_CER_FILE!, 'inert certificate')
|
||||
Object.defineProperty(process, 'platform', { value: 'win32', configurable: true })
|
||||
const { createWindowsTokenSigner } = await import('../scripts/windows-sign.mjs')
|
||||
vi.mocked(createWindowsTokenSigner).mockReturnValue(vi.fn(async () => { throw new Error('Production signer is forbidden') }))
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
})
|
||||
afterEach(async () => {
|
||||
Object.defineProperty(process, 'platform', platform)
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
await rm(state.root, { recursive: true, force: true, maxRetries: 3 })
|
||||
})
|
||||
|
||||
const execute = () => runWindowsSigningConcurrencyExperiment(['--execute', '--config-root', state.root, '--rounds', '1'])
|
||||
const lock = () => join(state.root, '.dsh-desktop-signing', 'attempt.json')
|
||||
|
||||
it('requires opt-in before reading configuration or querying a device', async () => {
|
||||
await runWindowsSigningConcurrencyExperiment([])
|
||||
expect(loadDesktopPackageEnvironment).not.toHaveBeenCalled()
|
||||
expect(inspectWindowsTokenIdentity).not.toHaveBeenCalled()
|
||||
expect(createPackagingRun).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['1999-01-01', '2101-01-01'])('rejects certificate validity at %s before querying or signing', async (date) => {
|
||||
vi.useFakeTimers({ toFake: ['Date'] }); vi.setSystemTime(new Date(date))
|
||||
await expect(execute()).rejects.toThrow('currently valid')
|
||||
expect(inspectWindowsTokenIdentity).not.toHaveBeenCalled()
|
||||
expect(beginWindowsSigningAttempt).not.toHaveBeenCalled()
|
||||
expect(state.stages).toEqual([])
|
||||
})
|
||||
|
||||
it.each(['certificate-mismatch', 'provider-unavailable', 'container-unavailable', 'unknown'])('refuses public identity %s before any signing', async (identity) => {
|
||||
state.identity = identity
|
||||
await expect(execute()).rejects.toThrow('Identity refused')
|
||||
expect(state.stages).toEqual([])
|
||||
expect(beginWindowsSigningAttempt).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('retains an existing default-account interlock without accessing the device', async () => {
|
||||
mkdirSync(dirname(lock()), { recursive: true }); writeFileSync(lock(), 'prior failure')
|
||||
await expect(execute()).rejects.toThrow('Retained signing interlock')
|
||||
expect(inspectWindowsTokenIdentity).not.toHaveBeenCalled()
|
||||
expect(readFileSync(lock(), 'utf8')).toBe('prior failure')
|
||||
})
|
||||
|
||||
it('completes all three modes and releases each default-account batch only after recording every verification', async () => {
|
||||
await execute()
|
||||
expect(state.successes).toBe(3)
|
||||
expect(state.failures).toBe(0)
|
||||
expect(state.signed.size).toBe(18)
|
||||
expect(existsSync(lock())).toBe(false)
|
||||
expect(beginWindowsSigningAttempt).toHaveBeenCalledTimes(3)
|
||||
expect(inspectWindowsTokenIdentity).toHaveBeenCalledWith(expect.objectContaining({
|
||||
container: 'fixture-container', environment: {},
|
||||
}))
|
||||
expect(JSON.stringify(state.events)).not.toMatch(/fixture-pin|fixture-secret|fixture-container/u)
|
||||
})
|
||||
|
||||
it('uses fresh real-file copies for 1, 2, 4 workers and reverses the second round', async () => {
|
||||
await runWindowsSigningConcurrencyExperiment(['--execute', '--config-root', state.root, '--corpus', join(state.root, 'manifest.json')])
|
||||
expect(state.successes).toBe(6)
|
||||
expect(state.signed.size).toBe(36)
|
||||
expect(state.stages.some(stage => stage.startsWith('compile-'))).toBe(false)
|
||||
expect(prepareSigningCorpus).toHaveBeenCalledOnce()
|
||||
expect(state.events.filter(event => 'type' in event && event.type === 'experiment-batch-plan')).toEqual([
|
||||
expect.objectContaining({ round: 1, jobs: 1, hardwareConcurrency: 1 }),
|
||||
expect.objectContaining({ round: 1, jobs: 2, hardwareConcurrency: 2 }),
|
||||
expect.objectContaining({ round: 1, jobs: 4, hardwareConcurrency: 4 }),
|
||||
expect.objectContaining({ round: 2, jobs: 4, hardwareConcurrency: 4 }),
|
||||
expect.objectContaining({ round: 2, jobs: 2, hardwareConcurrency: 2 }),
|
||||
expect.objectContaining({ round: 2, jobs: 1, hardwareConcurrency: 1 }),
|
||||
])
|
||||
expect(existsSync(lock())).toBe(false)
|
||||
})
|
||||
|
||||
it('offers a separate four-job control with only one hardware request', async () => {
|
||||
await runWindowsSigningConcurrencyExperiment(['--execute', '--config-root', state.root, '--rounds', '1', '--corpus', join(state.root, 'manifest.json'), '--pipeline-control'])
|
||||
expect(state.successes).toBe(1)
|
||||
expect(state.events).toContainEqual(expect.objectContaining({ type: 'experiment-batch-plan', jobs: 4, hardwareConcurrency: 1 }))
|
||||
})
|
||||
|
||||
it('refuses corpus preparation failure before beginning a hardware attempt', async () => {
|
||||
vi.mocked(prepareSigningCorpus).mockRejectedValueOnce(new Error('changed input'))
|
||||
await expect(runWindowsSigningConcurrencyExperiment(['--execute', '--config-root', state.root, '--corpus', join(state.root, 'manifest.json')])).rejects.toThrow('changed input')
|
||||
expect(beginWindowsSigningAttempt).not.toHaveBeenCalled()
|
||||
expect(state.signed.size).toBe(0)
|
||||
})
|
||||
|
||||
it('uses the stripping override only for public removal on a corpus copy', async () => {
|
||||
const copy = join(state.root, 'copy.exe')
|
||||
const stripTool = join(state.root, 'sdk-signtool.exe')
|
||||
vi.mocked(prepareSigningCorpus).mockImplementationOnce(async (_manifest, _directory, remove) => {
|
||||
await remove(copy)
|
||||
throw new Error('stop before hardware')
|
||||
})
|
||||
await expect(runWindowsSigningConcurrencyExperiment(['--execute', '--config-root', state.root, '--corpus', join(state.root, 'manifest.json'), '--strip-tool', stripTool])).rejects.toThrow('stop before hardware')
|
||||
expect(state.removals).toEqual([{ executable: stripTool, args: ['remove', '/s', copy], environment: {} }])
|
||||
expect(beginWindowsSigningAttempt).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['primary', 'final', 'audit'] as const)('retains the batch interlock and stops later modes after %s failure', async (failure) => {
|
||||
state.failure = failure
|
||||
await expect(execute()).rejects.toThrow(failure === 'audit' ? 'Audit failed' : 'signature verification failed')
|
||||
expect(state.successes).toBe(0)
|
||||
expect(state.failures).toBe(1)
|
||||
expect(beginWindowsSigningAttempt).toHaveBeenCalledOnce()
|
||||
expect(state.signed.size).toBe(1)
|
||||
expect(existsSync(lock())).toBe(true)
|
||||
expect(state.stages.some(stage => stage.includes('pipeline') || stage.includes('hardware2'))).toBe(false)
|
||||
})
|
||||
@@ -0,0 +1,71 @@
|
||||
/** Corpus preparation touches only private copies; public signature inspection is simulated. */
|
||||
import { createHash } from 'node:crypto'
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { prepareSigningCorpus } from './helpers/windows-signing-corpus.ts'
|
||||
import { inspectWindowsRuntimeSignature } from '../scripts/windows-runtime-signature.mjs'
|
||||
|
||||
vi.mock('../scripts/windows-runtime-signature.mjs', () => ({ inspectWindowsRuntimeSignature: vi.fn() }))
|
||||
let root: string
|
||||
let source: string
|
||||
let manifest: string
|
||||
const digest = (data: Buffer | string) => createHash('sha256').update(data).digest('hex')
|
||||
beforeEach(async () => {
|
||||
vi.resetAllMocks()
|
||||
root = await mkdtemp(join(tmpdir(), 'signing-corpus-'))
|
||||
source = join(root, 'original.dll')
|
||||
manifest = join(root, 'manifest.json')
|
||||
await writeFile(source, 'signed original')
|
||||
await writeFile(manifest, JSON.stringify([{ path: source, sha256: digest('signed original') }]))
|
||||
vi.mocked(inspectWindowsRuntimeSignature).mockResolvedValue({ status: 'NotSigned', timestamped: false, thumbprint: null })
|
||||
})
|
||||
afterEach(async () => { await rm(root, { recursive: true, force: true, maxRetries: 3 }) })
|
||||
|
||||
it('strips only a pinned private copy and preserves the original bytes', async () => {
|
||||
vi.mocked(inspectWindowsRuntimeSignature).mockResolvedValueOnce({ status: 'Valid', timestamped: true, thumbprint: 'A' })
|
||||
const remove = vi.fn(async (path: string) => { expect(path).not.toBe(source); await writeFile(path, 'unsigned copy') })
|
||||
const files = await prepareSigningCorpus(manifest, join(root, 'copies'), remove, new AbortController().signal)
|
||||
expect(remove).toHaveBeenCalledOnce()
|
||||
expect(files).toEqual([{ path: join(root, 'copies', '0.dll'), sha256: digest('unsigned copy'), bytes: 13 }])
|
||||
expect(await readFile(source, 'utf8')).toBe('signed original')
|
||||
})
|
||||
|
||||
it('accepts already unsigned input without invoking removal', async () => {
|
||||
const remove = vi.fn()
|
||||
const files = await prepareSigningCorpus(manifest, join(root, 'copies'), remove, new AbortController().signal)
|
||||
expect(remove).not.toHaveBeenCalled()
|
||||
expect(files[0]?.sha256).toBe(digest('signed original'))
|
||||
})
|
||||
|
||||
it.each([[], {}, [{ path: 'relative', sha256: 'a'.repeat(64) }], [{ path: 'ignored', sha256: 'invalid' }]])('rejects malformed manifest %j', async (value) => {
|
||||
await writeFile(manifest, JSON.stringify(value))
|
||||
const remove = vi.fn()
|
||||
await expect(prepareSigningCorpus(manifest, join(root, 'copies'), remove, new AbortController().signal)).rejects.toThrow('Signing corpus')
|
||||
expect(remove).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects source content changed since the manifest was created', async () => {
|
||||
await writeFile(source, 'changed source')
|
||||
await expect(prepareSigningCorpus(manifest, join(root, 'copies'), vi.fn(), new AbortController().signal)).rejects.toThrow('pinned digest')
|
||||
expect(inspectWindowsRuntimeSignature).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['HashMismatch', 'NotTrusted'])('rejects invalid source signature %s without removal', async (status) => {
|
||||
vi.mocked(inspectWindowsRuntimeSignature).mockResolvedValue({ status, timestamped: false, thumbprint: null })
|
||||
const remove = vi.fn()
|
||||
await expect(prepareSigningCorpus(manifest, join(root, 'copies'), remove, new AbortController().signal)).rejects.toThrow('invalid signature')
|
||||
expect(remove).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a copy whose signature removal did not finish', async () => {
|
||||
vi.mocked(inspectWindowsRuntimeSignature).mockResolvedValue({ status: 'Valid', timestamped: true, thumbprint: 'A' })
|
||||
await expect(prepareSigningCorpus(manifest, join(root, 'copies'), vi.fn(), new AbortController().signal)).rejects.toThrow('must be unsigned')
|
||||
})
|
||||
|
||||
it('stops before copying after cancellation', async () => {
|
||||
const controller = new AbortController(); controller.abort(new Error('cancelled'))
|
||||
await expect(prepareSigningCorpus(manifest, join(root, 'copies'), vi.fn(), controller.signal)).rejects.toThrow('cancelled')
|
||||
expect(inspectWindowsRuntimeSignature).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -0,0 +1,288 @@
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import { runSigningExperimentBatch } from './helpers/windows-signing-experiment.ts'
|
||||
|
||||
function barrier() {
|
||||
const pending = Promise.withResolvers<undefined>()
|
||||
return { promise: pending.promise, resolve: () => { pending.resolve(undefined) } }
|
||||
}
|
||||
const observe = (operation: Promise<void>) => operation.then(
|
||||
() => ({ ok: true as const }), (error: unknown) => ({ ok: false as const, error }),
|
||||
)
|
||||
|
||||
it('drains four issued hardware calls after failure without dispatching a fifth', async () => {
|
||||
const allStarted = barrier()
|
||||
const release = barrier()
|
||||
const failed = barrier()
|
||||
const started: number[] = []
|
||||
const finished: number[] = []
|
||||
const outcome = observe(runSigningExperimentBatch([1, 2, 3, 4, 5, 6], {
|
||||
jobs: 4, hardwareConcurrency: 4,
|
||||
sign: async (item) => {
|
||||
started.push(item)
|
||||
if (started.length === 4) allStarted.resolve()
|
||||
await allStarted.promise
|
||||
if (item === 1) { failed.resolve(); throw new Error('hardware failure') }
|
||||
await release.promise
|
||||
finished.push(item)
|
||||
},
|
||||
complete: async (item, sign) => { await sign(`staged-${item}`) },
|
||||
}))
|
||||
let settled = false
|
||||
void outcome.then(() => { settled = true })
|
||||
try {
|
||||
await failed.promise
|
||||
expect(started).toEqual([1, 2, 3, 4])
|
||||
expect(settled).toBe(false)
|
||||
} finally { release.resolve() }
|
||||
expect(await outcome).toEqual({ ok: false, error: new Error('hardware failure') })
|
||||
expect(finished.sort()).toEqual([2, 3, 4])
|
||||
expect(started).toEqual([1, 2, 3, 4])
|
||||
})
|
||||
|
||||
it('finishes each item before beginning the next in serial mode', async () => {
|
||||
const events: string[] = []
|
||||
await runSigningExperimentBatch([1, 2], {
|
||||
jobs: 1, hardwareConcurrency: 1,
|
||||
sign: async (item, path) => { expect(path).toBe(`staged-${item}`); events.push(`sign ${item}`) },
|
||||
complete: async (item, sign) => {
|
||||
events.push(`start ${item}`)
|
||||
await sign(`staged-${item}`)
|
||||
events.push(`complete ${item}`)
|
||||
},
|
||||
})
|
||||
expect(events).toEqual(['start 1', 'sign 1', 'complete 1', 'start 2', 'sign 2', 'complete 2'])
|
||||
})
|
||||
|
||||
it('overlaps completion with the next signature while keeping hardware concurrency at one', async () => {
|
||||
const firstSigning = barrier()
|
||||
const releaseFirstSignature = barrier()
|
||||
const secondStarted = barrier()
|
||||
const firstPost = barrier()
|
||||
const secondSigned = barrier()
|
||||
const releaseCompletion = barrier()
|
||||
const started: number[] = []
|
||||
let active = 0
|
||||
let peak = 0
|
||||
const outcome = observe(runSigningExperimentBatch([1, 2, 3], {
|
||||
jobs: 2, hardwareConcurrency: 1,
|
||||
sign: async (item) => {
|
||||
active++
|
||||
peak = Math.max(peak, active)
|
||||
try {
|
||||
if (item === 1) { firstSigning.resolve(); await releaseFirstSignature.promise }
|
||||
if (item === 2) secondSigned.resolve()
|
||||
} finally { active-- }
|
||||
},
|
||||
complete: async (item, sign) => {
|
||||
started.push(item)
|
||||
if (item === 2) secondStarted.resolve()
|
||||
await sign(`staged-${item}`)
|
||||
if (item === 1) firstPost.resolve()
|
||||
await releaseCompletion.promise
|
||||
},
|
||||
}))
|
||||
try {
|
||||
await Promise.all([firstSigning.promise, secondStarted.promise])
|
||||
expect(active).toBe(1)
|
||||
releaseFirstSignature.resolve()
|
||||
await Promise.all([firstPost.promise, secondSigned.promise])
|
||||
expect(started).toEqual([1, 2])
|
||||
expect(peak).toBe(1)
|
||||
} finally {
|
||||
releaseFirstSignature.resolve()
|
||||
releaseCompletion.resolve()
|
||||
expect(await outcome).toEqual({ ok: true })
|
||||
}
|
||||
expect(started).toEqual([1, 2, 3])
|
||||
})
|
||||
|
||||
it.each([2, 4] as const)('allows %i hardware requests but keeps later items queued until a job completes', async (limit) => {
|
||||
const bothSigning = barrier()
|
||||
const release = barrier()
|
||||
const started: number[] = []
|
||||
let active = 0
|
||||
let peak = 0
|
||||
const outcome = observe(runSigningExperimentBatch(Array.from({ length: limit + 1 }, (_, index) => index + 1), {
|
||||
jobs: limit, hardwareConcurrency: limit,
|
||||
sign: async () => {
|
||||
active++
|
||||
peak = Math.max(peak, active)
|
||||
if (active === limit) bothSigning.resolve()
|
||||
try { await release.promise } finally { active-- }
|
||||
},
|
||||
complete: async (item, sign) => { started.push(item); await sign(`staged-${item}`) },
|
||||
}))
|
||||
try {
|
||||
await bothSigning.promise
|
||||
expect(started).toEqual(Array.from({ length: limit }, (_, index) => index + 1))
|
||||
expect(active).toBe(limit)
|
||||
} finally { release.resolve(); expect(await outcome).toEqual({ ok: true }) }
|
||||
expect(peak).toBe(limit)
|
||||
expect(active).toBe(0)
|
||||
})
|
||||
|
||||
it.each([new Error('hardware failed'), undefined])('stops queued signatures and new items after a hardware rejection (%s)', async (failure) => {
|
||||
const secondStarted = barrier()
|
||||
const sign = vi.fn(async () => { await secondStarted.promise; throw failure })
|
||||
const started: number[] = []
|
||||
const result = await observe(runSigningExperimentBatch([1, 2, 3], {
|
||||
jobs: 2, hardwareConcurrency: 1, sign,
|
||||
complete: async (item, request) => {
|
||||
started.push(item)
|
||||
if (item === 2) secondStarted.resolve()
|
||||
await request(`staged-${item}`)
|
||||
},
|
||||
}))
|
||||
expect(result).toEqual({ ok: false, error: failure })
|
||||
expect(sign).toHaveBeenCalledTimes(1)
|
||||
expect(started).toEqual([1, 2])
|
||||
})
|
||||
|
||||
it('drains concurrent hardware after the first failure and retains that failure', async () => {
|
||||
const secondSigning = barrier()
|
||||
const firstFailed = barrier()
|
||||
const releaseSecond = barrier()
|
||||
const failure = new Error('first hardware failure')
|
||||
const calls: number[] = []
|
||||
let active = 0
|
||||
let settled = false
|
||||
const outcome = observe(runSigningExperimentBatch([1, 2, 3], {
|
||||
jobs: 2, hardwareConcurrency: 2,
|
||||
sign: async (item) => {
|
||||
calls.push(item)
|
||||
active++
|
||||
try {
|
||||
if (item === 1) { await secondSigning.promise; throw failure }
|
||||
secondSigning.resolve()
|
||||
await releaseSecond.promise
|
||||
throw new Error('later hardware failure')
|
||||
} finally { active-- }
|
||||
},
|
||||
complete: async (item, sign) => {
|
||||
try { await sign(`staged-${item}`) }
|
||||
catch (error) { if (item === 1) firstFailed.resolve(); throw error }
|
||||
},
|
||||
})).then((result) => { settled = true; return result })
|
||||
try {
|
||||
await firstFailed.promise
|
||||
expect(settled).toBe(false)
|
||||
expect(active).toBe(1)
|
||||
expect(calls).toEqual([1, 2])
|
||||
} finally { releaseSecond.resolve(); expect(await outcome).toEqual({ ok: false, error: failure }) }
|
||||
expect(active).toBe(0)
|
||||
expect(calls).toEqual([1, 2])
|
||||
})
|
||||
|
||||
it('preserves an undefined completion failure and starts no further work', async () => {
|
||||
const sign = vi.fn(async () => {})
|
||||
const complete = vi.fn(async () => { throw undefined })
|
||||
expect(await observe(runSigningExperimentBatch([1, 2], {
|
||||
jobs: 1, hardwareConcurrency: 1, complete, sign,
|
||||
}))).toEqual({ ok: false, error: undefined })
|
||||
expect(complete).toHaveBeenCalledTimes(1)
|
||||
expect(sign).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('prevents a waiting signature when completion fails and drains the issued hardware request', async () => {
|
||||
const secondSigning = barrier()
|
||||
const releaseSecond = barrier()
|
||||
const failureObserved = barrier()
|
||||
const failure = new Error('timestamp failed')
|
||||
const calls: number[] = []
|
||||
let pending: ReturnType<typeof observe> | undefined
|
||||
let settled = false
|
||||
const outcome = observe(runSigningExperimentBatch([1, 2, 3], {
|
||||
jobs: 2, hardwareConcurrency: 1,
|
||||
sign: async (item) => {
|
||||
calls.push(item)
|
||||
if (item === 2) { secondSigning.resolve(); await releaseSecond.promise }
|
||||
},
|
||||
complete: async (item, sign) => {
|
||||
await sign(`staged-${item}`)
|
||||
if (item === 1) {
|
||||
await secondSigning.promise
|
||||
// Failure can interrupt a completion that already owns a queued request.
|
||||
const waiting = observe(sign(`staged-retry-${item}`)).then((result) => { failureObserved.resolve(); return result })
|
||||
pending = waiting
|
||||
throw failure
|
||||
}
|
||||
},
|
||||
})).then((result) => { settled = true; return result })
|
||||
try {
|
||||
await failureObserved.promise
|
||||
expect(settled).toBe(false)
|
||||
expect(calls).toEqual([1, 2])
|
||||
} finally { releaseSecond.resolve(); expect(await outcome).toEqual({ ok: false, error: failure }) }
|
||||
expect(calls).toEqual([1, 2])
|
||||
expect(await pending).toEqual({ ok: false, error: failure })
|
||||
})
|
||||
|
||||
it('drains both completion workers after one fails without starting more items', async () => {
|
||||
const secondPost = barrier()
|
||||
const releaseSecondPost = barrier()
|
||||
const failed = barrier()
|
||||
const failure = new Error('verification failed')
|
||||
const started: number[] = []
|
||||
let settled = false
|
||||
const outcome = observe(runSigningExperimentBatch([1, 2, 3], {
|
||||
jobs: 2, hardwareConcurrency: 2, sign: async () => {},
|
||||
complete: async (item, sign) => {
|
||||
started.push(item)
|
||||
await sign(`staged-${item}`)
|
||||
if (item === 1) { await secondPost.promise; failed.resolve(); throw failure }
|
||||
secondPost.resolve()
|
||||
await releaseSecondPost.promise
|
||||
},
|
||||
})).then((result) => { settled = true; return result })
|
||||
try {
|
||||
await failed.promise
|
||||
expect(settled).toBe(false)
|
||||
expect(started).toEqual([1, 2])
|
||||
} finally { releaseSecondPost.resolve(); expect(await outcome).toEqual({ ok: false, error: failure }) }
|
||||
expect(started).toEqual([1, 2])
|
||||
})
|
||||
|
||||
it('cancels waiting signatures and new items while waiting for issued hardware to finish', async () => {
|
||||
const controller = new AbortController()
|
||||
const secondStarted = barrier()
|
||||
const release = barrier()
|
||||
const waitingRejected = barrier()
|
||||
const reason = new Error('operator cancelled')
|
||||
const calls: number[] = []
|
||||
let active = false
|
||||
let settled = false
|
||||
const outcome = observe(runSigningExperimentBatch([1, 2, 3], {
|
||||
jobs: 2, hardwareConcurrency: 1, signal: controller.signal,
|
||||
sign: async (item) => {
|
||||
calls.push(item)
|
||||
active = true
|
||||
try { await release.promise } finally { active = false }
|
||||
},
|
||||
complete: async (item, sign) => {
|
||||
if (item === 2) secondStarted.resolve()
|
||||
try { await sign(`staged-${item}`) }
|
||||
catch (error) { if (item === 2) waitingRejected.resolve(); throw error }
|
||||
},
|
||||
})).then((result) => { settled = true; return result })
|
||||
try {
|
||||
await secondStarted.promise
|
||||
controller.abort(reason)
|
||||
await waitingRejected.promise
|
||||
expect(active).toBe(true)
|
||||
expect(settled).toBe(false)
|
||||
expect(calls).toEqual([1])
|
||||
} finally { release.resolve(); expect(await outcome).toEqual({ ok: false, error: reason }) }
|
||||
expect(active).toBe(false)
|
||||
expect(calls).toEqual([1])
|
||||
})
|
||||
|
||||
it('does not begin work when cancellation was already requested', async () => {
|
||||
const reason = new Error('already cancelled')
|
||||
const complete = vi.fn(async () => {})
|
||||
const sign = vi.fn(async () => {})
|
||||
expect(await observe(runSigningExperimentBatch([1], {
|
||||
jobs: 2, hardwareConcurrency: 2, signal: AbortSignal.abort(reason), complete, sign,
|
||||
}))).toEqual({ ok: false, error: reason })
|
||||
expect(complete).not.toHaveBeenCalled()
|
||||
expect(sign).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -63,15 +63,32 @@ async function fixture() {
|
||||
sequence.push('compile'); await writeFile(output, 'inert unsigned probe')
|
||||
})
|
||||
const sign = vi.fn(async () => { sequence.push('sign') })
|
||||
const inspectIdentity = vi.fn(async () => 'matched' as const)
|
||||
const inspect = vi.fn(async (_path: string) => {
|
||||
sequence.push('inspect')
|
||||
return sign.mock.calls.length === 0
|
||||
? { status: 'NotSigned', timestamped: false, thumbprint: null }
|
||||
: { status: 'Valid', timestamped: true, thumbprint: 'A'.repeat(40) }
|
||||
})
|
||||
return { root, compiler, sequence, runDirectory: run.directory, environment, stateDirectory: join(root, 'state'), compile, sign, inspect }
|
||||
return { root, compiler, sequence, runDirectory: run.directory, environment, stateDirectory: join(root, 'state'), compile, sign, inspect, inspectIdentity }
|
||||
}
|
||||
|
||||
it.each(['provider-unavailable', 'container-unavailable', 'certificate-mismatch', 'unknown'] as const)
|
||||
('stops before compilation and signing on public identity result %s', async (result) => {
|
||||
const options = await fixture()
|
||||
const inspectIdentity = vi.fn(async (_input: { environment: NodeJS.ProcessEnv }) => result)
|
||||
await expect(preflightWindowsSigning({ ...options, inspectIdentity })).rejects.toThrow('No PIN was supplied')
|
||||
expect(options.compile).not.toHaveBeenCalled()
|
||||
expect(options.sign).not.toHaveBeenCalled()
|
||||
expect(inspectIdentity).toHaveBeenCalledOnce()
|
||||
expect(inspectIdentity.mock.calls[0]![0].environment).not.toHaveProperty('DSH_DESKTOP_WINDOWS_TOKEN_PIN')
|
||||
expect(inspectIdentity.mock.calls[0]![0].environment).not.toHaveProperty('DSH_DESKTOP_WINDOWS_SIGNTOOL')
|
||||
const events = await readFile(join(options.runDirectory, 'events.jsonl'), 'utf8')
|
||||
expect(events).toContain(`"result":"${result}"`)
|
||||
expect(events).not.toContain('fixture-pin')
|
||||
expect(events).not.toContain('fixture-container')
|
||||
})
|
||||
|
||||
it('compiles one new probe and verifies one production-format signature before success', async () => {
|
||||
const options = await fixture()
|
||||
await preflightWindowsSigning(options)
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { X509Certificate } from 'node:crypto'
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { rootCertificates } from 'node:tls'
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import { inspectWindowsTokenIdentity, windowsTokenIdentityFailure } from '../scripts/windows-token-identity.ts'
|
||||
|
||||
const launch = vi.hoisted(() => vi.fn(async (_file: string, _args: string[], _options: { env: NodeJS.ProcessEnv; timeout: number }) => ({ stdout: '', stderr: '' })))
|
||||
vi.mock('node:child_process', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import('node:child_process')>()
|
||||
const { promisify } = await import('node:util')
|
||||
return { ...original, execFile: Object.assign(vi.fn(), { [promisify.custom]: launch }) }
|
||||
})
|
||||
|
||||
const certificate = new X509Certificate(rootCertificates.find(pem => new X509Certificate(pem).publicKey.asymmetricKeyType === 'rsa')!)
|
||||
const jwk = certificate.publicKey.export({ format: 'jwk' })
|
||||
const modulus = Buffer.from(jwk.n!, 'base64url')
|
||||
const exponent = Buffer.from(jwk.e!, 'base64url').reduce((value, byte) => value * 256 + byte, 0)
|
||||
const blob = Buffer.alloc(20 + modulus.length)
|
||||
blob[0] = 6; blob[1] = 2
|
||||
blob.writeUInt32LE(0x2400, 4); blob.writeUInt32LE(0x31415352, 8)
|
||||
blob.writeUInt32LE(modulus.length * 8, 12); blob.writeUInt32LE(exponent, 16)
|
||||
Buffer.from(modulus).reverse().copy(blob, 20)
|
||||
const input = { certificate: certificate.raw, container: 'fixture-container', environment: {} }
|
||||
|
||||
it('compares the returned public key with the certificate, not the container name', async () => {
|
||||
const query = vi.fn(async () => JSON.stringify({ status: 'public-keys', publicKeys: [blob.toString('base64')] }))
|
||||
expect(await inspectWindowsTokenIdentity({ ...input, query })).toBe('matched')
|
||||
expect(query).toHaveBeenCalledOnce()
|
||||
const other = Buffer.from(blob)
|
||||
other[20] = other[20]! ^ 2
|
||||
expect(await inspectWindowsTokenIdentity({ ...input, query: async () => JSON.stringify({
|
||||
status: 'public-keys', publicKeys: [other.toString('base64')],
|
||||
}) })).toBe('certificate-mismatch')
|
||||
})
|
||||
|
||||
it.each(['provider-unavailable', 'container-unavailable', 'unknown'])('preserves public query result %s', async (status) => {
|
||||
expect(await inspectWindowsTokenIdentity({ ...input, query: async () => JSON.stringify({ status }) })).toBe(status)
|
||||
})
|
||||
|
||||
it.each(['not json', '{}', '{"status":"public-keys","publicKeys":[]}',
|
||||
'{"status":"public-keys","publicKeys":["bad"]}', '{"status":"matched"}',
|
||||
JSON.stringify({ status: 'public-keys', publicKeys: [blob.toString('base64'), 'bad'] })])
|
||||
('rejects incomplete or malformed public data without accepting a partial match: %s', async (output) => {
|
||||
expect(await inspectWindowsTokenIdentity({ ...input, query: async () => output })).toBe('unknown')
|
||||
})
|
||||
|
||||
it('contains query failures without retrying or exposing diagnostics', async () => {
|
||||
const query = vi.fn(async () => { throw new Error('private fixture diagnostic') })
|
||||
expect(await inspectWindowsTokenIdentity({ ...input, query })).toBe('unknown')
|
||||
expect(query).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('launches only the public query with no PIN, signing tool or inherited secrets', async () => {
|
||||
launch.mockClear()
|
||||
launch.mockResolvedValueOnce({ stdout: JSON.stringify({ status: 'public-keys', publicKeys: [blob.toString('base64')] }), stderr: '' })
|
||||
expect(await inspectWindowsTokenIdentity({ ...input, environment: {
|
||||
PATH: 'fixture-path', DSH_DESKTOP_WINDOWS_TOKEN_PIN: 'must-not-pass', API_KEY: 'must-not-pass',
|
||||
DSH_DESKTOP_WINDOWS_SIGNTOOL: 'must-not-run', NODE_OPTIONS: 'must-not-pass', NODE_PATH: 'must-not-pass',
|
||||
} })).toBe('matched')
|
||||
expect(launch).toHaveBeenCalledOnce()
|
||||
const [file, args, options] = launch.mock.calls[0]!
|
||||
expect(file).toBe('pwsh')
|
||||
expect(args).toContain('-NonInteractive')
|
||||
expect(args.at(-1)).toMatch(/windows-token-public-key\.ps1$/u)
|
||||
expect(options.timeout).toBe(15_000)
|
||||
expect(options.env).toEqual({ PATH: 'fixture-path', DSH_TOKEN_PUBLIC_CONTAINER: 'fixture-container' })
|
||||
})
|
||||
|
||||
it('reports a helper timeout or stderr as unknown without retry', async () => {
|
||||
launch.mockClear()
|
||||
launch.mockRejectedValueOnce(new Error('timeout with private diagnostic'))
|
||||
expect(await inspectWindowsTokenIdentity(input)).toBe('unknown')
|
||||
expect(launch).toHaveBeenCalledOnce()
|
||||
launch.mockResolvedValueOnce({ stdout: JSON.stringify({ status: 'public-keys', publicKeys: [blob.toString('base64')] }), stderr: 'unexpected' })
|
||||
expect(await inspectWindowsTokenIdentity(input)).toBe('unknown')
|
||||
})
|
||||
|
||||
it('reports a missing query launcher without exposing process diagnostics or retrying', async () => {
|
||||
launch.mockClear()
|
||||
launch.mockRejectedValueOnce(Object.assign(new Error('private launcher diagnostic'), { code: 'ENOENT' }))
|
||||
const result = await inspectWindowsTokenIdentity(input)
|
||||
expect(result).toBe('unknown')
|
||||
expect(launch).toHaveBeenCalledOnce()
|
||||
const message = windowsTokenIdentityFailure('unknown')
|
||||
expect(message).toContain('PowerShell 7 (pwsh) can start')
|
||||
expect(message).not.toContain('private launcher diagnostic')
|
||||
expect(message).toContain('no signing was attempted')
|
||||
})
|
||||
|
||||
it.skipIf(process.platform !== 'win32')('exercises native control flow with a fake CSP and never constructs the native adapter', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'token-public-test-'))
|
||||
try {
|
||||
const source = resolve(import.meta.dirname, '../scripts/windows-token-public-key.cs')
|
||||
const runner = join(root, 'test.ps1')
|
||||
const fixture = resolve(import.meta.dirname, 'fixtures/windows-token-public-key-fake.cs')
|
||||
writeFileSync(runner, `param([string]$Source, [string]$Fixture)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Add-Type -TypeDefinition ((Get-Content -LiteralPath $Source -Raw) + (Get-Content -LiteralPath $Fixture -Raw))
|
||||
[FakeTokenPublicKey]::Test()
|
||||
`)
|
||||
expect(execFileSync('pwsh', ['-NoProfile', '-NonInteractive', '-File', runner, source, fixture], { encoding: 'utf8', timeout: 30_000 }))
|
||||
.toContain('fake CSP cases passed')
|
||||
const native = readFileSync(source, 'utf8')
|
||||
const provider = /Provider = "([^"]+)"/u.exec(native)![1]
|
||||
expect(readFileSync(resolve(import.meta.dirname, '../scripts/windows-sign.cmd'), 'utf8')).toContain(`/csp "${provider}"`)
|
||||
expect(native).toContain('CryptAcquireContextW(out context, container, Provider, 1, PublicContextFlags)')
|
||||
expect(native).toContain('CryptExportKey(key, IntPtr.Zero, 6, 0, bytes, ref length)')
|
||||
expect(native).not.toMatch(/CryptSign|CryptSetProvParam|CryptGenKey|CryptAcquireCertificatePrivateKey/u)
|
||||
} finally { rmSync(root, { recursive: true, force: true, maxRetries: 3 }) }
|
||||
})
|
||||
@@ -21,23 +21,26 @@
|
||||
en: bb889e9514678d1c
|
||||
zh: 48cbec1e907d6a2b
|
||||
/windows-signing-and-runner-operations/machine-setup-and-runner-startup/supervised-startup:
|
||||
en: d8e2ed7bde76ed7e
|
||||
zh: 475917ecbd158b48
|
||||
en: cada81c15a20ecc2
|
||||
zh: 80b7a4af7b1e0e5d
|
||||
/windows-signing-and-runner-operations/machine-setup-and-runner-startup/optional-network-dependency:
|
||||
en: 60f34f8637b3c252
|
||||
zh: 805435964ab20263
|
||||
/windows-signing-and-runner-operations/signing-configuration:
|
||||
en: 4b5548818375eb9a
|
||||
zh: 8c975adbb1dca9c8
|
||||
/windows-signing-and-runner-operations/signing-and-timestamp-completion:
|
||||
en: e42565e00283e3f6
|
||||
zh: 584a33f07b50fd5b
|
||||
en: a8d23c931cafc236
|
||||
zh: 28fd4860549cdc7b
|
||||
/windows-signing-and-runner-operations/failure-records-and-operator-recovery:
|
||||
en: 5401b605a2f3a44e
|
||||
zh: fc1f6ee667bcd20d
|
||||
/windows-signing-and-runner-operations/signature-cache-and-concurrent-stages:
|
||||
en: c2686bf2e9589521
|
||||
zh: 58dfecf61e4f95da
|
||||
en: 6e8208d19ad510e5
|
||||
zh: eee42e8938c0f603
|
||||
/windows-signing-and-runner-operations/verification-limits:
|
||||
en: 94a471410c84a1c7
|
||||
zh: 115228e4ca36581d
|
||||
en: 53f1fe23f54af84e
|
||||
zh: d219606367ff2d72
|
||||
/windows-signing-and-runner-operations/dev-note:
|
||||
en: b75c8458928b72b9
|
||||
zh: 003ba4e17b4b5269
|
||||
@@ -38,14 +38,24 @@ Run `pwsh -NoProfile -File apps/desktop/scripts/setup-windows-packaging.ps1 -Roo
|
||||
|
||||
For the complete registration, label, routing and approval sequence, start with [CI publication setup](../publishing/ci/README.md#setup-sequence). The task installer requires the Windows .NET Framework 4.5+ compiler at `%SystemRoot%/Microsoft.NET/Framework64/v4.0.30319/csc.exe`. It compiles a GUI executable into `runner/dsh-task/launcher.exe`; this executable starts PowerShell with no console and waits for it to exit. The output and errors of `run.ps1` itself go to uniquely named `launcher-*.out.log` and `launcher-*.err.log`; filenames include UTC time, PID and a random identifier. Launcher failures disable periodic starts, including failures before the retry budget can be read. Task result `2` indicates launcher failure; `3` means its error log could not be written; `4` means disabling the task also failed and an operator must disable it in Task Scheduler. After the direct child exits, output draining is limited to five seconds. A timeout returns `5` with a `launcher-*.drain.log` diagnostic: output may be incomplete, but periodic recovery continues within the existing restart budget. Descendant processes may outlive the exited runner; draining logs does not terminate them. The recorded stop target is the launcher process, so stop includes the launcher and its descendants. Normal runner output remains in `run-*.log`.
|
||||
|
||||
Run `pwsh -NoProfile -File apps/desktop/scripts/windows-runner-task.ps1 -Action install -RunnerRoot <registered-runner-directory> -ConfigRoot <machine-root>/packaging` as the runner's Windows user, using its installed PowerShell 7. The installer checks the environment and copies startup scripts into `runner/dsh-task`; registration, tools and secrets remain separate. It registers a disabled, limited-permission interactive Task Scheduler task without storing an account password. For `-Action start` or `-Action status`, pass only `-RunnerRoot`; configuration and retry options apply only to installation. The task starts after this user's desktop login, including after reboot, and stops on logout. It does not provide unattended boot or unlock a signing Token.
|
||||
Run `pwsh -NoProfile -File apps/desktop/scripts/windows-runner-task.ps1 -Action install -RunnerRoot <registered-runner-directory> -ConfigRoot <machine-root>/packaging` as the runner's Windows user, using its installed PowerShell 7. The installer checks the environment and copies startup scripts into `runner/dsh-task`; registration, tools and secrets remain separate. It registers a disabled, limited-permission interactive Task Scheduler task without storing an account password. Task priority 4 gives the runner and its children normal CPU, I/O and memory priority; the scheduler default 7 lowers all three and slows dependency installation. Existing tasks retain their settings: while the runner is idle, stop, uninstall and reinstall the task before starting it again. For `-Action start` or `-Action status`, pass only `-RunnerRoot`; configuration and retry options apply only to installation. The task starts after this user's desktop login, including after reboot, and stops on logout. It does not provide unattended boot or unlock a signing Token.
|
||||
|
||||
The task permits one instance and has no execution time limit. A repeating trigger checks every `-RestartMinutes` minutes (default `1`) and restarts an exited launcher, including an unexpected zero exit. The persisted budget permits the initial launch plus `-RestartCount` restarts (default `10`) between explicit starts. Every desktop login also consumes this budget; `status` shows the remaining launches. After the budget is exhausted, login alone cannot restart the runner. Starting an already running task does not reset the budget. Missing or invalid budget/configuration files disable automatic starts until repaired. GitHub's launcher owns runner updates; task recovery does not rerun a failed workflow or clear signing interlocks. After retries are exhausted, inspect `runner/dsh-task/run-*.log`, the runner's `_diag` logs and `status`, correct the cause, then run `start`. A running task alone does not prove connectivity: verify `Connected to GitHub` and `Listening for Jobs`, then verify assignment in Actions. Both `run-*.log` and `launcher-*.log` are retained for operator-managed cleanup.
|
||||
|
||||
Before maintenance, pause new workflow dispatch and wait for the runner to become idle. `-Action stop` refuses an active worker for this runner, disables future starts and stops its task process tree; the local check cannot prevent a concurrent GitHub assignment. `-Action uninstall` requires a stopped, disabled task and retains registration, configuration and logs. Pulling repository changes does not update the installed copy. To upgrade it, record the configuration directory and retry settings, then run `stop`, `uninstall`, `install` with those settings, and `start` from the updated checkout. Confirm listening and job assignment again. When migrating a manually launched runner, stop its existing launcher only after it is idle, then start the task; never run both launchers together.
|
||||
Before maintenance, pause new workflow dispatch and wait for the runner to become idle. `-Action stop` refuses an active worker for this runner, disables future starts and stops its task process tree; the local check cannot prevent a concurrent GitHub assignment. `-Action uninstall` requires a stopped, disabled task and retains registration, configuration and logs. Pulling repository changes does not update the installed copy. To upgrade it, record the original -ConfigRoot, optional -NetworkRoot and retry settings, then run `stop`, `uninstall`, `install` with those settings, and `start` from the updated checkout. Confirm listening and job assignment again. When migrating a manually launched runner, stop its existing launcher only after it is idle, then start the task; never run both launchers together.
|
||||
|
||||
Operators can run `pwsh -NoProfile -File apps/desktop/tests/windows-runner-task.ps1` on Windows to exercise installation, console-free initial launch and automatic recovery, duplicate-start prevention, stop, child-process teardown, containment of startup/logging failures and uninstall against an isolated fake runner. This opt-in test creates a temporary task and retains diagnostic files; it does not contact GitHub or access signing hardware. `pwsh -NoProfile -File apps/desktop/tests/windows-runner-launcher.ps1` checks the GUI executable, child console absence, exit status, diagnostic output and bounded draining with an inherited writer without Task Scheduler; Windows CI runs it. Real-machine acceptance additionally checks an idle runner coming online without a visible Terminal window, an Actions job being assigned, and login after an operator-approved reboot. Signed packaging and logout/reboot acceptance require separate operator verification.
|
||||
|
||||
### Optional network dependency
|
||||
|
||||
Without `runner-network.json` in `ConfigRoot`, startup retains its scrubbed direct-network behavior. To configure a network dependency, create that operator-owned JSON file outside the checkout with exactly `proxyUrl`, `noProxy`, `serviceName`, `probeUrl`, `attempts`, `timeoutSeconds` and `retrySeconds`. The first four fields are strings; the three budgets are integers from 1 through 60. Use an empty `proxyUrl` for direct access and an empty `serviceName` when no Windows service owns the dependency. URLs must use HTTP(S) without credentials, queries or fragments; the proxy URL cannot include a path. Startup applies `HTTP_PROXY`, `HTTPS_PROXY`, `NO_PROXY` and `NODE_USE_ENV_PROXY` after environment scrubbing, then requires a successful Node HEAD probe before admitting the runner. Service readiness checks never start or stop a Windows service.
|
||||
|
||||
For a dedicated child process, add `-NetworkRoot <absolute-private-network-directory>` to task installation. That directory must contain `launch.json` with exactly an absolute `executable` filename, an `arguments` string array and an integer `restartDelaySeconds` from 1 through 3600. This mode requires `runner-network.json`; set its `serviceName` to empty. Give the child its own data directory, listener bindings and executable identity, distinct from applications managed by desktop software; separate ports alone do not prevent another manager from terminating a process by executable name. The scripts do not alter desktop network settings. Keep actual provider names, paths, addresses, ports and arguments in machine configuration, never in repository files or shared logs. Restrict access to both configuration directories to the runner operator.
|
||||
|
||||
Installation starts the dependency for readiness checking and stops it before registering the task. While the runner lives, the supervisor restarts only an exited owned network process after the configured delay, including repeated launch failures; it does not restart the runner or retry jobs or signing. Exit, restart and launch-failure messages appear in `run-*.log`; provider output is discarded. Restart messages establish process creation, not network availability: confirm reconnection in runner logs. A live but unresponsive dependency requires operator diagnosis. The supervisor exclusively locks `NetworkRoot`, verifies PID, start time and executable before reclaiming a stale child, and stops its child when the runner exits. Unrecoverable supervision errors leave the active runner alive until it exits and report that recovery has stopped. Preserve `NetworkRoot` when reinstalling; pulling changes alone does not replace installed helpers.
|
||||
|
||||
Windows CI runs `pwsh -NoProfile -File apps/desktop/tests/windows-runner-network.ps1` and `pwsh -NoProfile -File apps/desktop/tests/windows-runner-network-process.ps1` with isolated local fixtures. On a configured packaging machine, add `-CheckStartup` to the first command to verify network configuration after the actual startup environment scrub. These tests use no signing hardware or external network endpoints.
|
||||
|
||||
<a id="signing-configuration"></a>
|
||||
|
||||
## Signing configuration
|
||||
@@ -66,6 +76,8 @@ Follow the [version and build instructions](../packaging/README.md#release-versi
|
||||
|
||||
Signed Windows builds run a supervised signing preflight before compilation or dependency preparation. Static configuration, certificate validity, audit storage, compiler availability and any retained signing interlock are checked without token access. The local .NET Framework C# compiler creates a small private probe; the production signer signs it once, and verification requires the configured certificate and a timestamp before building continues. The probe is never executed. The overall 60-second preflight deadline includes timestamp attempts; deadline expiry, hardware signing errors and verification failures stop the run without another hardware attempt. Success proves the current signing path works, not that the PIN was independently authenticated: SafeNet may reuse login state. Do not log out or repeat authentication to test the PIN. `--check`, preparation-only and `--unsigned` modes do not run this hardware preflight; unsigned artifacts remain ineligible for release upload. Automated regression tests use a fake signer; release operators qualify real hardware separately.
|
||||
|
||||
Public identity queries require PowerShell 7 (`pwsh`) on the build account’s `PATH`; an unavailable or failed query process stops signing with an unknown identity result. Before compiling the probe, a separate noninteractive process reads public keys from the fixed eToken CSP using `CRYPT_VERIFYCONTEXT | CRYPT_SILENT`. It receives the container name but no PIN or signing credentials, and has a 15-second deadline within the existing preflight deadline. Both RSA key slots must be read completely (an absent slot is allowed); at least one public key must match the configured certificate. Provider/container access failure, unsupported public reads, malformed output, timeout or mismatch stops before hardware signing. A container-access error does not establish that the device or container is absent. Public information can be cached by middleware: a match does not authenticate the device, validate the PIN or establish remaining retries. This path is covered by a simulated CSP; the installed SafeNet version still requires separate operator qualification. No failure automatically retries authentication or clears the existing interlock.
|
||||
|
||||
Insert and unlock the token before packaging. The electron-builder hook passes each artifact to the CRLF `scripts/windows-sign.cmd`, which invokes the configured SignTool once with `/f`, SafeNet `/kc "[{{PIN}}]=container"`, `/csp "eToken Base Cryptographic Provider"`, a SHA-256 file digest, without a timestamp. The hook then completes a DigiCert SHA-256 RFC 3161 timestamp on isolated copies without signing credentials. The hook never substitutes electron-builder's bundled SignTool and never retries a failed signing request. Windows release packaging fails instead of emitting unsigned artifacts when the SignTool, certificate, container, PIN, token, or signature is unavailable.
|
||||
|
||||
Timestamp completion retries only a normally exited timestamp command returning failure or warning, at most three times with one- and two-second delays. Each attempt starts from the same verified primary signature. Launch errors, uncertain termination and verification failures stop immediately. SignTool operates on short private paths; publication copies verified bytes to the target volume before atomic replacement. Final Windows trust, certificate, timestamp and normalized full-file equality are required. Exhaustion stops packaging and preserves evidence without another hardware call. See the [signature completion decision](../../../.agents/notes/implemented/process/2026-09-17-windows-signature-completion.md).
|
||||
@@ -88,9 +100,11 @@ Runtime signing shares complete signed files across the current Windows account'
|
||||
|
||||
Preflight, primary-runtime signing, application-runtime signing and artifact creation each hold the account's signing-stage lock until their supervised child processes settle. Other builds wait before entering those stages; compilation and preparation do not hold the lock. Runtime signing workers own their locks, so terminating only the outer packaging process does not unlock a surviving cache user. Migration and maintenance acquire the same lock; older or external signing commands do not participate and must be scheduled separately. Stage waiting is outside the preflight deadline. Closing the stage handle releases ordinary contention without removing the separate hardware-attempt interlock; a retained hardware failure still requires operator recovery.
|
||||
|
||||
Cache-hit copying, hashing and per-file trust checks run with `DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY` workers from `.env.windows` (default `4`, integer `1`–`8`). All restores and their post-verification finish before cache misses enter serial hardware signing. A restore or verification failure stops new dispatch and drains active work before releasing the stage lock; hardware signing, runtime smoke checks and final integrity validation remain required.
|
||||
Cache-hit copying, hashing and per-file candidate trust checks run with `DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY` workers from `.env.windows` (default `4`, integer `1`–`8`). After all restores finish, bounded PowerShell batches verify every restored target's trust, timestamp and certificate before cache misses enter serial hardware signing. A restore failure stops new restores and drains active workers; a target-verification failure drains its inspection batch and prevents hardware signing. Runtime smoke checks and final integrity validation remain required.
|
||||
|
||||
Windows packaging reads `DSH_DESKTOP_WINDOWS_PACK_CONCURRENCY` from `.env.windows` or the selected preset: a positive integer, defaulting to `4`. Packaging batches first-party and vendor tarballs with bounded workers, then validates every payload and records publication order. Setting the limit to `1` selects serial packing; standalone `release:pack` defaults to `1`.
|
||||
PE discovery reads every regular file's header with at most eight open files, rejects links and malformed Windows executables, and drains active reads and closes before reporting failure. Packaging records `windows:enumerate-code` and `windows:inspect-signatures` as nested phases of signing and artifact verification; their timings are included in the enclosing stages.
|
||||
|
||||
Windows packaging reads `DSH_DESKTOP_WINDOWS_PACK_CONCURRENCY` from `.env.windows` or the selected preset: a positive integer, defaulting to `4`. Packaging batches first-party and vendor tarballs with bounded workers, then validates every payload and records publication order. After the complete build succeeds, tarball packing overlaps Electron and primary-runtime preparation in separate output directories. Both must finish successfully before runtime signing; preparation failure stops new packing commands and drains active processes before returning. Setting the limit to `1` selects serial tarball workers; standalone `release:pack` defaults to `1`.
|
||||
|
||||
Each runtime stage writes `SIGNATURE_CACHE_SUMMARY` to stdout and its packaging journal, including the actual directory, policy identity, hit/miss counts, newly published and retained entries, signing requests, avoided signing requests and validation failures. Timings distinguish signing, trust verification of restored and newly signed files, and restoration; restoration includes its trust check and staging cleanup. These counters exclude preflight, final artifact signing and the outer runtime signature checks. Stage lock events record waiting separately. Concurrent timings sum per-file work rather than stage wall time.
|
||||
|
||||
@@ -100,7 +114,13 @@ Use `pnpm --dir apps/desktop run cache:windows-signatures --usage` to inspect st
|
||||
|
||||
## Verification limits
|
||||
|
||||
Real hardware and installation evidence is owned by the [Desktop qualification record](../tests/README.md) and [installed-update procedure](../tests/installed-update/README.md). This document does not authorize PIN retries, automatic interlock deletion, device initialization or changes to the accepted isolation policy.
|
||||
Signing performance evidence and replacement-runner retesting are owned by the [signing concurrency qualification](../tests/signing-concurrency/README.md). Update and installation evidence is owned by the [Desktop qualification record](../tests/README.md) and [installed-update procedure](../tests/installed-update/README.md). This document does not authorize PIN retries, automatic interlock deletion, device initialization or changes to the accepted isolation policy.
|
||||
|
||||
For operator-approved concurrency experiments, run `node apps/desktop/tests/windows-signing-concurrency.perf.ts --execute --config-root <absolute-packaging-directory> --rounds 2`. Without `--execute`, it only prints usage. The default compares serial completion, two completion workers with one hardware request, and two concurrent hardware requests on six synthetic PE copies per batch. The second round reverses mode order.
|
||||
|
||||
Add `--corpus <absolute-manifest.json>` to compare 1, 2 and 4 workers on real release files. The manifest is a nonempty JSON array of objects with an absolute `path` and its lowercase hexadecimal `sha256`. Preparation copies each pinned file, verifies its signature and removes any valid signature from the copy; invalid signatures or changed bytes stop preparation. Signature removal requires SignTool with `remove /s`; `--strip-tool <absolute-signtool.exe>` overrides only that public preparation tool when the configured version lacks support. Hardware signing and timestamping still use the machine preset. Every batch uses fresh, byte-checked copies of the unsigned templates, preserving manifest order. `--pipeline-control` with a corpus instead runs four completion workers with one hardware request. Original files and release caches remain unchanged.
|
||||
|
||||
The experiment uses the machine test preset, checks public identity, holds the account signing-stage lock and retains one hardware-attempt interlock per batch. The first failure stops dispatch and drains issued work; up to four hardware calls may already be in flight. Only complete signature, timestamp and file verification permits normal interlock release. Files are never executed. Records remain under `.desktop-build/qualification/signing-concurrency`, including batch and per-file elapsed times, batch endpoint host CPU counters, free memory and driver-process memory samples. These memory samples exclude child processes and do not establish peak memory. Timings exclude input preparation, lock waiting and package assembly. Replaying ready-made artifacts also excludes build dependencies between them; the experiment does not enable production concurrency or establish full-release performance.
|
||||
|
||||
## Dev Note
|
||||
|
||||
|
||||
@@ -38,14 +38,24 @@ description: "配置和维护 Windows 打包 runner、签名凭据、EV Token
|
||||
|
||||
完整的注册、标签、路由和审批配置顺序见 [CI 发布配置](../publishing/ci/README.zh.md#setup-sequence)。任务安装器需要 Windows .NET Framework 4.5+ 编译器,路径为 `%SystemRoot%/Microsoft.NET/Framework64/v4.0.30319/csc.exe`。它将 GUI 可执行文件编译到 `runner/dsh-task/launcher.exe`;该程序不分配控制台地启动 PowerShell,并等待其退出。`run.ps1` 自身的输出和错误分别写入名称唯一的 `launcher-*.out.log` 和 `launcher-*.err.log`,文件名包含 UTC 时间、PID 和随机标识。启动器失败时禁用周期启动,包括尚未读取重试预算时发生的失败。任务结果 `2` 表示启动器失败,`3` 表示无法写入错误日志,`4` 表示禁用任务也失败,需要操作者在计划任务中手动禁用。直接子进程退出后,最多等待五秒排空输出;超时返回 `5`,将诊断写入 `launcher-*.drain.log`:输出可能不完整,但周期恢复仍受原有重试预算约束。后代进程可能在 runner 退出后继续运行;日志排空不会终止它们。记录的停止目标是启动器进程,因此 stop 覆盖启动器及其后代。正常 runner 输出仍保存在 `run-*.log`。
|
||||
|
||||
以 runner 的 Windows 用户运行 `pwsh -NoProfile -File apps/desktop/scripts/windows-runner-task.ps1 -Action install -RunnerRoot <registered-runner-directory> -ConfigRoot <machine-root>/packaging`,使用该机器安装的 PowerShell 7。安装器检查环境,并将启动脚本复制到 `runner/dsh-task`;注册、工具和凭据仍单独管理。它注册一个初始禁用、权限受限的交互式计划任务,不保存账户密码。执行 `-Action start` 或 `-Action status` 时只传 `-RunnerRoot`;配置目录及重试选项仅适用于安装。任务在该用户登录桌面后启动,重启后也需要登录,注销时停止。它不提供无人值守开机,也不解锁签名 Token。
|
||||
以 runner 的 Windows 用户运行 `pwsh -NoProfile -File apps/desktop/scripts/windows-runner-task.ps1 -Action install -RunnerRoot <registered-runner-directory> -ConfigRoot <machine-root>/packaging`,使用该机器安装的 PowerShell 7。安装器检查环境,并将启动脚本复制到 `runner/dsh-task`;注册、工具和凭据仍单独管理。它注册一个初始禁用、权限受限的交互式计划任务,不保存账户密码。任务优先级 4 让 runner 及子进程使用普通 CPU、I/O 和内存优先级;计划任务默认值 7 会降低三者的优先级,使依赖安装变慢。已有任务保留原设置:在 runner 空闲时停止、卸载并重新安装任务,再启动。执行 `-Action start` 或 `-Action status` 时只传 `-RunnerRoot`;配置目录及重试选项仅适用于安装。任务在该用户登录桌面后启动,重启后也需要登录,注销时停止。它不提供无人值守开机,也不解锁签名 Token。
|
||||
|
||||
任务只允许一个实例,且没有执行时限。周期触发器每隔 `-RestartMinutes` 分钟(默认 `1`)检查一次,并恢复已退出的启动器,包括意外的零退出码。持久预算允许首次启动及 `-RestartCount` 次重启(默认 `10`),直到再次显式启动。每次桌面登录也会消耗预算,`status` 显示剩余启动次数;预算耗尽后,仅登录不能重启 runner。对正在运行的任务执行 start 不会重置预算。预算或配置文件缺失、无效时,自动启动会禁用,需修复后恢复。GitHub 启动器负责 runner 更新;任务恢复不会重跑失败的 workflow,也不会清除签名互锁。重试耗尽后,检查 `runner/dsh-task/run-*.log`、runner 的 `_diag` 日志和 `status`,修复原因后运行 `start`。任务运行不等于连接成功:确认出现 `Connected to GitHub` 和 `Listening for Jobs`,再到 Actions 确认任务分配。`run-*.log` 和 `launcher-*.log` 均保留,由操作者管理清理。
|
||||
|
||||
维护前暂停新 workflow 的触发,等待 runner 空闲。`-Action stop` 在该 runner 存在活跃 worker 时拒绝停止,否则禁用后续启动并停止任务进程树;本地检查无法阻止 GitHub 同时分配任务。`-Action uninstall` 要求任务已停止并禁用,保留注册、配置和日志。拉取仓库更新不会更新已安装的副本。升级时先记录配置目录和重试设置,再从更新后的 checkout 依次执行 `stop`、`uninstall`、携带原设置的 `install` 和 `start`,最后重新确认监听与 job 分配。迁移手动启动的 runner 时,只在空闲后停止原启动器,再启动计划任务;不要同时运行两个启动器。
|
||||
维护前暂停新 workflow 的触发,等待 runner 空闲。`-Action stop` 在该 runner 存在活跃 worker 时拒绝停止,否则禁用后续启动并停止任务进程树;本地检查无法阻止 GitHub 同时分配任务。`-Action uninstall` 要求任务已停止并禁用,保留注册、配置和日志。拉取仓库更新不会更新已安装的副本。升级时先记录原来的 -ConfigRoot、可选的 -NetworkRoot 和重试设置,再从更新后的 checkout 依次执行 `stop`、`uninstall`、携带原设置的 `install` 和 `start`,最后重新确认监听与 job 分配。迁移手动启动的 runner 时,只在空闲后停止原启动器,再启动计划任务;不要同时运行两个启动器。
|
||||
|
||||
操作者可在 Windows 上运行 `pwsh -NoProfile -File apps/desktop/tests/windows-runner-task.ps1`,使用隔离的假 runner 验证安装、无控制台的首次启动与自动恢复、防止重复启动、停止、子进程退出、启动/日志失败时的禁用处理和卸载。此显式运行的测试创建临时任务并保留诊断文件,不连接 GitHub 或访问签名硬件。`pwsh -NoProfile -File apps/desktop/tests/windows-runner-launcher.ps1` 无需计划任务,检查 GUI 可执行文件、子进程无控制台、退出状态、诊断输出以及存在继承管道写端时的有界排空;Windows CI 会执行该测试。真实机器验收还需检查空闲 runner 上线且不出现 Terminal 窗口、Actions job 分配,以及经操作者批准的重启后登录。签名打包和注销/重启验收需要操作者单独验证。
|
||||
|
||||
### 可选网络依赖
|
||||
|
||||
`ConfigRoot` 中没有 `runner-network.json` 时,启动仍使用清理继承环境后的直连行为。要配置网络依赖,请在 checkout 外创建操作者管理的该 JSON 文件,且只包含 `proxyUrl`、`noProxy`、`serviceName`、`probeUrl`、`attempts`、`timeoutSeconds` 和 `retrySeconds`。前四个字段为字符串,后三个预算为 1 至 60 的整数。直连时将 `proxyUrl` 留空,没有 Windows 服务管理依赖时将 `serviceName` 留空。URL 必须使用 HTTP(S),不能包含凭据、查询参数或片段;代理 URL 不能包含路径。启动脚本在清理环境后设置 `HTTP_PROXY`、`HTTPS_PROXY`、`NO_PROXY` 和 `NODE_USE_ENV_PROXY`,并要求 Node HEAD 探测成功才允许启动 runner。服务就绪检查不会启动或停止 Windows 服务。
|
||||
|
||||
若使用专用子进程,在安装任务时添加 `-NetworkRoot <absolute-private-network-directory>`。该目录必须包含 `launch.json`,且只包含绝对路径的 `executable` 文件名、字符串数组 `arguments`,以及 1 至 3600 的整数 `restartDelaySeconds`。此模式要求存在 `runner-network.json`,其中 `serviceName` 应留空。为子进程分配独立的数据目录、监听绑定和可执行文件标识,与桌面软件管理的应用分离;仅使用不同端口无法防止其他管理器按可执行文件名终止进程。脚本不会修改桌面网络设置。实际服务商名称、路径、地址、端口和参数只保存在机器配置中,不得写入仓库文件或共享日志。将两个配置目录的访问权限限制为 runner 操作者。
|
||||
|
||||
安装时会启动依赖进行就绪检查,并在注册任务前停止它。runner 存活期间,守卫仅在自己拥有的网络进程退出后,按配置间隔重新启动它;启动连续失败时仍会重试,但不会重启 runner、重跑任务或重试签名。退出、重启和启动失败消息写入 `run-*.log`,依赖进程的输出会被丢弃。重启消息只表明进程已创建,不代表网络可用;请在 runner 日志中确认重新连接。进程仍存活但无响应时需要操作者排查。守卫独占锁定 `NetworkRoot`,回收残留子进程前核对 PID、启动时间和可执行文件,并在 runner 退出时停止子进程。不可恢复的守卫错误会保留活动 runner 直到其退出,并报告恢复已停止。重新安装时保留 `NetworkRoot`;仅拉取代码不会替换已安装的辅助脚本。
|
||||
|
||||
Windows CI 使用隔离的本地 fixture 运行 `pwsh -NoProfile -File apps/desktop/tests/windows-runner-network.ps1` 和 `pwsh -NoProfile -File apps/desktop/tests/windows-runner-network-process.ps1`。在已配置的打包机器上,给第一条命令添加 `-CheckStartup`,可验证实际启动环境清理后的网络配置。这些测试不会使用签名硬件或外部网络端点。
|
||||
|
||||
<a id="signing-configuration"></a>
|
||||
|
||||
## 签名配置
|
||||
@@ -66,6 +76,8 @@ PIN 不能包含 `]`、引号或换行,因为这些字符用于分隔 SafeNet
|
||||
|
||||
Windows 签名构建在编译或准备依赖前执行受监督的签名预检。静态配置、证书有效期、审计存储、编译器可用性及遗留签名锁的检查不访问 Token。本地 .NET Framework C# 编译器生成一个专用小探针,由正式签名器仅签名一次,随后必须验出配置的证书和时间戳才能继续构建。探针绝不执行。预检的整体 60 秒期限包含时间戳尝试;超时、硬件签名报错或验签失败都会停止本轮流程,不再次调用硬件。成功只证明当前签名路径可用,不证明 PIN 已独立认证:SafeNet 可能复用登录状态。不要为了验证 PIN 而注销或重复认证。`--check`、仅准备和 `--unsigned` 模式不执行此硬件预检;未签名产物仍不能发布上传。自动回归测试使用假签名器,真实硬件由发布操作人员单独验收。
|
||||
|
||||
公开身份查询要求构建账户的 `PATH` 中有 PowerShell 7(`pwsh`);查询进程不可用或失败时,以身份未知结果停止签名。编译探针前,独立非交互进程使用 `CRYPT_VERIFYCONTEXT | CRYPT_SILENT` 从固定的 eToken CSP 读取公钥。该进程只接收容器名,不接收 PIN 或签名凭据;15 秒期限计入已有预检总期限。两个 RSA 密钥槽必须完整读取(允许槽不存在),且至少一个公钥与配置证书匹配。提供程序或容器访问失败、不支持公开读取、输出损坏、超时或不匹配时,在硬件签名前停止。容器访问错误不能证明设备或容器不存在。中间件可能缓存公开信息,因此匹配不代表设备已认证、PIN 正确或已确认剩余尝试次数。此路径已由模拟 CSP 覆盖,当前 SafeNet 版本仍需操作人员单独验收。任何失败都不会自动重试认证或清除已有保护锁。
|
||||
|
||||
打包前插入并解锁 Token。electron-builder 钩子把每个产物交给采用 CRLF 的 `scripts/windows-sign.cmd`;该 CMD 只调用一次已配置的 SignTool,并指定 `/f`、SafeNet `/kc "[{{PIN}}]=容器"`、`/csp "eToken Base Cryptographic Provider"`和 SHA-256 文件摘要,不请求时间戳。随后钩子在隔离副本上完成 DigiCert SHA-256 RFC 3161 时间戳,不传递签名凭据。钩子不会改用 electron-builder 内置的 SignTool,也不会重试失败的签名请求。SignTool、证书、容器、PIN、Token 或签名不可用时,Windows 发布打包会失败,不会生成未签名产物。
|
||||
|
||||
时间戳处理仅对正常退出但返回失败或警告的时间戳命令重试,最多尝试三次,间隔为一秒和两秒。每次均从同一份已验证的主签名开始。启动错误、终止状态不确定或验签失败会立即停止。SignTool 使用短的私有路径;发布时先把已验证字节复制到目标卷,再原子替换。最终必须通过 Windows 信任、证书、时间戳和规范化全文件相等检查。尝试耗尽后停止打包并保留证据,不再次调用硬件。参见[签名完成决策](../../../.agents/notes/implemented/process/2026-09-17-windows-signature-completion.zh.md)。
|
||||
@@ -88,9 +100,11 @@ Windows 打包命令通过 `DESKTOP_PACKAGING_RECORD` 输出 `.desktop-build/pac
|
||||
|
||||
预检、主要运行时签名、应用运行时签名和产物生成分别持有账户级签名阶段锁,直到受监督的子进程结束。其他构建进入这些阶段前等待;编译和准备步骤不持锁。运行时签名进程自行持锁,因此仅终止外层打包进程不会释放仍在访问缓存的阶段锁。迁移及维护获取同一把锁;旧版或外部签名命令不参与排队,应另行避免并发。阶段等待不计入预检期限。关闭阶段句柄会释放普通竞争锁,不删除独立的硬件尝试互锁;遗留硬件失败仍需操作人员恢复。
|
||||
|
||||
缓存命中的复制、摘要和逐文件信任检查使用 `.env.windows` 中的 `DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY` 个工作任务(默认 `4`,整数 `1`–`8`)。所有恢复及后置验签完成后,未命中项才进入串行硬件签名。恢复或验签失败会停止派发新任务,并在释放阶段锁前等待在途工作结束;硬件签名、运行时 smoke 检查和最终完整性校验仍须执行。
|
||||
缓存命中的复制、摘要和逐文件候选信任检查使用 `.env.windows` 中的 `DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY` 个工作任务(默认 `4`,整数 `1`–`8`)。所有恢复完成后,有界 PowerShell 批次逐项验证恢复目标的信任、时间戳和证书,通过后未命中项才进入串行硬件签名。恢复失败会停止派发新的恢复任务并等待在途工作结束;目标验签失败会等待当前检查批次结束并阻止硬件签名。运行时 smoke 检查和最终完整性校验仍须执行。
|
||||
|
||||
Windows 打包从 `.env.windows` 或所选预设读取 `DSH_DESKTOP_WINDOWS_PACK_CONCURRENCY`:必须为正整数,默认 `4`。Windows 使用有界 worker 批量打包第一方和 vendor tarball,随后校验每个产物并记录发布顺序。设为 `1` 时串行打包;独立的 `release:pack` 默认使用 `1`。
|
||||
PE 发现会读取每个普通文件的头部,同时最多打开八个文件,拒绝链接和损坏的 Windows 可执行文件,并在报告失败前等待在途读取和关闭结束。打包将 `windows:enumerate-code` 与 `windows:inspect-signatures` 记录为签名和产物验证的嵌套阶段;其耗时已包含在外层阶段中。
|
||||
|
||||
Windows 打包从 `.env.windows` 或所选预设读取 `DSH_DESKTOP_WINDOWS_PACK_CONCURRENCY`:必须为正整数,默认 `4`。Windows 使用有界 worker 批量打包第一方和 vendor tarball,随后校验每个产物并记录发布顺序。完整构建成功后,tarball 打包与 Electron、主要运行时准备在各自输出目录中并行执行。两者都成功结束后才开始运行时签名;准备失败会停止新的打包命令,并等待在途进程结束后返回。设为 `1` 时 tarball worker 串行执行;独立的 `release:pack` 默认使用 `1`。
|
||||
|
||||
每个运行时阶段向标准输出及打包日志写入 `SIGNATURE_CACHE_SUMMARY`,包含实际目录、策略标识、命中及未命中数、新发布及保留项数、签名请求数、省去的签名请求数和验证失败数。计时区分签名、恢复文件和新签名文件的信任验证及恢复;恢复耗时包含其信任检查和暂存清理。这些统计不包含预检、最终产物签名及外层运行时验签。阶段锁事件单独记录等待时间。 并发计时累加逐文件工作耗时,不代表阶段墙钟时间。
|
||||
|
||||
@@ -100,7 +114,13 @@ Windows 打包从 `.env.windows` 或所选预设读取 `DSH_DESKTOP_WINDOWS_PACK
|
||||
|
||||
## 验证范围
|
||||
|
||||
真实硬件与安装证据由[Desktop 验证记录](../tests/README.zh.md)和[已安装更新流程](../tests/installed-update/README.zh.md)维护。本文档不授权 PIN 重试、自动删除 interlock、设备初始化或改变已接受的隔离策略。
|
||||
签名性能证据与更换 runner 后的复测由[签名并发验收](../tests/signing-concurrency/README.zh.md)维护。更新与安装证据由[Desktop 验证记录](../tests/README.zh.md)和[已安装更新流程](../tests/installed-update/README.zh.md)维护。本文档不授权 PIN 重试、自动删除 interlock、设备初始化或改变已接受的隔离策略。
|
||||
|
||||
经操作人员授权后,可运行 `node apps/desktop/tests/windows-signing-concurrency.perf.ts --execute --config-root <打包配置目录的绝对路径> --rounds 2` 做并发实验。不加 `--execute` 只打印用法。默认比较完全串行、两个完成任务共用单路硬件签名,以及两路硬件签名,每批使用六个合成 PE 副本。第二轮反转模式顺序。
|
||||
|
||||
加上 `--corpus <清单 JSON 的绝对路径>` 后,改用真实发布文件比较 1、2、4 个并发任务。清单是非空 JSON 数组,每项包含绝对路径 `path` 及对应的小写十六进制 `sha256`。准备阶段复制哈希固定的文件,验证签名并仅移除副本上的有效签名;签名无效或字节变化会中止准备。移除签名要求 SignTool 支持 `remove /s`;配置中的版本不支持时,可用 `--strip-tool <signtool.exe 的绝对路径>` 仅覆盖这个公开准备工具。硬件签名和时间戳仍使用机器 preset。每批使用经过字节比对的全新无签名模板副本,保持清单顺序。配合语料使用 `--pipeline-control` 时,改为四个完成任务共用一个硬件请求。原文件和发布缓存保持不变。
|
||||
|
||||
实验读取机器 test preset,核对公开身份,持有账户签名阶段锁,每批保留一条硬件尝试保护记录。首错停止派发并排空已发出的工作;最多可能已有四条硬件请求在途。只有全部签名、时间戳和文件验证成功才正常释放保护记录。文件从不执行。记录保留在 `.desktop-build/qualification/signing-concurrency`,包含整批及单文件耗时、批次起止时的主机 CPU 计数、可用内存和驱动进程内存采样。内存采样不含子进程,不能用于确定峰值内存。计时不含输入准备、等锁和包组装。重放现成产物还排除了它们之间的构建依赖;实验不启用生产并发,也不代表完整发布性能。
|
||||
|
||||
## 开发备注
|
||||
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
en: 735eb4f22d5f6f71
|
||||
zh: 697eef269b133039
|
||||
/api-gateway/programming-model:
|
||||
en: 78eb70d6c2ce6608
|
||||
zh: 826f0ccc206b7299
|
||||
en: 008d81750e966b5d
|
||||
zh: c31ca07da77ffdcc
|
||||
/api-gateway/component-responsibilities:
|
||||
en: fe382a9bcc768cc2
|
||||
zh: 78e3d50d59780c3d
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ Business services use `@Remote` or `@RemoteScope` to select the methods exposed
|
||||
|
||||
`@RemoteScope(key)` first resolves an identity to a scoped Context through `ctx.typert.contexts`, then obtains the service from that Context and invokes the method. It applies when the method itself depends on scoped composition and does not need to receive objects such as `Agent` explicitly.
|
||||
|
||||
`@Remote` accepts an export name or `{ name?, mode?, resources? }`, and `@RemoteScope(key, option?)` accepts an export name, as in `@RemoteScope('agent', 'current')` below, or `{ name?, resources? }` as its second argument. `resources` holds per-kind readers that return the identities of the resources one call touches for access control; each reader receives the method's wire arguments as its own parameters, with types inferred from the decorated method. Most methods declare no readers, because the Gateway also reads identities from lookup parameters and scoped Contexts that declare a `resource`, and from the default fields in `typert.resources`; the [Typert subsystem](subsystems/typert.md) owns that order.
|
||||
`@Remote` accepts an export name or `{ name?, mode?, resources? }`, and `@RemoteScope(key, option?)` accepts an export name, as in `@RemoteScope('agent', 'current')` below, or `{ name?, resources? }` as its second argument. `resources` holds per-kind readers that return the identities of the resources one call touches for access control; each reader receives the method's wire arguments as its own parameters, with types inferred from the decorated method. Most methods declare no readers, because the Gateway also reads identities from lookup parameters and scoped Contexts that declare a `resource`, and from the default fields or resolvers in `typert.resources`; the [Typert subsystem](subsystems/typert.md) owns that order.
|
||||
|
||||
Services normally extend `TypertRemoteService` so the constructor explicitly binds the Cordis service key and default Remote namespace. A service that already has another base class can instead declare `readonly typertRemote = bindTypertRemote(this, serviceKey)`; both forms leave an inspectable public binding and do not depend on the compiler injecting a symbol into the constructor.
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
`@RemoteScope(key)` 表示先通过 `ctx.typert.contexts` 把 identity 解析为一个作用域 Context,再从该 Context 取得服务并调用方法。它适用于方法本身依赖作用域组合、而不需要显式接收 `Agent` 等对象的情形。
|
||||
|
||||
`@Remote` 接受导出名或 `{ name?, mode?, resources? }`;`@RemoteScope(key, option?)` 的第二个参数接受导出名(如下文的 `@RemoteScope('agent', 'current')`)或 `{ name?, resources? }`。`resources` 按资源种类给出读取函数,返回一次调用所访问资源的身份,供访问控制使用;每个读取函数以方法的 wire 参数作为自身参数,类型从被装饰方法推断。多数方法不声明读取函数,因为 Gateway 还会从声明了 `resource` 的 lookup 参数与作用域 Context 以及 `typert.resources` 中的默认字段读取身份;[Typert 子系统](subsystems/typert.zh.md)负责该顺序。
|
||||
`@Remote` 接受导出名或 `{ name?, mode?, resources? }`;`@RemoteScope(key, option?)` 的第二个参数接受导出名(如下文的 `@RemoteScope('agent', 'current')`)或 `{ name?, resources? }`。`resources` 按资源种类给出读取函数,返回一次调用所访问资源的身份,供访问控制使用;每个读取函数以方法的 wire 参数作为自身参数,类型从被装饰方法推断。多数方法不声明读取函数,因为 Gateway 还会从声明了 `resource` 的 lookup 参数与作用域 Context 以及 `typert.resources` 中的默认字段或 resolver 读取身份;[Typert 子系统](subsystems/typert.zh.md)负责该顺序。
|
||||
|
||||
服务通常继承 `TypertRemoteService`,让 Cordis 服务 key 与默认 Remote namespace 在构造器中显式绑定。已有其他基类的服务可以改为声明 `readonly typertRemote = bindTypertRemote(this, serviceKey)`;两种方式都会留下可检查的公开 binding,不依赖编译器向构造函数注入 symbol。
|
||||
|
||||
|
||||
@@ -243,7 +243,7 @@ export interface Config {
|
||||
## `@deepseek-ai/dsh-api-session-controller`
|
||||
|
||||
- `inject`: `agentDefaultModel` · `agents` · `attachments` · `fileUploads` · `fs` · `llm` · `remoteAccess` · `sessions` · `sessionProjections` · `sessionQuery` · `typert` · `workspaceRegistry`
|
||||
- `source`: [`packages/api/session-controller/src/index.ts:93`](../packages/api/session-controller/src/index.ts)
|
||||
- `source`: [`packages/api/session-controller/src/index.ts:109`](../packages/api/session-controller/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Session Controller deployment policy. */
|
||||
|
||||
@@ -245,7 +245,7 @@ export interface Config {
|
||||
## `@deepseek-ai/dsh-api-session-controller`
|
||||
|
||||
- `inject`: `agentDefaultModel` · `agents` · `attachments` · `fileUploads` · `fs` · `llm` · `remoteAccess` · `sessions` · `sessionProjections` · `sessionQuery` · `typert` · `workspaceRegistry`
|
||||
- `source`: [`packages/api/session-controller/src/index.ts:93`](../packages/api/session-controller/src/index.ts)
|
||||
- `source`: [`packages/api/session-controller/src/index.ts:109`](../packages/api/session-controller/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Session Controller deployment policy. */
|
||||
|
||||
@@ -21,8 +21,8 @@
|
||||
en: 3221d1076554001f
|
||||
zh: 29f166be6a5c04d9
|
||||
/development-guide/contributor-reference/typescript-project-layout:
|
||||
en: 258eadaa4eecd5df
|
||||
zh: 6fc6de7b94c7b3e5
|
||||
en: 7486eed38e4086b9
|
||||
zh: 0b6152dc4f38b53a
|
||||
/development-guide/contributor-reference/environment-variables:
|
||||
en: dba2c72ee6ffa930
|
||||
zh: b65c27fbb9129ec8
|
||||
|
||||
+1
-1
@@ -74,7 +74,7 @@ Host and Client stay two aggregate programs because both sides declaration-merge
|
||||
|
||||
Seven packages split Host and Client tsconfigs: `api/remotes`, `api/gateway`, `api/session-controller`, `api/workspace-controller`, `client/connection`, `session-query/session-log-export`, and `experimental/liveshare-protocol`. `api/remotes`' Host entry participates in the Host Typert graph while its Client entry imports generated `/remote` declarations; `session-log-export` keeps Node archive production out of its browser controller; `liveshare-protocol` keeps the Node wire codecs on its Host leaf and the browser viewer carrier on its Client leaf. Each split package-root `tsconfig.json` is therefore only a solution, and the two aggregates and direct consumers reference `tsconfig.host.json` or `tsconfig.client.json` respectively. The workspace `constraints` gate walks the reachable Project Reference graph and checks each referencing project's own compiler face: a single-config target remains valid from either face, while a split target must name the matching leaf rather than its solution root or opposite leaf; it discovers split packages from the presence of both leaf configs, so a new split joins the gate automatically. The [`api-remotes` README](../packages/api/remotes/README.md) and [`session-log-export` README](../packages/session-query/session-log-export/README.md) explain their splits.
|
||||
|
||||
`pnpm run build:artifacts` emits every artifact in the generated dependency order, checking and compiling only the package references of each compiler face:
|
||||
`pnpm run build:artifacts` emits every artifact, checking and compiling only the package references of each compiler face. Client compilation waits for Host and Desktop bundling to finish: split compiler faces rewrite shared `lib/types` files read by those bundles. Client bundling follows Client compilation. The artifact order is:
|
||||
|
||||
```sh
|
||||
pnpm run build:native-system
|
||||
|
||||
@@ -78,7 +78,7 @@ Host 与 Client 保持两个 aggregate program,是因为两侧在相同键下
|
||||
|
||||
拆分 Host/Client tsconfig 的包有七个:`api/remotes`、`api/gateway`、`api/session-controller`、`api/workspace-controller`、`client/connection`、`session-query/session-log-export` 与 `experimental/liveshare-protocol`。`api/remotes` 的 Host 入口进入 Host Typert 图,而 Client 入口导入生成的 `/remote` 声明;`session-log-export` 则让 Node archive 生产代码不进入浏览器 controller;`liveshare-protocol` 把 Node 线路编解码放在 Host leaf、浏览器查看页载体放在 Client leaf。每个拆分包根 `tsconfig.json` 因此只作为 solution,两个 aggregate 和直接消费方分别引用 `tsconfig.host.json` 或 `tsconfig.client.json`。workspace `constraints` 门禁遍历可达的 Project Reference 图,并按各引用 project 自身的 compiler face 检查:只有单一配置的目标可由任一 face 引用,拆分配置的目标则必须引用匹配的 leaf,不得引用 solution 根或另一侧 leaf;该门禁按「两个 leaf 配置同时存在」自动发现拆分包,所以新拆分的包会自动纳入管辖。[`api-remotes` README](../packages/api/remotes/README.zh.md) 与 [`session-log-export` README](../packages/session-query/session-log-export/README.zh.md)分别说明其拆分。
|
||||
|
||||
`pnpm run build:artifacts` 按生成依赖顺序产出全部产物,只检查并编译各 compiler face 的包 reference:
|
||||
`pnpm run build:artifacts` 产出全部产物,只检查并编译各 compiler face 的包 reference。Client 编译等待 Host 和 Desktop 的 bundle 完成:拆分 compiler face 会重写这些 bundle 步骤读取的共享 `lib/types` 文件。Client bundle 在 Client 编译后执行。产物步骤顺序为:
|
||||
|
||||
```sh
|
||||
pnpm run build:native-system
|
||||
|
||||
@@ -839,14 +839,6 @@ Host service backing the generated `ctx.remote.session` namespace.
|
||||
*/
|
||||
resolveAgent(sessionId: SessionId): Promise<ApiSessionAgentResult>
|
||||
|
||||
/**
|
||||
* Inspect one attached or persisted Session without activating its Agent.
|
||||
* @param sessionId - durable Session identity.
|
||||
* @param signal - optional caller cancellation for persistence reads.
|
||||
* @returns the current attached state or persisted header and event prefix.
|
||||
*/
|
||||
inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise<SessionInspection>
|
||||
|
||||
/**
|
||||
* Read all visible Session rows without resuming an Agent.
|
||||
* @param _request - reserved empty list request.
|
||||
@@ -972,7 +964,7 @@ workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' |
|
||||
* @param signal - cancellation for persistence reads.
|
||||
* @returns one chronological page.
|
||||
*/
|
||||
@Remote({ name: 'page', resources: { session: addressedSession } }) page(request: SessionPageRequest, signal: AbortSignal): Promise<SessionPage>
|
||||
@Remote('page') page(request: SessionPageRequest, signal: AbortSignal): Promise<SessionPage>
|
||||
|
||||
/**
|
||||
* Follow one Session log from its opening or resume cursor.
|
||||
@@ -981,7 +973,7 @@ workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' |
|
||||
* @returns a complete opening snapshot followed by gap-free durable event
|
||||
* frames and optional cursorless assistant-stream frames.
|
||||
*/
|
||||
@Remote({ mode: 'stream', resources: { session: addressedSession } }) follow(request: SessionFollowRequest, signal: AbortSignal): AsyncIterable<SessionFollowFrame>
|
||||
@Remote({ mode: 'stream' }) follow(request: SessionFollowRequest, signal: AbortSignal): AsyncIterable<SessionFollowFrame>
|
||||
|
||||
/**
|
||||
* Read exact projections, returning cached hints only when migration is required.
|
||||
@@ -999,7 +991,7 @@ workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' |
|
||||
@Remote({ mode: 'stream' }) async *control(signal: AbortSignal): AsyncIterable<SessionControlFrame>
|
||||
```
|
||||
|
||||
Types: [SessionId](core.md) · [SessionInspection](persistence.md) · [SessionSearchRequest](session-query.md)
|
||||
Types: [SessionId](core.md) · [SessionSearchRequest](session-query.md)
|
||||
|
||||
Source: [`packages/api/session-controller/src/index.ts`](../../packages/api/session-controller/src/index.ts)
|
||||
|
||||
|
||||
@@ -843,14 +843,6 @@ Host service backing the generated `ctx.remote.session` namespace.
|
||||
*/
|
||||
resolveAgent(sessionId: SessionId): Promise<ApiSessionAgentResult>
|
||||
|
||||
/**
|
||||
* Inspect one attached or persisted Session without activating its Agent.
|
||||
* @param sessionId - durable Session identity.
|
||||
* @param signal - optional caller cancellation for persistence reads.
|
||||
* @returns the current attached state or persisted header and event prefix.
|
||||
*/
|
||||
inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise<SessionInspection>
|
||||
|
||||
/**
|
||||
* Read all visible Session rows without resuming an Agent.
|
||||
* @param _request - reserved empty list request.
|
||||
@@ -976,7 +968,7 @@ workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' |
|
||||
* @param signal - cancellation for persistence reads.
|
||||
* @returns one chronological page.
|
||||
*/
|
||||
@Remote({ name: 'page', resources: { session: addressedSession } }) page(request: SessionPageRequest, signal: AbortSignal): Promise<SessionPage>
|
||||
@Remote('page') page(request: SessionPageRequest, signal: AbortSignal): Promise<SessionPage>
|
||||
|
||||
/**
|
||||
* Follow one Session log from its opening or resume cursor.
|
||||
@@ -985,7 +977,7 @@ workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' |
|
||||
* @returns a complete opening snapshot followed by gap-free durable event
|
||||
* frames and optional cursorless assistant-stream frames.
|
||||
*/
|
||||
@Remote({ mode: 'stream', resources: { session: addressedSession } }) follow(request: SessionFollowRequest, signal: AbortSignal): AsyncIterable<SessionFollowFrame>
|
||||
@Remote({ mode: 'stream' }) follow(request: SessionFollowRequest, signal: AbortSignal): AsyncIterable<SessionFollowFrame>
|
||||
|
||||
/**
|
||||
* Read exact projections, returning cached hints only when migration is required.
|
||||
@@ -1003,7 +995,7 @@ workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' |
|
||||
@Remote({ mode: 'stream' }) async *control(signal: AbortSignal): AsyncIterable<SessionControlFrame>
|
||||
```
|
||||
|
||||
Types: [SessionId](core.zh.md) · [SessionInspection](persistence.zh.md) · [SessionSearchRequest](session-query.zh.md)
|
||||
Types: [SessionId](core.zh.md) · [SessionSearchRequest](session-query.zh.md)
|
||||
|
||||
Source: [`packages/api/session-controller/src/index.ts`](../../packages/api/session-controller/src/index.ts)
|
||||
|
||||
|
||||
@@ -12,11 +12,11 @@
|
||||
en: 21e88d68154c2c92
|
||||
zh: 8c9e05c65ef0f34d
|
||||
/typert-remote-calls/remote-access-resources:
|
||||
en: fa95f70437c07395
|
||||
zh: d4870b678b569a89
|
||||
en: 2e27d0745a4e3eac
|
||||
zh: eb4ad825852c595b
|
||||
/typert-remote-calls/typert-registry:
|
||||
en: 5827ef50011811c9
|
||||
zh: 21a9e7b368ba211a
|
||||
en: d891e9390cf890da
|
||||
zh: f45a31cc2ce2021e
|
||||
/typert-remote-calls/host-gateway:
|
||||
en: 58b923de12e48f18
|
||||
zh: d3c2372d8becf65e
|
||||
|
||||
@@ -286,7 +286,7 @@ interface RemoteInvocation {
|
||||
|
||||
## Remote access resources
|
||||
|
||||
Every Remote method is under the access control of [`dsh-api-remote-access`](../../packages/api/remote-access/README.md): a non-owner call is allowed only when the policy for its principal kind binds, on every resource the call touches, a role that lists the endpoint action. The package that owns an identity type merge-declares its resource kind in `TypertResourceMap`; `global`, the built-in identity-less resource, is never a kind there. The Gateway reads a kind's identities from the `resources` readers of `@Remote({ resources })` or `@RemoteScope(key, { resources })`, else from a lookup parameter or scoped Context that declares the kind as its `resource`, else from the default field registered in `typert.resources`; a call that names no resource touches `global`. The [Peer principals Agent Note](../../.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.md) owns the decision.
|
||||
Every Remote method is under the access control of [`dsh-api-remote-access`](../../packages/api/remote-access/README.md): a non-owner call is allowed only when the policy for its principal kind binds, on every resource the call touches, a role that lists the endpoint action. The package that owns an identity type merge-declares its resource kind in `TypertResourceMap`; `global`, the built-in identity-less resource, is never a kind there. The Gateway reads a kind's identities from the `resources` readers of `@Remote({ resources })` or `@RemoteScope(key, { resources })`, else from a lookup parameter or scoped Context that declares the kind as its `resource`, else from the default field or resolver registered in `typert.resources`; a call that names no resource touches `global`. The [Peer principals Agent Note](../../.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.md) owns the decision.
|
||||
|
||||
```ts type-equiv
|
||||
/**
|
||||
@@ -334,11 +334,14 @@ interface RemoteScopeOptions<WireArgs extends readonly unknown[] = never> {
|
||||
* throw, an empty list, or an identity other than a non-empty string
|
||||
* refuses the call. For a kind without a reader, the Gateway reads the
|
||||
* identity of a scoped Context whose Host adapter declares that `resource`
|
||||
* and of lookup parameters declaring it, else the field registered in
|
||||
* `typert.resources`: a top-level argument or, failing that, the first
|
||||
* object argument. A call that carries no identity touches `global`. The
|
||||
* readers and these defaults together must name every resource the method
|
||||
* accesses: access control never checks a resource they omit.
|
||||
* and of lookup parameters declaring it, else the registered default of
|
||||
* `typert.resources`: a `resolve` function receiving the validated named
|
||||
* wire values and their ordered parameters, or a `field` naming a top-level
|
||||
* argument or, failing that, a field of the first object argument. A
|
||||
* throwing resolver, an array result, or a field holding an array refuses
|
||||
* the call. A call that carries no identity touches `global`. The readers
|
||||
* and these defaults together must name every resource the method accesses:
|
||||
* access control never checks a resource they omit.
|
||||
*/
|
||||
readonly resources?: TypertResourceReaders<WireArgs>
|
||||
}
|
||||
@@ -358,7 +361,7 @@ interface RemoteMethodOptions<WireArgs extends readonly unknown[] = never> exten
|
||||
|
||||
## Typert registry
|
||||
|
||||
`ctx.typert` separates current-environment descriptors, explicitly selected Remote contributions, lookup providers, scoped Context providers, and the default fields of resource kinds. A lookup provider owns the stable wire declaration and default resolver; Host composition can configure an effect-scoped synchronous or asynchronous resolver for the same key, and unloading that configuration restores the default policy. Registrations are Cordis-owned effects and return awaitable disposers.
|
||||
`ctx.typert` separates current-environment descriptors, explicitly selected Remote contributions, lookup providers, scoped Context providers, and the default fields or resolvers of resource kinds. A lookup provider owns the stable wire declaration and default resolver; Host composition can configure an effect-scoped synchronous or asynchronous resolver for the same key, and unloading that configuration restores the default policy. Registrations are Cordis-owned effects and return awaitable disposers.
|
||||
|
||||
```ts type-equiv
|
||||
/** Minimal Typert runtime consumed through dependency inversion. */
|
||||
|
||||
@@ -286,7 +286,7 @@ interface RemoteInvocation {
|
||||
|
||||
## Remote 访问资源
|
||||
|
||||
每个 Remote 方法都受 [`dsh-api-remote-access`](../../packages/api/remote-access/README.zh.md) 的访问控制:只有当该 principal 种类的 policy 在调用访问的每个资源上都绑定了列出该端点 action 的 role 时,非 owner 调用才被放行。拥有某个身份类型的包把其资源种类声明合并进 `TypertResourceMap`;内置的无身份资源 `global` 从不是其中的种类。Gateway 从 `@Remote({ resources })` 或 `@RemoteScope(key, { resources })` 的 `resources` 读取函数读取某个种类的身份,其次是把该种类声明为 `resource` 的 lookup 参数或作用域 Context,再次是在 `typert.resources` 中登记的默认字段;不指向任何资源的调用访问 `global`。决策由 [Peer 身份属性 Agent Note](../../.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md) 负责。
|
||||
每个 Remote 方法都受 [`dsh-api-remote-access`](../../packages/api/remote-access/README.zh.md) 的访问控制:只有当该 principal 种类的 policy 在调用访问的每个资源上都绑定了列出该端点 action 的 role 时,非 owner 调用才被放行。拥有某个身份类型的包把其资源种类声明合并进 `TypertResourceMap`;内置的无身份资源 `global` 从不是其中的种类。Gateway 从 `@Remote({ resources })` 或 `@RemoteScope(key, { resources })` 的 `resources` 读取函数读取某个种类的身份,其次是把该种类声明为 `resource` 的 lookup 参数或作用域 Context,再次是在 `typert.resources` 中登记的默认字段或 resolver;不指向任何资源的调用访问 `global`。决策由 [Peer 身份属性 Agent Note](../../.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md) 负责。
|
||||
|
||||
```ts type-equiv
|
||||
/**
|
||||
@@ -334,11 +334,14 @@ interface RemoteScopeOptions<WireArgs extends readonly unknown[] = never> {
|
||||
* throw, an empty list, or an identity other than a non-empty string
|
||||
* refuses the call. For a kind without a reader, the Gateway reads the
|
||||
* identity of a scoped Context whose Host adapter declares that `resource`
|
||||
* and of lookup parameters declaring it, else the field registered in
|
||||
* `typert.resources`: a top-level argument or, failing that, the first
|
||||
* object argument. A call that carries no identity touches `global`. The
|
||||
* readers and these defaults together must name every resource the method
|
||||
* accesses: access control never checks a resource they omit.
|
||||
* and of lookup parameters declaring it, else the registered default of
|
||||
* `typert.resources`: a `resolve` function receiving the validated named
|
||||
* wire values and their ordered parameters, or a `field` naming a top-level
|
||||
* argument or, failing that, a field of the first object argument. A
|
||||
* throwing resolver, an array result, or a field holding an array refuses
|
||||
* the call. A call that carries no identity touches `global`. The readers
|
||||
* and these defaults together must name every resource the method accesses:
|
||||
* access control never checks a resource they omit.
|
||||
*/
|
||||
readonly resources?: TypertResourceReaders<WireArgs>
|
||||
}
|
||||
@@ -358,7 +361,7 @@ interface RemoteMethodOptions<WireArgs extends readonly unknown[] = never> exten
|
||||
|
||||
## Typert 注册表
|
||||
|
||||
`ctx.typert` 分开保存当前环境的 descriptor、显式选择的 Remote contribution、lookup 提供方、作用域上下文提供方与资源种类的默认字段。lookup 提供方拥有稳定 wire 声明和默认 resolver;Host 组合可以为同一个 key 配置 effect-scoped 同步或异步 resolver,配置卸载后恢复默认策略。各项注册都是由 Cordis 持有的 effect,并返回可等待的 disposer。
|
||||
`ctx.typert` 分开保存当前环境的 descriptor、显式选择的 Remote contribution、lookup 提供方、作用域上下文提供方与资源种类的默认字段或 resolver。lookup 提供方拥有稳定 wire 声明和默认 resolver;Host 组合可以为同一个 key 配置 effect-scoped 同步或异步 resolver,配置卸载后恢复默认策略。各项注册都是由 Cordis 持有的 effect,并返回可等待的 disposer。
|
||||
|
||||
```ts type-equiv
|
||||
/** Minimal Typert runtime consumed through dependency inversion. */
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
en: d9859219cdb0f367
|
||||
zh: 2a3cfa8cb786e327
|
||||
/deepseek-ai-dsh-api-gateway/host-service-typertgatewayservice-ctx-key-typertgateway:
|
||||
en: 891bab7fceee4f0a
|
||||
zh: be55b40151f049a1
|
||||
en: b35573c6707d00d9
|
||||
zh: 619bbba900b0fb8f
|
||||
/deepseek-ai-dsh-api-gateway/client-service-clientremote-ctx-key-remote:
|
||||
en: 0916113cdbe794fb
|
||||
zh: 14893c2cba95bfd6
|
||||
|
||||
@@ -32,7 +32,7 @@ The Host entry registers a trusted-host interceptor on Connection's shared `/api
|
||||
|
||||
Every call names the Peer it speaks for: `InvokeRemoteRequest.peer` and the `peer` argument of `wireStream.open()` are required, and the Gateway never supplies a default Peer. Dispatch from a disposed Peer fails with `gateway/peer-disposed`, for unary calls, streams, `$events`, and `$events/result` alike, and wire paths check it before they read the payload. The Host service injects `remoteAccess` and does not start without [`dsh-api-remote-access`](../remote-access/README.md). After the endpoint and argument-field checks, every call validates its JSON arguments once against their Typert-compiled types, failing with `gateway/input-invalid`, and dispatch reuses the validated values. For a call whose Peer principal is not the owner, after that validation and before lookup and Context resolution, the Gateway resolves the resources the call touches and asks `ctx.remoteAccess.decide({ principal, action, resources, args })` with the endpoint as the action and the validated arguments as `args`, or refuses with reason `resource-invalid` without asking when the resources are invalid; a refused call throws `remote-access/denied` with details `{ action, reason }`, so a refused call fails before it activates any Session. An owner call is dispatched without resource resolution or a decision.
|
||||
|
||||
Resources are read from the arguments validated against their Typert-compiled types, which drop object fields those types do not declare, while lookup and Context identities keep their wire values. For each resource kind, a reader in the method's `@Remote({ resources })` or `@RemoteScope(key, { resources })` options wins; it is called with the method's positional arguments, where lookup positions hold wire identities and the cancellation position holds the call's signal, and is read from the class metadata of the root Service instance. Otherwise every lookup parameter whose provider declares that kind through `resource` contributes its wire identity, an omitted optional one contributing none, and a Context method whose Host Context adapter declares that kind contributes its Context identity, read from the arguments before the Context is resolved. Otherwise the kind's default field registered in `typert.resources` is read from a top-level argument of that name or, failing that, from the first object argument. A reader that throws or returns an empty list, a default field holding an array, or an identity, including a Context identity, that is not a non-empty string makes the resolution `invalid`; a call that names no resource touches the `global` resource alone, and a call that names resources touches only those. A forwarded event carries every resource its entry declares, so there a reader returning `undefined` also makes the resolution `invalid`, while an entry without readers touches `global`.
|
||||
Resources are read from validated arguments: Typert-compiled codecs drop undeclared object fields, while SRC fallback checks JSON safety without stripping them; lookup and Context identities keep their wire values. For each resource kind, a reader in the method's `@Remote({ resources })` or `@RemoteScope(key, { resources })` options wins; it is called with the method's positional arguments, where lookup positions hold wire identities and the cancellation position holds the call's signal, and is read from the class metadata of the root Service instance. Otherwise every lookup parameter whose provider declares that kind through `resource` contributes its wire identity, an omitted optional one contributing none, and a Context method whose Host Context adapter declares that kind contributes its Context identity, read from the arguments before the Context is resolved. Otherwise the kind's declaration in `typert.resources` supplies either a default field or a synchronous resolver. A field reads a top-level argument of that name or, failing that, the first object argument. A resolver is called once with `{ args, parameters }`: named validated wire values and ordered parameter descriptions containing `name` and `wire`. Both defaults return one identity or `undefined`; arrays are invalid. An explicit reader may return several identities. A resolver or reader that throws, a reader returning an empty list, or an identity, including a Context identity, that is not a non-empty string makes the resolution `invalid`; a call that names no resource touches the `global` resource alone, and a call that names resources touches only those. A forwarded event carries every resource its entry declares, so there a reader returning `undefined` also makes the resolution `invalid`, while an entry without readers touches `global`.
|
||||
|
||||
A cancellation-aware Remote method declares `signal: AbortSignal` as its final Host parameter. The signal is descriptor metadata rather than a wire argument: Connection supplies it to the Gateway, and the Gateway injects it after decoded business parameters. SRC recognizes the reserved final name, while strict generation additionally requires the global `AbortSignal` type.
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ Connection 可用时,Host 入口会在 Connection 共享的 `/api` FetchHandle
|
||||
|
||||
每次调用都指明它所代表的 Peer:`InvokeRemoteRequest.peer` 与 `wireStream.open()` 的 `peer` 参数都是必填项,Gateway 从不提供默认 Peer。来自已释放 Peer 的分发以 `gateway/peer-disposed` 失败,一元调用、流、`$events` 与 `$events/result` 一律如此,wire 路径在读取 payload 之前检查。Host 服务注入 `remoteAccess`,没有 [`dsh-api-remote-access`](../remote-access/README.zh.md) 就不启动。每次调用在 endpoint 与参数字段检查之后按 Typert 编译类型校验一次 JSON 参数,失败为 `gateway/input-invalid`,分派复用校验后的值。对 Peer 的 principal 不是 owner 的调用,Gateway 在该校验之后、lookup 与 Context 解析之前解析调用访问的资源,并以端点为 action、校验后的参数为 `args` 询问 `ctx.remoteAccess.decide({ principal, action, resources, args })`,资源非法时不询问而以原因 `resource-invalid` 拒绝;被拒的调用抛出 details 为 `{ action, reason }` 的 `remote-access/denied`,因此被拒绝的调用在激活任何 Session 之前就失败。owner 的调用不解析资源、不询问判定即分派。
|
||||
|
||||
资源从经 Typert 编译类型校验后的参数中读取,校验会移除类型未声明的对象字段,lookup 与 Context 身份保持 wire 值。对每个资源种类,方法 `@Remote({ resources })` 或 `@RemoteScope(key, { resources })` options 中的读取函数优先;它以方法的位置参数调用(lookup 位为 wire 身份,取消参数位为调用的 signal),从根 Service 实例的类元数据读取。否则 provider 经 `resource` 声明了该种类的每个 lookup 参数贡献其 wire 身份,省略的可选 lookup 参数不贡献;Host Context adapter 声明了该种类的 Context 方法贡献其 Context 身份,在解析 Context 之前直接从参数读取。再否则从同名顶层参数读取该种类在 `typert.resources` 中登记的默认字段,没有同名参数时读第一个对象参数的同名字段。读取函数抛错或返回空列表、默认字段读到数组、或某个身份(含 Context 身份)不是非空字符串时,解析结果为 `invalid`;不指明任何资源的调用只访问 `global` 资源,指明了资源的调用只访问这些资源。转发事件携带其条目声明的每个资源,因此在事件路径上读取函数返回 `undefined` 同样使解析结果为 `invalid`,而没有读取函数的条目访问 `global`。
|
||||
资源从已校验参数中读取:Typert 编译 codec 会移除未声明的对象字段,SRC fallback 只检查 JSON 安全性而不移除字段;lookup 与 Context 身份保持 wire 值。对每个资源种类,方法 `@Remote({ resources })` 或 `@RemoteScope(key, { resources })` options 中的读取函数优先;它以方法的位置参数调用(lookup 位为 wire 身份,取消参数位为调用的 signal),从根 Service 实例的类元数据读取。否则 provider 经 `resource` 声明了该种类的每个 lookup 参数贡献其 wire 身份,省略的可选 lookup 参数不贡献;Host Context adapter 声明了该种类的 Context 方法贡献其 Context 身份,在解析 Context 之前直接从参数读取。再否则使用该种类在 `typert.resources` 中登记的默认字段或同步 resolver。字段规则读取同名顶层参数,没有时读取第一个对象参数的同名字段。resolver 以 `{ args, parameters }` 调用一次:命名的已校验 wire 值,以及包含 `name`、`wire` 的有序参数描述。两种默认规则均返回单个身份或 `undefined`,数组无效。显式读取函数可以返回多个身份。resolver 或读取函数抛错、读取函数返回空列表、或某个身份(含 Context 身份)不是非空字符串时,解析结果为 `invalid`;不指明任何资源的调用只访问 `global` 资源,指明了资源的调用只访问这些资源。转发事件携带其条目声明的每个资源,因此在事件路径上读取函数返回 `undefined` 同样使解析结果为 `invalid`,而没有读取函数的条目访问 `global`。
|
||||
|
||||
支持取消的 Remote 方法会把 `signal: AbortSignal` 声明为最后一个 Host 参数。signal 是 descriptor 元数据,而不是 wire 参数:Connection 将它提供给 Gateway,Gateway 则在已解码的业务参数之后注入它。SRC 识别这个保留的末位参数名,严格生成还要求它具有全局 `AbortSignal` 类型。
|
||||
|
||||
|
||||
@@ -22,10 +22,10 @@ type ResourceIds = readonly unknown[] | 'invalid'
|
||||
* `resources` reader of the method wins; otherwise every lookup parameter
|
||||
* whose provider declares the kind contributes its wire resource id, and so
|
||||
* does the Context identity of a Context method whose Host adapter declares
|
||||
* the kind; otherwise the kind's default field registered in
|
||||
* `typert.resources` is read from a top-level argument or, failing that,
|
||||
* from the first object argument.
|
||||
* @param typert - registry holding default fields, lookup providers, and Host Context adapters.
|
||||
* the kind; otherwise the kind's registered default resolver receives named
|
||||
* wire values and ordered parameter descriptions. A default field instead
|
||||
* reads a top-level argument or, failing that, the first object argument.
|
||||
* @param typert - registry holding default fields or resolvers, lookup providers, and Host Context adapters.
|
||||
* @param readers - the method's `resources` readers, or `undefined` when it declares none.
|
||||
* @param descriptor - receiver, parameter order, lookups, and cancellation slot of the method.
|
||||
* @param args - named arguments matched to the descriptor's field set: JSON
|
||||
@@ -33,9 +33,9 @@ type ResourceIds = readonly unknown[] | 'invalid'
|
||||
* identities as wire values.
|
||||
* @param signal - value placed in the cancellation slot of the positional arguments.
|
||||
* @returns the touched resources, the global resource alone when none;
|
||||
* `undefined` when the resources are invalid: a reader throws or returns
|
||||
* an empty list, a default field holds an array, or a resource id is not a
|
||||
* non-empty string.
|
||||
* `undefined` when the resources are invalid: a reader or resolver throws,
|
||||
* a reader returns an empty list, a default field or resolver holds an
|
||||
* array, or a resource id is not a non-empty string.
|
||||
*/
|
||||
export function callResources(
|
||||
typert: Pick<TypertRegistryContract, 'contexts' | 'lookups' | 'resources'>,
|
||||
@@ -46,25 +46,32 @@ export function callResources(
|
||||
): readonly RemoteAccessResource[] | undefined {
|
||||
// Each later source replaces an earlier one for its kind and keeps the kind's position.
|
||||
const sources = new Map<string, () => ResourceIds>()
|
||||
for (const { kind, field } of typert.resources.entries()) {
|
||||
sources.set(kind, () => {
|
||||
// A default field names exactly one resource id; only a reader may return several.
|
||||
const id = defaultFieldId(descriptor, args, field)
|
||||
const positionalArgs: readonly unknown[] = [
|
||||
...descriptor.parameters.map(parameter => args[parameter.wire]),
|
||||
...descriptor.cancellation === undefined ? [] : [signal],
|
||||
]
|
||||
for (const declaration of typert.resources.entries()) {
|
||||
sources.set(declaration.kind, () => {
|
||||
let id: unknown
|
||||
if (declaration.resolve !== undefined) {
|
||||
const resolve = declaration.resolve
|
||||
try {
|
||||
id = resolve({ args, parameters: descriptor.parameters })
|
||||
} catch {
|
||||
// A resolver failure cannot identify an authorized resource.
|
||||
return 'invalid'
|
||||
}
|
||||
} else {
|
||||
id = defaultFieldId(descriptor, args, declaration.field)
|
||||
}
|
||||
return Array.isArray(id) ? 'invalid' : id === undefined ? [] : [id]
|
||||
})
|
||||
}
|
||||
for (const [kind, ids] of lookupResources(typert, descriptor, args)) {
|
||||
sources.set(kind, () => ids)
|
||||
}
|
||||
let positional: readonly unknown[] | undefined
|
||||
for (const [kind, reader] of Object.entries(readers ?? {})) {
|
||||
sources.set(kind, () => {
|
||||
positional ??= [
|
||||
...descriptor.parameters.map(parameter => args[parameter.wire]),
|
||||
...descriptor.cancellation === undefined ? [] : [signal],
|
||||
]
|
||||
return read(reader, positional, [])
|
||||
})
|
||||
sources.set(kind, () => read(() => reader(...positionalArgs), []))
|
||||
}
|
||||
const resources: RemoteAccessResource[] = []
|
||||
for (const [kind, ids] of sources) {
|
||||
@@ -91,7 +98,7 @@ export function eventResources(
|
||||
if (declared.length === 0) return GLOBAL
|
||||
const resources: RemoteAccessResource[] = []
|
||||
for (const [kind, reader] of declared) {
|
||||
if (!collect(resources, kind, read(reader, args, 'invalid'))) return undefined
|
||||
if (!collect(resources, kind, read(() => reader(...args), 'invalid'))) return undefined
|
||||
}
|
||||
return resources
|
||||
}
|
||||
@@ -129,11 +136,11 @@ function lookupResources(
|
||||
return ids
|
||||
}
|
||||
|
||||
/** Run one reader with spread positional arguments: `undefined` yields `absent`, a list names several and must not be empty. */
|
||||
function read(reader: TypertResourceReader, args: readonly unknown[], absent: ResourceIds): ResourceIds {
|
||||
/** Read resource identities: `undefined` yields `absent`, a list names several and must not be empty. */
|
||||
function read(reader: () => unknown, absent: ResourceIds): ResourceIds {
|
||||
let value: unknown
|
||||
try {
|
||||
value = reader(...args)
|
||||
value = reader()
|
||||
} catch {
|
||||
// Any reader error means it cannot name its resources, so they are invalid.
|
||||
return 'invalid'
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
type TypertContext,
|
||||
type TypertLookup,
|
||||
type TypertResource,
|
||||
type TypertResourceResolveInput,
|
||||
} from '@deepseek-ai/dsh-typert-protocol'
|
||||
import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
|
||||
import { z } from 'zod'
|
||||
@@ -48,6 +49,7 @@ declare module '@deepseek-ai/dsh-typert-protocol' {
|
||||
/** Test-only resource kinds; the Gateway itself names no kind. */
|
||||
'test-shelf': TypertResource<string>
|
||||
'test-desk': TypertResource<string>
|
||||
'test-frame': TypertResource<string>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,11 +60,12 @@ const GUEST: Guest = { kind: 'test-guest', guest: 'gateway' }
|
||||
const GLOBAL = { kind: 'global' } as const
|
||||
const shelf = (id: string) => ({ kind: 'test-shelf', id }) as const
|
||||
const desk = (id: string) => ({ kind: 'test-desk', id }) as const
|
||||
const frame = (id: string) => ({ kind: 'test-frame', id }) as const
|
||||
|
||||
const FIXTURE_ACTIONS: readonly RemoteAccessAction[] = [
|
||||
'accessFixture/open', 'accessFixture/read', 'accessFixture/byAgent', 'accessFixture/byLookup',
|
||||
'accessFixture/byLookupAndOption', 'accessFixture/byList', 'accessFixture/scoped', 'accessFixture/scopedOnDesk',
|
||||
'accessFixture/scopedByReader', 'accessFixture/annotate', 'absentAccess/read', 'event:fixture/notice', 'event:fixture/shelf',
|
||||
'accessFixture/scopedByReader', 'accessFixture/annotate', 'accessFixture/bySession', 'absentAccess/read', 'event:fixture/notice', 'event:fixture/shelf',
|
||||
]
|
||||
|
||||
function role<K extends RemoteAccessResourceKind>(resource: K): RemoteAccessRole<K> {
|
||||
@@ -78,6 +81,8 @@ function on(resource: RemoteAccessResource): RemoteAccessBinding {
|
||||
return { role: role('test-shelf'), resource }
|
||||
case 'test-desk':
|
||||
return { role: role('test-desk'), resource }
|
||||
case 'test-frame':
|
||||
return { role: role('test-frame'), resource }
|
||||
default:
|
||||
throw new Error('no fixture role for this resource kind')
|
||||
}
|
||||
@@ -126,6 +131,12 @@ class AccessFixture extends TypertRemoteService {
|
||||
return this.seen()
|
||||
}
|
||||
|
||||
@Remote
|
||||
bySession(sessionId: string): string {
|
||||
void sessionId
|
||||
return this.seen()
|
||||
}
|
||||
|
||||
@Remote
|
||||
annotate(request: { readonly title: string }): string {
|
||||
this.requests.push(request)
|
||||
@@ -365,6 +376,22 @@ describe('Gateway access checks', () => {
|
||||
expect(decisions()).toEqual([{ action: 'absentAccess/read', resources: [GLOBAL] }])
|
||||
})
|
||||
|
||||
it('passes declared parameter names to a registered resolver through dispatch', async () => {
|
||||
const { ctx, hold } = await setup()
|
||||
const resolve = vi.fn(({ args, parameters }: TypertResourceResolveInput) => {
|
||||
const named = parameters.find(parameter => parameter.name === 'sessionId')
|
||||
return named === undefined ? undefined : args[named.wire] as string
|
||||
})
|
||||
const dispose = ctx.typert.resources.register('test-frame', { resolve })
|
||||
const guest = peerFor(ctx, GUEST)
|
||||
hold(frame('f1'))
|
||||
await expect(call(ctx, guest, 'bySession', { sessionId: 'f1' })).resolves.toBe('test-guest')
|
||||
await expect(call(ctx, guest, 'bySession', { sessionId: 'f2' }))
|
||||
.rejects.toMatchObject({ code: 'remote-access/denied' })
|
||||
expect(resolve).toHaveBeenCalledTimes(2)
|
||||
await dispose()
|
||||
})
|
||||
|
||||
it('checks access before resolving lookups, so a refused call activates nothing', async () => {
|
||||
const { ctx, hold } = await setup()
|
||||
const resolve = vi.fn((id: string) => ({ id }))
|
||||
@@ -868,6 +895,55 @@ describe('resource resolution', () => {
|
||||
expect(callResources(typert, undefined, method(), {}, NEVER)).toEqual([GLOBAL])
|
||||
})
|
||||
|
||||
it('resolves default identities through the registered function and preserves explicit overrides', async () => {
|
||||
const ctx = new Context()
|
||||
roots.push(ctx)
|
||||
await ctx.plugin(TypertRegistry)
|
||||
const resolve = vi.fn(({ args, parameters }: TypertResourceResolveInput) => {
|
||||
const named = parameters.find(parameter => parameter.wire === 'roomId')
|
||||
if (named !== undefined) return args[named.wire] as string | undefined
|
||||
const first = parameters[0]
|
||||
const request = first === undefined ? undefined : args[first.wire] as { address?: { id: string } } | undefined
|
||||
return request?.address?.id
|
||||
})
|
||||
const dispose = ctx.typert.resources.register('test-shelf', { resolve })
|
||||
expect(callResources(ctx.typert, undefined, method('roomId'), { roomId: 'r1' }, NEVER)).toEqual([ROOM])
|
||||
expect(callResources(ctx.typert, undefined, method('request'), { request: { address: { id: 'r1' } } }, NEVER)).toEqual([ROOM])
|
||||
expect(callResources(ctx.typert, undefined, method('request'), { request: {} }, NEVER)).toEqual([GLOBAL])
|
||||
resolve.mockClear()
|
||||
const args = { other: {}, later: { address: { id: 'r1' } } }
|
||||
expect(callResources(ctx.typert, undefined, method('other', 'later'), args, NEVER)).toEqual([GLOBAL])
|
||||
expect(resolve).toHaveBeenCalledExactlyOnceWith({ args, parameters: method('other', 'later').parameters })
|
||||
resolve.mockClear()
|
||||
expect(callResources(ctx.typert, undefined, {
|
||||
...method('renamed'), cancellation: { parameter: 'signal' },
|
||||
}, { renamed: { address: { id: 'r1' } } }, NEVER)).toEqual([ROOM])
|
||||
expect(resolve).toHaveBeenCalledExactlyOnceWith({
|
||||
args: { renamed: { address: { id: 'r1' } } }, parameters: method('renamed').parameters,
|
||||
})
|
||||
expect(callResources(ctx.typert, { 'test-shelf': () => 'override' }, method('request'), {
|
||||
request: { address: { id: 'r1' } },
|
||||
}, NEVER)).toEqual([shelf('override')])
|
||||
await dispose()
|
||||
expect(ctx.typert.resources.entries()).toEqual([])
|
||||
ctx.typert.resources.register('test-shelf', { resolve: () => { throw new Error('cannot resolve') } })
|
||||
expect(callResources(ctx.typert, undefined, method('request'), { request: {} }, NEVER)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects arrays from a default resolver instead of authorizing several identities', async () => {
|
||||
const ctx = new Context()
|
||||
roots.push(ctx)
|
||||
await ctx.plugin(TypertRegistry)
|
||||
for (const ids of [[], ['r1'], ['r1', 'r2']]) {
|
||||
const dispose = ctx.typert.resources.register('test-shelf', {
|
||||
// Exercise invalid results from an untyped provider at the authorization boundary.
|
||||
resolve: () => ids as never,
|
||||
})
|
||||
expect(callResources(ctx.typert, undefined, method(), {}, NEVER)).toBeUndefined()
|
||||
await dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('lets a reader replace the defaults for its kind, called with the positional wire arguments', async () => {
|
||||
const typert = await registry()
|
||||
const readers = { 'test-shelf': (ids: unknown) => ids }
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
en: a18ecc232e3c3933
|
||||
zh: b8e43580403f6d77
|
||||
/deepseek-ai-dsh-api-remote-access/host-service-remoteaccessservice-ctx-key-remoteaccess:
|
||||
en: 8a4be795c7aa2ef0
|
||||
zh: 02550b975e2e8d61
|
||||
en: 757a6bdf95e824c6
|
||||
zh: e8ae9712ad28c2f3
|
||||
/deepseek-ai-dsh-api-remote-access/client-face:
|
||||
en: d4cf8f646fdf2d39
|
||||
zh: 247f643e991aa806
|
||||
|
||||
@@ -37,7 +37,7 @@ Every composition that mounts [API Gateway](../gateway/README.md) mounts this se
|
||||
|
||||
A role `{ name, resource, actions }` is a plain value naming an action set over one resource kind, `global` or a kind declared in `TypertResourceMap`. A binding `{ role, resource, condition? }` grants the role on one resource of the role's kind. A policy answers `bindings(principal)` and an optional `explain(request)` synchronously from state it holds in memory. A request is allowed only when, on every resolved resource, some binding names that resource, its role lists the action, and it has no condition or its condition allows. Resources are independent: a grant on `global` implies nothing on any other resource, and no resource implies another. A condition receives `{ action, resource, args? }` and runs only for a binding whose resource and role match; `args` is absent for events, snapshots, and list filtering. `bindings()` runs once per `decide()`, once per `allows()` check, and once per snapshot computation; it is never cached across decisions, so time-dependent state applies to the next call.
|
||||
|
||||
The Gateway resolves resources from `@Remote` and `@RemoteScope` `resources` readers, lookup and Host Context adapter `resource` declarations, and the default fields in `typert.resources`, and the [API Gateway](../gateway/README.md) owns that order; this package reads no argument field. A call that names no resource touches the `global` resource alone, so only a `global` role can allow it; putting an action in a `global` role lets the principal call it on any resource the call does not name. The Gateway refuses a call whose resources are invalid with `resource-invalid` without asking `decide()`.
|
||||
The Gateway resolves resources from `@Remote` and `@RemoteScope` `resources` readers, lookup and Host Context adapter `resource` declarations, and the default field or resolver declarations in `typert.resources`, and the [API Gateway](../gateway/README.md) owns that order; this package reads no argument field. A call that names no resource touches the `global` resource alone, so only a `global` role can allow it; putting an action in a `global` role lets the principal call it on any resource the call does not name. The Gateway refuses a call whose resources are invalid with `resource-invalid` without asking `decide()`.
|
||||
|
||||
When `bindings()`, a condition, or `explain()` throws, the decision is refused with `policy-failed`; in a snapshot, a throwing `bindings()` leaves the principal no grant and a throwing condition excludes its action. A faulty rule therefore never breaks the shared `$events` source, the snapshot stream, or page rendering. The service logger reports the first failure of each policy after each `invalidate()` and each policy registration or removal; later failures of that policy stay silent until then.
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ kind: "package-reference"
|
||||
|
||||
role `{ name, resource, actions }` 是普通值,表示限定一种资源种类(`global` 或在 `TypertResourceMap` 中声明的种类)的 action 集合。binding `{ role, resource, condition? }` 在该 role 种类的一个资源上授予该 role。policy 根据预先载入内存的状态同步回答 `bindings(principal)` 与可选的 `explain(request)`。只有在每个已解析资源上都有某个 binding 指向该资源、其 role 列出该 action、且没有 condition 或 condition 放行时,请求才被放行。资源之间相互独立:`global` 上的授权不蕴含任何其他资源上的授权,任何资源也不蕴含另一个资源。condition 收到 `{ action, resource, args? }`,只对资源与 role 都匹配的 binding 调用;事件、快照与列表过滤时没有 `args`。`bindings()` 在每次 `decide()`、每次 `allows()` 判定与每次快照计算中各调用一次,不跨判定缓存,因此依赖时间的状态对下一次调用即时生效。
|
||||
|
||||
Gateway 依次从 `@Remote` 与 `@RemoteScope` 的 `resources` 读取函数、lookup 与 Host Context adapter 的 `resource` 声明、`typert.resources` 中的默认字段解析资源,解析顺序由 [API Gateway](../gateway/README.zh.md) 负责;本包不读取任何参数字段。不指明任何资源的调用只访问 `global` 资源,因此只有 `global` 级 role 能放行;把某个 action 放进 `global` 级 role,等于允许该 principal 在调用未指明的任何资源上调用它。资源非法的调用由 Gateway 以 `resource-invalid` 拒绝,不询问 `decide()`。
|
||||
Gateway 依次从 `@Remote` 与 `@RemoteScope` 的 `resources` 读取函数、lookup 与 Host Context adapter 的 `resource` 声明、`typert.resources` 中的默认字段或 resolver 声明解析资源,解析顺序由 [API Gateway](../gateway/README.zh.md) 负责;本包不读取任何参数字段。不指明任何资源的调用只访问 `global` 资源,因此只有 `global` 级 role 能放行;把某个 action 放进 `global` 级 role,等于允许该 principal 在调用未指明的任何资源上调用它。资源非法的调用由 Gateway 以 `resource-invalid` 拒绝,不询问 `decide()`。
|
||||
|
||||
`bindings()`、condition 或 `explain()` 抛错时,本次判定以 `policy-failed` 拒绝;计算快照时,`bindings()` 抛错即该 principal 没有 grant,condition 抛错即该 action 不放行。有问题的规则因此不会破坏共享的 `$events` 事件源、快照流或页面渲染。每次 `invalidate()` 以及每次 policy 注册或撤回之后,服务日志对每个 policy 只报告第一次失败;此后该 policy 的失败不再记录,直到下一次上述变化。
|
||||
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
en: 1fdf707322fb6996
|
||||
zh: 0204f7e9d4aa3cce
|
||||
/session-controller/use-this-package:
|
||||
en: 413cb0808730d48a
|
||||
zh: cfff51724b9dc2bf
|
||||
en: 495c01b3ae16ff22
|
||||
zh: 09848c0fe8384ba9
|
||||
/session-controller/client-references:
|
||||
en: e8979a803ec3e194
|
||||
zh: c567efcd971728ba
|
||||
|
||||
@@ -35,7 +35,7 @@ The Client journal validates current Session event envelopes before publishing f
|
||||
|
||||
Each endpoint states its activation policy. List reads only stored headers and projection-cache rows: it never calls per-session stat or opens a cold Session body. A current-format cache identity may supply every list hint; a lifecycle-matching predecessor cache may supply only its version-compatible title as a stale display fact, never as an authoritative fold seed. Search, attachment, history pages, log following, skill discovery, and workspace-path opening can inspect persistence without activating an Agent; `canOpenWorkspacePath()` reports native-opening availability without addressing a Session. Cancellation requires live state; queue mutation, model, rename, prompt, and file-reference operations may resolve or resume an ordinary Session. Prompt rejects content with neither non-whitespace text nor an attachment before resolving the Agent or appending Session events; queue edits accept only non-empty text content. Prompt admission consumes opaque receipts from the injected [`fileUploads`](../../client/file-upload/README.md) Host service and resolves every same-Agent receipt before sending the complete ordered content list through `ctx.attachments`. A receipt names a staged file or a staged image, and an image receipt subjects the prompt to the same image-input check as an encoded image. Prompt retries whose `requestId` is already queued or logged return the original acceptance without inserting another message. Create and fork are the only operations that create a new Agent directly. The service applies one preset-aware resume policy and subagent ownership fence to its own methods and to the Typert Agent and Session lookups used by other Remote namespaces. Queue mutation has one narrow exception: a live child whose current projected identity is continuable and comes from its own non-seed suffix accepts the ordinary Edit, Remove, and QueueDock Steer actions across both inbox destinations. One-shot, missing, unknown, corrupt, seed-only, or cold children remain rejected without resume. The skill catalog uses a live Agent's effective working directory and scoped registry when present. Cold listing uses the projected current directory, falling back to the recorded origin and then the configured directory default. It retains the preset standing scope without activating an Agent. The authenticated delivery routes use `workspaceDesktop()` for the serving Host name and file-manager behavior. `openWorkspacePath({ path, action: "reveal" })` delegates file-manager navigation to the native adapter; omitting `action` opens the file-type association, including HTML and SVG. Both operations require the composed filesystem to map the requested Host path to the same canonical process path; unmapped remote paths are refused before a native command runs. `session.projections` normally reads an exact baseline, including current-format cold logs, and returns `kind: sequenced`. Only a read requiring migration returns `kind: migration-required` hints without `asOfSeq`; missing keys remain absent. It returns null for a missing Session. Fork callers may set `allowMigration: false` to receive `session/migration-required` instead of starting source migration; omission retains automatic migration. The Client exposes shared values and explicit-read state through `projectionsBySession`; domains select their own keys. Session-list summaries carry `agentAvailable`, updated through existing summary and status events independently of durable projections. Sequenced reads and live projection frames use the same sequence ordering; cached reads fill hints without replacing sequenced values.
|
||||
|
||||
Every Remote endpoint is access-controlled: a Peer whose principal is not the owner reaches an endpoint only when, on every resource the call touches, a binding of that principal kind's policy in [`dsh-api-remote-access`](../remote-access/README.md) grants a role listing the action ([Peer principals Agent Note](../../../.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.md)). The controller injects `remoteAccess` and registers the `session` resource kind with the default field `sessionId` through `typert.resources`. `session/page` and `session/follow` declare a `session` reader in `@Remote({ resources })` that reads `request.address.sessionId` for an ordinary Session address and `request.address.childSessionId` for a subagent address; whether access to the parent Session covers its children is the policy's rule. `session/projections` reads the Session resource from `request.sessionId`, so a reconnecting reader reloads only the projections of Sessions it may read. `session/list`, `session/search`, and `session/control` read no resource, so their calls touch the `global` resource and need a `global` role; for a non-owner caller, `session/list` returns only the rows, `session/search` only the hits, and `session/control` only the baseline projections and projection frames, whose Session `ctx.remoteAccess.allows()` admits for the same action, so that action also belongs to a `session` role; owner results are unfiltered. `session/prompt` builds the durable `user-rpc` message source in its Remote method and records `principal` only for a non-owner caller, read through `principalOf(this.ctx.invocation)`; a source without `principal` belongs to the owner.
|
||||
Every Remote endpoint is access-controlled: a Peer whose principal is not the owner reaches an endpoint only when, on every resource the call touches, a binding of that principal kind's policy in [`dsh-api-remote-access`](../remote-access/README.md) grants a role listing the action ([Peer principals Agent Note](../../../.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.md)). The controller injects `remoteAccess` and registers a default resolver for the `session` resource kind through `typert.resources`. It reads a named `sessionId` parameter, otherwise the first object argument's `sessionId` or `address`; unrelated string parameters name no Session. An object containing both `sessionId` and `address` is invalid, even when they name the same Session. `session/page` and `session/follow` use this resolver to read `address.sessionId` for an ordinary Session address and `address.childSessionId` for a subagent address; whether access to the parent Session covers its children is the policy's rule. `session/projections` reads the Session resource from `request.sessionId`, so a reconnecting reader reloads only the projections of Sessions it may read. `session/list`, `session/search`, and `session/control` read no resource, so their calls touch the `global` resource and need a `global` role; for a non-owner caller, `session/list` returns only the rows, `session/search` only the hits, and `session/control` only the baseline projections and projection frames, whose Session `ctx.remoteAccess.allows()` admits for the same action, so that action also belongs to a `session` role; owner results are unfiltered. `session/prompt` builds the durable `user-rpc` message source in its Remote method and records `principal` only for a non-owner caller, read through `principalOf(this.ctx.invocation)`; a source without `principal` belongs to the owner.
|
||||
|
||||
List rows carry `formatStatus` when available, so Clients can identify migration-required Sessions without requesting their projections.
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ Client journal 在发布 follow 快照、live entry 或历史页之前验证当
|
||||
|
||||
每个 endpoint 都声明自己的激活策略。列表只读取持久化 header 与 projection cache row,绝不调用逐 Session stat 或打开冷 Session body。当前格式 cache identity 可以提供全部列表 hint;生命周期匹配的 predecessor cache 只能提供版本兼容的 title,作为可能过时的展示事实,绝不能作为权威 fold seed。搜索、附件、历史页、日志跟随、skill 发现和工作区路径打开可以在不激活 Agent 的情况下检查 persistence;`canOpenWorkspacePath()` 无需指定 Session 即可报告原生打开能力。取消要求 live 状态;queue 变更、模型、重命名、prompt 和文件引用操作可以解析或恢复普通 Session。提示词会在解析 Agent 或追加 Session 事件前,拒绝既没有非空白文本也没有附件的 content;queue edit 只接受非空文本 content。prompt 准入从注入的 [`fileUploads`](../../client/file-upload/README.zh.md) Host 服务取得不透明凭证,在把完整有序内容列表交给 `ctx.attachments` 前解析每个属于同一 Agent 的凭证。凭证指向一个暂存文件或一张暂存图片;图片凭证与编码图片一样要经过图片输入检查。`requestId` 已进入 queue 或日志时,prompt 重试直接返回原来的接受结果,不会重复插入消息。只有 create 与 fork 会直接创建新 Agent。该服务把同一套感知 preset 的恢复策略和 subagent ownership fence 同时用于自身方法,以及其他 Remote namespace 使用的 Typert Agent 与 Session lookup。Queue 变更只有一个狭窄例外:当前 projection identity 为 continuable 且来自自身非 seed suffix 的在线 child,可以在两个 inbox 目标上使用普通 Edit、Remove 与 QueueDock Steer action。One-shot、缺失、未知、损坏、仅含 seed identity 或冷 child 继续被拒绝,且不会恢复。skill 目录优先使用已有 live Agent 的有效工作目录与限定范围内的注册表。冷态列表使用投影中的当前目录,依次回退到记录的起始目录与配置的默认目录。它保留 preset 常驻 scope,不会激活 Agent。经过鉴权的文件交付路由通过 `workspaceDesktop()` 获取提供服务的 Host 名称和文件管理器行为。`openWorkspacePath({ path, action: "reveal" })` 将文件管理器导航委托给原生适配器;省略 `action` 时按文件类型关联打开,包括 HTML 和 SVG。两种操作都要求当前文件系统将请求的 Host 路径映射到同一个规范进程路径;无法映射的远端路径会在执行原生命令之前被拒绝。 `session.projections` 正常读取准确基线,包括当前格式的冷日志,返回 `kind: sequenced`。只有需要迁移时才返回不带 `asOfSeq` 的 `kind: migration-required` 提示,缺少的键保持缺席。Session 不存在时返回 null。Fork 调用者可设置 `allowMigration: false`,在源历史需要迁移时收到 `session/migration-required`;省略则保持自动迁移。Client 通过 `projectionsBySession` 暴露共享值和显式读取状态,由领域选择自身的 key。Session 列表摘要携带 `agentAvailable`,通过已有摘要与状态事件更新,与持久化 projection 相互独立。有序读取与实时 projection 帧使用相同的序号排序规则;缓存读取只补充提示,不替换有序值。
|
||||
|
||||
每个 Remote 端点都受访问控制:principal 不是 owner 的 Peer 只有在本次调用访问的每个资源上,该 principal kind 在 [`dsh-api-remote-access`](../remote-access/README.zh.md) 中的 policy 都有某个 binding 授予列出该 action 的 role 时,才能调用该端点([Peer 身份属性 Agent Note](../../../.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md))。控制器注入 `remoteAccess`,并经 `typert.resources` 以默认字段 `sessionId` 登记 `session` 资源种类。`session/page` 与 `session/follow` 在 `@Remote({ resources })` 中声明 `session` 读取函数:对普通 Session 地址读 `request.address.sessionId`,对 subagent 地址读 `request.address.childSessionId`;父会话的访问权是否覆盖其子会话由 policy 决定。`session/projections` 从 `request.sessionId` 读取会话资源,因此重连的读者只会重新读取它可读的会话的 projections。`session/list`、`session/search` 与 `session/control` 不读取资源,其调用访问 `global` 资源,需要 `global` role;对非 owner 调用方,`session/list` 只返回其 Session 被 `ctx.remoteAccess.allows()` 按同一 action 允许的行,`session/search` 只返回这类 Session 的命中,`session/control` 也只返回这类 Session 的 baseline projection 与 projection 帧,因此该 action 也要列在 `session` role 中;owner 的结果不过滤。`session/prompt` 在其 Remote 方法内构造持久的 `user-rpc` 消息 source,且只为非 owner 调用方记录经 `principalOf(this.ctx.invocation)` 读取的 `principal`;不带 `principal` 的 source 属于 owner。
|
||||
每个 Remote 端点都受访问控制:principal 不是 owner 的 Peer 只有在本次调用访问的每个资源上,该 principal kind 在 [`dsh-api-remote-access`](../remote-access/README.zh.md) 中的 policy 都有某个 binding 授予列出该 action 的 role 时,才能调用该端点([Peer 身份属性 Agent Note](../../../.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md))。控制器注入 `remoteAccess`,并经 `typert.resources` 为 `session` 资源种类登记默认 resolver。它读取具名 `sessionId` 参数,否则读取第一个对象参数的 `sessionId` 或 `address`;无关字符串参数不指明 Session。对象同时包含 `sessionId` 和 `address` 时无效,即使两者指向同一 Session。`session/page` 与 `session/follow` 使用该 resolver:对普通 Session 地址读 `address.sessionId`,对 subagent 地址读 `address.childSessionId`;父会话的访问权是否覆盖其子会话由 policy 决定。`session/projections` 从 `request.sessionId` 读取会话资源,因此重连的读者只会重新读取它可读的会话的 projections。`session/list`、`session/search` 与 `session/control` 不读取资源,其调用访问 `global` 资源,需要 `global` role;对非 owner 调用方,`session/list` 只返回其 Session 被 `ctx.remoteAccess.allows()` 按同一 action 允许的行,`session/search` 只返回这类 Session 的命中,`session/control` 也只返回这类 Session 的 baseline projection 与 projection 帧,因此该 action 也要列在 `session` role 中;owner 的结果不过滤。`session/prompt` 在其 Remote 方法内构造持久的 `user-rpc` 消息 source,且只为非 owner 调用方记录经 `principalOf(this.ctx.invocation)` 读取的 `principal`;不带 `principal` 的 source 属于 owner。
|
||||
|
||||
列表行在状态可用时携带 `formatStatus`,Client 无需请求其投影即可识别需要迁移的 Session。
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ import { installModelSelection } from '@deepseek-ai/dsh-agent'
|
||||
import type {} from '@deepseek-ai/dsh-agent-preset-registry'
|
||||
import { brandString } from '@deepseek-ai/dsh-brand'
|
||||
import type { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionInspection } from '@deepseek-ai/dsh-session-persistence'
|
||||
import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query'
|
||||
import { RemoteError, remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import type {} from '@deepseek-ai/dsh-typert-registry'
|
||||
@@ -123,40 +122,6 @@ export function apiSessionSubagentOwnershipError(sessionId: SessionId): ApiSessi
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Inspect one cold Session without repairing, resuming, or publishing it.
|
||||
* @param ctx - Host context carrying Session persistence.
|
||||
* @param sessionId - durable Session identity.
|
||||
* @param signal - optional cancellation for persistence reads.
|
||||
* @returns the persisted header and complete event prefix.
|
||||
*/
|
||||
export async function inspectApiSession(
|
||||
ctx: Context,
|
||||
sessionId: SessionId,
|
||||
signal?: AbortSignal,
|
||||
): Promise<SessionInspection> {
|
||||
try {
|
||||
using observation = await ctx.sessionQuery.observeSession(sessionId, {
|
||||
...(signal === undefined ? {} : { signal }),
|
||||
projectionMode: 'none',
|
||||
})
|
||||
if (observation.header.cwd === undefined) {
|
||||
throw new ApiSessionNotFound(`session "${sessionId}" not found`)
|
||||
}
|
||||
return {
|
||||
meta: observation.header,
|
||||
inheritedEventCount: observation.inheritedEventCount,
|
||||
events: [...observation.events],
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof SessionQueryError
|
||||
&& error.code === 'SESSION_QUERY_SESSION_NOT_FOUND') {
|
||||
throw new ApiSessionNotFound(`session "${sessionId}" not found`)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/** Owns every operation that may create, resume, or configure a Web Agent. */
|
||||
export class ApiSessionAgentController {
|
||||
private readonly resumes = new Map<SessionId, Promise<Agent>>()
|
||||
|
||||
@@ -31,7 +31,6 @@ import {
|
||||
ApiSessionSubagentOwnership,
|
||||
apiSessionSubagentOwnershipError,
|
||||
hasApiSessionSubagentOwner,
|
||||
inspectApiSession,
|
||||
type ApiSessionCreated,
|
||||
} from './agent.ts'
|
||||
import type {
|
||||
@@ -54,12 +53,6 @@ import type {
|
||||
SessionRequestId,
|
||||
} from './types.ts'
|
||||
|
||||
interface SessionReadState {
|
||||
readonly id: SessionId
|
||||
readonly header: SessionHeader
|
||||
readonly events: readonly SessionEvent[]
|
||||
}
|
||||
|
||||
type PromptContentCandidate =
|
||||
| SessionPromptRequest['content'][number]
|
||||
| Extract<SessionUpdateQueueRequest['action'], { readonly kind: 'edit' }>['content'][number]
|
||||
@@ -371,11 +364,16 @@ export class SessionCommandController {
|
||||
* @returns the durable attachment reference and base64-encoded bytes.
|
||||
*/
|
||||
async attachment(request: SessionAttachmentRequest): Promise<SessionAttachmentValue> {
|
||||
let source: SessionReadState
|
||||
let ref: ImageAttachmentRef | undefined
|
||||
try {
|
||||
source = await this.readSessionState(request.sessionId)
|
||||
using observation = await this.ctx.sessionQuery.observeSession(request.sessionId, { projectionMode: 'none' })
|
||||
if (observation.source === 'prepared' && observation.header.cwd === undefined) {
|
||||
throw new ApiSessionNotFound(`session "${request.sessionId}" not found`)
|
||||
}
|
||||
ref = referencedImage(observation.events, String(request.attachmentId))
|
||||
} catch (error) {
|
||||
if (error instanceof ApiSessionNotFound) {
|
||||
if (error instanceof ApiSessionNotFound
|
||||
|| (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_SESSION_NOT_FOUND')) {
|
||||
throw new RemoteError('session/not-found', error.message, { sessionId: request.sessionId })
|
||||
}
|
||||
throw new RemoteError(
|
||||
@@ -384,7 +382,6 @@ export class SessionCommandController {
|
||||
{},
|
||||
)
|
||||
}
|
||||
const ref = referencedImage(source.events, String(request.attachmentId))
|
||||
if (ref === undefined) {
|
||||
throw new RemoteError(
|
||||
'session/attachment-invalid',
|
||||
@@ -533,16 +530,6 @@ export class SessionCommandController {
|
||||
throw new RemoteError('gateway/internal', `failed to create session: ${String(error)}`, {})
|
||||
}
|
||||
|
||||
private async readSessionState(sessionId: SessionId): Promise<SessionReadState> {
|
||||
const attached = this.ctx.sessions.get(sessionId)
|
||||
if (attached !== undefined) {
|
||||
// oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
|
||||
return { id: attached.id, header: attached.header, events: attached.snapshotEvents() }
|
||||
}
|
||||
const inspected = await inspectApiSession(this.ctx, sessionId)
|
||||
return { id: inspected.meta.id, header: inspected.meta, events: inspected.events }
|
||||
}
|
||||
|
||||
private async forkWorkspace(source: SessionHeader): Promise<Workspace | undefined> {
|
||||
const workspaces = this.ctx.workspaceRegistry.list()
|
||||
const direct = workspaces.find(workspace => workspace.sessionIds.includes(source.id))
|
||||
|
||||
@@ -10,16 +10,14 @@ import { errorChain, ReasoningEffortId, type MessageSource } from '@deepseek-ai/
|
||||
import type {} from '@deepseek-ai/dsh-client-file-upload'
|
||||
import { canOpenNativePath, nativeFileManager, nativeFileApplications, openNativeFileApplication, openNativeAssociatedPath, revealNativePath } from '@deepseek-ai/dsh-native-command'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionInspection } from '@deepseek-ai/dsh-session-persistence'
|
||||
import type {} from '@deepseek-ai/dsh-session-projection-cache'
|
||||
import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query'
|
||||
import { Remote, RemoteError, TypertRemoteService, principalOf } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import { Remote, RemoteError, TypertRemoteService, principalOf, type TypertResourceResolveInput } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import { canonicalClientTimeZone } from '@deepseek-ai/dsh-util-time'
|
||||
import { assertNever } from '@deepseek-ai/dsh-util-values'
|
||||
import type {} from '@deepseek-ai/dsh-api-remote-access'
|
||||
import {
|
||||
ApiSessionAgentController,
|
||||
inspectApiSession,
|
||||
type ApiSessionAgentResult,
|
||||
} from './agent.ts'
|
||||
import { SessionCommandController, hasPromptContent } from './commands.ts'
|
||||
@@ -80,13 +78,31 @@ declare module '@deepseek-ai/cordis' {
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the Session resource a history address names: the Session itself, or
|
||||
* the child Session whose transcript a subagent address reads.
|
||||
* @param request - history request carrying the durable address.
|
||||
* @returns the Session whose access the read requires.
|
||||
* Extract the Session resource from an ID or a history address.
|
||||
*
|
||||
* Registers as the `session` kind default, so it applies to every Remote method of
|
||||
* every namespace: a top-level `sessionId` parameter, otherwise the first object
|
||||
* parameter carrying `sessionId` or an `address`. A new request type whose first
|
||||
* object parameter has an unrelated `address` field would be read as an address.
|
||||
* @param input - validated wire arguments and their ordered parameter descriptions.
|
||||
* @returns the named Session identity, otherwise the first object's Session identity or history address.
|
||||
* @throws Error when the request object supplies both sessionId and address.
|
||||
*/
|
||||
function addressedSession(request: { readonly address: SessionAddress }): SessionId {
|
||||
return request.address.kind === 'session' ? request.address.sessionId : request.address.childSessionId
|
||||
function resolveSessionResource({ args, parameters }: TypertResourceResolveInput): SessionId | undefined {
|
||||
const sessionParameter = parameters.find(parameter => parameter.name === 'sessionId' || parameter.wire === 'sessionId')
|
||||
if (sessionParameter !== undefined) return args[sessionParameter.wire] as SessionId | undefined
|
||||
for (const parameter of parameters) {
|
||||
const value = args[parameter.wire]
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) continue
|
||||
const request = value as { readonly sessionId?: SessionId; readonly address?: SessionAddress }
|
||||
const address = request.address
|
||||
if (request.sessionId !== undefined && address !== undefined) {
|
||||
throw new Error('Session resource request must not contain both sessionId and address')
|
||||
}
|
||||
return request.sessionId ?? (address === undefined ? undefined
|
||||
: address.kind === 'session' ? address.sessionId : address.childSessionId)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Session Controller deployment policy. */
|
||||
@@ -167,7 +183,7 @@ export class SessionController extends TypertRemoteService {
|
||||
|
||||
protected async [Service.init](): Promise<void> {
|
||||
const ctx = this.ctx
|
||||
ctx.typert.resources.register('session', { field: 'sessionId' })
|
||||
ctx.typert.resources.register('session', { resolve: resolveSessionResource })
|
||||
this.agents = new ApiSessionAgentController(ctx)
|
||||
this.agents.initialize()
|
||||
this.commands = new SessionCommandController(ctx, this.agents, process.cwd())
|
||||
@@ -240,28 +256,6 @@ export class SessionController extends TypertRemoteService {
|
||||
return this.agents.resolveAgent(sessionId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Inspect one attached or persisted Session without activating its Agent.
|
||||
* @param sessionId - durable Session identity.
|
||||
* @param signal - optional caller cancellation for persistence reads.
|
||||
* @returns the current attached state or persisted header and event prefix.
|
||||
*/
|
||||
inspect(
|
||||
sessionId: SessionId,
|
||||
signal?: AbortSignal,
|
||||
): Promise<SessionInspection> {
|
||||
const attached = this.ctx.sessions.get(sessionId)
|
||||
if (attached !== undefined) {
|
||||
return Promise.resolve({
|
||||
meta: attached.header,
|
||||
inheritedEventCount: attached.inheritedEventCount,
|
||||
// oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
|
||||
events: attached.snapshotEvents(),
|
||||
})
|
||||
}
|
||||
return inspectApiSession(this.ctx, sessionId, signal)
|
||||
}
|
||||
|
||||
/**
|
||||
* Read all visible Session rows without resuming an Agent.
|
||||
* @param _request - reserved empty list request.
|
||||
@@ -517,7 +511,7 @@ export class SessionController extends TypertRemoteService {
|
||||
* @param signal - cancellation for persistence reads.
|
||||
* @returns one chronological page.
|
||||
*/
|
||||
@Remote({ name: 'page', resources: { session: addressedSession } })
|
||||
@Remote('page')
|
||||
page(request: SessionPageRequest, signal: AbortSignal): Promise<SessionPage> {
|
||||
return this.history.page(request, signal)
|
||||
}
|
||||
@@ -529,7 +523,7 @@ export class SessionController extends TypertRemoteService {
|
||||
* @returns a complete opening snapshot followed by gap-free durable event
|
||||
* frames and optional cursorless assistant-stream frames.
|
||||
*/
|
||||
@Remote({ mode: 'stream', resources: { session: addressedSession } })
|
||||
@Remote({ mode: 'stream' })
|
||||
follow(request: SessionFollowRequest, signal: AbortSignal): AsyncIterable<SessionFollowFrame> {
|
||||
return this.history.follow(request, signal)
|
||||
}
|
||||
@@ -610,9 +604,6 @@ export class SessionController extends TypertRemoteService {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
export { buildModelCatalog }
|
||||
export default SessionController
|
||||
Loaded 100 of 127 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user