@assistkick/create 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/dist/bin/create.d.ts +2 -0
- package/dist/bin/create.js +25 -0
- package/dist/bin/create.js.map +1 -0
- package/dist/src/scaffolder.d.ts +22 -0
- package/dist/src/scaffolder.js +120 -0
- package/dist/src/scaffolder.js.map +1 -0
- package/package.json +24 -0
- package/templates/product-system/.env.example +8 -0
- package/templates/product-system/CLAUDE.md +45 -0
- package/templates/product-system/package.json +32 -0
- package/templates/product-system/packages/backend/package.json +37 -0
- package/templates/product-system/packages/backend/src/middleware/auth_middleware.test.ts +86 -0
- package/templates/product-system/packages/backend/src/middleware/auth_middleware.ts +35 -0
- package/templates/product-system/packages/backend/src/routes/auth.ts +463 -0
- package/templates/product-system/packages/backend/src/routes/coherence.ts +187 -0
- package/templates/product-system/packages/backend/src/routes/graph.ts +67 -0
- package/templates/product-system/packages/backend/src/routes/kanban.ts +201 -0
- package/templates/product-system/packages/backend/src/routes/pipeline.ts +41 -0
- package/templates/product-system/packages/backend/src/routes/projects.ts +122 -0
- package/templates/product-system/packages/backend/src/routes/users.ts +97 -0
- package/templates/product-system/packages/backend/src/server.ts +159 -0
- package/templates/product-system/packages/backend/src/services/auth_service.test.ts +115 -0
- package/templates/product-system/packages/backend/src/services/auth_service.ts +82 -0
- package/templates/product-system/packages/backend/src/services/coherence-review.ts +339 -0
- package/templates/product-system/packages/backend/src/services/email_service.ts +75 -0
- package/templates/product-system/packages/backend/src/services/init.ts +80 -0
- package/templates/product-system/packages/backend/src/services/invitation_service.test.ts +235 -0
- package/templates/product-system/packages/backend/src/services/invitation_service.ts +193 -0
- package/templates/product-system/packages/backend/src/services/password_reset_service.test.ts +151 -0
- package/templates/product-system/packages/backend/src/services/password_reset_service.ts +135 -0
- package/templates/product-system/packages/backend/src/services/project_service.test.ts +215 -0
- package/templates/product-system/packages/backend/src/services/project_service.ts +171 -0
- package/templates/product-system/packages/backend/src/services/pty_session_manager.test.ts +88 -0
- package/templates/product-system/packages/backend/src/services/pty_session_manager.ts +279 -0
- package/templates/product-system/packages/backend/src/services/terminal_ws_handler.ts +133 -0
- package/templates/product-system/packages/backend/src/services/user_management_service.test.ts +158 -0
- package/templates/product-system/packages/backend/src/services/user_management_service.ts +128 -0
- package/templates/product-system/packages/backend/tsconfig.json +22 -0
- package/templates/product-system/packages/frontend/index.html +13 -0
- package/templates/product-system/packages/frontend/package-lock.json +2666 -0
- package/templates/product-system/packages/frontend/package.json +30 -0
- package/templates/product-system/packages/frontend/public/favicon.svg +16 -0
- package/templates/product-system/packages/frontend/src/App.tsx +29 -0
- package/templates/product-system/packages/frontend/src/api/client.ts +386 -0
- package/templates/product-system/packages/frontend/src/api/client_projects.test.ts +104 -0
- package/templates/product-system/packages/frontend/src/api/client_refresh.test.ts +145 -0
- package/templates/product-system/packages/frontend/src/components/CoherenceView.tsx +414 -0
- package/templates/product-system/packages/frontend/src/components/GraphLegend.tsx +124 -0
- package/templates/product-system/packages/frontend/src/components/GraphSettings.tsx +112 -0
- package/templates/product-system/packages/frontend/src/components/GraphView.tsx +370 -0
- package/templates/product-system/packages/frontend/src/components/InviteUserDialog.tsx +85 -0
- package/templates/product-system/packages/frontend/src/components/KanbanView.tsx +470 -0
- package/templates/product-system/packages/frontend/src/components/LoginPage.tsx +116 -0
- package/templates/product-system/packages/frontend/src/components/ProjectSelector.tsx +187 -0
- package/templates/product-system/packages/frontend/src/components/QaIssueSheet.tsx +192 -0
- package/templates/product-system/packages/frontend/src/components/SidePanel.tsx +231 -0
- package/templates/product-system/packages/frontend/src/components/TerminalView.tsx +200 -0
- package/templates/product-system/packages/frontend/src/components/Toolbar.tsx +84 -0
- package/templates/product-system/packages/frontend/src/components/UsersView.tsx +249 -0
- package/templates/product-system/packages/frontend/src/constants/graph.ts +191 -0
- package/templates/product-system/packages/frontend/src/hooks/useAuth.tsx +54 -0
- package/templates/product-system/packages/frontend/src/hooks/useGraph.ts +27 -0
- package/templates/product-system/packages/frontend/src/hooks/useKanban.ts +21 -0
- package/templates/product-system/packages/frontend/src/hooks/useProjects.ts +86 -0
- package/templates/product-system/packages/frontend/src/hooks/useTheme.ts +26 -0
- package/templates/product-system/packages/frontend/src/hooks/useToast.tsx +62 -0
- package/templates/product-system/packages/frontend/src/hooks/use_projects_logic.test.ts +61 -0
- package/templates/product-system/packages/frontend/src/main.tsx +12 -0
- package/templates/product-system/packages/frontend/src/pages/accept_invitation_page.tsx +167 -0
- package/templates/product-system/packages/frontend/src/pages/forgot_password_page.tsx +100 -0
- package/templates/product-system/packages/frontend/src/pages/register_page.tsx +137 -0
- package/templates/product-system/packages/frontend/src/pages/reset_password_page.tsx +146 -0
- package/templates/product-system/packages/frontend/src/routes/ProtectedRoute.tsx +12 -0
- package/templates/product-system/packages/frontend/src/routes/accept_invitation.tsx +14 -0
- package/templates/product-system/packages/frontend/src/routes/dashboard.tsx +221 -0
- package/templates/product-system/packages/frontend/src/routes/forgot_password.tsx +13 -0
- package/templates/product-system/packages/frontend/src/routes/login.tsx +14 -0
- package/templates/product-system/packages/frontend/src/routes/register.tsx +14 -0
- package/templates/product-system/packages/frontend/src/routes/reset_password.tsx +13 -0
- package/templates/product-system/packages/frontend/src/styles/index.css +3358 -0
- package/templates/product-system/packages/frontend/src/utils/auth_validation.test.ts +51 -0
- package/templates/product-system/packages/frontend/src/utils/auth_validation.ts +19 -0
- package/templates/product-system/packages/frontend/src/utils/login_validation.test.ts +61 -0
- package/templates/product-system/packages/frontend/src/utils/login_validation.ts +24 -0
- package/templates/product-system/packages/frontend/src/utils/logout.test.ts +63 -0
- package/templates/product-system/packages/frontend/src/utils/node_sizing.test.ts +62 -0
- package/templates/product-system/packages/frontend/src/utils/node_sizing.ts +24 -0
- package/templates/product-system/packages/frontend/src/utils/task_status.test.ts +53 -0
- package/templates/product-system/packages/frontend/src/utils/task_status.ts +14 -0
- package/templates/product-system/packages/frontend/tsconfig.json +21 -0
- package/templates/product-system/packages/frontend/vite.config.ts +20 -0
- package/templates/product-system/packages/shared/.env.example +3 -0
- package/templates/product-system/packages/shared/README.md +1 -0
- package/templates/product-system/packages/shared/db/migrate.ts +32 -0
- package/templates/product-system/packages/shared/db/migrations/0000_dashing_gorgon.sql +128 -0
- package/templates/product-system/packages/shared/db/migrations/meta/0000_snapshot.json +819 -0
- package/templates/product-system/packages/shared/db/migrations/meta/_journal.json +13 -0
- package/templates/product-system/packages/shared/db/schema.ts +137 -0
- package/templates/product-system/packages/shared/drizzle.config.js +14 -0
- package/templates/product-system/packages/shared/lib/claude-service.ts +215 -0
- package/templates/product-system/packages/shared/lib/coherence.ts +278 -0
- package/templates/product-system/packages/shared/lib/completeness.ts +30 -0
- package/templates/product-system/packages/shared/lib/constants.ts +327 -0
- package/templates/product-system/packages/shared/lib/db.ts +81 -0
- package/templates/product-system/packages/shared/lib/git_workflow.ts +110 -0
- package/templates/product-system/packages/shared/lib/graph.ts +186 -0
- package/templates/product-system/packages/shared/lib/kanban.ts +161 -0
- package/templates/product-system/packages/shared/lib/markdown.ts +205 -0
- package/templates/product-system/packages/shared/lib/pipeline-state-store.ts +124 -0
- package/templates/product-system/packages/shared/lib/pipeline.ts +489 -0
- package/templates/product-system/packages/shared/lib/prompt_builder.ts +170 -0
- package/templates/product-system/packages/shared/lib/relevance_search.ts +159 -0
- package/templates/product-system/packages/shared/lib/session.ts +152 -0
- package/templates/product-system/packages/shared/lib/validator.ts +117 -0
- package/templates/product-system/packages/shared/lib/work_summary_parser.ts +130 -0
- package/templates/product-system/packages/shared/package.json +30 -0
- package/templates/product-system/packages/shared/scripts/assign-project.ts +52 -0
- package/templates/product-system/packages/shared/tools/add_edge.ts +61 -0
- package/templates/product-system/packages/shared/tools/add_node.ts +101 -0
- package/templates/product-system/packages/shared/tools/end_session.ts +87 -0
- package/templates/product-system/packages/shared/tools/get_gaps.ts +87 -0
- package/templates/product-system/packages/shared/tools/get_kanban.ts +125 -0
- package/templates/product-system/packages/shared/tools/get_node.ts +78 -0
- package/templates/product-system/packages/shared/tools/get_status.ts +98 -0
- package/templates/product-system/packages/shared/tools/migrate_to_turso.ts +385 -0
- package/templates/product-system/packages/shared/tools/move_card.ts +143 -0
- package/templates/product-system/packages/shared/tools/rebuild_index.ts +77 -0
- package/templates/product-system/packages/shared/tools/remove_edge.ts +59 -0
- package/templates/product-system/packages/shared/tools/remove_node.ts +96 -0
- package/templates/product-system/packages/shared/tools/resolve_question.ts +75 -0
- package/templates/product-system/packages/shared/tools/search_nodes.ts +106 -0
- package/templates/product-system/packages/shared/tools/start_session.ts +144 -0
- package/templates/product-system/packages/shared/tools/update_node.ts +133 -0
- package/templates/product-system/packages/shared/tsconfig.json +24 -0
- package/templates/product-system/pnpm-workspace.yaml +2 -0
- package/templates/product-system/smoke_test.ts +219 -0
- package/templates/product-system/tests/coherence_review.test.ts +562 -0
- package/templates/product-system/tests/db_sqlite_fallback.test.ts +75 -0
- package/templates/product-system/tests/edge_type_color_coding.test.ts +147 -0
- package/templates/product-system/tests/emit-tool-use-events.test.ts +85 -0
- package/templates/product-system/tests/feature_kind.test.ts +139 -0
- package/templates/product-system/tests/gap_indicators.test.ts +199 -0
- package/templates/product-system/tests/graceful_init.test.ts +142 -0
- package/templates/product-system/tests/graph_legend.test.ts +314 -0
- package/templates/product-system/tests/graph_settings_sheet.test.ts +804 -0
- package/templates/product-system/tests/hide_defined_filter.test.ts +205 -0
- package/templates/product-system/tests/kanban.test.ts +529 -0
- package/templates/product-system/tests/neighborhood_focus.test.ts +132 -0
- package/templates/product-system/tests/node_search.test.ts +340 -0
- package/templates/product-system/tests/node_sizing.test.ts +170 -0
- package/templates/product-system/tests/node_type_toggle_filters.test.ts +285 -0
- package/templates/product-system/tests/node_type_visual_encoding.test.ts +103 -0
- package/templates/product-system/tests/pipeline-state-store.test.ts +268 -0
- package/templates/product-system/tests/pipeline-unit.test.ts +593 -0
- package/templates/product-system/tests/pipeline.test.ts +195 -0
- package/templates/product-system/tests/pipeline_stats_all_cards.test.ts +193 -0
- package/templates/product-system/tests/play_all.test.ts +296 -0
- package/templates/product-system/tests/qa_issue_sheet.test.ts +464 -0
- package/templates/product-system/tests/relevance_search.test.ts +186 -0
- package/templates/product-system/tests/search_reorder.test.ts +88 -0
- package/templates/product-system/tests/serve_ui.test.ts +281 -0
- package/templates/product-system/tests/serve_ui_drizzle.test.ts +114 -0
- package/templates/product-system/tests/session_context_recall.test.ts +135 -0
- package/templates/product-system/tests/side_panel.test.ts +345 -0
- package/templates/product-system/tests/spec_completeness_label.test.ts +69 -0
- package/templates/product-system/tests/url_routing_test.ts +122 -0
- package/templates/product-system/tests/user_login.test.ts +150 -0
- package/templates/product-system/tests/user_registration.test.ts +205 -0
- package/templates/product-system/tests/web_terminal.test.ts +572 -0
- package/templates/product-system/tests/work_summary.test.ts +211 -0
- package/templates/product-system/tests/zoom_pan.test.ts +43 -0
- package/templates/product-system/tsconfig.json +24 -0
- package/templates/skills/product-bootstrap/SKILL.md +312 -0
- package/templates/skills/product-code-reviewer/SKILL.md +147 -0
- package/templates/skills/product-debugger/SKILL.md +206 -0
- package/templates/skills/product-debugger/references/agent-browser.md +1156 -0
- package/templates/skills/product-developer/SKILL.md +182 -0
- package/templates/skills/product-interview/SKILL.md +220 -0
|
@@ -0,0 +1,572 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for Web Terminal (feat_073).
|
|
3
|
+
* Tests PTY session manager logic, command whitelist validation (dec_029),
|
|
4
|
+
* command execution model, and WebSocket handler auth checks.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, it, mock } from 'node:test';
|
|
7
|
+
import assert from 'node:assert/strict';
|
|
8
|
+
import { PtySessionManager } from '../packages/backend/src/services/pty_session_manager.js';
|
|
9
|
+
|
|
10
|
+
// --- Command Whitelist (dec_029) ---
|
|
11
|
+
|
|
12
|
+
describe('Command Prefix Whitelist (dec_029)', () => {
|
|
13
|
+
const mockLog = mock.fn();
|
|
14
|
+
const mockSpawn = mock.fn(() => ({
|
|
15
|
+
onData: mock.fn(),
|
|
16
|
+
onExit: mock.fn(),
|
|
17
|
+
write: mock.fn(),
|
|
18
|
+
resize: mock.fn(),
|
|
19
|
+
kill: mock.fn(),
|
|
20
|
+
}));
|
|
21
|
+
const manager = new PtySessionManager({ spawn: mockSpawn as any, log: mockLog });
|
|
22
|
+
|
|
23
|
+
it('allows "claude" command', () => {
|
|
24
|
+
const result = manager.validateCommand('claude');
|
|
25
|
+
assert.equal(result.valid, true);
|
|
26
|
+
assert.equal(result.error, undefined);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('allows "claude login" command', () => {
|
|
30
|
+
const result = manager.validateCommand('claude login');
|
|
31
|
+
assert.equal(result.valid, true);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('allows "claude --version" command', () => {
|
|
35
|
+
const result = manager.validateCommand('claude --version');
|
|
36
|
+
assert.equal(result.valid, true);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('rejects "ls" command', () => {
|
|
40
|
+
const result = manager.validateCommand('ls');
|
|
41
|
+
assert.equal(result.valid, false);
|
|
42
|
+
assert.ok(result.error?.includes('not allowed'));
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('rejects "rm -rf /" command', () => {
|
|
46
|
+
const result = manager.validateCommand('rm -rf /');
|
|
47
|
+
assert.equal(result.valid, false);
|
|
48
|
+
assert.ok(result.error?.includes('not allowed'));
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('rejects "bash" command', () => {
|
|
52
|
+
const result = manager.validateCommand('bash');
|
|
53
|
+
assert.equal(result.valid, false);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('rejects empty command', () => {
|
|
57
|
+
const result = manager.validateCommand('');
|
|
58
|
+
assert.equal(result.valid, false);
|
|
59
|
+
assert.ok(result.error?.includes('Empty'));
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('rejects whitespace-only command', () => {
|
|
63
|
+
const result = manager.validateCommand(' ');
|
|
64
|
+
assert.equal(result.valid, false);
|
|
65
|
+
assert.ok(result.error?.includes('Empty'));
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('trims leading whitespace before validating', () => {
|
|
69
|
+
const result = manager.validateCommand(' claude login');
|
|
70
|
+
assert.equal(result.valid, true);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('rejects "git" command (not in whitelist)', () => {
|
|
74
|
+
const result = manager.validateCommand('git status');
|
|
75
|
+
assert.equal(result.valid, false);
|
|
76
|
+
assert.ok(result.error?.includes('not allowed'));
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('rejects "claude-imposter" command (must be exact prefix match)', () => {
|
|
80
|
+
const result = manager.validateCommand('claude-imposter');
|
|
81
|
+
assert.equal(result.valid, false);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// --- PTY Session Manager — session lifecycle ---
|
|
86
|
+
|
|
87
|
+
describe('PtySessionManager session lifecycle', () => {
|
|
88
|
+
it('creates a new session in idle state (no shell spawned)', () => {
|
|
89
|
+
const mockLog = mock.fn();
|
|
90
|
+
const mockSpawn = mock.fn();
|
|
91
|
+
const manager = new PtySessionManager({ spawn: mockSpawn as any, log: mockLog });
|
|
92
|
+
|
|
93
|
+
const session = manager.createSession('user-1', 80, 24);
|
|
94
|
+
assert.ok(session);
|
|
95
|
+
assert.equal(session.userId, 'user-1');
|
|
96
|
+
assert.equal(session.state, 'idle');
|
|
97
|
+
assert.equal(session.pty, null);
|
|
98
|
+
// No shell spawned on session creation
|
|
99
|
+
assert.equal(mockSpawn.mock.calls.length, 0);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('reuses existing session for same user', () => {
|
|
103
|
+
const mockLog = mock.fn();
|
|
104
|
+
const mockSpawn = mock.fn();
|
|
105
|
+
const manager = new PtySessionManager({ spawn: mockSpawn as any, log: mockLog });
|
|
106
|
+
|
|
107
|
+
const session1 = manager.createSession('user-1', 80, 24);
|
|
108
|
+
const session2 = manager.createSession('user-1', 100, 30);
|
|
109
|
+
assert.equal(session1, session2);
|
|
110
|
+
assert.equal(mockSpawn.mock.calls.length, 0);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('creates separate sessions for different users', () => {
|
|
114
|
+
const mockLog = mock.fn();
|
|
115
|
+
const mockSpawn = mock.fn();
|
|
116
|
+
const manager = new PtySessionManager({ spawn: mockSpawn as any, log: mockLog });
|
|
117
|
+
|
|
118
|
+
const session1 = manager.createSession('user-1', 80, 24);
|
|
119
|
+
const session2 = manager.createSession('user-2', 80, 24);
|
|
120
|
+
assert.notEqual(session1, session2);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('getSession returns undefined for unknown user', () => {
|
|
124
|
+
const mockLog = mock.fn();
|
|
125
|
+
const mockSpawn = mock.fn();
|
|
126
|
+
const manager = new PtySessionManager({ spawn: mockSpawn as any, log: mockLog });
|
|
127
|
+
|
|
128
|
+
assert.equal(manager.getSession('unknown'), undefined);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('destroySession removes the session and kills running PTY', () => {
|
|
132
|
+
const mockLog = mock.fn();
|
|
133
|
+
const mockPty = {
|
|
134
|
+
onData: mock.fn(),
|
|
135
|
+
onExit: mock.fn(),
|
|
136
|
+
write: mock.fn(),
|
|
137
|
+
resize: mock.fn(),
|
|
138
|
+
kill: mock.fn(),
|
|
139
|
+
};
|
|
140
|
+
const mockSpawn = mock.fn(() => mockPty);
|
|
141
|
+
const manager = new PtySessionManager({ spawn: mockSpawn as any, log: mockLog });
|
|
142
|
+
|
|
143
|
+
manager.createSession('user-1', 80, 24);
|
|
144
|
+
// Simulate a running command by sending "claude\r"
|
|
145
|
+
manager.writeToSession('user-1', 'claude\r');
|
|
146
|
+
assert.ok(manager.getSession('user-1'));
|
|
147
|
+
|
|
148
|
+
manager.destroySession('user-1');
|
|
149
|
+
assert.equal(manager.getSession('user-1'), undefined);
|
|
150
|
+
assert.equal(mockPty.kill.mock.calls.length, 1);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('destroyAll removes all sessions', () => {
|
|
154
|
+
const mockLog = mock.fn();
|
|
155
|
+
const mockPty1 = {
|
|
156
|
+
onData: mock.fn(),
|
|
157
|
+
onExit: mock.fn(),
|
|
158
|
+
write: mock.fn(),
|
|
159
|
+
resize: mock.fn(),
|
|
160
|
+
kill: mock.fn(),
|
|
161
|
+
};
|
|
162
|
+
const mockPty2 = {
|
|
163
|
+
onData: mock.fn(),
|
|
164
|
+
onExit: mock.fn(),
|
|
165
|
+
write: mock.fn(),
|
|
166
|
+
resize: mock.fn(),
|
|
167
|
+
kill: mock.fn(),
|
|
168
|
+
};
|
|
169
|
+
const ptys = [mockPty1, mockPty2];
|
|
170
|
+
let idx = 0;
|
|
171
|
+
const mockSpawn = mock.fn(() => ptys[idx++]);
|
|
172
|
+
const manager = new PtySessionManager({ spawn: mockSpawn as any, log: mockLog });
|
|
173
|
+
|
|
174
|
+
manager.createSession('user-1', 80, 24);
|
|
175
|
+
manager.createSession('user-2', 80, 24);
|
|
176
|
+
// Start commands so PTYs exist
|
|
177
|
+
manager.writeToSession('user-1', 'claude\r');
|
|
178
|
+
manager.writeToSession('user-2', 'claude\r');
|
|
179
|
+
|
|
180
|
+
manager.destroyAll();
|
|
181
|
+
assert.equal(manager.getSession('user-1'), undefined);
|
|
182
|
+
assert.equal(manager.getSession('user-2'), undefined);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('resizeSession updates stored dimensions and forwards to running PTY', () => {
|
|
186
|
+
const mockLog = mock.fn();
|
|
187
|
+
const mockPty = {
|
|
188
|
+
onData: mock.fn(),
|
|
189
|
+
onExit: mock.fn(),
|
|
190
|
+
write: mock.fn(),
|
|
191
|
+
resize: mock.fn(),
|
|
192
|
+
kill: mock.fn(),
|
|
193
|
+
};
|
|
194
|
+
const mockSpawn = mock.fn(() => mockPty);
|
|
195
|
+
const manager = new PtySessionManager({ spawn: mockSpawn as any, log: mockLog });
|
|
196
|
+
|
|
197
|
+
manager.createSession('user-1', 80, 24);
|
|
198
|
+
manager.writeToSession('user-1', 'claude\r'); // start a command so PTY exists
|
|
199
|
+
manager.resizeSession('user-1', 120, 40);
|
|
200
|
+
|
|
201
|
+
assert.equal(mockPty.resize.mock.calls.length, 1);
|
|
202
|
+
assert.deepEqual(mockPty.resize.mock.calls[0].arguments, [120, 40]);
|
|
203
|
+
|
|
204
|
+
const session = manager.getSession('user-1');
|
|
205
|
+
assert.equal(session?.cols, 120);
|
|
206
|
+
assert.equal(session?.rows, 40);
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
// --- Command execution model (dec_029 enforcement) ---
|
|
211
|
+
|
|
212
|
+
describe('Command execution — whitelist enforced on every command', () => {
|
|
213
|
+
it('rejects disallowed command typed character-by-character', () => {
|
|
214
|
+
const mockLog = mock.fn();
|
|
215
|
+
const mockSpawn = mock.fn();
|
|
216
|
+
const manager = new PtySessionManager({ spawn: mockSpawn as any, log: mockLog });
|
|
217
|
+
const output: string[] = [];
|
|
218
|
+
|
|
219
|
+
manager.createSession('user-1', 80, 24);
|
|
220
|
+
manager.addListener('user-1', (data) => output.push(data));
|
|
221
|
+
|
|
222
|
+
// Type "ls" and press Enter
|
|
223
|
+
manager.writeToSession('user-1', 'l');
|
|
224
|
+
manager.writeToSession('user-1', 's');
|
|
225
|
+
manager.writeToSession('user-1', '\r');
|
|
226
|
+
|
|
227
|
+
// No PTY should have been spawned
|
|
228
|
+
assert.equal(mockSpawn.mock.calls.length, 0);
|
|
229
|
+
|
|
230
|
+
// Output should contain the error message
|
|
231
|
+
const fullOutput = output.join('');
|
|
232
|
+
assert.ok(fullOutput.includes('not allowed'));
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('allows whitelisted command and spawns PTY', () => {
|
|
236
|
+
const mockLog = mock.fn();
|
|
237
|
+
const mockPty = {
|
|
238
|
+
onData: mock.fn(),
|
|
239
|
+
onExit: mock.fn(),
|
|
240
|
+
write: mock.fn(),
|
|
241
|
+
resize: mock.fn(),
|
|
242
|
+
kill: mock.fn(),
|
|
243
|
+
};
|
|
244
|
+
const mockSpawn = mock.fn(() => mockPty);
|
|
245
|
+
const manager = new PtySessionManager({ spawn: mockSpawn as any, log: mockLog });
|
|
246
|
+
|
|
247
|
+
manager.createSession('user-1', 80, 24);
|
|
248
|
+
manager.writeToSession('user-1', 'claude login\r');
|
|
249
|
+
|
|
250
|
+
// PTY should have been spawned with "claude" and ["login"]
|
|
251
|
+
assert.equal(mockSpawn.mock.calls.length, 1);
|
|
252
|
+
assert.equal(mockSpawn.mock.calls[0].arguments[0], 'claude');
|
|
253
|
+
assert.deepEqual(mockSpawn.mock.calls[0].arguments[1], ['login']);
|
|
254
|
+
|
|
255
|
+
// Session should be in running state
|
|
256
|
+
const session = manager.getSession('user-1');
|
|
257
|
+
assert.equal(session?.state, 'running');
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it('forwards raw input to running PTY process', () => {
|
|
261
|
+
const mockLog = mock.fn();
|
|
262
|
+
const mockPty = {
|
|
263
|
+
onData: mock.fn(),
|
|
264
|
+
onExit: mock.fn(),
|
|
265
|
+
write: mock.fn(),
|
|
266
|
+
resize: mock.fn(),
|
|
267
|
+
kill: mock.fn(),
|
|
268
|
+
};
|
|
269
|
+
const mockSpawn = mock.fn(() => mockPty);
|
|
270
|
+
const manager = new PtySessionManager({ spawn: mockSpawn as any, log: mockLog });
|
|
271
|
+
|
|
272
|
+
manager.createSession('user-1', 80, 24);
|
|
273
|
+
manager.writeToSession('user-1', 'claude\r');
|
|
274
|
+
|
|
275
|
+
// Now send input while command is running
|
|
276
|
+
manager.writeToSession('user-1', 'hello');
|
|
277
|
+
assert.equal(mockPty.write.mock.calls.length, 1);
|
|
278
|
+
assert.equal(mockPty.write.mock.calls[0].arguments[0], 'hello');
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it('returns to idle state and shows prompt when command exits', () => {
|
|
282
|
+
const mockLog = mock.fn();
|
|
283
|
+
let exitHandler: (info: { exitCode: number; signal?: number }) => void = () => {};
|
|
284
|
+
const mockPty = {
|
|
285
|
+
onData: mock.fn(),
|
|
286
|
+
onExit: mock.fn((handler: (info: { exitCode: number; signal?: number }) => void) => {
|
|
287
|
+
exitHandler = handler;
|
|
288
|
+
}),
|
|
289
|
+
write: mock.fn(),
|
|
290
|
+
resize: mock.fn(),
|
|
291
|
+
kill: mock.fn(),
|
|
292
|
+
};
|
|
293
|
+
const mockSpawn = mock.fn(() => mockPty);
|
|
294
|
+
const manager = new PtySessionManager({ spawn: mockSpawn as any, log: mockLog });
|
|
295
|
+
const output: string[] = [];
|
|
296
|
+
|
|
297
|
+
manager.createSession('user-1', 80, 24);
|
|
298
|
+
manager.addListener('user-1', (data) => output.push(data));
|
|
299
|
+
manager.writeToSession('user-1', 'claude --version\r');
|
|
300
|
+
|
|
301
|
+
// Simulate command exit
|
|
302
|
+
exitHandler({ exitCode: 0 });
|
|
303
|
+
|
|
304
|
+
const session = manager.getSession('user-1');
|
|
305
|
+
assert.equal(session?.state, 'idle');
|
|
306
|
+
assert.equal(session?.pty, null);
|
|
307
|
+
|
|
308
|
+
// Should show prompt again
|
|
309
|
+
const fullOutput = output.join('');
|
|
310
|
+
assert.ok(fullOutput.includes('$'));
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it('prevents arbitrary shell commands even after a valid command exits', () => {
|
|
314
|
+
const mockLog = mock.fn();
|
|
315
|
+
let exitHandler: (info: { exitCode: number; signal?: number }) => void = () => {};
|
|
316
|
+
const mockPty = {
|
|
317
|
+
onData: mock.fn(),
|
|
318
|
+
onExit: mock.fn((handler: (info: { exitCode: number; signal?: number }) => void) => {
|
|
319
|
+
exitHandler = handler;
|
|
320
|
+
}),
|
|
321
|
+
write: mock.fn(),
|
|
322
|
+
resize: mock.fn(),
|
|
323
|
+
kill: mock.fn(),
|
|
324
|
+
};
|
|
325
|
+
const mockSpawn = mock.fn(() => mockPty);
|
|
326
|
+
const manager = new PtySessionManager({ spawn: mockSpawn as any, log: mockLog });
|
|
327
|
+
const output: string[] = [];
|
|
328
|
+
|
|
329
|
+
manager.createSession('user-1', 80, 24);
|
|
330
|
+
manager.addListener('user-1', (data) => output.push(data));
|
|
331
|
+
|
|
332
|
+
// Execute a valid command
|
|
333
|
+
manager.writeToSession('user-1', 'claude --version\r');
|
|
334
|
+
exitHandler({ exitCode: 0 });
|
|
335
|
+
|
|
336
|
+
// Clear output to check next command
|
|
337
|
+
output.length = 0;
|
|
338
|
+
|
|
339
|
+
// Try to execute a disallowed command after returning to idle
|
|
340
|
+
manager.writeToSession('user-1', 'rm -rf /\r');
|
|
341
|
+
|
|
342
|
+
// Should NOT spawn a new PTY
|
|
343
|
+
assert.equal(mockSpawn.mock.calls.length, 1); // only the first valid command
|
|
344
|
+
const fullOutput = output.join('');
|
|
345
|
+
assert.ok(fullOutput.includes('not allowed'));
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it('handles backspace in idle mode', () => {
|
|
349
|
+
const mockLog = mock.fn();
|
|
350
|
+
const mockPty = {
|
|
351
|
+
onData: mock.fn(),
|
|
352
|
+
onExit: mock.fn(),
|
|
353
|
+
write: mock.fn(),
|
|
354
|
+
resize: mock.fn(),
|
|
355
|
+
kill: mock.fn(),
|
|
356
|
+
};
|
|
357
|
+
const mockSpawn = mock.fn(() => mockPty);
|
|
358
|
+
const manager = new PtySessionManager({ spawn: mockSpawn as any, log: mockLog });
|
|
359
|
+
const output: string[] = [];
|
|
360
|
+
|
|
361
|
+
manager.createSession('user-1', 80, 24);
|
|
362
|
+
manager.addListener('user-1', (data) => output.push(data));
|
|
363
|
+
|
|
364
|
+
// Type "ls", backspace twice, type "claude"
|
|
365
|
+
manager.writeToSession('user-1', 'ls');
|
|
366
|
+
manager.writeToSession('user-1', '\x7f\x7f'); // two backspaces
|
|
367
|
+
manager.writeToSession('user-1', 'claude\r');
|
|
368
|
+
|
|
369
|
+
// Should have spawned claude (not ls)
|
|
370
|
+
assert.equal(mockSpawn.mock.calls.length, 1);
|
|
371
|
+
assert.equal(mockSpawn.mock.calls[0].arguments[0], 'claude');
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it('handles Ctrl+C in idle mode (clears input)', () => {
|
|
375
|
+
const mockLog = mock.fn();
|
|
376
|
+
const mockPty = {
|
|
377
|
+
onData: mock.fn(),
|
|
378
|
+
onExit: mock.fn(),
|
|
379
|
+
write: mock.fn(),
|
|
380
|
+
resize: mock.fn(),
|
|
381
|
+
kill: mock.fn(),
|
|
382
|
+
};
|
|
383
|
+
const mockSpawn = mock.fn(() => mockPty);
|
|
384
|
+
const manager = new PtySessionManager({ spawn: mockSpawn as any, log: mockLog });
|
|
385
|
+
const output: string[] = [];
|
|
386
|
+
|
|
387
|
+
manager.createSession('user-1', 80, 24);
|
|
388
|
+
manager.addListener('user-1', (data) => output.push(data));
|
|
389
|
+
|
|
390
|
+
// Type "rm" then Ctrl+C
|
|
391
|
+
manager.writeToSession('user-1', 'rm');
|
|
392
|
+
manager.writeToSession('user-1', '\x03');
|
|
393
|
+
|
|
394
|
+
// Then type a valid command
|
|
395
|
+
manager.writeToSession('user-1', 'claude\r');
|
|
396
|
+
|
|
397
|
+
// Should have spawned claude (Ctrl+C cleared "rm")
|
|
398
|
+
assert.equal(mockSpawn.mock.calls.length, 1);
|
|
399
|
+
assert.equal(mockSpawn.mock.calls[0].arguments[0], 'claude');
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
it('handles empty Enter (shows prompt again)', () => {
|
|
403
|
+
const mockLog = mock.fn();
|
|
404
|
+
const mockSpawn = mock.fn();
|
|
405
|
+
const manager = new PtySessionManager({ spawn: mockSpawn as any, log: mockLog });
|
|
406
|
+
const output: string[] = [];
|
|
407
|
+
|
|
408
|
+
manager.createSession('user-1', 80, 24);
|
|
409
|
+
manager.addListener('user-1', (data) => output.push(data));
|
|
410
|
+
|
|
411
|
+
manager.writeToSession('user-1', '\r');
|
|
412
|
+
|
|
413
|
+
// No spawn
|
|
414
|
+
assert.equal(mockSpawn.mock.calls.length, 0);
|
|
415
|
+
|
|
416
|
+
// Should show prompt
|
|
417
|
+
const fullOutput = output.join('');
|
|
418
|
+
assert.ok(fullOutput.includes('$'));
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
it('handles spawn failure gracefully', () => {
|
|
422
|
+
const mockLog = mock.fn();
|
|
423
|
+
const mockSpawn = mock.fn(() => { throw new Error('spawn failed'); });
|
|
424
|
+
const manager = new PtySessionManager({ spawn: mockSpawn as any, log: mockLog });
|
|
425
|
+
const output: string[] = [];
|
|
426
|
+
|
|
427
|
+
manager.createSession('user-1', 80, 24);
|
|
428
|
+
manager.addListener('user-1', (data) => output.push(data));
|
|
429
|
+
|
|
430
|
+
manager.writeToSession('user-1', 'claude\r');
|
|
431
|
+
|
|
432
|
+
// Session should remain idle
|
|
433
|
+
const session = manager.getSession('user-1');
|
|
434
|
+
assert.equal(session?.state, 'idle');
|
|
435
|
+
|
|
436
|
+
// Should show error and prompt
|
|
437
|
+
const fullOutput = output.join('');
|
|
438
|
+
assert.ok(fullOutput.includes('Failed to execute'));
|
|
439
|
+
assert.ok(fullOutput.includes('$'));
|
|
440
|
+
});
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
// --- Output buffer (dec_030 — session persistence) ---
|
|
444
|
+
|
|
445
|
+
describe('PTY output buffering (dec_030)', () => {
|
|
446
|
+
it('buffers output including welcome message and prompt', () => {
|
|
447
|
+
const mockLog = mock.fn();
|
|
448
|
+
const mockSpawn = mock.fn();
|
|
449
|
+
const manager = new PtySessionManager({ spawn: mockSpawn as any, log: mockLog });
|
|
450
|
+
|
|
451
|
+
manager.createSession('user-1', 80, 24);
|
|
452
|
+
|
|
453
|
+
const buffered = manager.getBufferedOutput('user-1');
|
|
454
|
+
assert.ok(buffered.includes('Restricted terminal'));
|
|
455
|
+
assert.ok(buffered.includes('$'));
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
it('buffers PTY command output for reconnection', () => {
|
|
459
|
+
const mockLog = mock.fn();
|
|
460
|
+
let dataHandler: ((data: string) => void) | null = null;
|
|
461
|
+
const mockPty = {
|
|
462
|
+
onData: mock.fn((handler: (data: string) => void) => { dataHandler = handler; }),
|
|
463
|
+
onExit: mock.fn(),
|
|
464
|
+
write: mock.fn(),
|
|
465
|
+
resize: mock.fn(),
|
|
466
|
+
kill: mock.fn(),
|
|
467
|
+
};
|
|
468
|
+
const mockSpawn = mock.fn(() => mockPty);
|
|
469
|
+
const manager = new PtySessionManager({ spawn: mockSpawn as any, log: mockLog });
|
|
470
|
+
|
|
471
|
+
manager.createSession('user-1', 80, 24);
|
|
472
|
+
manager.writeToSession('user-1', 'claude --version\r');
|
|
473
|
+
|
|
474
|
+
assert.ok(dataHandler);
|
|
475
|
+
dataHandler!('Claude v1.0.0');
|
|
476
|
+
|
|
477
|
+
const buffered = manager.getBufferedOutput('user-1');
|
|
478
|
+
assert.ok(buffered.includes('Claude v1.0.0'));
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
it('returns empty string for unknown user', () => {
|
|
482
|
+
const mockLog = mock.fn();
|
|
483
|
+
const mockSpawn = mock.fn();
|
|
484
|
+
const manager = new PtySessionManager({ spawn: mockSpawn as any, log: mockLog });
|
|
485
|
+
|
|
486
|
+
assert.equal(manager.getBufferedOutput('unknown'), '');
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
it('notifies listeners when output is produced', () => {
|
|
490
|
+
const mockLog = mock.fn();
|
|
491
|
+
const mockSpawn = mock.fn();
|
|
492
|
+
const manager = new PtySessionManager({ spawn: mockSpawn as any, log: mockLog });
|
|
493
|
+
|
|
494
|
+
manager.createSession('user-1', 80, 24);
|
|
495
|
+
const listener = mock.fn();
|
|
496
|
+
manager.addListener('user-1', listener);
|
|
497
|
+
|
|
498
|
+
// Typing echoes characters
|
|
499
|
+
manager.writeToSession('user-1', 'c');
|
|
500
|
+
assert.ok(listener.mock.calls.length > 0);
|
|
501
|
+
assert.equal(listener.mock.calls[listener.mock.calls.length - 1].arguments[0], 'c');
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
it('removeListener stops notifications', () => {
|
|
505
|
+
const mockLog = mock.fn();
|
|
506
|
+
const mockSpawn = mock.fn();
|
|
507
|
+
const manager = new PtySessionManager({ spawn: mockSpawn as any, log: mockLog });
|
|
508
|
+
|
|
509
|
+
manager.createSession('user-1', 80, 24);
|
|
510
|
+
const listener = mock.fn();
|
|
511
|
+
manager.addListener('user-1', listener);
|
|
512
|
+
manager.removeListener('user-1', listener);
|
|
513
|
+
|
|
514
|
+
const callsBefore = listener.mock.calls.length;
|
|
515
|
+
manager.writeToSession('user-1', 'x');
|
|
516
|
+
assert.equal(listener.mock.calls.length, callsBefore);
|
|
517
|
+
});
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
// --- WebSocket auth checks ---
|
|
521
|
+
|
|
522
|
+
describe('Terminal WebSocket authentication', () => {
|
|
523
|
+
it('cookie parser extracts access_token from cookie header', () => {
|
|
524
|
+
const parseCookies = (cookieHeader: string | undefined): Record<string, string> => {
|
|
525
|
+
if (!cookieHeader) return {};
|
|
526
|
+
const cookies: Record<string, string> = {};
|
|
527
|
+
for (const pair of cookieHeader.split(';')) {
|
|
528
|
+
const [key, ...rest] = pair.trim().split('=');
|
|
529
|
+
if (key) {
|
|
530
|
+
cookies[key] = rest.join('=');
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
return cookies;
|
|
534
|
+
};
|
|
535
|
+
|
|
536
|
+
const cookies = parseCookies('access_token=jwt123; refresh_token=ref456');
|
|
537
|
+
assert.equal(cookies['access_token'], 'jwt123');
|
|
538
|
+
assert.equal(cookies['refresh_token'], 'ref456');
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
it('returns empty object for undefined cookie header', () => {
|
|
542
|
+
const parseCookies = (cookieHeader: string | undefined): Record<string, string> => {
|
|
543
|
+
if (!cookieHeader) return {};
|
|
544
|
+
const cookies: Record<string, string> = {};
|
|
545
|
+
for (const pair of cookieHeader.split(';')) {
|
|
546
|
+
const [key, ...rest] = pair.trim().split('=');
|
|
547
|
+
if (key) {
|
|
548
|
+
cookies[key] = rest.join('=');
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
return cookies;
|
|
552
|
+
};
|
|
553
|
+
|
|
554
|
+
const cookies = parseCookies(undefined);
|
|
555
|
+
assert.deepEqual(cookies, {});
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
it('admin role check accepts admin users', () => {
|
|
559
|
+
const payload = { sub: 'user-1', email: 'admin@test.com', role: 'admin' };
|
|
560
|
+
assert.equal(payload.role === 'admin', true);
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
it('admin role check rejects non-admin users', () => {
|
|
564
|
+
const payload = { sub: 'user-2', email: 'user@test.com', role: 'user' };
|
|
565
|
+
assert.equal(payload.role === 'admin', false);
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
it('admin role check rejects missing role', () => {
|
|
569
|
+
const payload = { sub: 'user-3', email: 'nobody@test.com' };
|
|
570
|
+
assert.equal((payload as any).role === 'admin', false);
|
|
571
|
+
});
|
|
572
|
+
});
|