codebay 0.0.1 → 0.2.1
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/.mochi/import-css/index-0nwv3654.css +18 -0
- package/.mochi/import-css/index-xndx4980.css +54 -0
- package/.mochi/manifest.json +1612 -0
- package/.mochi/server-island.js +1 -0
- package/.mochi/svelte-client/HydratableIsland-wrq79y0w.js +1 -0
- package/.mochi/svelte-client/_hydrate-AppShell_2rtnxn6pgp2rh-7xgbmxxg.js +2 -0
- package/.mochi/svelte-client/_hydrate-AvatarsView_257s9yq1wwr4f-3cnbjpz2.js +13 -0
- package/.mochi/svelte-client/_hydrate-InstanceView_2ma45l9bybfhl-2ntmdm3e.js +1 -0
- package/.mochi/svelte-client/_hydrate-SettingsView_8hmarwmf8onq-t08spym1.js +23 -0
- package/.mochi/svelte-client/chunk-04qv4w6w.js +2 -0
- package/.mochi/svelte-client/chunk-5jnepvzx.js +2 -0
- package/.mochi/svelte-client/chunk-hcztrd75.js +1 -0
- package/.mochi/svelte-client/chunk-qxwt25hn.js +4 -0
- package/.mochi/svelte-client/chunk-sjmcgh7f.js +2 -0
- package/.mochi/svelte-client/chunk-tr67wtwn.js +2 -0
- package/.mochi/svelte-compile/App-f0q5n4rk.server.js +1168 -0
- package/.mochi/svelte-compile/Avatars-p8gfef7x.server.js +332 -0
- package/.mochi/svelte-compile/ClientStats-z3tn9ad6.server.js +99 -0
- package/.mochi/svelte-compile/DefaultError-wh77vebf.server.js +52 -0
- package/.mochi/svelte-compile/Instance-atjwsczx.server.js +356 -0
- package/.mochi/svelte-compile/Settings-q88xw6h8.server.js +1173 -0
- package/.mochi/svelte-compile/chunk-4kwy539a.js +14 -0
- package/.mochi/svelte-compile/chunk-9bjk7yx6.js +81 -0
- package/.mochi/svelte-compile/chunk-btmcxhqy.js +58 -0
- package/.mochi/svelte-compile/chunk-sxqys3nw.js +1668 -0
- package/.mochi/svelte-compile/chunk-v6796ga4.js +3295 -0
- package/.mochi/svelte-compile/chunk-we0ggtws.js +34 -0
- package/.mochi/svelte-css/AppBar-3bxinv4pabv8l.css +1 -0
- package/.mochi/svelte-css/AppShell-36mpg5smnbmuw.css +1 -0
- package/.mochi/svelte-css/Avatar-24th63awm9mna.css +1 -0
- package/.mochi/svelte-css/AvatarEditor-1ryrs1md5g36y.css +1 -0
- package/.mochi/svelte-css/AvatarsView-3hq37u4dgtljn.css +1 -0
- package/.mochi/svelte-css/BackLink-2wlu0eovz9j86.css +1 -0
- package/.mochi/svelte-css/BranchBox-1yqu1q2mgnjwz.css +1 -0
- package/.mochi/svelte-css/Brand-1la7zw5uhcbl4.css +1 -0
- package/.mochi/svelte-css/Button-2vtbrzb5kgu25.css +1 -0
- package/.mochi/svelte-css/CheckmarkIcon-3qlazj2oc50jc.css +1 -0
- package/.mochi/svelte-css/ClientStats-19qzckq40p2q9.css +1 -0
- package/.mochi/svelte-css/CoinButton-1rej3sh3tkjud.css +1 -0
- package/.mochi/svelte-css/CredMenu-9hwgeptp7crb.css +1 -0
- package/.mochi/svelte-css/DashboardView-1cy4xpqhja9sx.css +1 -0
- package/.mochi/svelte-css/DefaultError-2b0daamydxgb2.css +1 -0
- package/.mochi/svelte-css/ErrorIcon-2x0tzsqxskw28.css +1 -0
- package/.mochi/svelte-css/FolderBrowser-1x428zb3f2kti.css +1 -0
- package/.mochi/svelte-css/HealthBox-er2her0jukwl.css +1 -0
- package/.mochi/svelte-css/IdeBar-2qoqi2ya9zdn3.css +1 -0
- package/.mochi/svelte-css/IdeLoader-26rlko8e00j3g.css +1 -0
- package/.mochi/svelte-css/InstanceCard-16f77aysm7lgp.css +1 -0
- package/.mochi/svelte-css/InstanceView-wq85bxtusont.css +1 -0
- package/.mochi/svelte-css/LoaderIcon-soq5wt3p05ye.css +1 -0
- package/.mochi/svelte-css/PortsBox-1t25b5yg7jva3.css +1 -0
- package/.mochi/svelte-css/SettingsCog-jftyadvo64qo.css +1 -0
- package/.mochi/svelte-css/SettingsView-1nfdwnm16cpp2.css +1 -0
- package/.mochi/svelte-css/Skeleton-8k69usiju43h.css +1 -0
- package/.mochi/svelte-css/StatusBadge-3mq0j7z6aq4j.css +1 -0
- package/.mochi/svelte-css/ThemePicker-1ir8jyocm8mvi.css +1 -0
- package/.mochi/svelte-css/ToastBar-3havgkmtofntq.css +1 -0
- package/.mochi/svelte-css/ToastIcon-2z5wrkc95khg0.css +1 -0
- package/.mochi/svelte-css/ToastMessage-2jfwb2og2lfa8.css +1 -0
- package/.mochi/svelte-css/ToastWrapper-1fbnym10rm2nv.css +1 -0
- package/.mochi/svelte-css/Toaster-kk60j193854n.css +1 -0
- package/.mochi/svelte-css/TopBar-2q87li6yt8ids.css +1 -0
- package/LICENSE +21 -0
- package/README.md +34 -3
- package/bin/codebay.ts +22 -0
- package/migrations/0001_init.sql +20 -0
- package/migrations/0002_port_forwards.sql +9 -0
- package/migrations/0003_options.sql +4 -0
- package/migrations/0004_instance_image.sql +1 -0
- package/package.json +87 -9
- package/public/sounds/done.wav +0 -0
- package/public/sounds/waiting.wav +0 -0
- package/src/__fixtures__/HelloWorld.svelte +4 -0
- package/src/additional-svelte-typings.d.ts +1 -0
- package/src/api.ts +61 -0
- package/src/avatars/anchor.ts +17 -0
- package/src/avatars/bear.ts +17 -0
- package/src/avatars/bee.ts +17 -0
- package/src/avatars/bob-marley.ts +17 -0
- package/src/avatars/car.ts +17 -0
- package/src/avatars/carpathians.ts +17 -0
- package/src/avatars/cat-body.ts +17 -0
- package/src/avatars/cat-sits.ts +17 -0
- package/src/avatars/cat.ts +17 -0
- package/src/avatars/celtic-cross.ts +17 -0
- package/src/avatars/cherry.ts +17 -0
- package/src/avatars/cotton.ts +17 -0
- package/src/avatars/crab.ts +17 -0
- package/src/avatars/crown.ts +17 -0
- package/src/avatars/diamond.ts +17 -0
- package/src/avatars/dog.ts +17 -0
- package/src/avatars/elephant.ts +17 -0
- package/src/avatars/fish.ts +17 -0
- package/src/avatars/fleur-de-lis.ts +17 -0
- package/src/avatars/flower.ts +17 -0
- package/src/avatars/fox.ts +17 -0
- package/src/avatars/frog.ts +17 -0
- package/src/avatars/ghost.ts +17 -0
- package/src/avatars/giraffe.ts +17 -0
- package/src/avatars/glasses.ts +17 -0
- package/src/avatars/heart.ts +17 -0
- package/src/avatars/index.ts +128 -0
- package/src/avatars/invader.ts +17 -0
- package/src/avatars/kangaroo.ts +17 -0
- package/src/avatars/key.ts +17 -0
- package/src/avatars/lightning.ts +17 -0
- package/src/avatars/mushroom.ts +17 -0
- package/src/avatars/name-prompts.ts +30 -0
- package/src/avatars/octopus.ts +17 -0
- package/src/avatars/owl.ts +17 -0
- package/src/avatars/penguin.ts +17 -0
- package/src/avatars/planet.ts +17 -0
- package/src/avatars/rabbit.ts +17 -0
- package/src/avatars/rat.ts +17 -0
- package/src/avatars/robot.ts +17 -0
- package/src/avatars/rocket.ts +17 -0
- package/src/avatars/sea.ts +17 -0
- package/src/avatars/serialize.ts +65 -0
- package/src/avatars/skull.ts +17 -0
- package/src/avatars/snail.ts +17 -0
- package/src/avatars/snake.ts +17 -0
- package/src/avatars/snakegame.ts +17 -0
- package/src/avatars/star.ts +17 -0
- package/src/avatars/svelte.ts +17 -0
- package/src/avatars/target.ts +17 -0
- package/src/avatars/tree.ts +17 -0
- package/src/avatars/types.ts +24 -0
- package/src/avatars/ukraine.ts +17 -0
- package/src/avatars/whale.ts +17 -0
- package/src/avatars/wheat.ts +17 -0
- package/src/avatars/yinyan.ts +17 -0
- package/src/components/AppBar.svelte +35 -0
- package/src/components/AppShell.svelte +302 -0
- package/src/components/Avatar.svelte +235 -0
- package/src/components/AvatarEditor.svelte +297 -0
- package/src/components/AvatarsView.svelte +115 -0
- package/src/components/BranchBox.svelte +32 -0
- package/src/components/Brand.svelte +41 -0
- package/src/components/Button.svelte +161 -0
- package/src/components/CoinButton.svelte +121 -0
- package/src/components/CredMenu.svelte +171 -0
- package/src/components/DashboardView.svelte +198 -0
- package/src/components/FolderBrowser.svelte +635 -0
- package/src/components/HealthBox.svelte +161 -0
- package/src/components/IdeBar.svelte +186 -0
- package/src/components/IdeLoader.svelte +135 -0
- package/src/components/InstanceCard.svelte +220 -0
- package/src/components/InstanceView.svelte +567 -0
- package/src/components/PortsBox.svelte +74 -0
- package/src/components/SettingsCog.svelte +22 -0
- package/src/components/SettingsView.svelte +1494 -0
- package/src/components/Skeleton.svelte +43 -0
- package/src/components/StatusBadge.svelte +54 -0
- package/src/components/ThemePicker.svelte +77 -0
- package/src/components/TopBar.svelte +64 -0
- package/src/components/UiShowcase.svelte +578 -0
- package/src/components/ui-showcase/ComponentDemo.svelte +85 -0
- package/src/container-injections/attention-hooks.ts +117 -0
- package/src/container-injections/claude-aliases.ts +43 -0
- package/src/container-injections/claude-code-credentials.ts +145 -0
- package/src/container-injections/claude-code-custom.ts +125 -0
- package/src/container-injections/claude-no-coauthor.ts +35 -0
- package/src/container-injections/claude-skip-permissions.ts +38 -0
- package/src/container-injections/claude-statusline.ts +109 -0
- package/src/container-injections/git-identity.ts +63 -0
- package/src/container-injections/git-safe-directory.ts +18 -0
- package/src/container-injections/github-credentials.ts +162 -0
- package/src/container-injections/host-env-vars.ts +108 -0
- package/src/container-injections/tmux.ts +67 -0
- package/src/fontsource.d.ts +3 -0
- package/src/index.ts +81 -0
- package/src/lib/auth.server.ts +95 -0
- package/src/lib/bridge.server.ts +34 -0
- package/src/lib/config.server.ts +140 -0
- package/src/lib/crypto.server.ts +12 -0
- package/src/lib/db.server.ts +196 -0
- package/src/lib/devcontainer.server.ts +511 -0
- package/src/lib/docker-client.server.ts +61 -0
- package/src/lib/docker.server.ts +139 -0
- package/src/lib/exec.server.ts +92 -0
- package/src/lib/git.server.ts +68 -0
- package/src/lib/health.server.ts +105 -0
- package/src/lib/injections.server.ts +72 -0
- package/src/lib/instances.server.ts +552 -0
- package/src/lib/links.ts +9 -0
- package/src/lib/picker.server.ts +46 -0
- package/src/lib/ports.server.ts +9 -0
- package/src/lib/proxy.server.ts +170 -0
- package/src/lib/repo-url.ts +60 -0
- package/src/lib/spawn.server.ts +14 -0
- package/src/lib/theme.server.ts +22 -0
- package/src/live.ts +50 -0
- package/src/pages/App.svelte +14 -0
- package/src/pages/Avatars.svelte +9 -0
- package/src/pages/Instance.svelte +9 -0
- package/src/pages/Settings.svelte +76 -0
- package/src/pages/UI.svelte +10 -0
- package/src/routes.ts +444 -0
- package/src/settings.ts +14 -0
- package/src/shell.html +100 -0
- package/src/sound.ts +69 -0
- package/src/theme.ts +24 -0
- package/src/types.ts +79 -0
- package/svelte.config.js +5 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { execInContainer } from '../lib/exec.server.ts';
|
|
2
|
+
import type { Injection } from '../lib/injections.server.ts';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The copied `.git` is owned by a different UID than the container user, so without this
|
|
6
|
+
* git aborts every command with "dubious ownership". `*` is safe in a single-tenant sandbox.
|
|
7
|
+
*/
|
|
8
|
+
export const gitSafeDirectory: Injection = {
|
|
9
|
+
id: 'git-safe-directory',
|
|
10
|
+
label: 'git safe.directory',
|
|
11
|
+
|
|
12
|
+
async apply(target, log) {
|
|
13
|
+
const res = await execInContainer(target, {
|
|
14
|
+
script: "git config --global --add safe.directory '*'"
|
|
15
|
+
});
|
|
16
|
+
if (!res.ok) log(`⚠ git safe.directory setup failed: ${res.error}\n`);
|
|
17
|
+
}
|
|
18
|
+
};
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { GITHUB_TOKEN } from '../lib/config.server.ts';
|
|
6
|
+
import { getOption } from '../lib/db.server.ts';
|
|
7
|
+
import { checkPresence, execInContainer } from '../lib/exec.server.ts';
|
|
8
|
+
import { spawnCapture } from '../lib/spawn.server.ts';
|
|
9
|
+
import type { ContainerTarget, Injection } from '../lib/injections.server.ts';
|
|
10
|
+
|
|
11
|
+
/** The only host the Settings and env-var overrides apply to; Enterprise hosts come from `gh`. */
|
|
12
|
+
const GH_HOST = 'github.com';
|
|
13
|
+
|
|
14
|
+
/** A blank field falls through to the env var, so a user can set just one provider. */
|
|
15
|
+
function manualGithubToken(): string | null {
|
|
16
|
+
if (getOption('manual_tokens_enabled') !== '1') return null;
|
|
17
|
+
return getOption('manual_github_token')?.trim() || null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface GhCredentials {
|
|
21
|
+
host: string;
|
|
22
|
+
token: string;
|
|
23
|
+
user?: string;
|
|
24
|
+
/** Defaults to https when unknown. */
|
|
25
|
+
gitProtocol?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Falls back to `gh`, which transparently spans all its storage backends. */
|
|
29
|
+
export async function readGhToken(
|
|
30
|
+
host: string = GH_HOST
|
|
31
|
+
): Promise<{ token: string; source: string } | null> {
|
|
32
|
+
if (host === GH_HOST) {
|
|
33
|
+
const manual = manualGithubToken();
|
|
34
|
+
if (manual) return { token: manual, source: 'Settings — manual token' };
|
|
35
|
+
if (GITHUB_TOKEN) return { token: GITHUB_TOKEN, source: 'CODEBAY_GITHUB_TOKEN env var' };
|
|
36
|
+
}
|
|
37
|
+
const token = await spawnCapture(['gh', 'auth', 'token', '--hostname', host]);
|
|
38
|
+
return token ? { token, source: `GitHub CLI — ${host}` } : null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function parseGhHosts(raw: string): string[] {
|
|
42
|
+
return [...raw.matchAll(/^([A-Za-z0-9._-]+):$/gm)].map((m) => m[1]!);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function ghHostBlock(raw: string, host: string): string | null {
|
|
46
|
+
const lines = raw.split('\n');
|
|
47
|
+
const start = lines.findIndex((l) => l === `${host}:`);
|
|
48
|
+
if (start === -1) return null;
|
|
49
|
+
const rest = lines.slice(start + 1);
|
|
50
|
+
const end = rest.findIndex((l) => /^\S/.test(l));
|
|
51
|
+
return rest.slice(0, end === -1 ? rest.length : end).join('\n');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function readGhHostsFile(): Promise<string | null> {
|
|
55
|
+
const file = join(homedir(), '.config', 'gh', 'hosts.yml');
|
|
56
|
+
if (!existsSync(file)) return null;
|
|
57
|
+
try {
|
|
58
|
+
return await readFile(file, 'utf8');
|
|
59
|
+
} catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function readGhHosts(): Promise<string[]> {
|
|
65
|
+
const raw = await readGhHostsFile();
|
|
66
|
+
if (!raw) return [];
|
|
67
|
+
// These keys get interpolated into a shell script, so only hostname-shaped ones may pass.
|
|
68
|
+
return parseGhHosts(raw).filter((h) => /^[A-Za-z0-9.-]+$/.test(h));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** A line parse suffices instead of a YAML dependency, since the token isn't in this file. */
|
|
72
|
+
async function readGhHostMeta(host: string): Promise<{ user?: string; gitProtocol?: string }> {
|
|
73
|
+
const raw = await readGhHostsFile();
|
|
74
|
+
if (!raw) return {};
|
|
75
|
+
const block = ghHostBlock(raw, host);
|
|
76
|
+
if (!block) return {};
|
|
77
|
+
const user = block.match(/^\s+user:\s*(\S+)/m)?.[1];
|
|
78
|
+
const gitProtocol = block.match(/^\s+git_protocol:\s*(\S+)/m)?.[1];
|
|
79
|
+
return { user, gitProtocol };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** `github.com` is included unconditionally so the manual/env override works with no `gh` setup. */
|
|
83
|
+
async function readAllGhCredentials(): Promise<GhCredentials[]> {
|
|
84
|
+
const configured = await readGhHosts();
|
|
85
|
+
const hosts = [...new Set([GH_HOST, ...configured])];
|
|
86
|
+
const creds = await Promise.all(
|
|
87
|
+
hosts.map(async (host): Promise<GhCredentials | null> => {
|
|
88
|
+
const found = await readGhToken(host);
|
|
89
|
+
if (!found) return null;
|
|
90
|
+
const meta = await readGhHostMeta(host);
|
|
91
|
+
return { host, token: found.token, ...meta };
|
|
92
|
+
})
|
|
93
|
+
);
|
|
94
|
+
return creds.filter((c): c is GhCredentials => c !== null);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Staging hosts.yml still authorizes `gh` if it's installed later. The whole file body
|
|
99
|
+
* rides the single `stdin` channel, because it may hold more than one host's token.
|
|
100
|
+
*/
|
|
101
|
+
async function injectGhCredentials(
|
|
102
|
+
target: ContainerTarget,
|
|
103
|
+
credsList: GhCredentials[]
|
|
104
|
+
): Promise<{ ok: boolean; error?: string }> {
|
|
105
|
+
const body = credsList
|
|
106
|
+
.map(({ host, token, user, gitProtocol }) => {
|
|
107
|
+
const protocol = gitProtocol || 'https';
|
|
108
|
+
return (
|
|
109
|
+
`${host}:\n` +
|
|
110
|
+
` oauth_token: ${token}\n` +
|
|
111
|
+
` git_protocol: ${protocol}\n` +
|
|
112
|
+
(user ? ` user: ${user}\n` : '')
|
|
113
|
+
);
|
|
114
|
+
})
|
|
115
|
+
.join('');
|
|
116
|
+
const setupGit = credsList
|
|
117
|
+
.map(
|
|
118
|
+
({ host }) =>
|
|
119
|
+
`command -v gh >/dev/null 2>&1 && gh auth setup-git --hostname ${host} 2>/dev/null || true; `
|
|
120
|
+
)
|
|
121
|
+
.join('');
|
|
122
|
+
const script =
|
|
123
|
+
'set -e; d=~/.config/gh; mkdir -p "$d"; ' +
|
|
124
|
+
'printf \'%s\' "$CODEBAY_STDIN" > "$d/hosts.yml"; chmod 600 "$d/hosts.yml"; ' +
|
|
125
|
+
setupGit;
|
|
126
|
+
const res = await execInContainer(target, { script, stdin: body, args: ['gh-inject'] });
|
|
127
|
+
return res.ok ? { ok: true } : { ok: false, error: res.error };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export const githubCredentials: Injection = {
|
|
131
|
+
id: 'github-credentials',
|
|
132
|
+
label: 'GitHub CLI',
|
|
133
|
+
|
|
134
|
+
auth: {
|
|
135
|
+
hint: 'run `gh auth login`',
|
|
136
|
+
async status() {
|
|
137
|
+
const credsList = await readAllGhCredentials();
|
|
138
|
+
return credsList.length
|
|
139
|
+
? { available: true, source: `GitHub CLI — ${credsList.map((c) => c.host).join(', ')}` }
|
|
140
|
+
: { available: false, source: null };
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
|
|
144
|
+
async apply(target, log) {
|
|
145
|
+
const credsList = await readAllGhCredentials();
|
|
146
|
+
if (!credsList.length) {
|
|
147
|
+
log('⚠ No GitHub CLI credentials found on host; skipped gh injection\n');
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
log(`Injecting GitHub CLI credentials for ${credsList.map((c) => c.host).join(', ')}…\n`);
|
|
151
|
+
const injected = await injectGhCredentials(target, credsList);
|
|
152
|
+
log(
|
|
153
|
+
injected.ok
|
|
154
|
+
? '✓ GitHub CLI authorized in container\n'
|
|
155
|
+
: `⚠ gh auth injection failed: ${injected.error}\n`
|
|
156
|
+
);
|
|
157
|
+
},
|
|
158
|
+
|
|
159
|
+
async check(target) {
|
|
160
|
+
return checkPresence(target, '[ -s ~/.config/gh/hosts.yml ] && echo 1 || echo 0');
|
|
161
|
+
}
|
|
162
|
+
};
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { getOption } from '../lib/db.server.ts';
|
|
2
|
+
import { checkPresence, execInContainer } from '../lib/exec.server.ts';
|
|
3
|
+
import type { ContainerTarget, Injection } from '../lib/injections.server.ts';
|
|
4
|
+
|
|
5
|
+
const ENV_FILE = '~/.codebay-host-env';
|
|
6
|
+
|
|
7
|
+
interface ResolvedVar {
|
|
8
|
+
name: string;
|
|
9
|
+
value: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Exported so `/settings` computes host presence from the same parse the injection uses. */
|
|
13
|
+
export function parseHostEnvVarNames(raw: string | null): string[] {
|
|
14
|
+
try {
|
|
15
|
+
const parsed = JSON.parse(raw ?? '[]');
|
|
16
|
+
return Array.isArray(parsed) ? parsed.filter((n): n is string => typeof n === 'string') : [];
|
|
17
|
+
} catch {
|
|
18
|
+
return [];
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Reports presence only — a value must never cross to the client. */
|
|
23
|
+
export function hostEnvVarPresence(names: string[]): Record<string, boolean> {
|
|
24
|
+
return Object.fromEntries(
|
|
25
|
+
names.map((name) => [name, Bun.env[name] !== undefined && Bun.env[name] !== ''])
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Null means "skip the injection"; `missing` lets `apply()` log names without logging values. */
|
|
30
|
+
export function hostEnvVarsConfig(): {
|
|
31
|
+
names: string[];
|
|
32
|
+
resolved: ResolvedVar[];
|
|
33
|
+
missing: string[];
|
|
34
|
+
} | null {
|
|
35
|
+
if (getOption('host_env_vars_enabled') !== '1') return null;
|
|
36
|
+
const names = parseHostEnvVarNames(getOption('host_env_var_names'));
|
|
37
|
+
if (!names.length) return null;
|
|
38
|
+
const resolved: ResolvedVar[] = [];
|
|
39
|
+
const missing: string[] = [];
|
|
40
|
+
for (const name of names) {
|
|
41
|
+
const value = Bun.env[name];
|
|
42
|
+
if (value !== undefined && value !== '') resolved.push({ name, value });
|
|
43
|
+
else missing.push(name);
|
|
44
|
+
}
|
|
45
|
+
if (!resolved.length) return null;
|
|
46
|
+
return { names, resolved, missing };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Values ride `stdin` as one payload, so they reach neither argv nor `docker inspect`. */
|
|
50
|
+
async function injectHostEnvVars(
|
|
51
|
+
target: ContainerTarget,
|
|
52
|
+
resolved: ResolvedVar[]
|
|
53
|
+
): Promise<{ ok: boolean; error?: string }> {
|
|
54
|
+
// Every interactive shell sources this file, so values must be single-quoted to stay literal.
|
|
55
|
+
const payload = resolved
|
|
56
|
+
.map(({ name, value }) => `export ${name}='${value.replaceAll("'", "'\\''")}'`)
|
|
57
|
+
.join('\n');
|
|
58
|
+
const script =
|
|
59
|
+
'set -e; f=$(eval echo "' +
|
|
60
|
+
ENV_FILE +
|
|
61
|
+
'"); ' +
|
|
62
|
+
'printf \'%s\\n\' "$CODEBAY_STDIN" > "$f"; chmod 600 "$f"; ' +
|
|
63
|
+
// The grep guard keeps a re-apply from stacking duplicate source lines.
|
|
64
|
+
'h=$(eval echo ~$(id -un)); src="[ -f \\"$f\\" ] && . \\"$f\\""; ' +
|
|
65
|
+
'for rc in "$h/.bashrc" "$h/.zshrc"; do ' +
|
|
66
|
+
'grep -qF "$src" "$rc" 2>/dev/null || printf \'%s\\n\' "$src" >> "$rc"; ' +
|
|
67
|
+
'done';
|
|
68
|
+
|
|
69
|
+
const res = await execInContainer(target, {
|
|
70
|
+
script,
|
|
71
|
+
stdin: payload,
|
|
72
|
+
args: ['host-env-vars']
|
|
73
|
+
});
|
|
74
|
+
return res.ok ? { ok: true } : { ok: false, error: res.error };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Only names are persisted — values are read fresh from this process's env at apply time.
|
|
79
|
+
* No `auth` block on purpose: this is opt-in, so it must not flag the required-auth chip.
|
|
80
|
+
*/
|
|
81
|
+
export const hostEnvVars: Injection = {
|
|
82
|
+
id: 'host-env-vars',
|
|
83
|
+
label: 'host env vars',
|
|
84
|
+
|
|
85
|
+
async apply(target, log) {
|
|
86
|
+
const config = hostEnvVarsConfig();
|
|
87
|
+
if (!config) {
|
|
88
|
+
log('⚠ Host env vars not configured (disabled or none resolve on host); skipped\n');
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
log(
|
|
92
|
+
`Injecting ${config.resolved.length} host env var(s): ${config.resolved.map((r) => r.name).join(', ')}…\n`
|
|
93
|
+
);
|
|
94
|
+
if (config.missing.length) {
|
|
95
|
+
log(`⚠ Not set on host, skipped: ${config.missing.join(', ')}\n`);
|
|
96
|
+
}
|
|
97
|
+
const result = await injectHostEnvVars(target, config.resolved);
|
|
98
|
+
log(
|
|
99
|
+
result.ok
|
|
100
|
+
? '✓ host env vars injected\n'
|
|
101
|
+
: `⚠ host env vars injection failed: ${result.error}\n`
|
|
102
|
+
);
|
|
103
|
+
},
|
|
104
|
+
|
|
105
|
+
async check(target) {
|
|
106
|
+
return checkPresence(target, `f=$(eval echo "${ENV_FILE}"); [ -s "$f" ] && echo 1 || echo 0`);
|
|
107
|
+
}
|
|
108
|
+
};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { checkPresence, execInContainer } from '../lib/exec.server.ts';
|
|
2
|
+
import type { Injection } from '../lib/injections.server.ts';
|
|
3
|
+
|
|
4
|
+
/** apt-get is the real path (the default image is Debian); the rest cover project images. */
|
|
5
|
+
export const INSTALL_SCRIPT =
|
|
6
|
+
'if command -v tmux >/dev/null 2>&1; then exit 0; fi; ' +
|
|
7
|
+
'export DEBIAN_FRONTEND=noninteractive; ' +
|
|
8
|
+
'if command -v apt-get >/dev/null 2>&1; then apt-get update -y >/dev/null && apt-get install -y --no-install-recommends tmux >/dev/null; ' +
|
|
9
|
+
'elif command -v apk >/dev/null 2>&1; then apk add --no-cache tmux >/dev/null; ' +
|
|
10
|
+
'elif command -v dnf >/dev/null 2>&1; then dnf install -y tmux >/dev/null; ' +
|
|
11
|
+
'elif command -v microdnf >/dev/null 2>&1; then microdnf install -y tmux >/dev/null; ' +
|
|
12
|
+
'elif command -v yum >/dev/null 2>&1; then yum install -y tmux >/dev/null; ' +
|
|
13
|
+
'else echo "no supported package manager (tried apt-get/apk/dnf/microdnf/yum)" >&2; exit 1; fi; ' +
|
|
14
|
+
'command -v tmux >/dev/null 2>&1';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* `mouse on` buys wheel-scroll at the cost of code-server's native drag-select, since tmux
|
|
18
|
+
* takes the mouse — hence `bind m` to toggle it, shadowing mark-pane, unused in a single pane.
|
|
19
|
+
*/
|
|
20
|
+
export const TMUX_CONF_LINES = [
|
|
21
|
+
'set -g mouse on',
|
|
22
|
+
'set -g history-limit 50000',
|
|
23
|
+
'set -g set-clipboard on',
|
|
24
|
+
'set -g allow-passthrough on',
|
|
25
|
+
'set -g status off',
|
|
26
|
+
'bind m set -g mouse \\; display-message "mouse: #{?mouse,on,off}"'
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
/** Lines arrive as `$@` so no conf text is interpolated into the loop body. */
|
|
30
|
+
const CONF_SCRIPT =
|
|
31
|
+
'h=$(eval echo ~$(id -un)); f="$h/.tmux.conf"; ' +
|
|
32
|
+
'for line in "$@"; do ' +
|
|
33
|
+
'grep -qF "$line" "$f" 2>/dev/null || printf \'%s\\n\' "$line" >> "$f"; ' +
|
|
34
|
+
'done';
|
|
35
|
+
|
|
36
|
+
const CHECK_SCRIPT = 'command -v tmux >/dev/null 2>&1 && echo 1 || echo 0';
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The runtime fallback to the build-time `codebay-tmux` feature, which is the primary
|
|
40
|
+
* install; failure is non-fatal because the terminal task guards on `command -v tmux`.
|
|
41
|
+
*/
|
|
42
|
+
export const tmux: Injection = {
|
|
43
|
+
id: 'tmux',
|
|
44
|
+
label: 'tmux',
|
|
45
|
+
|
|
46
|
+
async apply(target, log) {
|
|
47
|
+
log('Installing tmux…\n');
|
|
48
|
+
// Omitting remoteUser runs as root, which the package install needs.
|
|
49
|
+
const install = await execInContainer(
|
|
50
|
+
{ containerId: target.containerId },
|
|
51
|
+
{ script: INSTALL_SCRIPT }
|
|
52
|
+
);
|
|
53
|
+
if (!install.ok) {
|
|
54
|
+
log(`⚠ tmux install failed: ${install.error} — terminal falls back to non-persistent mode\n`);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const conf = await execInContainer(target, {
|
|
58
|
+
script: CONF_SCRIPT,
|
|
59
|
+
args: ['tmux-conf', ...TMUX_CONF_LINES]
|
|
60
|
+
});
|
|
61
|
+
log(conf.ok ? '✓ tmux installed\n' : `⚠ tmux conf setup failed: ${conf.error}\n`);
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
async check(target) {
|
|
65
|
+
return checkPresence(target, CHECK_SCRIPT);
|
|
66
|
+
}
|
|
67
|
+
};
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { Mochi, sequence, silenceInternalRoutes } from 'mochi-framework';
|
|
2
|
+
import { routes } from './routes.ts';
|
|
3
|
+
import { basicAuth } from './lib/auth.server.ts';
|
|
4
|
+
import { themeHandle } from './lib/theme.server.ts';
|
|
5
|
+
import { PROXY_PREFIX } from './lib/proxy.server.ts';
|
|
6
|
+
import {
|
|
7
|
+
BASIC_AUTH_PASSWORD,
|
|
8
|
+
HOST,
|
|
9
|
+
PORT,
|
|
10
|
+
PUBLIC_ORIGIN,
|
|
11
|
+
TRUSTED_ORIGINS,
|
|
12
|
+
ensureMochiKey
|
|
13
|
+
} from './lib/config.server.ts';
|
|
14
|
+
|
|
15
|
+
if (!BASIC_AUTH_PASSWORD) {
|
|
16
|
+
console.warn('⚠ BASIC_AUTH_PASSWORD is not set — the UI and all instances are unprotected.');
|
|
17
|
+
}
|
|
18
|
+
if (HOST !== '127.0.0.1' && HOST !== 'localhost' && !BASIC_AUTH_PASSWORD) {
|
|
19
|
+
console.warn(
|
|
20
|
+
`⚠ Binding to ${HOST} (non-loopback) without a password — anyone on the network can reach this server.`
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Must precede Mochi.serve(), which is where MOCHI_KEY is read.
|
|
25
|
+
ensureMochiKey();
|
|
26
|
+
|
|
27
|
+
await Mochi.serve({
|
|
28
|
+
port: PORT,
|
|
29
|
+
hostname: HOST,
|
|
30
|
+
development: process.env.MODE === 'development',
|
|
31
|
+
htmlShell: './src/shell.html',
|
|
32
|
+
handle: sequence(basicAuth, themeHandle),
|
|
33
|
+
// Without a pinned origin, Mochi's CSRF check refuses every form POST in production.
|
|
34
|
+
proxy: {
|
|
35
|
+
origin: PUBLIC_ORIGIN
|
|
36
|
+
},
|
|
37
|
+
csrf: {
|
|
38
|
+
trustedOrigins: TRUSTED_ORIGINS
|
|
39
|
+
},
|
|
40
|
+
filters: {
|
|
41
|
+
// Containers curl the bridge with no Origin header, which the check would 403;
|
|
42
|
+
// safe because the route authenticates by token, not ambient browser credentials.
|
|
43
|
+
'csrf:check': (decision, { url }) =>
|
|
44
|
+
url.pathname.startsWith('/api/bridge/') ? null : decision,
|
|
45
|
+
'consoleLogger:line': (line, ctx) => {
|
|
46
|
+
const kept = silenceInternalRoutes(line, ctx);
|
|
47
|
+
if (kept == null) {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
if (ctx.source.name === 'ws:message' && ctx.path.startsWith(PROXY_PREFIX + '/')) {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
// These fire on every Claude hook event and carry the bridge token.
|
|
54
|
+
if (ctx.path.startsWith('/api/bridge/')) {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
return kept;
|
|
58
|
+
},
|
|
59
|
+
// Dormant while trailingSlash is off, but keeps code-server's subpath safe if it returns.
|
|
60
|
+
'trailingSlash:redirect': (computed, { url }) =>
|
|
61
|
+
url.pathname.startsWith(PROXY_PREFIX + '/') ? null : computed
|
|
62
|
+
},
|
|
63
|
+
routes
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const url = 'http://localhost:' + PORT;
|
|
67
|
+
console.log(`Server running at ${url} (bound to ${HOST})`);
|
|
68
|
+
|
|
69
|
+
if (process.env.DISABLE_OPEN_BROWSER !== '1') {
|
|
70
|
+
const openCmd =
|
|
71
|
+
process.platform === 'darwin'
|
|
72
|
+
? ['open', url]
|
|
73
|
+
: process.platform === 'win32'
|
|
74
|
+
? ['cmd', '/c', 'start', '', url]
|
|
75
|
+
: ['xdg-open', url];
|
|
76
|
+
try {
|
|
77
|
+
Bun.spawn(openCmd, { stdout: 'ignore', stderr: 'ignore' });
|
|
78
|
+
} catch {
|
|
79
|
+
// Best-effort — a headless host has nothing to open.
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type { Handle } from 'mochi-framework';
|
|
2
|
+
import { BASIC_AUTH_PASSWORD, BASIC_AUTH_USERNAME } from './config.server.ts';
|
|
3
|
+
import { timingSafeEqualStr } from './crypto.server.ts';
|
|
4
|
+
|
|
5
|
+
const REALM = 'Codebay';
|
|
6
|
+
|
|
7
|
+
const MUTATING_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A cross-origin page can't set this without a CORS preflight this server never answers.
|
|
11
|
+
* Basic Auth alone wouldn't stop CSRF — browsers auto-attach cached credentials.
|
|
12
|
+
*/
|
|
13
|
+
const CSRF_HEADER = 'x-codebay-request';
|
|
14
|
+
|
|
15
|
+
function challenge(): Response {
|
|
16
|
+
return new Response('Authentication required', {
|
|
17
|
+
status: 401,
|
|
18
|
+
headers: { 'WWW-Authenticate': `Basic realm="${REALM}", charset="UTF-8"` }
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function csrfRejected(): Response {
|
|
23
|
+
return new Response('Forbidden', { status: 403 });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* WS handshakes are GETs that slip past the CSRF check, and browsers attach cached
|
|
28
|
+
* credentials cross-origin without a preflight. A missing Origin means a non-browser client.
|
|
29
|
+
*/
|
|
30
|
+
function wsOriginOk(request: Request): boolean {
|
|
31
|
+
const origin = request.headers.get('origin');
|
|
32
|
+
if (origin == null) return true;
|
|
33
|
+
try {
|
|
34
|
+
return new URL(origin).host === request.headers.get('host');
|
|
35
|
+
} catch {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** `Mochi.ws` routes are dispatched by Bun directly and never reach `basicAuth`. */
|
|
41
|
+
export function wsUpgradeAllowed(request: Request): boolean {
|
|
42
|
+
if (!wsOriginOk(request)) return false;
|
|
43
|
+
if (!BASIC_AUTH_PASSWORD) return true;
|
|
44
|
+
return credentialsOk(request.headers.get('Authorization'));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function credentialsOk(header: string | null): boolean {
|
|
48
|
+
if (!header?.startsWith('Basic ')) return false;
|
|
49
|
+
let decoded: string;
|
|
50
|
+
try {
|
|
51
|
+
decoded = atob(header.slice(6).trim());
|
|
52
|
+
} catch {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
const sep = decoded.indexOf(':');
|
|
56
|
+
if (sep === -1) return false;
|
|
57
|
+
const user = decoded.slice(0, sep);
|
|
58
|
+
const pass = decoded.slice(sep + 1);
|
|
59
|
+
// Both halves must always run — an `&&` short-circuit would leak which one failed by timing.
|
|
60
|
+
const userOk = timingSafeEqualStr(user, BASIC_AUTH_USERNAME);
|
|
61
|
+
const passOk = timingSafeEqualStr(pass, BASIC_AUTH_PASSWORD);
|
|
62
|
+
return userOk && passOk;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The CSRF guard runs even with no password set: on an unauthenticated localhost
|
|
67
|
+
* server it's the only thing between a malicious page and the destructive endpoints.
|
|
68
|
+
*/
|
|
69
|
+
export const basicAuth: Handle = async ({ event, resolve }) => {
|
|
70
|
+
const path = new URL(event.request.url).pathname;
|
|
71
|
+
|
|
72
|
+
// Containers can carry neither the app password nor the CSRF header; the route checks a token.
|
|
73
|
+
if (path.startsWith('/api/bridge/')) return resolve(event);
|
|
74
|
+
|
|
75
|
+
// Covers the `/p/:id/*` proxy relay only; `Mochi.ws` routes call `wsUpgradeAllowed` themselves.
|
|
76
|
+
if (
|
|
77
|
+
event.request.headers.get('upgrade')?.toLowerCase() === 'websocket' &&
|
|
78
|
+
!wsOriginOk(event.request)
|
|
79
|
+
) {
|
|
80
|
+
return csrfRejected();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Scoped to /api/ so it never touches the request shapes code-server itself generates.
|
|
84
|
+
if (
|
|
85
|
+
path.startsWith('/api/') &&
|
|
86
|
+
MUTATING_METHODS.has(event.request.method) &&
|
|
87
|
+
event.request.headers.get(CSRF_HEADER) == null
|
|
88
|
+
) {
|
|
89
|
+
return csrfRejected();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (!BASIC_AUTH_PASSWORD) return resolve(event);
|
|
93
|
+
if (credentialsOk(event.request.headers.get('Authorization'))) return resolve(event);
|
|
94
|
+
return challenge();
|
|
95
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { triggerReconcile } from './instances.server.ts';
|
|
2
|
+
|
|
3
|
+
/** UI-only state: it drives a tab pulse and is never persisted. */
|
|
4
|
+
export type AttentionState = 'done' | 'waiting';
|
|
5
|
+
|
|
6
|
+
// Pin to globalThis so dev-mode hot reload doesn't drop pending signals.
|
|
7
|
+
const globalForAttention = globalThis as unknown as {
|
|
8
|
+
__codebayAttention?: Map<string, AttentionState>;
|
|
9
|
+
};
|
|
10
|
+
const attention: Map<string, AttentionState> = (globalForAttention.__codebayAttention ??=
|
|
11
|
+
new Map());
|
|
12
|
+
|
|
13
|
+
export function getAttention(id: string): AttentionState | null {
|
|
14
|
+
return attention.get(id) ?? null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function setAttention(id: string, state: AttentionState): void {
|
|
18
|
+
if (attention.get(id) === state) {
|
|
19
|
+
console.log(`[attention] setAttention id=${id} state=${state} — no change, skipping reconcile`);
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
attention.set(id, state);
|
|
23
|
+
console.log(`[attention] setAttention id=${id} state=${state} → reconcile`);
|
|
24
|
+
triggerReconcile();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function clearAttention(id: string): void {
|
|
28
|
+
if (!attention.delete(id)) {
|
|
29
|
+
console.log(`[attention] clearAttention id=${id} — nothing set, skipping reconcile`);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
console.log(`[attention] clearAttention id=${id} → reconcile`);
|
|
33
|
+
triggerReconcile();
|
|
34
|
+
}
|