@moondeck/station 0.0.0-stage → 1.0.0
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/LICENSE +21 -0
- package/README.md +149 -2
- package/bin/uai-host.mjs +130 -0
- package/db/migrations/0000_host_tasks.sql +12 -0
- package/db/migrations/0001_host_ui.sql +11 -0
- package/db/migrations/0002_host_github_tokens.sql +8 -0
- package/db/migrations/0003_host_ssh_keys.sql +8 -0
- package/db/migrations/0004_host_owner_name.sql +1 -0
- package/db/migrations/0005_host_env_vars.sql +5 -0
- package/db/migrations/0006_host_project_env.sql +9 -0
- package/db/migrations/0007_host_github_token_kind.sql +7 -0
- package/db/migrations/0008_host_mcp_connections.sql +18 -0
- package/db/migrations/0009_host_agent_sessions.sql +15 -0
- package/db/migrations/0010_host_engine_accounts.sql +13 -0
- package/db/migrations/0011_codex_thread_id.sql +5 -0
- package/db/migrations/0012_agent_session_attach_key.sql +4 -0
- package/db/migrations/0013_github_credential_generations.sql +11 -0
- package/db/migrations/0014_host_inventory_event_index.sql +1 -0
- package/db/migrations/0015_host_settings.sql +9 -0
- package/db/migrations/0016_task_environment.sql +2 -0
- package/db/migrations/0017_engine_account_scope.sql +7 -0
- package/db/migrations/0018_engine_account_position.sql +3 -0
- package/db/migrations/0019_engine_account_store_ref.sql +3 -0
- package/db/migrations/0020_user_credential_store_ref.sql +6 -0
- package/db/migrations/0021_project_env_store_ref.sql +3 -0
- package/db/migrations/0022_task_project_ids.sql +5 -0
- package/db/migrations/0023_project_env_org.sql +4 -0
- package/db/migrations/0024_task_env_mode.sql +9 -0
- package/db/migrations/0025_project_env_authority.sql +147 -0
- package/db/migrations/0026_project_env_replica.sql +150 -0
- package/db/migrations/0027_cloud_credential_foundation.sql +51 -0
- package/db/migrations/0028_leased_engine_runtimes.sql +11 -0
- package/db/migrations/0029_task_shared_files_mode.sql +5 -0
- package/db/migrations/0030_credential_cleanup_restops.sql +12 -0
- package/db/migrations/meta/_journal.json +223 -0
- package/db/schema.ts +850 -0
- package/images/standard/Dockerfile +347 -0
- package/images/standard/README.md +167 -0
- package/images/standard/container/code-server-settings.json +63 -0
- package/images/standard/container/corepack-version +1 -0
- package/images/standard/container/uai-init +704 -0
- package/images/standard/container/uai-materialize-runtimes +1527 -0
- package/images/standard/container/uai-task-forwarder.mjs +303 -0
- package/images/standard/tool-versions +2 -0
- package/lib/__fixtures__/skill-index/SKILL.md +10 -0
- package/lib/__fixtures__/skill-index/skills/accessibility-checklist.md +7 -0
- package/lib/__fixtures__/skill-index/skills/brand-voice.md +9 -0
- package/lib/__fixtures__/skill-index/skills/design-tokens.md +9 -0
- package/lib/agent-cli.ts +1507 -0
- package/lib/agent-errors.ts +22 -0
- package/lib/agent.ts +672 -0
- package/lib/agents/attach-compatibility.ts +51 -0
- package/lib/agents/claude.ts +918 -0
- package/lib/agents/codex-token-usage.fixture.json +130 -0
- package/lib/agents/codex.ts +1392 -0
- package/lib/agents/cursor-period-usage.ts +253 -0
- package/lib/agents/cursor.ts +562 -0
- package/lib/agents/dispatch.ts +78 -0
- package/lib/agents/durable-proc.ts +334 -0
- package/lib/agents/factory.ts +57 -0
- package/lib/agents/grok.ts +381 -0
- package/lib/agents/kimi.ts +329 -0
- package/lib/agents/machine-durable.ts +604 -0
- package/lib/agents/mock.ts +133 -0
- package/lib/agents/mode.ts +64 -0
- package/lib/agents/opencode.ts +508 -0
- package/lib/agents/output-source.ts +3 -0
- package/lib/agents/proc.ts +221 -0
- package/lib/agents/rate-limit.ts +337 -0
- package/lib/agents/registry.ts +182 -0
- package/lib/agents/session-state.ts +197 -0
- package/lib/agents/skill-index-preamble.fixture.ts +26 -0
- package/lib/agents/transport.ts +741 -0
- package/lib/agents/types.ts +254 -0
- package/lib/agents/uai.ts +106 -0
- package/lib/agents/usage.ts +44 -0
- package/lib/apple-one-off.ts +277 -0
- package/lib/apple-runtime-recycle.ts +237 -0
- package/lib/apple-uninstall-teardown.ts +224 -0
- package/lib/assignment-evidence.ts +94 -0
- package/lib/attachments.ts +48 -0
- package/lib/browser-testing.ts +1537 -0
- package/lib/capture-flow.ts +332 -0
- package/lib/cloud-credential-cleanup-adapter.ts +123 -0
- package/lib/cloud-credential-host.ts +512 -0
- package/lib/cloud-credential-leases.ts +2587 -0
- package/lib/cloud-credential-runtime.ts +1357 -0
- package/lib/cloud-state.ts +56 -0
- package/lib/codex-auth.ts +456 -0
- package/lib/command-db.ts +310 -0
- package/lib/compose-network-recovery.ts +283 -0
- package/lib/connection-secret-redaction.ts +25 -0
- package/lib/container-runtime.ts +1453 -0
- package/lib/credential-cleanup-inventory.ts +490 -0
- package/lib/credential-consumers/engine-anchor.ts +1880 -0
- package/lib/credential-consumers/engines.ts +797 -0
- package/lib/credential-consumers/files.ts +934 -0
- package/lib/credential-consumers/github.ts +702 -0
- package/lib/credential-consumers/mcp.ts +389 -0
- package/lib/credential-consumers/task-files.ts +127 -0
- package/lib/credential-consumers/task-secrets.ts +10 -0
- package/lib/credential-store-pickup.ts +149 -0
- package/lib/credential-store-scope.ts +112 -0
- package/lib/credential-store.ts +932 -0
- package/lib/credentials-activation-authority.ts +709 -0
- package/lib/credentials-purge.ts +1006 -0
- package/lib/custom-connection-capture.ts +395 -0
- package/lib/custom-connection-launch.ts +384 -0
- package/lib/custom-connection-runtime.ts +141 -0
- package/lib/db.ts +80 -0
- package/lib/disk-pressure.ts +42 -0
- package/lib/docker-exec.ts +156 -0
- package/lib/engine-accounts.ts +1970 -0
- package/lib/engine-connect.ts +321 -0
- package/lib/engine-credential-env.ts +52 -0
- package/lib/engine-login.ts +2933 -0
- package/lib/engines.ts +1342 -0
- package/lib/enrollment-state.ts +251 -0
- package/lib/env-file.ts +246 -0
- package/lib/env-schema.ts +891 -0
- package/lib/env.ts +195 -0
- package/lib/fs-aliases.ts +49 -0
- package/lib/git-clone-credentials.ts +334 -0
- package/lib/git-diff.ts +530 -0
- package/lib/git-identity.ts +715 -0
- package/lib/github-git-auth.ts +207 -0
- package/lib/github-origin.ts +28 -0
- package/lib/github-tokens.ts +2439 -0
- package/lib/host-cloud-url.ts +62 -0
- package/lib/host-config.ts +279 -0
- package/lib/host-env.ts +424 -0
- package/lib/host-logs.ts +965 -0
- package/lib/internal-connections.ts +5 -0
- package/lib/keyed-promise-tail.ts +23 -0
- package/lib/launch-mode.ts +25 -0
- package/lib/lease.ts +239 -0
- package/lib/legacy-host.ts +769 -0
- package/lib/legacy-runtime-retirement.ts +424 -0
- package/lib/legacy-runtime-v1.fixture.ts +627 -0
- package/lib/machine-exec.ts +238 -0
- package/lib/machine-gateway-tunnel.ts +344 -0
- package/lib/machine-keys.ts +65 -0
- package/lib/machine-project-env-loader.ts +152 -0
- package/lib/machine-provider-aws.ts +923 -0
- package/lib/machine-provider-local.ts +338 -0
- package/lib/machine-provider.ts +90 -0
- package/lib/managed-activation-watcher.ts +72 -0
- package/lib/managed-install-owner-watcher.ts +55 -0
- package/lib/managed-operation-drain.ts +57 -0
- package/lib/managed-runtime.ts +4296 -0
- package/lib/managed-update-scheduler.ts +125 -0
- package/lib/mcp-capture.ts +865 -0
- package/lib/mcp-config-lock.ts +2 -0
- package/lib/mcp-connections.ts +959 -0
- package/lib/mcp-gateway.ts +2594 -0
- package/lib/obs.ts +512 -0
- package/lib/openrouter-budget.ts +247 -0
- package/lib/orchestrator.ts +11409 -0
- package/lib/person-capture.ts +327 -0
- package/lib/preview-ports.ts +85 -0
- package/lib/preview-sidecar.ts +331 -0
- package/lib/project-env-authority.ts +1319 -0
- package/lib/project-env-feature.ts +74 -0
- package/lib/project-env-legacy-tags.ts +130 -0
- package/lib/project-env-loader.ts +242 -0
- package/lib/project-env-replica.ts +2094 -0
- package/lib/project-env-schema-capture.ts +342 -0
- package/lib/project-env-staging.ts +148 -0
- package/lib/project-env-task-policy.ts +176 -0
- package/lib/project-env-task-replica.ts +1752 -0
- package/lib/release-feed.ts +61 -0
- package/lib/release-manifest.ts +710 -0
- package/lib/release-trust.ts +46 -0
- package/lib/repo-clone.ts +38 -0
- package/lib/routing/shadow.ts +261 -0
- package/lib/runner-boot.ts +10 -0
- package/lib/runtime-activation-tail.ts +279 -0
- package/lib/runtime-archive.ts +1216 -0
- package/lib/runtime-authority.ts +166 -0
- package/lib/runtime-guard.ts +36 -0
- package/lib/runtime-provider-state.ts +169 -0
- package/lib/runtime-reconnect.ts +86 -0
- package/lib/runtime-state.ts +1033 -0
- package/lib/safe-http.ts +364 -0
- package/lib/secrets-proxy/address.ts +95 -0
- package/lib/secrets-proxy/audit.ts +30 -0
- package/lib/secrets-proxy/ca-store.ts +200 -0
- package/lib/secrets-proxy/ca.ts +470 -0
- package/lib/secrets-proxy/container-steps.ts +138 -0
- package/lib/secrets-proxy/der.ts +273 -0
- package/lib/secrets-proxy/engine.ts +1633 -0
- package/lib/secrets-proxy/host.ts +93 -0
- package/lib/secrets-proxy/hostname.ts +57 -0
- package/lib/secrets-proxy/placeholders.ts +196 -0
- package/lib/secrets-proxy/policy.ts +150 -0
- package/lib/secrets-proxy/readiness.ts +148 -0
- package/lib/secrets-proxy/runtime.ts +582 -0
- package/lib/secrets-proxy/scrub.ts +559 -0
- package/lib/secrets-proxy/sidecar-view.ts +139 -0
- package/lib/secrets-proxy/task-scripts.ts +262 -0
- package/lib/secrets-proxy/task-trust.ts +605 -0
- package/lib/secrets-proxy/trust-watch.ts +80 -0
- package/lib/secrets.ts +71 -0
- package/lib/shared-files-s3.ts +976 -0
- package/lib/shared-files.ts +156 -0
- package/lib/sidecar-assign.ts +49 -0
- package/lib/sidecar-guard.ts +58 -0
- package/lib/sidecar-resume.ts +178 -0
- package/lib/sidecar.ts +69 -0
- package/lib/signin-box-credentials.ts +465 -0
- package/lib/signin-box.ts +114 -0
- package/lib/skill-index-example.fixture.ts +56 -0
- package/lib/skill-layout.ts +417 -0
- package/lib/skills.ts +489 -0
- package/lib/ssh.ts +401 -0
- package/lib/standard-image-cleanup.ts +117 -0
- package/lib/standard-image-pin.ts +207 -0
- package/lib/standard-image.ts +1697 -0
- package/lib/stopped-task-status-queue.ts +120 -0
- package/lib/task-container-cli.ts +269 -0
- package/lib/task-credential-cleanup.ts +752 -0
- package/lib/task-diff-sandbox.ts +464 -0
- package/lib/task-diff.ts +91 -0
- package/lib/task-env-forwarder.ts +228 -0
- package/lib/task-env-source.ts +249 -0
- package/lib/task-environment/anchor.ts +31 -0
- package/lib/task-environment/apple-container.ts +860 -0
- package/lib/task-environment/docker.ts +1344 -0
- package/lib/task-environment/index.ts +466 -0
- package/lib/task-environment/legacy-adoption.ts +467 -0
- package/lib/task-environment/machine-environment.ts +61 -0
- package/lib/task-environment/machine-task-up.ts +1227 -0
- package/lib/task-environment/machine.ts +974 -0
- package/lib/task-environment/registry.ts +59 -0
- package/lib/task-environment/self-identity.ts +20 -0
- package/lib/task-environment/self.ts +566 -0
- package/lib/task-environment/types.ts +642 -0
- package/lib/task-environment/workspace-files.ts +685 -0
- package/lib/task-environment/workspace-runtime.fixture.ts +50 -0
- package/lib/task-files-machine-retirement-outbox.ts +592 -0
- package/lib/task-files-machine-retirement.ts +116 -0
- package/lib/task-files-readiness.ts +127 -0
- package/lib/task-gateway-port.ts +22 -0
- package/lib/task-gateway-protocol.ts +190 -0
- package/lib/task-gateway-recovery.ts +47 -0
- package/lib/task-gateway-state.ts +512 -0
- package/lib/task-identity.ts +19 -0
- package/lib/task-inventory.ts +682 -0
- package/lib/task-machine-credentials-activation.ts +95 -0
- package/lib/task-machine-swap-safety.ts +729 -0
- package/lib/task-person-credentials.ts +76 -0
- package/lib/task-project-ops-host.ts +147 -0
- package/lib/task-project-ops.ts +1062 -0
- package/lib/task-secret-staging.ts +16 -0
- package/lib/task-status.ts +46 -0
- package/lib/task-token.ts +46 -0
- package/lib/token-broker-adopt.ts +344 -0
- package/lib/token-broker-lambda.ts +105 -0
- package/lib/token-broker-local.ts +392 -0
- package/lib/token-broker-revision.ts +310 -0
- package/lib/token-broker-ssm.ts +525 -0
- package/lib/token-broker.ts +218 -0
- package/lib/token-lanes/openai-oauth.ts +123 -0
- package/lib/token-lanes/registry.ts +45 -0
- package/lib/token-lanes/types.ts +163 -0
- package/lib/token-lanes/xai-oauth.ts +165 -0
- package/lib/transcript.ts +128 -0
- package/lib/tunnel-registry.ts +220 -0
- package/lib/tunnel-runtime.ts +235 -0
- package/lib/ulid.ts +16 -0
- package/lib/user-bus-env.ts +116 -0
- package/lib/user-credentials-store.ts +435 -0
- package/lib/vnode-pressure.ts +142 -0
- package/package.json +131 -4
- package/runner/runner.mjs +442 -0
- package/scripts/agent/_common.sh +633 -0
- package/scripts/agent/env-hook.sh +626 -0
- package/scripts/agent/moondeck-cli.sh +5 -0
- package/scripts/agent/task-down.sh +291 -0
- package/scripts/agent/task-status.sh +70 -0
- package/scripts/agent/task-up.sh +3761 -0
- package/scripts/agent/uai-env-exec.sh +26 -0
- package/scripts/install/darwin.ts +1048 -0
- package/scripts/install/linux.ts +958 -0
- package/scripts/install/types.ts +78 -0
- package/scripts/install/util.ts +254 -0
- package/scripts/install/win.ts +143 -0
- package/src/activation-respawn.ts +155 -0
- package/src/apple-tunnel-route.ts +104 -0
- package/src/capture-wire.ts +102 -0
- package/src/cli.ts +2583 -0
- package/src/cloud-credentials.ts +2144 -0
- package/src/cloud-origins.ts +23 -0
- package/src/credential-location.ts +248 -0
- package/src/credential-wire-validation.ts +25 -0
- package/src/custom-connections.ts +237 -0
- package/src/engine-account-paths.ts +11 -0
- package/src/engine-models.ts +39 -0
- package/src/engine-usage.ts +104 -0
- package/src/event-outbox.ts +185 -0
- package/src/index.ts +2899 -0
- package/src/load-env.ts +97 -0
- package/src/machine-tunnel-route.ts +156 -0
- package/src/main.ts +6349 -0
- package/src/never-proxied-domains.ts +31 -0
- package/src/outbound-frame.ts +31 -0
- package/src/paths.ts +81 -0
- package/src/project-env-latest.ts +130 -0
- package/src/project-env-policy.ts +396 -0
- package/src/project-env-wording.ts +53 -0
- package/src/protocol.ts +5545 -0
- package/src/runtime-bootstrap.ts +165 -0
- package/src/station-identity.ts +96 -0
- package/src/task-env-names.ts +28 -0
- package/src/task-secrets.ts +75 -0
- package/src/tunnel-dial.ts +102 -0
- package/src/ui/server.ts +852 -0
- package/src/ui/types.ts +173 -0
- package/src/workspace-path-policy.ts +41 -0
- package/ui/app.js +1102 -0
- package/ui/index.html +90 -0
- package/ui/style.css +685 -0
- package/ui/uai-favicon.svg +21 -0
- package/ui/uai-logo-black.svg +9 -0
- package/ui/uai-wheel.svg +9 -0
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lane `xai-oauth` — Grok subscription sign-ins (`grok login --device-auth`).
|
|
3
|
+
*
|
|
4
|
+
* `auth.json` is keyed by `<issuer>::<client-id>`; each entry carries
|
|
5
|
+
* `key` (the bearer the CLI sends), `refresh_token`, `expires_at` (ISO),
|
|
6
|
+
* `oidc_issuer` and `oidc_client_id`. Refresh is a standard OIDC
|
|
7
|
+
* `grant_type=refresh_token` against `https://auth.x.ai/oauth2/token`
|
|
8
|
+
* (confirmed from the issuer's discovery document, 2026-09-09; refresh_token
|
|
9
|
+
* is among its supported grants).
|
|
10
|
+
*
|
|
11
|
+
* ASSUMPTION, recorded until observed on a live refresh: `key` is the OIDC
|
|
12
|
+
* access token, so a refresh replaces it with `access_token`. The lane never
|
|
13
|
+
* writes on an answer that lacks `access_token`, so a wrong assumption costs
|
|
14
|
+
* a failed refresh (retried, then the reactive path), never a corrupted
|
|
15
|
+
* sign-in.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
BROKER_REFRESH_PLACEHOLDER,
|
|
20
|
+
isTerminalOAuthError,
|
|
21
|
+
parseObjectFile,
|
|
22
|
+
type CredentialFiles,
|
|
23
|
+
type CredentialHeader,
|
|
24
|
+
type LaneHttp,
|
|
25
|
+
type RefreshOutcome,
|
|
26
|
+
type TokenLane,
|
|
27
|
+
} from "./types";
|
|
28
|
+
import { brokerErrorKind, CREDENTIAL_LANE_SCHEDULES } from "../../src/cloud-credentials";
|
|
29
|
+
|
|
30
|
+
export const XAI_ISSUER = "https://auth.x.ai";
|
|
31
|
+
export const XAI_TOKEN_ENDPOINT = `${XAI_ISSUER}/oauth2/token`;
|
|
32
|
+
const HOUR_MS = 60 * 60 * 1000;
|
|
33
|
+
|
|
34
|
+
interface Entry {
|
|
35
|
+
key: string;
|
|
36
|
+
doc: Record<string, unknown>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function entriesOf(files: CredentialFiles): { doc: Record<string, unknown>; entries: Entry[] } | null {
|
|
40
|
+
const doc = parseObjectFile(files["auth.json"]);
|
|
41
|
+
if (!doc) return null;
|
|
42
|
+
const entries: Entry[] = [];
|
|
43
|
+
for (const [key, value] of Object.entries(doc)) {
|
|
44
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
45
|
+
entries.push({ key, doc: value as Record<string, unknown> });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return { doc, entries };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function expiryOf(entry: Record<string, unknown>): number | null {
|
|
52
|
+
const raw = entry.expires_at;
|
|
53
|
+
if (typeof raw !== "string") return null;
|
|
54
|
+
const ms = Date.parse(raw);
|
|
55
|
+
return Number.isFinite(ms) ? ms : null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export const xaiOAuthLane: TokenLane = {
|
|
59
|
+
id: "xai-oauth",
|
|
60
|
+
endpoints: [XAI_TOKEN_ENDPOINT],
|
|
61
|
+
// Observed 2026-09-09: the token lives SIX hours (create_time → expires_at).
|
|
62
|
+
// Refresh with three hours left — the hourly cycle then renews it at about
|
|
63
|
+
// four hours of age — and every four hours when expiry cannot be read.
|
|
64
|
+
// ADR-154 §7: the cloud names a revision on the same schedule.
|
|
65
|
+
schedule: CREDENTIAL_LANE_SCHEDULES["xai-oauth"]!,
|
|
66
|
+
reactiveSignals: [/invalid_grant/i, /token (?:has )?expired/i, /log ?in again/i, /unauthorized/i],
|
|
67
|
+
|
|
68
|
+
matches(header: CredentialHeader, files: CredentialFiles): boolean {
|
|
69
|
+
if (header.kind !== "grok" || header.authKind !== "config-dir") return false;
|
|
70
|
+
const parsed = entriesOf(files);
|
|
71
|
+
return (
|
|
72
|
+
parsed !== null && parsed.entries.some((e) => typeof e.doc.refresh_token === "string")
|
|
73
|
+
);
|
|
74
|
+
},
|
|
75
|
+
|
|
76
|
+
expiresAt(files: CredentialFiles): number | null {
|
|
77
|
+
const parsed = entriesOf(files);
|
|
78
|
+
if (!parsed) return null;
|
|
79
|
+
const known = parsed.entries.map((e) => expiryOf(e.doc)).filter((v): v is number => v !== null);
|
|
80
|
+
return known.length ? Math.min(...known) : null;
|
|
81
|
+
},
|
|
82
|
+
|
|
83
|
+
brokeredCopy(files: CredentialFiles): CredentialFiles {
|
|
84
|
+
const parsed = entriesOf(files);
|
|
85
|
+
if (!parsed) return files;
|
|
86
|
+
let changed = false;
|
|
87
|
+
for (const entry of parsed.entries) {
|
|
88
|
+
if (
|
|
89
|
+
typeof entry.doc.refresh_token === "string" &&
|
|
90
|
+
entry.doc.refresh_token !== BROKER_REFRESH_PLACEHOLDER
|
|
91
|
+
) {
|
|
92
|
+
entry.doc.refresh_token = BROKER_REFRESH_PLACEHOLDER;
|
|
93
|
+
changed = true;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return changed ? { ...files, "auth.json": `${JSON.stringify(parsed.doc, null, 2)}\n` } : files;
|
|
97
|
+
},
|
|
98
|
+
|
|
99
|
+
async refresh(files: CredentialFiles, http: LaneHttp, now: number): Promise<RefreshOutcome> {
|
|
100
|
+
const parsed = entriesOf(files);
|
|
101
|
+
if (!parsed) return { ok: false, terminal: true, reason: "auth.json is not readable" };
|
|
102
|
+
let refreshed = 0;
|
|
103
|
+
let expiresAt: number | null = null;
|
|
104
|
+
for (const entry of parsed.entries) {
|
|
105
|
+
const current = entry.doc.refresh_token;
|
|
106
|
+
if (typeof current !== "string" || current === BROKER_REFRESH_PLACEHOLDER) continue;
|
|
107
|
+
const issuer = typeof entry.doc.oidc_issuer === "string" ? entry.doc.oidc_issuer : XAI_ISSUER;
|
|
108
|
+
if (issuer.replace(/\/$/, "") !== XAI_ISSUER) {
|
|
109
|
+
return { ok: false, terminal: true, reason: "unexpected issuer" };
|
|
110
|
+
}
|
|
111
|
+
const clientId = entry.doc.oidc_client_id;
|
|
112
|
+
if (typeof clientId !== "string" || !clientId) {
|
|
113
|
+
return { ok: false, terminal: true, reason: "entry has no client id" };
|
|
114
|
+
}
|
|
115
|
+
let res;
|
|
116
|
+
try {
|
|
117
|
+
res = await http.postForm(XAI_TOKEN_ENDPOINT, {
|
|
118
|
+
grant_type: "refresh_token",
|
|
119
|
+
refresh_token: current,
|
|
120
|
+
client_id: clientId,
|
|
121
|
+
});
|
|
122
|
+
} catch (error) {
|
|
123
|
+
return {
|
|
124
|
+
ok: false,
|
|
125
|
+
terminal: false,
|
|
126
|
+
// A transport error can quote the request (and therefore the refresh
|
|
127
|
+
// token) in its message. Only its bounded kind may cross the lane.
|
|
128
|
+
reason: brokerErrorKind(error),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
if (res.status < 200 || res.status >= 300) {
|
|
132
|
+
return {
|
|
133
|
+
ok: false,
|
|
134
|
+
terminal: isTerminalOAuthError(res.status, res.json),
|
|
135
|
+
reason: `HTTP ${res.status}`,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
const body =
|
|
139
|
+
res.json && typeof res.json === "object" && !Array.isArray(res.json)
|
|
140
|
+
? (res.json as Record<string, unknown>)
|
|
141
|
+
: null;
|
|
142
|
+
const access = body?.access_token;
|
|
143
|
+
if (typeof access !== "string" || !access) {
|
|
144
|
+
return { ok: false, terminal: false, reason: "response missing access_token" };
|
|
145
|
+
}
|
|
146
|
+
entry.doc.key = access;
|
|
147
|
+
if (typeof body?.refresh_token === "string" && body.refresh_token) {
|
|
148
|
+
entry.doc.refresh_token = body.refresh_token;
|
|
149
|
+
}
|
|
150
|
+
const expiresIn = body?.expires_in;
|
|
151
|
+
if (typeof expiresIn === "number" && Number.isFinite(expiresIn)) {
|
|
152
|
+
const at = now + expiresIn * 1000;
|
|
153
|
+
entry.doc.expires_at = new Date(at).toISOString();
|
|
154
|
+
expiresAt = expiresAt === null ? at : Math.min(expiresAt, at);
|
|
155
|
+
}
|
|
156
|
+
refreshed += 1;
|
|
157
|
+
}
|
|
158
|
+
if (refreshed === 0) return { ok: false, terminal: true, reason: "no refresh token to use" };
|
|
159
|
+
return {
|
|
160
|
+
ok: true,
|
|
161
|
+
files: { ...files, "auth.json": `${JSON.stringify(parsed.doc, null, 2)}\n` },
|
|
162
|
+
expiresAt,
|
|
163
|
+
};
|
|
164
|
+
},
|
|
165
|
+
};
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shared channel transcript (ADR-027-adjacent). Agents have private,
|
|
3
|
+
* isolated conversations — each only hears what it's addressed. So they can opt
|
|
4
|
+
* into cross-agent awareness, the host maintains a plain-text log of every chat
|
|
5
|
+
* message at `<workspace>/.moondeck/chat.md` (mounted at /workspace), which any agent
|
|
6
|
+
* can read on demand. Secretary mode adds a frontstage projection at
|
|
7
|
+
* `chat-front.md`; the cloud decides which projection(s) a row belongs to and
|
|
8
|
+
* sends an explicit target list. Conversational rows (including dispatch
|
|
9
|
+
* instructions) only — no tool-call execution traces.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { mkdirSync } from "node:fs";
|
|
13
|
+
|
|
14
|
+
import { taskWorkspaceDir } from "./env";
|
|
15
|
+
import { rewriteAttachmentRefs } from "./orchestrator";
|
|
16
|
+
import type { TaskEnvironmentHandle } from "./task-environment/types";
|
|
17
|
+
import { appendContainedFileSync, makeContainedDirectoriesSync } from "./task-environment/workspace-files";
|
|
18
|
+
import type { TranscriptTarget } from "../src/protocol";
|
|
19
|
+
|
|
20
|
+
/** Container path agents are pointed at. */
|
|
21
|
+
export const CONTAINER_TRANSCRIPT_PATH = "/workspace/.moondeck/chat.md";
|
|
22
|
+
export const CONTAINER_FRONT_TRANSCRIPT_PATH =
|
|
23
|
+
"/workspace/.moondeck/chat-front.md";
|
|
24
|
+
|
|
25
|
+
const TARGET_FILENAME: Record<TranscriptTarget, string> = {
|
|
26
|
+
chat: "chat.md",
|
|
27
|
+
"chat-front": "chat-front.md",
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
function transcriptEntry(author: string, text: string): string | null {
|
|
31
|
+
// Rewrite cloud attachment URLs to the in-container path so an agent reading
|
|
32
|
+
// the transcript can open referenced files directly.
|
|
33
|
+
const body = rewriteAttachmentRefs(text).trim();
|
|
34
|
+
if (!body) return null;
|
|
35
|
+
return `## ${author}\n\n${body}\n\n`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function appendTranscript(
|
|
39
|
+
taskId: string,
|
|
40
|
+
author: string,
|
|
41
|
+
text: string,
|
|
42
|
+
targets: TranscriptTarget[],
|
|
43
|
+
): void {
|
|
44
|
+
const entry = transcriptEntry(author, text);
|
|
45
|
+
if (entry === null) return;
|
|
46
|
+
// Into the task's own .moondeck, never through a link the task put there
|
|
47
|
+
// (#cb3j0z). The workspace root is the runner's own path; .moondeck, directly
|
|
48
|
+
// below it, is made if gone without following a link.
|
|
49
|
+
const workspace = taskWorkspaceDir(taskId);
|
|
50
|
+
mkdirSync(workspace, { recursive: true });
|
|
51
|
+
makeContainedDirectoriesSync(workspace, [".moondeck"]);
|
|
52
|
+
for (const target of new Set(targets)) {
|
|
53
|
+
appendContainedFileSync(workspace, [".moondeck", TARGET_FILENAME[target]], Buffer.from(entry));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
// ADR-121: environment-side transcript for machine-backed tasks. The
|
|
59
|
+
// workspace lives in the machine's world, so the append travels over the
|
|
60
|
+
// environment transport (entry via stdin). A per-task promise chain
|
|
61
|
+
// preserves message order the way the sync host-FS path did implicitly.
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
const environmentAppendChains = new Map<string, Promise<void>>();
|
|
65
|
+
|
|
66
|
+
async function appendOneViaEnvironment(
|
|
67
|
+
environment: TaskEnvironmentHandle<unknown>,
|
|
68
|
+
target: TranscriptTarget,
|
|
69
|
+
entry: string,
|
|
70
|
+
): Promise<void> {
|
|
71
|
+
const path = `${environment.descriptor.workspacePath}/.moondeck/${TARGET_FILENAME[target]}`;
|
|
72
|
+
const proc = await environment.spawn({
|
|
73
|
+
projectEnvProfile: "maintenance",
|
|
74
|
+
argv: [
|
|
75
|
+
"/bin/sh",
|
|
76
|
+
"-c",
|
|
77
|
+
'mkdir -p "$(dirname "$1")" && cat >> "$1"',
|
|
78
|
+
"append",
|
|
79
|
+
path,
|
|
80
|
+
],
|
|
81
|
+
inheritEnv: [],
|
|
82
|
+
env: {},
|
|
83
|
+
timeoutMs: 30_000,
|
|
84
|
+
maxOutputBytes: 8 * 1024,
|
|
85
|
+
});
|
|
86
|
+
const drains = [
|
|
87
|
+
(async () => {
|
|
88
|
+
for await (const chunk of proc.stdout) void chunk;
|
|
89
|
+
})(),
|
|
90
|
+
(async () => {
|
|
91
|
+
for await (const chunk of proc.stderr) void chunk;
|
|
92
|
+
})(),
|
|
93
|
+
];
|
|
94
|
+
await proc.write(Buffer.from(entry));
|
|
95
|
+
await proc.closeInput();
|
|
96
|
+
const exit = await proc.completion;
|
|
97
|
+
await Promise.allSettled(drains);
|
|
98
|
+
if (exit.exitCode !== 0) {
|
|
99
|
+
throw new Error(`environment transcript append exited ${exit.exitCode}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function appendTranscriptViaEnvironment(
|
|
104
|
+
taskId: string,
|
|
105
|
+
environment: TaskEnvironmentHandle<unknown>,
|
|
106
|
+
author: string,
|
|
107
|
+
text: string,
|
|
108
|
+
targets: TranscriptTarget[],
|
|
109
|
+
): Promise<void> {
|
|
110
|
+
const entry = transcriptEntry(author, text);
|
|
111
|
+
if (entry === null) return Promise.resolve();
|
|
112
|
+
const previous = environmentAppendChains.get(taskId) ?? Promise.resolve();
|
|
113
|
+
const next = previous
|
|
114
|
+
.catch(() => {})
|
|
115
|
+
.then(async () => {
|
|
116
|
+
for (const target of new Set(targets)) {
|
|
117
|
+
await appendOneViaEnvironment(environment, target, entry);
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
environmentAppendChains.set(taskId, next);
|
|
121
|
+
// Bound the map: forget the chain once it settles as the latest entry.
|
|
122
|
+
void next.finally(() => {
|
|
123
|
+
if (environmentAppendChains.get(taskId) === next) {
|
|
124
|
+
environmentAppendChains.delete(taskId);
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
return next;
|
|
128
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tunnel upstreams, plus the frames that arrive before an upstream exists.
|
|
3
|
+
*
|
|
4
|
+
* `tunnel.open` is handled ASYNCHRONOUSLY on the host: resolving a target can
|
|
5
|
+
* await the task lifecycle lock and, for an ad-hoc preview, `docker run` of a
|
|
6
|
+
* sidecar (ADR-043). The frames that follow it — `tunnel.data`,
|
|
7
|
+
* `tunnel.requestEnd`, `tunnel.close` — are handled synchronously off the same
|
|
8
|
+
* WebSocket, and the cloud emits `requestEnd` for a bodyless GET on the tick
|
|
9
|
+
* after `open`. So they routinely land while the open is still resolving.
|
|
10
|
+
*
|
|
11
|
+
* Looking the tunnel up with `tunnels.get(id)?.` silently dropped them, and a
|
|
12
|
+
* dropped `requestEnd` is unrecoverable rather than merely late. The HTTP open
|
|
13
|
+
* deliberately leaves its `ClientRequest` unended so request bodies can stream,
|
|
14
|
+
* and node writes no header until `write`/`end` — so the upstream socket
|
|
15
|
+
* CONNECTS and then receives nothing at all. On the wire that reads as an
|
|
16
|
+
* established TCP connection carrying zero bytes, an upstream that never saw a
|
|
17
|
+
* request, and a flat 30s `504 host did not respond in time` from the cloud's
|
|
18
|
+
* ack deadline: four symptoms that each point somewhere different, none of them
|
|
19
|
+
* at the open that never finished.
|
|
20
|
+
*
|
|
21
|
+
* So ops for an opening tunnel are QUEUED and replayed on `register`. The
|
|
22
|
+
* queue is bounded by the request itself — the cloud stops sending once its
|
|
23
|
+
* ack deadline fires — and is dropped by `abandon` when an open fails, so a
|
|
24
|
+
* tunnel that never opens leaves nothing behind.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
export interface TunnelUpstream {
|
|
28
|
+
write(chunk: Buffer): boolean;
|
|
29
|
+
end(): void;
|
|
30
|
+
destroy(): void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type TunnelOp = (upstream: TunnelUpstream) => void;
|
|
34
|
+
type TunnelRelease = () => Promise<void>;
|
|
35
|
+
|
|
36
|
+
interface OpenTunnel {
|
|
37
|
+
readonly upstream: TunnelUpstream;
|
|
38
|
+
readonly release: TunnelRelease;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface OpeningTunnel {
|
|
42
|
+
readonly ops: TunnelOp[];
|
|
43
|
+
readonly settled: Promise<void>;
|
|
44
|
+
finish(): void;
|
|
45
|
+
aborted: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export class TunnelRegistry {
|
|
49
|
+
private readonly open = new Map<string, OpenTunnel>();
|
|
50
|
+
/** tunnelId -> ops that arrived while its `tunnel.open` was still resolving. */
|
|
51
|
+
private readonly opening = new Map<string, OpeningTunnel>();
|
|
52
|
+
/** Async provider teardown is joined by connection replacement/shutdown. */
|
|
53
|
+
private readonly pendingReleases = new Set<Promise<void>>();
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Mark a tunnel as opening. MUST be called synchronously when `tunnel.open`
|
|
57
|
+
* is received — before the first `await` of target resolution — or the frames
|
|
58
|
+
* that follow have nothing to queue against and are dropped, which is the
|
|
59
|
+
* whole bug this class exists for.
|
|
60
|
+
*/
|
|
61
|
+
markOpening(tunnelId: string): void {
|
|
62
|
+
const previous = this.opening.get(tunnelId);
|
|
63
|
+
if (previous) {
|
|
64
|
+
previous.aborted = true;
|
|
65
|
+
previous.finish();
|
|
66
|
+
}
|
|
67
|
+
let finish!: () => void;
|
|
68
|
+
const settled = new Promise<void>((resolve) => {
|
|
69
|
+
finish = resolve;
|
|
70
|
+
});
|
|
71
|
+
this.opening.set(tunnelId, {
|
|
72
|
+
ops: [],
|
|
73
|
+
settled,
|
|
74
|
+
finish,
|
|
75
|
+
aborted: false,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Target resolved: publish the upstream and replay what arrived meanwhile.
|
|
80
|
+
* Returns whether this upstream now owns the tunnel — false means the open
|
|
81
|
+
* was canceled during resolution and the upstream was destroyed, so the
|
|
82
|
+
* caller must not write to it (a stale generation must never emit bytes). */
|
|
83
|
+
register(
|
|
84
|
+
tunnelId: string,
|
|
85
|
+
upstream: TunnelUpstream,
|
|
86
|
+
release: TunnelRelease = async () => {},
|
|
87
|
+
): boolean {
|
|
88
|
+
const pending = this.opening.get(tunnelId);
|
|
89
|
+
this.opening.delete(tunnelId);
|
|
90
|
+
pending?.finish();
|
|
91
|
+
const releaseOnce = onceAsync(release);
|
|
92
|
+
if (pending?.aborted) {
|
|
93
|
+
upstream.destroy();
|
|
94
|
+
this.trackRelease(releaseOnce());
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
this.open.set(tunnelId, { upstream, release: releaseOnce });
|
|
98
|
+
if (!pending) return true;
|
|
99
|
+
for (const op of pending.ops) op(upstream);
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Open failed (no target, or it errored) — nothing will ever service these. */
|
|
104
|
+
abandon(tunnelId: string): void {
|
|
105
|
+
this.opening.get(tunnelId)?.finish();
|
|
106
|
+
this.opening.delete(tunnelId);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
get(tunnelId: string): TunnelUpstream | undefined {
|
|
110
|
+
return this.open.get(tunnelId)?.upstream;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
delete(tunnelId: string): void {
|
|
114
|
+
const current = this.open.get(tunnelId);
|
|
115
|
+
this.open.delete(tunnelId);
|
|
116
|
+
if (current) this.trackRelease(current.release());
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Run `op` against the upstream now, or queue it if the tunnel is still
|
|
121
|
+
* opening. A tunnel this registry has never heard of is dropped, exactly as
|
|
122
|
+
* the old `?.` did — that is a frame for a tunnel already torn down, not a
|
|
123
|
+
* race.
|
|
124
|
+
*/
|
|
125
|
+
apply(tunnelId: string, op: TunnelOp): void {
|
|
126
|
+
const current = this.open.get(tunnelId);
|
|
127
|
+
if (current) {
|
|
128
|
+
op(current.upstream);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
const pending = this.opening.get(tunnelId);
|
|
132
|
+
if (pending && !pending.aborted) pending.ops.push(op);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Tear the tunnel down now, or as soon as it opens. Queued ops are discarded
|
|
137
|
+
* rather than replayed first: they exist to be written to an upstream that is
|
|
138
|
+
* about to be destroyed, and a `write` after `destroy` is at best wasted and
|
|
139
|
+
* at worst an unhandled error on a socket nobody is listening to any more.
|
|
140
|
+
*/
|
|
141
|
+
abort(tunnelId: string): void {
|
|
142
|
+
const current = this.open.get(tunnelId);
|
|
143
|
+
this.open.delete(tunnelId);
|
|
144
|
+
if (current) {
|
|
145
|
+
current.upstream.destroy();
|
|
146
|
+
this.trackRelease(current.release());
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
const pending = this.opening.get(tunnelId);
|
|
150
|
+
if (pending) {
|
|
151
|
+
pending.ops.length = 0;
|
|
152
|
+
pending.aborted = true;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Destroy every upstream from one bridge generation and wait until opening
|
|
157
|
+
* targets have either failed or registered and their provider resources are
|
|
158
|
+
* released. No SSH forward may outlive the WebSocket generation that owns
|
|
159
|
+
* it — but reconnect must not be hostage to one wedged open, so the join is
|
|
160
|
+
* bounded: entries are already marked aborted, their eventual register()
|
|
161
|
+
* destroys the upstream and releases provider resources, and the releases
|
|
162
|
+
* stay tracked for the next generation's join. */
|
|
163
|
+
async abortAll(timeoutMs = 15_000): Promise<void> {
|
|
164
|
+
for (const pending of this.opening.values()) {
|
|
165
|
+
pending.ops.length = 0;
|
|
166
|
+
pending.aborted = true;
|
|
167
|
+
}
|
|
168
|
+
for (const [tunnelId, current] of this.open) {
|
|
169
|
+
this.open.delete(tunnelId);
|
|
170
|
+
current.upstream.destroy();
|
|
171
|
+
this.trackRelease(current.release());
|
|
172
|
+
}
|
|
173
|
+
let timer: NodeJS.Timeout | undefined;
|
|
174
|
+
const expired = new Promise<"timeout">((resolve) => {
|
|
175
|
+
timer = setTimeout(() => resolve("timeout"), timeoutMs);
|
|
176
|
+
timer.unref?.();
|
|
177
|
+
});
|
|
178
|
+
try {
|
|
179
|
+
while (this.opening.size > 0 || this.pendingReleases.size > 0) {
|
|
180
|
+
const opening = [...this.opening.values()].map((entry) => entry.settled);
|
|
181
|
+
const outcome = await Promise.race([
|
|
182
|
+
Promise.all([...opening, ...this.pendingReleases]).then(
|
|
183
|
+
() => "settled" as const,
|
|
184
|
+
),
|
|
185
|
+
expired,
|
|
186
|
+
]);
|
|
187
|
+
if (outcome === "timeout") {
|
|
188
|
+
console.warn(
|
|
189
|
+
`[tunnel] abortAll: ${this.opening.size} opening and ` +
|
|
190
|
+
`${this.pendingReleases.size} releasing tunnels did not settle ` +
|
|
191
|
+
`within ${timeoutMs}ms; continuing while they drain`,
|
|
192
|
+
);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
} finally {
|
|
197
|
+
clearTimeout(timer);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
private trackRelease(release: Promise<void>): void {
|
|
202
|
+
// A rejected provider release must neither crash the process (a bare
|
|
203
|
+
// `.finally()` chain rethrows with no handler attached) nor poison the
|
|
204
|
+
// abortAll() join above — cleanup failure is logged, never fatal.
|
|
205
|
+
const settled = release.catch((error: unknown) => {
|
|
206
|
+
console.warn(
|
|
207
|
+
`[tunnel] upstream release failed: ${
|
|
208
|
+
error instanceof Error ? error.message : String(error)
|
|
209
|
+
}`,
|
|
210
|
+
);
|
|
211
|
+
});
|
|
212
|
+
this.pendingReleases.add(settled);
|
|
213
|
+
void settled.finally(() => this.pendingReleases.delete(settled));
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function onceAsync(operation: TunnelRelease): TunnelRelease {
|
|
218
|
+
let result: Promise<void> | null = null;
|
|
219
|
+
return () => (result ??= operation());
|
|
220
|
+
}
|