feat(m4): verify signed surface bundles across hosts

This commit is contained in:
2026-08-04 01:51:23 +08:00
parent 4fc83e5b13
commit d8aa087bc8
21 changed files with 1135 additions and 8 deletions
+13
View File
@@ -0,0 +1,13 @@
# LineUp Wails v3 对照 SpikeM4-05
此目录是受限的 Wails 可行性验证,不是第二套 LineUp 客户端、更不是生产发布物。
固定依赖为 `github.com/wailsapp/wails/v3 v3.0.0-beta.3``go test ./contract` 在本机执行 Tauri Reference Host 维护的同一份版本化协议 fixture:`../tauri/src/runtime/golden/lineup-v1.json``GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go test -c ./...` 则编译真实 Wails `application` API;这是为不具备 GTK4/WebKitGTK 的 Linux 开发机保留的 API/跨编译门禁。
当前结论:Wails 能作为桌面 Host 候选运行同一份纯数据协议、Tool lifecycle 与安全拒绝 fixture;它不能因此获得任何比 Tauri 更多的权限。Surface 必须继续由 Host 校验 artifact、以 opaque/隔离 WebView 承载,并且不能注入 Wails bindingsCapability 只能由顶层 Host 在显式同意后执行。
本 Spike 的边界和后续准入:
- 已验证:beta.3 Go module、Wails application API 的 Windows/amd64 交叉编译、共享 golden fixture 的本机可执行投影、Surface source 字段拒绝。
- 尚不作为生产批准:完整窗口隔离/CSP、签名 artifact 加载、原生 capability handler、移动端和发布包,需要在 Wails 版本稳定后用与 Tauri 相同的有头验收清单完成。
- 这不改变 Android 当前暂缓状态,也不改变 LineUp v1 协议。
+129
View File
@@ -0,0 +1,129 @@
// Package contract is deliberately framework-free: production Wails wiring
// must not change LineUp's envelope/state semantics.
package contract
import (
"encoding/json"
"os"
"path/filepath"
"testing"
)
type fixture struct {
Contract string `json:"contract"`
Cases []testCase `json:"cases"`
}
type testCase struct {
ID string `json:"id"`
Expected expected `json:"expected"`
Envelopes []envelope `json:"envelopes"`
Transitions []transition `json:"transitions"`
}
type expected struct {
ItemKind string `json:"item_kind"`
Fallback string `json:"fallback"`
KernelToolStatus string `json:"kernel_tool_status"`
}
type transition struct {
AfterEnvelope int `json:"after_envelope"`
Kind string `json:"kind"`
CallID string `json:"call_id"`
}
type envelope struct {
Version int `json:"v"`
Type string `json:"type"`
Payload map[string]interface{} `json:"payload"`
}
func TestSharedLineUpV1GoldenContract(t *testing.T) {
bytes, err := os.ReadFile(filepath.Join("..", "..", "tauri", "src", "runtime", "golden", "lineup-v1.json"))
if err != nil {
t.Fatalf("read shared fixture: %v", err)
}
var spec fixture
if err := json.Unmarshal(bytes, &spec); err != nil {
t.Fatalf("parse shared fixture: %v", err)
}
if spec.Contract != "lineup-v1-golden-1" || len(spec.Cases) != 6 {
t.Fatalf("unexpected fixture declaration: %q (%d cases)", spec.Contract, len(spec.Cases))
}
for _, c := range spec.Cases {
t.Run(c.ID, func(t *testing.T) {
toolStates := map[string]string{}
kind, fallback := "", ""
for index, wire := range c.Envelopes {
kind, fallback = project(wire)
if kind == "tool-call" {
toolStates[stringValue(wire.Payload["call_id"])] = "pending"
}
for _, action := range c.Transitions {
if action.AfterEnvelope == index && action.Kind == "submit_tool_call" && toolStates[action.CallID] == "pending" {
toolStates[action.CallID] = "submitted"
}
}
if kind == "tool-result" {
callID := stringValue(wire.Payload["call_id"])
if toolStates[callID] == "submitted" {
toolStates[callID] = stringValue(wire.Payload["status"])
}
}
}
if kind != c.Expected.ItemKind || fallback != c.Expected.Fallback {
t.Fatalf("got kind=%q fallback=%q; want kind=%q fallback=%q", kind, fallback, c.Expected.ItemKind, c.Expected.Fallback)
}
if c.Expected.KernelToolStatus != "" {
ok := false
for _, state := range toolStates {
ok = ok || state == c.Expected.KernelToolStatus
}
if !ok {
t.Fatalf("tool state never reached %q: %#v", c.Expected.KernelToolStatus, toolStates)
}
}
})
}
}
func project(wire envelope) (kind, fallback string) {
if wire.Version != 1 {
return "fallback", "unsupported_version"
}
switch wire.Type {
case "lineup.v1.tool.call":
if stringValue(wire.Payload["call_id"]) == "" || stringValue(wire.Payload["tool"]) == "" {
return "fallback", "invalid_payload"
}
return "tool-call", ""
case "lineup.v1.tool.result":
if stringValue(wire.Payload["call_id"]) == "" || stringValue(wire.Payload["status"]) == "" {
return "fallback", "invalid_payload"
}
return "tool-result", ""
case "lineup.v1.ui.open", "lineup.v1.ui.patch", "lineup.v1.ui.close":
for _, forbidden := range []string{"bundle", "bundle_id", "bundle_url", "url", "html", "css", "javascript", "js", "script", "source"} {
if _, exists := wire.Payload[forbidden]; exists {
return "fallback", "invalid_payload"
}
}
return "surface", ""
case "lineup.v1.app.call":
if stringValue(wire.Payload["call_id"]) == "" || stringValue(wire.Payload["capability"]) == "" || stringValue(wire.Payload["reason"]) == "" || stringValue(wire.Payload["expires_at"]) == "" {
return "fallback", "invalid_payload"
}
if _, ok := wire.Payload["arguments"].(map[string]interface{}); !ok {
return "fallback", "invalid_payload"
}
return "app-call", ""
default:
return "fallback", "unsupported_type"
}
}
func stringValue(value interface{}) string {
text, _ := value.(string)
return text
}
+14
View File
@@ -0,0 +1,14 @@
module lineup-wails-spike
go 1.26.0
require github.com/wailsapp/wails/v3 v3.0.0-beta.3
require (
github.com/adrg/xdg v0.5.3 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
golang.org/x/sys v0.45.0 // indirect
)
+16
View File
@@ -0,0 +1,16 @@
github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78=
github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/wailsapp/wails/v3 v3.0.0-beta.3 h1:BrcZunEBVucncRx+xgkk9TzlXU4qc0ygJuEhKAAGaeA=
github.com/wailsapp/wails/v3 v3.0.0-beta.3/go.mod h1:BzATbK71VFikMMMCo434wAi0QcaI03P+xeaWgDvQvjw=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+30
View File
@@ -0,0 +1,30 @@
// The Wails M4 comparison host intentionally has no LineUp business logic in
// Go. The rendered shell consumes the same host-neutral contract fixture as
// Tauri; Go owns only native lifecycle and capability boundaries.
package main
import (
"net/http"
"github.com/wailsapp/wails/v3/pkg/application"
)
func newSpikeApplication() *application.App {
app := application.New(application.Options{
Name: "LineUp Wails Contract Spike",
Description: "M4 parity probe; not a production LineUp Host",
Assets: application.AssetOptions{Handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte("<!doctype html><meta charset=\"utf-8\"><title>LineUp Wails Contract Spike</title><main>Wails contract spike</main>"))
})},
})
app.Window.NewWithOptions(application.WebviewWindowOptions{
Title: "LineUp Wails Contract Spike",
URL: "/",
})
return app
}
func main() {
_ = newSpikeApplication().Run()
}
+151
View File
@@ -0,0 +1,151 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"github.com/wailsapp/wails/v3/pkg/application"
)
type goldenFixture struct {
Contract string `json:"contract"`
Cases []goldenCase `json:"cases"`
}
type goldenCase struct {
ID string `json:"id"`
Expected goldenExpected `json:"expected"`
Envelopes []wireEnvelope `json:"envelopes"`
Transitions []hostTransition `json:"transitions"`
}
type goldenExpected struct {
ItemKind string `json:"item_kind"`
Fallback string `json:"fallback"`
KernelToolStatus string `json:"kernel_tool_status"`
}
type hostTransition struct {
AfterEnvelope int `json:"after_envelope"`
Kind string `json:"kind"`
CallID string `json:"call_id"`
}
type wireEnvelope struct {
Version int `json:"v"`
Type string `json:"type"`
Payload map[string]interface{} `json:"payload"`
}
// TestGoldenContract is deliberately independent of Wails' JS bindings. It
// proves that a Wails Host can run the exact versioned fixture, and that the
// host's admission gate remains data-only before a WebView receives anything.
func TestGoldenContract(t *testing.T) {
var fixture goldenFixture
path := filepath.Join("..", "tauri", "src", "runtime", "golden", "lineup-v1.json")
bytes, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read shared golden fixture: %v", err)
}
if err := json.Unmarshal(bytes, &fixture); err != nil {
t.Fatalf("parse shared golden fixture: %v", err)
}
if fixture.Contract != "lineup-v1-golden-1" {
t.Fatalf("unexpected golden fixture contract %q", fixture.Contract)
}
if len(fixture.Cases) != 6 {
t.Fatalf("expected six shared cases, got %d", len(fixture.Cases))
}
for _, test := range fixture.Cases {
t.Run(test.ID, func(t *testing.T) {
status := map[string]string{}
kind := ""
fallback := ""
for index, envelope := range test.Envelopes {
kind, fallback = project(envelope)
if kind == "tool-call" {
status[stringValue(envelope.Payload["call_id"])] = "pending"
}
if kind == "tool-result" {
callID := stringValue(envelope.Payload["call_id"])
if status[callID] == "submitted" {
status[callID] = stringValue(envelope.Payload["status"])
}
}
for _, transition := range test.Transitions {
if transition.AfterEnvelope == index && transition.Kind == "submit_tool_call" && status[transition.CallID] == "pending" {
status[transition.CallID] = "submitted"
}
}
}
if kind != test.Expected.ItemKind {
t.Fatalf("projection = %q, want %q (fallback %q)", kind, test.Expected.ItemKind, fallback)
}
if fallback != test.Expected.Fallback {
t.Fatalf("fallback = %q, want %q", fallback, test.Expected.Fallback)
}
if test.Expected.KernelToolStatus != "" {
matched := false
for _, got := range status {
matched = matched || got == test.Expected.KernelToolStatus
}
if !matched {
t.Fatalf("no tool lifecycle reached %q: %#v", test.Expected.KernelToolStatus, status)
}
}
})
}
}
// Compile-time integration boundary: the Spike uses the actual Wails v3 API,
// but LineUp never grants a Surface a Wails binding. Any future production
// implementation must retain this separation.
func TestWailsApplicationBoundaryCompiles(t *testing.T) {
var options application.Options
if options.Name != "" {
t.Fatal("zero-value options changed unexpectedly")
}
}
func project(envelope wireEnvelope) (kind, fallback string) {
if envelope.Version != 1 {
return "fallback", "unsupported_version"
}
switch envelope.Type {
case "lineup.v1.tool.call":
if stringValue(envelope.Payload["call_id"]) == "" || stringValue(envelope.Payload["tool"]) == "" {
return "fallback", "invalid_payload"
}
return "tool-call", ""
case "lineup.v1.tool.result":
if stringValue(envelope.Payload["call_id"]) == "" || stringValue(envelope.Payload["status"]) == "" {
return "fallback", "invalid_payload"
}
return "tool-result", ""
case "lineup.v1.ui.open", "lineup.v1.ui.patch", "lineup.v1.ui.close":
for _, forbidden := range []string{"bundle", "bundle_id", "bundle_url", "url", "html", "css", "javascript", "js", "script", "source"} {
if _, exists := envelope.Payload[forbidden]; exists {
return "fallback", "invalid_payload"
}
}
return "surface", ""
case "lineup.v1.app.call":
if stringValue(envelope.Payload["call_id"]) == "" || stringValue(envelope.Payload["capability"]) == "" || stringValue(envelope.Payload["reason"]) == "" || stringValue(envelope.Payload["expires_at"]) == "" {
return "fallback", "invalid_payload"
}
if _, ok := envelope.Payload["arguments"].(map[string]interface{}); !ok {
return "fallback", "invalid_payload"
}
return "app-call", ""
default:
return "fallback", "unsupported_type"
}
}
func stringValue(value interface{}) string {
text, _ := value.(string)
return text
}