@songsid/agend 2.1.5 → 2.1.6-beta.10
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/agent-endpoint.d.ts +18 -0
- package/dist/agent-endpoint.js +53 -2
- package/dist/agent-endpoint.js.map +1 -1
- package/dist/apply-job.d.ts +114 -0
- package/dist/apply-job.js +214 -0
- package/dist/apply-job.js.map +1 -0
- package/dist/backend/claude-code.d.ts +25 -0
- package/dist/backend/claude-code.js +127 -0
- package/dist/backend/claude-code.js.map +1 -1
- package/dist/backend/codex.d.ts +57 -0
- package/dist/backend/codex.js +183 -0
- package/dist/backend/codex.js.map +1 -1
- package/dist/backend/credential-profile.d.ts +159 -0
- package/dist/backend/credential-profile.js +340 -0
- package/dist/backend/credential-profile.js.map +1 -0
- package/dist/backend/factory.js +4 -1
- package/dist/backend/factory.js.map +1 -1
- package/dist/backend/kiro-auth-store.d.ts +25 -0
- package/dist/backend/kiro-auth-store.js +61 -0
- package/dist/backend/kiro-auth-store.js.map +1 -0
- package/dist/backend/kiro.d.ts +8 -0
- package/dist/backend/kiro.js +31 -2
- package/dist/backend/kiro.js.map +1 -1
- package/dist/backend/muse.d.ts +90 -0
- package/dist/backend/muse.js +437 -0
- package/dist/backend/muse.js.map +1 -0
- package/dist/backend/types.d.ts +54 -0
- package/dist/backend/types.js.map +1 -1
- package/dist/channel/access-manager.d.ts +20 -0
- package/dist/channel/access-manager.js +25 -0
- package/dist/channel/access-manager.js.map +1 -1
- package/dist/channel/adapters/discord.d.ts +17 -0
- package/dist/channel/adapters/discord.js +72 -11
- package/dist/channel/adapters/discord.js.map +1 -1
- package/dist/channel/adapters/telegram.d.ts +6 -0
- package/dist/channel/adapters/telegram.js +36 -0
- package/dist/channel/adapters/telegram.js.map +1 -1
- package/dist/channel/markdown-chunk.d.ts +8 -3
- package/dist/channel/markdown-chunk.js +12 -3
- package/dist/channel/markdown-chunk.js.map +1 -1
- package/dist/channel/mcp-server.js +9 -12
- package/dist/channel/mcp-server.js.map +1 -1
- package/dist/channel/mcp-tools.d.ts +6 -2
- package/dist/channel/mcp-tools.js +6 -36
- package/dist/channel/mcp-tools.js.map +1 -1
- package/dist/channel/tool-router.js +32 -3
- package/dist/channel/tool-router.js.map +1 -1
- package/dist/channel/types.d.ts +18 -0
- package/dist/cli.js +113 -0
- package/dist/cli.js.map +1 -1
- package/dist/config-validator.js +45 -5
- package/dist/config-validator.js.map +1 -1
- package/dist/connection-secrets.d.ts +96 -0
- package/dist/connection-secrets.js +25 -0
- package/dist/connection-secrets.js.map +1 -0
- package/dist/daemon.d.ts +76 -0
- package/dist/daemon.js +489 -103
- package/dist/daemon.js.map +1 -1
- package/dist/event-log.d.ts +9 -0
- package/dist/event-log.js +21 -0
- package/dist/event-log.js.map +1 -1
- package/dist/fleet-level-config.d.ts +42 -0
- package/dist/fleet-level-config.js +78 -0
- package/dist/fleet-level-config.js.map +1 -0
- package/dist/fleet-lock.d.ts +21 -0
- package/dist/fleet-lock.js +42 -8
- package/dist/fleet-lock.js.map +1 -1
- package/dist/fleet-manager.d.ts +425 -2
- package/dist/fleet-manager.js +2055 -182
- package/dist/fleet-manager.js.map +1 -1
- package/dist/general-knowledge/skills/credential-profiles/SKILL.md +145 -0
- package/dist/general-knowledge/skills/worker-collaboration/SKILL.md +11 -1
- package/dist/instance-config-impact.d.ts +55 -0
- package/dist/instance-config-impact.js +152 -0
- package/dist/instance-config-impact.js.map +1 -0
- package/dist/instance-lifecycle.js +8 -4
- package/dist/instance-lifecycle.js.map +1 -1
- package/dist/locale.js +24 -0
- package/dist/locale.js.map +1 -1
- package/dist/logger.js +42 -25
- package/dist/logger.js.map +1 -1
- package/dist/muse-usage-relay.d.ts +72 -0
- package/dist/muse-usage-relay.js +419 -0
- package/dist/muse-usage-relay.js.map +1 -0
- package/dist/outbound-handlers.d.ts +5 -1
- package/dist/outbound-handlers.js +199 -1
- package/dist/outbound-handlers.js.map +1 -1
- package/dist/outbound-schemas.d.ts +7 -5
- package/dist/outbound-schemas.js +3 -2
- package/dist/outbound-schemas.js.map +1 -1
- package/dist/provider-probe.d.ts +48 -0
- package/dist/provider-probe.js +121 -0
- package/dist/provider-probe.js.map +1 -0
- package/dist/provider-secret-registry.d.ts +98 -0
- package/dist/provider-secret-registry.js +334 -0
- package/dist/provider-secret-registry.js.map +1 -0
- package/dist/quickstart-api.d.ts +164 -0
- package/dist/quickstart-api.js +351 -0
- package/dist/quickstart-api.js.map +1 -0
- package/dist/quickstart.js +23 -50
- package/dist/quickstart.js.map +1 -1
- package/dist/scheduler/db.js +17 -5
- package/dist/scheduler/db.js.map +1 -1
- package/dist/scheduler/db.test.js +34 -1
- package/dist/scheduler/db.test.js.map +1 -1
- package/dist/scheduler/types.d.ts +4 -0
- package/dist/scheduler/types.js.map +1 -1
- package/dist/secret-store.d.ts +25 -0
- package/dist/secret-store.js +165 -0
- package/dist/secret-store.js.map +1 -0
- package/dist/self-restart-limit.d.ts +21 -0
- package/dist/self-restart-limit.js +129 -0
- package/dist/self-restart-limit.js.map +1 -0
- package/dist/settings-api.d.ts +116 -0
- package/dist/settings-api.js +530 -5
- package/dist/settings-api.js.map +1 -1
- package/dist/setup-auth.d.ts +113 -0
- package/dist/setup-auth.js +181 -0
- package/dist/setup-auth.js.map +1 -0
- package/dist/setup-form.d.ts +18 -0
- package/dist/setup-form.js +315 -0
- package/dist/setup-form.js.map +1 -0
- package/dist/setup-host.d.ts +162 -0
- package/dist/setup-host.js +496 -0
- package/dist/setup-host.js.map +1 -0
- package/dist/setup-marker.d.ts +8 -0
- package/dist/setup-marker.js +39 -0
- package/dist/setup-marker.js.map +1 -0
- package/dist/setup-tunnel-consent.d.ts +53 -0
- package/dist/setup-tunnel-consent.js +82 -0
- package/dist/setup-tunnel-consent.js.map +1 -0
- package/dist/setup-wizard.js +4 -0
- package/dist/setup-wizard.js.map +1 -1
- package/dist/steer-capability.js +4 -1
- package/dist/steer-capability.js.map +1 -1
- package/dist/tips.js +1 -1
- package/dist/tips.js.map +1 -1
- package/dist/tmux-manager.js +29 -4
- package/dist/tmux-manager.js.map +1 -1
- package/dist/tool-permissions-notice.d.ts +38 -0
- package/dist/tool-permissions-notice.js +92 -0
- package/dist/tool-permissions-notice.js.map +1 -0
- package/dist/tool-permissions.d.ts +120 -0
- package/dist/tool-permissions.js +277 -0
- package/dist/tool-permissions.js.map +1 -0
- package/dist/topic-commands.js +1 -0
- package/dist/topic-commands.js.map +1 -1
- package/dist/transcript-sources.d.ts +12 -1
- package/dist/transcript-sources.js +19 -3
- package/dist/transcript-sources.js.map +1 -1
- package/dist/tunnel/cloudflared.d.ts +71 -0
- package/dist/tunnel/cloudflared.js +447 -0
- package/dist/tunnel/cloudflared.js.map +1 -0
- package/dist/tunnel/lease.d.ts +75 -0
- package/dist/tunnel/lease.js +209 -0
- package/dist/tunnel/lease.js.map +1 -0
- package/dist/tunnel/manager.d.ts +56 -0
- package/dist/tunnel/manager.js +167 -0
- package/dist/tunnel/manager.js.map +1 -0
- package/dist/tunnel/types.d.ts +119 -0
- package/dist/tunnel/types.js +33 -0
- package/dist/tunnel/types.js.map +1 -0
- package/dist/types.d.ts +2 -0
- package/dist/ui/dashboard.html +4 -3
- package/dist/ui/settings.html +808 -156
- package/dist/ui/view.html +2 -2
- package/dist/usage/i18n-keys.d.ts +1 -1
- package/dist/usage/i18n-keys.js +1 -1
- package/dist/usage/i18n-keys.js.map +1 -1
- package/dist/usage/providers.d.ts +23 -14
- package/dist/usage/providers.js +220 -58
- package/dist/usage/providers.js.map +1 -1
- package/dist/usage/usage-api.d.ts +13 -0
- package/dist/usage/usage-api.js +69 -1
- package/dist/usage/usage-api.js.map +1 -1
- package/dist/web-api.js +8 -5
- package/dist/web-api.js.map +1 -1
- package/dist/web-auth.d.ts +75 -0
- package/dist/web-auth.js +208 -2
- package/dist/web-auth.js.map +1 -1
- package/dist/web-terminal.d.ts +30 -1
- package/dist/web-terminal.js +86 -3
- package/dist/web-terminal.js.map +1 -1
- package/package.json +2 -1
package/dist/web-auth.d.ts
CHANGED
|
@@ -1,6 +1,81 @@
|
|
|
1
1
|
export declare const WEB_TOKEN_INVALID_MESSAGE = "Token expired or invalid \u2014 run /dashboard again";
|
|
2
|
+
/** No credential at all. Distinct from "wrong credential" so a browser that
|
|
3
|
+
* silently drops cookies is diagnosable instead of looking like a bad token. */
|
|
4
|
+
export declare const WEB_SESSION_REQUIRED_MESSAGE = "No session \u2014 open the dashboard link again (cookies must be enabled for this site)";
|
|
5
|
+
export declare const WEB_CROSS_SITE_MESSAGE = "Cross-site request rejected";
|
|
6
|
+
/** A URL token is redeemed for a session cookie on a GET; it is never a
|
|
7
|
+
* credential for a write, where it would also survive in history and logs. */
|
|
8
|
+
export declare const WEB_URL_TOKEN_WRITE_MESSAGE = "URL tokens are only redeemed on GET \u2014 send X-Agend-Token for API writes";
|
|
9
|
+
export declare const WEB_SESSION_COOKIE = "agend_session";
|
|
10
|
+
/** 12h. The old model was "valid forever"; the panel is an escape hatch, so the
|
|
11
|
+
* cookie outlives a working day and nothing more. `web-token rotate` kills every
|
|
12
|
+
* issued cookie immediately regardless of this. */
|
|
13
|
+
export declare const WEB_SESSION_MAX_AGE_SECONDS: number;
|
|
2
14
|
/**
|
|
3
15
|
* Load the fleet-wide web bearer token, creating it only when absent/invalid.
|
|
4
16
|
* A fleet restart must not revoke every previously issued /dashboard URL.
|
|
5
17
|
*/
|
|
6
18
|
export declare function loadOrCreateWebToken(dataDir: string): string;
|
|
19
|
+
/**
|
|
20
|
+
* The token as it is on disk right now, without creating one.
|
|
21
|
+
*
|
|
22
|
+
* Deliberately unbuffered: `agend web-token rotate` writes the file from a
|
|
23
|
+
* separate process, and a cached copy would keep authorizing revoked sessions
|
|
24
|
+
* until the fleet restarted — which is the whole point of having rotation.
|
|
25
|
+
* mtime/size caching cannot be used either, since a rotation within the same
|
|
26
|
+
* millisecond produces a same-size file.
|
|
27
|
+
*/
|
|
28
|
+
export declare function readWebToken(dataDir: string): string | null;
|
|
29
|
+
/**
|
|
30
|
+
* Replace the token with a fresh one. Every previously issued URL, header token
|
|
31
|
+
* and session cookie stops being accepted as soon as the next request is served.
|
|
32
|
+
*/
|
|
33
|
+
export declare function rotateWebToken(dataDir: string): string;
|
|
34
|
+
/**
|
|
35
|
+
* The cookie carries a derivation of the token, never the token itself: a
|
|
36
|
+
* stolen cookie opens the panel but cannot be replayed as `?token=` /
|
|
37
|
+
* `X-Agend-Token` (which also authorize /view writes and the CLI). Rotating
|
|
38
|
+
* `web.token` changes the derivation, so every issued cookie dies with it.
|
|
39
|
+
*/
|
|
40
|
+
export declare function webSessionCookieValue(token: string): string;
|
|
41
|
+
export declare function parseCookieHeader(header: string | undefined): Map<string, string>;
|
|
42
|
+
/** Minimal request shape — everything the gate reads, and nothing more, so the
|
|
43
|
+
* decision can be unit-tested without a socket. */
|
|
44
|
+
export interface WebGateRequest {
|
|
45
|
+
readonly method?: string | undefined;
|
|
46
|
+
readonly headers: NodeJS.Dict<string | string[]>;
|
|
47
|
+
}
|
|
48
|
+
export type WebGateDecision = {
|
|
49
|
+
readonly kind: "allow";
|
|
50
|
+
} | {
|
|
51
|
+
readonly kind: "exchange";
|
|
52
|
+
readonly setCookie: string;
|
|
53
|
+
readonly location: string;
|
|
54
|
+
} | {
|
|
55
|
+
readonly kind: "reject";
|
|
56
|
+
readonly status: 401 | 403;
|
|
57
|
+
readonly message: string;
|
|
58
|
+
};
|
|
59
|
+
/**
|
|
60
|
+
* True when the request either carries no Origin (a CLI, or a top-level GET
|
|
61
|
+
* navigation, neither of which a cross-site attacker controls) or carries one
|
|
62
|
+
* that matches the Host it was sent to.
|
|
63
|
+
*
|
|
64
|
+
* Only a *mismatch* rejects: requiring Origin would break every non-browser
|
|
65
|
+
* caller, which is the same `X-Agend-Token` path the CLI uses.
|
|
66
|
+
*/
|
|
67
|
+
export declare function isSameOriginRequest(req: WebGateRequest): boolean;
|
|
68
|
+
export declare function buildSessionCookie(token: string, secure: boolean): string;
|
|
69
|
+
export declare function hasValidSessionCookie(req: WebGateRequest, token: string): boolean;
|
|
70
|
+
/**
|
|
71
|
+
* The single authorization decision for every gated web route.
|
|
72
|
+
*
|
|
73
|
+
* Accepts, in order: a session cookie, an `X-Agend-Token` header (CLI and
|
|
74
|
+
* scripts), and — only to be redeemed for a cookie on a GET — a `?token=` in
|
|
75
|
+
* the URL. After the redemption the token is gone from the address bar, from
|
|
76
|
+
* browser history, and from anything that logs request URLs.
|
|
77
|
+
*/
|
|
78
|
+
export declare function decideWebGate(req: WebGateRequest, url: URL, token: string | null): WebGateDecision;
|
|
79
|
+
/** Defence in depth for handlers that run behind the gate: authorization only,
|
|
80
|
+
* no cookie issuing (the gate already did that). */
|
|
81
|
+
export declare function isWebRequestAuthorized(req: WebGateRequest, url: URL, token: string | null): boolean;
|
package/dist/web-auth.js
CHANGED
|
@@ -1,8 +1,20 @@
|
|
|
1
1
|
import { chmodSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
|
-
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
export const WEB_TOKEN_INVALID_MESSAGE = "Token expired or invalid — run /dashboard again";
|
|
5
|
+
/** No credential at all. Distinct from "wrong credential" so a browser that
|
|
6
|
+
* silently drops cookies is diagnosable instead of looking like a bad token. */
|
|
7
|
+
export const WEB_SESSION_REQUIRED_MESSAGE = "No session — open the dashboard link again (cookies must be enabled for this site)";
|
|
8
|
+
export const WEB_CROSS_SITE_MESSAGE = "Cross-site request rejected";
|
|
9
|
+
/** A URL token is redeemed for a session cookie on a GET; it is never a
|
|
10
|
+
* credential for a write, where it would also survive in history and logs. */
|
|
11
|
+
export const WEB_URL_TOKEN_WRITE_MESSAGE = "URL tokens are only redeemed on GET — send X-Agend-Token for API writes";
|
|
5
12
|
const WEB_TOKEN_PATTERN = /^[0-9a-f]{48}$/i;
|
|
13
|
+
export const WEB_SESSION_COOKIE = "agend_session";
|
|
14
|
+
/** 12h. The old model was "valid forever"; the panel is an escape hatch, so the
|
|
15
|
+
* cookie outlives a working day and nothing more. `web-token rotate` kills every
|
|
16
|
+
* issued cookie immediately regardless of this. */
|
|
17
|
+
export const WEB_SESSION_MAX_AGE_SECONDS = 12 * 60 * 60;
|
|
6
18
|
function readValidToken(path) {
|
|
7
19
|
try {
|
|
8
20
|
const token = readFileSync(path, "utf8").trim();
|
|
@@ -28,13 +40,16 @@ function replaceTokenAtomically(path, token) {
|
|
|
28
40
|
throw err;
|
|
29
41
|
}
|
|
30
42
|
}
|
|
43
|
+
function tokenPath(dataDir) {
|
|
44
|
+
return join(dataDir, "web.token");
|
|
45
|
+
}
|
|
31
46
|
/**
|
|
32
47
|
* Load the fleet-wide web bearer token, creating it only when absent/invalid.
|
|
33
48
|
* A fleet restart must not revoke every previously issued /dashboard URL.
|
|
34
49
|
*/
|
|
35
50
|
export function loadOrCreateWebToken(dataDir) {
|
|
36
51
|
mkdirSync(dataDir, { recursive: true });
|
|
37
|
-
const path =
|
|
52
|
+
const path = tokenPath(dataDir);
|
|
38
53
|
const existing = readValidToken(path);
|
|
39
54
|
if (existing) {
|
|
40
55
|
try {
|
|
@@ -51,4 +66,195 @@ export function loadOrCreateWebToken(dataDir) {
|
|
|
51
66
|
catch { /* best effort */ }
|
|
52
67
|
return token;
|
|
53
68
|
}
|
|
69
|
+
/**
|
|
70
|
+
* The token as it is on disk right now, without creating one.
|
|
71
|
+
*
|
|
72
|
+
* Deliberately unbuffered: `agend web-token rotate` writes the file from a
|
|
73
|
+
* separate process, and a cached copy would keep authorizing revoked sessions
|
|
74
|
+
* until the fleet restarted — which is the whole point of having rotation.
|
|
75
|
+
* mtime/size caching cannot be used either, since a rotation within the same
|
|
76
|
+
* millisecond produces a same-size file.
|
|
77
|
+
*/
|
|
78
|
+
export function readWebToken(dataDir) {
|
|
79
|
+
try {
|
|
80
|
+
return readValidToken(tokenPath(dataDir));
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
// This runs inside the request handler. A permissions or I/O error must
|
|
84
|
+
// close the panel, not throw out of the HTTP callback.
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Replace the token with a fresh one. Every previously issued URL, header token
|
|
90
|
+
* and session cookie stops being accepted as soon as the next request is served.
|
|
91
|
+
*/
|
|
92
|
+
export function rotateWebToken(dataDir) {
|
|
93
|
+
mkdirSync(dataDir, { recursive: true });
|
|
94
|
+
const path = tokenPath(dataDir);
|
|
95
|
+
const token = randomBytes(24).toString("hex");
|
|
96
|
+
replaceTokenAtomically(path, token);
|
|
97
|
+
try {
|
|
98
|
+
chmodSync(path, 0o600);
|
|
99
|
+
}
|
|
100
|
+
catch { /* best effort */ }
|
|
101
|
+
return token;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* The cookie carries a derivation of the token, never the token itself: a
|
|
105
|
+
* stolen cookie opens the panel but cannot be replayed as `?token=` /
|
|
106
|
+
* `X-Agend-Token` (which also authorize /view writes and the CLI). Rotating
|
|
107
|
+
* `web.token` changes the derivation, so every issued cookie dies with it.
|
|
108
|
+
*/
|
|
109
|
+
export function webSessionCookieValue(token) {
|
|
110
|
+
return createHash("sha256").update(`agend-web-session-v1:${token}`).digest("hex");
|
|
111
|
+
}
|
|
112
|
+
function constantTimeEquals(a, b) {
|
|
113
|
+
const left = Buffer.from(a, "utf8");
|
|
114
|
+
const right = Buffer.from(b, "utf8");
|
|
115
|
+
// timingSafeEqual throws on a length mismatch, which would itself leak length.
|
|
116
|
+
if (left.length !== right.length)
|
|
117
|
+
return false;
|
|
118
|
+
return timingSafeEqual(left, right);
|
|
119
|
+
}
|
|
120
|
+
export function parseCookieHeader(header) {
|
|
121
|
+
const jar = new Map();
|
|
122
|
+
if (!header)
|
|
123
|
+
return jar;
|
|
124
|
+
for (const part of header.split(";")) {
|
|
125
|
+
const eq = part.indexOf("=");
|
|
126
|
+
if (eq < 1)
|
|
127
|
+
continue;
|
|
128
|
+
const name = part.slice(0, eq).trim();
|
|
129
|
+
if (!name || jar.has(name))
|
|
130
|
+
continue;
|
|
131
|
+
jar.set(name, part.slice(eq + 1).trim());
|
|
132
|
+
}
|
|
133
|
+
return jar;
|
|
134
|
+
}
|
|
135
|
+
function headerValue(req, name) {
|
|
136
|
+
const raw = req.headers[name];
|
|
137
|
+
if (typeof raw === "string")
|
|
138
|
+
return raw;
|
|
139
|
+
if (Array.isArray(raw))
|
|
140
|
+
return raw[0] ?? null;
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* True when the request either carries no Origin (a CLI, or a top-level GET
|
|
145
|
+
* navigation, neither of which a cross-site attacker controls) or carries one
|
|
146
|
+
* that matches the Host it was sent to.
|
|
147
|
+
*
|
|
148
|
+
* Only a *mismatch* rejects: requiring Origin would break every non-browser
|
|
149
|
+
* caller, which is the same `X-Agend-Token` path the CLI uses.
|
|
150
|
+
*/
|
|
151
|
+
export function isSameOriginRequest(req) {
|
|
152
|
+
const origin = headerValue(req, "origin");
|
|
153
|
+
if (!origin)
|
|
154
|
+
return true;
|
|
155
|
+
const host = headerValue(req, "host");
|
|
156
|
+
if (!host)
|
|
157
|
+
return false;
|
|
158
|
+
try {
|
|
159
|
+
return new URL(origin).host === host;
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
// Anything unparseable is cross-site, including the opaque `null` origin
|
|
163
|
+
// that sandboxed frames and some redirect chains send.
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
/** Whether the browser reached us over TLS, which decides the cookie's Secure
|
|
168
|
+
* attribute. The health server is always plain HTTP, so the only signal is the
|
|
169
|
+
* tunnel/proxy in front of it. A forged header can only make us set Secure on a
|
|
170
|
+
* plain-HTTP response, which costs the forger their own cookie and nothing else. */
|
|
171
|
+
function isSecureRequest(req) {
|
|
172
|
+
const proto = headerValue(req, "x-forwarded-proto");
|
|
173
|
+
if (!proto)
|
|
174
|
+
return false;
|
|
175
|
+
return proto.split(",")[0].trim().toLowerCase() === "https";
|
|
176
|
+
}
|
|
177
|
+
export function buildSessionCookie(token, secure) {
|
|
178
|
+
const attrs = [
|
|
179
|
+
`${WEB_SESSION_COOKIE}=${webSessionCookieValue(token)}`,
|
|
180
|
+
"Path=/",
|
|
181
|
+
"HttpOnly",
|
|
182
|
+
// Strict, not Lax: the panel can restart instances, so a cross-site
|
|
183
|
+
// navigation must not arrive already authenticated.
|
|
184
|
+
"SameSite=Strict",
|
|
185
|
+
`Max-Age=${WEB_SESSION_MAX_AGE_SECONDS}`,
|
|
186
|
+
];
|
|
187
|
+
if (secure)
|
|
188
|
+
attrs.push("Secure");
|
|
189
|
+
return attrs.join("; ");
|
|
190
|
+
}
|
|
191
|
+
export function hasValidSessionCookie(req, token) {
|
|
192
|
+
const cookie = parseCookieHeader(headerValue(req, "cookie") ?? undefined).get(WEB_SESSION_COOKIE);
|
|
193
|
+
return !!cookie && constantTimeEquals(cookie, webSessionCookieValue(token));
|
|
194
|
+
}
|
|
195
|
+
function hasValidHeaderToken(req, token) {
|
|
196
|
+
const provided = headerValue(req, "x-agend-token");
|
|
197
|
+
return !!provided && constantTimeEquals(provided, token);
|
|
198
|
+
}
|
|
199
|
+
/** Same-origin relative target with the token stripped and every other query
|
|
200
|
+
* parameter kept. Relative on purpose: an absolute Location built from
|
|
201
|
+
* attacker-supplied Host would be an open redirect. */
|
|
202
|
+
function locationWithoutToken(url) {
|
|
203
|
+
const stripped = new URL(url.href);
|
|
204
|
+
stripped.searchParams.delete("token");
|
|
205
|
+
return `${stripped.pathname}${stripped.search}`;
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* The single authorization decision for every gated web route.
|
|
209
|
+
*
|
|
210
|
+
* Accepts, in order: a session cookie, an `X-Agend-Token` header (CLI and
|
|
211
|
+
* scripts), and — only to be redeemed for a cookie on a GET — a `?token=` in
|
|
212
|
+
* the URL. After the redemption the token is gone from the address bar, from
|
|
213
|
+
* browser history, and from anything that logs request URLs.
|
|
214
|
+
*/
|
|
215
|
+
export function decideWebGate(req, url, token) {
|
|
216
|
+
// No token on disk means the panel is closed, not open to everyone. Without
|
|
217
|
+
// this, a null token compared against a missing credential authorizes.
|
|
218
|
+
if (!token)
|
|
219
|
+
return { kind: "reject", status: 401, message: WEB_TOKEN_INVALID_MESSAGE };
|
|
220
|
+
if (!isSameOriginRequest(req)) {
|
|
221
|
+
return { kind: "reject", status: 403, message: WEB_CROSS_SITE_MESSAGE };
|
|
222
|
+
}
|
|
223
|
+
if (hasValidSessionCookie(req, token))
|
|
224
|
+
return { kind: "allow" };
|
|
225
|
+
if (hasValidHeaderToken(req, token))
|
|
226
|
+
return { kind: "allow" };
|
|
227
|
+
const queryToken = url.searchParams.get("token");
|
|
228
|
+
if (queryToken && constantTimeEquals(queryToken, token)) {
|
|
229
|
+
const method = (req.method ?? "GET").toUpperCase();
|
|
230
|
+
if (method !== "GET" && method !== "HEAD") {
|
|
231
|
+
return { kind: "reject", status: 401, message: WEB_URL_TOKEN_WRITE_MESSAGE };
|
|
232
|
+
}
|
|
233
|
+
return {
|
|
234
|
+
kind: "exchange",
|
|
235
|
+
setCookie: buildSessionCookie(token, isSecureRequest(req)),
|
|
236
|
+
location: locationWithoutToken(url),
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
const presented = queryToken ?? headerValue(req, "x-agend-token") ?? headerValue(req, "cookie");
|
|
240
|
+
return {
|
|
241
|
+
kind: "reject",
|
|
242
|
+
status: 401,
|
|
243
|
+
message: presented ? WEB_TOKEN_INVALID_MESSAGE : WEB_SESSION_REQUIRED_MESSAGE,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
/** Defence in depth for handlers that run behind the gate: authorization only,
|
|
247
|
+
* no cookie issuing (the gate already did that). */
|
|
248
|
+
export function isWebRequestAuthorized(req, url, token) {
|
|
249
|
+
if (!token)
|
|
250
|
+
return false;
|
|
251
|
+
if (!isSameOriginRequest(req))
|
|
252
|
+
return false;
|
|
253
|
+
if (hasValidSessionCookie(req, token))
|
|
254
|
+
return true;
|
|
255
|
+
if (hasValidHeaderToken(req, token))
|
|
256
|
+
return true;
|
|
257
|
+
const queryToken = url.searchParams.get("token");
|
|
258
|
+
return !!queryToken && constantTimeEquals(queryToken, token);
|
|
259
|
+
}
|
|
54
260
|
//# sourceMappingURL=web-auth.js.map
|
package/dist/web-auth.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"web-auth.js","sourceRoot":"","sources":["../src/web-auth.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,SAAS,EAAE,YAAY,EAAE,UAAU,EAAE,UAAU,EAAE,aAAa,EAAE,MAAM,SAAS,CAAC;AACpG,OAAO,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;
|
|
1
|
+
{"version":3,"file":"web-auth.js","sourceRoot":"","sources":["../src/web-auth.ts"],"names":[],"mappings":"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"}
|
package/dist/web-terminal.d.ts
CHANGED
|
@@ -116,6 +116,14 @@ export declare const MAX_TTL_MS: number;
|
|
|
116
116
|
export declare const DEFAULT_COLS = 120;
|
|
117
117
|
export declare const DEFAULT_ROWS = 36;
|
|
118
118
|
export declare const MIN_COLS = 20, MAX_COLS = 250, MIN_ROWS = 5, MAX_ROWS = 100;
|
|
119
|
+
/**
|
|
120
|
+
* tmux can report pane_dead before pane_dead_status has been populated when
|
|
121
|
+
* the host is under scheduler pressure. Re-probe only the optional exit
|
|
122
|
+
* diagnostic; the session is already known to have ended. The total budget
|
|
123
|
+
* is deliberately bounded so a wedged tmux cannot hold completion forever.
|
|
124
|
+
*/
|
|
125
|
+
export declare const EXIT_CODE_REPROBE_MAX_MS = 1000;
|
|
126
|
+
export declare const EXIT_CODE_REPROBE_INTERVAL_MS = 250;
|
|
119
127
|
/** Bytes of browser input allowed to wait for tmux before the session is ended as wedged (B4). */
|
|
120
128
|
export declare const MAX_PENDING_INPUT_BYTES: number;
|
|
121
129
|
/** Queued tmux operations allowed to wait (input batches + at most one resize); more means tmux is stuck. */
|
|
@@ -217,6 +225,15 @@ export declare class WebTerminalSession extends EventEmitter {
|
|
|
217
225
|
private schedulePoll;
|
|
218
226
|
/** Exposed for tests; the timer calls this every second. */
|
|
219
227
|
poll(): Promise<void>;
|
|
228
|
+
/**
|
|
229
|
+
* Fill in tmux's optional pane-dead status after the pane is already known
|
|
230
|
+
* dead. This is diagnostic enrichment only: timeout still completes with
|
|
231
|
+
* an undefined exit code and never retries the terminal/session itself.
|
|
232
|
+
*/
|
|
233
|
+
private reProbeExitCode;
|
|
234
|
+
/** A single re-probe cannot consume more than its share of the total budget. */
|
|
235
|
+
private paneStatusWithin;
|
|
236
|
+
private delay;
|
|
220
237
|
private observe;
|
|
221
238
|
private finishFromExit;
|
|
222
239
|
private finish;
|
|
@@ -239,6 +256,18 @@ export declare class TmuxTerminalBackend implements TerminalBackend {
|
|
|
239
256
|
* command" scope would be violated against an unrelated process.
|
|
240
257
|
*/
|
|
241
258
|
private readonly servers;
|
|
259
|
+
/**
|
|
260
|
+
* Where tmux actually put each socket, asked for while the server was alive.
|
|
261
|
+
*
|
|
262
|
+
* tmux does not unlink its socket when the server exits — verified on this
|
|
263
|
+
* tmux for a clean `kill-server` as well as for SIGKILL — so every session
|
|
264
|
+
* used to leave a file behind. Reconstructing the path instead would be a
|
|
265
|
+
* guess: the layout depends on TMUX_TMPDIR and the uid, and a wrong guess
|
|
266
|
+
* either deletes nothing or deletes something else. Recorded separately from
|
|
267
|
+
* `servers` because that map is only written when the PID fingerprint is
|
|
268
|
+
* strong, and a platform without one still deserves its socket cleaned up.
|
|
269
|
+
*/
|
|
270
|
+
private readonly socketPaths;
|
|
242
271
|
private readonly probe;
|
|
243
272
|
constructor(tmuxBin?: string, opts?: {
|
|
244
273
|
probeProcess?: (pid: number) => ProcessProbe;
|
|
@@ -301,7 +330,7 @@ export declare class TmuxTerminalBackend implements TerminalBackend {
|
|
|
301
330
|
*/
|
|
302
331
|
kill(socket: string): Promise<void>;
|
|
303
332
|
/** Test seam: adopt a server identity as if captured at start. */
|
|
304
|
-
rememberServerForTests(socket: string, pid: number, identity: string): void;
|
|
333
|
+
rememberServerForTests(socket: string, pid: number, identity: string, socketPath?: string): void;
|
|
305
334
|
}
|
|
306
335
|
export type ProcessProbe = {
|
|
307
336
|
kind: "identified";
|
package/dist/web-terminal.js
CHANGED
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
import { EventEmitter } from "node:events";
|
|
30
30
|
import { execFile } from "node:child_process";
|
|
31
31
|
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
32
|
-
import { mkdtempSync, openSync, rmSync, readFileSync, constants as fsConstants } from "node:fs";
|
|
32
|
+
import { mkdtempSync, openSync, rmSync, readFileSync, unlinkSync, constants as fsConstants } from "node:fs";
|
|
33
33
|
import { Socket as NetSocket } from "node:net";
|
|
34
34
|
import { tmpdir } from "node:os";
|
|
35
35
|
import { join } from "node:path";
|
|
@@ -43,6 +43,14 @@ export const MIN_COLS = 20, MAX_COLS = 250, MIN_ROWS = 5, MAX_ROWS = 100;
|
|
|
43
43
|
const REPLAY_BUFFER_LIMIT = 256 * 1024;
|
|
44
44
|
const POLL_INTERVAL_MS = 1_000;
|
|
45
45
|
const SUCCESS_EXIT_GRACE_MS = 15_000;
|
|
46
|
+
/**
|
|
47
|
+
* tmux can report pane_dead before pane_dead_status has been populated when
|
|
48
|
+
* the host is under scheduler pressure. Re-probe only the optional exit
|
|
49
|
+
* diagnostic; the session is already known to have ended. The total budget
|
|
50
|
+
* is deliberately bounded so a wedged tmux cannot hold completion forever.
|
|
51
|
+
*/
|
|
52
|
+
export const EXIT_CODE_REPROBE_MAX_MS = 1_000;
|
|
53
|
+
export const EXIT_CODE_REPROBE_INTERVAL_MS = 250;
|
|
46
54
|
/** Bytes of browser input allowed to wait for tmux before the session is ended as wedged (B4). */
|
|
47
55
|
export const MAX_PENDING_INPUT_BYTES = 64 * 1024;
|
|
48
56
|
/** Queued tmux operations allowed to wait (input batches + at most one resize); more means tmux is stuck. */
|
|
@@ -389,7 +397,8 @@ export class WebTerminalSession extends EventEmitter {
|
|
|
389
397
|
this.probeFailures = 0;
|
|
390
398
|
}
|
|
391
399
|
if (status && !status.alive) {
|
|
392
|
-
await this.
|
|
400
|
+
const exitCode = status.exitCode ?? await this.reProbeExitCode();
|
|
401
|
+
await this.finishFromExit(exitCode, pane);
|
|
393
402
|
return;
|
|
394
403
|
}
|
|
395
404
|
if (this.successSeenAt !== null && this.now() - this.successSeenAt > SUCCESS_EXIT_GRACE_MS) {
|
|
@@ -404,6 +413,50 @@ export class WebTerminalSession extends EventEmitter {
|
|
|
404
413
|
}
|
|
405
414
|
this.schedulePoll();
|
|
406
415
|
}
|
|
416
|
+
/**
|
|
417
|
+
* Fill in tmux's optional pane-dead status after the pane is already known
|
|
418
|
+
* dead. This is diagnostic enrichment only: timeout still completes with
|
|
419
|
+
* an undefined exit code and never retries the terminal/session itself.
|
|
420
|
+
*/
|
|
421
|
+
async reProbeExitCode() {
|
|
422
|
+
const deadline = Date.now() + EXIT_CODE_REPROBE_MAX_MS;
|
|
423
|
+
while (this.state === "running") {
|
|
424
|
+
const remaining = deadline - Date.now();
|
|
425
|
+
if (remaining <= 0)
|
|
426
|
+
return undefined;
|
|
427
|
+
const status = await this.paneStatusWithin(Math.min(EXIT_CODE_REPROBE_INTERVAL_MS, remaining));
|
|
428
|
+
if (status && !status.alive && status.exitCode !== undefined)
|
|
429
|
+
return status.exitCode;
|
|
430
|
+
if (this.state !== "running")
|
|
431
|
+
return undefined;
|
|
432
|
+
const afterProbe = deadline - Date.now();
|
|
433
|
+
if (afterProbe <= 0)
|
|
434
|
+
return undefined;
|
|
435
|
+
await this.delay(Math.min(EXIT_CODE_REPROBE_INTERVAL_MS, afterProbe));
|
|
436
|
+
}
|
|
437
|
+
return undefined;
|
|
438
|
+
}
|
|
439
|
+
/** A single re-probe cannot consume more than its share of the total budget. */
|
|
440
|
+
async paneStatusWithin(timeoutMs) {
|
|
441
|
+
let timer;
|
|
442
|
+
const timeout = new Promise(resolve => {
|
|
443
|
+
timer = setTimeout(() => resolve(null), timeoutMs);
|
|
444
|
+
timer.unref?.();
|
|
445
|
+
});
|
|
446
|
+
try {
|
|
447
|
+
return await Promise.race([this.backend.paneStatus(this.socketName).catch(() => null), timeout]);
|
|
448
|
+
}
|
|
449
|
+
finally {
|
|
450
|
+
if (timer)
|
|
451
|
+
clearTimeout(timer);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
delay(ms) {
|
|
455
|
+
return new Promise(resolve => {
|
|
456
|
+
const timer = setTimeout(resolve, ms);
|
|
457
|
+
timer.unref?.();
|
|
458
|
+
});
|
|
459
|
+
}
|
|
407
460
|
observe(pane) {
|
|
408
461
|
const obs = this.spec.observe;
|
|
409
462
|
if (!obs || !pane)
|
|
@@ -525,6 +578,18 @@ export class TmuxTerminalBackend {
|
|
|
525
578
|
* command" scope would be violated against an unrelated process.
|
|
526
579
|
*/
|
|
527
580
|
servers = new Map();
|
|
581
|
+
/**
|
|
582
|
+
* Where tmux actually put each socket, asked for while the server was alive.
|
|
583
|
+
*
|
|
584
|
+
* tmux does not unlink its socket when the server exits — verified on this
|
|
585
|
+
* tmux for a clean `kill-server` as well as for SIGKILL — so every session
|
|
586
|
+
* used to leave a file behind. Reconstructing the path instead would be a
|
|
587
|
+
* guess: the layout depends on TMUX_TMPDIR and the uid, and a wrong guess
|
|
588
|
+
* either deletes nothing or deletes something else. Recorded separately from
|
|
589
|
+
* `servers` because that map is only written when the PID fingerprint is
|
|
590
|
+
* strong, and a platform without one still deserves its socket cleaned up.
|
|
591
|
+
*/
|
|
592
|
+
socketPaths = new Map();
|
|
528
593
|
probe;
|
|
529
594
|
constructor(tmuxBin = "tmux", opts = {}) {
|
|
530
595
|
this.tmuxBin = tmuxBin;
|
|
@@ -585,6 +650,11 @@ export class TmuxTerminalBackend {
|
|
|
585
650
|
if (probe.kind === "identified")
|
|
586
651
|
this.servers.set(socket, { pid, identity: probe.identity });
|
|
587
652
|
}
|
|
653
|
+
// Ask while the server is up; after it dies there is nobody to ask.
|
|
654
|
+
const socketPath = (await this.tmux(socket, "display-message", ["-p", "#{socket_path}"])
|
|
655
|
+
.catch(() => "")).trim();
|
|
656
|
+
if (socketPath)
|
|
657
|
+
this.socketPaths.set(socket, socketPath);
|
|
588
658
|
await this.tmux(socket, "set-option", ["-g", "window-size", "manual"]);
|
|
589
659
|
if (signal?.aborted)
|
|
590
660
|
throw aborted();
|
|
@@ -786,11 +856,24 @@ export class TmuxTerminalBackend {
|
|
|
786
856
|
// Keep the identity so a later retry can still reach the process.
|
|
787
857
|
throw new Error(`tmux server on socket ${socket} could not be confirmed dead (${state})`);
|
|
788
858
|
}
|
|
859
|
+
// Only now: the socket is how every command reaches the server, including
|
|
860
|
+
// the kill above, so removing it earlier would cut off our own retries and
|
|
861
|
+
// leave a live server nothing can talk to.
|
|
862
|
+
const socketPath = this.socketPaths.get(socket);
|
|
863
|
+
this.socketPaths.delete(socket);
|
|
864
|
+
if (socketPath) {
|
|
865
|
+
try {
|
|
866
|
+
unlinkSync(socketPath);
|
|
867
|
+
}
|
|
868
|
+
catch { /* already gone, or never ours to remove */ }
|
|
869
|
+
}
|
|
789
870
|
this.servers.delete(socket);
|
|
790
871
|
}
|
|
791
872
|
/** Test seam: adopt a server identity as if captured at start. */
|
|
792
|
-
rememberServerForTests(socket, pid, identity) {
|
|
873
|
+
rememberServerForTests(socket, pid, identity, socketPath) {
|
|
793
874
|
this.servers.set(socket, { pid, identity });
|
|
875
|
+
if (socketPath)
|
|
876
|
+
this.socketPaths.set(socket, socketPath);
|
|
794
877
|
}
|
|
795
878
|
}
|
|
796
879
|
/**
|