codebay 0.9.0 → 0.10.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.
Files changed (78) hide show
  1. package/.mochi/manifest.json +216 -149
  2. package/.mochi/svelte-client/HydratableIsland-aebn1fa1.js +1 -0
  3. package/.mochi/svelte-client/_hydrate-AppShell_2rtnxn6pgp2rh-rx6arrv5.js +2 -0
  4. package/.mochi/svelte-client/{_hydrate-AvatarsView_257s9yq1wwr4f-92hepzby.js → _hydrate-AvatarsView_257s9yq1wwr4f-jpjdmgpe.js} +1 -1
  5. package/.mochi/svelte-client/_hydrate-InstanceView_2ma45l9bybfhl-c9y47dat.js +1 -0
  6. package/.mochi/svelte-client/_hydrate-SettingsView_8hmarwmf8onq-d61feq89.js +38 -0
  7. package/.mochi/svelte-client/{chunk-2j95sv83.js → chunk-0fjx3z3r.js} +1 -1
  8. package/.mochi/svelte-client/chunk-8xvr02wh.js +2 -0
  9. package/.mochi/svelte-client/{chunk-m497q88s.js → chunk-9143wy3j.js} +2 -2
  10. package/.mochi/svelte-client/{chunk-v65n8th6.js → chunk-bn731rda.js} +1 -1
  11. package/.mochi/svelte-client/{chunk-a2psjmrm.js → chunk-j72r8pkk.js} +2 -2
  12. package/.mochi/svelte-client/chunk-k37wj1jm.js +2 -0
  13. package/.mochi/svelte-client/{chunk-mpv3qx28.js → chunk-p20tkpde.js} +1 -1
  14. package/.mochi/svelte-client/chunk-pc0fm37c.js +2 -0
  15. package/.mochi/svelte-client/{chunk-6rbs0mhx.js → chunk-t0ap7q89.js} +1 -1
  16. package/.mochi/svelte-client/{chunk-rs41by6w.js → chunk-wvtkwfsh.js} +1 -1
  17. package/.mochi/svelte-compile/{App-7vjcp38v.server.js → App-59cdj86f.server.js} +167 -103
  18. package/.mochi/svelte-compile/{Avatars-e2cfc95t.server.js → Avatars-fmagfyj2.server.js} +1 -1
  19. package/.mochi/svelte-compile/{Instance-h78n2dsv.server.js → Instance-s1gqwzmx.server.js} +16 -10
  20. package/.mochi/svelte-compile/{Settings-tav2asn7.server.js → Settings-k447mt0x.server.js} +540 -88
  21. package/.mochi/svelte-compile/{chunk-brjv9pyd.js → chunk-3t7v4hk0.js} +1 -7
  22. package/.mochi/svelte-compile/chunk-gkjgybb2.js +15 -0
  23. package/.mochi/svelte-compile/{chunk-m8ygpyhr.js → chunk-th2dbqvc.js} +20 -32
  24. package/.mochi/svelte-css/Button-oharop2xz6am.css +1 -0
  25. package/.mochi/svelte-css/IdeBar-3bgr3m5z0lpeg.css +1 -0
  26. package/.mochi/svelte-css/{InstanceView-3vzow3ttxzlh3.css → InstanceView-1pd7j16bd2b16.css} +1 -1
  27. package/.mochi/svelte-css/SettingsView-n3oy75uonzp2.css +1 -0
  28. package/.mochi/svelte-css/{TopBar-2q87li6yt8ids.css → TopBar-2y76zxwehrfos.css} +1 -1
  29. package/migrations/0007_instance_avatar.sql +1 -0
  30. package/package.json +2 -2
  31. package/src/avatars/diamond.ts +5 -6
  32. package/src/avatars/index.ts +1 -16
  33. package/src/avatars/pick.server.ts +25 -0
  34. package/src/avatars/whale.ts +7 -8
  35. package/src/components/AppShell.svelte +29 -4
  36. package/src/components/Avatar.svelte +8 -8
  37. package/src/components/Button.svelte +22 -1
  38. package/src/components/DashboardView.svelte +14 -2
  39. package/src/components/FolderBrowser.svelte +6 -2
  40. package/src/components/IdeBar.svelte +56 -13
  41. package/src/components/IdeLoader.svelte +15 -3
  42. package/src/components/InstanceCard.svelte +6 -1
  43. package/src/components/InstanceView.svelte +33 -15
  44. package/src/components/SettingsView.svelte +548 -1
  45. package/src/components/TopBar.svelte +37 -10
  46. package/src/components/UiShowcase.svelte +5 -10
  47. package/src/container-injections/claude-aliases.ts +1 -1
  48. package/src/container-injections/claude-code-custom.ts +2 -1
  49. package/src/container-injections/claude-code-ide-extension.ts +5 -0
  50. package/src/container-injections/claude-code-install.ts +67 -0
  51. package/src/container-injections/claude-code-models.ts +2 -1
  52. package/src/container-injections/claude-code-update.ts +62 -1
  53. package/src/container-injections/claude-effort-level.ts +32 -0
  54. package/src/container-injections/claude-permission-mode.ts +40 -0
  55. package/src/container-injections/custom-env-vars.ts +82 -0
  56. package/src/container-injections/host-env-vars.ts +2 -1
  57. package/src/lib/config.server.ts +3 -0
  58. package/src/lib/db.server.ts +8 -3
  59. package/src/lib/devcontainer.server.ts +182 -47
  60. package/src/lib/health.server.ts +55 -11
  61. package/src/lib/injections.server.ts +46 -36
  62. package/src/lib/instances.server.ts +185 -19
  63. package/src/lib/log-capture.server.ts +262 -0
  64. package/src/lib/ports.server.ts +31 -0
  65. package/src/lib/proxy.server.ts +21 -76
  66. package/src/pages/Settings.svelte +22 -1
  67. package/src/routes.ts +98 -2
  68. package/src/types.ts +36 -0
  69. package/.mochi/svelte-client/HydratableIsland-k6q8esj4.js +0 -1
  70. package/.mochi/svelte-client/_hydrate-AppShell_2rtnxn6pgp2rh-sg96rp17.js +0 -2
  71. package/.mochi/svelte-client/_hydrate-InstanceView_2ma45l9bybfhl-p3f649gk.js +0 -1
  72. package/.mochi/svelte-client/_hydrate-SettingsView_8hmarwmf8onq-dzmgmv78.js +0 -29
  73. package/.mochi/svelte-client/chunk-7wkvn2vx.js +0 -2
  74. package/.mochi/svelte-client/chunk-m4fzsd5b.js +0 -2
  75. package/.mochi/svelte-css/Button-2vtbrzb5kgu25.css +0 -1
  76. package/.mochi/svelte-css/IdeBar-10xodudcx57qr.css +0 -1
  77. package/.mochi/svelte-css/SettingsView-2qfauz68axro1.css +0 -1
  78. package/src/container-injections/claude-skip-permissions.ts +0 -28
@@ -1,8 +1,10 @@
1
1
  <script lang="ts">
2
2
  import Plus from '@lucide/svelte/icons/plus';
3
3
  import Component from '@lucide/svelte/icons/component';
4
+ import Terminal from '@lucide/svelte/icons/terminal';
5
+ import LayoutTemplate from '@lucide/svelte/icons/layout-template';
4
6
  import { isDev } from 'mochi-framework';
5
- import type { AuthProvider, InstanceFilter } from '../types.ts';
7
+ import type { AuthProvider, InstanceFilter, InstanceMode } from '../types.ts';
6
8
  import type { AvatarArt } from '../avatars/index.ts';
7
9
  import Brand from './Brand.svelte';
8
10
  import InstanceFilterControl from './InstanceFilter.svelte';
@@ -18,6 +20,7 @@
18
20
  ready = true,
19
21
  creating = false,
20
22
  filter,
23
+ defaultMode = 'ide',
21
24
  onFilter,
22
25
  onNew,
23
26
  onDeleteAll
@@ -28,10 +31,14 @@
28
31
  ready?: boolean;
29
32
  creating?: boolean;
30
33
  filter?: InstanceFilter;
34
+ /** Drives the shortcut button, which always offers the mode the main button won't create. */
35
+ defaultMode?: InstanceMode;
31
36
  onFilter?: (v: InstanceFilter) => void;
32
- onNew?: () => void;
37
+ onNew?: (mode?: InstanceMode) => void;
33
38
  onDeleteAll?: () => void;
34
39
  } = $props();
40
+
41
+ const altMode = $derived<InstanceMode>(defaultMode === 'terminal' ? 'ide' : 'terminal');
35
42
  </script>
36
43
 
37
44
  <header class="topbar">
@@ -48,14 +55,28 @@
48
55
  <Button variant="danger" size="sm" onclick={onDeleteAll} disabled={!canDelete}>
49
56
  Delete all
50
57
  </Button>
51
- <Button
52
- variant="primary"
53
- icon={creating ? undefined : Plus}
54
- onclick={onNew}
55
- disabled={!ready || creating}
56
- >
57
- {creating ? 'Creating…' : 'New instance'}
58
- </Button>
58
+ <div class="new-group">
59
+ <Button
60
+ variant="primary"
61
+ size="sm"
62
+ icon={creating ? undefined : Plus}
63
+ onclick={() => onNew?.()}
64
+ disabled={!ready || creating}
65
+ >
66
+ {creating ? 'Creating…' : 'New instance'}
67
+ </Button>
68
+ <Button
69
+ variant="primary"
70
+ size="sm"
71
+ square
72
+ icon={altMode === 'terminal' ? Terminal : LayoutTemplate}
73
+ iconSize={15}
74
+ onclick={() => onNew?.(altMode)}
75
+ disabled={!ready || creating}
76
+ title={altMode === 'terminal' ? 'New terminal instance' : 'New full IDE instance'}
77
+ aria-label={altMode === 'terminal' ? 'New terminal instance' : 'New full IDE instance'}
78
+ />
79
+ </div>
59
80
  </div>
60
81
  </header>
61
82
 
@@ -72,4 +93,10 @@
72
93
  align-items: center;
73
94
  gap: 14px;
74
95
  }
96
+ /* The mode shortcut reads as part of the New instance control, not a separate action. */
97
+ .new-group {
98
+ display: inline-flex;
99
+ align-items: center;
100
+ gap: 6px;
101
+ }
75
102
  </style>
@@ -71,9 +71,8 @@
71
71
 
72
72
  let avatarScale = $state(8);
73
73
  let avatarName = $state('demo-instance');
74
- let avatarId = $state('alpha');
75
- // '' means "pick by id"; otherwise the value is an avatar name from the catalog.
76
- let avatarArtName = $state('');
74
+ // '' renders an empty panel; otherwise the value is an avatar name from the catalog.
75
+ let avatarArtName = $state(avatars[0]!.name);
77
76
  const avatarArt = $derived(avatars.find((a) => a.name === avatarArtName));
78
77
 
79
78
  let btnVariant = $state<'default' | 'primary' | 'danger'>('primary');
@@ -149,7 +148,7 @@
149
148
  canDelete={true}
150
149
  ready={true}
151
150
  creating={false}
152
- onNew={() => toast('New instance (demo)')}
151
+ onNew={(mode) => toast(`New ${mode ?? 'default'} instance (demo)`)}
153
152
  onDeleteAll={() => toast('Delete all (demo)')}
154
153
  />
155
154
 
@@ -187,7 +186,7 @@
187
186
  </details>
188
187
 
189
188
  <ComponentDemo title="Avatar">
190
- <Avatar id={avatarId} name={avatarName} scale={avatarScale} art={avatarArt} />
189
+ <Avatar name={avatarName} scale={avatarScale} art={avatarArt} />
191
190
  {#snippet controls()}
192
191
  <label>
193
192
  <span>scale ({avatarScale})</span>
@@ -197,14 +196,10 @@
197
196
  <span>name</span>
198
197
  <input type="text" bind:value={avatarName} />
199
198
  </label>
200
- <label>
201
- <span>id</span>
202
- <input type="text" bind:value={avatarId} />
203
- </label>
204
199
  <label>
205
200
  <span>art</span>
206
201
  <select bind:value={avatarArtName}>
207
- <option value="">(pick by id)</option>
202
+ <option value="">(none)</option>
208
203
  {#each avatars as art (art.name)}
209
204
  <option value={art.name}>{art.name}</option>
210
205
  {/each}
@@ -5,7 +5,7 @@ import {
5
5
  } from '../lib/container-files.server.ts';
6
6
  import type { Injection } from '../lib/injections.server.ts';
7
7
 
8
- /** Both resolve `claude`, which `claude-skip-permissions` also aliases, so the two compose. */
8
+ /** Both resolve `claude`, which `claude-permission-mode` also aliases, so the two compose. */
9
9
  const ALIAS_LINES = [
10
10
  "alias c200='CLAUDE_CODE_DISABLE_1M_CONTEXT=1 claude'",
11
11
  "alias cs='claude --model sonnet'"
@@ -43,7 +43,8 @@ export function customEndpointConfig(): {
43
43
  };
44
44
  }
45
45
 
46
- const ENV_FILE_NAME = '.codebay-claude-env';
46
+ /** Exported so the terminal/IDE launchers can source it explicitly — see `SOURCE_INJECTED_ENV`. */
47
+ export const ENV_FILE_NAME = '.codebay-claude-env';
47
48
 
48
49
  /** The whole file (token included) rides `stdin` inside `installShellEnvFile`, so nothing hits argv. */
49
50
  function envFileContent(config: NonNullable<ReturnType<typeof customEndpointConfig>>): string {
@@ -7,9 +7,14 @@ export const EXTENSION_ID = 'anthropic.claude-code';
7
7
  const EXT_GLOB = '~/.local/share/code-server/extensions/anthropic.claude-code-*';
8
8
 
9
9
  /** Idempotent (skips if already present); best-effort — needs Open VSX egress. */
10
+ // The bracketed `install-extensio[n]` keeps pgrep from matching this script's own `bash -lc` argv,
11
+ // so it only sees the launch line's real background install (which it waits out before falling back).
10
12
  export const INSTALL_SCRIPT =
11
13
  `if ls -d ${EXT_GLOB} >/dev/null 2>&1; then exit 0; fi; ` +
12
14
  `command -v code-server >/dev/null 2>&1 || { echo "code-server not found" >&2; exit 1; }; ` +
15
+ `for _ in {1..90}; do ` +
16
+ `pgrep -f 'install-extensio[n] ${EXTENSION_ID}' >/dev/null 2>&1 || break; sleep 1; done; ` +
17
+ `if ls -d ${EXT_GLOB} >/dev/null 2>&1; then exit 0; fi; ` +
13
18
  `code-server --install-extension ${EXTENSION_ID}`;
14
19
 
15
20
  export const CHECK_SCRIPT = `ls -d ${EXT_GLOB} >/dev/null 2>&1 && echo 1 || echo 0`;
@@ -0,0 +1,67 @@
1
+ import { checkPresence, execInContainer } from '../lib/exec.server.ts';
2
+ import type { Injection } from '../lib/injections.server.ts';
3
+
4
+ /**
5
+ * Sniffs before installing, because a project-supplied image usually ships its own Claude Code —
6
+ * and because the alternative (the upstream node feature) installs via nvm, which hard-fails on
7
+ * any image that sets NPM_CONFIG_PREFIX. Neither branch here touches nvm: npm when the image has
8
+ * Node, else the standalone installer, which needs no Node at all.
9
+ *
10
+ * `$1` is the remote user (the injection passes it; the build-time feature falls back to the
11
+ * devcontainer CLI's `$_REMOTE_USER`) — the standalone installer puts the binary under that
12
+ * user's `$HOME`, so running it as root would strand it in `/root`.
13
+ */
14
+ export const INSTALL_SCRIPT =
15
+ 'if command -v claude >/dev/null 2>&1; then exit 0; fi\n' +
16
+ 'if command -v npm >/dev/null 2>&1 && npm install -g @anthropic-ai/claude-code@latest >/dev/null 2>&1; then\n' +
17
+ ' p="$(npm prefix -g 2>/dev/null)"\n' +
18
+ // npm's global bin is off PATH whenever the image sets its own NPM_CONFIG_PREFIX, so a bare
19
+ // `command -v` would miss an install that just succeeded — and so would the terminal launcher.
20
+ ' [ -n "$p" ] && [ -x "$p/bin/claude" ] && ln -sf "$p/bin/claude" /usr/local/bin/claude 2>/dev/null\n' +
21
+ ' if command -v claude >/dev/null 2>&1 || [ -x "$p/bin/claude" ]; then exit 0; fi\n' +
22
+ 'fi\n' +
23
+ 'u="${1:-${_REMOTE_USER:-root}}"\n' +
24
+ 'h="$(getent passwd "$u" 2>/dev/null | cut -d: -f6)"; [ -n "$h" ] || h="$HOME"\n' +
25
+ 'f="$(mktemp)"\n' +
26
+ 'if command -v curl >/dev/null 2>&1; then curl -fsSL https://claude.ai/install.sh -o "$f"\n' +
27
+ 'elif command -v wget >/dev/null 2>&1; then wget -qO "$f" https://claude.ai/install.sh\n' +
28
+ 'else echo "no curl/wget to fetch the Claude Code installer" >&2; exit 1; fi\n' +
29
+ 'chmod 0755 "$f"\n' +
30
+ // -m keeps the HOME set here, so the installer targets the remote user's home, not root's.
31
+ 'if [ "$(id -un)" = "$u" ]; then "$f" >/dev/null 2>&1\n' +
32
+ 'else HOME="$h" su -m "$u" -c "\'$f\'" >/dev/null 2>&1; fi\n' +
33
+ 'rm -f "$f"\n' +
34
+ // The installer's ~/.local/bin is on no other user's PATH, and often not even on its own.
35
+ 'ln -sf "$h/.local/bin/claude" /usr/local/bin/claude 2>/dev/null || true\n' +
36
+ 'command -v claude >/dev/null 2>&1 || [ -x "$h/.local/bin/claude" ]\n';
37
+
38
+ const CHECK_SCRIPT = 'command -v claude >/dev/null 2>&1 && echo 1 || echo 0';
39
+
40
+ /**
41
+ * The runtime fallback to the build-time `codebay-claude` feature, mirroring tmux/ttyd. Terminal
42
+ * mode only: there the launcher *is* `claude`, so a missing binary leaves a bare shell — IDE-mode
43
+ * instances on a project image keep owning their own tooling.
44
+ */
45
+ export const claudeCodeInstall: Injection = {
46
+ id: 'claude-code-install',
47
+ label: 'Claude Code',
48
+ modes: ['terminal'],
49
+
50
+ async apply(target, log) {
51
+ log('Checking Claude Code is installed…\n');
52
+ // Omitting remoteUser runs as root, which the global npm install and the symlink need.
53
+ const install = await execInContainer(
54
+ { containerId: target.containerId },
55
+ { script: INSTALL_SCRIPT, args: ['claude-install', target.remoteUser ?? ''] }
56
+ );
57
+ log(
58
+ install.ok
59
+ ? '✓ Claude Code available\n'
60
+ : `⚠ Claude Code install failed: ${install.error} — the terminal opens a plain shell\n`
61
+ );
62
+ },
63
+
64
+ async check(target) {
65
+ return checkPresence(target, CHECK_SCRIPT);
66
+ }
67
+ };
@@ -26,7 +26,8 @@ export function manualModelConfig(): Record<string, string> | null {
26
26
  return Object.keys(filled).length ? filled : null;
27
27
  }
28
28
 
29
- const ENV_FILE_NAME = '.codebay-claude-models-env';
29
+ /** Exported so the terminal/IDE launchers can source it explicitly — see `SOURCE_INJECTED_ENV`. */
30
+ export const ENV_FILE_NAME = '.codebay-claude-models-env';
30
31
 
31
32
  function injectModels(
32
33
  target: ContainerTarget,
@@ -1,4 +1,5 @@
1
1
  import { execInContainer } from '../lib/exec.server.ts';
2
+ import { getOption, setOption } from '../lib/db.server.ts';
2
3
  import type { Injection } from '../lib/injections.server.ts';
3
4
 
4
5
  /**
@@ -18,6 +19,63 @@ export const UPDATE_SCRIPT =
18
19
  'npm install -g @anthropic-ai/claude-code@latest >/dev/null 2>&1 || exit 1; ' +
19
20
  'echo "updated $installed -> $latest"';
20
21
 
22
+ /**
23
+ * Same check with the latest version supplied by the host as `$0` (exec args, never interpolated
24
+ * into the script), skipping the in-container `npm view` registry round-trip.
25
+ */
26
+ export const PINNED_UPDATE_SCRIPT =
27
+ 'latest="$0"; ' +
28
+ 'command -v claude >/dev/null 2>&1 || exit 0; ' +
29
+ 'command -v npm >/dev/null 2>&1 || exit 0; ' +
30
+ "installed=$(claude --version 2>/dev/null | grep -oE '[0-9]+\\.[0-9]+\\.[0-9]+' | head -n1); " +
31
+ 'if [ "$installed" = "$latest" ]; then echo "current $latest"; exit 0; fi; ' +
32
+ 'npm install -g @anthropic-ai/claude-code@latest >/dev/null 2>&1 || exit 1; ' +
33
+ 'echo "updated $installed -> $latest"';
34
+
35
+ /** Anything looser could smuggle shell metacharacters out of a corrupted DB row into a container. */
36
+ export const VERSION_RE = /^\d+\.\d+\.\d+$/;
37
+
38
+ export const CACHE_TTL_MS = 60 * 60 * 1000;
39
+
40
+ export const LATEST_VERSION_KEY = 'claude_code_latest_version';
41
+ export const LATEST_CHECKED_AT_KEY = 'claude_code_latest_checked_at';
42
+
43
+ export function cacheIsFresh(checkedAt: string | null, now: number, ttlMs = CACHE_TTL_MS): boolean {
44
+ const t = Number(checkedAt);
45
+ return Number.isFinite(t) && t > 0 && now - t < ttlMs;
46
+ }
47
+
48
+ /** Best-effort: any failure (offline, timeout, odd payload) returns null and the caller falls back. */
49
+ export async function fetchLatestVersion(): Promise<string | null> {
50
+ try {
51
+ const res = await fetch('https://registry.npmjs.org/@anthropic-ai/claude-code/latest', {
52
+ signal: AbortSignal.timeout(3000)
53
+ });
54
+ if (!res.ok) return null;
55
+ const version = ((await res.json()) as { version?: unknown }).version;
56
+ return typeof version === 'string' && VERSION_RE.test(version) ? version : null;
57
+ } catch {
58
+ return null;
59
+ }
60
+ }
61
+
62
+ /** Fresh cached version, a newly fetched one (cached for next time), or null → use UPDATE_SCRIPT. */
63
+ async function resolveLatestVersion(): Promise<string | null> {
64
+ const cached = getOption(LATEST_VERSION_KEY);
65
+ if (
66
+ cached &&
67
+ VERSION_RE.test(cached) &&
68
+ cacheIsFresh(getOption(LATEST_CHECKED_AT_KEY), Date.now())
69
+ ) {
70
+ return cached;
71
+ }
72
+ const version = await fetchLatestVersion();
73
+ if (!version) return null;
74
+ setOption(LATEST_VERSION_KEY, version);
75
+ setOption(LATEST_CHECKED_AT_KEY, String(Date.now()));
76
+ return version;
77
+ }
78
+
21
79
  /** Best-effort, non-fatal — a failed update leaves the cached version in place. */
22
80
  export const claudeCodeUpdate: Injection = {
23
81
  id: 'claude-code-update',
@@ -25,10 +83,13 @@ export const claudeCodeUpdate: Injection = {
25
83
 
26
84
  async apply(target, log) {
27
85
  log('Checking Claude Code is up to date…\n');
86
+ const latest = await resolveLatestVersion();
28
87
  // Omitting remoteUser runs as root, which the global npm reinstall needs.
29
88
  const res = await execInContainer(
30
89
  { containerId: target.containerId },
31
- { script: UPDATE_SCRIPT, capture: true }
90
+ latest
91
+ ? { script: PINNED_UPDATE_SCRIPT, args: [latest], capture: true }
92
+ : { script: UPDATE_SCRIPT, capture: true }
32
93
  );
33
94
  if (!res.ok) {
34
95
  log(`⚠ Claude Code update check failed: ${res.error} — keeping the installed version\n`);
@@ -0,0 +1,32 @@
1
+ import { getOption } from '../lib/db.server.ts';
2
+ import { mergeClaudeSettings, readClaudeSettings } from '../lib/claude-settings.server.ts';
3
+ import { normalizeEffortLevel } from '../types.ts';
4
+ import type { ClaudeEffortLevel } from '../types.ts';
5
+ import type { Injection } from '../lib/injections.server.ts';
6
+
7
+ /** Also read by the settings-page `serverProps` to seed the UI. */
8
+ export function getClaudeEffortLevel(): ClaudeEffortLevel {
9
+ return normalizeEffortLevel(getOption('claude_effort_level'));
10
+ }
11
+
12
+ /** Writes Claude Code's `effortLevel` default into the container so new sessions start at the chosen effort. */
13
+ export const claudeEffortLevel: Injection = {
14
+ id: 'claude-effort-level',
15
+ label: 'effort level',
16
+
17
+ async apply(target, log) {
18
+ const level = getClaudeEffortLevel();
19
+ log(`Setting Claude effort level (${level})…\n`);
20
+ const result = await mergeClaudeSettings(target, { effortLevel: level });
21
+ log(
22
+ result.ok
23
+ ? '✓ Claude effort level configured in container\n'
24
+ : `⚠ Claude effort level injection failed: ${result.error}\n`
25
+ );
26
+ },
27
+
28
+ async check(target) {
29
+ const settings = await readClaudeSettings(target);
30
+ return settings?.effortLevel === getClaudeEffortLevel();
31
+ }
32
+ };
@@ -0,0 +1,40 @@
1
+ import {
2
+ appendLinesIfAbsent,
3
+ linesPresent,
4
+ SHELL_RC_FILES
5
+ } from '../lib/container-files.server.ts';
6
+ import { getOption } from '../lib/db.server.ts';
7
+ import { claudePermissionFlags, normalizePermissionMode } from '../types.ts';
8
+ import type { ClaudePermissionMode } from '../types.ts';
9
+ import type { Injection } from '../lib/injections.server.ts';
10
+
11
+ /** Also read by `provision()`, which bakes the same flags into the container's launcher. */
12
+ export function getClaudePermissionMode(): ClaudePermissionMode {
13
+ return normalizePermissionMode(getOption('claude_permission_mode'));
14
+ }
15
+
16
+ const aliasLine = () => `alias claude='claude ${claudePermissionFlags(getClaudePermissionMode())}'`;
17
+
18
+ /**
19
+ * Keeps a hand-typed `claude` on the same permission mode the auto-launched session gets.
20
+ * The `default` mode's bypass is safe here only because instances are throwaway sandboxes.
21
+ */
22
+ export const claudePermissionMode: Injection = {
23
+ id: 'claude-permission-mode',
24
+ label: 'claude permission mode',
25
+
26
+ async apply(target, log) {
27
+ const line = aliasLine();
28
+ log(`Installing ${line}…\n`);
29
+ const res = await appendLinesIfAbsent(target, SHELL_RC_FILES, [line]);
30
+ log(
31
+ res.ok
32
+ ? '✓ claude permission-mode alias installed\n'
33
+ : `⚠ claude alias setup failed: ${res.error}\n`
34
+ );
35
+ },
36
+
37
+ async check(target) {
38
+ return linesPresent(target, SHELL_RC_FILES, [aliasLine()]);
39
+ }
40
+ };
@@ -0,0 +1,82 @@
1
+ import { getOption } from '../lib/db.server.ts';
2
+ import { checkPresence } from '../lib/exec.server.ts';
3
+ import type { Injection } from '../lib/injections.server.ts';
4
+
5
+ export interface CustomEnvVar {
6
+ name: string;
7
+ value: string;
8
+ }
9
+
10
+ /** Same rule the host-env-vars name list and the settings action validate against. */
11
+ const NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
12
+
13
+ /** Exported so `/settings` and the log-redaction step parse the stored list the same way. */
14
+ export function parseCustomEnvVars(raw: string | null): CustomEnvVar[] {
15
+ let parsed: unknown;
16
+ try {
17
+ parsed = JSON.parse(raw ?? '[]');
18
+ } catch {
19
+ return [];
20
+ }
21
+ if (!Array.isArray(parsed)) return [];
22
+ const out: CustomEnvVar[] = [];
23
+ for (const entry of parsed) {
24
+ const name = (entry as CustomEnvVar)?.name;
25
+ const value = (entry as CustomEnvVar)?.value;
26
+ if (typeof name === 'string' && typeof value === 'string' && NAME_RE.test(name)) {
27
+ out.push({ name, value });
28
+ }
29
+ }
30
+ return out;
31
+ }
32
+
33
+ /** Null means "skip" — neither `containerEnv` nor the injection render anything. */
34
+ export function customEnvVarsConfig(): { vars: CustomEnvVar[] } | null {
35
+ if (getOption('custom_env_vars_enabled') !== '1') return null;
36
+ const vars = parseCustomEnvVars(getOption('custom_env_vars')).filter((v) => v.value !== '');
37
+ return vars.length ? { vars } : null;
38
+ }
39
+
40
+ /** The secret values eligible for log redaction — only while the feature is enabled. */
41
+ export function customEnvVarValues(): string[] {
42
+ return customEnvVarsConfig()?.vars.map((v) => v.value) ?? [];
43
+ }
44
+
45
+ /** Names ride argv (never a value), and `printenv` output is discarded so no value is ever echoed. */
46
+ const PRESENCE_SCRIPT =
47
+ 'for n in "$@"; do printenv "$n" >/dev/null 2>&1 || { echo 0; exit 0; }; done; echo 1';
48
+
49
+ /**
50
+ * The values themselves are injected via devcontainer.json `containerEnv` at container-create time
51
+ * (a post-up injection can't set container-wide env); this module verifies they landed and draws
52
+ * the health row. No `auth` block: values come from Settings, not a discovered host dependency.
53
+ */
54
+ export const customEnvVars: Injection = {
55
+ id: 'custom-env-vars',
56
+ label: 'custom env vars',
57
+
58
+ async apply(target, log) {
59
+ const config = customEnvVarsConfig();
60
+ if (!config) {
61
+ log('⚠ Custom env vars not configured (disabled or none set); skipped\n');
62
+ return;
63
+ }
64
+ const names = config.vars.map((v) => v.name);
65
+ log(`Verifying ${names.length} custom env var(s) via containerEnv: ${names.join(', ')}…\n`);
66
+ const present = await checkPresence(target, PRESENCE_SCRIPT, ['custom-env-vars', ...names]);
67
+ log(
68
+ present
69
+ ? '✓ custom env vars present in the container\n'
70
+ : '⚠ custom env vars not visible (containerEnv may not apply to this base image)\n'
71
+ );
72
+ },
73
+
74
+ async check(target) {
75
+ const config = customEnvVarsConfig();
76
+ if (!config) return false;
77
+ return checkPresence(target, PRESENCE_SCRIPT, [
78
+ 'custom-env-vars',
79
+ ...config.vars.map((v) => v.name)
80
+ ]);
81
+ }
82
+ };
@@ -7,7 +7,8 @@ import {
7
7
  } from '../lib/container-files.server.ts';
8
8
  import type { ContainerTarget, Injection } from '../lib/injections.server.ts';
9
9
 
10
- const ENV_FILE_NAME = '.codebay-host-env';
10
+ /** Exported so the terminal/IDE launchers can source it explicitly — see `SOURCE_INJECTED_ENV`. */
11
+ export const ENV_FILE_NAME = '.codebay-host-env';
11
12
 
12
13
  interface ResolvedVar {
13
14
  name: string;
@@ -51,6 +51,9 @@ export const APP_VERSION: string = (() => {
51
51
  /** Per-instance working copies live here: <INSTANCES_DIR>/<id>/workspace. */
52
52
  export const INSTANCES_DIR = join(DATA_DIR, 'instances');
53
53
 
54
+ /** Extracted Claude Code logs, in one flat folder that outlives the instances they came from. */
55
+ export const LOGS_DIR = join(DATA_DIR, 'logs');
56
+
54
57
  export const DB_PATH = join(DATA_DIR, 'app.sqlite');
55
58
 
56
59
  export const PORT_BASE = 8001;
@@ -30,6 +30,8 @@ export interface InstanceRow {
30
30
  remote_user: string | null;
31
31
  /** `'local'` when the folder shipped its own config, else the injected image; null pre-dates this. */
32
32
  image_source: string | null;
33
+ /** Sprite name chosen collision-free at creation; null pre-dates this and falls back to the id hash. */
34
+ avatar: string | null;
33
35
  /** Fixed at creation: which editor surface this instance provisions and serves. */
34
36
  mode: InstanceMode;
35
37
  /** Terminal mode only: 1 when the scratch-shell pane was left open, so a reload restores it. */
@@ -64,8 +66,8 @@ export function closeDb(): void {
64
66
  export function insertInstance(row: InstanceRow): void {
65
67
  db.query(
66
68
  `INSERT INTO instances
67
- (id, name, source_path, workspace_path, host_port, container_id, remote_workspace_folder, status, error, created_at, bridge_token, remote_user, image_source, mode, terminal_split)
68
- VALUES ($id, $name, $source_path, $workspace_path, $host_port, $container_id, $remote_workspace_folder, $status, $error, $created_at, $bridge_token, $remote_user, $image_source, $mode, $terminal_split)`
69
+ (id, name, source_path, workspace_path, host_port, container_id, remote_workspace_folder, status, error, created_at, bridge_token, remote_user, image_source, avatar, mode, terminal_split)
70
+ VALUES ($id, $name, $source_path, $workspace_path, $host_port, $container_id, $remote_workspace_folder, $status, $error, $created_at, $bridge_token, $remote_user, $image_source, $avatar, $mode, $terminal_split)`
69
71
  ).run({
70
72
  $id: row.id,
71
73
  $name: row.name,
@@ -80,6 +82,7 @@ export function insertInstance(row: InstanceRow): void {
80
82
  $bridge_token: row.bridge_token,
81
83
  $remote_user: row.remote_user,
82
84
  $image_source: row.image_source,
85
+ $avatar: row.avatar,
83
86
  $mode: row.mode,
84
87
  $terminal_split: row.terminal_split
85
88
  });
@@ -143,7 +146,9 @@ const UPDATABLE_COLUMNS = [
143
146
  'error',
144
147
  'remote_user',
145
148
  'image_source',
146
- 'terminal_split'
149
+ 'terminal_split',
150
+ // Reassigned when a rebuild finds the recorded port taken over on the host.
151
+ 'host_port'
147
152
  ] as const;
148
153
 
149
154
  type UpdatableColumn = (typeof UPDATABLE_COLUMNS)[number];