acrylblends 1.5.0 → 1.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +109 -30
- package/dist/index.js.map +1 -1
- package/legacy/README.md +1 -1
- package/legacy/blank/a53d41e6ef42/src/app.ts +160 -0
- package/legacy/blank/a53d41e6ef42/src/routes.ts +79 -0
- package/legacy/layouts.json +26 -0
- package/lib/cordis/door.js +5 -5
- package/lib/cordis/inspect.js +2 -2
- package/lib/cordis/state.js +3 -3
- package/lib/door/host.js +1 -1
- package/lib/{loopback-only-BbasoF7R.js → loopback-only-BoBCuDv7.js} +45 -4
- package/lib/loopback-only.d.ts +29 -1
- package/lib/loopback-only.js +2 -2
- package/lib/{middleware-BfG_fp8P.js → middleware-Do9XNe0Y.js} +1 -1
- package/lib/{snapshot-text-CXTnjxGx.js → snapshot-text-B9PWQoX6.js} +2 -2
- package/package.json +1 -1
- package/plugins/acryl-door/src/host/page-socket.ts +3 -3
- package/plugins/acryl-router/src/middleware/index.ts +1 -1
- package/plugins/acryl-router/src/middleware/loopback-only.ts +65 -3
- package/plugins/acryl-state/src/socket.ts +2 -2
- package/plugins/agent-panel/index.ts +2 -2
- package/plugins/instance/bundle.ts +1 -1
- package/plugins/instance/home.ts +11 -2
|
@@ -26,6 +26,8 @@ export interface LoopbackOptions {
|
|
|
26
26
|
* same-origin` and a `Referer` from this authority. `mutations` is not consulted: every request is held to that rule.
|
|
27
27
|
*/
|
|
28
28
|
readonly callers?: 'local' | 'own-page'
|
|
29
|
+
/** The hosted app's trusted front; default: the one the environment configures ({@link trustedFront}), usually none. Tests pass one. */
|
|
30
|
+
readonly front?: TrustedFront | undefined
|
|
29
31
|
}
|
|
30
32
|
|
|
31
33
|
const loopback = new BlockList()
|
|
@@ -45,7 +47,7 @@ export function loopbackAuthority(c: Context, hostnames: readonly string[] = LOO
|
|
|
45
47
|
const { remoteAddress, localPort } = connectionOf(c)
|
|
46
48
|
const host = c.req.header('host')
|
|
47
49
|
if (remoteAddress === undefined || localPort === undefined || host === undefined) return undefined
|
|
48
|
-
const address = (remoteAddress
|
|
50
|
+
const address = peerAddress(remoteAddress)
|
|
49
51
|
const family = isIP(address)
|
|
50
52
|
if (family === 0 || !loopback.check(address, family === 4 ? 'ipv4' : 'ipv6')) return undefined
|
|
51
53
|
let authority: URL
|
|
@@ -63,13 +65,73 @@ export function loopbackAuthority(c: Context, hostnames: readonly string[] = LOO
|
|
|
63
65
|
return authority
|
|
64
66
|
}
|
|
65
67
|
|
|
68
|
+
/**
|
|
69
|
+
* A hosted app's one trusted front (spec 049 M9, design `m9-hosted-workspace.md` C): the reverse proxy that serves this app's page under a public
|
|
70
|
+
* origin, for example `https://a1b2.213-47-80-26.sslip.io`. Only the hosting template sets it, through `ACRYL_PUBLIC_ORIGIN` and
|
|
71
|
+
* `ACRYL_TRUSTED_PROXY` (the proxy's exact peer addresses, comma separated). Unset, nothing changes: an app accepts its own page on loopback only.
|
|
72
|
+
*/
|
|
73
|
+
export interface TrustedFront {
|
|
74
|
+
/** The public origin the front serves this app's page from: a scheme and a host, no path. */
|
|
75
|
+
readonly origin: string
|
|
76
|
+
/** The exact peer addresses the front connects from; no ranges, so another box on the same network is never the front. */
|
|
77
|
+
readonly proxies: readonly string[]
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The front the environment configures, or `undefined` when it configures none or an invalid one (an invalid one is refused, never half used). */
|
|
81
|
+
export function trustedFrontFromEnv(env: NodeJS.ProcessEnv = process.env): TrustedFront | undefined {
|
|
82
|
+
const origin = env.ACRYL_PUBLIC_ORIGIN?.trim()
|
|
83
|
+
const proxies = (env.ACRYL_TRUSTED_PROXY ?? '').split(',').map(peer => peerAddress(peer.trim())).filter(peer => peer !== '')
|
|
84
|
+
if (origin === undefined || origin === '' || proxies.length === 0) return undefined
|
|
85
|
+
if (originOf(origin) !== origin || !/^https?:$/u.test(new URL(origin).protocol)) return undefined
|
|
86
|
+
if (proxies.some(peer => isIP(peer) === 0)) return undefined
|
|
87
|
+
return Object.freeze({ origin, proxies: Object.freeze(proxies) })
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
let configuredFront: TrustedFront | undefined | null = null
|
|
91
|
+
|
|
92
|
+
/** The front of this process, read from the environment once. */
|
|
93
|
+
export function trustedFront(): TrustedFront | undefined {
|
|
94
|
+
if (configuredFront === null) configuredFront = trustedFrontFromEnv()
|
|
95
|
+
return configuredFront
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function peerAddress(address: string): string {
|
|
99
|
+
return (address.replace(/^\[|\]$/gu, '').split('%', 1)[0] ?? '').replace(/^::ffff:/iu, '')
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The public authority of a request the trusted front passed on: the peer is exactly one of the front's addresses, the `Host` is exactly the
|
|
104
|
+
* public host, and the browser did not mark it cross-site. Anything else, or no front, gives `undefined`. The peer comes from the transport, never a
|
|
105
|
+
* header, so a page on another box cannot claim to be the front.
|
|
106
|
+
*/
|
|
107
|
+
export function frontAuthority(c: Context, front: TrustedFront | undefined = trustedFront()): URL | undefined {
|
|
108
|
+
if (front === undefined) return undefined
|
|
109
|
+
const { remoteAddress } = connectionOf(c)
|
|
110
|
+
if (remoteAddress === undefined || !front.proxies.includes(peerAddress(remoteAddress))) return undefined
|
|
111
|
+
const publicOrigin = new URL(front.origin)
|
|
112
|
+
if (c.req.header('host') !== publicOrigin.host || c.req.header('sec-fetch-site') === 'cross-site') return undefined
|
|
113
|
+
return publicOrigin
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The authority a browser page of this app reached it under: the loopback one, or the trusted front's public one. For routes a page calls; an
|
|
118
|
+
* operator route (the door's `.acryl/bin/acryl` calls) keeps {@link loopbackAuthority}, so nothing outside the box can operate the app.
|
|
119
|
+
*/
|
|
120
|
+
export function pageAuthority(
|
|
121
|
+
c: Context,
|
|
122
|
+
hostnames: readonly string[] = LOOPBACK_HOSTNAMES,
|
|
123
|
+
front: TrustedFront | undefined = trustedFront(),
|
|
124
|
+
): URL | undefined {
|
|
125
|
+
return loopbackAuthority(c, hostnames) ?? frontAuthority(c, front)
|
|
126
|
+
}
|
|
127
|
+
|
|
66
128
|
/** Whether a changing request is acceptable by its `Origin`: absent (not a browser cross-origin change) or naming this same authority. `null` and any other origin are not. */
|
|
67
129
|
export function sameOrigin(c: Context, authority: URL): boolean {
|
|
68
130
|
const origin = c.req.header('origin')
|
|
69
131
|
if (origin === undefined) return true
|
|
70
132
|
try {
|
|
71
133
|
const parsed = new URL(origin)
|
|
72
|
-
return parsed.
|
|
134
|
+
return parsed.origin === authority.origin && parsed.pathname === '/' && parsed.search === ''
|
|
73
135
|
} catch {
|
|
74
136
|
return false
|
|
75
137
|
}
|
|
@@ -106,7 +168,7 @@ export function loopbackOnly(options: LoopbackOptions = {}): MiddlewareHandler {
|
|
|
106
168
|
await next()
|
|
107
169
|
return
|
|
108
170
|
}
|
|
109
|
-
const authority =
|
|
171
|
+
const authority = pageAuthority(c, options.hostnames, 'front' in options ? options.front : trustedFront())
|
|
110
172
|
if (authority === undefined) return c.json({ error: 'request authority rejected' }, 403)
|
|
111
173
|
if (ownPage) {
|
|
112
174
|
if (!fromOwnPage(c, authority)) return c.json({ error: 'only this app\'s own page may call this' }, 403)
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
import type { Context, MiddlewareHandler } from 'acryl-router'
|
|
15
15
|
import type { UpgradeWebSocket } from 'acryl-router/helpers'
|
|
16
|
-
import {
|
|
16
|
+
import { pageAuthority } from 'acryl-router/middleware'
|
|
17
17
|
import type { ReadonlyCell } from './cell.ts'
|
|
18
18
|
|
|
19
19
|
/** How often the server writes a `ping`; a page gives up on a socket that is silent for three of these. */
|
|
@@ -34,7 +34,7 @@ export interface StateSocketOptions {
|
|
|
34
34
|
|
|
35
35
|
/** A refusal (403) unless the upgrade comes from this machine to this app, and from its own page when it comes from a page at all. */
|
|
36
36
|
export function refuseStranger(c: Context): Response | undefined {
|
|
37
|
-
const authority =
|
|
37
|
+
const authority = pageAuthority(c)
|
|
38
38
|
if (authority === undefined) return c.json({ error: 'request authority rejected' }, 403)
|
|
39
39
|
const origin = c.req.header('origin')
|
|
40
40
|
const site = c.req.header('sec-fetch-site')
|
|
@@ -22,7 +22,7 @@ import type { Context } from '@deepseek-ai/cordis'
|
|
|
22
22
|
import { clientModules } from '../acryl-node-server/src/client-modules.ts'
|
|
23
23
|
import { streamSSE, type UpgradeWebSocket } from '../acryl-router/src/helpers/index.ts'
|
|
24
24
|
import { Acryl, type MiddlewareHandler } from '../acryl-router/src/index.ts'
|
|
25
|
-
import {
|
|
25
|
+
import { pageAuthority } from '../acryl-router/src/middleware/index.ts'
|
|
26
26
|
import type {} from '../agent/index.ts'
|
|
27
27
|
import type {} from '../extensions/index.ts'
|
|
28
28
|
import type { ChatDriver, Driver } from './drivers.ts'
|
|
@@ -419,7 +419,7 @@ export function apply(ctx: Context, config: PanelConfig = {}): void {
|
|
|
419
419
|
}
|
|
420
420
|
const ws = new Acryl()
|
|
421
421
|
const own: MiddlewareHandler = async (c, next) => {
|
|
422
|
-
const authority =
|
|
422
|
+
const authority = pageAuthority(c)
|
|
423
423
|
const origin = c.req.header('origin')
|
|
424
424
|
const site = c.req.header('sec-fetch-site')
|
|
425
425
|
if (
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* `@deepseek-ai/cordis` still gets the bundle's, because two framework copies in one process break it (the `matchedRoutes` 500). Any other package
|
|
9
9
|
* resolves as Node resolves it, from the source's own `node_modules` (what the person added), with the bundle's as the fallback.
|
|
10
10
|
*
|
|
11
|
-
* `NODE_PATH` does not reach ESM; the seam is `registerHooks`, which covers `import`, `require` and `require.resolve` alike. `bundle-overlay.ts`
|
|
11
|
+
* `NODE_PATH` does not reach ESM; the seam is `registerHooks`, which covers `import`, `require` and (from Node 24, the frame host's Electron Node) `require.resolve` alike. `bundle-overlay.ts`
|
|
12
12
|
* registers it, first, in a product's frame host. Pure apart from `readProduct`.
|
|
13
13
|
*/
|
|
14
14
|
import { readFileSync, realpathSync } from 'node:fs'
|
package/plugins/instance/home.ts
CHANGED
|
@@ -218,8 +218,17 @@ export type MigrationResult =
|
|
|
218
218
|
export function migrateOldHome(
|
|
219
219
|
appRoot: string,
|
|
220
220
|
dataHome: string,
|
|
221
|
-
copy: (from: string, to: string) => void = (from, to) =>
|
|
222
|
-
|
|
221
|
+
copy: (from: string, to: string) => void = (from, to) => {
|
|
222
|
+
// Entry by entry into the empty data folder: on Linux cpSync refuses a target folder that exists (EEXIST) even when it is empty, while
|
|
223
|
+
// nothing inside may ever be overwritten either way.
|
|
224
|
+
for (const entry of readdirSync(from))
|
|
225
|
+
cpSync(join(from, entry), join(to, entry), {
|
|
226
|
+
recursive: true,
|
|
227
|
+
errorOnExist: true,
|
|
228
|
+
force: false,
|
|
229
|
+
verbatimSymlinks: true,
|
|
230
|
+
})
|
|
231
|
+
},
|
|
223
232
|
): MigrationResult {
|
|
224
233
|
const old = join(appRoot, OLD_HOME_DIR)
|
|
225
234
|
if (!existsSync(old) || !lstatSync(old).isDirectory()) return { status: 'none' }
|